JPA 연관관계 매핑 완벽 가이드: 단방향·양방향, 연관관계 주인, Cascade까지

JPA 연관관계 매핑 완벽 가이드: 단방향·양방향, 연관관계 주인, Cascade까지

“@ManyToOne과 @OneToMany의 차이가 뭔가요?”, “연관관계의 주인이 뭔가요?”, “양방향이 좋은 건가요?” — JPA 면접에서 빠지지 않는 질문들이다. 객체의 참조와 테이블의 외래 키는 본질적으로 다르고, 이 차이를 이해해야 올바른 매핑을 할 수 있다.


1. 객체 연관관계와 테이블 연관관계의 차이

1.1 근본적인 차이

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
┌─────────────────────────────────────────────────────────────────────┐
│           객체 연관관계 vs 테이블 연관관계                            │
├──────────────────────┬──────────────────────────────────────────────┤
│   객체 (Java)        │   테이블 (DB)                                │
├──────────────────────┼──────────────────────────────────────────────┤
│ 참조(reference)로 연관│ 외래 키(FK)로 연관                          │
│                      │                                              │
│ 단방향이 기본        │ FK 하나로 양방향 조인 가능                   │
│ (필드가 있는 쪽만    │ (JOIN 방향에 제한 없음)                      │
│  탐색 가능)          │                                              │
│                      │                                              │
│ member.getTeam() ✅  │ SELECT * FROM member                        │
│ team.getMembers() ❌ │ JOIN team ON member.team_id = team.id       │
│ (필드 없으면 불가)   │ (양쪽 다 가능)                              │
│                      │                                              │
│ 양방향 하려면        │ FK 하나면 충분                               │
│ 양쪽 모두에 참조 필요│                                              │
└──────────────────────┴──────────────────────────────────────────────┘

1.2 패러다임 불일치

1
2
3
4
5
6
7
8
9
10
// 객체: 참조로 탐색
Member member = memberRepository.findById(1L);
Team team = member.getTeam();          // member → team 탐색 가능
// team.getMembers()는 Team에 List<Member>가 없으면 불가능!

// 테이블: FK로 양방향 조인
// member → team
SELECT * FROM member m JOIN team t ON m.team_id = t.id;
// team → member (같은 FK로 반대 방향도 가능)
SELECT * FROM team t JOIN member m ON t.id = m.team_id;

이 차이 때문에 JPA는 연관관계의 주인(mappedBy) 개념을 도입했다.


2. 단방향 vs 양방향 연관관계

2.1 단방향 연관관계

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Member → Team 단방향 (가장 기본, 권장)
@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;  // Member에서 Team 참조 가능
}

@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;

    private String name;
    // Team에서 Member 참조 불가 — members 필드가 없음
}
1
2
3
4
5
6
7
8
9
10
11
Member 테이블               Team 테이블
┌────┬──────┬─────────┐    ┌────┬──────┐
│ id │ name │ team_id │    │ id │ name │
├────┼──────┼─────────┤    ├────┼──────┤
│ 1  │ 홍길동│ 1       │    │ 1  │ A팀  │
│ 2  │ 김철수│ 1       │    │ 2  │ B팀  │
│ 3  │ 이영희│ 2       │    └────┴──────┘
└────┴──────┴─────────┘
         ↑
    FK(team_id)가 Member에 있음
    → @ManyToOne 쪽이 FK를 가짐

2.2 양방향 연관관계

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;
}

@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;

    private String name;

    @OneToMany(mappedBy = "team")  // "나는 주인이 아니야, Member.team이 주인이야"
    private List<Member> members = new ArrayList<>();
}
1
2
3
4
// 양방향이면 양쪽 모두 탐색 가능
Member member = memberRepository.findById(1L);
Team team = member.getTeam();              // member → team ✅
List<Member> members = team.getMembers();  // team → members ✅

2.3 단방향이 기본인 이유

1
2
3
4
5
6
7
8
9
10
11
12
양방향의 비용:
● 양쪽에 참조를 관리해야 함 → 편의 메서드 필요
● 순환 참조 위험 (JSON 직렬화 시 무한 루프)
● 연관관계 주인 개념 이해 필요
● 코드 복잡도 증가

단방향으로 충분한 경우:
● JPQL/QueryDSL로 반대 방향 조회 가능
  → SELECT m FROM Member m WHERE m.team.id = :teamId
● 비즈니스 로직에서 역방향 탐색이 필요할 때만 양방향 추가

결론: 단방향으로 설계 시작, 필요할 때만 양방향 추가

3. 연관관계 어노테이션

3.1 @ManyToOne (다대일) — 가장 중요!

1
2
3
4
5
6
7
8
// 가장 많이 쓰는 연관관계: Member(N) → Team(1)
@Entity
public class Member {

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

@ManyToOne이 가장 중요한 이유: DB에서 FK는 항상 “다(N)” 쪽에 있고, @ManyToOne이 그 FK를 매핑한다.

3.2 @OneToMany (일대다)

1
2
3
4
5
6
7
// Team(1) ← Member(N) — 양방향의 역방향
@Entity
public class Team {

    @OneToMany(mappedBy = "team")  // 읽기 전용 (FK를 관리하지 않음)
    private List<Member> members = new ArrayList<>();
}
1
2
3
4
5
6
7
8
9
// ❌ @OneToMany 단방향은 쓰지 마라
@Entity
public class Team {
    @OneToMany
    @JoinColumn(name = "team_id")  // Team에서 Member의 FK를 관리
    private List<Member> members = new ArrayList<>();
}
// 문제: Team을 저장할 때 Member에 UPDATE 쿼리가 추가 발생 (성능↓, 혼란↑)
// INSERT INTO member ... → INSERT INTO member ... → UPDATE member SET team_id = ?

3.3 @OneToOne (일대일)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 주 테이블에 FK (권장)
@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

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

@Entity
public class Locker {
    @Id @GeneratedValue
    private Long id;

    private String name;

    @OneToOne(mappedBy = "locker")    // 양방향 시
    private Member member;
}
1
2
3
4
5
⚠️ @OneToOne 주의사항:
● 대상 테이블에 FK를 두면 LAZY 로딩이 안 됨
  (Locker에 member_id FK → Member.locker를 LAZY로 못 가져옴)
● 주 테이블에 FK를 두면 LAZY 로딩 가능
  → Member에 locker_id FK를 두는 것이 권장

3.4 @ManyToMany (다대다) — 실무에서 사용 금지!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// ❌ 이론적으로 가능하지만 실무에서 절대 쓰지 마라
@Entity
public class Member {
    @ManyToMany
    @JoinTable(name = "member_product")
    private List<Product> products = new ArrayList<>();
}

// ✅ 중간 테이블을 엔티티로 승격시켜라
@Entity
public class MemberProduct {
    @Id @GeneratedValue
    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 orderAmount;       // 중간 테이블에 추가 컬럼 가능!
    private LocalDateTime orderDate;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
┌─────────────────────────────────────────────────────────────────────┐
│              @ManyToMany를 쓰면 안 되는 이유                         │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  @ManyToMany가 자동 생성하는 중간 테이블:                            │
│  ┌─────────┬────────────┐                                          │
│  │member_id│ product_id │  ← 딱 이 두 컬럼만 있음                  │
│  └─────────┴────────────┘                                          │
│                                                                     │
│  실무에서 필요한 중간 테이블:                                        │
│  ┌────┬─────────┬────────────┬────────┬───────────┐               │
│  │ id │member_id│ product_id │ amount │ order_date│               │
│  └────┴─────────┴────────────┴────────┴───────────┘               │
│                                                                     │
│  → 중간 테이블에 추가 컬럼이 필요한 경우가 대부분                   │
│  → 중간 테이블을 엔티티로 만들어야 유연하게 확장 가능                │
│  → @ManyToMany는 쿼리 제어도 어렵고, 예상치 못한 쿼리 발생         │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

4. 연관관계의 주인과 mappedBy

4.1 연관관계의 주인이란?

양방향 연관관계에서 FK를 관리하는(INSERT/UPDATE) 쪽이 연관관계의 주인이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
┌─────────────────────────────────────────────────────────────────────┐
│                    연관관계의 주인 규칙                               │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  DB 테이블에서 FK가 있는 쪽 = 연관관계의 주인                       │
│                                                                     │
│  Member 테이블에 team_id(FK)가 있으므로                             │
│  → Member.team이 연관관계의 주인                                    │
│  → @ManyToOne 쪽이 주인 (항상!)                                    │
│                                                                     │
│  Team.members에 mappedBy = "team"                                  │
│  → "나는 주인이 아니야, Member.team 필드가 주인이야"                │
│  → 읽기만 가능, FK를 변경할 수 없음                                │
│                                                                     │
│  ★ 외래 키가 있는 곳 = 주인 = @ManyToOne 쪽                       │
│  ★ mappedBy가 있는 곳 = 주인이 아님 = 읽기 전용                   │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

4.2 주인만 FK를 변경할 수 있다

1
2
3
4
5
6
7
8
9
// ✅ 올바른 코드: 주인(Member.team)을 통해 연관관계 설정
Member member = new Member("홍길동");
member.setTeam(team);  // FK(team_id) 값이 INSERT됨
memberRepository.save(member);

// ❌ 잘못된 코드: 주인이 아닌 쪽(Team.members)에만 추가
team.getMembers().add(member);  // DB에 반영 안 됨! (mappedBy라서)
teamRepository.save(team);
// → member.team_id는 null → 연관관계 설정 실패

4.3 양방향 편의 메서드

양방향 매핑 시 양쪽 모두 값을 설정해야 객체 그래프가 일관된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Entity
public class Member {

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;

    // 양방향 편의 메서드
    public void changeTeam(Team team) {
        // 기존 팀에서 제거
        if (this.team != null) {
            this.team.getMembers().remove(this);
        }
        this.team = team;                 // 주인 쪽 설정 (DB 반영)
        if (team != null) {
            team.getMembers().add(this);  // 역방향도 설정 (객체 일관성)
        }
    }
}
1
2
3
4
5
6
7
8
9
// 사용
member.changeTeam(teamA);
// → member.team = teamA (DB에 반영됨)
// → teamA.members에 member 추가 (객체 일관성)

// 편의 메서드 없이 한쪽만 설정하면:
member.setTeam(teamA);
System.out.println(teamA.getMembers().size()); // 0 (영속성 컨텍스트에 반영 안 됨!)
// → 같은 트랜잭션 내에서 teamA.getMembers()를 조회하면 member가 없음

5. FetchType.LAZY vs EAGER

5.1 차이

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
┌─────────────────────────────────────────────────────────────────────┐
│                LAZY vs EAGER 로딩                                    │
├──────────────────────┬──────────────────────────────────────────────┤
│   LAZY (지연 로딩)   │   EAGER (즉시 로딩)                          │
├──────────────────────┼──────────────────────────────────────────────┤
│ 연관 엔티티를 실제    │ 엔티티 조회 시 연관 엔티티도                │
│ 사용할 때 쿼리 실행   │ 함께 즉시 조회                              │
│                      │                                              │
│ member.getTeam()     │ SELECT m.*, t.*                              │
│ → 이 시점에 쿼리 실행│   FROM member m                              │
│                      │   JOIN team t ...                            │
│                      │ → member 조회 시 team도 함께                 │
│                      │                                              │
│ ★ 실무 기본값       │ ❌ 실무에서 사용 금지                        │
│ 필요한 데이터만 로딩  │ 불필요한 조인, N+1 문제 원인                │
└──────────────────────┴──────────────────────────────────────────────┘

5.2 기본값과 권장 설정

1
2
3
4
5
6
7
// @ManyToOne, @OneToOne → 기본값이 EAGER ⚠️
// → 반드시 LAZY로 변경해야 함!
@ManyToOne(fetch = FetchType.LAZY)   // ★ 필수
@OneToOne(fetch = FetchType.LAZY)    // ★ 필수

// @OneToMany, @ManyToMany → 기본값이 LAZY
@OneToMany(mappedBy = "team")        // 이미 LAZY (그대로 둠)
1
2
3
4
5
6
실무 원칙: 모든 연관관계는 LAZY로 설정하고,
필요한 경우에만 fetch join 또는 EntityGraph로 한 번에 조회한다.

// LAZY로 설정 + 필요할 때 fetch join
@Query("SELECT m FROM Member m JOIN FETCH m.team WHERE m.id = :id")
Optional<Member> findByIdWithTeam(@Param("id") Long id);

6. cascade와 orphanRemoval

6.1 cascade (영속성 전이)

부모 엔티티를 저장/삭제할 때 자식 엔티티도 함께 저장/삭제한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Entity
public class Order {
    @Id @GeneratedValue
    private Long id;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> orderItems = new ArrayList<>();

    // 편의 메서드
    public void addOrderItem(OrderItem item) {
        orderItems.add(item);
        item.setOrder(this);
    }
}

@Entity
public class OrderItem {
    @Id @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id")
    private Order order;

    private String productName;
    private int price;
}
1
2
3
4
5
6
7
8
9
10
11
12
// cascade = ALL이면 Order를 저장할 때 OrderItem도 자동 저장
Order order = new Order();
order.addOrderItem(new OrderItem("상품A", 10000));
order.addOrderItem(new OrderItem("상품B", 20000));

orderRepository.save(order);
// → INSERT order + INSERT orderItem x2 (총 3개 INSERT)
// → orderItem을 별도로 save() 할 필요 없음!

// 삭제도 마찬가지
orderRepository.delete(order);
// → DELETE orderItem x2 + DELETE order (자식 먼저 삭제)

6.2 cascade 옵션

1
2
3
4
CascadeType.PERSIST  — 저장 시 함께 저장
CascadeType.REMOVE   — 삭제 시 함께 삭제
CascadeType.MERGE    — 병합 시 함께 병합
CascadeType.ALL      — 위 전체 (가장 많이 사용)

6.3 cascade를 써야 하는 경우 vs 쓰면 안 되는 경우

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
┌─────────────────────────────────────────────────────────────────────┐
│                cascade 사용 기준                                     │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  ✅ 써야 하는 경우:                                                 │
│  ● 부모-자식 관계가 명확할 때 (Order ↔ OrderItem)                  │
│  ● 자식의 생명주기가 부모에 완전히 종속될 때                        │
│  ● 자식이 다른 엔티티에서 참조되지 않을 때 (단일 소유자)           │
│                                                                     │
│  ❌ 쓰면 안 되는 경우:                                              │
│  ● 자식이 여러 부모에서 참조될 때 (Member ↔ Team)                  │
│    → Team 삭제 시 Member도 삭제되면 큰일!                          │
│  ● 자식의 생명주기가 부모와 독립적일 때                             │
│  ● 다른 엔티티에서도 자식을 참조할 때                               │
│                                                                     │
│  쉬운 판단 기준:                                                    │
│  "부모가 삭제되면 자식도 삭제되어야 하는가?"                        │
│    → YES: cascade 사용 (Order 삭제 → OrderItem 삭제)               │
│    → NO:  cascade 사용 금지 (Team 삭제 → Member 삭제? ❌)          │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

6.4 orphanRemoval (고아 객체 제거)

부모의 컬렉션에서 제거된 자식을 자동으로 DELETE한다.

1
2
3
4
5
@Entity
public class Order {
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<OrderItem> orderItems = new ArrayList<>();
}
1
2
3
4
5
6
Order order = orderRepository.findById(1L);
order.getOrderItems().remove(0);  // 컬렉션에서 제거
// → orphanRemoval = true이므로 해당 OrderItem에 DELETE 쿼리 실행!

order.getOrderItems().clear();    // 전체 제거
// → 모든 OrderItem에 DELETE 실행
1
2
3
4
5
6
cascade = REMOVE vs orphanRemoval = true 차이:
● cascade REMOVE: 부모 삭제 시 자식도 삭제
● orphanRemoval:  컬렉션에서 제거해도 자식 삭제

orphanRemoval은 cascade보다 더 강력하다.
→ 부모가 삭제되지 않아도, 컬렉션에서 빠지면 삭제됨

7. 연관관계 매핑 실수로 생기는 문제

7.1 mappedBy를 안 쓰고 양쪽 다 @JoinColumn

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ❌ 양쪽 모두 FK를 관리하려고 함
@Entity
public class Member {
    @ManyToOne
    @JoinColumn(name = "team_id")
    private Team team;
}

@Entity
public class Team {
    @OneToMany
    @JoinColumn(name = "team_id")  // mappedBy 대신 @JoinColumn
    private List<Member> members;
}
// → 두 곳에서 같은 FK(team_id)를 관리 → 예상치 못한 UPDATE 쿼리 발생
// → 반드시 한쪽에 mappedBy를 써서 주인을 명확히!

7.2 주인이 아닌 쪽에만 값 설정

1
2
3
4
5
6
7
8
9
10
11
// ❌ 가장 흔한 실수
Team team = new Team("A팀");
Member member = new Member("홍길동");

team.getMembers().add(member);  // 주인이 아닌 쪽에만 설정
teamRepository.save(team);
memberRepository.save(member);
// → member.team_id = null! (FK가 설정 안 됨)

// ✅ 주인 쪽에 설정해야 함
member.setTeam(team);  // 또는 member.changeTeam(team)

7.3 EAGER 로딩 + N+1 문제

1
2
3
4
5
6
7
8
9
10
11
12
13
// ❌ @ManyToOne이 기본 EAGER
@Entity
public class Member {
    @ManyToOne  // 기본값 EAGER!
    @JoinColumn(name = "team_id")
    private Team team;
}

List<Member> members = memberRepository.findAll();
// 1. SELECT * FROM member (멤버 N명)
// 2. SELECT * FROM team WHERE id = 1 (멤버1의 팀)
// 3. SELECT * FROM team WHERE id = 2 (멤버2의 팀)
// ... N번 추가 쿼리! → N+1 문제
1
2
3
4
5
6
7
8
// ✅ LAZY + fetch join으로 해결
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id")
private Team team;

@Query("SELECT m FROM Member m JOIN FETCH m.team")
List<Member> findAllWithTeam();
// → 쿼리 1번으로 Member + Team 모두 조회

7.4 양방향 순환 참조 (JSON 직렬화)

1
2
3
4
5
6
7
8
9
10
11
12
13
// ❌ 양방향 엔티티를 그대로 JSON 반환하면 무한 루프
@GetMapping("/api/members/{id}")
public Member getMember(@PathVariable Long id) {
    return memberRepository.findById(id).get();
    // member.team → team.members → member.team → ... 무한 루프!
}

// ✅ 해결: DTO로 변환하여 반환 (가장 권장하는 방법)
@GetMapping("/api/members/{id}")
public MemberResponse getMember(@PathVariable Long id) {
    Member member = memberService.findById(id);
    return MemberResponse.from(member);  // 필요한 필드만 담은 DTO
}

8. 실무 설계 가이드

8.1 연관관계 매핑 체크리스트

1
2
3
4
5
6
1. @ManyToOne 단방향으로 시작
2. fetch = FetchType.LAZY 필수
3. 역방향 탐색이 정말 필요한 경우에만 @OneToMany + mappedBy 추가
4. @ManyToMany는 중간 엔티티로 풀어서 사용
5. cascade는 생명주기가 완전히 종속된 경우에만
6. 엔티티를 직접 반환하지 말고 DTO로 변환

8.2 연관관계 선택 가이드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
┌─────────────────────────────────────────────────────────────────────┐
│                    연관관계 선택 가이드                               │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  "N:1 관계인가?" (대부분 이 경우)                                   │
│    → @ManyToOne 단방향 (기본)                                      │
│    → 역방향 필요 시 @OneToMany(mappedBy) 추가                      │
│                                                                     │
│  "1:1 관계인가?"                                                    │
│    → @OneToOne + LAZY + 주 테이블에 FK                             │
│    → 정말 1:1인지 재확인 (미래에 1:N이 될 수 있음)                  │
│                                                                     │
│  "N:M 관계인가?"                                                    │
│    → 중간 테이블을 엔티티로 만들어 1:N + N:1로 풀기                │
│    → @ManyToMany 절대 금지                                          │
│                                                                     │
│  "부모-자식 생명주기가 같은가?"                                      │
│    → cascade = ALL + orphanRemoval = true                           │
│    → Order ↔ OrderItem, Board ↔ BoardImage 등                     │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

면접 예상 질문 & 답변

Q1. 연관관계의 주인이 무엇이고, 왜 필요한가요?

양방향 연관관계에서 외래 키를 관리하는 쪽이 연관관계의 주인입니다. 객체는 양방향이면 양쪽에 참조가 있지만, DB의 FK는 하나뿐이므로 어느 쪽이 FK를 관리할지 정해야 합니다.

FK가 있는 테이블의 엔티티(= @ManyToOne 쪽)가 주인이 되고, 반대쪽은 mappedBy로 읽기 전용임을 표시합니다. 주인만 FK 값을 변경할 수 있고, mappedBy 쪽은 조회만 가능합니다.

Q2. @ManyToOne과 @OneToMany의 차이는?

@ManyToOne다(N) 쪽에서 일(1) 쪽을 참조합니다. DB의 FK가 있는 쪽이며, 연관관계의 주인입니다. MemberTeam을 참조하는 것이 대표적입니다.

@OneToMany일(1) 쪽에서 다(N) 쪽의 컬렉션을 참조합니다. 보통 mappedBy와 함께 사용하며 읽기 전용입니다. 양방향이 필요할 때만 추가합니다.

실무에서는 @ManyToOne 단방향이 기본이고, 역방향 탐색이 꼭 필요한 경우에만 @OneToMany(mappedBy)를 추가합니다.

Q3. 왜 @ManyToMany를 실무에서 쓰면 안 되나요?

@ManyToMany가 자동 생성하는 중간 테이블에는 두 FK 컬럼만 들어갑니다. 실무에서는 중간 테이블에 주문 수량, 생성일시 등 추가 컬럼이 필요한 경우가 대부분입니다.

또한 자동 생성된 테이블은 쿼리 제어가 어렵고 예상치 못한 쿼리가 발생합니다. 따라서 중간 테이블을 별도 엔티티로 만들어 @ManyToOne 두 개로 풀어서 사용해야 합니다.

Q4. LAZY와 EAGER 로딩의 차이는?

LAZY는 연관 엔티티를 실제로 사용(.getTeam())할 때 쿼리를 실행합니다. EAGER는 엔티티 조회 시 연관 엔티티도 즉시 함께 조회합니다.

실무에서는 모든 연관관계를 LAZY로 설정합니다. EAGER는 불필요한 조인을 유발하고 N+1 문제의 원인이 됩니다. 연관 데이터가 필요한 경우에는 fetch join이나 @EntityGraph로 명시적으로 함께 조회합니다.

주의할 점은 @ManyToOne@OneToOne의 기본값이 EAGER이므로, 반드시 fetch = FetchType.LAZY를 명시해야 합니다.

Q5. cascade와 orphanRemoval의 차이는?

cascade는 부모 엔티티를 저장·삭제할 때 자식도 함께 저장·삭제합니다. 예를 들어 cascade = ALL이면 부모를 save()할 때 자식도 자동 저장됩니다.

orphanRemoval = true는 부모의 컬렉션에서 자식을 제거하면 해당 자식에 DELETE 쿼리가 실행됩니다. 부모를 삭제하지 않아도 컬렉션에서 빠지면 삭제되므로 더 강력합니다.

두 옵션 모두 자식의 생명주기가 부모에 완전히 종속될 때만 사용해야 합니다. 여러 엔티티에서 참조되는 자식에 cascade를 쓰면 의도치 않은 삭제가 발생합니다.

Q6. 양방향 편의 메서드가 필요한 이유는?

양방향 연관관계에서 주인 쪽(member.setTeam(team))에만 값을 설정하면 DB에는 반영되지만, 같은 트랜잭션 내에서 team.getMembers()를 조회하면 member가 포함되지 않습니다. 영속성 컨텍스트의 1차 캐시에는 아직 반영 안 되었기 때문입니다.

편의 메서드는 양쪽 모두 값을 설정하여 객체 그래프의 일관성을 유지합니다. 보통 연관관계의 주인 쪽 엔티티에 changeTeam() 같은 메서드를 만들어 양쪽을 동시에 설정합니다.

Q7. 엔티티를 API 응답으로 직접 반환하면 안 되는 이유는?

첫째, 양방향 연관관계가 있으면 JSON 직렬화 시 무한 루프가 발생합니다. 둘째, 엔티티 구조가 변경되면 API 스펙도 함께 변경되어 클라이언트에 영향을 줍니다. 셋째, 불필요한 필드(비밀번호 등)가 노출될 위험이 있습니다.

따라서 항상 DTO로 변환하여 응답해야 합니다. DTO에는 클라이언트에게 필요한 필드만 담고, 엔티티 변경이 API에 영향을 주지 않도록 분리합니다.


마무리

JPA 연관관계 매핑의 핵심을 정리하면:

  • @ManyToOne 단방향이 기본: 역방향이 필요할 때만 양방향 추가
  • 연관관계의 주인 = FK가 있는 쪽: @ManyToOne 쪽이 항상 주인
  • mappedBy = 읽기 전용: 주인이 아닌 쪽은 FK를 변경할 수 없음
  • LAZY 필수: @ManyToOne, @OneToOne의 기본값(EAGER)을 반드시 변경
  • @ManyToMany 금지: 중간 엔티티로 풀어서 사용
  • cascade는 신중하게: 생명주기가 완전히 종속된 경우만
  • 엔티티 직접 반환 금지: 항상 DTO 변환