JPA - (다대일) 양방향 연관관계

이유석·2023년 1월 7일
2

JPA - Entity

목록 보기
5/14
post-thumbnail

양방향 연관관계의 이해를 위해, 다대일(N:1) 양방향 관계로 설명을 해보겠습니다.

객체 및 테이블 모델링

이해를 돕기 위해, 회원(Member)과 팀(Team)의 관계를 예시로 들어보겠습니다.

  • 회원과 팀이 있다.
  • 회원은 하나의 팀에만 소속될 수 있다.
  • 다수의 회원은 하나의 팀에 소속될 수 있습니다.
  • 즉, 회원관 팀은 다대일(N:1)의 관계입니다.

위 조건들을 살펴보면, 다대일 단방향 관계와 동일합니다..

  • 왜냐하면, 데이터베이스는 방향이 없기 때문입니다.
    (외래키를 사용한 JOIN 을 통하여 연관된 테이블의 조회가 가능합니다.)

위 조건에 다대일 양방향 관계를 위한 추가 조건은 아래와 같습니다.

  • 회원 객체와 팀 객체는 양방향 관계입니다.
  • 회원 객체(Member)는 Member.team 필드를 통해서 회원이 속한 팀 객체(Team)에 접근할 수 있습니다.
  • 팀 객체(Team)는 Team.members 필드를 통해서 팀에 속한 회원 객체 목록(List<Member>)에 접근할 수 있습니다.

위 관계를 통해서 객체 및 테이블 모델링을 한 결과는 아래와 같습니다.

객체 관계 매핑

해당 객체 모델링을 코드로 나타내어 보도록 하겠습니다.

코드 설명

Member 클래스 (다대일에서 에 해당합니다.)

@Entity
public class Member {
    @Id
    @Column(name = "MEMBER_ID)
    private Long id;
 
    @Column(name = "USERNAME")
    private String username;
 
    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;
  
    // Getter, Setter, Constructor...
}

Team 클래스 (다대일에서 에 해당합니다.)

@Entity
public class Team {
	@Id
    @Column(name = "TEAM_ID)
    private Long id;
    
    @Column(name = "NAME")
    private String name;
    
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>(); 
    // NPE 방지를 위해서, Collection 객체를 초기화 합니다.
    // NPE : Null Pointer Exception
    
    // Getter, Setter, Constructor
}
  • 다대일 양방향 매핑은 다대일 단방향 매핑과 일대다 단방향 을 함께 사용하는 형태입니다.

  • 다대일 양방향 매핑에서 에 속하는 Member 클래스다대일 단방향 매핑의 코드와 동일합니다.

  • 다대일 양방향 매핑에서 에 속하는 Team 클래스에 아래의 사항을 추가합니다.
    • 에 해당하는 클래스에 에 해당하는 클래스를 Collection 프레임워크로 감싼 형태의 참조 필드로 작성해주시면 됩니다.
    • 이때 해당 필드위에 @OneToMany(mappedBy = "반대쪽 매핑의 필드 이름값")를 추가하여 줍니다.
    • mappedBy 속성은 양방향 매핑일 때 사용하며, 반대쪽 매핑의 필드 이름값으로 설정하면 됩니다.
      이는 연관관계의 주인을 설정하기 위한 것 입니다.

연관관계의 주인

양방향 연관관계 매핑시, 반대편 테이블의 외래키를 관리하는 객체입니다.

양방향 연관관계는 2개의 단방향 매핑으로 이루어져있습니다.
즉, 객체의 참조가 2개이며, 이를 테이블 연관관계로 표현하면 외래 키 하나로 두 테이블의 연관관계를 관리합니다.

객체의 참조는 2개이며, 외래키는 1개인 객체 모델링과 테이블 모델링의 차이가 발생합니다.
그렇다면 2개의 단방향 매핑 중, 하나의 관계를 정해서 테이블의 외래키를 관리해야합니다.
이때 테이블의 외래키를 관리하는 객체를 연관관계의 주인이라고 합니다.

  • 연관관계의 주인만이 데이터베이스 연관관계와 매핑되고 외래키를 관리할 수 있다.
  • 주인이 아닌 쪽은 참조 필드를 통해서 읽기만 가능하다.

연관관계 주인 지정 (mappedBy)

연관관계의 주인은 mappedBy 속성을 활용하여 지정한다.

  • 주인은 mappedBy 속성을 사용하지 않는다.
  • 주인이 아니면 mappedBy 속성을 사용해서 속성의 값으로 연관관계의 주인을 지정해야 한다.
    • 이때 속성의 값으로 반대쪽 매핑의 필드 이름값 을 사용한다.

연관관계의 주인에 대한 더 자세한 내용은 JPA 에서의 연관관계를 참고해 주시기 바랍니다.

양방향 매핑 시 주의점

연관관계의 주인에 값을 입력하자!

  • 연관관계의 주인만이 데이터베이스의 외래 키의 값을 변경할 수 있다.
    그러므로 연관관계 설정 시, 아래와 같이 코드를 작성해 주어야 합니다.
Team team1 = new Team(0L, "팀1");
entityManager.persist(team1);

// 현재 연관관계의 주인은 외래키 (TEAM_ID)를 관리하는 Member 클래스 입니다.
Member member1 = new Member(0L, "회원1");
entityManager.persist(member1);

// 연관관계의 주인에 값 설정
member1.setTeam(team1);

// 역방향 연관관계를 설정하지 않아도, 지연 로딩을 통해서 아래에서 Member에 접근할 수 있다.
//team.getMembers().add(member);

// 이 동작이 수행되지 않으면 FK가 설정되어 있지 않은 1차캐시에만 영속화 된 상태이다. 
// SELECT 쿼리로 조회해봤자 list 사이즈 0이다.
entityManager.flush();
entityManager.clear();

Team findTeam = entityManager.find(Team.class, team1.getId());
List<Member> findMembers = findTeam.getMembers();

for (Member m : findMembers) {
	// flush, clear가 일어난 후에는 팀의 Members에 넣어주지 않았지만, 조회를 할 수 있음. 이것이 지연로딩
    System.out.println(m.getUsername());
}
Assertions.assertEquals(1, findMembers.size());

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

  • 그렇다면 정말 주인에만 값을 저장하고 주인이 아닌 곳에는 값을 저장하지 않아도 될까?
    • 이렇게만 해도 DB에 적용이 됩니다.
  • 아니다!!, 양쪽 방향에 모두 값을 입력해주는 것이 가장 안전합니다.
    • 즉시 로딩 방법을 사용하여, 1차캐시에 영속화 되어있는 값을 그대로 가져오는 경우
      주인이 아닌쪽에서 주인 객체의 조회가 불가능 합니다.
    • 또한, 아래의 코드와 같이 순수한 객체 상태에서 심각한 문제가 발생할 수 있기 때문입니다.
// 팀 및 회원 객체 생성
Team team = new Team(0L, "팀1");
Member member1 = new Member(0L, "회원1");
Member member2 = new Member(0L, "회원2");

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

List<Member> members = team.getMembers();
System.out.println("members.size = " + members.size());

Assertions.assertEquals( 2, members.size());

연관관계 편의 메서드

양방향 연관관계 설정 시, 결국 코드상에서 양쪽 모두 연관관계를 설정해 주는 것 이 좋습니다.

  • member1.setTeam(team); // 연관관계의 주인
  • team.getMembers.add(team) // 주인이 아니다. 저장 시 사용되지 않는다.

이때 위 두 코드는 항상 동시에 실행되어야 하기 때문에 이를 하나의 메서드로 사용하는 것이 안전합니다.

Member 클래스의 setTeam() 메서드를 아래와 같이 수정합니다.

public class Member {

	...
    
    // 연관관계 편의 메서드
    public void setTeam(Team team) {

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


        // 새로운 연관관계 설정
        this.team = team;
        if (team != null) { // 연관관계 제거 시, team == null
            team.getMembers().add(this);
        }
    }
}

연관관계 사용

연관관계를 등록, 조회, 수정, 삭제 하는 예제를 통해 연관관계를 어떻게 사용하는지 알아보겠습니다.

저장

public void saveTest() {
	Team team1 = new Team(0L, "팀1");
    entityManager.persist(team1);

    Member member1 = new Member(0L, "회원1");
    Member member2 = new Member(1L, "회원2");
    entityManager.persist(member1);
    entityManager.persist(member2);

    // 연관관계의 주인에 값 설정
    member1.setTeam(team1);
    member2.setTeam(team1);
}
  • JPA에서 엔티티를 저장할 때 연관된 모든 엔티티는 영속 상태이어야 합니다.

  • 위 코드를 실행하였을 때, 실행되는 SQL 은 아래와 같습니다.

INSERT INTO TEAM (TEAM_ID, NAME) VALUES (0, '팀1');
INSERT INTO MEMBER (MEMBER_ID, USERNAME, TEAM_ID) VALUES (0, '회원1', 0);
INSERT INTO MEMBER (MEMBER_ID, USERNAME, TEAM_ID) VALUES (1, '회원2', 0);

조회 (다대일 - '다'에서의 조회)

객체 그래프 탐색

Member member = em.find(Member.class, 0L);
Team team = member.getTeam();

객체지향 쿼리 (JPQL) 사용

String jpql = "select t from Team t join Member m on t.id = m.team.id "
                + "where m.id = :memberId";

Team team = entityManager.createQuery(jpql, Team.class)
				.setParameter("memberId", 0L)
                .getSingleResult();
  • 위 코드를 실행하였을 때, 실행되는 SQL 은 아래와 같습니다.
SELECT t.*
FROM Team t
	JOIN Member m ON t.TEAM_ID = m.TEAM_ID
WHERE m.MEMBER_ID = 0;

조회 (다대일 - '일'에서의 조회)

객체 그래프 탐색

Team team = entityManager.find(Team.class, 0L);

List<Member> members = team.getMembers();

객체지향 쿼리 (JPQL) 사용

String jpql = "select m from Member m join m.team t on t.id = m.team.id "
                + "where t.id = :teamId";

List<Member> members = entityManager.createQuery(jpql, Member.class)
                .setParameter("teamId", 0L)
                .getResultList();
  • 위 코드를 실행하였을 때, 실행되는 SQL 은 아래와 같습니다.
SELECT m.*
FROM Member m
	JOIN Team t ON t.TEAM_ID = m.TEAM_ID
WHERE t.TEAM_ID = 0;

수정

Team team2 = new Team(1L, "팀2");
entityManager.persist(team2);

Member member1 = entityManager.find(Member.class, 0L);
member1.setTeam(team2);

수정은 entityManager.update(); 와 같은 메서드가 없으며,
조회한 엔티티의 값만 변경해두면 트랜잭션을 커밋할 때, 플러시가 일어나면서 변경 감지 기능이 작동합니다.
이때, 변경사항을 데이터베이스에 자동으로 반영해줍니다.

  • 위 코드를 실행하였을 때, 실행되는 SQL 은 아래와 같습니다.
UPDATE MEMBER
SET
	TEAM_ID = 1, ...
WHERE
	MEMBER_ID = 0;

삭제

다대일 연관관계에서 에 연관된 엔티티인 Team 객체들 중, 팀1 을 제거해보겠습니다.

이때, 기존에 있던 연관관계를 먼저 제거하고 삭제를 수행해야 합니다.
그렇지 않으면 외래 키 제약 조건에 의해 데이터베이스에서 오류가 발생합니다.

Team team1 = entityManager.find(Team.class, 0L);

// Member 와의 연관관계를 제거합니다.
List<Member> members = team1.getMembers();
while (members.size() > 0) {
	members.get(0).setTeam(null);
}
        
entityManager.remove(team1);
  • 위 코드를 실행하였을 때, 실행되는 SQL 은 아래와 같습니다.
UPDATE MEMBER
SET
	TEAM_ID = null, ...
WHERE
	MEMBER_ID = 0;

UPDATE MEMBER
SET
	TEAM_ID = null, ...
WHERE
	MEMBER_ID = 1;

DELETE 
FROM TEAM
WHERE TEAM_ID = 0;

양방향 연관관계 정리

  • 단방향 매핑만으로도 이미 연관관계 매핑은 끝입니다.

    • 설계할 때 객체 입장에서 보면 양방향 매핑은 양쪽다 신경을 써야 하므로 복잡도가 증가합니다.

    • 실무에서 JPA 모델링 할 때, 단방향 매핑으로 처음에 설계를 끝냅니다(객체와 테이블을 매핑하는 것).

    • 일대다에서 다쪽에 단방향 매핑으로 쭉 설계하면 이미 테이블 FK 설정은 끝납니다.
      거기서 필요할때 양방향 매핑을 추가해서 역방향 조회 기능을 쓰면 된다. 자바 코드에 컬렉션만 추가하면 됩니다.

    • 양방향 매핑은 반대 방향으로 조회(객체 그래프 탐색) 기능이 추가된 것 뿐이다.

  • 연관관계의 주인을 정하는 기준

    • 비즈니스 로직을 기준으로 주인을 선택하면 안됩니다.
    • 연관관계 주인은 외래 키의 위치를 기준으로 정해야 합니다.
      즉, 외래 키를 보유한 테이블(객체)가 연관관계의 주인 입니다.

소스코드

profile
https://github.com/yuseogi0218

0개의 댓글