연관관계 매핑 기초
- 김영한님의 자바 ORM 표준 JPA 프로그래밍 기본편을 통해 객체와 테이블의 연관관계 차이, 단방향 및 양방향 연관관계 매핑 방법, 연관관계의 주인 개념, 그리고 예제를 통한 올바른 설계 패턴을 정리함
연관관계가 필요한 이유
예제 시나리오
- 요구사항
- 회원과 팀이 있음
- 회원은 하나의 팀에만 소속될 수 있음
- 회원과 팀은 다대일 관계임
테이블 중심 모델링의 문제점
-
잘못된 설계 방식
1 2 3 4 5 6 7 8 9 10 11
@Entity public class Member { @Id @GeneratedValue private Long id; @Column(name = "USERNAME") private String name; @Column(name = "TEAM_ID") private Long teamId; // 외래 키를 그대로 사용 }
- 외래 키 식별자를 직접 다룸
member.setTeamId(team.getId())와 같이 외래 키 값을 직접 설정해야 함 ```java Team team = new Team(); team.setName(“TeamA”); em.persist(team);
Member member = new Member(); member.setName(“member1”); member.setTeamId(team.getId()); // 외래 키 직접 설정 em.persist(member); ```
- 객체 그래프 탐색 불가능
member.getTeam()으로 연관된 팀을 조회할 수 없고,teamId로 다시 조회해야 함 ```java Member findMember = em.find(Member.class, member.getId());
// 연관관계가 없어서 식별자로 다시 조회해야 함 Long findTeamId = findMember.getTeamId(); Team findTeam = em.find(Team.class, findTeamId); ```
객체와 테이블의 차이
- 객체와 테이블의 패러다임 불일치
- 테이블
- 외래 키로 조인하여 연관 테이블 찾음
- 객체
- 참조를 통해 연관 객체 찾음
- 외래 키를 직접 다르면
member.getTeam()같은 객체 그래프 탐색이 불가능해짐
- 테이블
단방향 연관관계
객체 지향 모델링
-
객체 참조 사용
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
@Entity public class Member { @Id @GeneratedValue private Long id; @Column(name = "USERNAME") private String name; private int age; // 객체 참조 @ManyToOne @JoinColumn(name = "TEAM_ID") private Team team; // Getter, Setter }
어노테이션 설명
- @ManyToOne
- 다대일(N:1) 관계 설정 (
Member입장에서Team)
- 다대일(N:1) 관계 설정 (
- @JoinColumn(name = “TEAM_ID”)
- 조인할 외래 키 컬럼명 지정
1 2 3
@ManyToOne @JoinColumn(name = "TEAM_ID") private Team team;
- 조인할 외래 키 컬럼명 지정
연관관계 저장
1
2
3
4
5
6
7
8
9
10
// 팀 저장
Team team = new Team();
team.setName("TeamA");
em.persist(team);
// 회원 저장
Member member = new Member();
member.setName("member1");
member.setTeam(team); // 객체 참조 저장
em.persist(member);
-
실행 SQL
1 2 3
INSERT INTO TEAM (TEAM_ID, NAME) VALUES (1, 'TeamA'); INSERT INTO MEMBER (MEMBER_ID, USERNAME, TEAM_ID) VALUES (1, 'member1', 1);
참조로 연관관계 조회
-
객체 그래프 탐색
1 2 3 4 5
// 조회 Member findMember = em.find(Member.class, member.getId()); // 참조를 사용해서 연관관계 조회 Team findTeam = findMember.getTeam(); // 객체 그래프 탐색
- 한 줄로 연관된 객체 조회 가능
연관관계 수정
1
2
3
4
5
6
7
// 새로운 팀B
Team teamB = new Team();
teamB.setName("TeamB");
em.persist(teamB);
// 회원1에 새로운 팀B 설정
member.setTeam(teamB); // 참조만 변경하면 됨
-
실행 SQL
1 2 3
UPDATE MEMBER SET TEAM_ID = 2 -- teamB의 ID WHERE MEMBER_ID = 1;
양방향 연관관계
양방향 매핑
-
Member엔티티 (단방향과 동일)1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
@Entity public class Member { @Id @GeneratedValue private Long id; @Column(name = "USERNAME") private String name; private int age; @ManyToOne @JoinColumn(name = "TEAM_ID") private Team team; // Getter, Setter }
-
Team엔티티 (컬렉션 추가)1 2 3 4 5 6 7 8 9 10 11 12 13 14
@Entity public class Team { @Id @GeneratedValue private Long id; private String name; @OneToMany(mappedBy = "team") private List<Member> members = new ArrayList<>(); // Getter, Setter }
@OneToMany- 일대다 관계
mappedBy = "team"Member엔티티의team필드와 연결
-
테이블 구조는 변경 없음
- 테이블은 외래 키 하나로 양방향 조인 가능
반대 방향으로 객체 그래프 탐색
1
2
3
4
5
6
7
8
9
10
// 조회
Team findTeam = em.find(Team.class, team.getId());
// 역방향 조회
int memberSize = findTeam.getMembers().size();
// 역방향 탐색
for (Member member : findTeam.getMembers()) {
System.out.println("member = " + member.getName());
}
연관관계의 주인
객체와 테이블 연관관계 차이
- 연관관계의 수
- 객체
- 2개의 단방향 관계 (
A->B,B->A) - 객체 양방향 관계는 사실 서로 다른 단방향 관계 2개임
- 2개의 단방향 관계 (
- 테이블
- 1개의 양방향 관계 (
A<->B) - 외래 키 하나로 양쪽 조인 가능
- 1개의 양방향 관계 (
- 객체
- 참조와 외래 키의 차이
Member는Team참조 필요,Team은Member리스트 참조 필요 (단방향 2개)MEMBER테이블의TEAM_ID외래 키 하나로MEMBER JOIN TEAM,TEAM JOIN MEMBER모두 가능
연관관계의 주인 (Owner)
- 양방향 매핑 규칙
- 객체의 두 관계 중 하나를 연관관계의 주인으로 지정
- 연관관계의 주인
- 외래 키를 관리 (등록, 수정)
- 주인이 아닌 쪽
- 읽기만 가능 (mappedBy 속성으로 주인 지정)
1 2 3 4 5 6 7 8
// 주인: `Member.team` (외래 키 관리) @ManyToOne @JoinColumn(name = "TEAM_ID") private Team team; // 주인이 아님: `Team.members` (읽기 전용, mappedBy) @OneToMany(mappedBy = "team") private List<Member> members;
누구를 주인으로?
- 외래 키가 있는 곳을 주인으로 정함
MEMBER테이블에 외래 키(TEAM_ID)가 있으므로Member.team이 주인- 비즈니스 로직을 기준으로 주인을 선택하면 안 됨
- 외래 키가 없는 곳을 주인으로 하면 불필요한 UPDATE 쿼리가 발생하여 성능 저하 및 유지보수 어려움
양방향 매핑 실수와 주의사항
가장 많이 하는 실수
-
연관관계의 주인에 값을 입력하지 않음
1 2 3 4 5 6 7 8 9 10 11
Team team = new Team(); team.setName("TeamA"); em.persist(team); Member member = new Member(); member.setName("member1"); // 역방향(주인이 아닌 방향)만 연관관계 설정 team.getMembers().add(member); em.persist(member);
MEMBER테이블의TEAM_ID가 null로 저장됨
-
올바른 방법
1 2 3 4 5 6 7 8 9 10 11
Team team = new Team(); team.setName("TeamA"); em.persist(team); Member member = new Member(); member.setName("member1"); // 연관관계의 주인에 값 설정 member.setTeam(team); em.persist(member);
MEMBER테이블의TEAM_ID가 정상적으로 저장됨
양쪽 다 값을 입력해야 하는 이유
1
2
3
4
5
6
7
8
9
10
11
12
Team team = new Team();
team.setName("TeamA");
em.persist(team);
Member member = new Member();
member.setName("member1");
// 양쪽 다 설정
member.setTeam(team); // 주인에 값 설정 (필수)
team.getMembers().add(member); // 역방향에도 설정 (권장)
em.persist(member);
-
순수 객체 상태 고려
1 2 3 4 5 6 7 8 9 10 11 12
// 테스트 코드에서 JPA 없이 순수 Java 코드만 사용 Team team = new Team(); team.setName("TeamA"); Member member = new Member(); member.setName("member1"); member.setTeam(team); // team.getMembers().add(member); // 이게 없으면? List<Member> members = team.getMembers(); System.out.println(members.size()); // 0 출력
-
영속성 컨텍스트(flush, clear) 없이 조회 시 문제
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
Team team = new Team(); team.setName("TeamA"); em.persist(team); Member member = new Member(); member.setName("member1"); member.setTeam(team); em.persist(member); // flush, clear 없이 바로 조회 Team findTeam = em.find(Team.class, team.getId()); List<Member> members = findTeam.getMembers(); // 1차 캐시에서 조회 // team.getMembers().add(member)가 없었다면 System.out.println(members.size()); // 0 출력
연관관계 편의 메서드
-
양쪽에 값 설정 누락
1 2
member.setTeam(team); team.getMembers().add(member); // 깜빡하기 쉬움
-
연관관계 편의 메서드 사용
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
@Entity public class Member { @ManyToOne @JoinColumn(name = "TEAM_ID") private Team team; // 연관관계 편의 메서드 public void changeTeam(Team team) { this.team = team; team.getMembers().add(this); // 양쪽 설정 } } // 사용 member.changeTeam(team); // 한 번에 양쪽 설정
-
또는 Team에 작성
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
@Entity public class Team { @OneToMany(mappedBy = "team") private List<Member> members = new ArrayList<>(); // 연관관계 편의 메서드 public void addMember(Member member) { members.add(member); member.setTeam(this); // 양쪽 설정 } } // 사용 team.addMember(member); // 한 번에 양쪽 설정
- Member 또는 Team 중 한 곳에만 작성
무한 루프 조심
-
toString()
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 28 29
@Entity public class Member { @ManyToOne private Team team; @Override public String toString() { return "Member{" + "id=" + id + ", username='" + username + '\'' + ", team=" + team + // team.toString() 호출 '}'; } } @Entity public class Team { @OneToMany(mappedBy = "team") private List<Member> members; @Override public String toString() { return "Team{" + "id=" + id + ", name='" + name + '\'' + ", members=" + members + // members.toString() 호출 '}'; } }
- StackOverflowError 발생
-
Lombok
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
@Entity @Getter @ToString // 무한 루프 발생 public class Member { @ManyToOne private Team team; } @Entity @Getter @ToString // 무한 루프 발생 public class Team { @OneToMany(mappedBy = "team") private List<Member> members; }
-
JSON 생성 라이브러리
1 2 3 4 5 6 7 8
@RestController public class MemberController { @GetMapping("/members/{id}") public Member getMember(@PathVariable Long id) { return memberRepository.findById(id); // 무한 루프 } }
- JSON 변환 시 무한 루프 발생
-
해결 방법
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
// toString() 제거 또는 연관관계 필드 제외 @Override public String toString() { return "Member{" + "id=" + id + ", username='" + username + '\'' + // team 제외 '}'; } // Lombok 사용 시 @ToString(exclude = "team") // 연관관계 필드 제외 public class Member { @ManyToOne private Team team; } // JSON: DTO 사용 (권장) @GetMapping("/members/{id}") public MemberDto getMember(@PathVariable Long id) { Member member = memberRepository.findById(id); return new MemberDto(member); // DTO로 변환 }
양방향 매핑 설계 가이드
단방향 매핑만으로도 충분
- 단방향 매핑만으로도 이미 연관관계 매핑은 완료됨
- 양방향 매핑은 반대 방향 조회 기능만 추가한 것
- 테이블에는 전혀 영향을 주지 않음
양방향 매핑이 필요한 경우
1
2
3
4
5
6
7
// JPQL에서 역방향 탐색이 필요한 경우
// 단방향만 있을 때
String jpql = "select m from Member m join m.team t where t.name = :teamName";
// 양방향이 있을 때
String jpql = "select m from Team t join t.members m where m.username = :username";
- JPQL에서 역방향 탐색할 일이 많으면 양방향 추가
설계 권장사항
- 권장 순서
- 단방향 매핑으로 설계 완료
- 개발하면서 역방향 조회 필요하면 양방향 추가
- 테이블에는 영향 없으므로 언제든 추가 가능
- 연관관계의 주인 선택 기준
- 잘못된 기준
- 비즈니스 로직
- 올바른 기준
- 외래 키의 위치
- ex)
- 자동차와 바퀴 관계
- 비즈니스상 자동차가 더 중요해 보임
- 하지만
WHEEL테이블에CAR_ID(FK)가 있음 - 따라서
Wheel.car가 연관관계의 주인
- 잘못된 기준
연관관계 매핑 예제
객체 구조 (참조 사용)
-
Member엔티티1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
@Entity public class Member { @Id @GeneratedValue @Column(name = "MEMBER_ID") private Long id; private String name; private String city; private String street; private String zipcode; @OneToMany(mappedBy = "member") private List<Order> orders = new ArrayList<>(); // Getter, Setter }
-
Order엔티티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 28 29
@Entity @Table(name = "ORDERS") public class Order { @Id @GeneratedValue @Column(name = "ORDER_ID") private Long id; @ManyToOne @JoinColumn(name = "MEMBER_ID") private Member member; // 참조 사용 @OneToMany(mappedBy = "order") private List<OrderItem> orderItems = new ArrayList<>(); private LocalDateTime orderDate; @Enumerated(EnumType.STRING) private OrderStatus status; // 연관관계 편의 메서드 public void addOrderItem(OrderItem orderItem) { orderItems.add(orderItem); orderItem.setOrder(this); } // Getter, Setter }
-
OrderItem엔티티1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
@Entity public class OrderItem { @Id @GeneratedValue @Column(name = "ORDER_ITEM_ID") private Long id; @ManyToOne @JoinColumn(name = "ORDER_ID") private Order order; // 참조 사용 @ManyToOne @JoinColumn(name = "ITEM_ID") private Item item; // 참조 사용 private int orderPrice; private int count; // Getter, Setter }
-
Item엔티티1 2 3 4 5 6 7 8 9 10 11 12 13 14
@Entity public class Item { @Id @GeneratedValue @Column(name = "ITEM_ID") private Long id; private String name; private int price; private int stockQuantity; // Getter, Setter }
변경 전후 비교
-
변경 전 (데이터 중심)
1 2 3 4 5 6 7 8 9 10 11 12
@Entity public class Order { @Id private Long id; private Long memberId; // 외래 키 그대로 사용 } // 사용 Order order = em.find(Order.class, orderId); Long memberId = order.getMemberId(); Member member = em.find(Member.class, memberId); // 2번 조회
-
변경 후 (객체 지향)
1 2 3 4 5 6 7 8 9 10 11 12 13
@Entity public class Order { @Id private Long id; @ManyToOne @JoinColumn(name = "MEMBER_ID") private Member member; // 객체 참조 } // 사용 Order order = em.find(Order.class, orderId); Member member = order.getMember(); // 객체 그래프 탐색
연습 문제
-
객체와 관계형 데이터베이스가 관계를 표현하는 방식의 근본적인 차이는 무엇일까요?
a. 객체는 참조, 테이블은 외래 키
- 객체는 서로 다른 객체를 ‘참조’하며 관계를 맺음
- 반면에 테이블은 ‘외래 키(FK)’ 값을 통해 다른 테이블의 데이터를 연결함
- 이 차이 때문에 ORM 매핑이 필요함
-
JPA에서 양방향 연관관계의 ‘연관관계 주인’은 무엇을 기준으로 결정하는 것이 가장 중요할까요?
a. 외래 키(Foreign Key)의 위치
- 외래 키가 데이터베이스 테이블 어디에 위치하는지가 연관관계 주인을 결정하는 핵심 기준임
- 보통 N대1 관계에서 N쪽에 외래 키가 있으므로 N쪽이 주인이 됨
-
JPA 양방향 연관관계에서 ‘연관관계 주인’의 역할은 무엇일까요?
a. 외래 키 값을 관리(등록, 수정)
- 연관관계 주인만이 데이터베이스에 있는 외래 키의 값을 변경하거나 등록할 수 있음
- 주인이 아닌 쪽(mappedBy 설정된 곳)은 외래 키 값을 읽기만 가능함
-
JPA 연관관계를 설계할 때 권장되는 초기 접근 방식은 무엇일까요?
a. 단방향으로 먼저 설계 후 필요시 양방향 추가
- 처음부터 양방향으로 설계하면 불필요하게 복잡해질 수 있음
- 단방향으로 충분히 설계한 후, 애플리케이션 개발 중 역방향 조회가 정말 필요할 때 양방향을 추가하는 것이 좋음
-
JPA 양방향 연관관계를 사용할 때, Lombok의 toString()이나 JSON 직렬화 라이브러리 사용 시 주의해야 할 가장 흔한 문제는 무엇일까요?
a. 무한 루프 발생
- 양쪽 엔티티가 서로를 참조하는 구조 때문에 toString()이나 JSON 변환 시 서로를 반복적으로 호출하며 무한히 순환할 수 있음
- 이는 애플리케이션 오류로 이어짐
요약 정리
- 테이블 중심 설계는 외래 키 값을 그대로 사용하여 객체 그래프 탐색이 불가능하므로, 객체 참조를 사용한 연관관계 매핑이 필수임
- @ManyToOne과 @JoinColumn 어노테이션을 사용하여 다대일 관계를 매핑하며, 외래 키가 있는 곳이 연관관계의 주인이 됨
- 단방향 매핑만으로도 이미 연관관계 매핑은 완료되며, 양방향 매핑은 반대 방향 조회 기능만 추가한 것으로 테이블에는 영향을 주지 않음
- 양방향 매핑에서는 객체의 두 관계 중 하나를 연관관계의 주인으로 지정해야 하며, 주인만이 외래 키를 관리(등록, 수정)할 수 있고 주인이 아닌 쪽은 읽기만 가능함
- mappedBy 속성은 연관관계의 주인이 아닌 쪽에 사용하며, 외래 키가 있는 곳을 주인으로 정하는 것이 성능과 유지보수 측면에서 권장됨
- 양방향 매핑 시 연관관계의 주인에 값을 반드시 설정해야 하며, 순수 객체 상태를 고려하여 양쪽 모두에 값을 설정하는 것이 권장됨
- 연관관계 편의 메서드를 작성하면 양쪽에 값을 설정하는 것을 깜빡하지 않고 한 번에 처리할 수 있으며, 둘 중 한 곳에만 작성해야 함
- 무한 루프를 방지하기 위해 toString(), Lombok의 @ToString, JSON 직렬화 라이브러리 사용 시 연관관계 필드를 제외하거나 DTO를 사용해야 함
- 설계 시 단방향 매핑으로 먼저 완료한 후, JPQL에서 역방향 탐색이 필요한 경우에만 양방향을 추가하는 것이 권장됨
- 연관관계의 주인 선택 기준은 비즈니스 로직이 아니라 외래 키의 위치이며, 외래 키가 있는 N쪽이 항상 연관관계의 주인이 됨