Spring Data JPA — 검증과 매핑까지 한 번에

2026-05-02AWS SAA-C03 스터디

Spring Boot 3 핵심 정리 시리즈 3편. Spring Data JPA 의 핵심을 처음 보는 사람도 따라올 수 있게 풀어 갑니다 — JPA 엔티티 설계와 기본 키 전략 5종, JpaRepository 가 어떻게 인터페이스만으로 구현체를 만들어 주는지, 파생 쿼리·@Query·Specification, MapStruct 로 엔티티-DTO 매핑, @Valid·@NotBlank 등 Bean Validation 어노테이션, 중첩 객체 검증까지. 회사 자료실 비유로 풀어쓴 친절한 3편.

📚 Spring Boot 3 핵심 정리 · 3편 / 14편 — 검증과 매핑까지 한 번에

이 글은 Spring Boot 3 핵심 정리 시리즈의 세 번째 편입니다. 1편에서 IoC·DI 비유를, 2편에서 Spring MVC REST 컨트롤러 흐름을 잡았다면, 3편에서는 그 뒤편의 JPASpring Data JPA 를 풀어 가요. 데이터 계층이라고 불리는 영역이에요.

JPA(Java Persistence API)는 자바 객체를 데이터베이스 테이블에 매핑하는 표준 API 이고, Spring Data JPA 는 그 위에 얹혀서 Repository 인터페이스만 정의하면 구현체를 자동으로 만들어 주는 마법 같은 도구예요. JpaRepository 한 줄을 박으면 save()·findById()·findAll()·deleteById() 같은 메서드가 자동으로 생기고, 메서드 이름만으로 SQL 쿼리가 만들어지기까지 해요.

이 글의 비유는 — JPA = 회사 자료실 관리 시스템, Repository = 자료실 사서, Entity = 자료실에 보관되는 표준 문서 양식, DTO = 외부에 들고 나가는 요약 보고서예요. 이 비유 하나만 잡고 가면 어노테이션 30 개가 한 번에 정리됩니다.

왜 JPA 가 처음엔 어렵게 느껴질까요

이유는 네 가지예요.

첫째, 추상화가 두 단계 동시에 올라가요. 자바 → JDBC → JPA → Hibernate → Spring Data JPA 순으로 층이 쌓여 있어서 "내가 지금 어느 층 코드를 만지는 거지?" 가 헷갈려요.

둘째, JPA 엔티티 어노테이션이 줄지어 등장해요. @Entity·@Table·@Id·@GeneratedValue·@Column·@OneToMany·@ManyToOne·@Version·@CreationTimestamp … 한 클래스에 다 박혀 있으면 머리가 어지러워집니다.

셋째, JPA 의 영속성 컨텍스트가 보이지 않아요. "내가 setter 만 호출했는데 왜 UPDATE 쿼리가 나가지?" 같은 마법 같은 동작이 디버깅을 어렵게 만들어요.

넷째, 검증(Validation) 어노테이션이 또 한 묶음 따라옵니다. @NotNull·@NotEmpty·@NotBlank 가 다 비슷해 보이고, @Valid@Validated 도 헷갈려요.

해결법은 한 가지예요. JPA 를 회사 자료실 관리 시스템으로 잡고, Repository 가 사서, Entity 가 표준 문서 양식이라는 비유를 따라가면 갑자기 명확해집니다. 이 글은 그 비유를 따라 처음부터 풀어 갑니다.

JPA 엔티티 — 자료실의 표준 문서 양식

JPA 엔티티는 데이터베이스 테이블과 매핑되는 자바 클래스예요. @Entity 어노테이션 한 줄로 "이 클래스는 자료실에 보관될 표준 양식입니다" 라고 선언하면 끝이에요.

회사 비유로 — 엔티티는 인사 카드·계약서·재고 카드 같은 표준 문서 양식이에요. 양식 자체에는 이름·생년월일·소속 같은 칸이 있고, 자료실(데이터베이스)은 이 양식대로 채워진 문서들을 정리해서 보관해요.

엔티티 설계 시 꼭 지켜야 할 두 가지 원칙:

  1. JPA 스펙상 기본 생성자(@NoArgsConstructor) 필수 — JPA 가 리플렉션으로 객체를 만들 때 인자 없는 생성자가 있어야 해요.
  2. equals() / hashCode() 신중하게 — 기본 키 기반으로 구현하되 영속화 전(id 가 null) 상태에서도 안전해야 해요.

전형적인 엔티티 한 벌:

@Getter
@Setter
@NoArgsConstructor  // JPA 스펙 요구사항
@AllArgsConstructor
@Builder
@Entity
@Table(name = "product")  // 테이블 이름 명시
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    @UuidGenerator
    @Column(length = 36, columnDefinition = "varchar(36)", updatable = false, nullable = false)
    private UUID id;

    @Version  // 낙관적 락(Optimistic Lock) 을 위한 버전 필드
    private Integer version;

    @NotNull
    @NotBlank
    @Column(nullable = false)
    private String productName;

    @NotNull
    @Enumerated(EnumType.STRING)  // Enum 을 문자열로 저장 (기본값은 ORDINAL — 위험)
    @Column(nullable = false)
    private String category;

    @NotNull
    @Column(nullable = false)
    private String upc;

    private Integer quantityOnHand;

    @NotNull
    @Column(nullable = false)
    private BigDecimal price;

    // 생성일/수정일 자동 관리
    @CreationTimestamp  // Hibernate 가 INSERT 시 자동 설정
    private LocalDateTime createdDate;

    @UpdateTimestamp    // Hibernate 가 UPDATE 시 자동 설정
    private LocalDateTime updateDate;
}

기본 키 생성 전략 5 종을 정리하면:

전략설명적합한 경우
AUTOJPA 구현체가 결정간단한 경우
IDENTITYDB 자동 증가(AUTO_INCREMENT)MySQL 등 자동 증가 지원 DB
SEQUENCEDB 시퀀스 사용Oracle·PostgreSQL
TABLE별도 테이블로 시퀀스 관리DB 독립적이지만 성능 낮음
UUIDUUID 자동 생성분산 환경·보안 중요 환경

여기서 시험 함정이 하나 있어요. MySQL 에서 GenerationType.SEQUENCE 는 안 됩니다. MySQL 은 시퀀스를 네이티브로 지원하지 않아요. MySQL 에서는 IDENTITY(AUTO_INCREMENT) 또는 UUID 를 써야 해요.

또 하나의 함정 — @Enumerated(EnumType.STRING) 안 쓰면 큰일 납니다. 기본값은 ORDINAL 인데, 이건 enum 의 순서 번호로 저장해요. 나중에 enum 사이에 새 값을 끼워 넣으면 기존 데이터가 다 어긋나요. enum 은 무조건 STRING 으로 저장하는 게 안전해요.

> 한 줄 정리 — JPA 엔티티 = 표준 문서 양식. @NoArgsConstructor 필수, EnumType.STRING 무조건, MySQL 은 IDENTITY 또는 UUID.

JpaRepository — 자료실 사서

Spring Data JPA 의 가장 빛나는 부분이 Repository 인터페이스 자동 구현이에요. JpaRepository 인터페이스 하나만 정의하면, Spring 이 런타임에 그 구현체를 자동으로 만들어서 빈으로 등록해요.

회사 비유로 — Repository 는 자료실 사서예요. "이런 양식의 문서를 저장해 줘"·"이 ID 의 문서를 가져와 줘"·"전체 목록 보여 줘" 같은 요청을 받아서 처리해요. 우리는 사서에게 일을 시키기만 하면 되고, 사서가 어떻게 자료실을 뒤지는지(SQL 쿼리)는 신경 쓸 필요 없어요.

JPA Repository 계층을 한 번 정리하면:

Repository (마커 인터페이스)
    └── CrudRepository<T, ID>                  // 기본 CRUD
            └── PagingAndSortingRepository<T, ID>  // + 페이징·정렬
                    └── JpaRepository<T, ID>       // + JPA 특화(flush·deleteInBatch)

대부분 JpaRepository 를 쓰면 돼요.

@Repository  // 선택사항 — Spring Data 가 자동으로 빈 등록
public interface ProductRepository extends JpaRepository<Product, UUID> {

    // JpaRepository가 기본 CRUD 메서드 제공:
    // save(entity), saveAll(entities)
    // findById(id), findAll(), findAllById(ids)
    // existsById(id), count()
    // deleteById(id), delete(entity), deleteAll()

    // ─── 파생 쿼리 (메서드 이름으로 자동 쿼리 생성) ───
    List<Product> findByProductName(String productName);
    List<Product> findByCategory(String category);
    List<Product> findByProductNameAndCategory(String productName, String category);

    // LIKE 검색
    List<Product> findByProductNameLike(String productName);
    List<Product> findByProductNameContaining(String productName);  // %productName%

    // 정렬
    List<Product> findAllByOrderByProductNameAsc();

    // 존재 여부 확인
    boolean existsByProductName(String productName);

    // ─── @Query (JPQL) ───
    @Query("SELECT p FROM Product p WHERE p.price BETWEEN :minPrice AND :maxPrice")
    List<Product> findByPriceRange(@Param("minPrice") BigDecimal minPrice,
                                   @Param("maxPrice") BigDecimal maxPrice);

    // ─── 네이티브 SQL ───
    @Query(value = "SELECT * FROM product WHERE quantity_on_hand > :quantity",
           nativeQuery = true)
    List<Product> findByQuantityGreaterThan(@Param("quantity") int quantity);

    // ─── Pageable 지원 ───
    Page<Product> findByCategory(String category, Pageable pageable);
    Page<Product> findByProductNameIsNotNull(Pageable pageable);
}

파생 쿼리(Derived Query) 는 메서드 이름의 키워드를 보고 SQL 을 자동 생성해요. findBy + 필드명 + 조건 키워드(Like·Containing·Between·OrderBy) 패턴이에요. 처음에는 마법 같지만, 한 번 익히면 강력합니다.

여기서 시험 함정이 하나 있어요. 파생 쿼리는 단순한 조건엔 직관적이지만 5~6 개 조건이 조합되면 메서드 이름이 폭발합니다. 그럴 때는 @Query 로 JPQL 을 직접 쓰거나 Specification 패턴(다음 편)으로 가는 게 정답이에요.

방법예시장점단점
파생 쿼리findByProductName(String name)코드 없음, 직관적복잡한 쿼리 어려움
@Query (JPQL)@Query("SELECT p FROM Product p WHERE ...")강력하고 유연문자열 관리
@Query (Native)@Query(value="SELECT * FROM product ...", nativeQuery=true)SQL 직접 사용DB 종속
Specificationrepository.findAll(spec)동적 쿼리 조합복잡한 설정
QueryDSLqueryFactory.select(...)타입 안전 동적 쿼리별도 설정 필요

> 한 줄 정리 — Spring Data JPA = 인터페이스만 정의하면 사서가 자동으로 채용됨. 파생 쿼리는 단순할 때, 복잡해지면 @Query 또는 Specification.

서비스 계층에서 JPA 연동 — 엔티티-DTO 분리

컨트롤러에서 JPA 엔티티를 직접 반환하면 안 돼요. 두 가지 이유가 있어요.

  1. 불필요한 정보 노출 위험 — 엔티티에는 내부 필드(createdDate·version 같은)가 다 들어 있어서 API 응답으로 그대로 돌리면 정보가 새 나가요.
  2. 순환 참조와 N+1 쿼리 — 양방향 관계가 있는 엔티티를 직접 직렬화하면 무한 루프나 N+1 쿼리가 터져요.

해결법은 — 엔티티는 자료실 안에서만, 외부에는 DTO 만 들고 나가기예요. DTO(Data Transfer Object)는 API 응답·요청 전용 가벼운 객체예요. 회사 비유로 DTO 는 자료실의 원본 문서를 외부 회의용으로 요약한 보고서예요.

전형적인 서비스 계층 한 벌:

@Slf4j
@Service
@Primary
@RequiredArgsConstructor
public class ProductServiceJPA implements ProductService {

    private final ProductRepository productRepository;
    private final ProductMapper productMapper;

    @Override
    public List<ProductDTO> listProducts() {
        return productRepository.findAll()
                .stream()
                .map(productMapper::productToProductDto)
                .collect(Collectors.toList());
    }

    @Override
    public Optional<ProductDTO> getProductById(UUID id) {
        return productRepository.findById(id)
                .map(productMapper::productToProductDto);
    }

    @Override
    public ProductDTO saveNewProduct(ProductDTO productDTO) {
        return productMapper.productToProductDto(
                productRepository.save(productMapper.productDtoToProduct(productDTO))
        );
    }

    @Override
    public Optional<ProductDTO> updateProductById(UUID productId, ProductDTO productDTO) {
        AtomicReference<Optional<ProductDTO>> atomicReference = new AtomicReference<>();

        productRepository.findById(productId).ifPresentOrElse(
                foundProduct -> {
                    foundProduct.setProductName(productDTO.getProductName());
                    foundProduct.setCategory(productDTO.getCategory());
                    foundProduct.setUpc(productDTO.getUpc());
                    foundProduct.setPrice(productDTO.getPrice());
                    foundProduct.setQuantityOnHand(productDTO.getQuantityOnHand());
                    atomicReference.set(Optional.of(
                            productMapper.productToProductDto(productRepository.save(foundProduct))
                    ));
                },
                () -> atomicReference.set(Optional.empty())
        );

        return atomicReference.get();
    }

    @Override
    public Boolean deleteById(UUID productId) {
        if (productRepository.existsById(productId)) {
            productRepository.deleteById(productId);
            return true;
        }
        return false;
    }
}

MapStruct — 매핑 자동화

엔티티 ↔ DTO 변환 코드를 손으로 짜면 매번 setter 호출이 줄지어 나와요. MapStruct 는 컴파일 타임에 매핑 코드를 자동 생성하는 라이브러리예요. 리플렉션을 안 써서 ModelMapper 같은 런타임 매핑 라이브러리보다 성능이 훨씬 좋아요.

@Mapper
public interface ProductMapper {
    // Product 엔티티 → ProductDTO 변환
    ProductDTO productToProductDto(Product product);

    // ProductDTO → Product 엔티티 변환
    Product productDtoToProduct(ProductDTO dto);
}
<!-- pom.xml에 MapStruct 의존성 추가 -->
<dependency>
    <groupId>org.mapstruct</groupId>
    <artifactId>mapstruct</artifactId>
    <version>1.5.5.Final</version>
</dependency>
<dependency>
    <groupId>org.mapstruct</groupId>
    <artifactId>mapstruct-processor</artifactId>
    <version>1.5.5.Final</version>
    <scope>provided</scope>
</dependency>

여기서 시험 함정이 하나 있어요. Lombok 과 MapStruct 를 함께 쓸 때 annotationProcessorPaths 순서가 중요합니다. Lombok 프로세서가 MapStruct 프로세서보다 먼저 실행돼야 해요. 안 그러면 Lombok 이 만들어 줄 getter/setter 가 아직 없는 상태에서 MapStruct 가 매핑 코드를 만들어 컴파일 에러가 나요.

회사 비유로 — MapStruct 는 자료실 원본을 외부 보고서로 자동 변환하는 복사기예요. 손으로 옮겨 적던 걸 한 번에 처리해 주죠.

Bean Validation — 입력값 검증의 표준

JPA 엔티티와 DTO 에 데이터를 넣을 때 입력값이 비즈니스 규칙·형식을 만족하는지 확인하는 게 Bean Validation(JSR-380) 이에요. Spring Boot 에서는 spring-boot-starter-validation 의존성만 추가하면 Hibernate Validator 가 자동으로 구성돼요.

회사 비유로 — Bean Validation 은 자료실 입구의 검수원이에요. 양식이 빈칸 없이 채워졌는지, 규정에 맞는 값이 들어왔는지 확인하고 통과시켜요. 자세한 어노테이션 목록은 Spring Framework 공식 문서에서 확인할 수 있어요.

자주 쓰는 어노테이션 한 묶음:

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ProductDTO {
    private UUID id;
    private Integer version;

    // 문자열 검증
    @NotNull(message = "상품 이름은 필수입니다.")
    @NotBlank(message = "상품 이름은 공백일 수 없습니다.")
    @Size(min = 1, max = 50, message = "상품 이름은 1~50자여야 합니다.")
    private String productName;

    // 숫자 검증
    @NotNull(message = "가격은 필수입니다.")
    @Positive(message = "가격은 0보다 커야 합니다.")
    @DecimalMax(value = "9999.99", message = "가격은 9999.99를 초과할 수 없습니다.")
    private BigDecimal price;

    // 범위 검증
    @Min(value = 0, message = "재고는 0 이상이어야 합니다.")
    @Max(value = 9999, message = "재고는 9999를 초과할 수 없습니다.")
    private Integer quantityOnHand;

    // 패턴 검증
    @Pattern(regexp = "^[0-9]{8,13}$", message = "UPC는 8~13자리 숫자여야 합니다.")
    @NotBlank
    private String upc;

    // 날짜·시간 검증
    @Future(message = "만료일은 미래 날짜여야 합니다.")
    private LocalDate expirationDate;

    // 이메일 검증
    @Email(message = "올바른 이메일 형식이 아닙니다.")
    private String contactEmail;

    private LocalDateTime createdDate;
    private LocalDateTime updateDate;
}

자주 쓰는 Bean Validation 어노테이션:

어노테이션대상 타입설명
@NotNull모든 타입null 불허
@NotEmptyString·Collection·Map·Arraynull 및 빈값 불허
@NotBlankStringnull·빈값·공백만인 값 모두 불허
@Size(min, max)String·Collection크기/길이 제한
@Min / @Max정수·BigDecimal최솟값·최댓값
@Positive / @PositiveOrZero숫자형양수만·0 이상 허용
@EmailString이메일 형식 검증
@Pattern(regexp)String정규표현식 검증
@Future / @Past날짜·시간미래·과거 날짜
@Digits숫자형자릿수 제한

여기서 정말 중요한 시험 함정 — @NotNull vs @NotEmpty vs @NotBlank 차이예요. 표로 정리하면 한눈에 들어와요.

어노테이션null"" (빈 문자열)" " (공백)
@NotNull실패통과통과
@NotEmpty실패실패통과
@NotBlank실패실패실패

문자열 필드의 필수 값 검증에는 거의 무조건 @NotBlank 가 정답이에요. @NotEmpty 는 컬렉션에 더 자주 써요.

@Valid vs @Validated — 둘이 뭐가 다를까

JPA·DTO 에 어노테이션만 박아도 검증이 자동으로 되는 건 아니에요. 컨트롤러 메서드에서 @Valid 또는 @Validated 로 검증을 트리거해야 해요.

@PostMapping
public ResponseEntity<Void> createProduct(@Validated @RequestBody ProductDTO product) {
    ProductDTO saved = productService.saveNewProduct(product);
    HttpHeaders headers = new HttpHeaders();
    headers.add("Location", "/api/v1/products/" + saved.getId());
    return new ResponseEntity<>(headers, HttpStatus.CREATED);
}

@Valid@Validated 의 차이를 정리하면:

구분@Valid@Validated
출처Jakarta Validation 표준Spring Framework 확장
그룹 검증불가가능
메서드 인자 검증불가가능 (클래스 레벨에 박으면)
일반 사용OKOK

단순한 검증에는 @Valid 만으로도 충분하지만, 그룹 검증(생성 vs 수정) 이나 메서드 인자 검증 이 필요하면 @Validated 를 써야 해요.

// 검증 그룹 인터페이스 정의
public interface OnCreate {}
public interface OnUpdate {}

// 그룹별 다른 검증 규칙 적용
@Data
public class ProductDTO {
    @Null(groups = OnCreate.class)       // 생성 시 null이어야 함
    @NotNull(groups = OnUpdate.class)    // 수정 시 필수
    private UUID id;

    @NotBlank(groups = {OnCreate.class, OnUpdate.class})
    @Size(max = 50, groups = {OnCreate.class, OnUpdate.class})
    private String productName;
}

// 컨트롤러에서 그룹 지정
@PostMapping
public ResponseEntity<Void> createProduct(@Validated(OnCreate.class) @RequestBody ProductDTO product) {
    // 생성 시 id는 null이어야 하고, productName은 필수
    ...
}

@PutMapping("/{id}")
public ResponseEntity<Void> updateProduct(@PathVariable UUID id,
                                          @Validated(OnUpdate.class) @RequestBody ProductDTO product) {
    // 수정 시 id는 필수, productName은 필수
    ...
}

여기서 시험 함정이 하나 있어요. 그룹이 필요한데 @Valid 를 쓰면 그룹이 무시돼요. 모든 제약이 적용되는 식으로 동작합니다. 그룹을 쓸 때는 무조건 @Validated 예요.

중첩 객체 검증 — @Valid 한 번 더

또 하나 자주 빠지는 함정 — 중첩된 객체의 검증은 자동으로 안 됩니다.

public class OrderDTO {
    @NotNull
    private ProductDTO product;  // ProductDTO 안의 검증은 자동으로 안 됨!
}

// 올바른 예시
public class OrderDTO {
    @NotNull
    @Valid  // 중첩 객체의 Bean Validation 활성화
    private ProductDTO product;
}

회사 비유로 — 검수원은 메인 양식만 검사해요. 메인 양식 안에 끼워 넣은 부속 양식까지 검사하려면 "이것도 검사해 줘"라고 명시(@Valid)해야 합니다.

검증 실패 시 예외 처리

@Valid / @Validated 검증이 실패하면 MethodArgumentNotValidException 이 발생해요. 2 편에서 다룬 @RestControllerAdvice 로 전역 처리해요.

@RestControllerAdvice
public class CustomErrorController {

    // @RequestBody 검증 실패 처리
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<List<Map<String, String>>> handleBindErrors(
            MethodArgumentNotValidException exception) {

        List<Map<String, String>> errorList = exception.getBindingResult().getFieldErrors()
                .stream()
                .map(fieldError -> {
                    Map<String, String> errorMap = new HashMap<>();
                    errorMap.put("field", fieldError.getField());
                    errorMap.put("message", fieldError.getDefaultMessage());
                    errorMap.put("rejectedValue", String.valueOf(fieldError.getRejectedValue()));
                    return errorMap;
                })
                .collect(Collectors.toList());

        return ResponseEntity.badRequest().body(errorList);
    }

    // @RequestParam·@PathVariable 검증 실패 처리
    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity<String> handleConstraintViolation(ConstraintViolationException e) {
        String message = e.getConstraintViolations()
                .stream()
                .map(cv -> cv.getPropertyPath() + ": " + cv.getMessage())
                .collect(Collectors.joining(", "));
        return ResponseEntity.badRequest().body(message);
    }
}

검증 실패 예외 타입을 한 번에 정리:

상황발생 예외기본 HTTP 상태
@RequestBody 검증 실패MethodArgumentNotValidException400
@PathVariable·@RequestParam 검증 실패ConstraintViolationException500 (기본)
서비스 레이어 검증 실패ConstraintViolationException500 (기본)
타입 불일치MethodArgumentTypeMismatchException400
JSON 파싱 오류HttpMessageNotReadableException400

여기서 시험 함정이 하나 있어요. @PathVariable·@RequestParam 검증을 쓰려면 클래스 레벨에 @Validated 를 박아야 해요.

@Validated  // 클래스 레벨에 추가해야 @RequestParam 검증 활성화
@RestController
public class ProductController {
    @GetMapping
    public Page<ProductDTO> listProducts(
            @RequestParam(required = false)
            @Size(max = 50, message = "검색어는 50자 이하여야 합니다.")
            String name) {
        ...
    }
}

커스텀 Validator — 비즈니스 규칙 직접 검증

표준 어노테이션만으로 풀기 어려운 비즈니스 규칙은 커스텀 Validator 를 만들어요. 3 단계 — 어노테이션 정의, Validator 구현, DTO 적용.

// Step 1: 커스텀 어노테이션 정의
@Documented
@Constraint(validatedBy = ProductNameValidator.class)
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidProductName {
    String message() default "상품 이름에 허용되지 않는 단어가 포함돼 있습니다.";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
    String[] prohibitedWords() default {"테스트", "임시"};
}

// Step 2: Validator 구현
public class ProductNameValidator implements ConstraintValidator<ValidProductName, String> {
    private String[] prohibitedWords;

    @Override
    public void initialize(ValidProductName constraintAnnotation) {
        this.prohibitedWords = constraintAnnotation.prohibitedWords();
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null || value.isEmpty()) {
            return true;  // null 체크는 @NotNull이 담당
        }

        for (String word : prohibitedWords) {
            if (value.contains(word)) {
                context.disableDefaultConstraintViolation();
                context.buildConstraintViolationWithTemplate(
                        "'" + word + "'는 상품 이름에 사용할 수 없습니다.")
                        .addConstraintViolation();
                return false;
            }
        }
        return true;
    }
}

// Step 3: DTO에 적용
@Data
public class ProductDTO {
    @ValidProductName(prohibitedWords = {"테스트", "임시", "test"})
    private String productName;
}

JPA 통합 테스트 — @DataJpaTest

JPA 레이어만 빠르게 테스트할 때는 @DataJpaTest 를 써요. 컨트롤러·서비스는 로드하지 않고 JPA 관련 컴포넌트만 띄워요. 기본적으로 H2 인메모리 DB 를 자동으로 띄워서 테스트가 끝나면 정리됩니다.

@DataJpaTest
class ProductRepositoryTest {

    @Autowired
    ProductRepository productRepository;

    @Test
    void testSaveProduct() {
        Product savedProduct = productRepository.save(Product.builder()
                .productName("Sample Product")
                .category("Books")
                .upc("12345678")
                .price(new BigDecimal("9.99"))
                .build());

        // flush()로 즉시 INSERT 쿼리 실행 (유효성 검증 포함)
        productRepository.flush();

        assertThat(savedProduct).isNotNull();
        assertThat(savedProduct.getId()).isNotNull();
    }

    @Test
    void testSaveProductNameTooLong() {
        // 이름이 50자를 초과하면 DataIntegrityViolationException 발생
        assertThrows(DataIntegrityViolationException.class, () -> {
            Product savedProduct = productRepository.save(Product.builder()
                    .productName("Product 01234567890123456789012345678901234567890123456789")
                    .category("Books")
                    .upc("12345678")
                    .price(new BigDecimal("9.99"))
                    .build());
            productRepository.flush();
        });
    }
}

여기서 시험 함정이 하나 있어요. flush() 를 안 부르면 INSERT 쿼리가 트랜잭션 종료 전까지 안 나가요. 영속성 컨텍스트는 변경 사항을 모았다가 트랜잭션 commit 시점에 한 번에 flush 해요. 검증 위반이 즉시 터지길 원하면 명시적으로 flush() 를 불러야 합니다.

N+1 쿼리 문제 — 가장 흔한 함정

JPA 의 가장 악명 높은 함정이 N+1 쿼리 문제예요. @OneToMany 관계에서 부모 목록을 가져온 후, 각 부모의 자식 목록에 접근할 때마다 추가 쿼리가 한 번씩 더 나가요.

// N+1 문제가 발생하는 예시
List<Order> orders = orderRepository.findAll();  // 1번 쿼리
for (Order order : orders) {
    // 각 주문마다 아이템 조회 쿼리 발생 (N번)
    System.out.println(order.getItems().size());
}
// 총 1 + N 번 쿼리 — 100개 주문이면 101번

해결 방법은 세 가지:

// 해결책 1: FETCH JOIN (JPQL)
@Query("SELECT DISTINCT o FROM Order o LEFT JOIN FETCH o.items")
List<Order> findAllWithItems();

// 해결책 2: EntityGraph
@EntityGraph(attributePaths = {"items"})
List<Order> findAll();

// 해결책 3: batch_size 설정 (부분적 해결)
// application.yml
// spring.jpa.properties.hibernate.default_batch_fetch_size=100

회사 비유로 — N+1 은 사서가 부모 문서 100 장을 가져온 다음, 각 문서마다 자료실로 다시 들어가서 부속 문서를 따로 가져오는 식이에요. 한 번에 부모+자식을 같이 가져오는 게 FETCH JOIN 이고, "내가 부속 문서도 같이 가져올 거예요" 라고 미리 표시하는 게 EntityGraph 예요.

영속성 컨텍스트와 트랜잭션 경계

JPA 의 LAZY 컬렉션은 트랜잭션 컨텍스트 내에서만 로드될 수 있어요. @Transactional 이 없는 컨트롤러나 뷰에서 LAZY 컬렉션에 접근하면 LazyInitializationException 이 발생해요.

// 잘못된 예 — LazyInitializationException 발생
@Service
public class OrderServiceImpl {
    public Order getOrder(Long id) {
        return orderRepository.findById(id).orElseThrow();
        // 반환 후 트랜잭션 종료 → LAZY 컬렉션 접근 불가
    }
}

// 컨트롤러에서 LAZY 컬렉션 접근 시 오류!
Order order = orderService.getOrder(1L);
order.getItems().size();  // LazyInitializationException!

// 올바른 예 — 서비스 안에서 DTO로 변환
@Transactional(readOnly = true)
public OrderDTO getOrder(Long id) {
    Order order = orderRepository.findById(id).orElseThrow();
    return orderMapper.toDTO(order);  // 트랜잭션 안에서 변환
}

회사 비유로 — 사서는 자료실 문 안에서만 부속 문서를 펼쳐 볼 수 있어요. 손님이 자료실 밖에서 "이 문서의 부속 자료도 보여 줘" 라고 하면 사서는 이미 자료실을 떠난 후라 못 보여 줘요. 그래서 자료실 안(트랜잭션 내)에서 미리 보고서(DTO)로 옮겨 적어 가지고 나와야 합니다.

시험 직전 한 번 더 — 자주 헷갈리는 함정 모음

여기까지가 Spring Data JPA 3 편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.

  • JPA 엔티티 = 회사 자료실의 표준 문서 양식 / Repository = 사서
  • 엔티티 필수 — @Entity·@Id·@NoArgsConstructor(JPA 스펙)
  • 기본 키 전략 5종 — AUTO·IDENTITY·SEQUENCE·TABLE·UUID
  • MySQL 에서 SEQUENCE 안 됨IDENTITY 또는 UUID
  • Enum 은 무조건 @Enumerated(EnumType.STRING) — 기본 ORDINAL 은 위험
  • @Version — 낙관적 락(Optimistic Lock)
  • @CreationTimestamp / @UpdateTimestamp — Hibernate 가 자동 INSERT/UPDATE 시 설정
  • JpaRepository — 인터페이스만 정의하면 구현체 자동 생성
  • 파생 쿼리 — findBy + 필드명 + 키워드 (Like·Containing·Between·OrderBy)
  • 복잡한 쿼리 — @Query (JPQL) 또는 Specification
  • 컨트롤러에 엔티티 직접 노출 금지 — DTO 로 변환
  • MapStruct — 컴파일 타임 매핑, 리플렉션 X (성능 좋음)
  • Lombok + MapStruct 같이 쓸 때 — annotationProcessorPaths 순서 주의
  • Bean Validation — @NotNull < @NotEmpty < @NotBlank (문자열은 NotBlank)
  • @NotEmpty컬렉션 검증에 더 자주 사용
  • @Valid (Jakarta 표준) vs @Validated (Spring 확장 — 그룹 가능)
  • 그룹 검증이 필요하면 @Validated@Valid 는 그룹 무시
  • 중첩 객체 검증 시 @Valid 한 번 더 박아야 함
  • 검증 실패 → MethodArgumentNotValidException (@RequestBody)
  • 검증 실패 → ConstraintViolationException (@RequestParam·@PathVariable·서비스)
  • @RequestParam 검증 활성화 — 클래스 레벨에 @Validated 박기
  • @DataJpaTest — JPA 레이어만 빠르게 테스트 (H2 자동)
  • repository.flush() — 즉시 INSERT 쿼리 실행 (검증 위반 즉시 발견)
  • N+1 문제@OneToMany LAZY 에서 발생 → FETCH JOIN·EntityGraph 로 해결
  • LAZY 컬렉션은 트랜잭션 내에서만 접근 — 밖에서 접근하면 LazyInitializationException
  • 컨트롤러에 엔티티 반환하지 말고 DTO 변환 — 트랜잭션 내에서

시리즈 다른 편

같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!