Spring Boot 3 핵심 정리 시리즈 6편. JPA 관계 매핑이 처음엔 왜 헷갈리는지부터 회사 부서·인턴 비유로 풀어가며 — @OneToMany·@ManyToOne·@ManyToMany 양방향 관계의 주인 정하기, FetchType.LAZY가 부르는 N+1 쿼리 폭발, @EntityGraph와 JPQL Fetch Join 해결법, Cascade와 양방향 편의 메서드, Pageable로 데이터베이스에서 페이징, 1-based vs 0-based 페이지 번호 함정까지 처음 다루는 분도 따라올 수 있게 친절하게 풀어쓴 6편.
이 글은 Spring Boot 3 핵심 정리 시리즈의 여섯 번째 편입니다. 5편까지 따라오셨다면 이제 데이터베이스 한 테이블에 Product·Customer 같은 엔티티를 저장하고 페이징·검색·검증까지 능숙하게 다룰 수 있을 거예요. 그런데 현실 데이터는 한 테이블로 끝나지 않습니다. 한 고객은 여러 주문을 가지고, 한 주문은 여러 상품을 담고 있고, 한 상품은 여러 카테고리에 속해요. 이 관계를 JPA 어노테이션으로 어떻게 풀어내는가 — 그리고 풀어낸 다음에는 어떤 함정이 기다리는가 — 가 6편의 주제입니다.
이번 편에서는 @OneToMany 를 중심으로 양방향 관계의 주인 정하기, @ManyToOne·@ManyToMany까지 풀고, 가장 악명 높은 N+1 쿼리 문제와 그 해결책을 함께 풀어 갑니다. 마지막에는 페이징·트랜잭션 락·Spring Data REST까지 묶어서 한 번에 정리해 둘게요.
왜 JPA 관계 매핑이 처음엔 어렵게 느껴질까요
이유는 네 가지예요.
첫째, "누가 관계의 주인인가"가 처음엔 추상적입니다. mappedBy라는 속성을 한쪽에만 박는데, 왜 양쪽이 아니라 한쪽만 박는지, 박힌 쪽과 안 박힌 쪽 중 어디가 "주인"인지 — 이름만 봐서는 안 잡혀요.
둘째, FetchType.LAZY와 EAGER의 동작 방식이 보이지 않습니다. 코드는 똑같이 getOrders()인데, 그 한 줄 뒤에서 100번의 추가 쿼리가 날아갈 수도 있고 한 번에 끝날 수도 있어요. N+1 문제가 바로 이 보이지 않는 함정입니다.
셋째, 양방향 관계에서 데이터 동기화를 잊기 쉬워요. 부모에 자식을 추가했는데 자식의 부모 참조는 안 채워서, 메모리 안의 객체와 실제 데이터베이스가 어긋나는 일이 생깁니다.
넷째, CascadeType 선택이 위험할 수 있습니다. CascadeType.ALL을 무심코 박았다가 부모 하나 지웠는데 자식 100개가 같이 사라지는 사고가 실무에서 종종 일어나요.
해결법은 한 가지예요. JPA 관계를 "회사 부서 조직도" 로 잡고 풀면 갑자기 명확해집니다. @OneToMany는 부서장과 부서원, @ManyToOne은 부서원이 부서장을 가리키는 화살표, @ManyToMany는 직원이 여러 프로젝트에 걸쳐 있는 매트릭스 조직, mappedBy는 "내가 주인 아니야, 저쪽이 주인이야"라는 표시예요. 이 비유로 풀어 갑니다.
페이징 — 1만 건을 한 번에 던지면 안 됩니다
먼저 가벼운 주제부터 시작할게요. 페이징은 5편에서 살짝 봤지만 6편에서 관계 매핑을 다루기 전 한 번 더 정리하고 가는 게 좋습니다. 데이터가 1만 건인 상품 목록을 API 한 번에 다 던지면 — 클라이언트도 멈추고 서버 메모리도 치솟아요. 페이징은 데이터를 일정 크기로 나눠 반환하는 기본 패턴입니다.
회사 비유로 — "한 번에 다 가져오지 말고 25명씩만 데려와요" 라고 인사부에 부탁하는 거예요. Spring Data JPA는 Pageable 인터페이스로 이 부탁을 표준화합니다.
// Controller — 페이지 번호와 크기를 쿼리 파라미터로 받음
@GetMapping(PRODUCT_PATH)
public Page<ProductDTO> listProducts(
@RequestParam(required = false) String productName,
@RequestParam(required = false) String category,
@RequestParam(required = false) Integer pageNumber,
@RequestParam(required = false) Integer pageSize) {
return productService.listProducts(productName, category, pageNumber, pageSize);
}
// Service — PageRequest로 변환 후 Repository에 전달
public Page<ProductDTO> listProducts(String productName, String category,
Integer pageNumber, Integer pageSize) {
PageRequest pageRequest = buildPageRequest(pageNumber, pageSize);
Page<Product> productPage;
if (StringUtils.hasText(productName) && category == null) {
productPage = productRepository.findAllByProductNameIsLikeIgnoreCase(productName, pageRequest);
} else if (!StringUtils.hasText(productName) && category != null) {
productPage = productRepository.findAllByCategory(category, pageRequest);
} else {
productPage = productRepository.findAll(pageRequest);
}
return productPage.map(productMapper::productToProductDto);
}
public PageRequest buildPageRequest(Integer pageNumber, Integer pageSize) {
int queryPageNumber;
int queryPageSize;
// pageNumber가 null이거나 0 이하이면 0(첫 페이지)으로 설정
if (pageNumber != null && pageNumber > 0) {
queryPageNumber = pageNumber - 1; // API는 1-based, JPA는 0-based
} else {
queryPageNumber = DEFAULT_PAGE;
}
// 페이지 크기 상한선 설정으로 DOS 공격 방어
if (pageSize == null) {
queryPageSize = DEFAULT_PAGE_SIZE;
} else if (pageSize > 1000) {
queryPageSize = 1000;
} else {
queryPageSize = pageSize;
}
Sort sort = Sort.by(Sort.Order.asc("productName"));
return PageRequest.of(queryPageNumber, queryPageSize, sort);
}
여기서 시험 함정이 하나 있어요. API에서 받은 pageNumber는 보통 1-based(첫 페이지가 1)인데, JPA의 PageRequest는 0-based(첫 페이지가 0)입니다. 그대로 넘기면 사용자가 "1페이지"를 요청했는데 두 번째 페이지가 나옵니다. 반드시 pageNumber - 1 변환을 해 주세요. 시험에도 자주 나오고 실무 버그로도 흔합니다.
또 하나 — 페이지 크기에 상한선을 두지 않으면 클라이언트가 pageSize=1000000을 요청해 OOM을 유발할 수 있어요. 위 코드처럼 if (pageSize > 1000) queryPageSize = 1000; 같은 가드를 꼭 박아 둡니다.
마지막으로 — 정렬 없이 페이징만 하면 페이지 간 이동 시 같은 레코드가 두 페이지에 나타나거나 누락될 수 있어요. 데이터베이스 엔진이 매번 다른 순서로 반환할 권리가 있거든요. Sort.by(...)를 항상 함께 박아 줍니다.
> 한 줄 정리 — 페이징은 1-based ↔ 0-based 변환, 상한선, 정렬 — 이 셋을 묶어서 처리.
@OneToMany & @ManyToOne — 부서장과 부서원
이제 본격적으로 관계 매핑입니다. 가장 흔한 관계가 "하나가 여럿을 가진다" — 한 고객이 여러 주문을 가지는 식이죠. JPA에서는 이걸 @OneToMany 와 @ManyToOne 으로 양쪽에 표현해요.
회사 비유로 풀어 봅시다. 한 부서장(Customer)에 여러 부서원(Order)이 있어요. 부서장 입장에서는 "내 부서원들" 목록(Set)을 가지고 있고, 부서원 입장에서는 "내 부서장"(Customer customer) 한 명을 가리킵니다. 이 양방향 관계를 자바 코드로 풀면 다음과 같아요.
// Customer.java — 1쪽 (한 명의 부서장)
@Entity
@Table(name = "customer")
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(length = 36, columnDefinition = "varchar(36)", updatable = false, nullable = false)
private UUID id;
private String name;
@CreationTimestamp
private LocalDateTime createdDate;
@UpdateTimestamp
private LocalDateTime lastModifiedDate;
// Customer는 여러 Order를 가진다
// mappedBy = "customer" : Order.customer 필드가 연관관계의 주인
// cascade = CascadeType.ALL : Customer 저장/삭제 시 Order도 함께 처리
// fetch = FetchType.LAZY : 기본값, 실제로 orders를 사용할 때 쿼리 실행
@OneToMany(mappedBy = "customer", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private Set<Order> orders;
}
// Order.java — N쪽 (여러 명의 부서원)
@Entity
@Table(name = "product_order")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
private String customerRef;
@ManyToOne
@JoinColumn(name = "customer_id")
private Customer customer;
@OneToMany(mappedBy = "productOrder", cascade = CascadeType.ALL)
private Set<OrderLine> orderLines;
}
여기서 정말 중요한 함정 하나 — mappedBy는 "내가 주인이 아니야"라는 표시예요. 실제로 외래 키 컬럼(customer_id)을 가진 쪽이 주인이고, 그 반대편(컬렉션을 가진 쪽)에 mappedBy를 박습니다. 위 코드에서 Customer.orders에 mappedBy = "customer"가 박혔는데, 이는 "Order.customer 필드가 진짜 주인이야" 라는 의미예요.
왜 이게 중요할까요? 주인이 아닌 쪽에 데이터를 추가해도 데이터베이스에는 반영되지 않아요. customer.getOrders().add(newOrder)만 하고 newOrder.setCustomer(customer)를 안 하면 외래 키 컬럼이 비어 있는 채 저장됩니다. 그래서 양방향 관계에서는 편의 메서드를 엔티티에 박는 게 정석이에요.
// Customer.java에 편의 메서드 추가
@Entity
public class Customer {
@OneToMany(mappedBy = "customer", cascade = CascadeType.ALL)
private Set<Order> orders = new HashSet<>();
// 양방향 관계를 한 번에 설정하는 편의 메서드
public void addOrder(Order order) {
orders.add(order);
order.setCustomer(this); // 반대편(주인)도 설정
}
public void removeOrder(Order order) {
orders.remove(order);
order.setCustomer(null); // 연결 해제
}
}
이렇게 박아 두면 호출하는 쪽에서 customer.addOrder(newOrder) 한 줄로 양쪽이 동기화돼요. 양방향 데이터 어긋남 사고를 원천 방어합니다.
> 한 줄 정리 — 외래 키 컬럼을 가진 쪽이 주인 / 반대편에는 mappedBy / 양방향 관계는 편의 메서드로 동기화.
@ManyToMany — 직원이 여러 프로젝트에 걸쳐 있을 때
상품(Product) 하나가 여러 카테고리(Category)에 속하고, 한 카테고리에는 여러 상품이 있어요. 이게 @ManyToMany 입니다. 회사로 치면 매트릭스 조직 — 한 직원이 마케팅·개발·디자인 여러 프로젝트에 동시에 걸쳐 있는 그림이에요.
@ManyToMany는 두 엔티티 사이에 조인 테이블이 필요해요. JPA가 자동으로 이 테이블을 관리해 줍니다.
// Product.java
@Entity
@Table(name = "product")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
private String productName;
private String productType;
// Product는 여러 Category에 속한다
@ManyToMany
@JoinTable(
name = "product_category", // 조인 테이블 이름
joinColumns = @JoinColumn(name = "product_id"),
inverseJoinColumns = @JoinColumn(name = "category_id")
)
private Set<Category> categories = new HashSet<>();
}
// Category.java
@Entity
@Table(name = "category")
public class Category {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
private String description;
// 반대편 관계 선언 — 주인은 Product쪽
@ManyToMany(mappedBy = "categories")
private Set<Product> products = new HashSet<>();
}
여기서 시험 함정이 하나 있어요. @ManyToMany에서도 주인은 한쪽만이에요. 위 코드에서는 Product가 @JoinTable을 가져 주인이 되고, Category는 mappedBy = "categories"로 "내가 주인 아니야"를 표시합니다. 양쪽 다 @JoinTable을 박으면 Hibernate가 헷갈려서 두 개의 별도 조인 테이블을 만드는 사고가 납니다.
Flyway로 관계 스키마 관리하기
관계 매핑을 도입하면 데이터베이스 스키마가 함께 변해요. 외래 키 컬럼을 추가하고, 조인 테이블을 새로 만들어야 합니다. 이런 변경 이력을 코드와 같은 git에 묶어 관리하는 게 Flyway입니다.
-- V3__add-customer-table.sql
CREATE TABLE customer
(
id varchar(36) NOT NULL,
name varchar(50),
created_date timestamp,
last_modified_date timestamp,
CONSTRAINT pk_customer PRIMARY KEY (id)
);
-- V4__add-order-tables.sql
CREATE TABLE product_order
(
id varchar(36) NOT NULL,
customer_id varchar(36),
created_date timestamp,
last_modified_date timestamp,
customer_ref varchar(255),
CONSTRAINT pk_product_order PRIMARY KEY (id),
CONSTRAINT fk_product_order_customer FOREIGN KEY (customer_id) REFERENCES customer (id)
);
CREATE TABLE order_line
(
id varchar(36) NOT NULL,
product_order_id varchar(36),
product_id varchar(36),
order_quantity integer,
quantity_allocated integer,
created_date timestamp,
last_modified_date timestamp,
CONSTRAINT pk_order_line PRIMARY KEY (id),
CONSTRAINT fk_order_line_order FOREIGN KEY (product_order_id) REFERENCES product_order (id),
CONSTRAINT fk_order_line_product FOREIGN KEY (product_id) REFERENCES product (id)
);
외래 키 제약 조건은 데이터 무결성을 데이터베이스 레벨에서 보장해 줘요. 예를 들어 존재하지 않는 customer_id로 주문을 만들려고 하면 데이터베이스가 즉시 거부합니다. JPA만 믿지 말고 데이터베이스 제약도 함께 박는 게 정석이에요.
N+1 문제 — 관계 매핑의 가장 큰 함정
자, 이제 JPA 관계 매핑에서 가장 악명 높은 함정을 풀어 볼게요. 코드는 짧지만 뒤에서 일어나는 일이 큽니다.
// 고객 100명 조회
List<Customer> customers = customerRepository.findAll(); // 1번 쿼리 (SELECT * FROM customer)
// 각 고객의 주문 개수 출력
customers.forEach(c -> System.out.println(c.getOrders().size()));
여기서 시험 함정이 하나 있어요. 위 코드는 101번의 SQL 쿼리를 날립니다. 첫 번째 쿼리는 고객 100명 조회 — 그건 정상이에요. 그런데 c.getOrders()를 호출할 때마다 Hibernate가 각 고객별로 한 번씩 추가 쿼리를 날려요. 100명이면 추가 100번 — 합쳐서 N + 1 = 101번입니다.
@OneToMany·@ManyToMany의 기본 fetch 전략이 FetchType.LAZY 라서 이런 일이 벌어져요. 컬렉션을 실제로 쓰는 순간에 가서야 쿼리가 날아가는 거죠. 화면 한 번 새로 고침에 SQL 100개가 콘솔에 찍히는 광경이 보이면 이 문제를 만난 겁니다.
해결책은 두 가지가 정석이에요.
해결책 1 — @EntityGraph로 한 번에 가져오기
public interface CustomerRepository extends JpaRepository<Customer, UUID> {
// attributePaths에 적힌 필드는 조회 시 함께 EAGER 로딩
@EntityGraph(attributePaths = {"orders"})
List<Customer> findAllWithOrders();
}
@EntityGraph는 어떤 연관 객체를 함께 가져올지 메서드별로 지정해요. 호출 시점에 한 번의 JOIN으로 다 가져오기 때문에 N+1이 사라집니다.
해결책 2 — JPQL Fetch Join
public interface CustomerRepository extends JpaRepository<Customer, UUID> {
@Query("SELECT c FROM Customer c JOIN FETCH c.orders WHERE c.id = :id")
Optional<Customer> findByIdWithOrders(@Param("id") UUID id);
}
JOIN FETCH는 JPQL 키워드로 — 한 번의 SQL로 부모와 자식을 같이 끌어옵니다. @EntityGraph보다 표현력이 좋아서 복잡한 조건이 필요할 때 자주 써요.
> 한 줄 정리 — FetchType.LAZY가 부르는 N+1 문제는 @EntityGraph 또는 JOIN FETCH로 해결 / 두 도구 모두 한 번의 JOIN으로 다 가져오는 게 핵심.
CascadeType — 함께 살고 함께 죽는 관계?
CascadeType은 부모 엔티티의 작업이 자식에게 어디까지 전파될지 정합니다. 회사로 치면 부서장이 퇴사할 때 부서원도 같이 퇴사하느냐, 아니면 부서원은 다른 부서로 이동하느냐의 정책이에요.
// 위험: Customer 삭제 시 Order도 모두 삭제됨
@OneToMany(mappedBy = "customer", cascade = CascadeType.ALL)
// 안전: 필요한 타입만 명시
@OneToMany(mappedBy = "customer", cascade = {CascadeType.PERSIST, CascadeType.MERGE})
여기서 정말 중요한 시험 함정 — CascadeType.ALL은 PERSIST·MERGE·REMOVE·REFRESH·DETACH 모두를 포함합니다. 특히 REMOVE가 위험해요. "고객 데이터 정리"를 한다고 무심코 Customer를 지웠는데, 그 고객의 과거 주문 내역까지 모두 사라지는 사고가 일어납니다. 회계·감사 입장에서는 큰일이에요.
룰은 한 줄 — CascadeType.ALL은 정말 부모-자식이 한 운명일 때만, 나머지는 PERSIST·MERGE 정도만 명시적으로 박는다.
FetchType — 언제 데이터를 가져올까
| FetchType | 기본값 | 데이터 로드 시점 | 성능 위험 |
|---|---|---|---|
LAZY | @OneToMany, @ManyToMany | 실제 접근 시 | N+1 문제 |
EAGER | @ManyToOne, @OneToOne | 부모 로드 시 | 불필요한 데이터 로드 |
@OneToMany·@ManyToMany의 기본은 LAZY인데 비해 @ManyToOne·@OneToOne의 기본은 EAGER예요. 둘 다 함정이 있습니다 — LAZY는 N+1, EAGER는 안 쓰는 데이터까지 매번 끌어오는 부하. 일반적으로 모든 관계를 LAZY로 명시하고, 필요할 때만 @EntityGraph/JOIN FETCH로 끌어오는 패턴이 권장돼요.
트랜잭션 — ACID와 @Transactional
관계 매핑이 등장하면 자연스럽게 여러 테이블에 걸친 작업이 생겨요. "주문 생성 + 재고 차감 + 결제 기록" 같은 일이 한 묶음으로 돼야 — 중간에 실패하면 모든 게 원래대로 돌아가야 합니다. 이게 트랜잭션이에요.
ACID 네 가지 속성을 한 번 짚고 갈게요.
- 원자성(Atomicity): 트랜잭션 내의 모든 연산은 전부 성공하거나 전부 실패. 중간 상태 없음.
- 일관성(Consistency): 트랜잭션 전후로 데이터베이스가 항상 유효한 상태 유지.
- 격리성(Isolation): 동시에 실행되는 트랜잭션끼리 간섭하지 않음.
- 지속성(Durability): 커밋된 결과는 영구 저장.
Spring에서 트랜잭션은 @Transactional 한 줄로 선언해요.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final CustomerRepository customerRepository;
private final ProductRepository productRepository;
// 클래스 레벨에 박으면 모든 public 메서드에 적용
// readOnly = true는 읽기 전용 — Hibernate가 dirty checking을 건너뛰어 성능 최적화
@Transactional(readOnly = true)
public Page<OrderDTO> listOrders(UUID customerId, Integer pageNumber, Integer pageSize) {
PageRequest pageRequest = buildPageRequest(pageNumber, pageSize);
return orderRepository.findAllByCustomerId(customerId, pageRequest)
.map(orderMapper::orderToOrderDto);
}
// 쓰기 작업은 readOnly 없이 기본 @Transactional 사용
@Transactional
public OrderDTO saveNewOrder(UUID customerId, OrderCreateDTO orderCreate) {
Customer customer = customerRepository.findById(customerId)
.orElseThrow(NotFoundException::new);
Set<OrderLine> orderLines = new HashSet<>();
orderCreate.getOrderLines().forEach(dto -> {
orderLines.add(OrderLine.builder()
.product(productRepository.findById(dto.getProductId())
.orElseThrow(NotFoundException::new))
.orderQuantity(dto.getOrderQuantity())
.build());
});
return orderMapper.orderToOrderDto(
orderRepository.save(Order.builder()
.customer(customer)
.orderLines(orderLines)
.customerRef(orderCreate.getCustomerRef())
.build())
);
}
}
여기서 시험 함정이 하나 있어요. @Transactional은 Spring AOP 프록시 기반이에요. 같은 클래스 안에서 메서드를 직접 호출하면 — 프록시를 거치지 않기 때문에 트랜잭션이 적용되지 않습니다.
@Service
public class ProductService {
// 내부 호출: 트랜잭션 미적용!
public void createProduct(ProductDTO dto) {
saveProduct(dto); // 같은 클래스 내부 호출 — 프록시 안 거침
}
@Transactional
public void saveProduct(ProductDTO dto) {
// 이 트랜잭션은 외부에서 호출될 때만 활성화됨
}
}
또 한 가지 — 기본적으로 @Transactional은 RuntimeException과 Error에만 롤백합니다. Checked Exception(예: IOException)은 잡혀도 롤백 안 돼요. 명시적으로 풀려면 rollbackFor를 박습니다.
@Transactional(rollbackFor = {Exception.class})
public void saveProduct(ProductDTO dto) throws IOException {
// IOException 발생 시에도 롤백됨
}
트랜잭션 전파 속성 비교
| 전파 속성 | 동작 설명 | 적합한 상황 |
|---|---|---|
REQUIRED (기본값) | 기존 트랜잭션 사용, 없으면 생성 | 대부분의 서비스 메서드 |
REQUIRES_NEW | 항상 새 트랜잭션 생성, 기존 일시 중단 | 독립적인 로그 기록 |
NESTED | 중첩 트랜잭션 (savepoint 사용) | 부분 롤백이 필요한 경우 |
SUPPORTS | 기존 트랜잭션 사용, 없으면 없이 실행 | 읽기 작업 |
NOT_SUPPORTED | 트랜잭션 없이 실행 | 트랜잭션이 불필요한 작업 |
NEVER | 트랜잭션이 있으면 예외 발생 | 트랜잭션 금지 강제 |
MANDATORY | 반드시 기존 트랜잭션 필요, 없으면 예외 | 호출자가 트랜잭션 보장해야 할 때 |
낙관적 락 vs 비관적 락 — 충돌 처리 두 가지 철학
여러 사용자가 같은 데이터를 동시에 수정할 때 어떻게 처리할까요. 두 가지 철학이 있어요.
| 구분 | 낙관적 락 | 비관적 락 |
|---|---|---|
| 전략 | 충돌이 드물다고 가정, 수정 시 충돌 감지 | 충돌을 예방하기 위해 선제적으로 잠금 |
| 구현 | @Version 어노테이션으로 버전 컬럼 관리 | @Lock(LockModeType.PESSIMISTIC_WRITE) |
| 성능 | 쓰기 충돌이 적을 때 성능 우수 | 충돌이 잦을 때 일관성 보장 |
| 오류 | OptimisticLockException 발생 | 다른 트랜잭션이 완료될 때까지 대기 |
| 적합한 상황 | 다수 읽기, 소수 쓰기 | 다수 쓰기, 데이터 충돌 빈번 |
낙관적 락은 "충돌은 드물 거야, 일단 가서 부딪히면 그때 처리하자"는 철학이에요. 회사로 치면 — 두 명이 같은 문서를 편집하다가 한 명이 먼저 저장하면 두 번째 사람한테 "이미 다른 사람이 저장했어요, 새로고침하고 다시 시도해 주세요"라고 알리는 식입니다.
// 낙관적 락 구현
@Entity
@Table(name = "product")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
// @Version: 업데이트 시마다 자동 증가
// 충돌 시 OptimisticLockException 발생
@Version
private Integer version;
private String productName;
private BigDecimal price;
}
비관적 락은 반대 철학이에요. "충돌이 자주 일어나니까 아예 처음부터 잠가 두자"는 식. 회사로 치면 — 누군가 문서를 열면 다른 사람은 그 문서를 못 보게 막는 거예요.
// 비관적 락 구현
public interface ProductRepository extends JpaRepository<Product, UUID> {
// 조회 시 바로 쓰기 락을 획득 (다른 트랜잭션 차단)
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Product> findById(UUID id);
}
은행 계좌 잔액 차감 같은 충돌 빈번한 시나리오는 비관적 락, 일반적인 게시글 편집은 낙관적 락 — 이렇게 나눠 쓰는 게 정석입니다.
Spring Data REST — 리포지토리만 만들면 API 끝
마지막으로 보너스 기능을 짧게 풀고 갈게요. Spring Data REST는 JpaRepository만 선언하면 — 컨트롤러 코드 한 줄 안 적어도 — RESTful API가 자동 생성되는 도구예요. 프로토타입과 사내 관리 도구에 매우 유용합니다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-rest</artifactId>
</dependency>
@RepositoryRestResource
public interface ProductRepository extends JpaRepository<Product, UUID> {
// 자동 생성되는 엔드포인트:
// GET /products - 목록 조회 (페이징 지원)
// GET /products/{id} - 단건 조회
// POST /products - 생성
// PUT /products/{id} - 전체 수정
// PATCH /products/{id} - 부분 수정
// DELETE /products/{id} - 삭제
}
여기서 시험 함정이 하나 있어요. Spring Data REST는 JPA 엔티티를 API 응답으로 그대로 직렬화합니다. 비밀번호·내부 상태 같은 민감한 필드가 실수로 노출될 수 있어요. 또 복잡한 비즈니스 로직(재고 확인 후 주문 생성, 이메일 발송 등)을 추가하려면 @RepositoryEventHandler나 별도 컨트롤러를 조합해야 해서 — 결국 Spring Data REST의 간결함이 사라집니다.
| 항목 | Spring Data REST | 직접 구현 컨트롤러 |
|---|---|---|
| 개발 속도 | 매우 빠름 (코드 거의 없음) | 느림 (보일러플레이트 많음) |
| 유연성 | 제한적 | 매우 높음 |
| API 설계 | HATEOAS 자동 적용 | 자유롭게 설계 |
| 보안 통제 | 설정 복잡 | 세밀한 제어 가능 |
| DTO 분리 | 엔티티 직접 노출 | DTO로 내부 모델 숨기기 가능 |
| 적합한 상황 | 프로토타입, 내부 관리 API | 프로덕션, 공개 API |
룰은 한 줄 — 프로토타입·내부 관리 도구에는 Spring Data REST, 외부 공개 API는 직접 구현 컨트롤러.
JPA 엔티티의 equals와 hashCode 함정
마지막으로 한 가지 더. JPA 엔티티에서 Set·HashMap을 쓸 때 — Lombok의 @Data를 무심코 박으면 양방향 관계에서 무한 루프(StackOverflowError)가 납니다. 1편에서도 짚었던 함정이에요.
// 안전한 방식 — equals/hashCode를 ID 기반으로 직접 구현
@Entity
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Product)) return false;
Product product = (Product) o;
return id != null && id.equals(product.getId());
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}
또는 Lombok을 쓴다면 @Getter+@Setter+@NoArgsConstructor만 박고 관계 필드에는 @ToString.Exclude·@EqualsAndHashCode.Exclude를 추가합니다.
더 자세히 — 공식 문서
JPA·Spring Data JPA의 더 자세한 사양과 어노테이션 옵션은 Spring Data JPA 공식 레퍼런스에서 확인할 수 있어요. @OneToMany·@ManyToMany의 자세한 옵션, Pageable 검증, Auditing까지 친절하게 정리돼 있습니다.
시험 직전 한 번 더 — 자주 헷갈리는 함정 모음
여기까지가 6편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.
@OneToMany기본 fetch = LAZY — 실제 접근 시 추가 쿼리 발생@ManyToOne·@OneToOne기본 fetch = EAGER — 부모 로드 시 자동 함께 로드mappedBy= "내가 주인이 아니야" — 외래 키 컬럼 가진 쪽이 주인- 양방향 관계는 편의 메서드(addX·removeX)로 양쪽 동기화
@ManyToMany— 한쪽에만@JoinTable, 반대편은mappedBy- 양쪽 다
@JoinTable박으면 별도 조인 테이블 두 개 생김 — 사고 - N+1 문제 —
customer.getOrders()100번 호출 = 추가 100번 SQL - N+1 해결 —
@EntityGraph(attributePaths = {...})또는 JPQLJOIN FETCH CascadeType.ALL=PERSIST+MERGE+REMOVE+REFRESH+DETACH- 부모 삭제 시 자식까지 삭제하고 싶을 때만
ALL, 보통은PERSIST+MERGE만 - API 1-based ↔ JPA 0-based —
pageNumber - 1변환 필수 - 페이지 크기 상한선 1000 가드 — OOM 방어
- 정렬 없는 페이징 = 페이지 간 데이터 일관성 깨짐
@Transactional은 프록시 기반 — 같은 클래스 내부 호출은 미적용@Transactional기본 롤백 =RuntimeException+Error만 —Checked는rollbackFor로@Transactional(readOnly = true)— 읽기 전용 최적화- 낙관적 락 =
@Version— 충돌 드물 때 / 비관적 락 =@Lock(PESSIMISTIC_WRITE)— 충돌 잦을 때 - Spring Data REST =
@RepositoryRestResource한 줄로 자동 CRUD API - 엔티티 직접 노출 위험 — 프로덕션 공개 API에는 직접 컨트롤러 권장
- JPA 엔티티
equals·hashCode= ID 기반으로 — Lombok@Data무한 루프 주의 - 양방향 관계 필드에는
@ToString.Exclude·@EqualsAndHashCode.Exclude - Flyway 마이그레이션 파일(
V__.sql)로 외래 키·조인 테이블 변경 이력 git 관리
시리즈 다른 편
같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.
- 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 · 베스트 프랙티스 (완)