김영한 님의 자바 ORM 표준 JPA 프로그래밍을 읽고 정리한 내용입니다.

교보문고 링크

연관관계 매핑 기초

객체는 참조를 사용하여 연관 관계를 맺고 테이블은 외래 키를 사용해서 연관 관계를 맺는다.

관계가 있는 다른 데이터를 참조한다는 점에서 동일하지만, 참조와 외래 키는 완전히 다른 특징을 갖는다.

연관관계 매핑을 이해하기 위한 두 가지 키워드가 있다.

  1. 방향 : 회원/팀이라는 관계를 생각해본다.

    • 단방향 : 회원 → 팀, 팀 → 회원 둘 중 한 쪽만 참조하는 관계
    • 양방향 : 회원 → 팀, 팀 → 회원 둘이 서로를 참조하는 관계
  2. 다중성

    • 1:1 (일대일)
    • 1:N (일대다 혹은 다대일)
    • N:M (다대다)
  3. 연관관계의 주인 : 양방향 연관관계를 만들 떄 연관 관계의 주인을 정해야 한다.

단방향 연관관계

가장 먼저 이해해야 할 것은 다대일 단방향 관계이다.

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

SQL로 회원에 대한 정보를 가져오고 싶을 때, 회원 중심 혹은 팀 중심의 쿼리를 입력할 수 있다.

  1. 회원 중심

     SELECT *
     FROM MEMBER M
     INNER JOIN TEAM T ON M.TEAM_ID = T.TEAM_ID
  2. 팀 중심

     SELECT *
     FROM TEAM T
     INNER JOIN MEMBER M ON M.TEAM_ID = T.TEAM_ID

자바 객체는 필드를 사용하여 참조를 할 수 있다.

  1. 회원 중심

     class Team {
         Long id;
         ...
     }
     class Member {
         Long id;
         Team team;
         ...
     }
    
     // 참조는 아래처럼
     Member a = new Member();
     a.getTeam.getId();
  2. 팀 중심

     class Team {
         Long id;
         Member id;
         ...
     }
     class Member {
         Long id;
     }
    
     // 참조는 아래처럼
     Team a = new Team();
     a.getMember.getId();

둘은 비슷해 보이지만 큰 차이가 있다.

SQL문은 동일한 JOIN절 하나로 양방향 조인이 가능했다.

객체는 양방향 참조를 하기 위해서는 각 객체에 참조할 대상을 반드시 필드로 넣어줘야 한다.

객체 관계 매핑

JPA를 활용하여 연관관계를 매핑하려면 어노테이션을 사용한다.

  1. Member class

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

     @Entity
     @Getter
     @Setter
     public class Team {
    
         @Id
         @Column (name = "TEAM_ID")
         private Long id;
    
         private String name;
     }

@ManyToOne

  • 다대일 관계를 나타내는 매핑 정보
  • 속성
    • optional (default true) : false로 설정하면 연관된 엔티티가 반드시 있어야 함.
    • fetch : 글로벌 패치 전략 설정
    • cascade : 영속성 전이 기능 사용
    • targetEntity : 연관된 엔티티의 타입 정보 설정 (targetEntity = Member.class 식으로 사용)

@JoinColumn (name="TEAM_ID")

  • 외래 키 매핑 시 사용
  • name 속성은 매핑할 외래키의 이름
  • 어노테이션을 생략해도 외래 키가 생성됨.
    • 생략 시 외래키의 이름이 기본 전략을 활용하여 생성된다.
  • 속성
    • name : 매핑할 외래 키의 이름
    • referencedColumnName : 외래 키가 참조하는 대상 테이블의 컬럼명
    • foreignKey : 외래 키 제약조건 지정 (테이블 생성 시에만 적용됨)
    • unique/nullable/insertable/updateable/columnDefinition/table : @Column의 속성과 같음

연관관계 사용

CRUD 예제를 둘러보며 연관 관계 사용법을 익혀보자.

저장

public void save() {

    Team team = Team.builder()
        .name("team");
        .build();

    em.persist(team);

    Member member1 = Member.builder()
        .username("member1")
        .team(team)
        .build();

    em.persist(member1);

    Member member2 = Member.builder()
        .username("member2");
        .team(team)
        .build();

    em.persist(member2);
}

builder(), build()는 롬복 플러그인으로 생성되는 builder를 의미한다.

위 예제에서 회원 엔티티는 팀 엔티티를 참조하고 저장했다.

em.persist(team); 이 실행되는 순간 team 객체에는 id가 부여된다.

내부적으로는 이를 활용한 쿼리가 생성되어 DB에 저장하게 된다.

조회

연관관계가 있는 엔티티를 조회하는 방법은 크게 2가지다.

  1. 객체 그래프 탐색 (객체 연관관계를 사용한 조회)

     Member member = em.find(Member.class, member1_id);
     Team team = member.getTeam();
  2. 객체지향 쿼리(JPQL) 사용

     private statid void read(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", "team1")
             .getResultList();
     }

JPQL의 쿼리는 일반 쿼리처럼 ID 기반으로 조회하지 않고 엔티티 객체를 활용해서 조회하는 것에 가깝다.

중간에 t.name=:teamName 이라는 문구에서 :teamName과 같이 :로 시작하는 것은 파라미터를 바인딩받는 문법이다.

위의 JPQL은 아래의 쿼리로 변환되어 실행된다.

SELECT M.*
FROM MEMBER M
INNER JOIN TEAM T ON M.TEAM_ID = T.TEAM_ID
WHERE T.NAME = 'team1'

수정

회원1의 소속을 팀1에서 팀2로 변경해보자.

private static void update(EntityManager em) {

    Team team2 = Team.builder()
        .name("team2");
        .build();

    em.persist(team2);

    Member member = em.find(Member.class, member1_id);
    member.setTeam(team2);
}

트랜잭션이 커밋되는 순간 플러시가 일어나고 변경 감지 기능이 동작한다.

그리고 변경사항을 DB에 반영한다.

연관관계 제거

회원1의 소속을 제거하는 예제이다.

private static void delete(EntityManager em) {

    Member member1 = em.find(Member.class, member1_id);
    member1.setTeam(null);
}

team을 null로 설정하면 연관된 엔티티가 삭제된다.

만약 특정 팀을 삭제하고 싶다면 그 팀 소속의 회원 정보에서 특정 팀에 대한 정보를 모두 삭제해야 한다.

(데이터베이스의 제약 조건을 따른다)

양방향 연관관계

지금까지 예제로는 회원이 어느 팀 소속이었는지 쉽게 확인할 수 있었다.

이번엔 팀 소속의 회원이 누구인지를 더 쉽게 구해보는 양방향 연관 예제이다.

  • 회원은 1개의 소속 팀을 가질 수 있다. → Team (단일 객체 필드)
  • 팀에 소속된 회원은 N명이다 → List (복수 객체 필드)
    • List 말고 다른 Collection들도 지원한다.

Team 클래스는 List 필드를 갖지만 데이터베이스의 TEAM 테이블은 member id을 가질 필요가 없다.

DB에서는 MEMBER 테이블의 TEAM_ID 외래키 하나로도 양방향을 구현할 수 있기 때문이다.

양방향 관계를 갖도록 기존의 엔티티를 수정한다.

  1. Member class

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

     @Entity
     @Getter
     @Setter
     public class Team {
    
         @Id
         @Column (name = "TEAM_ID")
         private Long id;
    
         private String name;
    
         @OneToMany (mappedBy = "team")
         private List<Member> members = new ArrayList<>();
     }

Team 클래스에 List 타입 필드를 추가했다.

그리고 @OneToMany 어노테이션으로 매핑 정보를 표시했다.

mappedBy 속성은 양방향 매핑 시 반대쪽 매핑(Member 클래스의 Team team)값을 주면 된다.

추가된 리스트로 인해 소속 회원을 쉽게 조회할 수 있게 되었다.

private void memberList() {
    Team team = em.find(Team.class, team1_id);
    List<Member> members = team.getMembers();
}

연관관계의 주인

실제 쿼리는 TEAM_ID 하나로 양방향 매핑이 가능했지만, 객체 참조의 경우엔 양쪽 다 참조할 필드를 써주어야 한다.

이런 경우, 두 테이블에 서로의 고유 키를 외래 키로 포함할 필요가 없다.

mappedBy는 이런 상황에서 외래 키의 위치를 정의하기 위해 사용한다.

외래 키를 갖는 엔티티가 연관 관계의 주인이 되며, mappedBy는 주인을 명시한다.

  • Team.members에는 mappedBy="team" 속성을 사용한다.
  • Member.team이 주인이다.

양방향 연관관계 저장

mappedBy를 사용하여 양방향 연관관계를 설정 시 주의해야 할 점이 있다.

주인이 아닌 필드는 변경하더라도 저장되지 않는다는 것이다.

private void updateEntity(EntityManager em) {

    Team team = em.find(Team.class, team1_id);

    team.getMembers.add(
        Member.builder()
            .name("member3")
            .build();
    ); // 저장되지 않음
    team.getMembers.add(
        Member.builder()
            .name("member4")
            .build();
    ); // 저장되지 않음

    Member member1 = em.find(Member.class, member1_id);
    member1.setTeam(team2); // 저장됨
}

위의 예시에서 members의 변동 내역은 저장되지 않는다.

물리적인 TEAM 테이블에는 MEMBER 관련 컬럼이 존재하지 않기 때문이다.

반대로 MEMBER 테이블에는 TEAM_ID라는 컬럼이 존재하기 때문에 밑 두 줄의 명령은 DB에 반영된다.

  • 회원의 소속 변경?

    만약 member1의 소속을 바꾸고 싶다면, Member 클래스와 Team 클래스를 모두 바꾸어주는 것이 좋다.

    테이블 관점에서는 Member 클래스의 team 필드만 바꿔주면 되지만 JPA로 불러왔으나 아직 값이 변하지 않은 순수 자바 객체인 경우 문제가 생길 수 있다.

      private void example(EntityManager em) {
    
          Team team2 = em.find(Team.class, team2_id);
    
          // member1의 소속 팀은 현재 1
          Member member1 = em.find(Member.class, member1_id);
    
          // member1의 소속 팀이 2로 변경됨
          member1.setTeam(team2);
    
          for (Member member : team2.getMembers()) {
              // member1이 team2의 회원 목록에 없어 출력되지 않는다.
              System.out.println(member.getUsername());
          }
      }
  • Refactoring

    엔티티 코드를 조금만 변경하여 위와 같은 문제를 해결해보자.

      @Entity
      @Getter
      @Setter
      public class Member {
    
          @Id
          @Column (name = "MEMBER_ID")
          private Long id;
    
          private String username;
    
          @ManyToOne
          @JoinColumn (name = "TEAM_ID")
          @Setter (AccessLevel.NONE)
          private Team team;
    
          public void setTeam(Team team) {
    
              if (this.team != null) {
                  this.team.getMembers().remove(this);
              }
    
              this.team = team;
              team.getMembers().add(this);
          }
      }

    setTeam에서 소속 팀 변경 및 소속 팀의 회원 변경까지 한 번에 처리하면 개발자가 신경 쓸 요소를 줄일 수 있다.

'Java > JPA' 카테고리의 다른 글

JPA 사용 시 주의할 점  (1) 2021.07.22
[JPA] 다양한 연관관계 매핑  (0) 2020.04.07
[JPA] 엔티티 매핑  (0) 2020.02.22
[JPA] EntityManager, 영속성 컨텍스트  (0) 2020.02.17

+ Recent posts