티스토리 뷰

JPA

[JPA] 단방향 연관관계

Jaime.Lee 2022. 6. 28. 10:30

소스 코드는 여기 있습니다. (commit hash: e336ad7)

> git clone https://github.com/lcalmsky/jpa
> git checkout e336ad7

Overview

단방향 연관관계를 설명합니다.

JPA를 설명하기 위해 포스팅하는 글이지만 스프링 내에서 사용하는 경우가 많아 스프링 부트 프로젝트로 구성하였습니다.
프로젝트를 구성하는 내용은 다른 포스팅에도 그동안 많이 작성하였기 때문에 생략하였습니다.

객체 지향 모델링

저번 포스팅에서와 다르게 객체지향적으로 모델링한 모습은 아래와 같습니다.

  • 객체 연관관계

Member 객체가 Team 객체를 참조하기 위해선 teamId를 가지는 게 아니라 Team 객체를 참조하고 있어야 합니다.

  • 테이블 연관관계

코드로 나타내면 다음과 같습니다.

/src/main/java/com/tistory/jaimenote/jpa/domain/entity/Member.java

package com.tistory.jaimenote.jpa.domain.entity;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@ToString
public class Member {

  @Id
  @GeneratedValue
  private Long id;

  private String name;

  @ManyToOne // (1)
  @JoinColumn(name = "team_id") // (1)
  private Team team;

  private Member(String name) { // (2)
    this.name = name;
  }

  public static Member withName(String name) { // (3)
    return new Member(name);
  }

  public void join(Team team) { // (4)
    this.team = team;
  }
}

(1) 객체를 참조한 부분에 테이블의 FK 해당하는 컬럼(team_id)을 매핑해줍니다.
(2) 외부에서 객체를 임의로 생성할 수 없게 private 생성자를 사용하였습니다.
(3) 외부에서 객체를 사용할 때 호출할 static 생성자 입니다.
(4) 외부에서 팀을 세팅해주기 위한 메서드 입니다.

/src/main/java/com/tistory/jaimenote/jpa/domain/entity/Team.java

package com.tistory.jaimenote.jpa.domain.entity;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@ToString
public class Team {

  @Id
  @GeneratedValue
  private Long id;

  private String name;

  private Team(String name) {
    this.name = name;
  }

  public static Team withName(String name) {
    return new Team(name);
  }
}

이렇게 객체간 참조를 애너테이션을 통해 매핑해주게되면, Member 객체 입장에서는 너무 쉽게 Team 객체를 참조할 수 있게 됩니다.

테스트

연관관계 설정만으로 우리가 원하는대로 객체 지향적으로 동작하는지 확인해보겠습니다.

/src/test/java/com/tistory/jaimenote/jpa/infra/repository/MemberRepositoryTest.java

package com.tistory.jaimenote.jpa.infra.repository;

import com.tistory.jaimenote.jpa.domain.entity.Member;
import com.tistory.jaimenote.jpa.domain.entity.Team;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;

@DataJpaTest
class MemberRepositoryTest {

  @Autowired
  MemberRepository memberRepository;
  @Autowired
  TeamRepository teamRepository;

  @BeforeEach
  void setup() {
    Team team = Team.withName("토트넘");
    teamRepository.save(team);
    Member member = Member.withName("손흥민");
    member.join(team);
    memberRepository.save(member);
  }

  @Test
  void findMemberTest() {
    List<Member> members = memberRepository.findAll();
    for (Member member : members) {
      System.out.println(member);
    }
  }
}

저장보단 조회가 목적이므로 테스트 전에 TeamMember를 저장하도록 하였습니다.

테스트가 실행될 때 로그를 확인해보면,

Hibernate: create sequence hibernate_sequence start with 1 increment by 1
Hibernate: 

    create table member (
       id bigint not null,
        name varchar(255),
        team_id bigint,
        primary key (id)
    )
Hibernate: 

    create table team (
       id bigint not null,
        name varchar(255),
        primary key (id)
    )
Hibernate: 

    alter table member 
       add constraint FKcjte2jn9pvo9ud2hyfgwcja0k 
       foreign key (team_id) 
       references team

이렇게 테이블을 생성하는데 member 테이블에 team_id가 동일하게 설정된 것을 확인할 수 있고, 마지막에 FK로 등록하는 것도 확인할 수 있습니다.

그리고나서 @BeforeEach 애노테이션이 붙어있는 setup 메서드가 수행되는데,

Hibernate: 
    insert 
    into
        team
        (name, id) 
    values
        (?, ?)
2022-06-26 05:33:26.259 TRACE 88204 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [VARCHAR] - [토트넘]
2022-06-26 05:33:26.261 TRACE 88204 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [BIGINT] - [1]
Hibernate: 
    insert 
    into
        member
        (name, team_id, id) 
    values
        (?, ?, ?)
2022-06-26 05:33:26.266 TRACE 88204 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [VARCHAR] - [손흥민]
2022-06-26 05:33:26.266 TRACE 88204 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [BIGINT] - [1]
2022-06-26 05:33:26.266 TRACE 88204 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [3] as [BIGINT] - [2]

team, member가 정상적으로 insert 되었습니다.

그리고 마지막으로 조회하는 부분의 로그를 살펴보면,

Hibernate: 
    select
        member0_.id as id1_0_,
        member0_.name as name2_0_,
        member0_.team_id as team_id3_0_ 
    from
        member member0_
Member(id=2, name=손흥민, team=Team(id=1, name=토트넘))

이렇게 정상적으로 조회된 것을 확인할 수 있습니다.

사실 member의 로그를 출력하는 과정에서 team 객체를 당연히 참조하게 되는데요, 이 때 추가 쿼리가 발생해야 하지만 현재는 EntityManager에서 영속 상태로 관리하고 있기 때문에 캐싱된 객체를 바로 반환해주었습니다.

정확한 테스트를 위해서는 아래 처럼 수정해야 합니다.

package com.tistory.jaimenote.jpa.infra.repository;

import com.tistory.jaimenote.jpa.domain.entity.Member;
import com.tistory.jaimenote.jpa.domain.entity.Team;
import java.util.List;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;

@DataJpaTest
class MemberRepositoryTest {

  @Autowired
  MemberRepository memberRepository;
  @Autowired
  TeamRepository teamRepository;
  @PersistenceContext // (1)
  EntityManager entityManager;

  @BeforeEach
  void setup() {
    Team team = Team.withName("토트넘");
    teamRepository.save(team);
    Member member = Member.withName("손흥민");
    member.join(team);
    memberRepository.save(member);
    entityManager.flush(); // (2)
    entityManager.clear(); // (2)
  }

  @Test
  void findMemberTest() {
    List<Member> members = memberRepository.findAll();
    for (Member member : members) {
      System.out.println(member); // (3)
    }
  }
}

(1) 영속성 컨텍스트를 주입합니다.
(2) 영속성 상태를 저장하고 비워줍니다.
(3) member.toString이 호출되면서 team을 출력할 때 team 객체를 참조합니다.

다시 테스트를 실행하여 로그를 확인해보면,

Hibernate: 
    select
        member0_.id as id1_0_,
        member0_.name as name2_0_,
        member0_.team_id as team_id3_0_ 
    from
        member member0_
Hibernate: 
    select
        team0_.id as id1_1_0_,
        team0_.name as name2_1_0_ 
    from
        team team0_ 
    where
        team0_.id=?
2022-06-26 05:43:48.620 TRACE 89017 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [BIGINT] - [1]
Member(id=2, name=손흥민, team=Team(id=1, name=토트넘))

정상적으로 쿼리가 두 번 발생한 것을 확인할 수 있습니다.

하지만 이렇게 단순한 쿼리를 매번 2회씩 수행할 필요는 없겠죠?

다양한 방법이 존재하지만 이 부분은 차차 다뤄보도록 하겠습니다.

JPA를 다룬 다른 포스팅에 이미 여러 차례 소개되긴 했지만, 기본적인 부분에 대해 추가로 포스팅하고 있어서 앞으로도 진도와 상황에 맞춰 해당 내용을 추가할 예정입니다.

정리

단방향 연관관계를 맺을 때는 Entity 클래스 중 하나의 클래스에만 @ManyToOne, @JoinColumn 과 같은 애너테이션을 추가하였습니다.

DB에서는 team을 먼저 찾고, team_id를 이용해 해당 team의 전체 member를 찾아낼 수 있지만, 객체는 Member -> Team 으로 단방향 참조만 일어나기 때문에 Team을 이용해 Member를 찾는 것은 불가능합니다. (MemberTeam을 이용해 Collection을 만들고 비교해가면서 찾는 상황은 배제하고)

이렇게 한 방향으로만 참조할 수 있는 관계를 단방향 연관관계라고 합니다.

댓글