[JPA] 3.연관관계 매핑
Updated:
연관관계 매핑
이 게시글을 통해 객체와 테이블간의 연관관계 차이를 이해하고 객체의 참조와 테이블의 외래키를 매핑하는 것을 목표로 한다
배경지식
본격적인 설명에 들어가기에 앞서 3가지 단어 정도만 어느 정도 알아두고 들어가도록 하자
- 방향
- 참조의 방향을 의미
- 단방향과 양방향이 존재
- 다중성
- RDB에서의 다대일(N:1), 일대다(1:N), 일대일(1:1), 다대다(N:N)
- 연관관계의 주인
- 객체에서 양방향 연관관계는 관계를 관리하는 주인이 필요함
연관관계가 필요한 이유
일단 아래의 2가지 조건을 따르는 예시를 이용하여 이해해보자
- 회원과 팀이 있다
- 회원과 팀은 다대일(N:1)관계이다
연관관계 없이 그저 테이블 설계를 따라 엔티티를 설계한다면 아래 그림과 같다

위 그림에 맞게 엔티티 코드를 작성하면 아래와 같다
@Entity
@Getter @Setter
public class Member {
@Id @GeneratedValue
@Column(name = "member_id")
private Long id;
@Column(name = "username")
private String name;
@Column(name = "team_id")
private Long teamId;
}
@Entity
@Getter @Setter
public class Team {
@Id @GeneratedValue
@Column(name = "team_id")
private Long id;
private String name;
}
이번에는 간략한 테스트 코드를 작성해보자
//Team 저장
Team team = new Team();
team.setName("teamA");
em.persist(team);
//Member 저장
Member member = new Member();
member.setName("memberA");
member.setTeamId(team.getId()); //객체지향스럽지 않다
em.persist(member);
//조회
Member findMember = em.find(Member.class, member.getId());
Team findTeam = em.find(Team.class, member.getTeamId()); //객체지향스럽지 않다
저장이나 조회 로직에서 객체지향스럽지 않은 부분이 존재한다
테이블은 외래키로 조인하여 연관된 테이블을 찾고, 객체는 참조를 이용하여 연관된 객체를 찾는다는 차이가 발생하므로 객체를 테이블에 맞추어 모델링하면 객체지향스럽지 않게 된다
이제부터는 본격적으로 객체지향적인 설계를 알아보자
1. 단방향 연관관계
이번에도 위와 똑같은 조건을 따르는 예시를 이용해 설명한다

- 위의 그림과 비교했을때 Member에서 직접 Team이라는 객체를 참조하여 연관관계를 만들었다는 점이 눈에 띈다
다음은 엔티티 코드이다. Member 엔티티만 달라지고 Team은 동일하다
@Entity
@Getter @Setter
public class Member {
@Id @GeneratedValue
@Column(name = "member_id")
private Long id;
@Column(name = "username")
private String name;
@ManyToOne
@JoinColumn(name = "team_id")
private Team team;
}
@ManyToOne과@JoinColumn2가지가 추가되었음을 확인할 수 있다@ManyToOne은 Member와 Team의 관계가 다대일임을 명시한다@JoinColumn은 엔티티에서 @JoinColumn이 붙은 필드값과 name 속성에 명시된 테이블의 외래키를 매핑하는 역할을 한다- 즉, name 속성에 적어준 값이 테이블에서 외래키가 되어 연관된 다른 테이블로 조인할 수 있도록 한다
- 위 코드에서 Member 객체 내의 team이라는 필드값을 MEMBER 테이블의 TEAM_ID(외래키)값과 매핑하였기 때문에 TEAM_ID(외래키)값을 통해 TEAM 테이블에 조인한다고 보면 된다
//Team 저장
Team team = new Team();
team.setName("teamA");
em.persist(team);
//Member 저장
Member member = new Member();
member.setName("memberA");
member.setTeam(team); //객체지향스럽다
em.persist(member);
//조회 코드
Member findMember = em.find(Member.class, member.getId());
Team findTeam = findMember.getTeam(); //객체지향스럽다
위와 같이 저장과 조회 로직이 전보다 훨씬 객체지향스럽게 바뀌었다
+α) 지연로딩과 즉시로딩
조회 로직에서 @ManyToOne의 fetch 속성을 FetchType.LAZY로 설정하면 현재 요구한 데이터만 조회하여 가져오고, FetchType.EAGER로 설정하면 조인을 이용하여 다른 데이터도 한 번에 땡겨온다. 지금은 이 정도로만 알아두고 자세한건 프록시와 연관관계 관리 글에서 살펴보자
2. 양방향 연관관계
앞으로 배울 내용들이 JPA에서 가장 복잡하기도 하고 중요한 부분이니 집중해서 알아보자

-
위의 그림을 살펴보면 단방향 연관관계와 테이블은 동일하고 Team 객체에 List 형태로 값이 들어갔다는 차이점만 존재한다
-
테이블의 경우 외래키 하나만으로 양측 테이블에서 상대측 테이블로 접근가능하다
-
반면 객체의 경우 양측 객체에 상대측 객체를 참조할 수 있는 필드값이 없는 한 접근가능하지 않다
Team 객체가 Member 객체에 접근할 수 있도록 코드를 변경해보자
@Entity
public class Member {
@Id @GeneratedValue
@Column(name = "member_id")
private Long id;
private String name;
@ManyToOne
@JoinColumn(name = "team_id")
private Team team;
}
@Entity
public class Team {
@Id @GeneratedValue
@Column(name = "team_id")
private Long id;
private String name;
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
}
- 주의깊게 살펴봐야할 점은
@OneToMany이다 @OneToMany를 통해 Team과 Member가 1대다 관계임을 명시한다mappedBy속성을 통해 반대측 객체의 어떤 필드값과 연결되었는지 명시한다- 위 예시에서 Team 객체의 members 필드값이 Member 객체의 team 필드값과 연결되어있음을 명시한다
- 아래에서 배우겠지만
mappedBy속성에 명시된 값이 연관관계의 주인이라고 생각하면 된다- 위의 코드에서
mappedBy속성에 명시된 team 즉, Member 객체 측의 team 필드가 연관관계의 주인임을 알 수 있다
- 위의 코드에서
- List와 같은 필드값은 필드 자체에서 초기화를 통해 null값이 발생하지 않도록 하는 것이 관례이다
다음으로 저장과 조회 로직도 살펴보자
//Team 저장
Team team = new Team();
team.setName("teamA");
em.persist(team);
//Member 저장
Member member = new Member();
member.setName("memberA");
member.setTeam(team);
em.persist(member);
//필수!!!
em.flush();
em.clear();
//조회
Member findMember = em.find(Member.class, member.getId());
List<Member> members = findMember.getTeam().getMembers();
for (Member m : members) {
System.out.println("m.getName() = " + m.getName());
}
}
- 위의 코드는 단순하지만 하나 헷갈릴만한 점이 존재한다. 만약,
em.flush()와em.clear()를 지우면 어떻게 출력될까? 하는 부분이다 - 결과부터 말하자면 아무것도 출력되지 않는다. 여기서부터 혼란이 올 수 있는데 천천히 생각해보면 이해할 수 있으니 당황하지 말자
- 우선 여기에선
@GenratedValue전략으로 SEQUENCE를 사용한다고 가정한다.em.persist()는 DB로부터 seq값만을 가져와 1차 캐시에 엔티티를 저장한다. 그렇다면em.flush()와em.clear()가 없다면 조회 로직 부분에선 DB까지 접근하지 않고 1차 캐시에서 값을 가져오게 된다 - 1차 캐시는 JPA가 DB로부터 값을 가져와 각종 연관관계를 설정하기 이전의 순수 객체만을 들고 있으므로 당연히 team 인스턴스의 members 필드에 직접 데이터를 넣지 않은 현재 상황에서는 값이 존재하지 않는다
3. 연관관계의 주인과 mappedBy
양방향 연관관계에서 테이블 측은 외래키 하나만으로 조인을 이용해 양쪽에서 반대쪽으로 모두 접근할 수 있지만 객체 측은 양쪽 객체 모두에 참조값이 존재해야 한다. 이 점을 이해하는 것이 중요하다. 양방향 연관관계는 단방향 연관관계가 2개 있는 것과 같다고 생각하면 된다

- 양방향 연관관계시에 객체의 연관관계 매핑은 참조를 2개 이용하고 테이블의 연관관계 매핑은 외래키 하나만을 이용한다. 그렇다면, 위의 그림에서 Member 객체의 team과 Team 객체의 members 중 어느 것으로 테이블의 외래키를 관리해야 할까라는 의문점이 발생한다
- 결국 2개의 참조값 중에 하나를 연관관계의 주인으로 지정해주어야 한다. 연관관계의 주인만이 외래키를 등록, 수정할 수 있고, 주인이 아닌 측은 읽기만 가능하도록 설정한다
- 객체에서 주인이 아닌 참조값 측에 아무리 데이터를 넣어도 DB에 반영되지 않는다
- 주인이 아닌 측에서는 mappedBy를 이용하여 주인을 지정해준다. 이제 다시 team과 members 중 어느 것으로 외래키를 관리해야 하는 지 알아보자.
결론부터 말하자면, 일대다 관계에서 다 측에서 즉, 외래키를 가지고 있는 쪽을 연관관계의 주인으로 설정하여 외래키를 관리하도록 설정해야 한다
4. 양방향 연관관계 매핑 시 주의점
양방향 매핑시에 사람들이 자주 하는 실수가 바로 연관관계의 주인이 아닌 측에서 값을 넣는 것이다. 주인이 아닌 측에서는 데이터를 아무리 넣어도 값이 들어가지 않음을 명심하자
DB 입장에서 봤을 때는 연관관계의 주인 측에만 데이터를 넣어주면 된다. 실제로 아래 코드를 살펴보면 연관관계의 주인 측에만 데이터를 넣어줘도 원하는대로 동작하는 것을 알 수 있다
//Team 저장
Team team = new Team();
team.setName("teamA");
em.persist(team);
//Member 저장
Member member = new Member();
member.setName("memberA");
member.setTeam(team);
em.persist(member);
//필수!!!
em.flush();
em.clear();
//조회
Team findTeam = em.find(Team.class, team.getId());
findTeam.getMembers().forEach(m -> System.out.println("m.getName() = " + m.getName()));
- 위와 같이 Team 객체의 members 필드에 값을 넣어주지 않아도 JPA가 DB로부터 값을 읽어와 알아서 값을 넣어준다
- 다만, 위의 코드처럼 연관관계의 주인이 되는 객체에만 데이터를 넣어주게 되면
em.flush(),em.clear()코드를 지우면 조회 로직에서 출력되는 값이 없게 된다em.persist()를 통해 DB로부터 seq값을 가져와 1차 캐시에 엔티티를 저장한 상태이기 때문에em.flush(),em.clear()코드가 없다면 조회시에 1차 캐시에서 엔티티를 가져오게 된다- 현재 1차 캐시의 엔티티는 JPA에 의해 연관관계가 세팅되지 않은 순수한 객체이기 때문에 team의 members 필드에 값을 직접 넣어주지 않은 상태로는 출력이 되지 않는 것이 당연하다
JPA의 도움없이 순수한 테스트를 작성하기 위해서라도 양쪽 객체에 값을 넣어주는게 맞고 이를 위해 연관관계 편의 메서드를 만든다
Member 엔티티에 아래의 연관관계 편의 메서드를 추가한다
public void changeTeam(Team team) {
this.team = team;
team.getMembers().add(this);
}
+α) 무한루프
JPA의 엔티티에서 toString()을 사용하면 참조값을 통해 계속 toString()을 호출하기 때문에 무한루프에 빠지게 된다. 또한, API Controller에서 엔티티를 그대로 반환하지 말고 DTO로 변환하여 반환해야 한다. 만약 엔티티를 그대로 반환한다면 무한루프에 빠질 수 있을 뿐만 아니라 엔티티를 변경할 시에 API의 스펙이 변할 수 있기 때문에 문제가 된다
+α) 양방향 매핑 정리
사실, 단방향 매핑만으로도 이미 연관관계 매핑은 종료된 것이다. 양방향 매핑은 반대측 객체에서도 조회(객체 그래프 탐색)가 가능하도록 설정을 추가한 것일 뿐이다
처음에는 단방향 매핑만으로 설계하고 나중에 개발시에 필요하다면 양방향으로 바꿔주면 된다. 어차피 단방향에서 양방향으로 바꿔도 테이블에 변화는 없고 Java 코드 몇줄만 추가될 뿐이다
Leave a comment