연관관계 매핑 기초

윤용운·2022년 4월 4일
2

JPA_스터디

목록 보기
5/9
post-thumbnail

5장. 연관관계 매핑 기초

객체의 참조와 테이블의 외래 키를 매핑하는 것이 이 장의 목표

단방향 연관관계

회원과 팀이 있다.
회원은 하나의 팀에만 소속될 수 있다.
회원과 팀은 다대일 관계이다

  • 회원 객체는 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
  • 참조를 통한 연관관계는 언제나 단방향이고, 양방향으로 만들고 싶으면 반대쪽에도 필드를 추가해서 참조를 보관해야 하는데, 이는 정확히 말하면 양방향 연관관계가 아니라, 서로 다른 단방향 연관관계가 2개라는 차이가 있다.

순수한 객체 연관관계

@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과 회원 2member2team에 소속시키려면 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
    이름 그대로 다대일(N:1) 관계라는 매핑 정보이다. 연관관계를 매핑할 때 이렇게 다중성을 나타내는 어노테이션을 필수로 사용해야 한다.
  • @JoinColumn(name = "TEAM_ID")
    @JoinColumn은 외래 키를 매핑할 때 사용한다. 회원과 팀 테이블은 TEAM_ID 외래키로 연관관계를 맺으므로 이 값을 지정하면 된다. 이 어노테이션은 생략이 가능하다.

@JoinColumn

@JoinColumn은 외래 키를 매핑할 때 사용한다.

속성기능기본값
name매핑할 외래 키 이름필드명 + _ + 참조하는 테이블의 기본 키 컬럼명
referencedColumnName외래 키가 참조하는 대상 테이블의 컬럼명참조하는 테이블의 기본키 컬럼명
foreignKey(DDL)외래 키 제약조건을 직접 지정할 수 있다. 테이블 생성시에만 사용
unique
nullable
insertalbe
updatable
columnDefinition
table
@Column의 속성과 같다

@ManyToOne

@ManyToOne은 다대일 관계에서 사용한다.

속성기능기본값
optionalfalse로 설정하면 연관된 엔티티가 항상 있어야 한다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.membersList로 추가하였다.

양방향 연관관계 매핑

@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_IDUSERNAMETEAM_ID
member1회원1null
member2회원2null

연관관계의 주인이 아닌 Team.members에만 값을 저장했기 때문에 TEAM_IDnull이 저장되어 있다.

순수한 객체까지 고려한 양방향 연관관계

객체 관점에서 바라보면, 양쪽 방향에 모두 값을 입력해주는 것이 가장 안전하다.

// 순수객체
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를 참조하도록 정상반영되기 때문이다.
하지만, 영속성 컨텍스트가 살아있는 상태에서 teamAgetMembers()를 호출하면 member1이 반환되는 것이 문제이다. 따라서 위에서 말한것처럼 관계를 제거하는 것이 안전하다.

정리

  • 양방향의 장점 : 객체 그래프 탐색 기능의 추가
    주인의 반대편은 mappedBy로 주인을 지정해야 하고, 주인의 반대편은 단순히 보여주는 일(객체 그래프 탐색)만 할 수 있다.
  • 연관관계 주인을 정하는 기준
    외래키의 위치와 관련해서 정해야지 비즈니스 중요도로 접근하면 안된다.
  • 무한루프에 빠지지 않게 주의해라.
    Member.toString()에서 getTeam()을 호출하고, Team.toString() 에서 getMember()를 호출하게 되면 무한루프에 빠질 수 있다. JSON으로 변환 할 때 자주 발생하는데, JSON라이브러리들은 보통 무한루프에 빠지지 않도록 하는 어노테이션이나 기능을 제공한다. Lombok 라이브러리 역시 주의해야 한다.

Reference

  • 자바 ORM 표준 JPA 프로그래밍 (김영한)

0개의 댓글