spring_2기[본캠프]/과제

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

minwoo95 2026. 4. 20. 20:44

1. 오늘 한 일
오늘은 NovelCraft 프로젝트에서 mentor/mentoring 도메인의 API를 구현하고 테스트 코드를 작성했다.

멘토링 접수 목록 조회 API 구현 - GET /api/mentorings/received
멘티 수락 API 구현 - PATCH /api/mentorings/{mentoringId}/mentees/{menteeId}/accept
멘티 거절 API 구현 - PATCH /api/mentorings/{mentoringId}/mentees/{menteeId}/reject
원고 다운로드 URL 조회 API 구현 - GET /api/mentorings/{mentoringId}/documents
멘토링 종료 API 구현 - PATCH /api/mentorings/{mentoringId}/complete
멘토링 상세 조회 API 구현 - GET /api/mentorings/{mentoringId}
피드백 작성 API 구현 - POST /api/mentorings/{mentoringId}/feedbacks
MentoringService 단위 테스트 커버리지 100% 달성
코드래빗 리뷰 피드백 반영 및 수정


2. 트러블슈팅
2-1. 34개 테스트 전부 실패 - findById() vs findByUserId() mock 불일치
문제 상황
테스트를 실행하자마자 34개 전부가 실패했다. 에러 메시지는 모두 동일하게 등록된 멘토 프로필을 찾을 수 없습니다였다. 성공 케이스든 예외 케이스든 관계없이 모든 테스트의 첫 번째 assert에서 터졌다.
원인 분석
스택 트레이스를 보니 MentoringService.java:42, :63, :90, :115, :139, :160, :190 등 서비스의 모든 메서드 첫 번째 줄에서 동일한 예외가 발생하고 있었다. 서비스 코드를 다시 확인해보니 모든 메서드가 첫 줄에서 mentorRepository.findByUserId(userId)를 호출하는 구조였다.
문제는 테스트 코드에서 mentorRepository.findById(MENTOR_ENTITY_ID)로 mock을 설정한 것이었다. 서비스가 실제로 호출하는 메서드는 findByUserId()인데 findById()에 mock을 걸어놨으니 당연히 mock이 동작하지 않고 Optional.empty()가 반환되어 예외가 터진 것이다.
java// 테스트 코드 (기존 - 잘못된 코드)
given(mentorRepository.findById(MENTOR_ENTITY_ID)).willReturn(Optional.of(mentor));

// 서비스 코드 (실제 호출)
Mentor mentor = mentorRepository.findByUserId(userId)
        .orElseThrow(() -> new ServiceErrorException(MentorExceptionEnum.MENTOR_NOT_FOUND));
해결
모든 테스트의 given 절을 findByUserId(USER_ID)로 일괄 수정했다. 단순한 실수지만 34개 테스트가 전부 실패한 원인이었다. 테스트 작성 전에 서비스 코드의 repository 호출 흐름을 위에서 아래로 따라가면서 실제 호출 메서드를 확인하는 습관이 필요하다는 것을 깨달았다.
java// 수정 후
given(mentorRepository.findByUserId(USER_ID)).willReturn(Optional.of(mentor));

2-2. 권한 예외 테스트에서 의도한 예외가 발생하지 않는 문제
문제 상황
acceptMentee_unauthorized_throws, rejectMentee_unauthorized_throws 등 권한 없는 유저 케이스 테스트에서 기댓값인 MENTORING_UNAUTHORIZED 대신 MENTOR_NOT_FOUND가 발생했다.
Expected : "해당 멘토링에 대한 권한이 없습니다"
Actual   : "등록된 멘토 프로필을 찾을 수 없습니다"
원인 분석
기존 테스트에서 권한 없는 유저를 표현하기 위해 userId=999L을 서비스에 넘겼다. 그런데 mentorRepository.findByUserId(999L)에 대한 mock이 등록되지 않아 Optional.empty()가 반환되면서 MENTOR_NOT_FOUND가 먼저 터졌다.
서비스 코드의 실행 순서는 다음과 같다.
java// 1. findByUserId 호출 → mock 없으면 empty() → MENTOR_NOT_FOUND 발생
Mentor mentor = mentorRepository.findByUserId(userId)
        .orElseThrow(() -> new ServiceErrorException(MentorExceptionEnum.MENTOR_NOT_FOUND));

// 2. mentorship 조회
Mentorship mentorship = mentorshipRepository.findById(mentoringId)...

// 3. 권한 체크 → MENTORING_UNAUTHORIZED
if (!mentorship.getMentorId().equals(mentor.getId())) {
    throw new ServiceErrorException(MentoringExceptionEnum.MENTORING_UNAUTHORIZED);
}
3번 권한 체크까지 도달하려면 1번 findByUserId가 반드시 성공해야 하는데, 999L에 대한 mock이 없으니 1번에서 막혀버린 것이다.
해결
USER_ID(userId, findByUserId 인자)와 MENTOR_ENTITY_ID(Mentor 엔티티 PK)의 역할을 명확히 분리했다. 권한 없는 케이스에서는 OTHER_USER_ID로 조회는 성공하지만 mentor의 엔티티 ID가 mentorship의 mentorId와 다른 otherMentor를 별도로 만들어 mock에 등록했다.
javaprivate static final Long USER_ID         = 1L;   // findByUserId() 인자
private static final Long MENTOR_ENTITY_ID = 5L;  // Mentor 엔티티 PK (mentorship.mentorId)
private static final Long OTHER_USER_ID   = 999L; // 권한 없는 유저의 userId

// 권한 없는 케이스 테스트
Mentor otherMentor = Mentor.create(OTHER_USER_ID, ...);
setField(otherMentor, "id", 999L); // MENTOR_ENTITY_ID(5L)와 다른 ID → 권한 체크 실패

given(mentorRepository.findByUserId(OTHER_USER_ID)).willReturn(Optional.of(otherMentor));
given(mentorshipRepository.findById(MENTORING_ID)).willReturn(Optional.of(mentorship));

// mentorship.mentorId = 5L, otherMentor.id = 999L → MENTORING_UNAUTHORIZED 발생
assertThatThrownBy(() -> mentoringService.acceptMentee(MENTORING_ID, MENTEE_ID, OTHER_USER_ID))
        .isInstanceOf(ServiceErrorException.class)
        .hasMessage(MentoringExceptionEnum.MENTORING_UNAUTHORIZED.getMessage());

2-3. UnnecessaryStubbingException - 불필요한 stub 등록
문제 상황
acceptMentee_mentor_not_found_throws, completeMentoring_mentor_not_found_throws 테스트에서 UnnecessaryStubbingException이 발생했다.
Unnecessary stubbings detected.
Following stubbings are unnecessary:
  1. -> at MentoringServiceTest.java:265 (mentorshipRepository.findById)
  2. -> at MentoringServiceTest.java:266 (mentorRepository.findById)
원인 분석
해당 테스트들은 멘토 프로필이 없을 때 예외가 발생하는 케이스다. 서비스 코드에서 findByUserId()가 empty()를 반환하면 그 즉시 MENTOR_NOT_FOUND 예외를 던지고 메서드가 종료된다. 그 이후의 mentorshipRepository.findById()는 절대 호출되지 않는다.
Mockito의 strict mode에서는 등록된 stub이 한 번도 호출되지 않으면 테스트가 불필요한 코드를 포함하고 있다고 판단해서 예외를 던진다. 실제로 호출되지 않는 stub을 등록해둔 것이 문제였다.
java// 기존 (잘못된 코드)
given(mentorshipRepository.findById(MENTORING_ID)).willReturn(Optional.of(mentorship)); // 호출되지 않음
given(mentorRepository.findById(MENTOR_ENTITY_ID)).willReturn(Optional.empty());        // 호출되지 않음

// 서비스 코드: findByUserId() empty → 즉시 예외 → findById() 호출 안됨
해결
MENTOR_NOT_FOUND 케이스에서는 findByUserId()만 empty()로 설정하면 충분하다. 그 이후 절대 호출되지 않는 stub은 전부 제거했다.
java// 수정 후
@Test
@DisplayName("멘토 프로필이 없으면 예외 발생")
void acceptMentee_mentor_not_found_throws() {
    given(mentorRepository.findByUserId(USER_ID)).willReturn(Optional.empty());

    assertThatThrownBy(() -> mentoringService.acceptMentee(MENTORING_ID, MENTEE_ID, USER_ID))
            .isInstanceOf(ServiceErrorException.class)
            .hasMessage(MentorExceptionEnum.MENTOR_NOT_FOUND.getMessage());
}

2-4. reached end of file while parsing 컴파일 에러
문제 상황
MentoringControllerTest.java 352번째 줄에서 컴파일 에러가 발생하면서 빌드 자체가 실패했다.
MentoringControllerTest.java: reached end of file while parsing :352
원인 분석
에러가 가리키는 위치가 MentoringFeedbackResponse mockResponse = new MentoringFeedbackResponse( 였다. CreateFeedbackTest 내부 메서드를 작성하던 중 코드가 중간에 잘린 채로 저장된 것이었다. 닫는 중괄호 }가 부족한 상태라 Java 파서가 파일 끝까지 읽어도 클래스가 닫히지 않는다고 판단한 것이다.
해결
잘린 메서드를 완성하고 CreateFeedbackTest 클래스와 MentoringControllerTest 클래스를 닫는 } 2개를 파일 맨 끝에 추가했다. reached end of file while parsing 에러는 항상 파일 끝에서 발생하지만 실제 원인은 중간 어딘가의 중괄호 누락이므로 파일 전체를 위에서 아래로 훑어봐야 한다.

2-5. Mentorship.create() 인자 수 불일치 컴파일 에러
문제 상황
MentoringServiceTest 컴파일 시 아래 에러가 발생했다.
method create in class Mentorship cannot be applied to given types;
required: Long, Long, Long, String, String, String
found:    Long, Long, Long, String, String
reason: actual and formal argument lists differ in length
원인 분석
Mentorship.create() 메서드에 title 파라미터가 추가됐는데 테스트 코드에서 기존 5개 인자로 호출하고 있었다. 서비스 개발 과정에서 엔티티에 title 컬럼이 추가되었고 create() 메서드 시그니처가 변경됐는데 테스트 코드가 그에 맞게 업데이트되지 않은 것이었다.
해결
MentoringServiceTest에서 Mentorship.create() 호출하는 곳 3군데를 전부 6인자로 수정했다.
java// 수정 전 (5인자)
mentorship = Mentorship.create(MENTOR_ENTITY_ID, MENTEE_ID, NOVEL_ID, "신청 동기입니다", "https://s3.amazonaws.com/file.pdf");

// 수정 후 (6인자 - title 추가)
mentorship = Mentorship.create(MENTOR_ENTITY_ID, MENTEE_ID, NOVEL_ID, "신청 동기입니다", "https://s3.amazonaws.com/file.pdf", "자바 백엔드 로드맵");

3. 코드래빗 리뷰 반영
3-1. countTotalMenteesByMentorId - PENDING/REJECTED 신청자 집계 제외
기존 쿼리는 상태 구분 없이 DISTINCT menteeId를 세고 있어 거절된 신청자까지 총 멘티 수에 포함됐다. 거절 이력이 많은 멘토일수록 실제보다 통계가 부풀려지는 문제가 있었다.
java// 수정 전 - PENDING, REJECTED 포함
@Query("SELECT COUNT(DISTINCT m.menteeId) FROM Mentorship m WHERE m.mentorId = :mentorId")
long countTotalMenteesByMentorId(@Param("mentorId") Long mentorId);

// 수정 후 - ACCEPTED, COMPLETED만 집계
@Query("SELECT COUNT(DISTINCT m.menteeId) FROM Mentorship m " +
        "WHERE m.mentorId = :mentorId " +
        "AND m.status IN ('ACCEPTED', 'COMPLETED')")
long countTotalMenteesByMentorId(@Param("mentorId") Long mentorId);

 

3-2. completeMentoring_success 테스트 - 비현실적인 슬롯 상태 수정
기존 테스트는 mentorship.approve()만 호출하고 슬롯 차감 없이 maxMentees=4를 기댓값으로 설정했다. completeMentoring()은 슬롯을 1 증가시키기만 하므로 초기값 3에서 +1이 되어 4가 되는 것처럼 보이지만, 이는 수락 시점의 슬롯 차감이 없는 비현실적인 상태다.
실제 흐름은 수락 시 슬롯 차감(3→2), 종료 시 슬롯 반환(2→3)이다.
java// 수정 전 - 수락 시 슬롯 차감 없이 종료
mentorship.approve();
// maxMentees = 3 (차감 안됨)
assertThat(mentor.getMaxMentees()).isEqualTo(4); // 3 + 1 → 비현실적

// 수정 후 - 실제 흐름 반영
mentorship.approve();
mentor.decreaseSlot(); // 수락 시점 슬롯 차감 (3 → 2)
assertThat(mentor.getMaxMentees()).isEqualTo(3); // 2 + 1 → 원래값으로 복구 확인

 

3-3. createFeedback_success - ArgumentCaptor로 저장 엔티티 검증 강화
기존에는 save(any())로 저장 호출 여부만 검증했다. 서비스가 MentorFeedback.create()에 잘못된 mentoringId나 authorId를 넘겨도 테스트가 통과하는 구조였다.
ArgumentCaptor로 실제 저장된 엔티티를 잡아서 필드값까지 검증하도록 강화했다. 또한 MentorFeedback 엔티티의 작성자 필드가 mentorId가 아닌 authorId임을 확인하고 수정했다.
java// 수정 전
verify(mentorFeedbackRepository, times(1)).save(any()); // 저장 여부만 확인

// 수정 후
ArgumentCaptor<MentorFeedback> captor = ArgumentCaptor.forClass(MentorFeedback.class);
verify(mentorFeedbackRepository, times(1)).save(captor.capture());
MentorFeedback saved = captor.getValue();

assertThat(saved.getMentorshipId()).isEqualTo(MENTORING_ID);   // 잘못된 mentoringId 방지
assertThat(saved.getAuthorId()).isEqualTo(MENTOR_ENTITY_ID);   // mentorId가 아닌 authorId
assertThat(saved.getContent()).isEqualTo("ERD 설계 및 API 명세 작성");

4. 느낀 점
오늘 하루 troubeshooting이 꽤 많았다. 공통된 원인을 하나 꼽자면 서비스 코드의 실행 흐름을 제대로 파악하지 않고 테스트를 작성했다는 것이다.
findById()와 findByUserId()의 차이처럼 메서드 이름 하나 차이로 34개 테스트가 전부 실패하는 상황이 발생했다. 테스트를 작성할 때는 서비스 코드의 메서드를 위에서 아래로 따라가면서 어떤 repository 메서드가 어떤 인자로 호출되는지 확인하고 mock을 설정해야 한다. 테스트가 많이 실패하면 에러 메시지가 전부 같은 경우가 있는데 이럴 때는 스택 트레이스를 보고 실제로 어느 줄에서 터지는지 확인하는 것이 빠르다.
UnnecessaryStubbingException을 통해 Mockito의 동작 원리를 더 잘 이해하게 됐다. stub이 실제로 호출되는지 안 되는지를 의식하면서 테스트를 작성하면 서비스 코드의 실행 분기를 더 명확하게 파악할 수 있다.
코드래빗이 ArgumentCaptor 활용을 제안한 것이 인상 깊었다. save(any())만 검증하는 것과 실제 저장 엔티티의 필드를 검증하는 것은 테스트의 신뢰도에서 큰 차이가 있다. 그리고 MentorFeedback의 필드가 mentorId가 아닌 authorId라는 것도 직접 엔티티 코드를 확인하면서 잡아낼 수 있었다. 테스트를 작성하면서 실제로 코드를 꼼꼼히 읽게 되는 효과도 있다는 걸 느꼈다.