이 글은 Velog에서 이전한 글입니다. Velog 원문 보기
JPA 연관관계 설계, 진짜 제대로 하고 있나요?
JPA 를 다루다 보면 CascadeType, orphanRemoval, 연관관계 방향 같은 키워드를 자주 접하지만, 실제로 이를 잘 이해하고 도메인에 맞게 설계하는 것은 또 다른 이야기입니다.
이번 글에서는 제가 면접에서 겪은 실수를 바탕으로, 연관관계 설계 시 반드시 짚고 넘어가야 할 개념들을 정리하겠습니다.
✅ CascadeType이란?
Cascade
- JPA에서 연관된 엔티티에 영속성 전파(Persistence Propagation) 를 적용할 수 있도록 하는 설정입니다.
대표적인 옵션:
ALL: 모든 영속성 전파 (PERSIST, MERGE, REMOVE, REFRESH, DETACH)PERSIST: 부모 저장 시 자식도 함께 저장MERGE: 병합 시 자식도 병합REMOVE: 부모 삭제 시 자식도 함께 삭제REFRESH: 부모 새로 고침 시 자식도 갱신DETACH: 부모 detach 시 자식도 detach
⚠️ 참고:
CascadeType.ALL은 모든 전파를 포함하므로 편리하지만, 실무에서는 불필요한 전파로 인한 예기치 않은 삭제가 발생할 수 있어 주의가 필요합니다.
예시
게시글(Post)과 댓글(Comment)이 있을 때, Post를 삭제하면 Comment도 같이 삭제되길 원한다면 CascadeType.REMOVE를 사용할 수 있습니다.
@OneToMany(mappedBy = "post", cascade = CascadeType.REMOVE)
private List<Comment> comments = new ArrayList<>();
✅ orphanRemoval이란?
orphanRemoval = true
- 부모 객체에서 자식 객체를 컬렉션에서 제거하면, DB에서도 자동으로 삭제되도록 하는 설정입니다.
예시:
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Comment> comments = new ArrayList<>();
즉, comments.remove(comment) 했을 때 comment가 DB에서 자동으로 삭제됩니다.
단, 연관관계도 끊겨야 orphan이 됩니다. 참조는 남아 있는데 리스트에서만 제거했다면 삭제되지 않습니다.
🔎 예시: 연관관계가 끊기지 않으면 orphanRemoval이 작동하지 않는 경우
@Entity
class Parent {
@OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Child> children = new ArrayList<>();
public void removeChild(Child child) {
children.remove(child);
// child.setParent(null); ← 이 코드가 없으면 orphanRemoval 작동 ❌
}
}
위 코드에서 children.remove(child)만 실행하고 child.setParent(null)을 호출하지 않으면, JPA 입장에서는 자식(Child)이 여전히 부모(Parent)를 참조하고 있기 때문에 orphan 상태로 간주하지 않습니다.
즉, DB에서 삭제되지 않고 남아 있게 됩니다.
해결 방법
public void removeChild(Child child) {
children.remove(child);
child.setParent(null); // 연관관계도 함께 끊어줘야 orphanRemoval 작동
}
이처럼 양방향 연관관계일 때는 컬렉션에서 제거 + 반대편 참조 해제를 동시에 해줘야 orphanRemoval이 정상 작동합니다.
연관관계를 동기화해주는 편의 메서드가 중요한 이유입니다.
🔎 왜 양쪽을 다 끊어야 할까?
- 양방향 연관관계는 사실상 "단방향 두 개"의 합
- JPA는 주인 쪽(@ManyToOne이 달린 자식) 컬럼 값이 null이 되거나 삭제되야 orphan으로 판단
- 하지만 부모 쪽 컬렉션에서만 제거하면 자식 엔티티의 parent 필드에는 여전히 부모가 남아있어 -> DB에선 FK가 그대로 -> 삭제 X
✅ CascadeType vs orphanRemoval
| 항목 | CascadeType.REMOVE | orphanRemoval = true |
|---|---|---|
| 트리거 조건 | 부모 삭제 | 자식이 고아 상태가 될 때 |
| 삭제 시점 | 부모 삭제할 때 같이 | 컬렉션에서 제거 시 바로 |
| 목적 | 연쇄 삭제 | 고아 객체 자동 삭제 |
이 둘은 비슷해 보이지만 목적과 작동 방식이 다릅니다. 둘 다 쓰는 것도 가능하지만, 무조건 설정해선 안 됩니다.
❌ 잘못된 연관관계 설계 시 문제
❌ 잘못된 설계 예시: 단방향 OneToMany만 존재
@Entity
class Parent {
@Id
@GeneratedValue
private Long id;
@OneToMany
private List<Child> children = new ArrayList<>();
}
@Entity
class Child {
@Id
@GeneratedValue
private Long id;
private String name;
}
문제점:
mappedBy가 없기 때문에 JPA는Parent_Child라는 조인 테이블을 생성합니다.- 도메인상 부모-자식 관계임에도 불구하고, 중간 테이블로 인해 관계가 모호해집니다.
- 자식 엔티티(Child)는 부모를 전혀 알 수 없어 연관관계를 실질적으로 제어할 수 없습니다.
- 유지보수 시 문제가 생기기 쉬우며, 성능 최적화에도 방해가 됩니다.
✅ 올바른 설계 예시: 양방향 + 외래 키 주인 명확화
@Entity
class Parent {
@Id
@GeneratedValue
private Long id;
@OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Child> children = new ArrayList<>();
public void addChild(Child child) {
children.add(child);
child.setParent(this); // 연관관계 편의 메서드
}
}
@Entity
class Child {
@Id
@GeneratedValue
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id") // 외래 키 주인
private Parent parent;
public void setParent(Parent parent) {
this.parent = parent;
}
}
설명:
mappedBy = "parent": 자식이 연관관계 주인임을 명시- 외래 키는
child.parent_id컬럼으로 생성됨 addChild()메서드로 양방향 동기화 보장cascade와orphanRemoval을 통해 생명주기 관리 및 삭제 처리 가능
✅ JPA 생명주기와 CascadeType 관계
CascadeType은 단순한 편의 기능이 아니라, JPA 엔티티 생명주기와 긴밀하게 연결되어 있습니다.
| 생명주기 이벤트 | 관련 CascadeType |
|---|---|
persist() |
CascadeType.PERSIST, ALL |
remove() |
CascadeType.REMOVE, ALL |
merge() |
CascadeType.MERGE, ALL |
detach() |
CascadeType.DETACH, ALL |
refresh() |
CascadeType.REFRESH, ALL |
예를 들어 부모만 persist() 하면 자식도 함께 저장되길 원할 때 cascade = PERSIST가 필요합니다.
그렇지 않으면 TransientPropertyValueException 같은 예외가 발생할 수 있습니다.
✅ 연관관계 성능 최적화: 지연 vs 즉시 로딩, fetch join
JPA에서 연관관계의 fetch 전략은 성능에 큰 영향을 줍니다.
| 전략 | 설명 |
|---|---|
| LAZY (지연로딩) | 연관 객체를 실제 사용할 때 쿼리 실행 |
| EAGER (즉시로딩) | 엔티티 조회 시 연관 객체도 즉시 조회 |
기본 전략:
@ManyToOne,@OneToOne: 기본 EAGER@OneToMany,@ManyToMany: 기본 LAZY
실무에서는 모든 연관관계를 기본 LAZY로 설정하고,
필요할 때만 fetch join이나 EntityGraph로 명시적 로딩을 추천합니다.
예시:
select p from Post p join fetch p.comments where p.id = :id
- N+1 문제 방지
- 성능 개선
- 다만 fetch join은 페이징 시 사용 제한이 있으므로 주의 필요
✅ 단방향을 기본으로, 양방향은 정말 필요한 경우에만
JPA 연관관계는 단방향 설계를 기본 원칙으로 가져가고,
정말 필요한 경우에만 양방향 연관관계를 도입해야 합니다.
단방향 설계가 기본인 이유:
- 객체 간 참조만 있으면 충분 (DB처럼 항상 양방향일 필요 없음)
- 양방향은 결국 단방향 2개 + 동기화 메서드 필요
- 양방향은 N+1 문제, 무한루프 문제 (toString 등) 발생 가능
- Jackson 직렬화 시 순환 참조 문제 발생 → @JsonIgnore 등 필요
즉, 양방향이 필요하다면 그 이유가 "정말로 필요한지" 설명할 수 있어야 합니다.
❓면접 놓쳤던 질문 복습 정리
“왜 Cascade를 설정하셨어요?”
❌ 나쁜 답변 예시
"삭제할 때 같이 삭제되니까요" 라고만 답함
- 연관관계에서 의도를 코드로 명확히 드러내지 못함
- 단순히 기능적으로만 이해하고 있었고, 도메인 생명주기나 설계 원칙에 대한 고려 부족
❌ 좋은 답변 예시
해당 연관관계는 부모 엔티티와 자식 엔티티가 생명주기를 함께 공유하는 관계이기 때문에 Cascade를 설정했습니다.
예를 들어, 게시글이 삭제되면 그에 달린 댓글도 같이 삭제되어야 의미가 있기 때문에,
CascadeType.REMOVE를 통해 게시글 삭제 시 댓글도 함께 삭제되도록 설계했습니다.
마무리하며
JPA 연관관계 설정은 단순히 작동만 하면 되는 기능이 아니라,
도메인의 의도와 생명주기를 코드로 표현하는 중요한 설계 행위입니다.
CascadeType, orphanRemoval, 연관관계 방향, fetch 전략 하나하나가 결국은
“이 객체는 어떻게 살아가고, 누구와 함께 삭제되며, 어떻게 조회돼야 하는가”라는 도메인 질문에 대한 답이어야 합니다.
'Language·CS > Backend Interview' 카테고리의 다른 글
| DB Replication 구조와 Read/Write 분리 전략의 장단점 (0) | 2026.08.02 |
|---|---|
| 중복 요청, 어떻게 ‘한 번만’ 처리할 것인가 – 멱등성과 서버 설계의 핵심 원리 (0) | 2026.08.02 |
| 무중단 배포 전략(Rolling, Blue-Green, Canary ): 트래픽 제어부터 롤백까지 (0) | 2026.08.02 |
| Redis 키 네임스페이스와 Hash 구조, 실무에서 어떻게 설계할까? (0) | 2026.08.02 |