1. 들어가며
이전 글에서 데이터베이스의 테이블 간 연관관계를 중심으로
- 일대일 / 일대다 / 다대다
- 식별 관계 vs 비식별 관계
같은 내용을 먼저 짚고 넘어갔다.
👉 2025.04.13 - [DB 모르는 백엔드 탈출기] - [DB 모르는 백엔드 탈출기 Ep.1] JPA 쓰기 전에 꼭 알아야 할 DB 기초
[DB 모르는 백엔드 탈출기 Ep.1] JPA 쓰기 전에 꼭 알아야 할 DB 기초
1. 들어가며많은 백엔드 개발자들이 JPA부터 배우기 시작한다.Entity 클래스를 만들고, 어노테이션을 붙이면쿼리 없이도 데이터가 저장되고 조회되는 걸 보고 이렇게 생각한다.“와, JPA가 다 해주
mingking2.tistory.com
DB 관점에서 연관관계가 어떻게 구성되는지 이해했다면,
이제는 JPA에서 이 연관관계를 어떻게 표현하고 매핑하는가?를 알아야 한다.
특히 JPA에서는 연관관계 설정 시 반드시 등장하는 개념이 있다.
바로 연관관계의 주인(Owner) 이다.
처음 JPA를 사용할 때,
- mappedBy는 왜 붙이고
- 어느 쪽에 @JoinColumn을 써야 하는지
- 값은 어디에 설정해야 실제 DB에 반영되는지
이런 부분에서 헷갈린 적이 한 번쯤은 있을 것이다.
이번 글에서는 “연관관계에서 주인이란 도대체 무엇을 의미하고, 왜 중요할까?” 라는 질문을 중심으로,
- JPA가 연관관계를 어떻게 관리하는지,
- 어디가 DB의 외래 키(FK)를 실제로 다루는지,
코드 흐름 중심으로 쉽게 정리해보자.
2. JPA에서 연관관계를 어떻게 표현할까?
연관관계는 단순히 @OneToMany 또는 @ManyToOne 한 줄 붙이고 끝나는 문제가 아니다.
연관된 엔티티가 누군지, 어느 쪽이 FK를 갖는지, 어디서 insert/update가 발생하는지까지 모두 연결해서 봐야 한다.
1️⃣ 일대다(1:N) / 다대일(N:1)
📌 예시: 팀과 회원 (Team - Member)
- 하나의 팀에는 여러 명의 회원이 소속될 수 있다.
- 회원은 하나의 팀에만 소속된다.
💾 DB 관점
- member 테이블에 team_id FK 존재
- team 테이블은 반대로 member를 직접 참조하지 않음
💡 JPA 매핑
// Member.java
@ManyToOne
@JoinColumn(name = "team_id") // FK를 소유한 쪽이 연관관계의 주인
private Team team;
// Team.java
@OneToMany(mappedBy = "team") // 읽기 전용(주인이 아님)
private List<Member> members = new ArrayList<>();
✅ 연관관계의 주인은 FK를 갖고 있는 Member
→ 실제 DB 변경은 member.setTeam() 에 의해 일어난다.
2️⃣ 일대일(1:1)
📌 예시: 회원과 프로필 이미지 (User - ProfileImage)
- 한 명의 유저는 하나의 프로필 이미지를 가진다.
- 이미지 또한 하나의 유저에만 속한다.
💾 DB 관점
- 외래 키를 어느 테이블에 넣을지는 선택의 문제지만, 대부분 주 테이블(User)에 FK를 둔다.
💡 JPA 매핑
// User.java
@OneToOne
@JoinColumn(name = "profile_image_id")
private ProfileImage profileImage;
// ProfileImage.java
@OneToOne(mappedBy = "profileImage")
private User user;
✅ 외래 키를 가진 쪽(User)이 연관관계의 주인이다.
3️⃣ 다대다(N:M)
📌 예시: 사용자와 관심 태그 (User - Tag)
- 하나의 사용자는 여러 태그를 가질 수 있고
- 하나의 태그는 여러 사용자에게 사용될 수 있다.
💾 DB 관점
- user_tag 중간 테이블을 만든다 (user_id, tag_id)
💡 JPA 매핑
// UserTag.java
@Entity
public class UserTag {
@ManyToOne
private User user;
@ManyToOne
private Tag tag;
private LocalDateTime createdAt;
}
✅ 실무에서는 @ManyToMany를 지양하고, 반드시 중간 엔티티를 생성해 관리한다.
3. 왜 관계 설정이 예상대로 작동하지 않을까?
JPA를 처음 사용할 때 가장 많이 부딪히는 문제는 바로
“객체 관계는 양방향으로 연결했는데, 왜 DB에는 반영이 안 되지?” 이다.
예를 들어, 다음과 같이 객체 양쪽에서 관계를 맺었다고 해보자:
// 예: Member와 Team이 양방향으로 연관된 경우
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
@ManyToOne
@JoinColumn(name = "team_id")
private Team team;
}
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
}
Team team = new Team();
Member member = new Member();
member.setTeam(team);
team.getMembers().add(member);
em.persist(team);
em.persist(member);
이제 member와 team이 서로 연결된 것처럼 보이지만 실제로 DB에는 아무 것도 저장되지 않을 수 있다.
왜냐하면 연관관계의 주인만이 insert/update 쿼리를 날릴 수 있기 때문이다.
✅ mappedBy가 붙은 쪽은 읽기 전용이다.
값을 설정해도 insert 쿼리를 생성하지 않으며, 단지 반대편 필드의 값을 읽기 위해 존재한다.
4. 연관관계의 주인은 누가 되어야 할까?
연관관계를 설정할 때 JPA에서 가장 중요한 결정 중 하나는
바로 “누가 연관관계의 주인(Owner)이 될 것인가?”이다.
앞서 살펴봤듯, JPA에서 연관관계의 주인만이 외래 키를 관리하고 insert/update 쿼리를 생성할 수 있다.
그런데 이 “주인”이라는 개념은 단방향과 양방향 관계에 따라 적용 방식이 달라진다.
따라서, 연관관계의 방향부터 정확히 이해하고 나서 주인을 결정해야 한다.
➡️ 단방향 관계에서는 주인이 필요 없다
@Entity
public class Member {
@ManyToOne
@JoinColumn(name = "team_id") // 해당 필드가 주인이다.
private Team team;
}
- 단방향 관계는 외래 키를 가진 엔티티에서 다른 객체를 참조하는 구조다.
- 연관관계의 주인이라는 개념 자체가 필요 없다.
- mappedBy도 당연히 사용하지 않는다.
↔️양방향 관계에서는 반드시 주인을 정해야 한다
✅ 주인은 외래 키를 가진 쪽이 된다
JPA는 객체 간의 연관관계를 테이블의 외래 키(FK)를 기반으로 매핑한다.
즉, 외래 키를 가지는 엔티티가 곧 연관관계의 주인이 된다.
@ManyToOne
@JoinColumn(name = "team_id")
private Team team;
위처럼 @JoinColumn이 붙은 필드가 바로 주인이다.
mappedBy가 붙은 반대편은 단순히 읽기 전용이며, 연관관계를 거울처럼 반영하는 역할만 한다.
💡 그럼 JoinColumn과 mappedBy는 무슨 차이가 있을까?
- @JoinColumn은 이 필드가 실제 DB의 외래 키(FK)를 가진다는 의미다.
즉, 이 필드에 값을 설정하면 JPA가 실제로 insert/update 쿼리를 날린다.
- mappedBy는 반대편 필드에 의해 매핑된다는 의미다.
즉, 여기서는 연관관계를 조작하지 않고, 단지 참조만 하겠다는 선언이다.
실제 쿼리를 발생시키는 권한은 없으며, 주인의 상태를 "읽어와서 보여주는 역할"만 한다.
👉 그래서 @JoinColumn이 붙은 쪽이 반드시 연관관계의 주인이 된다.
❗ mappedBy가 붙은 쪽은 DB 변경 권한이 없다
Team team = new Team();
Member member = new Member();
team.getMembers().add(member); // DB 반영되지 않는다.
em.persist(team);
em.persist(member);
이 코드는 Team에서 Member 리스트에 추가했지만,
주인인 Member.team에 값이 설정되지 않았기 때문에 실제로 DB에는 아무것도 반영되지 않는다.
연관관계의 주인만이 DB에 영향을 미친다.
반대편은 단순히 참조용이며 값을 저장해도 무시된다.
💡 연관관계 주인을 설정할 때 고려할 점
- 외래 키가 어느 쪽에 있는가?
- 어떤 객체에서 연관된 값을 관리하고 싶은가?
- 실질적으로 insert/update를 발생시킬 책임이 있는 쪽은 누구인가?
이러한 기준을 통해,
반드시 외래 키를 가진 쪽에만 @JoinColumn을 선언하고, 반대쪽에는 mappedBy를 명시하여 혼란을 방지해야 한다.
5. 양방향 연관관계는 반드시 필요한가?
JPA를 처음 배울 때 많은 예제에서 양방향 연관관계를 기본처럼 사용하기 때문에,
실무에서도 무조건 양방향을 설정해야 한다고 오해하는 경우가 많다.
하지만 실제 서비스 개발에서는 양방향 연관관계를 필요한 경우에만 신중하게 설정하는 것이 좋다.
먼저 양방향, 단방향 각각의 장단점을 비교해보자.
↔️ 양방향 연관관계
양쪽 엔티티에서 서로를 참조하는 구조
예: Member.team, Team.members
✅ 장점
- 객체 그래프 탐색이 용이하다
→ team.getMembers()처럼 반대편 객체에 쉽게 접근할 수 있다.
- 양방향 조회가 가능하다
→ 양쪽에서 모두 연관된 데이터를 불러올 수 있다.
❗단점
- 연관관계 관리가 복잡해진다
→ member.setTeam(team)과 team.getMembers().add(member)를 모두 호출해야 한다.
- 순환 참조 위험
→ @ToString, @EqualsAndHashCode, JSON 직렬화 등에서 무한 루프 발생 가능
- 연관관계의 주인만 DB에 영향을 미친다는 점을 놓치기 쉽다
→ mappedBy는 읽기 전용이라 persist() 시 반영되지 않는다.
➡️ 단방향 연관관계
한쪽 엔티티에서만 다른 쪽을 참조하는 구조
예: Order.member만 있고, Member.orders는 없음
✅ 장점
- 구조가 단순하고 유지보수가 쉽다
- 순환 참조 문제를 원천적으로 방지한다
- 성능 최적화에 유리하다 (불필요한 조인, 로딩 방지)
❗단점
- 반대 방향의 탐색이 불가능하다
→ member.getOrders()처럼 반대편에서 접근하려면 직접 쿼리를 작성해야 한다.
💡 실무에서는 단방향이 기본
- 조회가 한쪽에서만 필요하다면 단방향만 설정한다.
- 양쪽 모두에서 조회가 자주 필요한 경우에만 양방향으로 확장한다.
- 양방향을 사용하더라도 연관관계 편의 메서드(addMember() 같은)를 통해 일관성을 유지해야 한다.
예: 단방향으로 충분한 경우
@Entity
public class Order {
@ManyToOne
private Member member;
}
- 주문을 통해 회원을 조회할 수 있으면 충분한 상황이다.
- 굳이 Member에 List<Order>를 추가하지 않아도 된다.
예: 양방향이 필요한 경우
@Entity
public class Team {
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
}
- 팀 상세 페이지에서 구성원 목록을 자주 보여주는 경우
- Team에서도 Member를 알아야 하는 요구사항이 존재
연관관계는 필요할 때만, 필요한 만큼만 설계하고,
방향성과 주인을 신중하게 결정하는 것이 중요하다.
6. 연관관계를 꼭 써야 할까?
실무에서는 연관관계를 끊고 PK만 쓰는 방식도 많이 사용한다.
JPA를 처음 배울 때는 @ManyToOne, @OneToMany, @OneToOne 등 다양한 연관관계를 사용하는 것이
객체 지향적인 설계라고 배운다. 하지만 실제 서비스 개발 현장에서는
오히려 이 연관관계를 최대한 끊고, Long userId처럼 단순히 식별자(PK)만 사용하는 방식을 더 많이 채택하고 있다.
이유는 무엇일까?
📌 객체 대신 식별자(PK)만 사용하는 방식
기존에는 다음과 같이 객체를 직접 참조하였다:
@Entity
public class Post {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
}
하지만 실무에서는 다음과 같이 연관관계를 제거하고, FK만 필드로 보관하는 방식이 자주 사용된다:
@Entity
public class Post {
private Long userId;
}
처음에는 이 방식이 JPA 철학과 어긋나는 듯 보일 수 있지만,
실무적인 관점에서 분명한 이점을 제공한다.
🤔 왜 실무에서는 연관관계를 끊고 PK만 사용할까?
✅ 유지보수성과 명확한 의도
연관관계를 맺으면 해당 엔티티를 저장하거나 수정할 때
JPA 내부적으로 의도하지 않은 insert, update, select가 발생할 수 있다.
반면, 식별자만 명시적으로 저장하면 쿼리 흐름을 전적으로 개발자가 통제할 수 있다.
✅ 순환 참조 이슈 방지
양방향 연관관계를 사용할 경우
@ToString, equals, hashCode, JSON 직렬화 과정에서 무한 루프가 발생할 수 있다.
FK만 사용하는 경우에는 이러한 문제를 애초에 설계적으로 차단할 수 있다.
✅ 지연 로딩에 따른 N+1 문제 제거
@ManyToOne(fetch = FetchType.LAZY)를 설정하더라도,
의도치 않게 연관된 엔티티가 자동으로 로딩되며 추가 쿼리가 발생하는 경우가 많다.
하지만 식별자만 사용하는 방식에서는
JPA가 객체를 로딩할 일이 없으므로, 불필요한 쿼리 비용이 전혀 발생하지 않는다.
지연 로딩과 N+1 문제에 대해서는 추후에 더 자세히 설명한다.
✅ 성능 최적화가 쉬움
연관관계를 제거하면, 조회 쿼리와 DTO 매핑을 개발자가 직접 정의하게 되며,
불필요한 조인이나 로딩 없이 목적에 맞는 SQL을 명확하게 작성할 수 있다.
이는 JPA보다는 MyBatis 스타일의 명시적 데이터 접근 방식에 가깝지만,
대용량 트래픽 환경이나 단순 조회 중심 서비스에서는 훨씬 유리하다.
❓ 단점은 없을까?
❌ 객체 지향적 설계에서 멀어진다
엔티티 간의 직접적인 관계가 사라지므로,
객체 그래프 탐색이 불가능해지고
userRepository.findById(post.getUserId())와 같이 항상 수동 조회 코드가 필요하다.
❌ 추상화 수준이 낮아진다
연관관계를 통해 얻을 수 있는
Cascade, Dirty Checking, 영속성 전이 등의 JPA의 편의 기능을 활용할 수 없다.
결과적으로, 개발자가 관리해야 할 코드와 책임이 많아진다.
🔎 정리
| 구분 | 연관관계 사용 | PK만 사용 |
| 장점 | 객체 탐색 용이, JPA 기능 활용 | 쿼리 명확, 성능 최적화, 순환 참조 방지 |
| 단점 | N+1, 예상치 못한 쿼리 발생, 순환 참조 위험 | 직접 조회 필요, 추상화 수준 낮음 |
| 실무 적용 | 핵심 도메인에 적절 | 단순 구조나 조회 중심에 유리 |
연관관계는 무조건적으로 쓰는 것이 아니라,
필요에 따라 전략적으로 선택해야 한다.
7. 결론
연관관계는 단순히 어노테이션 몇 줄로 끝나는 문제가 아니다.
어떤 방향으로 데이터를 연결할지, 어느 쪽이 책임을 가질지를 고려해서 정해야 하는 설계의 일부다.
객체 지향적으로 잘 표현하는 것도 중요하지만,
실무에서는 그보다 더 중요한 것이 있다.
바로 유지보수가 쉬운 구조, 그리고 불필요한 쿼리를 줄일 수 있는 효율적인 설계다.
이번 글을 통해 연관관계를 반드시 써야 하는 게 아니라,
필요한 곳에만 잘 선택해서 써야 한다는 점을 배웠다.
다음 글에서는 JPA의 실행 흐름과 성능에 깊이 관여하는
프록시, 지연 로딩 전략(LAZY vs EAGER), 그리고 N+1 문제의 구조와 해결법을 다룰 예정이다.
'DB 모르는 백엔드 탈출기' 카테고리의 다른 글
| [DB 모르는 백엔드 탈출기 Ep.3] JPA는 객체를 어떻게 관리할까? (0) | 2025.04.14 |
|---|---|
| [DB 모르는 백엔드 탈출기 Ep.2] 테이블은 객체가 아니다. (1) | 2025.04.13 |
| [DB 모르는 백엔드 탈출기 Ep.1] JPA 쓰기 전에 꼭 알아야 할 DB 기초 (0) | 2025.04.13 |
| [DB 모르는 백엔드 탈출기 Ep.0] JPA가 다 해주는 거 아니었나요? (0) | 2025.04.12 |
