객체의 참조와 테이블의 외래 키를 매핑하는 것이 이 장의 목표
회원과 팀이 있다.
회원은 하나의 팀에만 소속될 수 있다.
회원과 팀은 다대일 관계이다
Member.team
필드로 팀 객체와 연관관계를 맺고, 팀 객체는 회원 객체를 가지지 않는 단방향 연관관계이다Team team = member.getTeam();
// Member member = team.getMember(); ????
TEAM_ID
라는 외래키를 가지고 서로 조인을 할 수 있다.# 회원과 팀을 조인하는 SQL
SELECT *
FROM MEMBER M
JOIN TEAM T ON M.TEAM_ID = T.TEAM_ID
# 팀과 회원을 조인하는 SQL
SELECT *
FROM TEAM T
JOIN MEMBER M ON T.TEAM_ID = M.TEAM_ID
@Getter
@Setter
public class Member {
private String id;
private String username;
private Team team; // 팀의 참조를 보관
}
@Getter
@Setter
public class Team {
private String id;
private String name;
}
다음과 같은 순수한 java 코드에서, 회원 1 member1
과 회원 2member2
를 team
에 소속시키려면 member1.setTeam(team)
, member2.setTeam(team)
과 같이 소속시킬수 있다(회원 : 팀 = N : 1). 그리고 Team findTeam = member1.getTeam()
과 같은 코드로 속한 팀을 조회 할 수 있다.
객체는 참조를 사용해서 연관관계를 탐색할 수 있는데, 이것을 객체 그래프 탐색이라고 한다.
CREATE TABLE MEMBER (
MEMBER_ID VARCHAR(255) PRIMARY KEY,
TEAM_ID VARCHAR(255),
USERNAME VARCHAR(255)
)
CREATE TABLE TEAM (
TEAM_ID VARCHAR(255) PRIMARY KEY,
NAME VARCHAR(255),
)
# 외래키 지정
ALTER TABLE MEMBER ADD CONSTRAINT FK_MEMBER_TEAM
FOREIGN KEY (TEAM_ID)
REFERENCES TEAM
다음과 같이 회원 테이블과 팀 테이블을 설정해주게 되면, 외래키를 사용해서 연관관계를 탐색할 수 있게 되는데, 이를 조인이라고 한다.
JPA를 사용해서 테이블에 맞게 객체를 매핑하였다.
@Getter
@Setter
@Entity
public class Member {
@Id @Column(name = "MEMBER_ID")
private String id;
private String username;
// 연관관계 매핑
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team; // 팀의 참조를 보관
}
@Getter
@Setter
@Entity
public class Team {
@Id @Column(name = "TEAM_ID")
private String id;
private String name;
}
@ManyToOne
@JoinColumn(name = "TEAM_ID")
@JoinColumn
은 외래 키를 매핑할 때 사용한다. 회원과 팀 테이블은 TEAM_ID
외래키로 연관관계를 맺으므로 이 값을 지정하면 된다. 이 어노테이션은 생략이 가능하다.@JoinColumn
은 외래 키를 매핑할 때 사용한다.
속성 | 기능 | 기본값 |
---|---|---|
name | 매핑할 외래 키 이름 | 필드명 + _ + 참조하는 테이블의 기본 키 컬럼명 |
referencedColumnName | 외래 키가 참조하는 대상 테이블의 컬럼명 | 참조하는 테이블의 기본키 컬럼명 |
foreignKey(DDL) | 외래 키 제약조건을 직접 지정할 수 있다. 테이블 생성시에만 사용 | |
unique nullable insertalbe updatable columnDefinition table | @Column의 속성과 같다 |
@ManyToOne
은 다대일 관계에서 사용한다.
속성 | 기능 | 기본값 |
---|---|---|
optional | false로 설정하면 연관된 엔티티가 항상 있어야 한다 | true |
fetch | 글로벌 페치 전략을 설정한다 | @ManyToOne = FetchType.EAGER @OneToMany = FetchType.LAZY |
cascade | 영속성 전이 기능을 사용한다. | |
targetEntity | 연관된 엔티티의 타입 정보를 설정한다. 이 기능은 거의 사용하지 않는다. 컬렉션을 사용해도 제네릭으로 타입 정보를 알 수 있다. |
연관관계를 등록, 수정, 삭제 조회하는 예제들을 알아본다.
public void testSave() {
// 팀 1 저장
Team team1 = new Team("team1", "팀1");
em.persist(team1);
// 회원 1 저장
Member member1 = new Member("member1", "회원1");
member1.setTeam(team1); // 연관관계 설정(회원 -> 팀 참조)
em.persist(member1); // 저장
// 회원 2 저장
Member member2 = new Member("member2", "회원2");
member2.setTeam(team1); // 연관관계 설정(회원 -> 팀 참조)
em.persist(member2); // 저장
}
회원 엔티티는 팀 엔티티를 참조하고 저장하였다. JPA는 참조한 팀의 식별자를 외래 키로 사용해서 적절한 등록 쿼리를 생성하고, 실행할 것이다.
- 만약
team1
이 영속되지 않았다면?
org.hibernate.TransientPropertyValueException
Thrown when a property cannot be persisted because it is an association with a transient unsaved entity instance. : flush되기 전에, 해당 객체와 연관된 모든 객체들이 영속 상태여야 한다.
엔티티를 조회하는 방법
1. 객체 그래프 탐색
2. 객체지향 쿼리(JPQL) 사용
member.getTeam()
을 사용해서 member
와 연관된 team
엔티티를 조회할 수 있다.
Member member = em.find(Member.class, "member1");
Team team = member.getTeam();
이처럼 객체를 통해 연관된 엔티티를 조회하는 것을 객체 그래프 탐색이라 한다.
String jpql = "select m from Member m join m.team where t.name=:teamName";
List<Member> resultList = em.createQuery(jpql, Member.class)
.setParameter("teamName", "팀 1")
.getResultList();
from Member m join m.team t
회원이 팀과 관계를 가지고 있는 필드 m.team
을 통해 Member와 Team을 조인
where t.name=:teamName
t.name
을 검색조건으로 사용해서 팀1에 속한 회원만 검색
:teamName
.setParameter()
를 통해 파라미터를 바인딩 받는 문법.
Team team2 = new Team("team2", "팀 2");
em.persist(team2);
// 회원 1에 새로운 팀 2 설정
Member member = em.find(Member.class, "member1");
member.setTeam(team2);
수정은 기존과 같이 엔티티의 값(여기서는 참조)만 변경해두면 트랜잭션을 커밋할 떄 플러시가 일어나면서 변경 감지 기능이 작동하고, 자동으로 데이터베이스에 반영한다.
Member member1 = em.find(Member.class, "member1");
member1.setTeam(null); // 연관관계 제거
연관관계를 null
로 지정하면, 다음과 같은 SQL이 실행된다.
UPDATE MEMBER
SET
TEAM_ID=null, ...
WHERE
ID='member1'
member1.setTeam(null);
member2.setTeam(null);
em.remove(team);
연관된 엔티티 삭제시 기존에 있던 연관관계를 먼저 제거하고 삭제해야 한다. 그렇지 않으면 외래키 제약조건으로 인해, 데이터베이스에서 오류가 발생하게 된다.
예시)
Caused by: org.h2.jdbc.JdbcSQLIntegrityConstraintViolationException: Referential integrity constraint violation: "FKDB7RXUSRC56FJB93XAKTGR5H0: PUBLIC.TESTMEMBER FOREIGN KEY(TEAM_ID) REFERENCES PUBLIC.TEAM(TEAM_ID) ('team1')"; SQL statement:
데이터베이스 테이블은 외래 키 하나로 양방향으로 조회할 수 있다. 따라서, 엔티티에서도 팀에서 회원으로 접근할 수 있도록 연관관계를 설정해 주어야 한다(1:N). 따라서, Team.members
를 List
로 추가하였다.
@Getter
@Setter
@Entity
public class Member {
@Id @Column(name = "MEMBER_ID")
private String id;
private String username;
// 연관관계 매핑
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team; // 팀의 참조를 보관
}
@Getter
@Setter
@Entity
public class Team {
@Id @Column(name = "TEAM_ID")
private String id;
private String name;
//추가
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<Member>();
}
팀과 회원은 일대다 관계이고, 따라서 팀 엔티티에 List<Member>
를 추가하였다. 이후에 일대다 관계를 매핑하기 위해 @OneToMany
매핑정볼르 사용하였다. mappedBy
속성은 양방향 매핑일 때 사용하는데, 반대쪽 매핑의 필드 이름값을 사용하면 된다(반대쪽 매핑 : Member.team
).
Team team = em.find(Team.class, "team1");
List<Member> members = team.getMembers(); // 팀 -> 회원
// 객체 그래프 탐색
테이블에서의 연관관계는 다음과 같다.
회원 <-> 팀 (양방향)
하지만, 객체의 연관관계에는 양방향 연관관계라는 것이 없고, 서로 다른 단방향 연관관계 2개를 양방향인 것처럼 보이게 한다.
회원 -> 팀 (단방향)
팀 -> 회원 (단방향)
여기서 보이는 차이점은, 테이블은 외래 키 하나로 두 테이블의 연관관계를 관리한다. 하지만, 엔티티를 양방향으로 설정 시, 객체의 참조는 둘인데 외래키는 하나인 상황이 발생하게 된다. 이 때, 두 객체 연관관계 중 하나를 정해서 테이블의 외래키를 관리해야 하는데, 이것을 연관관계의 주인이라고 한다.
물론 단방향으로 매핑시에는 참조가 하나이므로, 해당 참조로 외래키를 관리하면 된다.
연관관계의 주인만이 데이터베이스 연관관계와 매핑되고 외래 키를 관리(등록, 수정, 삭제) 할 수 있다. 반면에 주인이 아닌 쪽은, 읽기만 할 수 있다.
mappedBy
속성을 사용하지 않는다.mappedBy
속성을 사용해서 속성의 값으로 연관관계의 주인을 지정해야 한다.연관관계의 주인을 정한다는 것은 사실 외래 키 관리자를 선택하는 것으로, Member.team
을 주인으로 선택하면 자기 테이블에 있는 외래키를 관리하면 되지만, Team.members
를 주인으로 설정하게 되면 물리적으로 전혀 다른 테이블의 외래 키를 관리해야 한다.
연관관계의 주인은 테이블에 외래 키가 있는 곳으로 정해야 한다.
여기서는 회원 테이블이 외래 키를 가지고 있으므로 Member.team
이 주인이 된다. 주인이 아닌 Team.members
에는 mappedBy="team"
속성을 사용해 주인이 아님을 설정하면 된다.
class Team {
@OneToMany(mappedBy="team") // mappedBy 속성의 값은 연관관계의 주인인 Member.team
private List<Member> members = new ArrayList<Member>();
}
다대일, 일대다 관계에서는 항상 다 쪽이 외래 키를 가진다. 따라서
@ManyToOne
은 항상 연관관계의 주인이 되므로,@ManyToOne
에는mappedBy
속성이 없다.
public void testSave() {
// 팀 1 저장
Team team1 = new Team("team1", "팀1");
em.persist(team1);
// 회원 1 저장
Member member1 = new Member("member1", "회원1");
member1.setTeam(team1); // 연관관계 설정(회원 -> 팀 참조)
em.persist(member1); // 저장
// 회원 2 저장
Member member2 = new Member("member2", "회원2");
member2.setTeam(team1); // 연관관계 설정(회원 -> 팀 참조)
em.persist(member2); // 저장
}
여기까지는 단방향 연관관계의 저장 방법과 동일하다. team1.getMembers().add(member1);
과 같은 코드가 있어야 할 것 같지만, Team.members
는 연관관계의 주인이 아니르모 외래키에 영향을 주지 않는다. 따라서, 데이터베이스에 저장할 때도 무시되게 된다.
Member member1 = new Member("member1", "회원1");
em.persist(member1);
Member member2 = new Member("member2", "회원2");
em.persist(member2);
Team team1 = new Team("team1", "팀1");
team1.getMembers().add(member1);
team1.getMembers().add(member2);
em.persist(team1);
주인이 아닌 곳에 값을 설정하게 되면, 데이터베이스에는 다음과 같이 저장된다.
MEMBER_ID | USERNAME | TEAM_ID |
---|---|---|
member1 | 회원1 | null |
member2 | 회원2 | null |
연관관계의 주인이 아닌 Team.members
에만 값을 저장했기 때문에 TEAM_ID
에 null
이 저장되어 있다.
객체 관점에서 바라보면, 양쪽 방향에 모두 값을 입력해주는 것이 가장 안전하다.
// 순수객체
Team team1 = new Team("team1", "팀1");
Member member1 = new Member("member1", "회원1");
Member member2 = new Member("member2", "회원2");
member1.setTeam(team1);
member2.setTeam(team2);
List<Member> members = team1.getMembers();
// members.size() == 0
다음 코드처럼, ORM에서는 관계형 데이터베이스 뿐만 아니라 객체도 함께 고려해주어야 한다. 그렇다면, 회원 -> 팀 관계와 마찬가지로 팀 -> 회원도 설정을 해주어야 한다.
// 순수객체_양방향
Team team1 = new Team("team1", "팀1");
Member member1 = new Member("member1", "회원1");
Member member2 = new Member("member2", "회원2");
member1.setTeam(team1);
team1.getMembers().add(member1);
member2.setTeam(team1);
team1.getMembers().add(member2);
List<Member> members = team1.getMembers();
// members.size() == 2
양쪽 모두 관계를 설정하면, members.size()
의 값도 예상했던 값인 2가 나오는것을 볼 수 있다.
// JPA_양방향
Team team1 = new Team("team1", "팀1");
em.persist(team1);
Member member1 = new Member("member1", "회원1");
// 양방향 연관관계 설정
member1.setTeam(team1);
team1.getMembers().add(member1);
em.persist(member1);
Member member2 = new Member("member2", "회원2");
// 양방향 연관관계 설정
member2.setTeam(team1);
team1.getMembers().add(member2);
em.persist(member2);
Member.team
: 연관관계의 주인. 이 값으로 외래키를 관리한다Team.members
: 연관관계의 주인이 아니므로, 저장시에 사용되지 않는다.객체의 양방향 연관관계는 양쪽 모두 관계를 맺어주어야 한다.
양방향 연관관계는 양쪽 다 신경을 써야 한다. 하지만, 실수로 member2.setTeam(team)
과 team1.getMembers().add(member)
둘 중 하나만 호출하는 경우가 생길 수도 있다. 따라서, 두 코드를 하나인 것처럼 사용하는 것이 편리하다.
// setter를 사용하여 한번에 양방향 관계를 설정하도록 변경
public void setTeam(Team team) {
this.team = team;
team.getMembers().add(this);
}
다음과 같이 한번에 양방향 관계를 설정하는 메소드를 연관관계 편의 메소드라고 한다.
member1.setTeam(teamA);
member1.setTeam(teamB);
Member findMember = teamA.getMember(); // member1이 여전히 조회된다.
teamB
로 변경할 때, teamA
-> member1
의 연관관계를 제거하지 않았기 때문에 teamA
에서 여전히 member1
이 조회가 되게 된다. 따라서, 연관관계 변경 시에는 기존 팀이 있으면, 기존 팀과 회원의 연관관계를 삭제하는 코드가 필요하다.
public void setTeam(Team team) {
// 기존 관계 제거
if (this.team != null) {
this.team.getMembers().remove(this);
}
this.team = team;
team.getMembers().add(this);
}
물론, 데이터베이스에서는
teamA
->member1
의 관계가 제거되지 않아도 외래키를 변경하는데는 문제가 없다.member1
->teamB
로 변경했으므로 데이터베이스에서 외래키는teamB
를 참조하도록 정상반영되기 때문이다.
하지만, 영속성 컨텍스트가 살아있는 상태에서teamA
의getMembers()
를 호출하면member1
이 반환되는 것이 문제이다. 따라서 위에서 말한것처럼 관계를 제거하는 것이 안전하다.
mappedBy
로 주인을 지정해야 하고, 주인의 반대편은 단순히 보여주는 일(객체 그래프 탐색)만 할 수 있다.
- 연관관계 주인을 정하는 기준
외래키의 위치와 관련해서 정해야지 비즈니스 중요도로 접근하면 안된다.
- 무한루프에 빠지지 않게 주의해라.
Member.toString()
에서getTeam()
을 호출하고,Team.toString()
에서getMember()
를 호출하게 되면 무한루프에 빠질 수 있다. JSON으로 변환 할 때 자주 발생하는데, JSON라이브러리들은 보통 무한루프에 빠지지 않도록 하는 어노테이션이나 기능을 제공한다. Lombok 라이브러리 역시 주의해야 한다.