개요
어플리케이션 내 객체는 다른 객체와 협력 관계를 통해 문제를 해결한다.
해결하고자 하는 문제에 따라 객체들은 다양한 협력 관계를 가지며 이에 따라 연관관계도 다양하다.
동일하게 엔티티 객체도 다른 엔티티 객체와 다양한 연관관계를 맺는다.
하지만 객체와 테이블은 다른 방식으로 관계를 맺는다.
객체는 참조(주소)를 사용해 관계를 맺고, 테이블은 외래 키를 사용해 관계를 맺는다.
ORM에서 가장 어려운 부분이 위 두 연관관계를 매핑하는 일이다.
객체와 테이블 연관관계 매핑에서 핵심이 되는 키워드는 아래와 같다.
- 방향 : 방향은 단방향과 양방향이 있다. 단방향은 한쪽으로의 일방적인 관계를 말하고, 양방향은 양쪽으로의 관계를 말한다.
- 다중성 : 다중성은 한 엔티티가 다른 엔티티와의 관계에서 가질 수 있는 인스턴스를 말한다.
- 연관관계의 주인 : 연관관계가 양방향일 때 해당 연관관계의 주인을 정해야 한다.
이제 연관관계 매핑에 대해 자세히 살펴보자. 이번 기록에선 다대일 연관관계만 다루려고 한다.
단방향 연관관계
다대일 단방향 관계를 이해하면, 연관관계 매핑에서 가장 어려운 부분의 핵심을 알 수 있다.
팀원과 팀과의 관계를 통해 다대일 단방향 관계를 살펴보자.
팀과 팀원
팀과 팀원의 객체 연관관계는 아래와 같다.
객체 연관관계에서 Member는 참조를 이용해 해당 Member의 팀을 알 수 있지만, Team은 해당 Team에 소속된 Member를 알 수 없다.
따라서 위 관계를 단방향 관계라고 할 수 있다.
팀과 팀원의 테이블 연관관계는 아래와 같다.
Team의 PK를 가지고 있는 테이블은 Member다. 따라서 Member 테이블에서 Team을 조인해 해당 Member가 속한 Team을 찾을 수 있다는 것은 객체 연관관계와 동일해 보인다. 하지만 Team 또한 Member 테이블과의 조인으로 해당 Team에 속한 Member를 찾을 수 있다. 결과적으로 위 관계는 양방향 관계라고 할 수 있다.
위에서 볼 수 있듯 한 엔티티(테이블/객체)에서 다른 엔티티(테이블/객체)에 대한 정보(PK/참조)를 가지고 있다는 같은 상황은 동일하지만,
상대에 대한 정보(PK/참조)가 없는 엔티티(테이블/객체)에서 상대 엔티티의 필드를 가져올 수 있는가에 대한 여부에서 차이가 있다.
이 차이를 JPA에서 어떻게 풀어냈을까?
JPA에서의 팀과 팀원
Member 엔티티와 Team 엔티티의 코드는 아래와 같다.
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "MEMBER_ID")
Long id;
String name;
@ManyToOne
@JoinColumn(name = "TEAM_ID")
Team team;
...
public void changeTeam(final Team05 team) {
this.team = team;
}
...
}
@Entity
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "TEAM_ID")
Long id;
String name;
...
}
이제 위 코드를 분석해 보자.
@ManyToOne
다대일(N:1)이라는 연관관계 매핑 정보이다. 여러 멤버가 팀에 속할 수 있음을 뜻한다.
연관관계를 매핑할 때 이처럼 다중성을 나타내는 어노테이션을 필수로 사용해야 한다.
속성
속성 | 기능 | 기본값 |
optional | false로 설정하면 연관된 엔티티가 항상 있어야 한다. | true |
fetch | 글로벌 페치 전략을 설정한다. | @ManyToOne -> FetchType.EAGER @OneToMany -> FetchType.LAZY |
cascade | 영속성 전이 기능을 사용한다. | |
targetEntity | 연관된 엔티티의 타입 정보를 설정한다. |
@JoinColumn
조인 컬럼은 외래 키를 매핑할 때 사용한다. name 속성에는 매핑할 외래 키 이름을 지정한다.
따라서 Member 객체에 매핑되는 테이블에 name 속성의 값이 외래 키 이름으로 사용되는 것이다.
JoinColumn은 생략 가능하며 생략하면 "필드명 + _ + 참조하는 테이블의 컬럼명"을 기본 전략으로 이름이 생성된다.
위 예시에선 team_TEAM_ID가 될 것이다.
주요 속성
속성 | 기능 | 기본값 |
name | 매핑할 외래 키 이름 | 필드명 + _ + 참조하는 테이블의 컬럼명 |
referencedColumnName | 외래 키가 참조하는 대상 테이블의 컬럼명 | 참조하는 테이블의 기본키 컬럼명 |
foreignKey(DDL) | 외래 키 제약 조건을 지정할 수 있다. | |
unique, nullable, insertable, updatable, columnDefinition, table | @Column의 속성과 같다. |
CRUD
이제 CRUD를 통해 연관관계를 어떻게 사용하는지 살펴보자.
CREATE (저장)
public void create(EntityManager entityManager) {
Team team = new Team();
team.setName("TeamA");
entityManager.persist(team);
Member member = new Member("member1");
member.changeTeam(team);
entityManager.persist(member);
}
JPA에서 엔티티를 저장할 때 연관된 모든 엔티티는 영속 상태여야 한다.
하고자 하는 것은 Team 객체와 member 객체를 저장하는 것, member 객체를 생성한 team에 속하도록 하는 것이다.
이때 연관된 엔티티가 영속 상태여야 하므로 Team 객체를 먼저 생성하고, persist() 메서드로 영속성 컨텍스트에서 관리되도록 했다.
결과는 아래와 같다.
Member Table
Team Table
READ (조회)
위 저장 과정에서 member가 team에 속하도록 했다면, 조회하는 것은 쉽다.
객체 그래프 탐색을 이용해 member.getTeam()으로 member 객체가 속한 팀의 정보를 쉽게 조회할 수 있다.
Update (수정)
JPA에선 dirty checking을 통해 객체의 상태 변화를 DB에 반영한다.
연관관계를 맺고 있는 엔티티도 마찬가지다. member의 팀이 변경된다면, member의 메서드를 통해 속한 팀을 변경시키면 된다.
Delete (삭제)
삭제도 마찬가지로 member의 메서드를 통해 속한 팀을 null로 해주면, 연관관계가 삭제된다.
하지만 이때 member와 team의 연관관계가 아닌 Team 객체를 삭제하기 위해선 해당 Team에 속한 member의 team을 모두 null로 바꿔줘야 한다. 그렇지 않으면 외래 키 제약조건으로 인해 오류가 발생한다.
양방향 연관관계
위 단방향 연관관계에선 Member에서 Team으로의 일방적인 연관관계만 가능했다. 만약 Team에서 Member로 접근하고 싶다면, 양방향 연관관계를 이용하면 된다. 이때 Team은 여러 Member를 가질 수 있기 때문에 List 등 컬렉션으로 Member에게 접근할 수 있다.
앞서 말한 것처럼 테이블 관점에선 Member의 외래 키 하나만으로 Team에서도 소속 팀원을 조회할 수 있기 때문에 테이블에 추가할 것은 없다. 그렇다면 엔티티 객체에선 어떤 추가 작업을 해줘야 할까?
양방향 연관관계 매핑
Member 엔티티에는 변경할 부분이 없고, Team 엔티티에 추가해줘야 하는 것이 있다.
@Entity
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "TEAM_ID")
Long id;
String name;
@OneToMany(mappedBy = "team")
List<Member> members = new ArrayList<>();
...
}
@OneToMany
팀과 회원은 일대다 관계다. 따라서 해당 매핑정보를 나타내는 어노테이션을 사용해야 한다.
mappedBy 속성은 양방향 매핑일 때 사용하는데 반대쪽 매핑의 필드 이름을 값으로 주면 된다.
Member에서 team 필드로 소속팀에 접근하므로 위처럼 team을 값으로 줬다.
이제 members 필드를 이용해 소속 팀원들을 확인할 수 있다.
연관관계의 주인
테이블은 외래 키 하나로 두 테이블의 연관관계를 관리해 양방향 연관관계를 제공하지만, 객체는 각각 상대 객체에 대한 참조를 가지고 있어야 양방향 연관관계를 맺을 수 있다.
객체 연관관계
- 회원 ➡️ 팀 (단방향)
- 회원 ⬅️ 팀 (단방향)
테이블 연관관계
- 회원 ↔️ 팀 (양방향)
때문에 두 객체의 연관관계 중 하나를 정해 테이블의 외래 키를 관리할 연관관계의 주인을 정해야 한다.
당연하게도 일대다 관계에서 다 쪽이 외래키를 가진다. 따라서 다 쪽이 연관관계의 주인이다.
외래키를 가지지 않은 쪽에선 mappedBy를 통해 연관관계의 주인이 아님을 명시한다.
mappedBy의 속성 값은 주인 엔티티의 필드다. 위의 코드에선 Member(주인)의 team 필드를 값으로 지정한다.
양방향 연관관계에서 많이 하는 실수
양방향 연관관계를 맺었다고 하면 주인은 상대 객체에 직접 참조로 접근할 수 있고, 상대는 컬렉션으로 주인을 관리하고 있는 상황이다.
하지만 그렇다고 해서 완전히 POJO처럼 객체를 영속화할 수는 없다. 주인이 아닌 곳에만 값을 입력하게 되면, DB에 정상적으로 저장되지 않는다. 코드를 통해 살펴보자.
public static void ownerMistake(EntityManager entityManager) {
Member member = new Member("member1");
entityManager.persist(member);
Team team = new Team();
team.setName("TeamA");
//연관관계의 주인이 아니라면 참조를 추가해도 DB에 저장되지 않는다.
//외래 키가 없으니 Member에 접근할 수 없다.
team.getMembers().add(member);
entityManager.persist(team);
}
위 코드를 보면 주인이 아닌 Team의 members 필드에 주인 Member를 추가하고, Member의 team 필드에 값을 지정해주지 않았다.
결과는 아래와 같다.
주인인 Member가 가지는 Team 외래 키가 정상적으로 저장되지 않았다. 그렇다면 주인 Member에만 Team을 지정하면 될까?
public static void ownerMistake(EntityManager entityManager) {
Team team = new Team();
team.setName("TeamA");
entityManager.persist(team);
Member member = new Member("member1");
member.changeTeam(team);
entityManager.persist(member);
System.out.println("==========");
//연관관계의 주인인 멤버에서 team을 지정했지만, 순수 객체에서 team에 멤버가 추가되지 않았다.
//따라서 멤버가 출력되지 않는다.
for (Member m : team.getMembers()) {
System.out.println("name = " + m.getName());
}
}
위 코드에선 Team의 members 필드 순수 객체에 새로운 member가 추가되지 않았기 때문에 아무것도 출력되지 않는다.
ORM에선 객체와 관계형 데이터베이스 모두 중요하다. DB 뿐만 아니라 순수 객체도 고려해야 한다.
Member의 changeTeam 메서드를 수정해 위 문제를 해결해 보자.
public void changeTeam(final Team team) {
this.team = team;
team.addMember(this);
}
위처럼 DB와 순수 객체 모두 고려해 양방향 관계를 모두 설정하도록 변경했다.
이제 위 메서드를 이용하면, DB와 순수 객체에 모두 team의 member 영입이 구현된다.
하지만 아직 팀 변경 메서드에 허점이 있다. 처음에 Member가 teamA에 소속되어 있다고 해보자.
의도대로 양방향 연관관계가 잘 구현됐다. 하지만 이때 Member가 Team을 변경한다고 해보자.
위 changeTeam 메서드로 Team을 변경했을 경우 그림과 같은 상황이 발생한다. Member의 외래 키는 변경됐지만, TeamA의 members 필드엔 아직 Member 객체가 남아있는 상황이다. 따라서 아래와 같이 수정해줘야 한다.
public void setTeam(Team team) {
if(this.team != null) {
this.team.getMembers().remove(this);
}
this.team = team;
team.getMembers().add(this);
}
위처럼 양방향 연관관계를 사용하려면 로직이 견고해야 한다. 다르게 말하면 귀찮아진다.
양방향 연관관계를 사용했을 때 좋은 점은 편하게 상대 객체를 조회할 수 있는 객체 그래프 탐색 기능뿐이다.
때문에 웬만하면 단방향 연관관계로 구현하는 것이 좋을 것 같다.
마무리
이번 기록을 작성하면서 직접 참조와 간접 참조에 대한 명확한 장단점 비교가 안 된다는 것을 알았다.
위처럼 연관관계 매핑이 복잡하고, 실수가 나오기 쉽기 때문에 최근 간접 참조로 엔티티를 설계해 사용했다.
간접참조를 사용하면서 느꼈던 점들을 복기해서 직접 참조와 간접 참조의 명확한 장단점 비교를 기록해 봐야겠다.
참고
'Backend > JPA' 카테고리의 다른 글
[JPA] 영속성 전이와 고아 객체(Cascade, Orphan) (0) | 2024.05.17 |
---|---|
[JPA] 프록시와 연관관계 관리(즉시로딩, 지연로딩) (0) | 2024.05.15 |
[JPA] 다대다 연관관계 매핑 (0) | 2024.05.10 |
[JPA] 영속성 관리(영속성 컨텍스트, 엔티티 매니저) (0) | 2024.05.04 |
[JPA] 왜 JPA를 사용해야 할까? (0) | 2024.04.19 |