spring_2기[본캠프]/과제

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

minwoo95 2026. 4. 21. 22:20

1. 오늘 한 일
오늘은 팀원과 함께 멘토링 도메인의 권한 검증 로직에 대한 코드 리뷰를 진행했다.
getManuscriptDownloadUrl(), acceptMentee(), rejectMentee() 등 MentoringService 전반의 권한 비교문이 올바른지 분석했고, Mentorship.create() 호출부를 추적하여 mentorId 저장 방식을 검증했다.
2. 트러블슈팅
2-1. mentorship.getMentorId().equals(mentor.getId()) 비교문이 항상 틀리다는 팀원 주장
문제 상황
팀원이 아래 비교문이 값이 다를 수밖에 없다고 주장했다.
javaif (!mentorship.getMentorId().equals(mentor.getId())) {
    throw new ServiceErrorException(MentoringExceptionEnum.MENTORING_UNAUTHORIZED);
}
원인 분석
팀원의 주장을 검증하기 위해 Mentorship이 insert되는 시점, 즉 멘티 서비스에서 Mentorship.create()를 호출하는 코드를 추적했다.
확인 결과 멘티 서비스에서 Mentorship.create()의 첫 번째 인자로 mentor.getId()(mentors 테이블 PK)가 아닌 userId(users 테이블 PK)를 넘기고 있었다.
users 테이블    mentors 테이블    mentorships 테이블
id = 999  →   id = 3            mentorId = 999  ← userId가 저장됨
              userId = 999
따라서 비교문에서:
mentorship.getMentorId() = 999  (userId)
mentor.getId()           = 3    (mentors PK)
→ 항상 다른 값 → 항상 UNAUTHORIZED 발생
팀원의 주장이 맞았다.
해결
멘티 서비스의 Mentorship.create() 첫 번째 인자를 userId에서 mentor.getId()로 수정했다.
java// 수정 전 - userId를 넘기는 버그
Mentorship.create(userId, menteeId, novelId, motivation, manuscriptUrl);

// 수정 후 - mentors 테이블 PK를 넘기도록 수정
Mentorship.create(mentor.getId(), menteeId, novelId, motivation, manuscriptUrl);
3. 느낀 점
코드 리뷰에서 팀원이 "값이 다를 수밖에 없다"고 했을 때 처음엔 코드가 맞다고 확신했다. 하지만 실제로 insert 시점의 호출부까지 끝까지 추적해보니 팀원이 맞았다.
의심이 생겼을 때 서비스 코드 한두 개만 보고 판단하지 말고 데이터가 저장되는 시점부터 조회되는 시점까지 전체 흐름을 추적해야 한다는 것을 깨달았다. 특히 userId와 mentor.id처럼 이름이 비슷한 값들은 어느 테이블의 PK인지 항상 명확히 구분해야 한다.

 

--------------------------------------------------------------

 

1. 오늘 한 일
오늘은 NovelCraft 프로젝트에서 Mentorship 도메인의 설계 오류를 발견하고 팀원과 논의 끝에 리팩토링을 진행했다.
Mentorship.title 필드 제거
MentorFeedback에 title, sessionNumber 필드 추가
MentorFeedback.create() 시그니처 변경
MentoringDetailResponse title → novelTitle 교체 및 FeedbackInfo 구조 변경
MentoringFeedbackRequest, MentoringFeedbackResponse title/sessionNumber 추가
MentoringService createFeedback() 비관적 락 적용 (V2)
관련 테스트 코드 4개 전체 수정
2. 트러블슈팅
2-1. Mentorship.title을 MentorFeedback으로 옮겨야 하는가 — 도메인 설계 논의
문제 상황
팀원이 Mentorship 엔티티의 title 필드를 MentorFeedback으로 옮겨야 한다는 의견을 제시했다. 피드백이 여러 개일 수 있으니 각 피드백마다 제목이 있어야 한다는 논리였다.
원인 분석
처음에는 Mentorship.title이 멘토링 신청 자체를 식별하는 제목이므로 그 위치가 맞다고 판단했다. 그런데 프로젝트 구조를 다시 살펴보니 실제 문제는 다른 곳에 있었다.
멘토 한 명이 최대 5명의 멘티를 동시에 관리하는 1:1 매칭 구조에서, 각 멘토링은 회차별로 피드백이 쌓이는 이력 구조가 되어야 했다. 그런데 현재는 totalSessions 숫자만 있고 각 회차의 내용이 MentorFeedback에 제대로 담겨있지 않은 상태였다. title이 Mentorship에 있으면 멘토링 신청 전체의 제목만 존재하고, 회차별 피드백 제목이 없어 이력 관리가 불완전했다.
결론적으로 팀원의 의견이 맞았다. title은 각 피드백 회차를 구분하는 제목으로 MentorFeedback에 있어야 하고, sessionNumber도 함께 추가해 몇 회차 피드백인지 명확히 해야 했다.
해결
java// 변경 전 — Mentorship
private String title;  // 멘토링 신청 제목

// 변경 후 — MentorFeedback
private String title;        // 회차별 피드백 제목
private int sessionNumber;   // 몇 회차 피드백인지
Mentorship.create()에서 title 파라미터를 제거하고, MentorFeedback.create()에 title과 sessionNumber를 추가했다.
2-2. sessionNumber 동시성 이슈 — 비관적 락 적용
문제 상황
CodeRabbit 리뷰에서 sessionNumber 계산 로직의 동시성 이슈를 지적받았다.
java// 문제 코드
int nextSession = mentorship.getTotalSessions() + 1;
두 요청이 동시에 들어오면 둘 다 같은 nextSession 값을 읽고 같은 sessionNumber로 저장하는 경우가 발생할 수 있다.
해결
V1/V2 고도화 구조로 분리해 해결했다.
V1은 기존 로직 유지, V2는 비관적 락과 유니크 제약을 함께 적용했다.
java// V2 — MentorshipRepository
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT m FROM Mentorship m WHERE m.id = :id")
Optional<Mentorship> findByIdWithLock(@Param("id") Long id);
java// V2 — MentorFeedback 엔티티
@Table(
    name = "mentorship_feedbacks",
    uniqueConstraints = @UniqueConstraint(columnNames = {"mentorship_id", "session_number"})
)
비관적 락으로 동시 요청을 직렬화하고, 유니크 제약으로 중복 저장 자체를 DB 레벨에서 방어했다. 혹시라도 충돌이 발생하면 MENTORING_SESSION_CONFLICT 예외로 400을 반환한다.
2-3. MentoringDetailResponse.title → novelTitle 교체 누락
문제 상황
Mentorship.title을 제거하면서 MentoringDetailResponse에서 mentorship.getTitle()을 호출하는 부분을 놓쳤다.
원인 분석
title을 MentorFeedback으로 이동하면서 MentoringDetailResponse의 title 필드를 어떤 값으로 대체할지 결정이 필요했다. 피그마 화면을 확인하니 멘토링 상세에서 제목으로 표시되는 것은 소설 제목이었다.
해결
java// 변경 전
String title,  // Mentorship.title 사용

// 변경 후
String novelTitle,  // Novel.title로 대체

// MentoringService — novelRepository에서 직접 조회
String novelTitle = novelRepository.findById(mentorship.getCurrentNovelId())
        .map(Novel::getTitle)
        .orElse("알 수 없는 소설");
V2에서는 soft-delete까지 적용해 삭제된 소설 제목이 노출되는 문제도 함께 해결했다.
java// V2
String novelTitle = novelRepository.findByIdAndIsDeletedFalse(mentorship.getCurrentNovelId())
        .map(Novel::getTitle)
        .orElse("알 수 없는 소설");
3. 코드래빗 리뷰 반영
3-1. title @Size(max = 200) 누락
MentoringFeedbackRequest의 title 필드에 @NotBlank만 있어서 200자를 초과하면 DB 예외로 500이 반환될 수 있었다. @Size(max = 200)을 추가해 400으로 끊도록 수정했다.
java@NotBlank(message = "피드백 제목을 입력해 주세요")
@Size(max = 200, message = "피드백 제목은 200자 이하로 입력해 주세요")
String title,
3-2. findById soft-delete 미적용
getMentoringDetail에서 novelRepository.findById()를 사용해 삭제된 소설 제목이 그대로 노출될 수 있었다. V2에서 findByIdAndIsDeletedFalse()로 교체했다.
4. 느낀 점
오늘 설계 논의에서 배운 점이 있다. 처음에는 Mentorship.title이 맞는 위치라고 판단했는데 팀원의 의견을 듣고 실제 도메인 구조를 다시 살펴보니 생각이 바뀌었다.
핵심은 "이 데이터가 무엇을 식별하는가"였다. title이 멘토링 신청 전체를 식별하는 것인지, 아니면 각 피드백 회차를 식별하는 것인지를 명확히 하니 답이 나왔다. 1:1 멘토링에서 회차별 이력 관리가 핵심이라면 title은 당연히 MentorFeedback에 있어야 한다.
동시성 이슈는 단순히 락을 거는 것으로 끝내지 않고 DB 유니크 제약까지 함께 걸어서 이중으로 방어한 것이 좋았다. 애플리케이션 레벨의 락만 믿는 것보다 DB 레벨에서도 막는 것이 더 안전하다는 것을 다시 확인했다.