3/3(화) AI 검증 비즈니스 프로젝트 - 테스트코드, 인증과 JPA Audit

dev_joo·2026년 3월 3일

AI 검증 비즈니스 프로젝트

PR 리뷰

오늘은 코드카타 대신 고봉밥 PR🍚 을 보며 오늘의 학습을 시작했다.

리뷰를 하면서 몰랐던 패턴들을 몇 가지 새롭게 알게 되었다.
(뭔가 팀원에 도움이 되기보단 배우기만 하게 되는 것 같아 너무 미안하다..😭)

DTO 정적팩토리 메서드 from() 방식 (Entity -> DTO)

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 안에 두면:

  • 엔티티 → DTO 변환 책임이 DTO 내부로 모인다.
  • 서비스 코드가 훨씬 깔끔해진다.
  • 필드가 추가되어도 수정 범위가 줄어든다.
  • 변환 방식이 일관되게 유지된다.

특히 리뷰 도메인처럼 응답 필드가 많은 경우,
👉 변환 로직을 한 곳에 모아두는 것이 유지보수에 훨씬 유리하다는 걸 느꼈다.

에러 메시지를 Enum으로 관리하기


@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으로 관리하면:

  • 리뷰 정책 자체가 코드로 정리된다.
  • HTTP 상태와 메시지가 함께 묶인다.
  • 예외 응답 구조가 통일된다.
  • 도메인 규칙이 더 명확해진다.

데일리 스크럼 (오전)

여러가지 의논사항을 논의하니 1 시간이 지났다. (아침 스크럼은 간단하게.. 10분...)
BaseEntity는 두개를 상속받는 대신 하나를 상속받도록 나중에 바꾸고,
에러 응답 형태는 팀장님이 작성해둔 에러 형태를 따르기로 했다.

Controller DTO와 Service DTO의 패키지를 분리할지는 기능 개발 이후 실제 DTO 상태를 보고, 장단점을 비교한 뒤 결정하기로 했다.

테스트 코드 작성

“어차피 코드도 내가 짜고 테스트도 내가 짜는게 무슨 의미가 있지?

처음 테스트 코드를 작성하면서 라는 의문이 들었다.

하지만 시간이 지나 코드를 수정하다 보면, 처음의 설계 의도를 잊은 채 변경하는 순간이 생길 수 있다.

테스트는 그럴 때 “아니야, 이 API는 원래 이런 동작을 하기로 했어.” 라고 알려주는 일종의 안전장치이다.

Spring의 테스트 종류

종류테스트 대상DB 사용속도대표 애너테이션
Controller 테스트HTTP 요청/응답X빠름@WebMvcTest
Service 테스트비즈니스 로직(Mock)빠름@ExtendWith(MockitoExtension.class)
통합 테스트전체 흐름O느림@SpringBootTest

이번 프로젝트에서는 HTTP 레벨의 요청/응답 검증이 목적이다.
즉, "API 명세가 제대로 지켜지는가?" 를 확인하는 테스트다.
그래서 @WebMvcTest를 사용한 Controller 테스트를 작성했다.

Global Exception Handler

@RestControllerAdvice
각 예외가 발생했을 때 컨트롤러 대신 응답을 처리한다.

  • 컨트롤러마다 try-catch를 작성하지 않아도 되고
  • 일관된 에러 응답 포맷을 유지할 수 있다.
@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"));
    }
}

ReviewApiTest


@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만 로딩한다.

  • Controller
  • ControllerAdvice
  • Filter
  • Jackson(JSON 직렬화/역직렬화)

즉, Service, Repository, DB는 아예 올라오지 않게 해서 HTTP 계층만 빠르게 검증하는 테스트다.

@WebMvcTest(ReviewController.class)

처럼 테스트 시 사용하는 컨트롤러를 명확히 지정해줄 수도 있다.

테스트에 필요한 Bean 주입하기

이번 프로젝트에서는 @RestControllerAdvice 기반의
Global Exception Handler를 사용하고 있다.

이 클래스는 컨트롤러에서 발생한 예외를 가로채
일관된 에러 응답 포맷으로 변환해주는 역할을 한다.

@RestControllerAdvice
public class GlobalExceptionHandler

ExceptionHandler를 명시적으로 포함시켜줘야 한다.

@Import({GlobalExceptionHandler.class, SecurityConfig.class})

즉, 에러 응답 포맷 자체도 API 스펙의 일부이기 때문에
이 부분까지 검증하고 있는 것이다.

추가로 실제 API는 Security Filter 체인을 거쳐 동작하기 때문에,
테스트 환경에서도 Security 설정을 포함시켜야 실제 요청 흐름과 유사한 환경을 만들 수 있다.

Spring Security가 활성화된 프로젝트에서는 기본적으로 FilterChainProxy가 등록된다.
따라서 테스트 환경에서도 보안 필터 구성이 일부 필요하다.

@MockitoBean 테스트에 필요한 가짜 Bean 주입하기

@WebMvcTest에서 등록해주지 않는 Bean을 테스트 코드에선 Mock으로 주입한다.

@MockitoBean
ReviewService reviewService;

이렇게 하면
"Service는 정상적으로 이 값을 반환한다고 가정하자."

그리고 우리는 오직 Controller가 그 값을 어떻게 HTTP 응답으로 변환하는지만 검증한다.(슬라이스 테스트)

우리 프로젝트는 JWT 기반 인증을 사용하고 있는데, Security Filter 체인에서 JwtUtil을 참조하기 때문에 해당 Bean이 없으면 ApplicationContext 로딩 자체가 실패한다.

@MockitoBean
JwtUtil jwtUtil;

JwtUtil 또한 @MockitoBean으로 Mock 처리해야 컨텍스트 로딩 실패를 방지할 수 있다. 이 부분은 팀원분이 친절하게 알려주셨다.🥰

Spring Security 기반 인증과 추적성(Traceability)

현재 각자 비즈니스 로직을 구현한 뒤, 기존에 만들어둔 인증을 붙이는 작업을 진행했다.
이 과정에서 튜터님께서 인증 필터를 통한 인증 절차를 다시 설명해주셨다.

인증은 보안을 넘어 추적성으로 이어진다

Spring Security를 사용하면,
클라이언트의 요청이 들어올 때 인증 필터를 거치게 되고,
인증이 완료된 사용자 정보는 SecurityContext에 저장된다.

이 컨텍스트에 저장된 정보는 요청이 처리되는 동안 언제든지 참조할 수 있다.
그리고 이 인증 정보는 JPA Auditing 기능과 연결해 사용자 이력 관리에 활용할 수 있다.

JPA Audit은 시간만 기록하는 기능이 아니었다

나는 그동안 JPA Audit을
createdAt, updatedAt 같은 시간 자동 기록 기능 정도로만 생각했다.

하지만 튜터님의 피드백을 통해,
Audit은 시간뿐 아니라 사용자 정보까지 자동으로 저장할 수 있다는 사실을 처음 알게 되었다.

Spring Data JPA의 Auditing 기능은
AuditorAware 인터페이스를 통해 현재 인증된 사용자 정보를 가져올 수 있다.

Authentication Filter → SecurityContext(Holder) → AuditorAware → Entity

Audit로 채울 수 있는 값의 종류

JPA Auditing에서 기본적으로 사용할 수 있는 어노테이션은 다음과 같다.
(@EnableJpaAuditing 설정 이후)

1️⃣ 시간 관련

@CreatedDate
@LastModifiedDate

2️⃣ 사용자 관련

@CreatedBy
@LastModifiedBy

여기서 저장되는 값은 AuditorAware<T>의 제네릭 타입에 따라 달라지기 때문에
단순 문자열이 아니라 식별자나 엔티티 자체도 가능하다.

AuditorAware<String> → username 저장
AuditorAware<Long> → userId 저장
AuditorAware<User> → User 엔티티 자체 저장

팀원들에게 코드 리뷰 받기

merge (origin) main 주의하기


전체 CRUD 로직을 작성하기 전 팀 리뷰를 받기 위해 풀 리퀘스트를 올렸는데 원격 저장소에서 merge를 안하고 로컬 브랜치에서 merge를 했다는 걸 뒤늦게 알았다🥲

그리고 한 번 생성된 PR은 삭제할 수 없다. Close 처리만 가능하며, 기록은 그대로 남는다.
덕분에(?) 중간 피드백을 받기 위해 PR을 올린다는 나의 실수도 영구 보존되었다. 😭

profile
풀스택 연습생. 끈기있는 삽질로 무대에서 화려하게 데뷔할 예정 ❤️🔥

0개의 댓글