프레임워크 · JPA · 연관관계 매핑

JPA · 연관관계 매핑

테이블끼리 외래키(FK)로 이어진 관계를, Java 엔티티? 객체끼리 서로 참조하는 모양으로 옮기는 일입니다. 주문이 사용자를 가리키고, 사용자는 자기 주문 목록을 가지는 식으로요.

테이블 세계는 "번호표"로 관계를 표현합니다. 주문 행에 user_id 번호가 적혀 있죠. 객체 세계는 번호 대신 실제 사람을 손에 쥐고 다닙니다. JPA는 "번호표 ↔ 실제 객체" 사이를 통역해 줍니다.
이 페이지의 버전 기준

Jakarta Persistence 3.1(import jakarta.persistence.*) · Hibernate? 6.x · Spring Boot? 3.3.1 · Java? 17 기준입니다.

연관관계 3요소

연관관계 하나를 정할 때는 세 가지를 결정합니다.

요소고르는 것설명
방향단방향 / 양방향한쪽만 상대를 참조하면 단방향, 양쪽이 서로 참조하면 양방향. DB 테이블은 방향이 없고(외래키 하나뿐), 방향은 오직 객체 세계의 이야기입니다.
다중성N:1 · 1:N · 1:1 · N:M"몇 대 몇"인가. 주문 여러 개가 사용자 한 명을 보면 N:1. 사용자 한 명이 주문 여러 개를 보면 1:N.
주인owner / 반대편외래키 컬럼을 실제로 가진 쪽이 연관관계의 주인입니다. 주인만 FK 값을 INSERT·UPDATE 할 수 있어요.

연관관계의 주인과 mappedBy

이게 JPA 연관관계에서 가장 헷갈리는 부분입니다. 양방향에서 두 객체가 서로를 가리키지만, DB에는 외래키 컬럼이 딱 하나뿐입니다. 그래서 "둘 중 누가 그 FK를 책임지는가"를 정해야 해요. 그게 주인(owner)입니다.

  • 주인 = 외래키를 가진 쪽. 보통 N:1의 N쪽(예: 주문). 주인 쪽 변경만 DB FK에 반영됩니다.
  • 반대편(주인이 아닌 쪽)mappedBy 로 "나는 주인이 아니고, 저쪽 필드가 주인이야"라고 알려줍니다. 반대편은 읽기 전용(조회용 거울)이에요.
mappedBy = "user" 는 "이 관계의 외래키는 상대 엔티티의 user 필드가 관리한다"는 뜻입니다. 거울에 비친 모습(반대편)을 아무리 바꿔도 실물(주인)이 안 바뀌면 DB는 그대로예요.
흔한 실수: 주인이 아닌 쪽만 바꾸기

mappedBy 가 붙은 컬렉션에만 값을 넣고 주인 쪽(order.user)을 안 채우면, DB의 외래키는 갱신되지 않습니다. 주인 쪽을 반드시 세팅해야 저장됩니다.

@ManyToOne — 가장 많이 쓰는 외래키 매핑

"여럿이 하나를 본다"(N:1). 주문 여러 개가 사용자 한 명을 가리키는, 실무에서 제일 자주 쓰는 매핑입니다. 외래키를 가진 쪽이라 항상 연관관계의 주인이에요.

@Entity
@Table(name = "tb_order", schema = "lds")
public class Order {
  @Id @GeneratedValue
  private UUID id;

  // 주문 여럿(N) → 사용자 하나(1). 이 쪽이 외래키 owner.
  @ManyToOne(fetch = FetchType.LAZY, optional = false)
  @JoinColumn(
      name = "user_id",                 // 외래키 컬럼 이름
      nullable = false,                 // FK 컬럼에 NOT NULL
      foreignKey = @ForeignKey(name = "fk_order_user"))  // FK 제약 이름
  private User user;
}
속성의미
@JoinColumn(name)이 엔티티 테이블에 만들 외래키 컬럼 이름. 생략하면 필드명_참조PK명 규칙으로 자동 생성.
nullableFK 컬럼이 NULL 허용인지(DDL의 NOT NULL 여부).
foreignKey생성될 FK 제약조건 이름 지정. 관리·디버깅 편의용.
optional@ManyToOne 자체 속성. false 면 "이 연관은 반드시 존재"(NOT NULL과 같은 맥락). 기본값 true.

@OneToMany — 컬렉션으로 1:N

"하나가 여럿을 본다"(1:N). 사용자 한 명이 자기 주문 목록을 가지는 쪽입니다. 보통 @ManyToOne반대편(양방향) 으로 씁니다. 외래키는 N쪽(주문)에 있으므로, 이 쪽은 주인이 아니라서 mappedBy 를 붙여 줍니다.

@Entity
@Table(name = "tb_user", schema = "lds")
public class User {
  @Id @GeneratedValue
  private UUID id;

  // mappedBy = "user": 외래키는 Order.user 필드가 관리한다(나는 주인 아님).
  @OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
  private List<Order> orders = new ArrayList<>();
}

컬렉션 타입은 List<Order> 또는 Set<Tag> 를 씁니다. 순서가 중요하면 List, 중복 없는 집합이면 Set이 자연스러워요.

단방향 @OneToMany + @JoinColumn 은 주의

mappedBy 없이 @OneToMany@JoinColumn 만 붙이면(단방향 1:N), 외래키가 N쪽 테이블에 있는데 주인은 1쪽이라 어긋납니다. 그러면 Hibernate가 주문을 저장한 뒤 별도 UPDATE 문으로 FK를 또 채우는 비효율이 생겨요. 가능하면 양방향(@ManyToOne 주인 + @OneToMany mappedBy) 으로 푸는 것을 권장합니다.

@OneToOne — 1:1

"하나가 하나를 본다"(1:1). 사용자와 사용자 상세정보처럼 일대일로 짝지을 때 씁니다. 외래키를 어느 테이블에 둘지(주 테이블 / 대상 테이블) 선택할 수 있고, FK를 가진 쪽이 주인입니다.

@Entity
public class User {
  @Id @GeneratedValue
  private UUID id;

  // 주인: profile_id 외래키를 User 테이블에 둠
  @OneToOne(fetch = FetchType.LAZY, optional = true)
  @JoinColumn(name = "profile_id")
  private UserProfile profile;
}

@Entity
public class UserProfile {
  @Id @GeneratedValue
  private UUID id;

  // 반대편(주인 아님): 외래키는 User.profile 이 관리
  @OneToOne(mappedBy = "profile", fetch = FetchType.LAZY)
  private User user;
}
  • 주 테이블에 FK: 위 예처럼 자주 접근하는 쪽 테이블에 외래키를 두는 방식. 가장 흔합니다.
  • 대상 테이블에 FK: 반대로 두면 향후 1:N 으로 확장할 때 유리할 수 있습니다.
  • optional = false 로 두면 짝이 반드시 존재한다는 뜻이고, 이 경우 LAZY 가 더 잘 동작합니다(NULL 가능성이 있으면 프록시 판단이 어려워 즉시 조회가 끼어들 수 있어요).

@ManyToMany — N:M (실무에선 풀어쓰기 권장)

"여럿이 여럿을 본다"(N:M). 사용자와 태그처럼 양쪽 다 여러 개를 가질 때입니다. 관계형 DB에는 N:M 을 직접 표현할 수 없어서 중간(연결) 테이블이 필요한데, @ManyToMany + @JoinTable 이 그 중간 테이블을 자동으로 만들어 줍니다.

@Entity
public class User {
  @Id @GeneratedValue
  private UUID id;

  @ManyToMany
  @JoinTable(
      name = "tb_user_tag",                                  // 연결 테이블
      joinColumns = @JoinColumn(name = "user_id"),           // 내 쪽 FK
      inverseJoinColumns = @JoinColumn(name = "tag_id"))     // 상대 쪽 FK
  private Set<Tag> tags = new HashSet<>();
}
실무 권장: @ManyToMany 대신 연결 엔티티로 풀어쓰기

자동 생성된 연결 테이블에는 두 외래키만 들어갑니다. 그런데 현실에서는 "언제 연결됐는지(가입일)", "권한 수준" 같은 추가 컬럼이 거의 항상 필요해져요. 그러면 @ManyToMany 로는 표현이 안 됩니다.

그래서 처음부터 중간 엔티티(예: UserTag)를 만들고, 그 안에 @ManyToOne 두 개(User, Tag)를 두는 방식이 권장됩니다. N:M 을 1:N + N:1 두 개로 풀어쓰는 거예요. 관리·확장 모두 편합니다.

@Entity
@Table(name = "tb_user_tag", schema = "lds")
public class UserTag {
  @Id @GeneratedValue
  private UUID id;

  @ManyToOne(fetch = FetchType.LAZY)
  @JoinColumn(name = "user_id")
  private User user;

  @ManyToOne(fetch = FetchType.LAZY)
  @JoinColumn(name = "tag_id")
  private Tag tag;

  @Column
  private Instant linkedAt;   // 연결 테이블에 추가 컬럼을 자유롭게!
}

FetchType — 언제 가져올까 (LAZY vs EAGER)

연관된 객체를 언제 DB에서 조회할지 정하는 옵션입니다.

  • LAZY(지연 로딩): 일단 가짜 객체(프록시)만 넣어 두고, 그 필드를 실제로 꺼내 쓰는 순간 DB를 조회합니다. 안 쓰면 조회도 안 해요.
  • EAGER(즉시 로딩): 부모를 조회할 때 연관된 것까지 곧바로 함께 조회합니다. 안 써도 무조건 가져옵니다.
LAZY 는 "필요하면 그때 부를게"(주문하면 그때 요리), EAGER 는 "미리 다 차려 둘게"(안 먹어도 한 상 가득). 안 먹을 음식까지 차리면 낭비죠.

각 연관관계의 기본 fetch 값(JPA 표준):

애너테이션기본 FetchType
@ManyToOneEAGER
@OneToOneEAGER
@OneToManyLAZY
@ManyToManyLAZY

외우는 법: ~ToOne 은 EAGER, ~ToMany 는 LAZY 가 기본입니다.

실무 권장: 전부 LAZY 로

EAGER 는 안 쓰는 데이터까지 끌어오고, N+1 문제(아래)의 단골 원인이라 예측이 어렵습니다. 그래서 실무에서는 모든 연관관계를 명시적으로 fetch = FetchType.LAZY 로 지정하는 것이 정석입니다. 필요한 곳에서만 fetch join·EntityGraph 로 함께 가져오면 돼요.

cascade — 영속성 전이

부모 엔티티에 한 작업(저장·삭제 등)을 자식 엔티티에도 자동으로 전파하는 기능입니다. 부모만 save 했는데 자식까지 같이 저장되게 하는 식이에요.

@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Order> orders = new ArrayList<>();
CascadeType전파되는 작업
PERSIST저장(persist)을 자식에게도
MERGE병합(merge)을 자식에게도
REMOVE삭제(remove)를 자식에게도
REFRESH새로고침(refresh)을 자식에게도
DETACH분리(detach)를 자식에게도
ALL위 전부

cascade 기본값은 "아무것도 전파 안 함"(빈 값)입니다. 필요할 때만 켜세요.

orphanRemoval 과의 차이

둘 다 자식을 삭제할 수 있어 헷갈리지만, 작동 조건이 다릅니다.

  • CascadeType.REMOVE: 부모를 삭제할 때 자식도 같이 삭제. 부모가 사라져야 동작합니다.
  • orphanRemoval = true: 부모의 컬렉션에서 자식을 빼내기만 해도(고아가 되면) 그 자식을 DELETE. 부모는 그대로 살아 있어도 됩니다. (user.getOrders().remove(order) → order 행 삭제)
REMOVE 는 "집을 허물 때 가구도 같이 버림". orphanRemoval 은 "집에서 가구 하나만 내놨더니 곧장 폐기됨". 부모(집)와의 연결이 끊긴 자식은 버려진다는 점이 핵심입니다.
자식 → 부모(@ManyToOne)에는 cascade 금지

주문에서 사용자로 가는 @ManyToOneREMOVE/ALL 을 걸면, 주문 하나 지우다가 사용자까지 삭제되는 사고가 납니다. cascade·orphanRemoval 은 부모가 자식을 소유하는 방향(보통 @OneToMany)에만 거세요.

N+1 문제와 해결책

연관관계를 다룰 때 가장 자주 만나는 성능 함정입니다.

원인: 사용자 목록 N명을 한 번에 조회한 뒤, 반복문에서 각 사용자의 orders(LAZY)에 접근하면, 사용자마다 주문 조회 SQL이 따로 나갑니다. 결국 목록 1번 + 각 건별 N번 = 총 N+1번의 쿼리가 실행돼요. 100명이면 101번입니다.

// 사용자 100명 조회 → 쿼리 1번
List<User> users = userRepo.findAll();
for (User u : users) {
  u.getOrders().size();   // 사용자마다 주문 조회 → 쿼리 100번 추가!  (총 101)
}

해결책 (EAGER 로 바꾸는 건 답이 아닙니다 — 오히려 항상 N+1 을 유발):

방법설명
fetch join (JPQL)join fetch 로 연관 엔티티를 한 번의 JOIN 쿼리로 같이 조회. 가장 직접적인 해결.
@EntityGraph리포지토리 메서드에 어떤 연관을 함께 가져올지 선언. JPQL 안 쓰고 fetch join 효과.
batch fetch sizeHibernate 설정(hibernate.default_batch_fetch_size). LAZY 컬렉션들을 모아 IN (...) 으로 한 번에 조회. N+1 을 1+1 수준으로 완화.
@BatchSize특정 엔티티/컬렉션에만 batch 크기를 지정. 위 전역 설정의 개별 버전.
// 1) fetch join — JPQL
@Query("select u from User u join fetch u.orders")
List<User> findAllWithOrders();

// 2) @EntityGraph — 함께 가져올 연관을 선언
@EntityGraph(attributePaths = "orders")
List<User> findAll();

// 3) 전역 batch fetch size — application.properties
//    spring.jpa.properties.hibernate.default_batch_fetch_size=100

// 4) @BatchSize — 컬렉션/엔티티에 직접
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
@BatchSize(size = 100)
private List<Order> orders = new ArrayList<>();

양방향 편의 메서드 + 무한루프 주의

양방향에서는 객체 두 개가 서로를 가리켜야 일관성이 맞습니다. 한쪽만 세팅하면 메모리상의 객체 상태가 어긋나요. 그래서 양쪽을 한 번에 맞춰 주는 편의 메서드를 두는 게 정석입니다.

@Entity
public class User {
  @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
  private List<Order> orders = new ArrayList<>();

  // 편의 메서드: 양쪽을 동시에 동기화
  public void addOrder(Order order) {
    orders.add(order);     // 1쪽 컬렉션에 추가
    order.setUser(this);   // 주인(N쪽) FK 필드도 세팅 → DB 반영의 핵심!
  }
}
toString · JSON 직렬화의 무한루프

양방향이면 User → orders → 각 Order → user → orders … 로 서로를 끝없이 참조합니다. toString() 이나 JSON 직렬화에서 이걸 그대로 타면 StackOverflow / 무한루프가 나요.

대책: 한쪽 필드를 toString()·equals()에서 제외하고, JSON 응답에는 엔티티를 직접 내보내지 말고 DTO 로 변환하세요. 부득이하면 한쪽에 @JsonIgnore(또는 @JsonManagedReference/@JsonBackReference)를 답니다.

한눈에 — 연관관계 카탈로그

애너테이션다중성기본 fetch주요 속성
@ManyToOneN : 1EAGER@JoinColumn, optional, cascade (항상 FK 주인)
@OneToMany1 : NLAZYmappedBy, cascade, orphanRemoval
@OneToOne1 : 1EAGER@JoinColumn / mappedBy, optional
@ManyToManyN : MLAZY@JoinTable(joinColumns/inverseJoinColumns), mappedBy
꼭 기억할 두 가지

1) N+1 을 항상 의식하라. 컬렉션을 반복하며 LAZY 필드를 건드리면 쿼리가 폭발합니다. fetch join·EntityGraph·batch size 로 막으세요.

2) EAGER 는 지양하라. ~ToOne 기본이 EAGER 라 무심코 두면 안 쓰는 조회가 따라옵니다. 전부 FetchType.LAZY 로 명시하는 습관을.

이 프로젝트와의 관계

이 프로젝트의 엔티티들도 위 규칙 그대로 매핑합니다. 단순 조회는 메서드 이름 쿼리로, 여러 테이블을 동적으로 조인해 가져오는 복잡한 조회는 QueryDSL 로 처리해 N+1 을 피합니다. 실제 코드와 데이터 접근 계층은 be-04 데이터 접근 에서 다룹니다.

다음 단계