본문으로 건너뛰기
홈
기술
기술 전체
프로그래밍68
컴퓨터 과학63
AI48
웹 개발36
인프라33
데이터31
소프트웨어 공학18
소개
← 목록으로웹 개발 › 백엔드 › JPA

5. 연관관계 매핑

목차
@JoinColumn
  • 외래키를 매핑할 때 사용
    • name : 매핑할 외래키 이름
    • referencedColumnName : 외래 키가 참조하는 대상 테이블의 컬럼명
@ManyToOne
  • 다대일 관계에서 사용
    • optional : false로 설정하면 연관된 엔티티가 항상 있어야 한다.
    • fetch : 해당 연관을 조회할 기본 전략을 설정한다. @ManyToOne의 기본값은 EAGER이며 예제는 LAZY를 명시한다.
    • cascade : 영속성 전이 기능 사용한다.
    • targetEntity : 연관된 엔티티의 타입정보를 설정

@ManyToOne은 여러 주문이 한 고객을 참조하는 것처럼 현재 엔티티가 다, 상대 엔티티가 일인 관계에 사용한다. 외래 키가 있는 쪽을 소유자로 두고 @JoinColumn으로 컬럼 이름을 정할 수 있다.

@Entity
class Order {
    @Id
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "customer_id", nullable = false)
    private Customer customer;
}
flowchart LR
  orders["orders.customer_id: 외래 키"] --> customers["customers.id: 기본 키"]
  Order[Order.customer 참조] --> Customer[Customer 엔티티]

@ManyToOne의 기본 페치 전략은 EAGER이지만, 연관 데이터를 항상 쓸 것이 아니라면 LAZY를 명시하고 실제 조회 SQL을 확인하는 편이 좋다. referencedColumnName을 생략하면 기본적으로 대상 기본 키를 참조한다.

cascade는 영속성 작업을 연관 엔티티에 전파한다. 고객을 여러 주문이 공유한다면 주문을 삭제할 때 고객까지 삭제되도록 설정해서는 안 된다. optional=false는 객체 관계의 필수성을 뜻하고 nullable=false는 외래 키 컬럼의 제약에 해당하므로 두 조건을 함께 확인한다.

조회할 때 생기는 문제

LAZY 관계는 Order를 읽을 때 Customer를 바로 가져오지 않을 수 있다. 주문 100건을 조회하고 반복문에서 각 주문의 customer.name을 읽으면 고객 조회 SQL이 추가로 여러 번 나갈 수 있다. 이것이 흔히 말하는 N+1 조회 문제다. 모든 관계를 EAGER로 바꾸기보다, 고객 정보가 필요한 화면의 조회 쿼리에서 페치 조인이나 조회 전용 DTO를 사용해 필요한 데이터를 한 번에 가져오는지 확인한다.

또한 지연 로딩된 관계를 EntityManager가 닫힌 뒤 처음 읽으면 사용할 수 없는 경우가 있다. 데이터를 사용할 트랜잭션과 영속성 컨텍스트 범위를 먼저 정하고, 화면에 보낼 값은 그 안에서 준비한다. 연관 관계는 객체 필드 하나로 끝나지 않는다. 외래 키 제약, 저장 순서, 조회 쿼리 수까지 함께 검토해야 실제 동작을 이해할 수 있다.

참고: Jakarta Persistence 3.0 @ManyToOne API

외래 키의 주인과 객체 참조는 따로 생각하기

주문과 고객을 양방향으로 탐색하고 싶다면 Customer.orders에 @OneToMany(mappedBy = "customer")를 선언할 수 있다. mappedBy의 값은 컬럼명이 아니라 Order 클래스의 필드명이다. DB에서 외래 키를 가진 orders 행을 변경하는 쪽은 Order.customer다. 양쪽 리스트만 수정하면 DB 외래 키가 바뀐다고 생각하는 오류가 자주 생긴다.

@Entity
class Customer {
    @Id
    private Long id;

    @OneToMany(mappedBy = "customer")
    private List<Order> orders = new ArrayList<>();

    public void addOrder(Order order) {
        orders.add(order);
        order.assignCustomer(this);
    }
}

@Entity
class Order {
    @Id
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "customer_id", nullable = false)
    private Customer customer;

    void assignCustomer(Customer customer) {
        this.customer = customer;
    }
}

이 코드는 관계를 맞추는 핵심만 보여준다. 별도 파일의 import, 기본 생성자, 생성자 또는 ID 생성 전략은 실제 프로젝트에 맞춰 추가해야 한다. 도메인 메서드 addOrder는 메모리에서 양쪽 참조를 맞추는 데 도움이 된다. 실제 FK 값은 관계의 주인인 Order.customer를 기준으로 저장된다. 연관 관계를 단방향으로만 써도 요구사항을 만족한다면 양방향 필드를 만들 필요는 없다. 필드가 늘수록 직렬화 순환과 상태 동기화 부담도 커진다.

주문 목록 화면의 SQL을 먼저 설계하기

주문 목록에 고객 이름이 필요하면 Order만 100건 가져온 뒤 반복문에서 order.getCustomer().getName()을 호출할 때 어떤 SQL이 나가는지 확인한다. 고객이 모두 다르면 SELECT가 1건 + 고객별 최대 100건이 될 수 있다. 같은 고객을 여러 주문이 공유하면 영속성 컨텍스트가 중복 조회를 줄일 수 있으므로 항상 정확히 101건이라는 표현은 피한다.

한 화면에 고객 이름만 필요하다면 DTO 조회가 단순할 수 있다. 엔티티와 고객을 함께 조작해야 한다면 해당 조회에서 join fetch를 검토한다. 여러 컬렉션을 동시에 fetch join하면 결과 행이 크게 증가하고 페이지네이션과 충돌할 수 있다. 문제를 해결했다고 믿기 전에 실제 SQL 수, 전송 행 수, 메모리 사용량을 비교한다.

flowchart TD
  A["주문 목록 요청"] --> B["Order 조회"]
  B --> C{"고객 필드 접근?"}
  C -- "아니오" --> D["주문만 반환"]
  C -- "예" --> E["추가 SQL 가능성 확인"]
  E --> F["DTO 조회 / 필요한 관계 fetch join"]
  F --> G["SQL 수와 결과 행 수 측정"]

관계 저장 실패와 조회 성능 실패는 원인이 다르다. FK가 비었다면 주인 쪽 필드를 확인하고, 연관 객체 접근 시 예외가 나면 트랜잭션 경계를, SQL이 너무 많다면 fetch 전략과 조회 쿼리를 확인한다. API 응답에 엔티티를 그대로 직렬화하면 양방향 참조와 지연 로딩이 응답 생성 시점에 문제를 일으킬 수 있으므로 응답 DTO로 변환하는 편이 명확하다.

참고: Jakarta Persistence 관계 매핑, Hibernate fetching 가이드.

같은 카테고리의 글