JPA(Java Persistence API)를 이해하는 데 어려움을 겪는 이유 중 두 가지는 영속성 컨텍스트의 작동 메커니즘과 연관관계 매핑입니다. 이번 포스팅에서는 연관관계 매핑에 대해 정리해보고자 합니다.
JPA는 객체 지향 프로그래밍과 데이터베이스 간의 패러다임 불일치를 해결하기 위해 설계되었습니다. 이를 통해 객체지향적인 개발 방식을 지원하며, 연관관계 매핑은 이러한 패러다임 불일치를 해소하고 객체지향적인 개발을 용이하게 합니다.
연관관계 정의 규칙
연관관계를 매핑할 때 고려해야 할 사항은 다음과 같이 3가지로 구분됩니다.
- 방향: 단방향, 양방향 (객체 참조)
- 연관관계의 주인: 양방향 관계에서 관리 주체가 되는 엔티티 (FK를 가지는 테이블)
- 다중성: 다대일(N:1), 일대다(1:N), 일대일(1:1), 다대다(N:M)
위 내용을 고려하여 연관관계를 정의할 수 있습니다.
단방향, 양방향
데이터베이스 하나의 테이블이 외래 키를 가지고 있다면 양 쪽 테이블 조인이 가능합니다. 따라서 데이터베이스는 단방향이니 양방향이니 나눌 필요가 없습니다. 그러나 객체는 참조 필드가 있어야 다른 객체를 참조(접근)하는 것이 가능합니다.
예를 들어 A객체와 B객체가 있는 경우, A객체만 B객체를 참조할 수 있다면 단방향 관계, 두 객체 모두 각각 서로를 참조할 수 있다면 양방향 관계라고 합니다. 사실 엄밀하게 말하면 양방향 관계 ↔️ 는 존재하지 않으며, 두 객체가 각각 단방향 참조를 가져 양방향 관계처럼 사용할 수 있어 양방향 관계라 불리는 것입니다.
- A -> B 단방향 참조
@Getter
class A {
private long id;
private B b; // this.getB() -> B 참조
}
@Getter
class B {
private long id;
}
- A <-> B 양방향 참조
@Getter
class A {
private long id;
private B b; // this.getB() -> B 참조
}
@Getter
class B {
private long id;
private A a; // this.getA() -> A 참조
}
단방향, 양방향은 JPA를 사용하여 객체지향 프로그래밍과 데이터베이스의 불일치 패러다임을 맞추기 위해 존재하며, 객체는 단뱡향 연관관계를 가질지, 양방향 연관관계를 가질지 선택해야 합니다.
🤔 그렇다면 양방향 관계로 정하면 편하지 않나?
객체 관점에서 양방향 매핑을 했을 때 고민해봐야 할 것들이 더 생기게 됩니다. 일반적으로 사용자 엔티티는 많은 엔티티와 연관관계를 맺습니다. 이러한 경우 모든 엔티티를 양방향 관계로 설정하게 되면 사용자 엔티티는 너무 많은 테이블과 연관관계를 맺게 되고 복잡해지는 문제가 발생합니다.
또한 값을 수정할 때 단방향 매핑은 객체 하나만 신경 쓰면 되지만, 양방향 매핑은 두 객체를 신경 써야 하기 때문에 설계를 기본적으로 단방향으로 잡고, 필요에 따라 양방향으로 매핑하는 것이 옳습니다.
연관관계의 주인
두 객체(A, B)가 양방향 관계, 즉 단방향 관계 2개(A -> B, B -> A)를 맺을 때 연관관계의 주인을 지정해야 합니다. 연관관계의 주인은 외래 키를 비롯한 테이블 컬럼 저장, 수정, 삭제의 권한을 가지고 있음을 뜻합니다. 이와 반대로 주인이 아닌 반대 객체는 읽기(mappedBy)만 가능합니다.
일반적으로 연관관계의 주인이 데이터베이스 상으로 외래 키를 가진 테이블이 되며, 이와 같은 방식이 관리하기 효율적이기 때문입니다.
🤔 왜 연관관계 주인을 지정해야 하는가?
예를 들어, 게시글(Post)의 게시판(Board)을 다른 게시판으로 수정하려 할 때, Post 객체의 setBoard() 메서드를 사용할지, Board 객체의 getPosts() 메서드를 통해 게시글 목록을 수정할지 헷갈릴 수 있습니다.
객체 입장에서는 두 방법 모두 맞는 방법이지만 JPA 입장에서는 어떤 객체가 변경을 주도하는지 혼란을 주게 됩니다. Post에서 Board를 수정할 때 외래 키를 수정할지, Board에서 Post를 수정할 때 외래 키를 수정할 지 결정하기 어려운 것입니다. 이러한 이유로 두 객체 사이의 연관관계의 주인을 정해 명확하게 Post 객체로 Board를 수정할 때 만 수정하겠다고 정하는 것입니다.
🤔 연관관계의 주인만 제어하면 되나?
데이터베이스 관점에서 외래 키가 있는 테이블만 수정하는 경우 연관관계의 주인만 변경하는 것이 맞는지 물어본다면 맞다고 볼 수 있습니다. 그러나 객체 관점에서는 둘 다 변경해 주는 것이 옳습니다.
이러한 이유는 두 참조를 사용하는 순수한 두 객체는 데이터 동기화를 해줘야 하기 때문입니다. 이를테면 두 객체 중 하나만 변경하고, 트랜잭션이 끝나는 경우 데이터베이스는 이를 반영해 줍니다. 그러나 같은 트랜잭션 범위 내 값을 수정하고 재사용하는 경우 객체 관점에서 하나의 객체만 변경되고 변경되지 못한 객체는 변경된 사실을 알지 못하기 때문에 자칫 다른 결과가 나올 수 있으며, 이러한 오류는 찾기 매우 어렵기 때문입니다.
다중성
JPA는 데이터베이스를 기준으로 다중성을 결정하며, 연관관계를 다음과 같이 대칭성을 가지게 됩니다.
- 일대다(1:N) ↔ 다대일(N:1)
- 일대일(1:1) ↔ 일대일(1:1)
- 다대다(N:M) ↔ 다대다(M:N)
다대일(N:1) - @ManyToOne
주 테이블 회원(Member)과 대상 테이블 팀(Team)의 관계가 있다고 가정합니다.
요구사항은 다음과 같습니다.
- 하나의 팀은 여러 회원을 포함할 수 있다.
- 하나의 회원은 하나의 팀에 포함될 수 있다.
- 회원과 게시판은 다대일 관계를 가지면 다 쪽이 연관관계 주인이다.
다대일 단방향
@Entity
public class Member {
@Id @GeneratedValue
@Column(name = "member_id")
private Long id;
@ManyToOne
@JoinColumn(name = "team_id")
private Team team;
...
}
@Entity
public class Team {
@Id @GeneratedValue
@Column(name = "team_id")
private Long id;
...
}
다대일 단방향은 다 쪽인 Member에서 @ManyToOne만 추가해 주는 것을 확인할 수 있습니다. 반대인 Team에서는 Member를 참조하지 않습니다. (단방향이기 때문)
다대일 양방향
@Entity
public class Member {
@Id @GeneratedValue
@Column(name = "member_id")
private Long id;
@ManyToOne
@JoinColumn(name = "team_id")
private Team team;
...
}
@Entity
public class Team {
@Id @GeneratedValue
@Column(name = "team_id")
private Long id;
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
...
}
다대일 양방향은 단방향 관계를 가진 상태에서 반대쪽에 @OneToMany를 추가하고 양방향 매핑을 걸어주고 연관관계의 주인을 mappedBy로 지정해 줍니다. mappedBy에 지정되는 값은 대상이 되는 변수명을 따라 지정하면 됩니다.
일대다(1:N) - @OneToMany
일대다는 다대일의 반대 입장인데 굳이 쓸 필요가 있나? 싶지만 앞서 다대일의 기준은 연관관계의 주인이 다(N) 쪽에 둔 것이며, 이번에 정리할 일대다의 기준은 연관관계의 기준이 일(1) 쪽에 둔 것입니다.
실무에서 일(1)에 연관관계를 주인으로 지정하는 것은 거의 쓰지 않습니다. 데이터베이스 관점에서 다(N) 쪽이 외래 키를 관리하지만 객체 관점에서는 연관관계의 주인을 반대로 둘 수 있습니다. 그러나 데이터베이스 관점에선 테이블의 외래 키가 옮겨지는 것은 아닙니다.
일대다 단방향
@Entity
public class Member {
@Id @GeneratedValue
@Column(name = "member_id")
private Long id;
...
}
@Entity
public class Team {
@Id @GeneratedValue
@Column(name = "team_id")
private Long id;
@OneToMany
@JoinColumn(name = "member_id")
priavte List<Member> members = new ArrayList<>();
...
}
위와 같은 방식은 치명적인 단점을 가지고 있습니다. 예를 들어 Team에 포함되는 Member를 추가하는 경우 Member -> Team으로 변경할 수 없기 때문에 Team -> Member로 참조하여 추가해줘야 합니다.
객체 관점에서는 별 문제없어 보이나, 이러한 방식은 Team을 insert 하는 쿼리가 나간 후, Member를 update 하는 쿼리가 나가게 됩니다. 이는 데이터베이스에 외래 키를 가지고 있는 테이블은 Member이기 때문에 Team 테이블 하나로 관계를 맺어줄 수 없기 때문입니다.
객체는 Team 하나만 추가해 주면 끝이지만, 데이터베이스는 Member가 Team의 외래 키를 가지기 때문에 Team과 Member를 같이 가져와야 합니다. 따라서 일대다 단방향 관계를 쓰기보다 다대일 양방향 관계를 쓰는 것이 추후 유지보수 면에서도 훨씬 수월합니다.
일대다 양방향 (실무 사용 금지 ❌)
일대다 양방향은 공식적으로 존재하지 않습니다.
사용하려면 방법이 있긴 하나, 일대다 양방향을 사용할 때는 다대일 양방향을 사용하는 것이 더 좋은 방식입니다.
일대일(1:1) - @OneToOne
일대일 관계의 경우 주 테이블에 외래 키를 넣을 수도 있고, 대상 테이블에 외래 키를 넣을 수 있습니다. 상황에 따라 연관관계의 주인을 정하면 됩니다.
예를 들어 User, Locker 엔티티가 있으며, User는 하나의 Locker를 가질 수 있습니다. 또한 주 테이블은 User이며 외래 키를 갖고 있습니다.
일대일 단방향
@Entity
public class User {
@Id @GeneratedValue
@Column(name = "user_id")
private Long id;
@OneToOne
@JoinColumn(name = "locker_id")
private Locker locker;
...
}
@Entity
public class Locker {
@Id @GeneratedValue
@Column(name = "locker_id")
private Long id;
...
}
크게 특별할 게 없습니다.
일대일 양방향
@Entity
public class User {
@Id @GeneratedValue
@Column(name = "user_id")
private Long id;
@OneToOne
@JoinColumn(name = "locker_id")
private Locker locker;
...
}
@Entity
public class Locker {
@Id @GeneratedValue
@Column(name = "locker_id")
private Long id;
@OneToOne(mappedBy = "locker")
private User user;
...
}
양방향도 다를 것 없이 @OneToOne을 넣어주고, mappedBy로 연관관계의 주인을 지정해 주면 됩니다.
일대일 단방향 (대상 테이블이 연관관계 주인인 경우)
앞서 말한 것처럼 주 테이블이 User이며 외래 키를 가지고 있는 경우 JPA는 이를 지원하지 않습니다.
일대일 양방향 (대상 테이블이 연관관계 주인인 경우)
반대로 @JoinColumn을 대상 테이블에 걸어두고 mappedBy를 주 테이블에 걸어주면 됩니다. 그러나 이에 대한 논란의 여지가 있습니다. 외래 키를 주 테이블과 대상 테이블 중 누가 관리하는 것이 좋을지 생각해봐야 합니다.
일반적으로 테이블은 한번 생성이 되면 변경되는 경우는 거의 없습니다. 하지만 비즈니스 로직은 언제든지 바뀔 가능성이 있습니다. 예를 들어 User가 여러 Locker를 가질 수 있게 요구사항이 변경된다면 Locker는 다(N)가 될 것이며, 다(N) 쪽이 외래 키를 가지고 있는 것이 변경에 유연합니다.
🤔 미래에 변경될지도 모르기 때문에 다가 될 확률이 높은 테이블에 외래 키를 놓는 것이 좋을까?
그건 또 아니라고 볼 수 있습니다. 객체 관점에서 주 테이블인 User가 외래 키를 가지고 있다면 User를 조회할 때 이미 Locker의 참조를 가지고 있기 때문에 성능 상 이득을 볼 여지가 있습니다.
Locker -> User를 조회하기보단 User -> Locker를 조회할 확률이 더 높기 때문입니다. 개발자 입장에서는 주 테이블에 외래 키를 두는 것이 좋겠지만, 데이터베이스를 관리하는 입장에서는 다르기 때문에 상황에 따라 맞춰 써야 합니다.
다대다(N:M) - @ManyToMany
실무에서 사용하지 않습니다.
다대다는 결국 중간 테이블을 만들어 외래 키를 관리하는 방식인데 이러한 중간 테이블을 따로 관리할 수 없다는 문제를 가지고 있습니다. 또한 개발자가 쿼리를 직접 짜고도 본인도 모르는 복잡한 쿼리가 발생하는 경우가 있을 수 있습니다.
다대다를 사용하기보단 직접 중간 테이블을 담당할 엔티티를 따로 설계하는 것이 좋습니다.
@ManyToMany -> @OneToMany ↔️ @ManyToOne
자바 ORM 표준 JPA 프로그래밍 - 기본편 | 김영한 - 인프런
김영한 | JPA를 처음 접하거나, 실무에서 JPA를 사용하지만 기본 이론이 부족하신 분들이 JPA의 기본 이론을 탄탄하게 학습해서 초보자도 실무에서 자신있게 JPA를 사용할 수 있습니다., 실무에서도
www.inflearn.com
'Spring > JPA' 카테고리의 다른 글
프록시(Proxy) (0) | 2024.04.28 |
---|---|
상속관계 매핑 (0) | 2024.04.24 |
엔티티 매핑(Entity Mapping) - 기본 키 (1) | 2024.04.22 |
엔티티 매핑(Entity Mapping) - 객체와 테이블, 필드와 컬럼 (1) | 2024.04.21 |
영속성 컨텍스트(Perisistence Context) (0) | 2024.04.21 |