spring_2기[본캠프]/과제

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

minwoo95 2026. 4. 15. 17:28

1. 기술 스택 선택 이유
Spring Boot 3.3.5

빠른 개발 환경 구성: Auto-Configuration과 Starter 의존성으로 설정 코드 최소화
Spring Security 연동: JWT 기반 인증/인가를 자연스럽게 통합 가능
JPA/QueryDSL 지원: ORM 기반 데이터 접근 계층 구현 용이
Jakarta EE 지원: javax → jakarta 마이그레이션 완료로 최신 표준 준수

Spring Security + JWT
세션 대신 JWT를 선택한 이유

무상태(Stateless) 아키텍처: 서버가 세션을 유지하지 않아 수평 확장에 유리
3단계 토큰 구조: AccessToken(단기) + RefreshToken(장기) + TempToken(임시 회원가입용)으로 보안 강화
Redis 블랙리스트: 로그아웃 시 토큰을 Redis에 등록해 즉시 무효화

QueryDSL
단순 CRUD는 Spring Data JPA, 복잡한 조건 조회는 QueryDSL

타입 안전한 쿼리: 문자열 기반 JPQL의 오타 위험 없이 컴파일 타임에 오류 감지
동적 쿼리: BooleanExpression으로 유연한 필터링 구현
팀 컨벤션: Entity 간 직접 연관관계 금지 → PK 참조 + QueryDSL Join 방식 채택

Redis

RefreshToken 저장: 사용자별 RefreshToken 저장으로 Silent Refresh 구현
블랙리스트 관리: 로그아웃된 AccessToken의 남은 유효시간 동안 블랙리스트 유지
빠른 읽기/쓰기: 인메모리 DB로 토큰 검증 속도 최적화

테스트 전략 — 슬라이스 테스트 선택

@WebMvcTest: Controller 레이어만 로드 → 빠른 실행 속도, 의존성 Mock 처리로 격리된 테스트
@ExtendWith(MockitoExtension): Service 단위 테스트 → 비즈니스 로직만 검증
통합테스트(@SpringBootTest)는 실제 DB 연동이 필요한 E2E 시나리오에 한정 사용 예정


2.트러블슈팅 기록

이슈 1. @WebMvcTest에서 JPA Auditing 충돌
상황
@WebMvcTest 실행 시 JpaMetamodelMappingContext 빈을 찾을 수 없다는 에러 발생
에러
Error creating bean with name 'jpaMetamodelMappingContext'
— expected at least 1 bean
원인
@WebMvcTest는 Web MVC 관련 빈만 로드하는 슬라이스 테스트입니다. @EnableJpaAuditing이 메인 클래스에 선언되어 있으면, @WebMvcTest 컨텍스트에는 JPA 관련 빈이 없어 충돌이 발생합니다.
해결
메인 클래스는 팀 공유 코드이므로 수정하지 않고, 테스트 클래스에서 @MockBean으로 모킹했습니다.
java@MockBean
private org.springframework.data.jpa.mapping.JpaMetamodelMappingContext jpaMappingContext;

💡 팀 공통 설정 파일은 개인이 임의로 수정하지 않고, 테스트 레벨에서 해결하는 것이 협업에 더 적합합니다.


이슈 2. @WebMvcTest + Spring Security 401/403
상황
JwtFilter, JwtUtil 등 Security 빈이 없어 컨텍스트 로드 실패 또는 401/403 응답
에러
No qualifying bean of type 'JwtUtil' available
— UnsatisfiedDependencyException
원인
@WebMvcTest는 SecurityAutoConfiguration을 로드하면서 JwtFilter 빈 생성을 시도합니다. JwtFilter는 JwtUtil, RedisUtil, UserDetailsService, UserCacheService를 주입받는데 이 빈들이 컨텍스트에 없어 실패합니다.
해결
java// 1. Security 필터 비활성화
@AutoConfigureMockMvc(addFilters = false)

// 2. Security 관련 빈 MockBean 등록
@MockBean private JwtUtil jwtUtil;
@MockBean private RedisUtil redisUtil;
@MockBean private UserDetailsService userDetailsService;
@MockBean private UserCacheService userCacheService;

💡 addFilters = false는 필터 실행을 막지만 빈 생성은 막지 않습니다. Security 관련 의존성 빈은 별도로 @MockBean 처리가 필요합니다.


이슈 3. anyInt() vs any() — primitive 파라미터 Mockito 오류
상황
getMonthlyStatistics(Long, int, int) 스터빙 시 NullPointerException 발생
에러
Cannot invoke 'Integer.intValue()' because the return value of 'any()' is null
원인
Mockito의 any()는 Object 타입을 반환합니다. int(primitive)는 Object가 아니므로 언박싱 시 null이 되어 NPE가 발생합니다.
해결
java// ❌ 잘못된 방법
given(service.getMonthlyStatistics(any(), any(), any()))

// ✅ 올바른 방법
given(service.getMonthlyStatistics(any(), anyInt(), anyInt()))

💡 primitive 타입 파라미터에는 타입에 맞는 matcher를 사용해야 합니다: anyInt(), anyLong(), anyBoolean() 등


이슈 4. MissingServletRequestParameterException → 500
상황
필수 @RequestParam 누락 시 400이 아닌 500이 반환됨
원인
GlobalExceptionHandler에 MissingServletRequestParameterException 핸들러가 없어 기본 500으로 처리됨
해결
java@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<BaseResponse<Void>> handleMissingParam(
        MissingServletRequestParameterException e) {
    return ResponseEntity.status(HttpStatus.BAD_REQUEST)
            .body(BaseResponse.fail(HttpStatus.BAD_REQUEST.name(), "필수 파라미터가 누락되었습니다"));
}

이슈 5. @AuthenticationPrincipal이 null로 주입됨
상황
컨트롤러 테스트에서 userDetails가 null이어서 NPE 발생
에러
Cannot invoke 'UserDetailsImpl.getUser()' because 'userDetails' is null
원인
@AuthenticationPrincipal은 SecurityContextHolder에서 인증 정보를 꺼냅니다. 테스트 환경에서 addFilters=false로 Security 필터를 비활성화하면 SecurityContext에 인증 정보가 없어 null이 주입됩니다.
해결
@BeforeEach에서 SecurityContextHolder에 Mock 인증 정보를 직접 주입했습니다.
java@BeforeEach
void setUp() {
    User mockUser = mock(User.class);
    given(mockUser.getId()).willReturn(1L);
    given(userDetails.getUser()).willReturn(mockUser);

    UsernamePasswordAuthenticationToken auth =
            new UsernamePasswordAuthenticationToken(userDetails, null, List.of());
    SecurityContextHolder.getContext().setAuthentication(auth);
}

이슈 6. @Secured 롤 매핑 불일치 — 403 Forbidden
상황
로그인 성공 후 캘린더 API 호출 시 403 Forbidden 반환
원인

@Secured({"READER", "AUTHOR"}) → 내부적으로 ROLE_READER를 찾음
hasAnyRole("READER", "AUTHOR") → 마찬가지로 ROLE_READER를 찾음
실제 권한: UserDetailsImpl.getAuthorities()가 반환하는 값은 "READER" (ROLE_ 없음)

세 조건이 맞지 않아 403이 발생했습니다.
해결
java// SecurityConfig 수정
// ❌ .requestMatchers("/api/calendars/**").hasAnyRole("READER", "AUTHOR")
// ✅ .requestMatchers("/api/calendars/**").hasAnyAuthority("READER", "AUTHOR")

// Controller에서 @Secured 어노테이션 제거

💡 hasAnyRole()은 ROLE_ 접두사를 자동으로 붙입니다. SimpleGrantedAuthority에 ROLE_가 없으면 매핑 실패합니다. hasAnyAuthority()는 값 그대로 비교하므로 명시적이고 안전합니다.


이슈 7. Postman JWT 토큰 서명 불일치
상황
로그인 후 발급받은 토큰으로 API 호출 시 JWT 서명 불일치 에러
에러
JWT signature does not match locally computed signature.
JWT validity cannot be asserted and should not be trusted.
원인
Postman Scripts(Post-response)가 로그인 API가 아닌 다른 API에 잘못 설정되어 있었습니다. 잘못된 응답에서 토큰을 추출하거나 빈 값이 저장되었습니다.
해결
로그인 API(POST /api/auth/login)의 Scripts → Post-response에 토큰 저장 스크립트를 올바르게 배치했습니다.
javascriptconst token = phttp://m.response.json().data.accessToken;
phttp://m.collectionVariables.set("authToken", token.replace("Bearer ", ""));

3.전체 회고
이번 Calendar 도메인 개발을 통해 Spring Boot 테스트 환경의 복잡성을 깊이 이해할 수 있었습니다.

@WebMvcTest는 빠르지만 Security/JPA 설정과의 충돌을 주의해야 합니다
Mockito의 타입별 matcher 구분(any vs anyInt)은 실수하기 쉬운 포인트입니다
hasAnyRole vs hasAnyAuthority 차이는 Spring Security 사용 시 반드시 알아야 할 핵심 개념입니다
팀 공통 코드는 개인이 임의로 수정하지 않고 테스트 레벨에서 해결하는 것이 협업 원칙에 맞습니다