이번 글의 목표는
객체의 참조와 테이블의 외래키를 매핑하는 것이다.
다음은 연관관계 매핑을 알기 위한 핵심 키워드이다.
방향(Direction)
단방향, 양방향이 있다.
예를 들어 회원과 팀이 연관관계일 때 [회원 -> 팀] 또는 [팀 -> 회원]처럼 한쪽만 참조하는 것을 단방향 관계라고 하고,
[회원 -> 팀], [팀 -> 회원] 처럼 양쪽이 같이 참조하는 것을 양방향 관계라고 한다.
방향은 객체관계에서만 존재하고, 테이블 관계는 항상 양방향이다.
다중성(Multiplicity)
[다대일(N:1) / 일대다(1:N) / 일대일(1:1) / 다대다(N:M)] 다중성이 있다.
예를들어 회원과 팀이 연관관계일 때 여러 회원은 한 팀에 속하므로 다대일(N:1)관계이다.
반대로, 팀에는 여러 회원이 속하므로 일대다(1:N)관계이다.
연관관계의 주인(Owner)
객체를 양방향 관계로 만들면 연관관계의 주인을 정해야 한다.
1. 단방향 연관 관계
- 회원과 팀이 있다
- 회원은 하나의 팀에만 소속될 수 있다
- 회원과 팀은 다대일 관계다
객체 연관 관계
- 회원 객체는 Member.Team의 필드로 팀 객체와 연관관계를 맺고있다.
- 회원 객체와 팀 객체는 다대일(N:1)관계이다.
- 회원은 Member.Team필드를 통해서 속해있는 팀을 알 수 있지만, 팀은 속한 회원을 알 수 없다.
테이블 연관관계
- 회원 테이블은 TeamId 외래 키로 팀 테이블과 연관관계를 맺는다.
- 회원 테이블과 팀 테이블은 양방향 관계다.
- 회원 테이블의 TeamId 외래 키를 통해서 회원과 팀을 조인할 수 있고, 반대로 팀과 회원도 조인할 수 있다.
- 예를 틀어 회원 테이블의 TeamId 외래 키 하나로 [회원 JOIN 팀] 과 [팀 JOIN 회원]둘 다 가능하다.
다음은 회원과 팀을 조인하는 SQL이다.
SELECT *
FROM MEMBER M
JOIN TEAM T ON M.TeamId = T.TeamId;
다음은 반대인 팀과 회원을 조인하는 SQL이다.
SELECT *
FROM TEAM T
JOIN MEMBER M ON T.TeamId = M.TeamId;
객체 연관관계와 테이블 연관관계의 가장 큰 차이
- 참조를 통한 연관관계는 항상 단방향이다.
- 객체간의 연관관계를 양방향으로 만들고 싶으면 반대쪽에도 필드를 추가해서 참조를 보관해야 한다. 결국 연관관계를 하나 더 만들어야 한다.
- 이렇게 양쪽에서 서로 참조하는 것을 양방향 연관관계라고 한다.
- 하지만 정확히 이야기 하면 양방향 관계가 아닌 서로 다른 관계 2개다.
- 반면에 테이블은 외래키 하나로 양방향 조인할 수 있다.
01. 순수한 객체 연관관계
다음은 JPA를 사용하지 않은 순수한 회원과 팀 클래스의 코드다.
public class Member
{
private String id;
private String name;
private Team team;
public void setTeam(Team team)
{
this.team = team;
}
//Getter, Setter...
}
class Team
{
private String id;
private String name;
//Getter, Setter...
}
다음은 코드 실행부다.
public static void main(String[] args) {
Member member1 = new Member("member1", "회원 1");
Member member2 = new Member("member2", "회원 2");
Team team1 = new Team("team1","팀 1");
member1.setTeam(team1);
member2.setTeam(team1);
Team findTeam = member1.getTeam();
}
클래스 관계
인스턴스 관계
위 그림을 보면 회원1과 회원2는 팀1에 소속했다.
그리고 다음 코드로 회원1이 속한 팀1을 조회할 수 있다.
Team findTeam = member1.getTeam();
이처럼 객체는 참조를 사용해서 연관관계를 탐색할 수 있는데 이것을 객체 그래프 탐색이라 한다.
02. 테이블 연관관계
아래 코드는 회원 테이블과 팀 테이블의 DDL이다.
추가로 회원 테이블의 TeamId에 외래 키 제약 조건을 설정했다.
CREATE TABLE MEMBER (
MemberId VARCHAR(255) NOT NULL,
TeamId VARCHAR(255),
NAME VARCHAR(255),
PRIMARY KEY (MemberId)
)
CREATE TABLE TEAM (
TeamId VARCHAR(255) NOT NULL,
NAME VARCHAR(255),
PRIMARY KEY (TeamId)
)
ALTER TABLE MEMBER ADD CONSTRAINT FK_MEMBER_TEAM
FOREIGN KEY (TeamId)
REFERENCES TEAM;
다음 SQL을 실행해서 회원1과 회원2를 팀1에 소속시켰다.
INSERT INTO TEAM(TeamId, NAME) VALUES('team1', '팀1');
INSERT INTO MEMBER(MemberId, TeamId, NAME)
VALUES('member1', 'team1', '회원1');
INSERT INTO MEMBER(MemberId, TeamId, NAME)
VALUES('member2', 'team2', '회원2');
다음 SQL을 실행해서 회원1이 소속된 팀을 조회한다.
SELECT T.*
FROM MEMBER M
JOIN TEAM T ON M.TeamId = T.TeamId
WHERE M.MemberId = 'member1';
이처럼 DB는 외래 키를 사용해서 연관관계를 탐색할 수 있는데 이것을 조인이라 한다.
03. 객체 관계 매핑
지금까지 객체만 사용한 연관관계와 테이블만 사용한 연관관계를 각각 알아보았다.
이제 JPA를 사용해서 둘을 매핑할 것이다.
- 객체 연관관계 : 회원 객체의 Member.team필드 사용
- 테이블 연관관계 : 회원 테이블의 Member.TeamId 외래키 컬럼 사용
@Entity // 엔티티 선언
@Table(name = "MEMBER")
public class Member {
@Id
@Column(name="ID")
private String id;
@Column(name="NAME", nullable = false, length = 10)
private String username;
@ManyToOne
@JoinColumn(name = "TeamId")
private Team team;
// Getter, Setter...
위 코드에서 회원 엔티티를 매핑했다.
Member.team과 MEMBER.TeamId를 매핑하는 것이 연관관계 매핑이다.
@ManyToOne
@JoinColumn(name = "TeamId")
private Team team;
회원 엔티티에 있는 연관관계 매핑 부분에 새로운 어노테이션들이 있다.
- @ManyToOne : 이름 그대로 다대일(N:1) 관계라는 매핑 정보다. 회원과 팀은 다대일 관계다. 연관관계를 매핑할 때 이렇게 다중성을 나타내는 어노테이션을 필수로 사용해야 한다.
- @JoinColumn(name="TeamId") : 조인 컬럼은 외래 키를 매핑할 때 사용한다. name속성에는 매핑할 외래 키 이름을 지정한다. 회원과 팀 테이블은 "TeamId"외래 키로 연관관계를 맺으므로 이 값을 지정하면 된다. 이 컬럼은 생략할 수 있다.
2. 연관관계 사용
연관관계를 등록, 수정, 삭제, 조회하는 예제를 통해 연관관계를 어떻게 사용하는지 알아보자.
01. 저장
public void testSave()
{
// 팀1 저장
Team team1 = new Team("team1", "팀1");
em.persist(team1);
// 회원 1,2 저장
Member member1 = new Member("member1", "회원1");
member1.setTeam(team1); // 연관관계 설정 : member1 -> team1
em.persist(member1);
Member member1 = new Member("member1", "회원1");
member1.setTeam(team1); // 연관관계 설정 : member2 -> team1
em.persist(member1);
}
위 코드는 회원 1,2와 팀을 저장하는 코드이다.
member1.setTeam(team1); // 연관관계 설정 : member1 -> team1
em.persist(member1);
회원 엔티티는 팀 엔티티를 참조하고 저장했다.
JPA는 참조한 팀의 식별자(Team.TeamId)를 외래 키로 사용해서 적절한 등록 쿼리를 생성한다.
02. 조회
연관관계가 있는 엔티티를 조회하는 방법은 크게 2가지다.
- 객체 그래프 탐색(객체 연관관계를 사용한 조회)
- 객체지향 쿼리 사용(JPQL)
객체 그래프 탐색
member.getTeam()을 사용해서 member와 연관된 team엔티티를 조회할 수 있다.
Member member = em.find(Member.class, "member1");
Team team = member.getTeam(); // 객체 그래프 탐색
System.out.println("팀 이름 = " + team.getName();
// 출력 결과 : 팀 이름 = 팀1
이처럼 객체를 통해 연관된 엔티티를 조회하는 것을 객체 그래프 탐색이라 한다.
객체지향 쿼리 사용
만약 회원을 대상으로 조회하는데 팀1에 소속된 회원만 조회하려면 회원과 연관된 팀 엔티티를 검색 조건으로 사용해야 한다.
SQL은 연관된 테이블을 조인해서 검색조건을 사용하면 된다.
JPQL도 조인을 지원한다.
private static void queryLogicJoin(EntityManager em)
{
String jpql = "select m from Member m join m.team t where "
+ "t.name=:teamName";
List<Member> resultList = em.createQuery(jpql, Member.class)
.setParameter("teamName", "팀1");
.getResultList();
for (Member member : resultList)
{
System.out.println("[query] member.username="
+ member.getUsername());
}
}
// 결과 : [query] member.username=회원1
// 결과 : [query] member.username=회원2
JPQL의 from Member m join m.team t 부분을 보면 회원이 팀과 관계를 가지고 있는 필드(m.team)를 통해서 Member와 Team을 조인했다.
그리고 where 절을 보면 조인한 t.name을 검색조건으로 사용해서 팀1에 속한 회원만 검색했다.
select m from Member m join m.team t
where t.name=:teamName
위 코드에서 :teamName과 같이 :로 시작하는 것은 파라미터를 바인딩 받는 문법이다.
위 코드를 실행하면 생성되는 SQL은 다음과 같다.
SELECT m.* FROM MEMBER MEMBER
INNER JOIN
TEAM TEAM ON MEMBER.TeamId = TEAM1.TeamId
WHERE
TEAM1_.NAME="팀1";
실행된 SQL과 JPQL을 비교하면 JPQL은 객체(엔티티)를 대상으로 하고 SQL보다 간결하다.
03. 수정
private static void updateTeam(EntityManager em)
{
// 새로운 팀
Team team2 = new Team("team1", "팀1");
em.persist(team2);
// 회원1에 새로운 팀2 설정
Member member = em.find(Member.class,"member1");
member.setTeam(team2);
}
위 코드는 팀 소속을 바꾸는 메서드이다.
해당 코드가 실행되면 다음과 같은 수정 SQL문이 실행된다.
UPDATE MEMBER
SET
TeamId = 'team2', ...
WHERE
MemberId='member1';
앞서 3일차에 이야기했듯이 수정은 em.update()같은 메서드가 아니라 불러온 엔티티의 값만 변경해두면 자동으로 변경을 감지해서 변경사항을 DB에 자동으로 반영한다.
04. 연관관계 제거
private static void deleteRelation(EntityManager em)
{
Member member = em.find(Member.class,"member1");
member.setTeam(null); // 연관관계 제거
}
위의 코드는 연관관계를 제거하는 메서드이다.
해당 코드가 실행되면 다음과 같은 수정 SQL문이 실행된다.
UPDATE MEMBER
SET
TeamId = null, ...
WHERE
MemberId='member1';
04. 연관된 엔티티 삭제
연관된 엔티티를 삭제하려면 기존에 있던 연관관계를 먼저 제거하고 삭제해야한다.
그렇지 않으면 외래 키 제약조건으로 인해, DB에서 오류가 발생한다.
member1.setTeam(null); // 회원1 연관관계 제거
member2.setTeam(null); // 회원2 연관관계 제거
em.remove(team); // 팀 삭제
팀1에는 회원1과 회원2가 소속되어있다.
이때 팀1을 삭제하려면 연관관계를 먼저 끊어야 한다.
3. 양방향 연관관계
지금까지 회원에서 팀으로만 접근하는 다대일 단방향 매핑을 알아보았다.
이번에는 반대방향인 팀에서 회원으로 접근하는 관계를 추가할 것이다.
위의 그림과 같이 회원과 팀은 다대일 관계다.
반대로 팀에서 회원은 일대다 관계다.
일대다 관계는 여러 건과 연관관계를 맺을 수 있으므로 컬렉션을 사용해야 한다.
때문에 Team.members를 List 컬렉션으로 추가했다.
객체 연관관계를 정리하면 다음과 같다.
- 회원 -> 팀 (Member.Team)
- 팀 -> 회원 (Team.members)
DB테이블은 외래 키 하나로 양방향으로 조회할 수 있다.
두 테이블의 연관관계는 외래 키 하나만으로 양방향 조회가 가능하므로 처음부터 양방향 관계이다.
따라서 DB에 추가할 내용은 전혀 없다.
앞서 말했듯 TeamId 외래키를 사용해서 MEMBER JOIN TEAM이 가능하고 반대로 TEAM JOIN MEMBER도 가능하다.
01. 양방향 연관관계 매핑
@Entity // 엔티티 선언
@Table(name = "MEMBER")
public class Member {
@Id
@Column(name="ID")
private String id;
@Column(name="NAME", nullable = false, length = 10)
private String username;
@ManyToOne
@JoinColumn(name = "TeamId")
private Team team;
// Getter, Setter...
@Entity // 엔티티 선언
@Table(name = "MEMBER")
public class Team {
@Id
@Column(name="TeamId")
private String id;
private String name;
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<Member>();
// Getter, Setter...
회원 엔티티는 변경사항이 없고, 팀 엔티티에는 변경사항이 있다.
팀과 회원은 일대다 관계다.
따라서 엔티티에 컬렉션인 List<Member> members를 추가했다.
그리고 일대다 관계를 매핑하기 위해 @OneToMany 매핑 정보를 사용했다.
mappedBy 속성은 양방향 매핑일 때 사용하는데 반대쪽 매핑의 필드 이름을 값으로 주면 된다.
반대쪽 매핑이 Member.team이므로 team을 값으로 주었다.
02. 일대다 컬렉션 조회
public void biDirection()
{
Team team = em.find(Team.class, "team1");
List<Member> members = team.getMembers(); // 팀 -> 회원
for (Member member : members)
{
System.out.println("member.name = " +
member.getName());
}
}
// 결과
// member.name = 회원1
// member.name = 회원2
4. 연관관계의 주인
@OneToMany는 직관적으로 이해가 된다.
하지만 mappedBy는 왜 필요할까?
엄밀히 말하자면 객체에는 양방향 연관관계라는 개념이 없다.
서로 다른 단방향 연관관계 2개를 애플리케이션 로직으로 잘 묶는 것이다.
반면에 DB 테이블은 앞서 말했듯 외래 키 하나만으로 양쪽이 서로 조인할 수 있다.
따라서 테이블은 외래 키 하나만으로 양방향 연관관계를 맺는다.
객체 연관관계는 다음과 같다.
- 회원 -> 팀 연관관계 1개(단방향)
- 팀 -> 회원 연관관계 1개(단방향)
테이블 연관관계는 다음과 같다.
- 회원 <-> 팀의 연관관계 1개(양방향)
테이블은 외래 키 하나로 두 테이블의 연관관계를 관리한다.
엔티티를 단방향으로 매핑하면 참조를 하나만 사용하므로 이 참조로 외래 키를 관리하면 된다.
그런데 양방향으로 매핑하면 회원 -> 팀, 팀 -> 회원 두곳에서 서로를 참조한다.
따라서 객체의 연관관계를 관리하는 포인트가 2곳이 된다.
엔티티를 양방향 연관관계로 설정하면 객체의 참조는 둘인데 외래 키는 하나다.
따라서 둘 사이에 차이가 발생한다.
이런 차이로 인해 JPA에서는 두 객체 연관관계 중 하나를 정해서 테이블의 외래 키를 관리해야 하는데 이것을 연관관계의 주인(Owner)라고 한다.
01. 양방향 매핑의 규칙 : 연관관계의 주인
양방향 연관관계 매핑 시 지켜야 할 규칙이 있는데 두 연관관계 중 하나를 연관관계의 주인으로 정해야 한다.
연관관계의 주인만이 DB 연관관계와 매핑되고, 외래 키를 관리(등록, 수정, 삭제)할 수 있다.
반면에 아닌 쪽은 읽기만 가능하다.
어떤 연관관계를 주인으로 정할지는 mappedBy속성을 사용하면 된다.
- 주인은 mappedBy속성을 사용하지 않는다.
- 주인이 아니면 mappedBy속성을 사용해서 속성의 값으로 연관관계의 주인을 지정해야 한다.
그렇다면 Member.team, Team.members 둘 중 어느 것을 연관관계의 주인으로 정해야할까?
- 회원 -> 팀(Member.team)방향
class Member
{
@ManyToOne
@JoinColumn(name="TeamId")
private Team team;
...
}
- 팀 -> 회원(Team.members) 방향
class Team
{
@OneToMany
private List<Member> members = new ArrayList<Member>();
...
}
연관관계의 주인을 정한다는 것인 사실 외래 키 관리자를 선택하는 것이다.
여기서는 회원 테이블에 있는 TeamId 왜래 키를 관리할 관리자를 선택해야 한다.
만약 회원 엔티티에 있는 Member.team을 주인으로 선택하면 자기 테이블에 있는 외래 키를 관리하면 된다.
하지만 팀 엔티티에 있는 Team.members를 주인으로 선택하면 물리적으로 전혀 다른 테이블의 외래키를 관리해야 한다.
왜냐하면 이 경우 Team.members가 있는 Team 엔티티는 TEAM 테이블에 매핑되어 있는데 관리해야 할 외래 키는 MEMBER테이블에 있기 때문이다.
02. 연관관계의 주인은 외래 키가 있는 곳
연관관계의 주인은 테이블에 외래 키가 있는 곳으로 정해야 한다.
여기서는 회원 테이블이 외래 키를 가지고 있으므로 Member.team이 주인이 된다.
주인이 아닌 Team.members에는 mappedBy="team"속성을 사용해서 주인이 아님을 설정한다.
"team"은 연관관계의 주인인 Member엔티티의 team필드를 말한다.
정리하면 연관관계의 주인만 DB 연관관계와 매핑되고 외래 키를 관리할 수 있다.
5. 양방향 연관관계 저장
public void testSave()
{
// 팀1 저장
Team team1 = new Team("team1", "팀1");
em.persist(team1);
// 회원1 저장
Member member1 = new Member("member1", "회원1");
member1.setTeam(team1); // 연관관계 설정 member1 -> team1
em.persist(member1);
// 회원2 저장
Member member2 = new Member("member2", "회원2");
member1.setTeam(team1); // 연관관계 설정 member2 -> team1
em.persist(member1);
}
위의 코드를 보면
팀 1을 저장하고
회원 1, 회원2에 연관관계의 주인인 Member.team 필드를 통해서 회원과 팀의 연관관계를 설정하고 저장했다.
참고로 이 코드는 당방향 연관관계의 회원과 팀을 저장하는 코드와 완전히 같다.
양방향 연관관계에서는 주인만 작업을 할 수 있다고 했다.
때문에
team1.getMembers().add(member1); // 무시
team1.getMembers().add(member2); // 무시
위 코드는 Team.members는 주인이 아니기 때문에 코드가 무시되고,
member1.setTeam(team1); // 연관관계 설정
member2.setTeam(team1); // 연관관계 설정
위 코드는 Member.team은 주인이기 때문에 코드가 반영된다.
6. 양방향 연관관계의 주의점
위에서도 말했듯 주인만 연관관계를 설정할 수 있다고 했다.
01. 순수한 객체까지 고려한 양방향 연관관계
사실은 객체 관점에서 양쪽 방향에 모두 값을 입력해주는 것이 가장 안전하다.
양쪽 방향 모두 값을 입력하지 않으면 JPA를 사용하지 않는 순수한 객체 생태에서 심각한 문제가 발생할 수 있다.
만약 JPA를 사용하지 않고 엔티티에 대한 테스트 코드를 작성한다고 가정하자.
ORM은 객체와 관계형 DB 둘다 중요하다.
DB뿐만 아니라 객체도 함께 고려해야 한다.
public void test()
{
// 팀1
Team team1 = new Team("team1", "팀1");
Member member1 = new Member("member1", "회원1");
Member member2 = new Member("member2", "회원1");
member1.setTeam(team1); // 연관관계 설정 member1 -> team1
member2.setTeam(team1); // 연관관계 설정 member2 -> team1
List<Member> members = team1.getMembers();
System.out.println("members.size = " + members.size());
}
// 결과 : members.size = 0
위 코드는 JPA를 사용하지 않는 순수한 객체다.
코드를 보면 Member.team에만 연관관계를 설정했고, 반대 방향은 연관관계를 설정하지 않았다.
당연하게도 members 리스트의 크기는 변하지 않았을 것이다.
public void test()
{
// 팀1
Team team1 = new Team("team1", "팀1");
Member member1 = new Member("member1", "회원1");
Member member2 = new Member("member2", "회원1");
member1.setTeam(team1); // 연관관계 설정 member1 -> team1
team1.getMembers().add(member1) // 연관관계 설정 team1 -> member1
member2.setTeam(team1); // 연관관계 설정 member2 -> team1
team1.getMembers().add(member2) // 연관관계 설정 team1 -> member2
List<Member> members = team1.getMembers();
System.out.println("members.size = " + members.size());
}
// 결과 : members.size = 2
위 코드에서는 양쪽 모두 관계를 설정했다.
결과도 기대했던 2가 출력된다.
객체까지 고려하면 이렇게 양쪽 다 관계를 맺어야 한다.
만약 위의 코드가 JPA로 작성한 코드여도 연관관계의 주인인 Member.Team만 적용될 것이기 때문에 상관없다.
앞서 말한것 처럼 객체까지 고려해서 주인이 아닌 곳에도 값을 입력하자.
결론 : 객체의 양방향 연관관계는 양쪽 모두 관계를 맺어주자.
02. 연관관계 편의 메소드
양방향 연관관계는 결국 양쪽 다 신경 써야 한다.
member.setTeam()과 team.getMembers().add()를 각각 호출하다 보면 실수로 둘 중 하나만 호출해서 양방향이 깨질 수 잇따.
public class Member
{
private Team team;
public void setTeam(Team team)
{
this.team = team;
team.getMembers().add(this);
}
}
...
위의 코드로 양방향 관계를 모두 설정하도록 변경했다.
member1.setTeam(team1);
member2.setTeam(team1);
이렇게만 해도 양방향 관계를 모두 설정할 수 있다.
이렇게 한 번에 양방향 관계를 설정하는 메소드를 연관관계 편의 메소드라고 한다.
03. 연관관계 편의 메소드 작성 시 주의사항
사실 위의 코드에는 버그가 있다.
만약 연관관계를 끊기 위해 Team을 null로 입력했다면?
member1은 팀과의 연관관계를 끊었지만 팀은 member1과의 연관관계가 끊이지 않았다.
때문에 삭제하는 코드에서는 양 방향 모두에서 연관관계를 삭제하는 코드를 추가해야 한다.
'JPA' 카테고리의 다른 글
JPA TIL - 07 고급 매핑 (0) | 2024.05.08 |
---|---|
JPA TIL - 06 다양한 연관관계 매핑 (0) | 2024.04.16 |
JPA TIL - 04 엔티티 매핑 (0) | 2024.04.02 |
JPA TIL - 03 영속성 관리 (0) | 2024.03.26 |
JPA TIL - 02 프로젝트 설정 및 엔티티 매니저 관리 (0) | 2024.03.25 |