눈 깜짝할 사이에 레벨 3가 끝났다.
2달간의 치열했던 과정을 끝내고 돌아봤을 때, 나에게 의미 있었던 것들을 모아서 글로 정리해보려고 한다.
세 번째 주제는 기술적 도전과 선택이다. 2달간 모아온 프로젝트를 개발하며 마주했던 기술적 과정들을 되돌아보려 한다. 백엔드 기술에 한정해서 작성했다.
#1 엔티티 구조와 객체지향
모아온은 MySQL와 JPA를 사용하고 있다. 프로젝트 초기, 도메인/엔티티 모델을 설계할 때 객체지향적 구조와 데이터베이스 지향적 구조 중 어떤 것을 선택할지 고민하게 되었다.
- 데이터베이스 지향적 구조는, 데이터베이스의 ERD를 먼저 설계한 후, 테이블과 1:1 매핑되는 엔티티를 작성하는 방식이다. 지금까지 진행했던 프로젝트는 모두 데이터베이스 지향적 구조를 선택했다.
- 객체지향적 구조는, DB를 고려하지 않고 엔티티 클래스를 먼저 작성한다. 데이터베이스의 테이블 구조는 JPA가 자동 생성해 주는 것을 사용한다.
우테코에서는 레벨1에 객체지향을 배우고 레벨 2에 스프링을 배운다. “JPA는 객체지향적 코드를 작성할 수 있게 하는가”는 우테코 레벨2의 뜨거운 감자 중 하나였다. 나는 이에 대해 JPA를 도입함으로써 객체지향적 코드를 작성할 수 있다고 생각한다. 그 이유는 엔티티 매핑을 사용해 JPA가 DB 테이블 구조를 알아서 만들고, DB를 신경 쓰지 않고 코드를 작성할 수 있기 때문이다.
이러한 내 추측을 프로젝트를 통해 직접 경험해 보고 싶었다. 모아온의 엔티티 구조를 객체지향적으로 설계해 보자는 의견을 제안했고, 채택되었다.
객체지향적으로 설계를 해보자고 했지만, 막상 적용하려니 쉽지 않았다. 방법을 몰라서가 아니라, 그동안 데이터베이스 중심으로 설계해 온 관성을 떨쳐내기가 어려웠다. 그중에 가장 어려웠던 것은 @ManyToMany의 도입이었다.
두 도메인의 다대다 관계는 흔하게 볼 수 있다. 모아온의 경우 Project 도메인과 Category 도메인은 다대다 관계를 가진다. 일반적으로는 ProjectCategory 같은 중간 테이블을 만들고, @ManyToOne 어노테이션을 두 개 사용해서 엔티티를 설계한다. @ManyToMany 어노테이션은 사용하지 않는 것이 관례처럼 여겨진다.
“객체지향적 구조”를 호기롭게 외쳤지만, 막상 @ManyToMany 에는 쉽사리 손이 가지 않았다. 그래서 우선, 왜 @ManyToMany 를 쓰지 말라고 하는지 찾아봤다. 간단히 정리하자면 아래 두 가지 이유 때문이다.
- 변동 가능성: 만약 두 엔티티의 관계에 속성이 추가될 때를 대비해 중간 엔티티를 만들어 둘 필요가 있다.
- 성능: 예상치 못한 쿼리가 추가로 발생할 수 있다.
그래서 3개의 구조를 후보로 두고 열심히 토론했다.
- ProjectCategory 엔티티 사용 + 단방향 연관 관계 : 가장 흔하게 채택하는 방식
- ProjectCategory 엔티티 사용 + Project 엔티티에 List<Category> 를 양방향 연관관계로 사용 : 중간 타협점
- Project 엔티티가 List<Category>를 @ManyToMany 로 사용 : 객체 지향적인 구조
치열한 토론 끝에 3번을 선택했다. 실제 문제가 체감될 때 변경하자는 결론을 내렸다. 1, 2번 방식에서 발생하는 객체지향적이지 못한 코드는 당장 체감되는 문제였다. 반면 3번 방식의 잠재적 문제들(확장성, 성능)은 실제로 발생할지 불확실했고, 발생한다면 그때 대응해도 늦지 않다고 판단했다.
토론 과정을 통해 팀의 의견을 통일시키고, 우리만의 기준을 만들 수 있었다. 더 중요한 것은 이 과정에서 "왜 그렇게 해야 하는지"에 대해 깊이 고민해볼 수 있었다는 점이다. 관례를 무조건 따르는 것이 아니라, 프로젝트의 맥락에서 최선의 선택이 무엇인지 판단할 수 있는 경험이었다.
#2 QueryDSL 도입과 라이브러리 선택
모아온의 경우 검색, 필터링, 정렬 등 다양한 조회 기능을 도입한다. 따라서 동적 쿼리가 프로그램의 큰 부분을 차지한다. JPA 환경에서 동적 쿼리를 작성하는 방법은 JPQL, Criteria, Specification, QueryDSL 등이 있다. 이중 모아온은 QueryDSL을 선택했다. 4가지 방식을 비교했을 때 사용성 측면에서 압도적으로 우수하다고 판단했기 때문이다.
실제로 다음과 같이 복잡한 형식의 쿼리를 QueryDSL을 사용해서 작성하고 있다.
@Repository
@RequiredArgsConstructor
public class CustomizedProjectRepositoryImpl implements CustomizedProjectRepository {
private static final int FETCH_EXTRA_FOR_HAS_NEXT = 1;
private static final double MINIMUM_MATCH_SCORE = 0.0;
private static final String BLANK = " ";
private final JPAQueryFactory jpaQueryFactory;
@Override
public List<Project> findWithSearchConditions(ProjectQueryCondition condition) {
SearchKeyword searchKeyword = condition.search();
List<String> techStackNames = condition.techStackNames();
List<String> categoryNames = condition.categoryNames();
ProjectSortType sortBy = condition.projectSortType();
Cursor<?> cursor = condition.cursor();
int limit = condition.limit();
return jpaQueryFactory
.selectFrom(project).distinct()
.leftJoin(project.categories, projectCategory)
.leftJoin(project.techStacks, techStack)
.where(
techStackNamesIn(techStackNames),
categoryNamesIn(categoryNames),
satisfiesMatchScore(searchKeyword),
applyCursor(cursor)
)
.having(
hasExactTechStackCount(techStackNames),
hasExactCategoryCount(categoryNames)
)
.groupBy(project.id)
.orderBy(toOrderBy(sortBy))
.limit(limit + FETCH_EXTRA_FOR_HAS_NEXT)
.fetch();
}
// ...
}
QueryDSL을 도입하면서 라이브러리 선택에 대한 고민이 있었다. 원본 QueryDSL 대신
이를 포크 한 OpenFeign QueryDSL을 사용하기로 했다..
변경 전
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
변경 후
implementation "io.github.openfeign.querydsl:querydsl-jpa:7.0"
annotationProcessor "io.github.openfeign.querydsl:querydsl-apt:7.0:jpa"
annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
testAnnotationProcessor "io.github.openfeign.querydsl:querydsl-apt:7.0:jpa"
testAnnotationProcessor 'jakarta.persistence:jakarta.persistence-api'
라이브러리 선택 과정에서 두 가지 옵션을 비교해 봤다.
기존 QueryDSL은 오래된 역사와 안정성을 가지고 있지만, 유지보수가 중단된 상태였다.
SQL Injection 같은 보안 취약점도 더 이상 패치되지 않고 있어 장기적으로 위험했다.
반면 OpenFeign QueryDSL은 활발히 유지보수되고 있지만, 상대적으로 짧은 역사와
작은 커뮤니티 때문에 안정성에 대한 우려가 있었다.
고민 끝에 스프링 공식 문서에서 OpenFeign QueryDSL 사용을 권장한다는 점을 근거로 OpenFeign QueryDSL을 최종 선택했다. 결과적으로 별다른 문제없이 안정적으로 사용할 수 있었다.
#3 Full Text Index 와 검색
모아온의 검색 경험 향상을 위해 MySQL의 전문 검색 인덱스(Full Text Index)를 사용했다. 검색 기능 구현은 포포가 했고, 나는 코드 리뷰를 통해 함께 학습하며 개선 방향을 논의했다.
이 과정에서 검색 기능을 제대로 구현하는 것이 생각보다 복잡하다는 것을 깨달았다. MySQL의 Full Text Index는 기본적으로 2-gram 방식으로 인덱스를 생성한다. 그런데 영어와 달리 한글은 한 글자만으로도 의미를 갖는 경우가 많아서 예상과 다른 검색 결과가 나오는 경우가 있었다. 또한 불용어 처리도 씬경써야 했다.
검색 전략을 결정하는 과정에서도 많은 고민이 있었다. 정확도를 높이기 위해 매칭 점수를 어떻게 계산할지, 부분 검색과 완전 일치 검색
의 비중을 어떻게 조절할지 등을 논의해야 했다.
레벨 4에서는 최근 검색 기능을 좀 더 고도화하기 위해 Elastic Search 도입을 고려 중이다.
#4 REST Docs
모아온은 API 문서로 REST Docs를 선택했고, 해당 부분을 담당해서 구현했다.
우리 팀은 "테스트 코드도 코드다"라는 철학을 바탕으로 테스트코드의 퀄리티에 많은 관심을 가지고 있다. 따라서 문서 생성을 위해 테스트 코드의 본래 목적을 방해하지 않도록 최대한 신경 써서 설계했다. 또한 테스트용 컨트롤러를 활용해서 에러 코드 목록을 자동으로 생성해서 문서에 표로 포함시켰다.

'후기 or 회고 > 우아한테크코스' 카테고리의 다른 글
| [우테코 Lv4] 4레벨 마무리 + 4레벨 이후의 나는? (6) | 2025.11.03 |
|---|---|
| [우테코 Lv3] 레벨3 회고 (2) - 모아온의 회의 문화 (7) | 2025.09.08 |
| [우테코 Lv3] 레벨3 회고 (1) - 기획과 프로토타입 (3) | 2025.09.08 |
| [우테코 Lv2] 레벨2 미션4 방탈출 결제 / 배포 회고 (7) | 2025.06.17 |
| [우테코 Lv2] 레벨2 미션3 방탈출 예약 대기 회고 (8) | 2025.06.02 |