JPA 엔티티 연관관계 매핑

Uhae·3일 전

엔티티끼리 관계를 맺을 때 붙이는 어노테이션을 코드와 함께 정리했습니다. 지난 글에서 개념으로만 짚었던 연관관계의 주인이 코드에서 어떻게 드러나는지에 초점을 맞췄어요.

예제는 Spring Boot 3 (jakarta.persistence)와 Lombok 기준입니다.

1. 한눈에 보기

어노테이션관계예시FK 위치 (주인)
@ManyToOne다대일회원 N : 팀 1N쪽 (회원)
@OneToMany일대다팀 1 : 회원 N보통 mappedBy로 읽기 전용
@OneToOne일대일회원 1 : 사물함 1주 테이블 쪽 (선택)
@ManyToMany다대다회원 N : 상품 N연결 테이블 (실무에서는 지양)


2. @ManyToOne: 가장 기본이 되는 관계

회원(N)이 팀(1)에 소속되는 관계입니다. DB에서 FK는 항상 N쪽에 생기기 때문에, @ManyToOne이 붙은 쪽이 자연스럽게 연관관계의 주인이 됩니다.

2-1. 단방향

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {

 @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
 private Long id;

 private String name;

 @ManyToOne(fetch = FetchType.LAZY)
 @JoinColumn(name = "team_id") // FK 컬럼명
 private Team team;

 public Member(String name, Team team) {
 this.name = name;
 this.team = team;
 }
}
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Team {

 @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
 private Long id;

 private String name;

 public Team(String name) {
 this.name = name;
 }
}

Hibernate가 만들어주는 DDL입니다.

create table member (
 id bigint generated by default as identity,
 name varchar(255),
 team_id bigint,
 primary key (id)
);

alter table member
 add constraint fk_member_team
 foreign key (team_id) references team (id);
  • @JoinColumn(name = "team_id"): FK 컬럼을 지정합니다. 생략하면 필드명_PK컬럼명(team_id)으로 자동 생성됩니다.
  • fetch = LAZY: @ManyToOne의 기본값은 EAGER이므로, 명시적으로 LAZY를 지정하는 습관을 들이는 게 좋습니다.
Team team = new Team("백엔드");
em.persist(team);

Member member = new Member("hyun", team);
em.persist(member); // INSERT INTO member (name, team_id) VALUES ('hyun', 1)

3. 양방향 @OneToMany

팀에서 소속 회원을 꺼내고 싶다면 Team 쪽에도 컬렉션을 추가합니다. 이건 DB가 아니라 객체 탐색을 위한 편의 기능이고, 테이블 구조는 그대로입니다.

@Entity
public class Team {
 // ...

 @OneToMany(mappedBy = "team") // Member.team 필드가 주인
 private List<Member> members = new ArrayList<>();
}

mappedBy = "team"은 "연관관계의 주인은 Member의 team 필드이고, 나는 읽기만 한다" 는 선언입니다.

3-1. 가장 흔한 실수: 주인에 값을 세팅하지 않기

Team team = new Team("백엔드");
em.persist(team);

Member member = new Member("hyun", null);
team.getMembers().add(member); // ❌ 주인이 아닌 쪽에만 세팅
em.persist(member);
// INSERT INTO member (name, team_id) VALUES ('hyun', null) ← FK가 null!

FK는 주인(Member.team) 의 값으로만 저장됩니다. 그렇다고 주인에만 세팅하면, 같은 트랜잭션 안에서 team.getMembers()가 비어 있는 상태가 됩니다. 그래서 양쪽 모두 세팅하는 연관관계 편의 메서드를 만들어 둡니다.

public class Member {
 // ...

 public void changeTeam(Team team) {
 if (this.team != null) {
 this.team.getMembers().remove(this); // 기존 팀에서 제거
 }
 this.team = team;
 team.getMembers().add(this); // 새 팀에 추가
 }
}

3-2. 양방향 사용 시 주의점

  • toString(), Lombok @ToString: Member와 Team이 서로를 출력하며 무한 루프가 생깁니다. 연관관계 필드는 toString에서 제외합니다.
  • JSON 직렬화: 엔티티를 그대로 API 응답으로 내리면 같은 이유로 무한 순환이 일어납니다. DTO로 변환해서 반환하세요.
  • 원칙: 설계는 단방향으로 시작하고, 반대 방향 조회가 꼭 필요할 때만 양방향을 추가합니다. 양방향은 코드만 늘어나고 테이블에는 영향이 없습니다.

4. @OneToOne

회원 1명이 사물함 1개를 가지는 관계입니다. FK는 둘 중 어느 쪽 테이블에 둬도 됩니다.

4-1. 주 테이블에 FK (권장)

@Entity
public class Member {
 // ...

 @OneToOne(fetch = FetchType.LAZY)
 @JoinColumn(name = "locker_id") // member 테이블에 FK
 private Locker locker;
}
@Entity
public class Locker {

 @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
 private Long id;

 private String number;

 @OneToOne(mappedBy = "locker") // 양방향이 필요할 때만
 private Member member;
}
create table member (
 id bigint generated by default as identity,
 name varchar(255),
 locker_id bigint unique, -- 1:1 이라서 unique
 primary key (id)
);

4-2. 알아두면 좋은 점

  • 대상 테이블에 FK를 두는 단방향 1:1은 JPA가 지원하지 않습니다. 그쪽에 FK를 두려면 양방향으로 만들어야 합니다.
  • mappedBy(주인이 아닌) 쪽의 @OneToOne은 LAZY를 지정해도 즉시 로딩처럼 동작하는 경우가 많습니다. FK가 없는 쪽은 연관 객체가 null인지 확인하려면 어차피 조회를 해야 해서, 프록시를 만들 수 없기 때문입니다.
  • 그래서 자주 조회하는 쪽(주 테이블)에 FK를 두는 구조가 성능과 관리 면에서 유리합니다.

5. @ManyToMany는 쓰지 않는다

회원은 여러 상품을 주문하고, 상품도 여러 회원에게 주문됩니다. 코드로는 @ManyToMany 한 줄이면 됩니다.

@ManyToMany
@JoinTable(
 name = "member_product",
 joinColumns = @JoinColumn(name = "member_id"),
 inverseJoinColumns = @JoinColumn(name = "product_id")
)
private List<Product> products = new ArrayList<>();

JPA가 member_product 연결 테이블을 알아서 만들어주지만, 실무에서는 사용하지 않습니다.

  • 연결 테이블에 주문 시간, 수량 같은 컬럼을 추가할 수 없습니다.
  • 연결 테이블이 엔티티로 드러나지 않아서 어떤 쿼리가 나가는지 예측하기 어렵습니다.

5-1. 해결: 연결 엔티티로 풀어내기

@ManyToMany를 @OneToMany + @ManyToOne 두 개로 쪼갭니다.

@Entity
public class MemberProduct {

 @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
 private Long id;

 @ManyToOne(fetch = FetchType.LAZY)
 @JoinColumn(name = "member_id")
 private Member member;

 @ManyToOne(fetch = FetchType.LAZY)
 @JoinColumn(name = "product_id")
 private Product product;

 private int count; // 연결 테이블에 추가 컬럼 가능
 private LocalDateTime orderedAt;
}
// Member, Product 쪽
@OneToMany(mappedBy = "member")
private List<MemberProduct> memberProducts = new ArrayList<>();


6. 연관관계에서 자주 쓰는 속성

6-1. cascade (영속성 전이)

부모 엔티티의 상태 변화를 자식에게 함께 전파합니다.

@OneToMany(mappedBy = "team", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Member> members = new ArrayList<>();
Team team = new Team("백엔드");
Member hyun = new Member("hyun", team);
Member kim = new Member("kim", team);

team.getMembers().add(hyun); // cascade는 부모의 컬렉션을 따라 전파되므로
team.getMembers().add(kim); // 컬렉션에 담아야 함

em.persist(team); // Team만 persist 했는데 Member 2명도 함께 INSERT
옵션동작
PERSIST부모를 저장할 때 자식도 저장
REMOVE부모를 삭제할 때 자식도 삭제
MERGE병합 시 자식도 병합
ALL위 전부

6-2. orphanRemoval (고아 객체 제거)

부모와의 연관관계가 끊어진 자식을 자동으로 DELETE 합니다.

team.getMembers().remove(0); // 컬렉션에서만 제거했는데
// 커밋 시 DELETE FROM member WHERE id = ? 실행

⚠️ cascade와 orphanRemoval은 자식의 생명주기를 부모가 완전히 책임지는 경우(예: 주문과 주문상품)에만 사용합니다. 다른 엔티티도 참조하는 대상(예: 회원이 여러 곳에서 참조됨)에 걸면 의도치 않게 데이터가 삭제될 수 있습니다.


7. 정리

상황선택
N:1@ManyToOne(fetch = LAZY) + @JoinColumn, N쪽이 주인
반대 방향 조회가 필요@OneToMany(mappedBy = ...) 추가 + 연관관계 편의 메서드
1:1주 테이블(자주 조회하는 쪽)에 FK, @OneToOne(fetch = LAZY)
N:N@ManyToMany 대신 연결 엔티티
자식 생명주기를 부모가 관리cascade = ALL + orphanRemoval = true

실무 체크리스트

  • 모든 연관관계는 LAZY로 지정한다.
  • 단방향으로 먼저 설계하고, 필요할 때만 양방향을 추가한다.
  • 양방향이라면 편의 메서드를 만들고, toString과 JSON 응답(DTO 변환)에서 순환을 끊는다.
  • @ManyToMany는 쓰지 않는다.

다음 글에서는 이렇게 맺은 연관관계를 조회할 때 생기는 N+1 문제와 fetch join을 코드로 정리해보겠습니다.

0개의 댓글