우테코에서 진행했던 프로젝트에서 검색 기능을 개선하는 일을 맡았던 적이 있다. 검색 속도가 느려서 성능을 개선하는 것이 아니라, 사용자의 편의성을 위한 개선이었다. 기존 검색 기능은 검색어가 포함된 경우를 최신순으로 반환하고 있었는데 검색어와 좀 더 유사한 결과를 먼저 반환하도록 개선하면 어떻겠느냐는 안건이었다. 확실히 사용자에게 편의성을 더 제공해줄 수 있고, 개인적으로도 도전적인 주제인 거 같아서 내가 하겠다고 했었다. 결론적으로는 오랜 시간을 쓰고도 기능개선에 실패했지만 그 과정이 의미가 있었기에 글을 써 보려 한다. 무엇보다 의미 있는 경험이라고 생각하면서도 다른 사람들에게 전달할 때 제대로 설명하지 못 하는 경우가 너무 많아서 한 번 정리하는 게 낫겠다 싶기도 했다.
우선 그 당시에 떠올렸었던 방법들과 어떤 점들을 고려했는지 적고, 프로젝트가 갈무리되고도 해결하지 못한 게 아쉬워서 추가적으로 생각해본 방법까지 비교할 예정이다.
기존 검색 기능
우선 문제의 확실한 이해를 위해 기존 검색 기능이 어떻게 이루어졌는지 예시 코드를 만들어보았다.
@Entity
@Getter
@Setter
@NoArgsConstructor
public class Post {
public Post(String title, String content) {
this.title = title;
this.content = content;
}
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String content;
}
public interface PostRepository extends JpaRepository<Post, Long> {
@Query(value = "select * from post "
+ "where id < :postId "
+ "and title like :titleSearch "
+ "order by id desc "
+ "limit 3",
nativeQuery = true)
List<Post> searchByTitle(String titleSearch, Long postId);
}
PK인 Id와 제목, 내용을 가지는 Post 클래스가 있고, 이를 데이터베이스에 쉽게 저장하고 조회하기 위해 Spring Data JPA를 사용했다. searchByTitle() 메서드는 like 쿼리를 사용해서 검색어를 포함하는 제목을 가지는 포스트들을 반환하는 역할을 한다. Native Query를 사용하지 않아도 Spring Data JPA가 위의 쿼리를 만들도록 메서드를 만들 수 있지만 직접 쿼리를 보는 편이 좋다고 생각해서 예제는 이렇게 작성해보았다.
이 때 우리 서비스에서는 무한 스크롤을 적용했었는데, 성능을 위해 no offset으로 구현했었다. Where 절에서 이전 요청에서 전달되었던 가장 마지막 포스트의 아이디를 받아 그보다 이전의 아이디를 가지는 포스트만 찾는 부분이 그 부분이다. 테이블에 데이터가 많지 않을 때는 큰 성능의 차이가 있지 않지만, 데이터가 많아짐에 따라 큰 차이가 날 수 있는 부분이다. 서비스의 사용자나 데이터베이스의 데이터의 양이 그 정도를 고려할 정도는 아니었지만 미리 적용했었다. 자세한 내용은 다음 글을 읽어보면 좋다.
1. 페이징 성능 개선하기 - No Offset 사용하기
일반적인 웹 서비스에서 페이징은 아주 흔하게 사용되는 기능입니다. 그래서 웹 백엔드 개발자분들은 기본적인 구현 방법을 다들 필수로 익히시는데요. 다만, 그렇게 기초적인 페이징 구현 방
jojoldu.tistory.com
잘 작동하는지 직접 보기 위해 테스트 코드까지 작성해보았다.
@DataJpaTest
class PostRepositoryTest {
@Autowired
private PostRepository postRepository;
@Test
void searchByTitle() {
//given
Post post1 = new Post("제목1", "내용1");
Post post2 = new Post("제목2", "내용2");
Post post3 = new Post("제목3", "내용3");
Post post4 = new Post("제목4", "내용4");
savePosts(post1, post2, post3, post4);
//when
String searchTitle = "%제목%";
List<Post> firstResult = postRepository.searchByTitle(searchTitle, 100L);
List<Post> secondResult = postRepository.searchByTitle(searchTitle, firstResult.get(2).getId());
//then
assertThat(firstResult).containsExactly(post4, post3, post2);
assertThat(secondResult).containsExactly(post1);
}
private void savePosts(Post... posts) {
postRepository.saveAll(Arrays.asList(posts));
}
}
우선 given절에서 데이터베이스에 4개의 포스트를 저장했다. firstResult에서는 title에 ‘제목’을 포함하는 포스트를 검색한 결과가 저장된다. 이 때 무한 스크롤에서 첫 요청이므로 전달할 postId는 없는 게 맞지만 일단 전체 테이블 row보다 큰 수인 100을 넣어서, 첫 요청과 똑같이 동작하도록 했다. 검색어에 해당하는 포스트를 최대 3개까지 반환하므로 {post4, post3, post2}가 담겨있다고 예상할 수 있다.
secondResult에는 무한 스크롤에서 두 번째 요청이 담긴다. title에 ‘제목’을 포함하는 포스트를 최대 3개 가져오는데, 이전 요청에서 전달된 포스트들 중 가장 마지막(post2)의 아이디를 전달했다. 테이블에 post2보다 작은 아이디를 가지는 포스트는 1개밖에 없으므로 secondResult에는 {post1}만 담겨있다고 예상할 수 있다.

유사도순 검색으로 개선하기
기존 검색 기능이 잘 동작하는 것을 확인했으니 유사도순으로 검색 결과를 반환하도록 개선하자. 요구사항은 다음과 같다.
- 검색어와 가장 유사한 데이터를 먼저 반환한다.
- 동일한 유사도를 가지는 데이터가 존재한다면 최신에 만들어진 데이터를 먼저 반환한다.
- 무한 스크롤을 지원한다.
우선 유사도라는 것을 정의해야 할 필요성이 있다. 무엇을 기준으로 데이터가 검색어와 유사하다고 할 수 있을까? 유명한 검색 엔진들에서 열심히 연구한 복잡한 알고리즘들이 있겠지만, 그 정도의 퀄리티는 필요없을 것이다. 단순히 생각해보았을 때 특정 검색어가 주어졌을 때 해당 검색어를 포함하는 모든 데이터 중 길이가 가장 짧은 데이터는 검색어를 포함하고 있는 비율이 가장 높을 것이다.
예를 들어 ‘가방’이라는 검색어가 주어졌을 때, ‘가방’, ‘책가방’, ‘가방 만드는 법’, ‘방가방가’, ‘아버지가 방에 들어가신다’ 등 ‘가방’을 포함하는 다양한 데이터가 있다. 이 중에서 길이가 짧은 ‘가방’, ‘책가방’ 등은 경험적으로 ‘가방’과 관련이 높다는 생각이 든다. 물론 ‘방가방가’보다는 ‘가방 만드는 법’이 좀 더 유사하겠지만, 너무 어렵게 구현하기에는 자신이 없으니 유사도를 다음과 같이 정의했다.
"검색어를 포함하면서 길이가 짧을수록 유사도가 높다"
쿼리로 해결하기
처음에는 쿼리로 해결해보았다. PostRepository에 searchByTitleWithRelevance()라는 메서드를 추가해보자.
@Query(value = "select * from post "
+ "where title like :titleSearch "
+ "and ((length(title) = (select length(p1.title) "
+ " from post p1 "
+ " where p1.id = :postId) "
+ " and id < :postId) "
+ "or length(title) > (select length(p1.title) "
+ " from post p1 "
+ " where p1.id = :postId)) "
+ "order by length(title), id desc "
+ "limit 3",
nativeQuery = true)
List<Post> searchByTitleWithRelevance1(String titleSearch, Long postId);
쿼리문이 길어지고 서브쿼리도 2개나 사용해서 좀 복잡해졌다.
Where절 첫 번째 조건에서 일단 반환되는 데이터는 반드시 검색어를 포함하도록 한다. 또한 무한 스크롤로 작동해야 하므로 이전에 마지막으로 전달된 포스트와 (1) 제목의 길이가 같은 경우엔 Id가 작거나 (더 이전에 만들어졌거나) (2) 제목의 길이가 긴 경우를 찾는다. 그리고 제목의 길이의 오름차순으로 정렬하고, 같은 경우 최신순으로 정렬한 뒤 limit 갯수만큼 반환한다.
참고로 해당 쿼리문은 첫번째 스크롤 요청에선 제대로 동작하지 않는데, 원래 동적으로 쿼리를 작성해야 하지만 테스트 코드에서 적절한 postId 값을 전달하여 보완했다. 실제 서비스 코드에서는 Querydsl을 사용했었다.
@Test
void searchByTitleWithRelevance1() {
//given
Post testPost = new Post("", "");
Post post1 = new Post("제목111", "내용111");
Post post2 = new Post("제목11", "내용11");
Post post3 = new Post("제목222", "내용222");
Post post4 = new Post("제목22", "내용22");
Post post5 = new Post("제목123", "내용123");
Post post6 = new Post("제목1", "내용1");
Post post7 = new Post("제목제목1", "내용내용1");
Post post8 = new Post("제목", "내용");
savePosts(testPost, post1, post2, post3, post4, post5, post6, post7, post8);
String searchTitle = "%제목1%";
//when
List<Post> firstResult = postRepository.searchByTitleWithRelevance1(searchTitle, 1L);
List<Post> secondResult = postRepository.searchByTitleWithRelevance1(searchTitle, firstResult.get(2).getId());
//then
assertThat(firstResult).containsExactly(post6, post2, post7);
assertThat(secondResult).containsExactly(post5, post1);
}

테스트 코드를 작성하였고 의도대로 잘 작동한다. 어떤 식으로 동작하는지 그림으로 정리해보았다. testPost는 첫 번째 스크롤 요청이 제대로 동작하기 위해 만든 객체이므로 그림에서 제외했다.


우선 데이터베이스에 8개의 포스트가 저장되어 있다. 이 때 title에 ‘제목1’을 포함하는 포스트를 요청하는 첫 번째 스크롤 요청이 들어왔다. 이전에 전달된 postId가 없으므로 where절에서 전체 테이블을 대상으로 ‘제목1’을 포함하는 포스트를 찾는다. 아이디가 각각 1, 2, 5, 6, 7인 포스트 5개가 존재한다. 그 후 order by절까지 수행하면 6, 2, 7, 5, 1 순서로 정렬되고 limit 3에서 가장 위의 세 포스트인 6, 2, 7이 반환된다.
두 번째 스크롤 요청에서는 이전 요청의 마지막 포스트의 id인 7이 파라미터로 전달됐다. where절 수행 이후 포스트 1, 5가 남고 order by절을 거치면 포스트 5, 1이 반환되게 된다.
장점
- 코드에 큰 변화 없이 메서드 하나 추가로 개선이 가능 (구현이 쉽다)
단점
- 매 검색 요청마다 Full Table Scan이 발생
- length() 메서드에 대한 인덱스 생성이 불가능
- 유사도 측정 방식의 변경에 대해 유연하지 않음
서비스 코드에서 해결하기
두 번째 방식은 어차피 매 요청마다 Full Table Scan이 발생하니 유사도 측정 방식의 변경에 대한 유연함이라도 해소하려는 발상에서 시작했다. 쿼리를 통해 검색어를 포함하는 데이터들을 모두 가지고 온 뒤 Java 레벨에서 유사도 순으로 정렬했다. 동일한 유사도 측정 방식을 사용했지만 추후에 방식을 변경할 때 SQL에 의존적이지 않아도 된다는 장점이 있다.
public interface PostJdbcRepository {
List<Post> searchByTitleWithRelevance2(String titleSearch, Long postId);
}
public interface PostRepository extends JpaRepository<Post, Long>, PostJdbcRepository {}
@Repository
public class PostJdbcRepositoryImpl implements PostJdbcRepository {
private final JdbcTemplate jdbcTemplate;
public PostJdbcRepositoryImpl(DataSource dataSource) {
this.jdbcTemplate = new JdbcTemplate(dataSource);
}
@Override
public List<Post> searchByTitleWithRelevance2(String titleSearch, Long postId) {
List<Post> posts = findPostsContainsTitleSearch(titleSearch);
posts.sort(Comparator.comparing(p -> p.getTitle().length()));
return getPostsPage(posts, postId);
}
private static List<Post> getPostsPage(List<Post> posts, Long postId) {
if (Objects.isNull(postId)) {
return posts.subList(0, Math.min(3, posts.size() + 1));
}
List<Post> result = new ArrayList<>();
int count = 0;
for (Post post : posts) {
if (count > 0) {
result.add(post);
if (count == 4) break;
}
if (post.getId().equals(postId)) {
count += 1;
}
}
return result;
}
private List<Post> findPostsContainsTitleSearch(String titleSearch) {
String sql = "select * from post "
+ "where title like ? "
+ "order by id desc";
return jdbcTemplate.query(sql, (rs, rowNum) -> {
Post post = new Post();
post.setId(rs.getLong("id"));
post.setTitle(rs.getString("title"));
post.setContent(rs.getString("content"));
return post;
}, "%" + titleSearch + "%");
}
}
Repository 레벨에서 구현하기 위해 PostJdbcRepository 인터페이스를 만들어 PostRepository가 상속하게 했다. 구현체는 따로 PostJdbcRepositoryImpl로 만들어서 스프링의 자동 주입을 활용하였다.
중심이 되는 searchByTitleWithRelevance2() 메서드의 역할은 다음과 같다.
- title에 검색어를 포함하는 모든 포스트를 최신순으로 가져온다.
- 유사도 순으로 정렬한다. 여기서는 title의 길이를 오름차순으로 정렬했다.
- 정렬된 포스트들 중에서 이전에 마지막으로 전달된 포스트의 id를 기준으로 limit의 갯수만큼 반환한다. 만약 첫 스크롤 요청이라면 처음부터 limit의 갯수만큼 반환한다.
@Test
void searchByTitleWithRelevance2() {
//given
Post testPost = new Post("", "");
Post post1 = new Post("제목111", "내용111");
Post post2 = new Post("제목11", "내용11");
Post post3 = new Post("제목222", "내용222");
Post post4 = new Post("제목22", "내용22");
Post post5 = new Post("제목123", "내용123");
Post post6 = new Post("제목1", "내용1");
Post post7 = new Post("제목제목1", "내용내용1");
Post post8 = new Post("제목", "내용");
savePosts(testPost, post1, post2, post3, post4, post5, post6, post7, post8);
String searchTitle = "%제목1%";
//when
List<Post> firstResult = postRepository.searchByTitleWithRelevance2(searchTitle, null);
List<Post> secondResult = postRepository.searchByTitleWithRelevance2(searchTitle, firstResult.get(2).getId());
//then
assertThat(firstResult).containsExactly(post6, post2, post7);
assertThat(secondResult).containsExactly(post5, post1);
}

쿼리로 해결한 방법에서 진행한 테스트를 메서드만 바꿔서 작성했다. 마찬가지로 잘 작동한다.
장점
- 유사도 알고리즘 구현을 Java로 할 수 있어 변경이 용이하다
단점
- 매번 요청마다 Full Table Scan이 발생한다.
- 코드 레벨에서도 최대 NlogN의 정렬을 필요로 한다.
처음 기능을 맡았을 때는 두 방식 중에서 뒤의 방식으로 구현했었다. 둘 다 성능적으로는 불안하다(인덱스를 타지 않음)는 공통점이 있었지만, 추후에 유사도 측정 방식을 변경할 때 더 쉽게 변경할 수 있다는 장점이 있었기 때문이다.
하지만 인덱스 없이 Full Table Scan을 하는데 스크롤 요청이라 예상치 못 하게 많은 요청이 들어올 수 있다는 점이 불안했다. 물론 데이터베이스의 데이터량이 많지 않아 큰 부담은 없지만, 그렇게 생각하면 굳이 유사도순으로 검색을 할 필요도 없었다. 그냥 스크롤 몇 번 내리다보면 검색어에 해당하는 모든 결과를 볼 수 있는 상황이기 때문이다. 결국 팀원들끼리 상의 결과 최종적으로 기능 개선을 하지 않는 방향으로 결정이 났었다.
프로젝트를 마친 이후에도 이 문제를 잘 해결하지 못한 게 기억이 남아 고민을 했었는데, Real MySQL 책을 읽다가 전문 인덱스에서 실마리를 얻었다. 사실 전문 인덱스를 그 당시에도 알고 있긴 했지만 작동 원리를 제대로 몰라서 도입할 생각은 못 했었다. 전문 인덱스를 설정해서 Full Table Scan을 하지 않도록 해 보자.
전문(Full-Text) 인덱스 설정하기
전문 인덱스란 CHAR, VARCHAR, TEXT 등의 문자 기반 컬럼에서 특정한 검색어를 포함하는 쿼리를 더 빠르게 실행할 수 있도록 만들어진 인덱스이다. 역인덱스 방식인데 책에서의 색인 부분이라고 생각하면 이해하기 쉽다. MYSQL 에서는 InnoDB, MyISAM 엔진에서만 지원한다.
전문 인덱스 생성은 테이블 생성 시에 하거나 테이블 생성 후에도 ALTER TABLE이나 CREATE INDEX 구문을 통해 추가할 수 있다. 예제로 만들었던 Post 테이블에 전문 인덱스를 추가해 보았다.
ALTER TABLE post ADD FULLTEXT INDEX idx_post_title (title) WITH PARSER ngram;
저장할 데이터가 한글을 포함한다면 마지막에 WITH PARSER NGRAM을 넣어주는 편이 성능이 잘 나오는데, 이는 기본 파서가 한글에 적합하지 않기 때문이다. 기본 파서는 문장을 공백을 기준으로 잘라서 특정 단어(stopword)를 제외한 뒤 인덱싱하는데, 이는 조사를 사용하지 않는 문화권에 적합한 방식이다. 따라서 한국어, 중국어, 일본어 등을 위해 다양한 파서를 지원하는데 한국어에는 NGRAM이 적합하다. NGRAM은 문장을 N개만큼의 형태로 잘라서 인덱싱한다. 예를 들어 ‘대한민국’이라는 단어를 저장한다면 N이 2일 때 ‘대한’, ‘한민’, ‘민국’으로 나누어진다. MYSQL의 기본 ngram 토큰 사이즈는 2이며 최소 1에서 최대 10까지 설정할 수 있다.
전문 인덱스를 이용해 검색을 할 경우 LIKE 구문 대신 MATCH AGAINST를 사용한다.
MATCH (col1, col2, ...) AGAINST ('검색어' [search modifier])
search_modifier:
{
IN NATURAL LANGUAGE MODE (default)
| IN NATURAL LANGUAGE MODE WITH QUERY EXPANSION
| IN BOOLEAN MODE
| WITH QUERY EXPANSION
}
- NATURAL LANGUAGE : 검색어를 구문 그대로 해석한다.
- BOOLEAN : 검색어를 특수 쿼리 언어의 규칙을 사용해 해석한다. 특정한 검색어가 존재하거나 존재하지 않거나, 더 높은 가중치를 가지는 등의 연산자를 포함할 수 있다.
- QUERY EXPANSION : 우선 검색어로 NATURAL LANGUAGE MODE를 수행한다. 그 뒤 반환된 가장 관련성 있는 행의 단어를 검색 문자열에 추가한 뒤 다시 검색을 수행한 결과를 반환한다.
추가적으로 NGRAM 파서를 사용했을 때 MATCH AGAINST 구문은 약간 다르게 동작한다. ngram_token_size가 2라면 검색어 역시 2개씩 토큰으로 잘라서 검색한다. 이 때 natural language 모드에서는 토큰을 하나라도 포함하면 결과를 반환하고, boolean 모드에서는 모든 토큰을 포함해야 결과를 반환한다. 모든 검색어를 포함하는 것을 찾는 것이 목표이므로 boolean 모드를 사용하여 쿼리를 작성해보았다.
select *
from post p
where match(p.title) against(? IN BOOLEAN MODE)
and (match(p.title) against(? IN BOOLEAN MODE) < (select match(p1.title) against(? IN BOOLEAN MODE)
from post p1
where p1.id = ?)
or (match(p.title) against(? IN BOOLEAN MODE) = (select match(p1.title) against(? IN BOOLEAN MODE)
from post p1
where p1.id = ?)
and p.id < ?)
)
order by match(p.title) against(? IN BOOLEAN MODE), id desc
limit 3;
테스트는 MySQL에서 직접 해보자. 테스트 코드에서 사용하던 H2 DB가 전문 검색을 지원하긴 하는데, 실제 서비스에서 사용하는 MYSQL과 달라 제대로 된 테스트가 될지 불확실하기 때문이다. 전문 검색 인덱스를 사용하는 경우 어떻게 테스트 코드를 작성해야 할지는 나중에 좀 더 고민해봐야겠다.
추가적으로 알아야 하는 점이 전문 인덱스는 인덱스 생성으로 인한 메모리 사용량이 많다. 따라서 테이블의 데이터량이 적을 때에는 오히려 인덱스를 생성하는 것이 더 성능에 부정적인 영향을 미칠 수 있음을 고려해야 한다. 따라서 테이블의 row가 10만개가 있을 때와, 100만개가 있을 때 둘 다 테스트해 보았다.
row = 100000인 경우
#포스트 저장용 프로시저
DELIMITER $$
Drop procedure if exists save_post$$
CREATE PROCEDURE save_post()
BEGIN
declare i int default 1;
while i <= 100000
do
insert into post(content, title)
values (concat('내용', i), concat('제목', i));
set i = i + 1;
end while;
end$$
DELIMITER $$
제목1~제목100000까지의 서로 다른 title을 가지는 포스트를 저장했다. 검색어로 55을 포함하는 포스트를 최대 5개까지 반환하는 쿼리를 실행해보았다. 무한 스크롤 요청인만큼 첫 스크롤 요청인 경우와 50055의 id를 가지는 포스트가 반환된 이후의 요청 2개를 실행했다.
(1) LIKE


(2) 전문 인덱스


LIKE를 사용하는 경우나 전문 검색 인덱스를 사용하는 경우 큰 성능 차이가 있지 않음을 볼 수 있다. 첫 요청에서 반환하는 포스트가 다른 이유는 match against 구문에서 유사도를 측정하는 방식이 다르기 때문이다. 자세한 계산식은 공식문서를 살펴보자.
row = 1000000인 경우
(1) LIKE


(2) 전문 인덱스


테이블의 row가 많을 때는 전문 인덱스가 full table scan을 하는 like보다 빠른 것을 볼 수 있다. 참고로 테스트에서 프로시저로 넣은 포스트들의 title 간의 차이가 크지 않아서 전문 인덱스의 효과를 제대로 보지 못 했을 가능성이 높다.
실제로 전문 인덱스를 도입하려면 쿼리가 메모리에 몇 번 접근할지 추정하여 어느 시점부터 인덱스를 설정하는 것이 유리할지 판단하는 것이 중요하다.
참고
ngram_token_size가 n일 때 길이가 n 이상인 검색어를 인자로 전달하면 실행 속도가 굉장히 느려지는 경우가 있다. MYSQL 공식문서 상에서 ‘abcde’라는 검색어가 들어왔을 때 ‘ab bc cd de ef’의 구문 검색을 한다고 되어 있다. 이 때 생성된 인덱스를 제대로 이용하지 못 하는 것으로 추정된다.
따라서 검색어를 미리 전처리하여 ‘abcde’대신 ‘+ab +bc +cd +de’를 전달하면 응답을 빠르게 하는 것을 볼 수 있다. ‘+’는 해당 문자를 반드시 포함해야 한다는 것을 의미한다. 물론 abcde를 포함하는 것이 아닌 ‘abce rcde’ 등의 데이터도 반환하지만 전문 인덱스를 이용할 수 있다.
느낀 점
여러 가지 방법을 알아봤지만 서비스의 규모를 고려해봤을 때 사실 기능 개선을 하지 않는 것도 합리적이라는 생각이 든다. 데이터베이스에 담긴 데이터가 적으면 사용자들은 자신이 원하는 검색 결과를 얻기 위해 많아도 몇 번만 스크롤하면 되기 때문에 현재 개발할 필요성이 있느냐의 문제인 것 같다.
물론 어떤 업무가 있을 때 그 업무를 진행할 적절한 시기가 언제인지 판단할 수 있는지는 다양한 배경 지식이 필요하다. 개발자가 공부하고 알아야 할 것들이 참 많다는 생각을 또 하게 된다.
참고 자료
'우아한 테크코스 5기' 카테고리의 다른 글
| 코끼리끼리 프로젝트 코드 의존성 분리 스토리 (0) | 2024.02.19 |
|---|---|
| [우아한 테크코스] 레벨 2 - 쇼핑 주문(협업) 미션 회고 (0) | 2023.06.04 |
| [우아한 테크코스] 레벨 1 - 사다리 미션 회고 (0) | 2023.03.19 |
| [우아한 테크코스] 레벨 1 - 자동차 경주 회고 (5) | 2023.02.23 |
| [우아한 테크코스] 프리코스 회고 (1) | 2023.02.23 |