JPA 관계 매핑 — @OneToMany와 N+1 함정

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

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 핵심 정리 · 6편 / 14편 — @OneToMany와 N+1 함정

이 글은 Spring Boot 3 핵심 정리 시리즈의 여섯 번째 편입니다. 5편까지 따라오셨다면 이제 데이터베이스 한 테이블에 Product·Customer 같은 엔티티를 저장하고 페이징·검색·검증까지 능숙하게 다룰 수 있을 거예요. 그런데 현실 데이터는 한 테이블로 끝나지 않습니다. 한 고객은 여러 주문을 가지고, 한 주문은 여러 상품을 담고 있고, 한 상품은 여러 카테고리에 속해요. 이 관계를 JPA 어노테이션으로 어떻게 풀어내는가 — 그리고 풀어낸 다음에는 어떤 함정이 기다리는가 — 가 6편의 주제입니다.

이번 편에서는 @OneToMany 를 중심으로 양방향 관계의 주인 정하기, @ManyToOne·@ManyToMany까지 풀고, 가장 악명 높은 N+1 쿼리 문제와 그 해결책을 함께 풀어 갑니다. 마지막에는 페이징·트랜잭션 락·Spring Data REST까지 묶어서 한 번에 정리해 둘게요.

왜 JPA 관계 매핑이 처음엔 어렵게 느껴질까요

이유는 네 가지예요.

첫째, "누가 관계의 주인인가"가 처음엔 추상적입니다. mappedBy라는 속성을 한쪽에만 박는데, 왜 양쪽이 아니라 한쪽만 박는지, 박힌 쪽과 안 박힌 쪽 중 어디가 "주인"인지 — 이름만 봐서는 안 잡혀요.

둘째, FetchType.LAZYEAGER의 동작 방식이 보이지 않습니다. 코드는 똑같이 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 orders)을 가지고 있고, 부서원 입장에서는 "내 부서장"(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.ordersmappedBy = "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을 가져 주인이 되고, CategorymappedBy = "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.ALLPERSIST·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) {
        // 이 트랜잭션은 외부에서 호출될 때만 활성화됨
    }
}

또 한 가지 — 기본적으로 @TransactionalRuntimeExceptionError에만 롤백합니다. 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 RESTJpaRepository만 선언하면 — 컨트롤러 코드 한 줄 안 적어도 — 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 = {...}) 또는 JPQL JOIN FETCH
  • CascadeType.ALL = PERSIST+MERGE+REMOVE+REFRESH+DETACH
  • 부모 삭제 시 자식까지 삭제하고 싶을 때만 ALL, 보통은 PERSIST+MERGE
  • API 1-based ↔ JPA 0-basedpageNumber - 1 변환 필수
  • 페이지 크기 상한선 1000 가드 — OOM 방어
  • 정렬 없는 페이징 = 페이지 간 데이터 일관성 깨짐
  • @Transactional은 프록시 기반 — 같은 클래스 내부 호출은 미적용
  • @Transactional 기본 롤백 = RuntimeException+Error만 — CheckedrollbackFor
  • @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 관리

시리즈 다른 편

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

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

답글 남기기

error: Content is protected !!