우리 팀의 컨벤션은 객체 매핑(Object Mapping)을 거의 쓰지 않는 쪽으로 흘러왔다.
@ManyToMany는 물론이고, 양방향 @OneToMany도 잘 쓰지 않는다. 심할 때는 "연관관계 매핑 자체를 최소화하자"는 분위기까지 간다.
그래서 한 번 정리해두고 싶었다. JPA의 연관관계 매핑, 도대체 쓰는 게 맞을까 안 쓰는 게 맞을까?
먼저 한 가지 사례부터 짚고 싶다. 우리 회사는 개발자 채용 사전 과제를 검토할 때, @ManyToMany를 그대로 쓴 코드에 감점을 주는 경우가 많다. 오해는 말자. 모든 회사가 그렇다는 이야기는 아니다. 세상엔 @ManyToMany를 자연스럽게 쓰는 팀도 많다. 다만 우리 팀 기준에서는, 실무에서 반복적으로 문제를 일으켜 온 매핑을 아무 고민 없이 썼다는 신호로 보기 때문이다.
100% 사용도, 100% 배제도 아니다. 이 글에서는 @ManyToMany가 내부에서 어떻게 동작하는지, 양방향 매핑이 왜 순환참조를 만드는지, 그리고 "쓸까 말까"를 판단하는 나만의 기준을 정리한다.
① @ManyToMany는 어떻게 동작하나 — 그리고 왜 감점이 되는가
@ManyToMany를 선언하면 JPA는 우리가 만들지 않은 연결 테이블(join table) 을 자동으로 만들어 관리한다.
class Student {
@ManyToMany
private List<Course> courses = new ArrayList<>();
}
class Course {
@ManyToMany(mappedBy = "courses")
private List<Student> students = new ArrayList<>();
}
이 코드는 테이블 세 개를 만든다.
student course student_course ← JPA가 자동 생성
-------- -------- ----------------
id id student_id (FK)
course_id (FK)
객체 세계에는 student_course라는 개념이 없다. 오직 DB를 위해 존재하는 테이블을 JPA가 대신 숨겨서 관리해 주는 것이다. 여기서 두 가지 문제가 시작된다.
첫째, 연결 테이블에 컬럼을 추가할 수 없다.
실무에서 다대다 관계는 거의 항상 "관계 자체에 대한 데이터"를 요구한다. 수강 신청이라면 신청일, 성적, 상태 같은 값이 붙는다.
student_course
----------------
student_id
course_id
enrolled_at ← 이런 컬럼을 넣고 싶어진다
grade
status
그런데 @ManyToMany가 만든 연결 테이블은 순수하게 두 FK만 가진다. 여기에 컬럼 하나만 추가되어도 @ManyToMany로는 표현이 불가능하다. 결국 연결 테이블을 별도 엔티티로 승격시켜야 한다.
class Enrollment {
@ManyToOne(fetch = FetchType.LAZY)
private Student student;
@ManyToOne(fetch = FetchType.LAZY)
private Course course;
private LocalDateTime enrolledAt;
private String grade;
}
즉 @ManyToMany는 "언젠가 반드시 깨질 확률이 매우 높은" 매핑이다. 요구사항이 조금만 자라도 구조를 뒤엎어야 한다.
둘째, 수정 시 동작이 위험하다.
@ManyToMany 컬렉션에서 원소 하나를 바꾸면, Hibernate는 종종 연결 테이블의 관련 행을 전부 삭제한 뒤 다시 삽입한다. 개발자는 "하나만 바꿨다"고 생각하지만, 실제로는 DELETE 여러 건 + INSERT 여러 건이 나간다. 데이터가 많아질수록 성능과 예측 가능성이 모두 나빠진다.
그래도 쓸 수 있는 경우 — 딱 이 정도
@ManyToMany가 정당화되는 조건은 분명하다.
관계 자체에 어떤 속성도 붙지 않고, 앞으로도 붙지 않을 것이 확실하며, 조회 위주로만 쓰는 순수 연결.
이 조건을 만족하는 대표적인 두 케이스다.
- 게시글 ↔ 태그 — 게시글에 태그를 붙인다. 관계에 추가 정보가 없다. 태그를 붙였다/뗐다만 존재한다.
- 사용자 ↔ 권한(Role) — 사용자에게 역할을 부여한다. 부여 사실 외에 관계 데이터가 없고, 대부분 읽기 위주다.
이 정도의 속성 없는 순수 M:N이라면 @ManyToMany가 코드를 간결하게 해준다. 하지만 여기서도 안전장치를 두는 편이 좋다. 실무에서는 나중을 대비해 처음부터 연결 엔티티로 풀어 두는 팀도 많다.
정리하면, 관계에 속성이 하나라도 필요한 순간 @ManyToMany는 후보에서 제외된다. 우리 팀이 사전 과제에서 이 매핑에 감점을 주는 이유도 여기에 있다. 대부분의 도메인은 다대다 관계에 반드시 속성이 붙는데, @ManyToMany를 그대로 썼다는 건 그 확장을 고려하지 않았다는 신호로 읽히기 때문이다.
② 양방향 연관관계와 순환참조
연관관계에서 진짜 문제를 일으키는 건 @ManyToMany만이 아니다. 양방향 매핑도 마찬가지다.
여기서 내 입장을 분명히 해두고 싶다. 나는 "@ManyToOne도 웬만하면 쓰지 말자"는 주장에는 동의하지 않는다. 대신 이 원칙에는 매우 동의한다.
객체 그래프가 복잡해질 정도의 양방향 연관관계는 최대한 피하자.
비슷해 보이지만 꽤 다른 이야기다. @ManyToOne이 문제가 아니라, 양방향이 문제다.
양방향이 만드는 순환참조
class Order {
@ManyToOne(fetch = FetchType.LAZY)
private Member member;
}
class Member {
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();
}
Order는 Member를 참조하고, Member는 다시 Order 목록을 참조한다. 서로가 서로를 가리키는 순환참조(Circular Reference) 구조다. 이 구조에서 다음 문제들이 줄줄이 따라온다.
- Jackson 직렬화 무한 루프 — API로
Order를 내보내면Order → Member → Orders → Order → ...를 끝없이 직렬화하다 터진다.@JsonIgnore, DTO 분리 같은 땜질이 필요해진다. - equals / hashCode 무한 재귀 — 양쪽을 서로 참조하면 동등성 비교가 서로를 호출하며 스택을 넘긴다.
- 무한 / 예측 불가능한 Lazy Loading — 객체를 타고 들어가다 의도치 않은 시점에 쿼리가 나간다.
- 객체 그래프 비대화 —
Member하나를 열었을 뿐인데 주문 전체가 딸려 올 수 있는 구조가 된다.
즉 @OneToMany 양방향은 그 자체로 순환참조 안티패턴을 코드에 심는 셈이다.
단방향 @ManyToOne은 대부분 문제가 없다
반대로 이렇게만 두면?
class Order {
@ManyToOne(fetch = FetchType.LAZY)
private Member member;
}
단방향 @ManyToOne은 사실상 문제가 거의 없다. 객체의 참조 방향이 테이블의 참조 컬럼 방향과 정확히 일치하기 때문이다.
member order
-------- --------
id id
member_id ← Member를 가리키는 참조 컬럼
member_id는 order 테이블에 있다. 그러니 객체에서도 Order → Member 한 방향만 참조하는 것이 테이블 구조와 자연스럽게 맞아떨어진다. Hibernate 성능 최적화로 잘 알려진 Vlad Mihalcea도 The best way to map a @OneToMany relationship에서 "대부분의 경우, 자식 쪽의 @ManyToOne 애너테이션 하나면 필요한 건 다 된다(most of the time, the @ManyToOne annotation on the child side is everything you need)" 고 말한다. 같은 글에서 그는 @JoinColumn 없는 단방향 @OneToMany가 불필요한 조인 테이블과 추가 쿼리를 유발해 비효율적이라는 점도 짚는다.
반대로 Member → List<Order>는 테이블에는 존재하지 않는 방향을 객체에서 억지로 만들어낸 것이다. 이 "억지 방향"이 앞의 모든 문제의 출발점이다.
③ 그럼 어떻게 쓰나 — FK만 들기 vs 객체 참조
양방향을 버렸다면, Member의 주문 목록이 필요할 때는 어떻게 할까? 두 가지 선택지가 있다.
방법 1 — 단방향 @ManyToOne + Repository 조회 (가장 흔함)
내가 본 대부분의 실무 프로젝트가 이 방식이다.
Order는 단방향@ManyToOne으로Member를 참조한다.Member에는List<Order>를 아예 만들지 않는다.- 주문 목록이 필요하면 Repository로 해결한다.
List<Order> orders = orderRepository.findByMemberId(memberId);
굳이 member.getOrders()를 만들지 않는다. 필요한 조회는 Repository가 담당한다. 이것만으로 양방향의 문제 대부분이 사라진다.
방법 2 — 객체 참조 없이 FK만 보관
한 걸음 더 나아가, 아예 객체 참조를 두지 않고 식별자만 들고 있는 방식도 요즘 많이 쓴다.
class Order {
private Long memberId;
}
Member가 필요하면 그때 조회한다.
Member member = memberRepository.findById(order.getMemberId());
String name = member.getName();
장점
- JPA 프록시가 없다
- 순환참조가 원천적으로 없다
- 엔티티 간 의존성이 줄어든다
- 테스트가 쉬워진다
특히 MSA나 CQRS처럼 서비스·모델 간 결합도를 낮추는 게 중요한 환경에서 이 방식을 아주 많이 쓴다.
단점
order.getMember().getName() 한 줄이면 끝나는 것을,
Member member = memberRepository.findById(order.getMemberId());
String name = member.getName();
매번 이렇게 풀어 써야 한다. 즉 코드가 늘고, Repository 의존이 늘고, Join 활용이 줄어든다. 비즈니스 로직이 많아질수록 오히려 번거로워질 수 있다.
다만 이 번거로움은 생각보다 줄일 수 있다. findById로 하나씩 풀어 쓰는 대신, QueryDSL의 on 절에 조인 조건을 직접 써서 매핑 없이 붙이거나, 목록을 한 번 더 조회해서 메모리에서 조립하는 방법이 있다. 실제로 우리 팀 코드가 어느 쪽으로 수렴했는지는 후속 글 JPA 연관관계를 안 쓰면 조회는 어떻게 하나에 코드로 정리했다.
결국 이 선택은 흑백이 아니다. 다음 섹션의 "판단 기준"이 필요한 이유다.
④ 쓰는 게 맞나, 안 쓰는 게 좋나 — JPA를 보는 관점
이 질문은 사실 JPA를 어떻게 바라보느냐에 대한 철학의 차이다.
Object Mapping이 존재하는 이유
JPA가 만들어진 가장 큰 이유는 이것이다.
"객체를 참조하듯 DB도 사용할 수 있게 하자."
Order order = orderRepository.findById(1L);
Member member = order.getMember();
String name = member.getName();
객체지향적으로 무척 자연스럽다. SQL이라면 JOIN을 직접 써야 할 것을,
SELECT * FROM orders o JOIN member m ON o.member_id = m.id
JPA가 이 과정을 숨겨준다. 이 아이디어 자체는 아주 훌륭하다.
그런데 왜 실무에서는 욕을 먹을까
문제는 JPA가 객체지향을 너무 믿게 만든다는 데 있다.
Order → Member → Company → Country → Currency
이렇게 참조를 계속 따라갈 수 있게 되면, 어느새 이런 코드가 생긴다.
order.getMember().getCompany().getCountry().getCurrency();
이 한 줄 뒤에서 Lazy Loading, N+1, 순환참조, 영속성 컨텍스트, 프록시 같은 문제가 한꺼번에 튀어나온다. 편해 보였던 객체 그래프가 통제 불가능한 그물이 되는 순간이다.
핵심 기준 — 연관관계보다 "생명주기가 같은가"
그렇다고 Object Mapping이 나쁜가? 아니다. Order → Member 정도는 객체로 표현하는 편이 훨씬 읽기 쉽다.
내가 더 중요하게 보는 기준은 연관관계의 종류가 아니라, 두 객체의 생명주기가 같은가이다.
Order
└─ OrderItem
OrderItem은 Order 없이 존재하기 어렵다. 사실상 하나의 Aggregate다. 이런 경우엔 Object Mapping이 매우 잘 어울린다. 생성·수정·삭제가 함께 움직이므로 cascade, orphanRemoval까지 자연스럽게 쓸 수 있다.
Order
└─ Member
반대로 Member는 Order 없이도 존재하는 독립 Aggregate다. 이 경우엔 객체 참조를 최소화하는 편이 낫다.
DDD에서도 같은 이야기를 한다. Aggregate 간에는 객체 참조 대신 memberId 같은 식별자 참조를 권장하는 경우가 많다. Aggregate끼리 강하게 연결될수록 변경의 영향 범위가 커지기 때문이다.
내 기준
새 프로젝트를 시작한다면 나는 이렇게 나눈다.
적극적으로 쓴다
@OneToOne- 단방향
@ManyToOne - Value Object
@Embedded
거의 쓰지 않는다
@OneToMany(특히 양방향)@ManyToMany- 양방향 연관관계
상황에 따라 FK만 저장한다
Member가 독립적인 도메인이거나, 회원 정보가 자주 필요하지 않다면Long memberId만 보관하는 방식도 충분히 고려한다.
두 가지만 덧붙이고 싶다.
@OneToOne은 먼저@Embeddable을 고려한다. 두 객체의 생명주기가 같다면 별도 테이블·엔티티로 나누기보다 값 타입으로 임베드하는 편이 단순하고 안전하다.- 생명주기가 다르다면
@OneToOne도 좋은 선택이다. 같은 값처럼 묻어가는 관계가 아니라 독립적으로 생기고 사라지는 관계라면, 임베드보다@OneToOne이 맞다.
그래서 "Object Mapping을 쓰지 말자"는 주장에는
나는 극단적인 주장에는 동의하지 않는다. JPA의 Object Mapping은 분명 생산성을 크게 높여주는 강력한 기능이다. 다만 객체 모델과 데이터 모델이 완전히 같지는 않다는 점을 잊지 않는 게 중요하다.
그래서 내가 가장 선호하는 원칙은 이렇다.
- 같은 Aggregate 내부라면 Object Mapping을 적극 활용한다.
- Aggregate 밖이라면 객체 참조를 최소화하고, 필요하면 FK만 보관하거나 Repository로 조회한다.
@ManyToMany, 양방향@OneToMany처럼 객체 그래프를 불필요하게 키우는 매핑은 가능한 한 피한다.
결론
Object Mapping은 강력한 기술이다. 하지만 강력한 만큼 아무 생각 없이 넣으면 안 되고, 남발해서는 절대 안 된다.
@ManyToMany는 관계에 속성이 없는 순수 연결(태그, 권한 정도)에서만 제한적으로 쓰고, 그 외에는 연결 엔티티로 푼다.- 양방향
@OneToMany는 순환참조를 코드에 심는 것과 같으므로 피한다. 대부분은 단방향@ManyToOne하나로 충분하다. - 컬렉션 조회는 Repository로, Aggregate 밖의 관계는 FK 참조로 대체하는 것을 우선 고려한다.
"연관관계를 무조건 쓰지 말자"도, "객체지향이니까 다 매핑하자"도 답이 아니다. 두 객체의 생명주기를 먼저 보고, 그 관계가 정말 객체 그래프로 표현될 가치가 있는지 매번 신중하게 판단하는 것 — 그게 JPA를 실무에서 오래 잘 쓰는 방법이라고 생각한다.
다음 글 — 그럼 조회는 무엇으로 대체하나
이 글은 "언제 매핑을 쓰고 언제 안 쓸까"까지만 다뤘다. 실무에서 바로 따라오는 질문은 그 다음이다. 매핑을 줄이기로 했다면 조회는 무엇으로 하나.
후속 글에서는 감이 아니라 코드를 세어 봤다. 운영 중인 서비스 두 개를 열어서 연관관계 어노테이션이 몇 개 있고 어디에 남아 있는지, 조회 코드가 어떤 모양으로 수렴했는지를 정리했다. 위에서 원칙으로 적은 "Aggregate 안에서는 Object Mapping, 밖에서는 FK 참조"가 실제 코드에서 어떻게 지켜지고 어디서 깨져 있는지도 같이 확인할 수 있다.
참고
'백엔드' 카테고리의 다른 글
| 리액티브 프로그래밍이 대체 뭔가 — 정통 방식의 한계에서 WebFlux까지 (0) | 2026.08.06 |
|---|
