연관관계 매핑 기초


객체와 테이블 연관관계의 차이를 이해한다. 객체의 참조와 테이블의 외래 키를 매핑하는 방법을 이해한다.

용어 이해

  • 방향: 단방향, 양방향
  • 다중성: 다대일, 일대다, 일대일, 다대다
  • 연관관계의 주인: 객체 양방향 연관관계는 관리 주인이 필요

연관관계가 필요한 이유


예제 시나리오

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

객체를 테이블에 맞추어 모델링

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    @Column(name = "USERNAME")
    private String name;
    @Column(name = "TEAM_ID")
    private Long teamId;

}
@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;
    private String name;

}
// 팀 저장
Team team = new Team();
team.setName("TeamA");
em.persist(team);
// 회원 저장
Member member = new Member();
member.setName("member1");
member.setTeamId(team.getId());
em.persist(member);
// 조회
Member findMember = em.find(Member.class, member.getId());
// 연관관계가 없음
Team findTeam = em.find(Team.class, team.getId());

객체를 테이블에 맞추어 데이터 중심으로 모델링하면, 협력 관계를 만들 수 없다. 테이블은 외래 키로 조인을 사용해서 연관된 테이블을 찾는 반면, 객체는 참조를 사용해서 연관된 객체를 찾는다. 테이블과 객체 사이에는 이런 큰 간격이 존재한다.

단방향 연관관계


객체 지향 모델링

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    @Column(name = "USERNAME")
    private String name;
    private int age;
    // @Column(name = "TEAM_ID")
    // private Long teamId;
    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;

}

//팀 저장
Team team = new Team();
team.setName("TeamA");
em.persist(team);
//회원 저장
Member member = new Member();
member.setName("member1");
member.setTeam(team); //단방향 연관관계 설정, 참조 저장
em.persist(member);
//조회
Member findMember = em.find(Member.class, member.getId());
//참조를 사용해서 연관관계 조회
Team findTeam = findMember.getTeam();
// 새로운 팀B
Team teamB = new Team();
teamB.setName("TeamB");
em.persist(teamB);
// 회원1에 새로운 팀B 설정
member.setTeam(teamB);

양방향 연관관계와 연관관계의 주인 1 - 기본


양방향 매핑

객체의 양방향 관계

객체의 양방향 관계는 사실 양방향 관계가 아니라 서로 다른 단방향 관계 2개다. 객체를 양방향으로 참조하려면 단방향 연관관계 2개를 만들어야 한다.

  • 회원 → 팀 member.getTeam()
  • 팀 → 회원 team.getMembers()

테이블의 양방향 관계

테이블은 외래 키 하나로 두 테이블의 연관관계를 관리한다. MEMBER.TEAM_ID 외래 키 하나로 양방향 연관관계를 가진다.

  • 회원 ← → 팀
    • SELECT * FROM MEMBER M JOIN TEAM T ON M.TEAM_ID = T.TEAM_ID
    • SELECT * FROM TEAM T JOIN MEMBER M ON T.TEAM_ID = M.TEAM_ID

둘 중 하나로 외래 키를 관리해야 함

Member 의 Team 이나 Team 의 members 중에 어떤 값이 바뀔 때 MEMBER.TEAM_ID 를 업데이트 해야 하는가? 둘 중에 하나가 외래 키를 관리하도록 정해주는 것이 연관관계의 주인이다.

연관관계의 주인

객체의 두 관계 중 하나를 연관관계의 주인으로 지정한다. 연관관계의 주인만이 외래 키를 관리할 수 있으며, 주인이 아닌쪽은 읽기만 가능하다.

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

그러면 누구를 주인으로 정해야해?

외래 키가 있는 곳을 주인으로 정해야한다. DB 에서 ManyToOne 관계일 경우, 항상 FK 를 갖는 쪽은 Many 가 되기 때문에, Many 쪽이 연관관계의 주인이 되는 것이 좋다. 위 예시에서는 Member 의 Team 이 연관관계의 주인이 되는 것이다.

양방향 매핑 시 가장 많이 하는 실수

Team team = new Team();
team.setName("TeamA");
em.persist(team);
Member member = new Member();
member.setName("member1");
// 역방향(주인이 아닌 방향)만 연관관계 설정
team.getMembers().add(member);
em.persist(member);

위 코드 실행 시 연관관계의 주인에 값을 입력하지 않았기 때문에 memberteamnull 이 된다.

Team team = new Team();
team.setName("TeamA");
em.persist(team);
Member member = new Member();
member.setName("member1");
member.setTeam(team); // 연관관계의 주인에만 값 설정
em.persist(member);
team.getMembers(); // null

연관관계의 주인에만 값을 넣어준다면, JPA 에 의해 mappedBy 쪽에서도 값을 가져올 수 있지만, 2가지 문제점이 있을 수 있다.

  1. em.flush() 이후 em.clear() 를 한 뒤 DB 에서 값을 가져온다면 문제가 없지만, 영속성 컨텍스트에 Team 과 Member 를 저장한 뒤 사용할 경우, team.getMembers() 를 호출한다면 null 값이 반환된다.
  2. 테스트 케이스는 대부분 JPA 없이 순수 자바 코드로 이루어지는데, 이럴 경우 null 이 나올 수 있다.

양방향 연관관계 주의

Team team = new Team();
team.setName("TeamA");
em.persist(team);
Member member = new Member();
member.setName("member1");
member.setTeam(team); // 연관관계의 주인에 값 설정
em.persist(member);
team.getMembers().add(member); // 역방향에도 값 설정
  1. 위에서 언급한 2가지 문제점과 더불어, 사용되는 코드가 객체지향적이지 못하기 때문에 가급적이면 위 코드 처럼 양쪽에 값을 다 넣어주는 것이 좋다.
public void changeTeam(Team team) {
    this.team = team;
    team.getMembers().add(this);
}
  1. 또한, 연관관계 설정 시 양쪽에 모두 값을 설정할 수 있도록 연관관계 편의 메서드를 생성하자.
  2. 양방향 매핑 시 toString() 와 JSON 생성 라이브러리 사용 시 memberteam 을 참조하고 teammember 를 참조하면서 무한 루프가 발생할 수 있기 때문에 가급적이면 toString() 을 사용하지 말고 Controller 에는 엔티티를 절대 반환하지 않도록 하자. Controller 가 DTO 를 반환하는 이유가 여기에 있다.

양방향 매핑 정리

  • 단방향 매핑만으로도 이미 연관관계 매핑은 완료된 것이다.
  • 양방향 매핑은 반대 방향으로 조회(객체 탐색 그래프) 기능이 추가된 것 뿐이다.
  • JPQL 에서 역방향으로 탐색할 일이 많다.
  • 단방향 매핑을 잘 하고 양방향은 필요할 때 추가해도 된다. 테이블에 영향을 주지 않는다.

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

  • 연관관계의 주인은 외래 키의 위치를 기준으로 정해야한다.

실전 예제 2 - 연관관계 매핑 시작


테이블 구조

테이블 구조는 이전과 같다.

객체 구조

참조를 사용하도록 변경한다.

연관관계 매핑 시 고려사항 3가지


다중성

  • @ManyToOne 다대일
  • @OneToMany 일대다
  • @OneToOne 일대일
  • @ManyToMany 다대다

단방향, 양방향

테이블

  • 외래 키 하나로 양쪽 조인 가능
  • 사실 방향이라는 개념이 없음 객체
  • 참조용 필드가 있는 쪽으로만 참조 가능
  • 한쪽만 참조하면 단방향
  • 양쪽이 서로 참조하면 양방향

연관관계 주인

  • 테이블은 외래 키 하나로 두 테이블이 연관관계를 맺음
  • 객체 양방향 관계는 참조가 2군데 있음
  • 둘 중 테이블의 외래 키를 관리할 곳을 지정해야 함
  • 즉, 연관관계의 주인은 외래키를 관리하는 참조이고, 주인의 반대편은 단순 조회만 가능

다대일 [N:1]


다대일 단방향

  • 가장 많이 사용하는 연관관계
  • 다대일의 반대는 일대다

다대일 양방향

  • 외래 키가 있는 쪽이 연관관계의 주인
  • 양쪽을 서로 참조하도록 개발

일대다 [1:N]


일대다 단방향

결론부터 말하자면 이런 구조는 거의 가져가지 않는다. 일대다 단뱡향은 일대다에서 일이 연관관계의 주인이 된다. 하지만, 테이블 일대다 관계에서는 항상 다쪽에 외래 키가 존재한다. 이 경우, 객체와 테이블의 차이 때문에 반대편 테이블의 외래 키를 관리하는 특이한 구조가 된다.

Member member = new Member();
member.setUserName("member1");
em.persist(member);
Team team = new Team();
team.setName("teamA");
team.getMembers().add(member);
em.persist(team);

해당 코드를 실행했을 경우, 테이블 입장에서는 MEMBER 가 TEAM_ID 를 가지고 있기 때문에, TEAM 을 추가하기 위해 MEMBER 테이블에 UPDATE 쿼리가 추가로 나가게 된다.

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

또한, 위처럼 일대다 관계를 갖는 컬럼에 @JoinColumn 을 꼭 사용해야 한다. 그렇지 않으면 TEAM_ID 와 MEMBER_ID 를 가지고 있는 중간 테이블을 하나 추가하여 조인 테이블 방식을 사용하여 외래 키를 관리하게 된다.

일대다 단방향 정리

일대다 단방향 매핑의 단점

  • 엔티티가 관리하는 외래 키가 다른 테이블에 있음
  • 연관관계 관리를 위해 추가로 UPDATE SQL 실행 때문에, 객체지향적으로 손해를 보더라도 일대다 단방향 매핑보다는 다대일 양방향 구조를 사용하는 관계로 만들어주는 것이 더 좋은 설계일 수 있다.

일대다 양방향

이런 매핑은 공식적으로 존재하지 않지만 만들 수는 있다. 이런 경우, @JoinColumn(insertable=false, updatable=false) 로 읽기 전용 필드를 사용해서 양방향 처럼 사용할 수 있다. 이런 구조를 사용하기 보다는 다대일 양방향 구조를 사용하는 것이 좋다.

일대일 [1:1]


일대일 관계

일대일 관계는 그 반대도 일대일이기 때문에 대칭 관계라고 생각해볼 수 있다. 때문에, 주 테이블이나 대상 테이블 중에 한 곳에서 외래 키를 선택할 수 있다. 테이블 관점에서 일대일 관계를 구현하기 위해선 외래 키에 UNI 제약조건이 추가되어야 한다.

주 테이블에 외래 키 단방향

@ManyToOne 단방향 매핑과 유사하다는 것을 알 수 있다.

주 테이블에 외래 키 양방향

다대일 양방향 매핑 처럼 외래 키가 있는 곳이 연관관계의 주인이 되며, 반대편은 mappedBy 를 적용하면 된다.

대상 테이블에 외래 키 단방향

단방향 관계는 JPA 에서 지원하지 않지만, 양방향 관계는 지원한다. MEMBER 테이블을 많이 SELECT 한다는 가정하에, MEMBER 가 LOCKER 를 가지고 있는 것이 더 효율적이다.

대상 테이블에 외래 키 양방향

사실 일대일 주 테이블에 외래 키 양방향과 매핑 방법은 같다.

일대일 정리

주 테이블에 외래 키를 위치시키면, 주 객체가 대상 객체의 참조를 가지는 것 처럼 주 테이블에 외래 키를 두고 대상 테이블을 찾을 수 있다. 이는 객체지향 개발자들이 선호하는 방법이며, JPA 매핑이 편리하다는 점이 있다. 주 테이블만 조회해도 대상 테이블에 데이터가 있는지 확인이 가능하다는 장점이 있지만, 값이 없으면 외래 키에 null 을 허용한다는 단점 역시 존재한다. 대상 테이블에 외래 키를 위치시키면, 주 테이블과 대상 테이블을 일대일에서 일대다 관계로 변경할 때 다쪽이 되는 테이블에 이미 외래 키가 존재하기 때문에, 테이블 구조를 유지하고 변경할 수 있다는 장점이 존재한다. 하지만, 프록시 기능의 한계로 지연 로딩으로 설정해도 항상 즉시 로딩되는 단점 역시 존재한다.

다대다 [N:M]


관계형 데이터베이스는 정규화된 테이블 2개로 다대다 관계를 표현할 수 없다. 이를 구현하기 위해 연결 테이블을 추가해서 일대다, 다대일 관계로 풀어내야 한다. 객체는 컬렉션을 사용해서 객체 2개로 다대다 관계가 가능하다. @ManyToMany@JoinTable 로 연결 테이블을 지정해서 사용해야 한다.

다대다 매핑의 한계

편리해 보이지만 실무에서 사용하지 않는다. 연결 테이블이 단순히 연결만 하고 끝나지 않고, 주문시간, 수량 같은 데이터가 들어올 수 있다.

다대다 한계 극복

연결 테이블을 엔티티로 승격시켜 연결 테이블용 엔티티를 추가한다. @ManyToMany@OneToMany @ManyToOne

실전 예제 3 - 다양한 연관간계 매핑


N:M 관계는 1:N, N:1 로 변경

테이블의 N:M 관계는 중간 테이블을 이용해서 1:N, N:1 로 변경하는 것이 좋다. 실전에서는 중간 테이블이 단순하지 않기 때문에 N:M 은 사용하지 않는다.

@JoinColumn

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

  • name 매핑할 외래 키 이름
  • referencedColumnName 외래 키가 참조하는 대상 테이블의 컬럼명
  • foreignKey(DDL) 외래 키 제약조건을 직접 지정할 수 있음
  • unique nullable insertable updatable columnDefinition table @Column 의 속성과 같음

@ManyToOne

다대일 관계 매핑 시 사용한다.

  • optional false 로 설정 시 연관된 엔티티가 항상 있어야 함
  • fetch 글로벌 페치 전략을 설정
  • cascade 영속성 전이 기능을 사용
  • targetEntity 연관된 엔티티 타입 정보를 설정. 제네릭으로 타입 정보를 알 수 있기 때문에 거의 사용하지 않음

@OneToMany

다대일 관계 매핑 시 사용한다.

  • mappedBy 연관관계의 주인 필드를 선택
  • fetch 글로벌 페치 전략을 설정
  • cascade 영속성 전이 기능을 사용
  • targetEntity 연관된 엔티티 타입 정보를 설정. 제네릭으로 타입 정보를 알 수 있기 때문에 거의 사용하지 않음

상속관계 매핑


객체는 상속관계가 있지만 RDB 에는 상속관계가 없다. RDB 의 슈퍼타입 서브타입 관계라는 모델링 기법이 객체의 상속과 유사하다. 즉, 상속관계 매핑이란, 객체의 상속구조와 RDB 의 슈퍼타입 서브타입 관계를 매핑하는 것을 의미한다. 슈퍼타입 서브타입 논리 모델을 실제 물리 모델로 구현하는 법은 총 3가지가 존재한다.

  1. 조인 전략
  2. 단일 테이블 전략
  3. 구현 클래스마다 테이블 전략

조인 전략

@Inheritance*(*strategy = InheritanceType.JOINED*)* @DiscriminatorColumn(name = "DTYPE") 기본값은 “DTYPE” 이며, 자식 테이블을 구분하기 위해 DTYPE 이 추가되는 것이 좋다. @DiscriminatorValue("XXX") 기본값은 “클래스명” 이며, 자식 테이블의 테이블명을 명시해주고 싶을 때 사용할 수 있다. 조인 전략의 장점으로는, 테이블이 정규화 되어있고, 외래 키 참조 무결성 제약조건을 활용할 수 있으며, 효율적으로 저장공간을 사용할 수 있다는 점이 있다. 반면, 단점으로는, 조회 시 조인을 많이 사용되어 성능이 저하되고, 데이터 저장 시 INSERT 쿼리가 2번 호출되는 점이 있다. 객체의 상속과 가장 유사하기 때문에 기본적으론, 조인 전략이 정석이라고 생각하는 것이 좋다.

단일 테이블 전략

@Inheritance*(*strategy = InheritanceType.SINGLE_TABLE*)* 단일 테이블 전략은 모든 자식 테이블 요소를 한 테이블에서 관리하기 때문에 자식 테이블을 구분하기 위하여DTYPE 를 자동으로 추가해준다. 단일 테이블 전략의 장점으로는, 조인이 필요 없기에 일반적으로 조회가 빠르다는 점이 있다. 반면, 단점으로는, 자식 엔티티가 매핑한 컬럼은 모두 null 을 허용해야하며, 단일 테이블에 모든 것을 저장하기 때문에 상황에 따라 조회 성능이 오히려 느려질 수 있다는 점이 있다.

구현 클래스마다 테이블 전략

@Inheritance*(*strategy = InheritanceType.TABLE_PER_CLASS*)* 부모 테이블이 없기 때문에 Item 타입으로 조회 시 모든 자식 테이블을 뒤져서 결과를 찾는다. 이 전략은 사용하지 않는 것이 좋다.

@MappedSuperclass - 매핑 정보 상속


@MappedSuperclass
public abstract class BaseEntity {
    private String createdBy;
    private LocalDateTime createdDate;
    private String lastModifiedBy;
    private LocalDateTime lastModifiedDate;
}

공통 매핑 정보가 필요할 때 사용한다. 즉, 속성만 상속받아서 쓰고 싶을 때 사용한다. 테이블과 관계 없이 단순히 엔티티가 공통으로 사용하는 매핑 정보를 모으는 역할이다. 주로 등록일, 수정일, 등록자, 수정자 같은 전체 엔티티에서 공통으로 적용하는 정보를 모을 때 사용한다. 직접 생성해서 사용할 일이 거의 없기 때문에 추상 클래스를 권장한다.

실전 예제 4 - 상속관계 매핑


도메인 모델