Backend/Spring

[자바 ORM 표준 JPA 프로그래밍] 5. 연관관계 매핑 기초 / 정리글

SOLokill 2022. 5. 11. 01:31

 방향(Direction)
- 단방향: A -> B or B -> A (둘 중 한 쪽만 참조하는 관계)
- 양방향: A -> B and B -> A (두 쪽 모두 서로 참조하는 관계)

다중성((Multiplicity)
- 다대일(N:1)
- 일대다(1:N)
- 일대일(1:1)
- 다대다(N:M)

연관관계의 주인(Owner)
- 객체를 양방향 연관관계로 만들면 연관관계의 주인을 정해야 한다.

단방향 연관관계 - 다대일(N:1)

  • 객체 연관관계
    - 회원 객체는 Member.team 필드(멤버변수)로 팀 객체와 연관관계를 맺는다.
    - 회원 객체와 팀 객체는 단방향 관계이다. member.getTeam()으로 회원에서 팀의 조회는 가능하지만 그 반대는 불가능하다.
  • 테이블 연관관계
    - 회원 테이블은 TEAM_ID 외래 키로 팀 테이블과 연관관계를 맺는다.
    - 회원 테이블과 팀 테이블은 양방향관계이다. 회원 테이블의 TEAM_ID 외래 키를 통해 두 테이블을 서로 조인할 수 있다.
  • 정리
    - 객체는 참조(주소)로 연관관계를 맺는다. (a.getB().getC()) 
    - 테이블은 외래 키로 연관관계를 맺는다. (JOIN)
    - 참조를 통한 연관관계는 언제나 단방향이다. 양방향으로 만드려면 서로 다른 단방향 관계 2개를 만들어 양쪽에서 참조하는 방식으로 구현할 수 있다.
    - 외래 키를 사용하는 테이블의 연관관계는 양방향이다.

순수한 객체 연관관계

순수한 : JPA 를 사용하지 않은 객체만 사용한 연관관계

더보기

객체는 참조를 사용해서 연관관계를 탐색할 수 있는데 이것을 '객체 그래프 탐색'이라 한다.

테이블 연관관계

 

테이블 DDL
CREATE TABLE MEMBER(
	MEMBER_ID VARCHAR(255) NOT NULL,
	TEAM_ID VARCHAR(255),
	USERNAME VARCHAR(255),
	PRIMARY KEY (MEMBER_ID)
)

CREATE TABLE TEAM (
	TEAM_ID VARCHAR(255) NOT NULL,
	NAME VARCHAR(255),
	PRIMARY KEY (TEAM_ID)
)

ALTER TABLE MEMBER ADD CONSTRAINT FK_MEMBER_TEAM
	FOREIGN KEY (TEAM_ID)
   	REFERENCES TEAM
SQL로 조인하여 연관관계 탐색하기
- 회원1 과 회원2 소속시키기
INSERT INTO TEAM(TEAM_ID, NAME) VALUES('team1', '팀1');
INSERT INTO MEMBER(MEMBER_ID, TEAM_ID, USERNAME) VALUES('member1', 'team1', '회원1');
INSERT INTO MEMBER(MEMBER_ID, TEAM_ID, USERNAME) VALUES('member2', 'team1', '회원2');

- 회원1이 소속된 팀 조회하기
SELECT T.*
FROM MEMBER M
	JOIN TEAM T ON M.TEAM_ID = T.NAME_ID
WHERE M.MEMBER_ID = 'member1'

JPA로 매핑해보기

// 매핑한 회원 엔티티
@Entity
public class Member {
	@Id
	@Column(name = "MEMBER_ID")
	private String id;
	private String username;
	
	//연관관계 매핑
	@ManyToOne
	@JoinColumn(name="TEAM_ID")
	private Team team;
	
	//연관관계 설정
	public void setTeam(Team team) {
		this.team = team;
	}
	//Getter, Setter ...
}
// 매핑한 팀 엔티티
@Entity
public class Team {
	@Id
	@Column (name = "TEAM_ID")
	private String id;
	
	private String name;
	//Getter, Setter ...
}

객체 연관관계 : 회원 객체의 Member.team 필드 사용.
테이블 연관관계 : 회원 테이블의 MEMBER.TEAM_ID 외래 키 컬럼을 사용

더보기

@ManyToOne : 이름 그대로 다대일(N:1) 관계라는 매핑 정보다. 연관관계 매핑 시에 이렇게 다중성을 나타내는 어노테이션을 필수로 사용한다.
@JoinColumn(name="TEAM_ID") : 조인 컬럼은 외래 키를 매핑할 때 사용한다. name 속성에는 매핑할 외래 키 이름을 지정한다. 이 어노테이션은 생략 가능하다.

 

@JoinColumn

@JoinColumn은 외래 키 매핑 시에 사용한다.

속성 기능 기본값
name 매핑할 외래 키 이름 필드명 + _ + 참조하는 테이블의 기본 키 컬럼명
referencedColumnName 외래 키가 참조하는 대상 테이블의 컬럼명 참조하는 테이블의 기본 키 컬럼명
foreignKey(DDL) 외래 키 제약조건을 직접 지정할 수 있다. 이 속성은 테이블을 생성할 때만 사용한다.  
unique
nullable
insertable
updatable
columnDefinition
table
@Column의 속성과 같다.  
더보기
@JoinColumn 생략
- 다음처럼 @JoinColumn을 생략하면 외래 키를 찾을 때 기본 전략을 사용한다.

@ManyToOne
private Team team;

기본 전략 : 필드명 + "_" + 참조하는 테이블의 컬럼명
예시 : 필드명(team) + "_" + 참조하는 테이블의 컬럼명(TEAM_ID) =
team_TEAM_ID 외래 키를 사용한다.

@ManyToOne

@ManyToOne 어노테이션은 다대일 관계에서 사용한다.

속성 기능 기본값
optional false로 설정하면 연관된 엔티티가 항상 있어야 한다. (nullable로 이해) true
fetch 글로벌 패치 전략을 설정한다. 자세한 내용은 8장에서 설명 - @ManyToOne=FetchType.EAGER
- @OneToMany=FetchType.LAZY
cascade 영속성 전이 기능을 사용한다. 자세한 내용은 8장에서 설명  
targetEntity 연관된 엔티티의 타입 정보를 설정한다. 이 기능은 거의 사용하지 않는다. 컬렉션을 사용해도 제네릭으로 타입 정보를 알 수 있다.  
더보기

다대일과 비슷한 일대일 관계도 있다. 단방향 관계를 매핑할 때 둘 중 어떤 것을 사용해야 할지는 반대편 관계에 달려 있다. 반대편이 일대다 관계면 다대일을 사용하고 반대편이 일대일 관계면 일대일을 사용하면 된다. 참고로 일대일 관계는 다음 장에서 설명.

연관관계 사용

저장

회원과 팀을 저장하는 코드
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");
    member2.setTeam(team1); //연관관계 설정 member2 -> team1 == 회원 -> 팀 참조
    em.persist(member2); //저장
}
더보기

주의>

JPA에서 엔티티를 저장할 때 연관된 모든 엔티티는 영속 상태여야 한다. 영속성 컨텍스트에 등록되어야 CRUD 작업이 진핼할 수 있다.

조회

연관관계가 있는 엔티티를 조회하는 방법은 크게 2가지다.
- 객체 그래프 탐색(객체 연관관계를 사용한 조회)
- 객체지향 쿼리 사용(JPQL)

/* 
객체 그래프 탐색
*/
Member member = em.find(Member.class, "member1");
Team team = member.getTeam(); // 객체 그래프 탐색
System.out.println("팀 이름 = " + team.getName());

// 출력 결과 : 팀 이름 = 팀1
/*
객체지향 쿼리 사용 - 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. printin (" [query] member. username=,' +member.getUsername());
}

// 결과: [query] member.username = 회원1
// 결과: [query] member.username = 회원2
더보기

JPQL : SQL을 추상화한 객체 지향 쿼리 언어, 엔티티 객체를 대상으로 쿼리한다. SQL은 데이터베이스 테이블을 대상으로 쿼리 한다. : 부분은 바인딩 부분 (변수 넣을 수 있음) 

수정

/*
연관관계를 수정하는 코드
*/
private static void updateRelation(EntityManager em) {
	//새로운 팀 2
	Team team2 = new Team(”team2”, ”팀2”);
	em.persist(team2);
	
	//회원 1 에 새로운 팀2 설정
	Member member = em.find(Member.class, "memberl");
	member.setTeam(team2);
}
/*
실행되는 수정 SQL
UPDATE MEMBER
SET
	TEAM_ID='team2', ...
WHERE
	ID='member1'
/*
더보기
수정 작업에서는 em.update() 같은 메소드가 없다. 단순히 불러온 엔티티의 값만 변경해두면 트랜잭션을 커밋할 때 플러시가 일어나면서 변경 감지 기능이 작동한다. 그리고 변경사항을 데이터베이스에 자동으로 반영한다.
연관관계를 수정할 때도, 참조하는 대상만 변경하면 나머지는 JPA가 자동으로 처리한다.

연관관계 제거

/*
연관관계를 삭제하는 코드
*/
private static void deleteRelation(EntityManager em) {
	Member member1 = em.find(Member.class, "member1");
   	member1.setTeam(null); //연관관계 제거
}
/*
실행되는 연관관계 제거 SQL
UPDATE MEMBER
SET
	TEAM_ID=null, ...
WHERE
	ID='member1'
*/

remove() 를 이용해 영속성 컨텍스트에서 제거하는 것이 중요

양방향 연관관계

데이터베이스 테이블은 외래 키 하나로 양방향으로 조회할 수 있기에 데이터베이스에 추가할 내용은 없다.
Team.members를 List 컬렉션으로 추가했다.

더보기

JPA는 List를 포함해서 Collection, Set, Map 같은 다양한 컬렉션을 지원한다.

/*
매핑한 회원 엔티티
*/
@Entity
public class Member {
  @Id
  @Column (name = ”MEMBER_ID”)
  private String id;
  private String username;

  @ManyToOne
  @JoinColumn(name="TEAM_ID H)
  private Team team;

  //연관관계 :설정
  public void setTeam(Team team) {
    this.team = team;
  }
  //Getter, Setter ...  
}

연관관계의 주인

더보기

@OneToMany만 있으면 되지 mappedBy는 왜 필요할까?
객체에느 양방향 연관관계라는 것이 없다. 서로 다른 단방향 연관관계 2개를 애플리케이션 로직으로 잘 묶어서 양방향인 것처럼 보이게 할 뿐이다.

테이블은 외래 키 하나로 두 테이블의 연관관계를 관리한다. 엔티티를 단방향으로 매핑하면 참조를 하나만 사용하므로 이 참조로 외래 키를 관리하면 된다. 엔티티를 양방향 연관관계로 설정하면 객체의 참조는 둘인데 외래 키는 하나다. 따라서 둘 사이에 차이가 발생한다. 이런 차이로 인해 JPA에서는 두 객체 연관관계 중 하나를 정해서 테이블의 외래키를 관리해야 하는데 이것을 연관관계의 주인(OWNER)이라 한다. 연관관계의 주인만이 데이터베이스 연관관계와 매핑되고 외래 키를 관리(등록, 수정, 삭제)할 수 있다. 반면에 주인이 아닌 쪽은 읽기만 할 수 있다. 연관관계의 주인을 정한다는 것은 사실 외래 키 관리자를 선택하는 것이다.

양방향 매핑의 규칙: 연관관계의 주인

양방향 연관관계 매핑 시 지켜야 할 규칙이 있는데 두 연관관계 중 하나를 연관관계의 주인으로 정해야 한다. 연관관계의 주인만이 데이터베이스 연관관계와 매핑되고 외래 키를 관리(등록, 수정, 삭제)할 수 있다. 반면에 주인이 아닌 쪽은 읽기만 할 수 있다. 연관관계의 주인을 정한다는 것은 사실 외래 키 관리자를 선택하는 것이다.

/*
연관관계의 주인은 테이블에 외래 키가 있는 곳으로 정해야 한다.
*/
class Team {
	@OneToMany(mappedBy="team") //MappedBy 속성의 값은
    				//연관관계의 주인인 Member.team
    private List<Member> members = new ArrayList<Member>();
    ...
}

정리: 연관관계의 주인만 데이터베이스 연관관계와 매핑되고 외래 키를 관리할 수 있다. 주인이 반대편(inverse, non-owning side)은 읽기만 가능하고 외래 키를 변경하지는 못한다.

더보기

참고> 데이터베이스 테이블의 다대일, 일대다 관계에서는 항상 다 쪽이 외래 키를 가진다. 다 쪽인 @ManyToOne은 항상 연관관계의 주인이 되므로 mappedBy를 설정할 수  없다. 따라서 @ManyToOne에는 mappedBy 속성이 없다.

양방향 연관관계 저장
이 코드는 단방향 연관관계에서 살펴본 예제 5.6의 회원과 팀을 저장하는 코드와 완전히 같다.

/*
양방향 연관관계 저장
*/
public void teamSave(){

	//팀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");
    member2.setTeam(team1); //연관관계 설정 member2 -> team1
    em.persist(member2);
}

양방향 연관관계의 주의점

양방향 연관관계를 설정하고 가장 흔히 하는 실수는 연관관계의 주인에는 값을 입력하지 않고, 주인이 아닌 곳에만 값을 입력하는 것이다. 데이터베이스에 외래 키값이 정상적으로 저장되지 않으며 이것부터 의심해보자.

public void testSaveNonOwner() {
  //회원1 저장
  /*
  양방향 연관관계 주의점
  */
  Member member 1 = new Member ("member 1", ''회원1”);
  em.persist(member1);

  //회원2 저장
  Member member2 = new Member (”member2", "회원2”);
  em.persist(member2);

  Team teaml = new Team("teaml", ”팀 1”);
  
  //주인이 아닌 곳만 연관관계 설정
  teaml.getMembers().add(member1);
  teaml.getMembers().add(member2);
  em.persist(teaml);
}
MEMBER_ID USERNAME TEAM_ID
member1 회원1 null
member2 회원2 null

외래 키 TEAM_ID에 team1이 아닌 null 값이 입력되어 있는데, 연관관계의 주인이 아닌 Team.members에만 값을 저장했기 때문이다. 연관관계의 주인만이 외래 키의 값을 변경할 수 있다.

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

좋은 방법은 객체 관점에서 양쪽 방향에 모두 값을 입력해주는 것이 가장 안전하다. 양쪽 방향 모두 값을 입력하지 않으면 JPA를 사용하지 않는 순수한 객체 상태에서 심각한 문제가 발생할 수 있다.

/*
JPA로 코드 완성
*/
public void testORM_양방향 () {
  //팀1 저장
  Team teaml : new Team ("teaml”, "팀 1”) ;
  em.persist(team1);

  Member member 1 = new Member ("member 1", "회원1”) ;
  
  //양방향 연관관계 설정
  member1.setTeam(teaml) ; //연관관계 설정 member 1 -> teaml
  team1.getMembers().add(member1); //연관관계 설정 teaml -> member 1
  em.persist(member1);

  Member member2 = new Member ("meinber2", ”회원2”) ;
  
  //양방향 연관관계 설정
  member2.setTeam (teaml); //연관관계 설정 member2 -> teaml
  team1.getMembers().add(member2); "연관관계 설정 teaml -> member2
  em.persist(member2);
}

연관관계 편의 메소드

양방향 연관관계는 결국 양쪽 다 신경 써야 한다. 이전에는 member.setTeam(team)team.getMembers.add(member)를 각각 호출하다 보면 실수로 둘 중 하나만 호출해서 양방향이 깨질 수 있다. 양방향 관계에서 두 코드는 하나인 것처럼 사용하는 것이 안전하다.

/*
양방향 리팩토링 전체코드
*/
public void testORM_양방향_리팩토링() {
	Team team1 = new Team("team1", "팀1");
   	em.persist(team1);
    
   	Member member1 = new Member("member1", "회원1");
   	member1.setTeam(team1); //양방향 설정
   	em.persist(member1);
    
   	Member member2 = new Member("member2", "회원2");
   	member2.setTeam(team1); //양방향 설정
   	em.persist(member2);
}

연관관계 편의 메소드 작성 시 주의사항

setTeam() 메소드의 버그 : 연관관계를 변경할 떄는 기존 팀이 있으면 기존 팀과 회원의 연관관계를 삭제하는 코드를 추가해야 한다. 

/*
기존 관계 제거
*/
public void setTeam(Team team) {
	//기존 팀과 관계를 제거
    if (this.team != null) {
    	this.team.getMembers().remove(this);
    }
    this.team = team;
    team.getMembers().add(this);
}
정리
연관관계가 하나인 단방향 매핑은 언제나 연관관계의 주인이라는 점이다. 양방향은 여기에 주인이 아닌 연관관계를 하나 추가했을 뿐이다. 결국 단방향과 비교해서 양방향의 장점은 반대방향으로 객체 그래프 탐색 기능이 추가된 것뿐이다.
 
연관관계의 주인을 정하는 기준
단방향은 항상 외래 키가 있는 곳을 기준으로 매핑하면 된다. 하지만 양방향은 연관관계의 주인(Owner)이라는 이름으로 인해 오해가 있을 수 있다. 비즈니스 로직상 더 중요하다고 연관 관계의 주인으로 선택하면 안 된다. 비즈니스 중요도를 배제하고 단순히 외래 키 관리자 정도의 의미만 부여해야 한다. 따라서 연관 관계의 주인은 외래 키의 위치와 관련해서 정해야지 비즈니스 중요도로 접근하면 안 된다.
더보기
양방향 매핑 시에는 무한 루프에 빠지지 않게 조심해야 한다.
Member.toString()에서 getTeam()을 호출하고 Team.toString()에서 getMember()를 호출하면 무한 루프에 빠질 수 있다. 외래 키를 이용해서 연관된 객체를 가져오게 되서 팀이 멤버를 호출하고 멤버가 팀을 호출하고 무한 반복...
@JsonIgnore @toStringExclude가 있으니 그걸 사용하면 해결 가능 (일 쪽에서 선언하면 됨)
더보기

일대다를 연관관계의 주인으로 선택하는 것이 불가능한 것만은 아니다.
팀 엔티티의 Team.members를 연관관계의 주인으로 선택할 수는 있다. 하지만 성능과 관리 측면에서 권장하지 않는다. 될 수 있으면 외래 키가 있는 곳을 연관관계의 주인으로 선택하자(다 쪽을 선택해라!)