본문 바로가기
Back-End/JPA

[북스터디] 자바 ORM 표준 JPA 프로그래밍 : 연관 관계 매핑 기초

by newny 2024. 6. 16.
반응형

Intro


객체 참조화 테이블의 외래 키를 매핑한는 것이 이 장의 목표!

  • 방향(Direction)
    • 단방향 : 회원 → 팀
    • 양방향 : 회원 ↔ 팀
  • 다중성(Multiplicity) : 다대일(N:1), 일대다(1:N), 일대일(1:1), 다대다(N:M)
  • 연관 관계의 주인 : 객체를 양방향 연관 관계로 만들면 연관 관계의 주인을 정해야함

 
 
 
 

단방향 연관관계


  • 회원과 팀이 있다고 가정
  • 회원은 하나의 팀에만 소속될 수 있음
  • 회원과 팀은 다대일 관계

 
 

객체 연관 관계

  • 회원 객체는 Member.team 필드(멤버변수)로 팀 객체와 연관관계를 맺는다.
  • 회원 객체와 팀 객체는 단방향 관계이다. (회원객체를 통해 팀을 알 수 있지만 팀 객체로는 회원을 알 수 없음

 
 

테이블 연관관계

  • 회원 테이블을 TEAM_ID 외래 키로 팀 테이블과 연관관계를 맺는다.
  • 회원 테이블과 팀 테이블은 양방향 관계이다. (외래키를 하나로 MEMBER JOIN TEAM, TEAM JOIN MEMBER 모두 가능)

 
 

객체 연관관계와 테이블 연관관계의 가장 큰 차이

JPA 1장에서 설명했듯, 객체와 테이블의 패러다임 불일치로 인한 문제가 발생한다.
객체를 양방향으로 만들기 위해 서로 참조하게 하여도, 서로 다른 단방향 관계 2개가 형성될 뿐이다. (Member → Team, Team → Member)
 
 

객체 연관관계 VS 테이블 연관관계 정리

  • 객체
    • 단방향 2개를 만들어야 양방향으로 사용 가능 : Member → Team, Team → Member
    • 객체 그래프 탐색 : 참조를 사용해서 연관관계를 탐색
  • 테이블
    • 외래키를 이용한 JOIN으로 인해 양방향: Member ↔ Team
    • 조인 : 외래키를 이용한 연관관계 탐색

 
 

객체 관계 매핑 어노테이션

@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 ...
}

@ManyToOne

  • 다대일(N:1) 관계라는 매핑 정보다. 연관관계를 매핑할 때 이렇게 다중성을 나타내는 어노테이션을 필수로 사용해야 한다.
  • 속성
    • optional : false로 설정하면 연관된 엔티티가 항상 있어야 함(기본값 : true)
    • fetch : 글로벌 페치 전략을 설정한다. (자세한 내용은 8장에서 설명)
    • cascade : 영속성 전이 기능을 사용한다. (자세한 내용은 8장에서 설명)
    • targetEntity : 연관된 엔티티 타입 정보를 설정한다. (이 기능은 거의 사용하지 않음)

 

@JoinColumn(name = “TEAM_ID”)

  • 조인 컬럼은 외래 키를 매핑할 때 사용한다. name 속성에는 매핑할 외래 키 이름을 지정한다. 이 어노테이션은 생략할 수 있다.
  • 속성
    • name : 매핑할 외래 키 이름을 지정한다. 이 어노테이션은 생략할 수 있다. (기본값 “필드명” + “_” + “참조하는 테이블의 기본 키 컬럼명”)
    • referencedColumnName : 외래 키가 참조하는 대상 테이블의 컬럼명 (참조하는 테이블의 기본 컬럼명)
    • foreignKey(DDL) : 외래 키 제약 조건을 직접 지정할 수 있다. (이 속성은 테이블을 생성할 때만 사용)
    • 나머지 속성들 : @Column의 속성과 같은 속성을 가지고 있음

 
 
 
 

연관관계 사용


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에서 엔티티를 저장할 때 연관된 모든 엔티티는 연속 상태여야 한다.
 
 

조회

  • 객체 그래프 탐색
    • 객체를 통해 연관된 엔티티를 조회
  • 객체지향 쿼리 사용
    • 객체 지향 쿼리인 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)
    		.setParamater("teamName", "팀1");
    		.getResultList();
    	
    	for (Member member : resultList) {
    		System.out.println("[query] member.username=" + 
    			member.getUsername());
    	}
    }
    
    // 결과
    // [query] member.username=회원1
    // [query] member.username=회원2
    
    SELECT M.* FROM MEMBER MEMBER
    INNER JOIN
    	TEAM TEAM ON MEMBER.TEAM_ID = TEAM1_.ID
    WHERE
    	TEAM1_.NAME = '팀1'
    
    실행된 SQL과 JPQL을 비교하면 JPQL은 객체(엔티티)를 대상으로 하고 SQL보다 간결하다.

 
 

수정

public static void updateRelation(EntityManager em) {
	
	// 새로운 2팀
	Team team2 = new Team("team2", "팀2");
  em.persist(team2);

	// 회원1 저장
	Member member = em.find(Member.class, "member1");
	member.setTeam(team2);
}
UPDATE MEMBER
SET
	TEAM_ID='team2', ...
WHERE
	ID='member1'

 
 

연관관계 제거

private static void deleteRelation(EntityManager em) {
	
	Member member1 = em.find(Member.class, "member1");
	member1.setTeam(null); // 연관관계 제거
}
UPDATE MEMBER
SET 
	TEAM_ID=null, ...
WHERE
	ID='member1'

 
 

연관된 엔티티 삭제

연관된 엔티티를 삭제하려면 기존에 있던 연관관계를 먼저 제거하고 삭제해야 한다. 그렇지 않으면 외래 키 제약조건으로 인해, 데이터베이스에서 오류가 발생한다.

member1.setTeam(null); // 회원1 연관관계 제거
member2.setTeam(null); // 회원2 연관관계 제거
em.remove(team); // 팀 삭제

 
 
 
 

양방향 연관관계


이번에는 반대 방향인 팀에서 회원으로 접근하는 관계를 추가하여 양방향 연관관계로 매핑하겠다.
객체 연관관계는 아래와 같다.

  • Member → Team
  • Team → Member

데이터베이스 테이블은 외래 키 하나로 양방향으로 조회할 수 있으므로, 따라서 추가할 내용은 전혀 없다.
 
 

양방향 연관관계 매핑

회원 엔티티는 변경사항이 없고, 팀 엔티티를 재설정해야한다.

@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>();
	
	// Getter, Setter ...
}

팀과 회원은 일대다 관계이므로 팀 엔티티에 컬렉션인 List<Member> members를 추가했다. 그리고 일대다 관계를 매핑하기 위해 @OneToMany 매핑 정보를 사용했다.
 

@OneToMany

  • 일대다 관계를 매핑하기 위한 어노테이션
  • 속성
    • mappedBy : 양방향 매핑일 경우 사용, 반대쪽 매핑의 필드 이름을 값으로 주면됨(아래의 ‘연관 관계의 주인’ 파트에서 자세히 설명)

 
 

일대다 컬렉션 조회

public void biDirection() {
	
	Team team = em.find(Team.class, "team1");
	List<Member> members = team.getMembers(); // 팀 → 회원, 객체 그래프 탐색
	
	for(Member member : members) {
		System.out.println("member.username = " + 
			member.getUsername());
	}
}

// 결과
// member.username = 회원1
// member.username = 회원2

 
 
 
 

연관 관계의 주인


객체에는 서로 다른 단방향 연관관계 2개를 애플리케이션 로직으로 잘 묶어서 양방향인 것처럼 보이게 할 뿐 양방향 연관관계라는것은 없다.
따라서 엔티티를 양방향 연관관계로 설정하면 객체의 참조는 둘인데 외래 키는 하나다.
그러므로 두 객체 연관관계 중 하나를 정해서 테이블의 외래 키를 관리해야 하는데 이것을 연관관계의 주인이라고 한다.
 
 

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

연관관계의 주인만이 데이터베이스 연관관계와 매핑되고 외래 키를 관리(등록, 수정, 삭제)할 수 있다. 반면에 주인이 아닌 쪽은 읽기만 할 수 있다.
어떤 연관관계를 주인으로 정할지는 mappedBy 속성을 사용하면 된다.
 
 

mappedBy 속성

  • 주인은 mappedBy 속성을 사용하지 않는다.
  • 주인이 아니면 mappedBy 속성을 사용해서 속성의 값으로 연관관계의 주인을 지정해야 한다.

 
 

연관관계의 주인은 외래 키가 있는 곳

연관관계이 주인을 정한다는 것은 외래 키 관리자를 선택하는 것이다. 해당 예제의 테이블에서는 TEAM_ID가 외래 키 관리자가 된다.
또한 연관관계의 주인은 테이블에 외래 키가 있는 곳으로 정해야 한다. 여기서는 회원 테이블이 외래 키를 가지고 있으므로 Member.team이 주인이 된다. 주인이 아닌 Team.members에는 mappedBy 속성을 이용하여 주인이 아님을 설정해야한다.

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

 
 
 
 

양방향 연관관계의 주의점


연관관계의 주인에는 값을 입력하지 않고, 주인이 아닌 곳에만 값을 입력하는 경우에 문제가 발생한다. ( member.setTeam()을 설정하지 않을경우)
 
 

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

주인이 아닌 방향은 값을 설정하지 않아도 될까? 결론적부터 말하자면 양쪽 모두 값을 세팅해주는것이 안전하다.

public void testSave() {

    // 팀1 저장
    Team team1 = new Team("team1", "팀1");
    em.persist(team1);

    // 회원1 저장
    Member member1 = new Member("member1", "회원1");
    member1.setTeam(team1);
    // team1.getMembers().add(member1); 양방향 연관관계 설정을 하지 않음
    em.persist(member1);

    // 회원2 저장
    Member member2 = new Member("member2", "회원2");
    member2.setTeam(team1);
    // team1.getMembers().add(member2); 양방향 연관관계 설정을 하지 않음
    em.persist(member2);

    List<Member> members = team1.getMembers();
    System.out.println("members.size = " + members.size()); // members.size = 0
}

예를들어 위의 코드처럼 member.setTeam(team)의 값은 설정하고, team.getMembers().add(member)의 값은 설정하지 않을경우 DB 반영에는 문제가 되지 않는다.
하지만 위의 상황처럼 하나의 로직 안에서 저장된 member 객체를 team.members를 통해 찾아오게 될 경우 1차 캐시와 DB에 저장되어있지 않은 상태가 되므로 member.size의 값은 0이 된다.
물론 해당 저장과 동시에 값을 조회하는 로직이 아닌 저장과 조회가 각각 다른 트랜잭션에서 실행된다면, team.members를 이용하여 객체를 찾아오는 것은 가능하다. (1차캐시에 없으므로 DB 조회 → 1차캐시 저장 후 값 반환)
따라서 안전하게 사용하려면 객체까지 고려하여 양쪽 다 관계를 맺어야 한다.
 
 

연관관계 편의 메서드

public class Member {
	
	private Team team;
	
	public void setTeam(Team team) {
		this.team = team;
		team.getMembers().add(this);
	}
	...

Member 클래스의 setTeam() 메서드 코드를 위와같이 리팩토링하여 좀 더 안전하게 양방향 관계를 설정할 수 있다.
이렇게 한 번에 양방향 관계를 설정하는 메서드를 연관관계 편의 메서드라고 한다.
 
 

연관관계 작성 시 주의 사항

주의 사항 1 : 삭제되지 않은 연관관계

member1.setTeam(teamA); 
member1.setTeam(teamB); 
List<Member> members = teamA.members; // teamA가 가지고있는 members 리스트에 member1이 여전히 존재함

위의 코드처럼 member1이 연관관계를 teamB로 변경하여도 여전히 teamA객체는 member1과 연관관계가 끊어지지 않은 상황이다.

public void setTeam(Team team) {

	// 기존 팀과 관계를 제거
	if (this.team != null) {
		this.team.getMembers().remove(this);
	}
	this.team = team;
	team.getMembers().add(this);
}

따라서 연관관계를 변경할 때는 위와 같이 기존 팀이 있으면 기존 팀과 회원의 연관관계를 삭제하는 코드를 추가해야 한다.

 

주의 사항 2 : 무한 루프

@Entity
public class Team {
    // ...

    @JsonManagedReference
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();

    @Override
    public String toString() {
        return "Team{" +
                "id=" + id +
                ", name='" + name + '\'' + 
                ", members=" + members;
    }
    
    // ...
}

@Entity
public class Member {
    // ...

    @JsonBackReference
    @ManyToOne
    @JoinColumn(name = "team_id")
    private Team team;
    
    @Override
    public String toString() {
        return "Member{" +
                "id=" + id +
                ", username='" + username + '\'' +
                ", team=" + team;
    }

    // ...
}
Team team = new Team("team1", "Team 1");
Member member1 = new Member("user1", "User 1");
Member member2 = new Member("user2", "User 2");

team.getMembers().add(member1);
team.getMembers().add(member2);

member1.setTeam(team);
member2.setTeam(team);

System.out.println(team); // 무한 루프 발생

위의 코드처럼 toString() 메서드를 이용하여 호출할 경우 무한 루프에 빠지게된다.
team의 String 출력을 위해 toString() 메서드가 호출이 되고, 내부의 members가 호출되는데 해당 코드도 String으로 출력이 되야하므로 toString() 메서드가 또 호출이된다. 그럼 해당 member 객체의 team또한 또 호출이 되면서 무한 루프에 빠지게 된다.
해당 문제를 해결하는 가장 간단한 방법으로는 toString()메서드를 호출하지 않고, 필요한 정보만은 출력하는 방식으로 수정하는 것이다.
 
 
 
 

정리


  • 단방향 매핑만으로 테이블과 객체의 연관관계 매핑은 이미 완료되었다.
  • 단방향을 양뱡향으로 만들면 반대방향으로 객체 그래프 탐색 기능이 추가된다.
  • 양방향 연관관계를 매핑하려면 객체에서 양쪽 방향을 모두 관리해야한다.
  • 연관관계의 주인은 외래 키의 위치과 관련해서 정해야지 비즈니스 중요도로 접근하면 안된다.

 
 
 
 

출처

자바 ORM 표준 JPA 프로그래밍 - 김영한 지음 (에이콘 출판)
https://catsbi.oopy.io/ed9236a0-6521-471d-8a0d-b852147b5980

반응형

댓글