Spring Boot 3 핵심 정리 시리즈 3편. Spring Data JPA 의 핵심을 처음 보는 사람도 따라올 수 있게 풀어 갑니다 — JPA 엔티티 설계와 기본 키 전략 5종, JpaRepository 가 어떻게 인터페이스만으로 구현체를 만들어 주는지, 파생 쿼리·@Query·Specification, MapStruct 로 엔티티-DTO 매핑, @Valid·@NotBlank 등 Bean Validation 어노테이션, 중첩 객체 검증까지. 회사 자료실 비유로 풀어쓴 친절한 3편.
이 글은 Spring Boot 3 핵심 정리 시리즈의 세 번째 편입니다. 1편에서 IoC·DI 비유를, 2편에서 Spring MVC REST 컨트롤러 흐름을 잡았다면, 3편에서는 그 뒤편의 JPA 와 Spring 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 어노테이션 한 줄로 "이 클래스는 자료실에 보관될 표준 양식입니다" 라고 선언하면 끝이에요.
회사 비유로 — 엔티티는 인사 카드·계약서·재고 카드 같은 표준 문서 양식이에요. 양식 자체에는 이름·생년월일·소속 같은 칸이 있고, 자료실(데이터베이스)은 이 양식대로 채워진 문서들을 정리해서 보관해요.
엔티티 설계 시 꼭 지켜야 할 두 가지 원칙:
- JPA 스펙상 기본 생성자(
@NoArgsConstructor) 필수 — JPA 가 리플렉션으로 객체를 만들 때 인자 없는 생성자가 있어야 해요. 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 종을 정리하면:
| 전략 | 설명 | 적합한 경우 |
|---|---|---|
AUTO | JPA 구현체가 결정 | 간단한 경우 |
IDENTITY | DB 자동 증가(AUTO_INCREMENT) | MySQL 등 자동 증가 지원 DB |
SEQUENCE | DB 시퀀스 사용 | Oracle·PostgreSQL |
TABLE | 별도 테이블로 시퀀스 관리 | DB 독립적이지만 성능 낮음 |
UUID | UUID 자동 생성 | 분산 환경·보안 중요 환경 |
여기서 시험 함정이 하나 있어요. 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 종속 |
| Specification | repository.findAll(spec) | 동적 쿼리 조합 | 복잡한 설정 |
| QueryDSL | queryFactory.select(...) | 타입 안전 동적 쿼리 | 별도 설정 필요 |
> 한 줄 정리 — Spring Data JPA = 인터페이스만 정의하면 사서가 자동으로 채용됨. 파생 쿼리는 단순할 때, 복잡해지면 @Query 또는 Specification.
서비스 계층에서 JPA 연동 — 엔티티-DTO 분리
컨트롤러에서 JPA 엔티티를 직접 반환하면 안 돼요. 두 가지 이유가 있어요.
- 불필요한 정보 노출 위험 — 엔티티에는 내부 필드(
createdDate·version같은)가 다 들어 있어서 API 응답으로 그대로 돌리면 정보가 새 나가요. - 순환 참조와 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 불허 |
@NotEmpty | String·Collection·Map·Array | null 및 빈값 불허 |
@NotBlank | String | null·빈값·공백만인 값 모두 불허 |
@Size(min, max) | String·Collection | 크기/길이 제한 |
@Min / @Max | 정수·BigDecimal | 최솟값·최댓값 |
@Positive / @PositiveOrZero | 숫자형 | 양수만·0 이상 허용 |
@Email | String | 이메일 형식 검증 |
@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 확장 |
| 그룹 검증 | 불가 | 가능 |
| 메서드 인자 검증 | 불가 | 가능 (클래스 레벨에 박으면) |
| 일반 사용 | OK | OK |
단순한 검증에는 @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 검증 실패 | MethodArgumentNotValidException | 400 |
@PathVariable·@RequestParam 검증 실패 | ConstraintViolationException | 500 (기본) |
| 서비스 레이어 검증 실패 | ConstraintViolationException | 500 (기본) |
| 타입 불일치 | MethodArgumentTypeMismatchException | 400 |
| JSON 파싱 오류 | HttpMessageNotReadableException | 400 |
여기서 시험 함정이 하나 있어요. @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 문제 —
@OneToManyLAZY 에서 발생 → FETCH JOIN·EntityGraph 로 해결 - LAZY 컬렉션은 트랜잭션 내에서만 접근 — 밖에서 접근하면
LazyInitializationException - 컨트롤러에 엔티티 반환하지 말고 DTO 변환 — 트랜잭션 내에서
시리즈 다른 편
같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.
- 1편 — Spring Boot 입문
- 2편 — Spring MVC REST · MockMVC
- 3편 — Spring Data JPA · 검증 (현재 글)
- 4편 — MySQL · Flyway · TestContainers
- 5편 — CSV 업로드 · 페이징 · 동적 쿼리
- 6편 — JPA 관계 매핑 심화
- 7편 — Spring Security · OAuth 2.0 · JWT
- 8편 — RestTemplate · RestClient
- 9편 — Reactive Programming · WebFlux 입문
- 10편 — WebFlux 심화 · MongoDB · WebClient
- 11편 — Cloud Gateway · Maven/Gradle · Buildpack
- 12편 — OpenAPI · Spring AI
- 13편 — Actuator · 관측성
- 14편 — Spring Cache · 이벤트
- 15편 — Docker · Compose · Kubernetes
- 16편 — 마이크로서비스 · Apache Kafka
- 17편 — Spring Professional · 베스트 프랙티스 (완)