[Spring Boot] JPA 에서의 연관관계
JPA 에서 가장 중요한 개념이라고 하면 연관관계 매핑과 영속성 컨텍스트가 있다. 이번 포스팅에서는 이 중에서 연관관계 매핑에 대해서 이야기해보려고 한다.
객체지향 프로그램에서의 객체와 RDB 에서의 테이블이 서로 연관관계를 맺는 방법이 다르다. 그렇기 때문에 이 둘의 차이를 채우기 위한 매핑과정이 필요하고 이를 ORM 인 JPA 가 수행하게 된다.
연관관계에서는 아래와 같은 용어들이 등장한다.
- 방향(Direction) : 단방향 연관관계, 양방향 연관관계
- 다중성(Multiplicity) : 다대일(N:1), 일대다(1:N), 일대일(1:1), 다대다(N:M)
- 연관관계의 주인(Owner)
데이터 중심의 모델링
JPA 에서 해주는 연관관계 매핑에 대해서 알아보기에 앞서 잘못된 연관관계 방법에 대해서 살펴보려고 한다.
class Member {
private long id;
private long teamId;
private String userName;
}
class Team {
private long id;
private String teamName;
}
위 예시는 객체를 RDB 의 테이블에 맞춰 데이터 중심적으로 모델링 한 것이다. 테이블은 외래키를 통해 또 다른 테이블과의 연관관계를 가지는데 teamId 가 이 외래키에 해당하는 것이다.
하지만 이렇게 테이블에 맞춰 연관관계를 가지면 객체지향 프로그래밍에서의 객체를 제대로 활용할 수 없다. 즉, 객체 사이의 협력관계를 만들 수 없고 굉장히 부자연스러워진다.
또한 해당 객체를 테이블에 저장하고 읽어오는 과정에서 여러가지 단점들이 있는데 예시코드로 살펴보자.
@RequiredArgsConstructor
public class Jpa {
private final EntityManagerFactory emf;
public void save() {
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
try {
Team team = new Team();
team.setTeam("teamA");
em.persist(team);
Member member = new Member();
member.setName("member1");
member.setTeamId(team.getId());
em.persist(member);
tx.commit();
} catch(Exception e) {
tx.rollback();
} finally {
em.close();
}
}
public void find(Long memberId) {
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
try {
// 불필요한 과정이 굉장히 많이 포함된다.
Member findMember = em.find(Member.class, memberId);
Long findTeamId = findMember.getTeamId();
Team findTeam = em.find(Team.class, findTeamId);
tx.commit();
} catch(Exception e) {
tx.rollback();
} finally {
em.close();
}
}
}
특정 member 가 속한 팀 정보를 조회하기 위해서는 Member 를 조회한 뒤 외래키로 가지고 있는 teamId 를 통해서 Team 을 조회하는 과정을 계속해서 반복해야 한다. 즉, Member, Team 을 조회하는 2개의 쿼리를 따로 작성해야 한다.
물론 이 과정을 하면되지 않나? 라고 생각할 수도 있다. 하지만 연관관계가 많아진다면 어떻게 될지 생각해보자.
단방향 매핑
단방향 연관관계에서 객체와 데이터베이스의 테이블이 각각 어떻게 연관관계를 가지는지 살펴보자
- 객체 : 참조를 통해 연관된 객체를 찾는다. A가 B를 참조할 때 B -> A 는 불가능하다.
- 테이블 : 외래키로 Join 해 연관된 테이블을 조회한다. 양방향으로 A -> B, B -> A 모두 가능하다.
이러한 차이가 있고 이러한 차이를 극복하기 위해 매핑이 필요한 것이다. 위에서 설명한 데이터 중심의 모델링은 이러한 차이를 극복하기 위해 객체를 데이터에 맞춘 것이다.
위 그림과 같이 연관관계를 맺는 것을 객체 중심의 모델링이라고 한다. 코드를 살펴보자.
@Entity
public class Memeber {
@Id @GeneratedValue
private Long id;
private String userName;
@ManyToOne
@JoniColumn(name = "team_id")
private Team team;
}
굉장히 자연스러운 연관관계를 가지는 것을 확인할 수 있다.
그럼 단순히 이렇게만 쓰면 되는걸까?
아니다. 이러한 객체는 테이블과 연관관계를 맺는 구조가 다르다. 그렇기 때문에 데이터를 테이블에 삽입하고 읽어올 때 다음과 같은 불필요한 과정들을 거쳐야한다.
public Member findMember(String memberId) {
// SELECT * FROM members WHERE memberId = :memberId;
Member member = new Member(); // 쿼리 결과로 객체 생성
// SELECT * FROM teams WHERE teamId = :teamId;
// 위에서 쿼리한 member 결과에는 외래키인 teamId 가 존재한다.
Team team = new Team();
member.setTeam(team);
return member;
}
이러한 코드를 개발자가 매번 작성해줘야 하는 것이다.
또한 member.getTeam() 을 믿고 사용하려면 이렇게 제대로 매핑이 되어있는지 확인하는 단계를 거쳐야 한다. 서비스 계층과 데이터 액세스 계층이 명확하게 분리가 안되는 것이다.
이러한 매핑을 ORM 이 해주겠다는 것이다. EntityManager 를 이용한 코드를 살펴보자.
@RequiredArgsConstructor
public class Jpa {
private final EntityManagerFactory emf;
public void save() {
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
try {
Team team = new Team();
team.setTeam("teamA");
em.persist(team);
Member member = new Member();
member.setName("member1");
member.setTeam(team);
em.persist(member);
tx.commit();
} catch(Exception e) {
tx.rollback();
} finally {
em.close();
}
}
public void find(Long memberId) {
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
try {
// findMember 를 가져올 때 연관관계를 가지는 Team 까지 가져와서 객체 참조형태로 매핑까지 해준다.
Member findMember = em.find(Member.class, memberId);
Team findTeam = findMember.getTeam();
tx.commit();
} catch(Exception e) {
tx.rollback();
} finally {
em.close();
}
}
}
여기서 잠시 N+1 문제에 대해서 살펴보고 넘어가자. 단방향 연관관계를 사용한다면 아주 흔하게 발생하는 문제이고 인식하지 않고 있다면 발생하는지 알지 못하고 넘어가버리게 된다. 개인이 공부 목적으로 프로젝트를 진행할 때 N+1 문제가 성능상의 이슈로 식별되기 쉽지 않기 때문이다. 나도 그랬다...!
N + 1 문제
이번 포스팅에서는 N + 1 문제에 대해서 간단하게 소개만하고 넘어갈 예정이다.
N + 1 문제는 JPA 를 사용한다면 굉장히 흔하게 접할 수 있는 문제이기 때문에 더욱 중요하다. 하지만 트래픽이 많지 않은 프로젝트에서는 N + 1 문제를 체감하기 어렵기 때문에 크게 의식하지 않을 수 있다는 문제가 있다.
그럼 N + 1 문제란 무엇일까?
@Entity
public class Memeber {
@Id @GeneratedValue
private Long id;
private String userName;
}
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
private teamName;
@OneToMany
@JoniColumn(name = "member_id")
private List<Member> members;
}
위와 같은 단방향 연관관계를 가지는 두 엔티티가 있다. 현재 TeamA 라는 팀에는 총 12명의 Member 가 포함되어 있다고 했을 때 TeamA 를 find() 하면 무슨일이 벌어질까?
SELECT * FROM Team WHERE teamId = 1; // TeamA 의 teamId 는 1 이라고 가정한다.
SELECT * FROM Member WHERE memberId = "member1";
SELECT * FROM Member WHERE memberId = "member2";
SELECT * FROM Member WHERE memberId = "member3";
SELECT * FROM Member WHERE memberId = "member4";
SELECT * FROM Member WHERE memberId = "member5";
SELECT * FROM Member WHERE memberId = "member6";
SELECT * FROM Member WHERE memberId = "member7";
SELECT * FROM Member WHERE memberId = "member8";
SELECT * FROM Member WHERE memberId = "member9";
SELECT * FROM Member WHERE memberId = "member10";
SELECT * FROM Member WHERE memberId = "member11";
SELECT * FROM Member WHERE memberId = "member12";
위와 같은 쿼리가 발생하게 된다.
조회 대상이었던 Team 을 조회하기 위한 한 번의 쿼리와 해당 팀과 연관관계를 맺고 있는 Member 들을 조회하기 위한 N 번의 쿼리(총 N + 1 번의 쿼리)가 발생한다.
만약 하나의 Team 에 속한 Member 가 10만명이라면 무슨일이 벌어질까?
한 번의 요청에 10만 1번의 SQL 쿼리가 발생할 것이다.
그러면 이런 요청이 10만번 발생하면 무슨일이 벌어질까?
셀 수 없을만큼 많은 수의 SQL 쿼리가 발생할 것이다.
정리하자면 N + 1 문제가 발생할 때 트래픽이 증가함에 따라 SQL 쿼리의 수는 기하 급수적으로 늘어나게 된다. 이는 서비스에 심각한 성능저하를 유발할 수 있고 따라서 반드시 해결해야하는 문제다.
이러한 N + 1 문제를 해결하기 위한 방법으로는 Fetch Join, @EntityGraph 가 있는데 이는 별도의 포스팅을 통해서 살펴볼 예정이다.
양방향 연관관계
사실 객체에서는 양방향 연관관계라는건 없다. 그냥 두 객체가 서로 참조하는 단방향 연관관계가 2개인 것을 양방향 연관관계라고 하는 것이다.
데이터베이스의 테이블은 애초에 단방향 연관관계가 없었고 양방향 연관관계만 존재했다. 외래키를 통해 Join 하고 그 결과를 통해 A->B, B->A 모두 가능하기 때문이다.
객체에서 양방향 연관관계가 없고 단방향 연관관계 2개를 양방향 연관관계라고 말하기 때문에 데이터베이스 테이블과의 차이점이 생기게되고 이러한 차이점을 채우는 것이 양방향 연관관계 매핑이다.
위와 같이 객체가 서로를 참조하기 위해 단방향 연관관계를 가지는 것을 양방향 연관관계라 말한다.
객체에는 두 개의 단방향 연관관계가 있고 테이블에는 하나의 양방향 연관관계가 있다. 그럼 Member 객체의 team 을 수정해도 외래키인 TEAM_ID 가 수정되어야 하고, Team 의 members 를 수정해도 외래키인 TEAM_ID 가 수정되어야 한다. 다시 말해 MEMBER 테이블의 외래키 TEAM_ID 는 두 개의 연관관계 매핑에 엮여있다.
이러한 문제를 해결하기 위해 양방향 연관관계 매핑에서는 해당 연관관계의 주인을 결정해야 한다.
즉, 객체의 두 개의 단방향 연관관계 중에서 외래키 TEAM_ID 에 영향을 줄 하나의 단방향 연관관계를 지정해줘야 한다는 것이다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String memberName;
@ManyToOne
@JoinColumn("team_id")
private Team team;
}
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
private String teamName;
@OneToMany(mappedBy = "team")
List<Member> members = new ArrayList<>();
}
여기서 중요하게 봐야할 것은 @OneToMany 어노테이션에 mappedBy 값이다. 이 값이 하는 역할이 두 개의 단바향 연관관계 중에서 연관관계 매핑의 주인을 정하는 것이다.
위에서 @OneToMany(mappedBy = "team") 의 의미는 team 이라는거에 매핑한다는 것이다. 따라서 연관관계 매핑의 주인은 team 을 가지고 있는 Member 클래스가 된다.
이렇게 연관관계 매핑의 주인이 정해지면 다음과 같은 규칙이 생긴다.
- 연관관계 매핑의 주인만이 외래키를 관리한다. (등록 & 수정)
- 주인이 아닌 쪽에서는 오직 읽기만 가능하다.
- 주인은 mappedBy 속성을 사용하지 않는다.
- 주인이 아닌 쪽에서는 mappedBy 속성을 통해 주인을 지정해줘야 한다.
이런 규칙이 필요한 이유는 "난 members 를 수정했는데 왜 테이블에 반영이 안되지" 와 같은 문제가 발생할 수 있기 때문이다. 테이블에 반영하려면 반드시 연관관계 매핑의 주인에 값을 변경해줘야 한다.
그럼 여기서 이러한 의문이 들 수 있다.
그래서 두 객체중 어떤걸로 연관관계 매핑의 주인을 지정해야되는데?
그 답은, 외래키를 가지고 있는 테이블을 연관관계 매핑의 주인으로 설정하면 된다. 물론 정해진 것은 아니다. 하지만 이렇게 하지 않으면 Team 객체의 members 를 수정했을 때 MEMBER 테이블이 수정되는 불일치 문제가 발생할 수 있다. 이러한 혼동을 방지하기 위해 외래키를 가지고 있는 테이블에 해당하는 객체를 연관관계 매핑의 주인으로 설정하는 것을 권장한다.
양방향 연관관계에서 주의점
양방향 연관관계에서 주의해야 할 점은 연관관계 매핑의 주인에 값을 입력하지 않으면 데이터베이스 테이블에 데이터가 반영되지 않는다는 점이다.
Member member = new Member();
member.setName("member1");
em.persist(member);
Team team = new Team();
team.setName("teamA");
team.getMembers().add(member);
em.persist(team);
이렇게 했을 때 외래키에는 null 값이 저장되는 문제가 생긴다. 연관관계 매핑의 주인이 Member 인데 member.setTeam() 메소드를 통해 Team 을 지정해주지 않았기 때문이다.
이러한 문제를 해결하기 위해 연관관계 매핑의 주인만 수정해도 되지만, 순수한 객체 참조관계를 고려한다면 그냥 양쪽 다 입력해주는게 바람직하다.
더 나아가서 Team 을 셋팅하는 메소드를 만들어서 처리하면 더 깔끔하다.
public void changeTeam(Team team) {
this.team = team;
team.getMembers().add(this);
}
또 한가지 주의해야할 점은 순환참조문제가 발생할 수 있다는 것이다.
우리가 굉장히 흔하게 사용하는 toString(), lombok, JSON 생성 라이브러리를 쓰게되면 순환참조문제가 발생할 수 있다.
/* 회원(Member) 엔티티*/
@Entity
public class Member {
@Override
public String toString() {
return "Member{" +
"id=" + id +
", name='" + name + '\'' +
", team=" + team +
'}';
}
}
/* 팀(Team) 엔티티 */
@Entity
public class Team{
@Override
public String toString() {
return "Team{" +
"id=" + id +
", name='" + name + '\'' +
", members=" + members +
'}';
}
}
그럼 이러한 양방향 연관관계가 필요한 이유는 무엇일까?
사실 데이터베이스 테이블에서의 연관관계는 객체에서의 단방향 연관관계나, 양방향 연관관계나 변화가 없기 때문에 단방향 연관관계로도 충분하다. 그러면 양방향 연관관계가 필요한 이유에는 어떤 것들이 있을까?
가장 쉽게 생각할 수 있는 이유는 "반대 방향으로의 참조가 가능하기 때문(반대 방향으로의 객체 그래프 탐색 기능 추가)" 이다. 반대 방향으로의 참조가 필요한 경우는 단순히 비즈니스 로직에서 필요한 경우도 있을 것이다. 하지만 JPQL 에서 역방향 참조가 필요한 경우가 많다는 점이 중요하다.
기본적으로 단방향 연관관계를 사용하고 필요에 따라서 양방향 연관관계를 사용하는 것은 옳다.
하지만, 단방향 연관관계가 양방향 연관관계보다 좋다고 말하는 것은 잘못된 말이다.
일대다(1:N) 단방향 연관관게 매핑에서 영속성 전이(@OneToMany(cascade = CascadeType.ALL)) 를 통한 Insert 시에 외래키 지정을 위한 추가적인 Update 쿼리가 발생할 수 있다. 이 경우에는 오히려 양방향 연관관계로 변경함으로써 이러한 불필요한 Update 쿼리를 없앨 수 있다.
@Transactional
public void createTeamWithMembers() {
Team team = new Team("teamA");
Member member1 = new Member("member1");
Member member2 = new Member("member2");
team.getMembers().add(member1);
team.getMembers().add(member2);
Team savedTeam = teamRepository.save(team);
}
이렇게 하게 되면 하나의 트랜잭션으로 묶여있기 때문에 쓰기지연으로 인해서 최종적으로 총 3번의 Insert 쿼리가 발생할 것이라고 예상하게 된다.
하지만, 실제로 발생하는 쿼리는 다음과 같다.
즉 왜래키 지정을 위해 추가적인 Update 쿼리가 발생하게 된다. 만약 Insert 가 10만번 발생한다면 10만번의 Update 쿼리가 추가적으로 발생하게 된다.
따라서 이러한 경우에 대해서는 양방향 연관관계가 필요한 경우가 있다.
(일반적인 상황에서는 단방향 연관관계를 사용하는 것이 좋다)
연관관계 예시
먼저 다대일(N:1) 연관관계에 대해서 살펴보자.
이러한 다대일 연관관계를 가장 많이 사용하게 되고, 객체가 서로 참조가 필요한 상황이라면 양방향 연관관계를 사용하면 된다.
일대다(1:N) 연관관계에 대해서 살펴보자.
일대다 관계에서는 '다' 쪽에 항상 외래키가 존재하게 된다. 객체에는 Team 에 있지만 테이블에는 MEMBER 에 외래키가 있기 때문에 혼동이 올 수 있다. (양방향 연관관계에서 연관관계 매핑의 주인을 결정할 때 나왔던 내용과 유사하다)
그리고 이러한 일대다(1:N) 연관관계에서는 @JoinColumn 을 반드시 사용해야한다. 사용하지 않으면 중간에 Join 테이블을 하나 추가하게 된다.
따라서 기본적으로 다대일(N:1) 연관관계를 사용하다가 반대 방향의 참조가 필요하면 양방향 연관관계를 사용하는 것을 권장한다.
물론 일대다(1:N) 연관관계에서도 양방향 연관관계가 존재한다.
/* 팀(Team) */
public class Team{
...
@OneToMany
@JoinColumn(name="TEAM_ID")
private List<Member> members = new ArrayList<>();
...
}
/* 멤버(Member) */
public class Member{
...
@ManyToOne
@JoinColumn(name="TEAM_ID", insertable=false, updatable=false)
private Team team;
...
}
이 경우에 insertable, updateable 옵션을 임의로 해제해 연관관계 매핑의 주인이 아닌쪽에서는 등록, 수정이 불가능하도록 임의로 만들어줘야 한다. 공식적으로 이러한 양방향 연관관계 매핑은 지원하지 않는다.
마지막으로 다대다(N:M) 연관관계에 대해서 살펴보자.
다대다 연관관계는 실무에서 거의 사용하지 않고 추천도 하지 않는다고 한다. 다대다 연관관계가 필요한 경우에는 중간에 하나의 엔티티를 추가해 @ManyToMany 를 @OneToMany, @ManyToOne 두 개로 분리하는 것을 권장한다.
다대다 연관관계에 대한 내용은 추후에 더 정리를할 예정이다.
참고한 자료는 아래와 같다.
https://www.youtube.com/watch?v=WfrSN9Z7MiA&list=PL9mhQYIlKEhfpMVndI23RwWTL9-VL-B7U
https://catsbi.oopy.io/e5ab2f18-321c-4ac5-a7bb-df739964d5c2
https://www.youtube.com/watch?v=rYj8PLIE6-k