spring_2기[본캠프]/과제

[파이널 과제] NovelCraft 웹소설 창작 플랫폼 개발 프로젝트 Day 4

minwoo95 2026. 4. 17. 20:40

1. 오늘 한 일

오늘은 NovelCraft 프로젝트에서 멘토 도메인 전체를 담당하여 구현했다.

  • 멘토 등록 신청 API 구현 - POST /api/mentors
  • 멘토 정보 수정 API 구현 - PUT /api/mentors/me
  • 내 멘토 프로필 조회 API 구현 - GET /api/mentors/me
  • careerLevel 기준 자동 승인 로직 구현
  • 매일 자정 실행되는 등급 자동 조정 배치 스케줄러 구현
  • 등급 변경 이력 테이블 및 저장 로직 구현
  • 서비스, 컨트롤러 단위 테스트 작성
  • 코드래빗 리뷰 피드백 반영 및 수정

단순한 CRUD처럼 보였지만 실제로는 자동 승인 조건 설계, 동시성 문제, 여러 도메인 간 데이터 조회, 배치 설계 등 다양한 기술적 판단이 필요했다.

 

2. 기술 선정 이유

2-1. 엔티티 간 연관관계 미사용 - ID 참조 방식

JPA를 사용하면 보통 @ManyToOne, @OneToMany 같은 연관관계를 설정한다. 하지만 이 프로젝트의 설계 원칙은 엔티티가 서로의 연관관계를 갖지 않고 FK를 일반 컬럼으로 가져가는 방식이다.

이를 선택한 이유는 두 가지다. 첫째, 연관관계를 맺으면 지연 로딩, N+1, 영속성 컨텍스트 범위 등 관리 포인트가 늘어나고 팀원들이 실수하기 쉬운 영역이 생긴다. 둘째, 도메인 간 결합도를 낮춰 각 도메인이 독립적으로 개발되고 변경될 수 있도록 하기 위함이다. 대신 필요한 조인은 QueryDSL로 처리하는 방식으로 보완했다.

멘토 도메인에서 userId, novelId 등을 단순 Long 컬럼으로 저장하고 NovelRepository, EpisodeRepository를 직접 주입받아 조회하는 방식이 이 원칙을 따른 것이다.

2-2. 자동 등급 조정 - Spring @Scheduled 배치

멘토 등급 자동 조정 방식으로 이벤트 기반과 배치 스케줄러 두 가지를 고려했다.

이벤트 기반은 에피소드가 발행되거나 좋아요가 달릴 때마다 조건을 체크하는 방식이다. 실시간 반영이 가능하지만 에피소드, 좋아요 도메인에 멘토 도메인 로직이 침투하게 되어 결합도가 높아지고 각 도메인의 책임이 흐려진다는 문제가 있다.

배치 방식은 매일 자정에 한 번 전체 APPROVED 멘토를 순회하며 조건을 검사한다. 구현이 단순하고 각 도메인이 서로를 몰라도 된다. 현재 트래픽 규모에서 실시간 반영이 필수가 아니라고 판단하여 배치를 선택했다. Spring의 @Scheduled로 cron 표현식을 사용하면 별도의 인프라 없이 간단하게 구현할 수 있다는 점도 이유였다.

2-3. 리스트 필드 JSON 직렬화 저장

mainGenres, specialFields, mentoringStyles처럼 복수의 값을 갖는 필드를 별도 테이블로 분리하지 않고 JSON 문자열로 단일 컬럼에 저장했다.

별도 테이블로 분리하면 조회 시 항상 조인이 필요하고, 수정 시 기존 데이터 삭제 후 재삽입하는 로직이 필요하다. 반면 이 필드들은 단순 목록 조회 외에 복잡한 검색 쿼리가 필요 없고 변경 빈도도 낮다. ObjectMapper를 이용한 JSON 직렬화/역직렬화로 충분하다고 판단했다.

2-4. 등급 변경 이력 별도 테이블 관리

Mentor 엔티티에 바로 덮어쓰는 방식 대신 MentorCareerHistory 테이블을 별도로 두어 등급 변경 이력을 누적 저장했다.

변경 전 등급, 변경 후 등급, 변경 사유를 함께 기록하면 추후 관리자가 등급 변경 히스토리를 추적하거나 이의 제기가 발생했을 때 근거 자료로 활용할 수 있다. 데이터는 한 번 쌓이면 삭제되지 않는 append-only 구조라 단순하고 안전하다.

2-5. 정적 팩토리 패턴 적용

Mentor, MentorCareerHistory 엔티티 생성에 모두 정적 팩토리 메서드를 사용했다. 생성자를 private으로 막고 create() 메서드만 열어두면 객체 생성 시점에 필요한 검증이나 초기값 설정을 한 곳에서 관리할 수 있다. 팀원들이 엔티티를 생성할 때 실수로 필드를 빠뜨리는 것도 방지할 수 있다.


3. 트러블슈팅

3-1. multipart/form-data Content-Type 문제

멘토 등록 API를 처음에 파일과 JSON을 함께 받는 multipart/form-data 방식으로 구현했다. @RequestPart로 data 파트를 받도록 했는데 Postman에서 테스트하니 계속 Content-Type application/json is not supported 에러가 발생했다.

원인을 찾아보니 Postman에서 form-data의 Text 타입으로 전송하면 해당 파트가 application/octet-stream으로 전송된다. Spring은 @RequestPart로 객체를 받을 때 해당 파트의 Content-Type을 보고 역직렬화하는데, application/json이 아니면 변환이 불가능했다.

Postman에서 data 파트의 Content-Type을 직접 application/json으로 지정하면 해결되지만 설정이 번거롭고 팀원들에게 설명하기도 어렵다. S3 연동 전이라 파일이 실제로 저장되지도 않는 상황이었기 때문에 우선 @RequestBody로 JSON만 받도록 임시 변경하고 TODO 주석으로 S3 연동 후 multipart로 전환할 위치를 명시해두었다. 발표 시에는 multipart/form-data를 선택한 이유를 설명할 수 있어야 했는데, HTTP multipart 스펙상 각 파트가 자신의 Content-Type을 가질 수 있고 Spring의 HttpMessageConverter가 이를 이용해 자동으로 역직렬화하므로 서버 코드의 관심사를 분리한 방식이라는 점을 정리했다.

3-2. DB 컬럼 누락 500 에러

멘토 등록 API를 호출하니 Unknown column special_fields in field list 에러가 발생했다. 엔티티에는 specialFields, certificationFileUrl 컬럼을 추가했는데 실제 DB 테이블에는 반영되지 않은 것이었다.

프로젝트에서 ddl-auto가 none으로 설정되어 있어 엔티티가 변경되어도 DB에 자동으로 반영되지 않는다. ALTER TABLE로 누락된 컬럼을 직접 추가해서 해결했다. 이 과정에서 팀 내에서 스키마 변경이 발생하면 반드시 마이그레이션 SQL을 공유하는 프로세스가 필요하다는 것을 느꼈다.

3-3. @AuthenticationPrincipal 타입 불일치

컨트롤러에서 @AuthenticationPrincipal Long userId로 userId를 주입받으려 했는데 null이 들어왔고, 결국 Column user_id cannot be null 에러가 발생했다.

원인은 JwtFilter에서 SecurityContextHolder에 저장되는 principal이 Long이 아니라 UserDetailsImpl 객체였기 때문이다. @AuthenticationPrincipal은 principal을 그대로 꺼내주는 어노테이션인데 타입이 맞지 않으면 null이 주입된다. 다른 팀원의 컨트롤러 코드를 확인해보니 전부 @AuthenticationPrincipal UserDetailsImpl userDetails로 받고 있었다. 팀 내 공통 구조를 미리 확인하지 않고 개발한 것이 실수였다.

3-4. 동시 요청 race condition

멘토 등록 신청 시 사전에 PENDING, APPROVED 여부를 조회한 후 저장하는 구조였다. 그런데 동시에 두 요청이 들어오면 둘 다 조회를 통과한 뒤 저장을 시도해서 중복 저장이 발생할 수 있었다.

코드래빗 리뷰에서 이 문제를 지적받았다. check-then-act 패턴의 전형적인 race condition이다. Mentor 엔티티의 userId 컬럼에 @Column(unique = true)가 걸려 있어 DB 레벨에서 중복을 막아주긴 하지만 이때 DataIntegrityViolationException이 터지면서 500으로 응답된다. save 호출 부분을 try-catch로 감싸 DataIntegrityViolationException을 잡아 MENTOR_ALREADY_APPROVED 예외로 변환하고 409로 응답하도록 처리했다.

3-5. 배치 로그에 이전 등급이 잘못 출력되는 버그

배치 실행 로그에서 mentorId=1 INTRODUCTION → ELEMENTARY라고 출력되어야 할 것이 mentorId=1 ELEMENTARY → ELEMENTARY처럼 찍혔다.

원인은 mentor.upgradeCareerLevel(newLevel)을 호출한 이후에 mentor.getCareerLevel()을 로그에 찍었기 때문이다. 이미 값이 변경된 후라 이전 등급이 아닌 새 등급이 출력됐다. upgradeCareerLevel 호출 전에 CareerLevel previousLevel = mentor.getCareerLevel()로 이전 값을 변수에 저장하고, 로그와 이력 저장에 그 변수를 사용하는 방식으로 수정했다. 작은 실수지만 이런 부분이 운영 환경에서 디버깅을 어렵게 만든다는 것을 깨달았다.

3-6. 수정 API에서 빈 리스트와 null의 동작 불일치

멘토 정보 수정 시 mainGenres에 빈 배열을 전송해도 기존 값이 그대로 유지됐다. 의도한 동작은 빈 배열을 보내면 기존 장르가 전부 지워지는 것이었는데 그렇게 동작하지 않았다.

원인은 toJson() 메서드가 null과 빈 리스트를 동일하게 null로 처리했기 때문이다. Mentor.update() 메서드는 null이면 기존 값을 유지하는 방식이라 빈 리스트가 null로 변환되어 들어오면 기존 값이 유지됐다. 등록용 toJson()과 수정용 toJsonForUpdate()를 분리하여 toJson()은 null과 빈 리스트 모두 null을 반환하고, toJsonForUpdate()는 null만 null을 반환하고 빈 리스트는 실제로 직렬화해서 DB에 빈 배열이 저장되도록 했다.

3-7. EpisodeRepository 빈 리스트 IN 쿼리 방어

코드래빗 리뷰에서 countByNovelIdInAndStatus, sumLikeCountByNovelIdIn 메서드가 빈 리스트를 받으면 IN :novelIds 쿼리가 DB 제공자에 따라 문법 오류를 낼 수 있다는 지적이 있었다.

호출부인 MentorService와 MentorCareerLevelScheduler에서는 novelIds.isEmpty() 체크를 하고 있어 현재는 문제가 없다. 하지만 나중에 다른 곳에서 이 메서드를 호출할 때 실수할 수 있다. @Query 메서드를 Raw 메서드로 두고 default 메서드로 감싸서 빈 리스트가 들어오면 즉시 0L을 반환하도록 메서드 자체에 방어 로직을 추가했다.


4. 느낀 점

오늘 하루 멘토 도메인 하나를 구현하면서 생각보다 많은 결정과 실수, 수정이 있었다.

가장 크게 느낀 것은 팀 공통 구조를 먼저 파악해야 한다는 것이다. @AuthenticationPrincipal 타입 문제는 다른 팀원의 코드를 먼저 확인했다면 발생하지 않았을 실수다. 팀 프로젝트에서 공통으로 사용하는 Security 구조, 응답 포맷, 예외 처리 방식 등은 개발 전에 반드시 파악해두어야 한다.

동시성 문제를 직접 경험한 것도 좋은 공부였다. check-then-act 패턴의 위험성을 코드로 직접 보고 DB 유니크 제약과 예외 처리를 조합해서 해결하는 방법을 익혔다. 애플리케이션 레벨의 중복 체크만으로는 race condition을 완전히 막을 수 없고, DB 제약과 예외 핸들링이 반드시 함께 있어야 한다는 것을 배웠다.

코드래빗 리뷰 도구를 처음 사용해봤는데 생각보다 유용했다. 빈 리스트 IN 쿼리, 로그 버그, 수정용 직렬화 분리 같은 것들은 사람이 리뷰할 때 놓치기 쉬운 디테일인데 꽤 정확하게 짚어줬다. 물론 모든 지적이 다 맞는 것은 아니라 판단이 필요하지만, 기계적으로 확인 가능한 패턴 문제들은 자동화 도구에 맡기는 것이 효율적이라고 느꼈다.

마지막으로 설계 시 null과 빈 값의 의미를 명확히 구분해야 한다는 것도 배웠다. 특히 부분 업데이트 API에서 null은 기존 값 유지, 빈 배열은 명시적 삭제라는 규칙을 초기에 명확히 정해두지 않으면 구현과 테스트 단계에서 혼란이 생긴다. 다음에는 API 설계 단계에서 이런 엣지케이스까지 미리 정의해두려고 한다.