
오늘은 코드카타 대신 고봉밥 PR🍚 을 보며 오늘의 학습을 시작했다.
리뷰를 하면서 몰랐던 패턴들을 몇 가지 새롭게 알게 되었다.
(뭔가 팀원에 도움이 되기보단 배우기만 하게 되는 것 같아 너무 미안하다..😭)
public record ReviewResponse(
UUID reviewId,
double rating,
String comment,
// 필드 생략
) {
public static ReviewResponse from(Review review) {
return new ReviewResponse(
review.getId(),
review.getRating(),
review.getComment(),
);
}
}
이전에는 서비스 계층에서 이런 식으로 직접 DTO를 생성했다.
new ReviewResponse(...);
하지만 from() 같은 정적 팩토리 메서드를 DTO 안에 두면:
특히 리뷰 도메인처럼 응답 필드가 많은 경우,
👉 변환 로직을 한 곳에 모아두는 것이 유지보수에 훨씬 유리하다는 걸 느꼈다.
@Getter
@AllArgsConstructor
public enum ErrorCode {
// 리뷰 정책 관련
RATING_POLICY_VIOLATION(HttpStatus.BAD_REQUEST, "별점 정책 위반"),
REVIEW_PERIOD_EXPIRED(HttpStatus.BAD_REQUEST, "리뷰 작성 가능 기간 초과"),
REVIEW_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 리뷰가 존재합니다."),
// 주문 연관 검증
ORDER_NOT_FOUND(HttpStatus.NOT_FOUND, "주문이 존재하지 않습니다."),
ORDER_NOT_COMPLETED(HttpStatus.BAD_REQUEST, "주문 완료 후에만 리뷰 작성 가능");
private final HttpStatus status;
private final String description;
}
Enum으로 관리하면:
여러가지 의논사항을 논의하니 1 시간이 지났다. (아침 스크럼은 간단하게.. 10분...)
BaseEntity는 두개를 상속받는 대신 하나를 상속받도록 나중에 바꾸고,
에러 응답 형태는 팀장님이 작성해둔 에러 형태를 따르기로 했다.
Controller DTO와 Service DTO의 패키지를 분리할지는 기능 개발 이후 실제 DTO 상태를 보고, 장단점을 비교한 뒤 결정하기로 했다.
처음 테스트 코드를 작성하면서 라는 의문이 들었다.
하지만 시간이 지나 코드를 수정하다 보면, 처음의 설계 의도를 잊은 채 변경하는 순간이 생길 수 있다.
테스트는 그럴 때
“아니야, 이 API는 원래 이런 동작을 하기로 했어.”라고 알려주는 일종의 안전장치이다.
| 종류 | 테스트 대상 | DB 사용 | 속도 | 대표 애너테이션 |
|---|---|---|---|---|
| Controller 테스트 | HTTP 요청/응답 | X | 빠름 | @WebMvcTest |
| Service 테스트 | 비즈니스 로직 | (Mock) | 빠름 | @ExtendWith(MockitoExtension.class) |
| 통합 테스트 | 전체 흐름 | O | 느림 | @SpringBootTest |
이번 프로젝트에서는 HTTP 레벨의 요청/응답 검증이 목적이다.
즉, "API 명세가 제대로 지켜지는가?" 를 확인하는 테스트다.
그래서 @WebMvcTest를 사용한 Controller 테스트를 작성했다.
@RestControllerAdvice
각 예외가 발생했을 때 컨트롤러 대신 응답을 처리한다.
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(ApiException.class)
public ResponseEntity<ErrorResponse> handleApiException(ApiException e) {
ErrorCode errorCode = e.getErrorCode();
log.error("{} is occurred.", errorCode);
return ResponseEntity
.status(errorCode.getStatus())
.body(ErrorResponse.of(errorCode.getDescription()));
}
// 400: 요청 파라미터 또는 도메인 값 오류
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ErrorResponse> handleIllegalArgument(IllegalArgumentException e) {
log.error("IllegalArgumentException is occurred.", e);
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ErrorResponse.of("BAD_REQUEST", e.getMessage()));
}
//409: 중복/상태 충돌
@ExceptionHandler(IllegalStateException.class)
public ResponseEntity<ErrorResponse> handleIllegalState(IllegalStateException e) {
log.error("IllegalStateException is occurred", e);
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body(ErrorResponse.of("CONFLICT", e.getMessage()));
}
//502: 외부 API 장애/연동 실패
@ExceptionHandler(KakaoApiException.class)
public ResponseEntity<ErrorResponse> handleKakaoApiException(KakaoApiException e) {
log.error("{} is occurred", e.getMessage());
return ResponseEntity
.status(HttpStatus.BAD_GATEWAY)
.body(ErrorResponse.of("KAKAO_API_ERROR", e.getMessage()));
}
//400: Validation 실패 (@Valid)
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException e) {
Map<String, String> fieldErrors = new LinkedHashMap<>();
log.error("DataIntegrityViolationException is occurred.", e);
for (FieldError fe : e.getBindingResult().getFieldErrors()) {
fieldErrors.putIfAbsent(fe.getField(), fe.getDefaultMessage());
}
ErrorResponse data = ErrorResponse.of("VALIDATION_ERROR", fieldErrors);
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(data);
}
//그 외: 500. 운영에서 내부 메시지 노출을 피하기 위해 message는 고정.
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(Exception e) {
log.error("Exception is occurred.", e);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ErrorResponse.of("INTERNAL_ERROR", "internal server error"));
}
}
@WebMvcTest(ReviewController.class)
@Import({GlobalExceptionHandler.class, SecurityConfig.class})
class ReviewControllerTest {
@Autowired
MockMvc mockMvc;
@Autowired
ObjectMapper objectMapper; // Java 객체 ↔ JSON 변환
@MockitoBean
ReviewService reviewService;
@MockitoBean
JwtUtil jwtUtil;
@Test
@DisplayName("리뷰 생성 성공 시 201 반환")
void createReview_success() throws Exception {
// given
UUID reviewId = UUID.randomUUID();
UUID orderId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
ReviewCreateRequest request =
new ReviewCreateRequest(orderId, 5, "맛있어요!");
ReviewResponse response =
new ReviewResponse(
reviewId,
orderId,
userId,
5,
"맛있어요!",
LocalDateTime.now(),
LocalDateTime.now()
);
given(reviewService.createReview(any(), any()))
.willReturn(response);
// when & then
mockMvc.perform(post("/api/v1/reviews")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andDo(print())
.andExpect(status().isCreated())
.andExpect(header().exists("Location"))
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.reviewId").value(reviewId.toString()))
.andExpect(jsonPath("$.orderId").value(orderId.toString()))
.andExpect(jsonPath("$.userId").value(userId.toString()))
.andExpect(jsonPath("$.rating").value(5))
.andExpect(jsonPath("$.comment").value("맛있어요!"))
.andExpect(jsonPath("$.createdAt").exists())
.andExpect(jsonPath("$.updatedAt").exists());
}
@Test
@DisplayName("요청 값 검증 실패 시 400 반환")
void createReview_validationFail() throws Exception {
// rating이 @Min, @Max 등으로 검증된다
String invalidJson = """
{
"orderId": null,
"rating": 10,
"comment": ""
}
""";
mockMvc.perform(post("/api/v1/reviews")
.contentType(MediaType.APPLICATION_JSON)
.content(invalidJson))
.andDo(print())
.andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.error").value("VALIDATION_ERROR"));
}
@Test
@DisplayName("비즈니스 예외 발생 시 409 반환")
void createReview_conflict() throws Exception {
UUID orderId = UUID.randomUUID();
ReviewCreateRequest request =
new ReviewCreateRequest(orderId, 5, "맛있어요!");
given(reviewService.createReview(any(), any()))
.willThrow(new IllegalStateException("이미 해당 주문 건으로 작성된 리뷰가 있습니다."));
mockMvc.perform(post("/api/v1/reviews")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andDo(print())
.andExpect(status().isConflict())
.andExpect(jsonPath("$.error").value("CONFLICT"));
}
}
@WebMvcTest는 Controller 계층을 대상으로 하는 슬라이스 테스트이며, MVC 테스트에 필요한 Bean만 로딩한다.
즉, Service, Repository, DB는 아예 올라오지 않게 해서 HTTP 계층만 빠르게 검증하는 테스트다.
@WebMvcTest(ReviewController.class)
처럼 테스트 시 사용하는 컨트롤러를 명확히 지정해줄 수도 있다.
이번 프로젝트에서는 @RestControllerAdvice 기반의
Global Exception Handler를 사용하고 있다.
이 클래스는 컨트롤러에서 발생한 예외를 가로채
일관된 에러 응답 포맷으로 변환해주는 역할을 한다.
@RestControllerAdvice
public class GlobalExceptionHandler
ExceptionHandler를 명시적으로 포함시켜줘야 한다.
@Import({GlobalExceptionHandler.class, SecurityConfig.class})
즉, 에러 응답 포맷 자체도 API 스펙의 일부이기 때문에
이 부분까지 검증하고 있는 것이다.
추가로 실제 API는 Security Filter 체인을 거쳐 동작하기 때문에,
테스트 환경에서도 Security 설정을 포함시켜야 실제 요청 흐름과 유사한 환경을 만들 수 있다.
Spring Security가 활성화된 프로젝트에서는 기본적으로 FilterChainProxy가 등록된다.
따라서 테스트 환경에서도 보안 필터 구성이 일부 필요하다.
@WebMvcTest에서 등록해주지 않는 Bean을 테스트 코드에선 Mock으로 주입한다.
@MockitoBean
ReviewService reviewService;
이렇게 하면
"Service는 정상적으로 이 값을 반환한다고 가정하자."
그리고 우리는 오직 Controller가 그 값을 어떻게 HTTP 응답으로 변환하는지만 검증한다.(슬라이스 테스트)
우리 프로젝트는 JWT 기반 인증을 사용하고 있는데, Security Filter 체인에서 JwtUtil을 참조하기 때문에 해당 Bean이 없으면 ApplicationContext 로딩 자체가 실패한다.

@MockitoBean
JwtUtil jwtUtil;
JwtUtil 또한 @MockitoBean으로 Mock 처리해야 컨텍스트 로딩 실패를 방지할 수 있다. 이 부분은 팀원분이 친절하게 알려주셨다.🥰
현재 각자 비즈니스 로직을 구현한 뒤, 기존에 만들어둔 인증을 붙이는 작업을 진행했다.
이 과정에서 튜터님께서 인증 필터를 통한 인증 절차를 다시 설명해주셨다.
Spring Security를 사용하면,
클라이언트의 요청이 들어올 때 인증 필터를 거치게 되고,
인증이 완료된 사용자 정보는 SecurityContext에 저장된다.
이 컨텍스트에 저장된 정보는 요청이 처리되는 동안 언제든지 참조할 수 있다.
그리고 이 인증 정보는 JPA Auditing 기능과 연결해 사용자 이력 관리에 활용할 수 있다.
나는 그동안 JPA Audit을
createdAt, updatedAt 같은 시간 자동 기록 기능 정도로만 생각했다.
하지만 튜터님의 피드백을 통해,
Audit은 시간뿐 아니라 사용자 정보까지 자동으로 저장할 수 있다는 사실을 처음 알게 되었다.
Spring Data JPA의 Auditing 기능은
AuditorAware 인터페이스를 통해 현재 인증된 사용자 정보를 가져올 수 있다.
Authentication Filter → SecurityContext(Holder) → AuditorAware → Entity
JPA Auditing에서 기본적으로 사용할 수 있는 어노테이션은 다음과 같다.
(@EnableJpaAuditing 설정 이후)
@CreatedDate
@LastModifiedDate
@CreatedBy
@LastModifiedBy
여기서 저장되는 값은 AuditorAware<T>의 제네릭 타입에 따라 달라지기 때문에
단순 문자열이 아니라 식별자나 엔티티 자체도 가능하다.
AuditorAware<String> → username 저장
AuditorAware<Long> → userId 저장
AuditorAware<User> → User 엔티티 자체 저장

전체 CRUD 로직을 작성하기 전 팀 리뷰를 받기 위해 풀 리퀘스트를 올렸는데 원격 저장소에서 merge를 안하고 로컬 브랜치에서 merge를 했다는 걸 뒤늦게 알았다🥲
그리고 한 번 생성된 PR은 삭제할 수 없다. Close 처리만 가능하며, 기록은 그대로 남는다.
덕분에(?) 중간 피드백을 받기 위해 PR을 올린다는 나의 실수도 영구 보존되었다. 😭