JPA 연관관계 설정 및 편의 메서드 작성하기

junto·2024년 7월 6일
0

spring

목록 보기
24/30
post-thumbnail

History

전체 코드 링크: click

  • 24.08.28: 기존 편의 메서드 수정, 추가 및 테스트 코드 추가

연관관계 편의 메서드는 왜 필요할까?

  • 기본적으로 DB 테이블과 객체지향 관점의 불일치에서 문제가 발생한다.

테이블은 외래키 하나로 두 테이블 간 데이터가 조인되어 사용되지만, 객체에서 관계는 두 개의 참조가 존재하고 외래키를 관리하는 주인 쪽에서 데이터를 관리한다. 이때, 주인이 아닌 객체와 데이터 간에 불일치 문제가 발생할 수 있기 때문에 필요하다.

연관관계 편의 메서드 이점

1. 객체지향적인 코드 작성

  • 외래키 주인에만 값을 설정했을 경우 다른 쪽에서 데이터에 접근할 수 없는 문제가 발생할 수 있다. 특히, JPA 1차 캐시에 있는 데이터가 아직 Flush가 되지 않았거나 단위 테스트 환경에서 문제가 된다.
  • 고객과 공간이 일대다 양방향 관계를 가진 예시에서 연관관계 주인(Space)에서만 관계를 설정한다면, 주인이 아닌 쪽에선 예상과 다른 결과가 나와 일관성을 깨진다.
// Space
public void setBiCustomer(BiCustomer biCustomer) {
  this.biCustomer = biCustomer;
}
@Test
@DisplayName("양방향 관계 설정이 누락되면 객체 일관성 깨짐")
public void createCustomerTest() {
  BiCustomer customer = new BiCustomer("customer1");
  BiSpace space1 = new BiSpace("space1");
  BiSpace space2 = new BiSpace("space2");
  space1.setBiCustomer(customer);
  space2.setBiCustomer(customer);
  
  // customer.getBiSpaces().add(space1);
  // customer.getBiSpaces().add(space2);
  
  assertThat(space1.getBiCustomer().getName()).isEqualTo(customer.getName());
  assertThat(space2.getBiCustomer().getName()).isEqualTo(customer.getName());

  assertThat(customer.getBiSpaces()).hasSize(2); // Error! Expected size: 2 but was: 0 in: []
}

2. 양방향 매핑 시 실수 줄임

  • 위의 예시처럼 연관관계 주인인 공간만 관계를 설정하고, 주인이 아닌 고객에서 관계 설정을 누락하여 객체 양뱡향 관계가 깨질 수 있다. 아래처럼 연관관계의 주인에서 관계를 설정하면서 주인이 아닌 고객에서도 관계를 설정해주면 이러한 실수를 예방할 수 있다.
// 불완전한 연관관계 편의 메서드
public void setBiCustomer(BiCustomer biCustomer) {
  this.biCustomer = biCustomer;
  biCustomer.getBiSpaces().add(this); 
}

3. 객체 생성 복잡도 줄이기

  • 양방향 연관관계에서 생성자로 의존 관계에 있는 객체를 생성할 때 의존성이 없는 객체부터 만들어야하는 순서 의존성이 생기고, 생성자가 복잡해진다.
  • 의존관계가 없는 생성자로 각각의 객체들을 만든 다음 연관관계 설정 메서드로 관계를 설정하면 객체 생성의 복잡도를 낮출 수 있다.
BiCustomer customer = new BiCustomer("customer1");
BiSpace space = new BiSpace("space1");
space.setBiCustomer(customer);

양방향 관계를 모두 설정하지 않으면 제대로 동작하지 않을까?

  • 그렇지 않다. 연관관계 주인에서만 관계를 설정한 경우에도 외래키는 잘 등록된다. JPA는 데이터베이스에 등록된 외래키 정보를 보고, 연관관계를 구성할 수 있다.
@Test
@Transactional
@DisplayName("MySQL에 저장된 데이터를 조회할 때, JPA 연관관계는 자동으로 구성된다.")
public void getSpaces() {
  BiSpace space = spaceRepository.findById(1L)
      .orElseThrow(() -> new RuntimeException("해당 아이디인 공간이 존재하지 않습니다."));
  BiCustomer customer =
      customerRepository
          .findById(1L)
          .orElseThrow(() -> new RuntimeException("해당 아이디인 고객이 존재하지 않습니다."));
  assertThat(space.getName()).isEqualTo("주니의 공간1");
  assertThat(customer.getBiSpaces().size()).isEqualTo(2);
}
  • 헷갈릴 수 있는게, InMemoryDB에서 아래와 같은 코드를 실행하면 실패한다. 왜 실패할까?
@Test
@Transactional
@DisplayName("양방향 관계 설정이 누락되어도 영속성 컨텍스트에 등록되었다면 자유롭게 객채 그래프 탐색 가능")
public void lookUpInPersistContext() {
  BiCustomer customer = new BiCustomer("customer1");
  BiSpace space1 = new BiSpace("space1");
  BiSpace space2 = new BiSpace("space2");
  space1.setBiCustomer(customer);
  space2.setBiCustomer(customer);
  BiCustomer savedCustomer = customerRepository.save(customer);
  spaceRepository.save(space1);
  spaceRepository.save(space2);
  BiCustomer foundCustomer = customerRepository.findById(savedCustomer.getId()).get();
  assertThat(foundCustomer.getName()).isEqualTo(customer.getName());
  assertThat(foundCustomer.getBiSpaces().size()).isEqualTo(2);
}
  • 영속성 컨텍스트의 동작을 떠올려보자. 객체가 생성되고, 연관관계 주인에서만 관계가 설정된 상태로 영속성 컨텍스트에 저장된다. DB에서 조회하는 쿼리(findById)를 실행했지만, 영속성 컨텍스트에 양방향 관계 설정이 누락된 엔티티가 조회되며 테스트가 실패한다. DB에서 조회하기 전에 영속성 컨텍스트를 초기화하면 JPA가 외래키를 보고 만든 객체 관계가 포함된 엔티티가 영속성 컨텍스트에 새로 등록된다.
entityManager.clear();

연관관계 편의 메서드 작성하기

  • 외래키 주인 쪽에만 연관관계 편의 메서드 제공할 수도 있지만, 주인이 아닌 쪽에서도 제공하면 좀 더 유연한 코드 작성이 가능하다!

양방향

1. 일대다, 다대일 (고객 - 공간)

1) 유효하지 않은 참조 제거

  • 앞서 작성한 연관관계 편의메서드를 살펴보면 아래와 같은 문제가 발생한다. 기존에 관계가 존재하는 경우 아래 그림처럼 유효하지 않은 참조를 가지게 된다.
// Space
public void setBiCustomer(BiCustomer biCustomer) {
  this.biCustomer = biCustomer;
  biCustomer.getBiSpaces().add(this); 
}

// Space
public void setBiCustomer(BiCustomer biCustomer) {
  if (this.biCustomer != null) {
    this.biCustomer.getBiSpaces().remove(this); // 기존 참조 제거
  }
  this.biCustomer = biCustomer;
  biCustomer.getBiSpaces().add(this);
}
  • 마찬가지로 연관관계의 주인이 아닌 고객 엔티티에서 연관관계 편의 메서드를 작성하여 코드의 유연성을 높일 수 있다. 이때, 연관관계 주인 메서드를 사용하면 코드가 더욱 간단해진다.
// Consumer
public void addSpace(BiSpace biSpace) {
  this.biSpaces.add(biSpace);
  if (biSpace.getBiCustomer() != this) {
    biSpace.setBiCustomer(this);
  }
}

// Consumer
public void removeSpace(BiSpace biSpace) {
  this.biSpaces.remove(biSpace);
  if (biSpace.getBiCustomer() == this) {
    biSpace.setBiCustomer(null);
  }
}

2) 저장 순서에 따른 업데이트 쿼리 발생

  • 외래 키를 가진 엔티티(연관관계의 주인)을 먼저 저장하면, 등록할 외래키가 없기 때문에 NULL이 저장된다. 후에 연관관계의 주인이 아닌 엔티티를 저장할 때 NULL인 외래키가 해당 기본 키로 업데이트가 된다.
@Test
@Transactional
@Rollback(false)
@DisplayName("Mysql DB에 데이터 삽입")
public void makeDummyData() {
  BiCustomer biCustomer = new BiCustomer("juny");
  BiSpace biSpace1 = new BiSpace("주니의 공간1");
  BiSpace biSpace2 = new BiSpace("주니의 공간2");

  biSpace1.setBiCustomer(biCustomer);
  biSpace2.setBiCustomer(biCustomer);

  spaceRepository.save(biSpace1);
  spaceRepository.save(biSpace2);

  customerRepository.save(biCustomer);
}
// insert into spaces_bi (bi_customer_id,name) values (NULL,'주니의 공간1');
// insert into spaces_bi (bi_customer_id,name) values (NULL,'주니의 공간2');
// insert into customers_bi (name) values ('juny');
// update spaces_bi set bi_customer_id=1,name='주니의 공간1' where id=3;
// update spaces_bi set bi_customer_id=1,name='주니의 공간2' where id=4;
  • 이러한 불필요한 쿼리가 발생되는 것을 막기 위해선 외래 키가 없는 엔티티를 먼저 등록하고, 외래 키가 있는 엔티티를 등록하면 업데이트 쿼리가 발생하지 않는다.
...
customerRepository.save(biCustomer);

spaceRepository.save(biSpace1);
spaceRepository.save(biSpace2);

// insert into customers_bi (name) values ('juny');
// insert into spaces_bi (bi_customer_id,name) values (1,'주니의 공간1');
// insert into spaces_bi (bi_customer_id,name) values (1,'주니의 공간2');

3) Set 사용 시 주의사항

  • Set은 내부적으로 중복을 허용하지 않기 때문에 equals()와 hashcode()를 이용하여 객체가 이미 존재하는지 확인한다. Set의 경우 연관된 엔티티를 지연로딩으로 설정했더라도, equals에서 연관된 엔티티 필드를 비교한다면, 이때 연관 엔티티를 모두 조회하게 된다.
@OneToMany(mappedBy = "biCustomer")
private Set<BiSpace> biSpaces = new HashSet<>();

// OneToMany 연관관계 편의 메서드, 고객 - 공간 [양방향]
public void addSpace(BiSpace biSpace) {
  this.biSpaces.add(biSpace);
  if (biSpace.getBiCustomer() != this) {
    biSpace.setBiCustomer(this);
  }
}

4) cascade 조건이 있는 경우

  • cascade 설정은 엔티티 간의 연관 관계에서 특정 작업(ex: 저장, 삭제 등)을 자동으로 전파하는 역할을 한다. 해당 설정이 있다고해서 연관관계 편의 메서드 내용이 달라지지 않는다.
@OneToMany(mappedBy = "biCustomer", cascade = CascadeType.ALL, orphanRemoval = true)
private List<BiSpace> biSpaces = new ArrayList<>();
  • 클라이언트 코드에서 부모 엔티티만 저장하면, 자동으로 자식 엔티티에 전파한다. CascadeType.ALL, orphanRemoval = true 옵션을 사용하면 부모 엔티티에 종속적으로 되어 부모 엔티티가 만들어질 때 연관된 자식 엔티티도 함께 만들어지며, 삭제도 마찬가지다. 마치 식별자 있는 값 컬렉션처럼 동작한다.
@Test
@Transactional
@Rollback(false)
@DisplayName("Mysql DB에 데이터 삽입")
public void makeDummyData() {
  BiCustomer biCustomer = new BiCustomer("juny");
  BiSpace biSpace1 = new BiSpace("주니의 공간1");
  BiSpace biSpace2 = new BiSpace("주니의 공간2");

  biSpace1.setBiCustomer(biCustomer);
  biSpace2.setBiCustomer(biCustomer);

  customerRepository.save(biCustomer);
}
  • casecade 설정이 있다면 자식 엔티티에 대한 insert 쿼리도 발생하지만, cascade 설정이 없다면 부모 엔티티 insert into customers_bi (name) values ('juny'); 쿼리만 실행된다.

3. 다대다

1) JPA가 만들어주는 @ManyToMany를 사용한 경우 (공간 - 서브카테고리)

  • 중간 엔티티를 JPA가 만들어주는 경우를 말한다.
// Space
public void addSubCategory(SubCategory subCategory) {
  this.subCategories.add(subCategory);
  subCategory.addSpace(this);
}

// Space
public void removeSubCategory(SubCategory subCategory) {
  subCategory.removeSpace(this);
}

// SubCategory
public void addSpace(Space space) {
  this.spaces.add(space);
  space.addSubCategory(this);
}

// SubCategory
public void removeSpace(Space space) {
  space.removeSubCategory(this);
}

2) @OneToMany, @ManyToOne을 사용한 경우 (공간 - 옵션)

  • 중간 엔티티를 직접 만들어 사용한 경우를 말한다. 중간 엔티티를 삭제할 때 equals() 메서드가 제대로 정의되어 있지 않다면, 동일한 SpaceOption을 찾기 어려울 수 있다. 직접 iterator로 순회하며 해당 객체를 찾아 지울 수도 있지만, remove 메서드를 이용하면 코드가 더욱 간단해진다. 따라서 연관관계 편의 메서드를 작성하기 전에 중간 엔티티의 equals() 함수를 작성한다.
public class SpaceOptionId {

  @Column(name = "space_id")
  private Long spaceId;

  @Column(name = "option_id")
  private Long optionId;

  @Override
  public boolean equals(Object o) {
    if (this == o) {
      return true;
    }
    if (o == null || getClass() != o.getClass()) {
      return false;
    }
    SpaceOptionId that = (SpaceOptionId) o;
    return Objects.equals(spaceId, that.spaceId) &&
      Objects.equals(optionId, that.optionId);
  }

  @Override
  public int hashCode() {
    return Objects.hash(spaceId, optionId);
  }
}
// 공간
public void addOption(Option option) {
  SpaceOption spaceOption = new SpaceOption(this, option);
  spaceOptions.add(spaceOption);
  option.getSpaceOptions().add(spaceOption);
}

// 공간
public void removeOption(Option option) {
  SpaceOption spaceOption = new SpaceOption(this, option);

  option.getSpaceOptions().remove(spaceOption);
  spaceOption.setSpace(null);
  spaceOption.setOption(null);
}

// 옵션
public void addSpace(Space space) {
  SpaceOption spaceOption = new SpaceOption(space, this);
  spaceOptions.add(spaceOption);
  space.getSpaceOptions().add(spaceOption);
}

// 옵션
public void removeSpace(Space space) {
  SpaceOption spaceOption = new SpaceOption(space, this);
  
  space.getSpaceOptions().remove(spaceOption);
  spaceOption.setSpace(null);
  spaceOption.setOption(null);
}

4. 일대일 (유저 - 호스트)

  • 연관관계를 설정하거나 제거할 때 모두 set함수를 이용한다. null이 들어올 수도 있으므로 NPE를 방지하기 위해 null로 설정된 경우 반대쪽 연관관계를 설정하지 않도록 한다.
// 유저
public void setHost(Host host) {
  this.host = host;
  if (host != null) {
    host.setUser(this);
  }
}

// 호스트
public void setUser(User user) {
  this.user = user;
  if (user != null) {
    user.setHost(this);
  }
}

단방향

  • 단방향의 경우 양방향보다 연관관계 편의 메서드가 간단해진다. 간단한 관계인 경우 연관관계 편의 메서드를 제공하지 않아도 되지만, 형식상 일관되게 제공하는 것이 좋아보인다.

1. 일대다 (고객 - 공간)

// 고객
public void addSpace(UniSpace uniSpace) {
  this.uniSpaces.add(uniSpace);
}

추가 업데이트 쿼리 발생 주의

  • 일대다 단방향 관계는 사용하지 않는 것이 권장된다. 이유는 외래키가 없는 엔티티가 먼저 등록되면서 외래키가 있는 엔티티에 업데이트 쿼리가 발생하기 때문이다.
@Test
@Transactional
@Rollback(false)
public void createCustomerTest() {
  UniCustomer uniCustomer = new UniCustomer("juny");
  UniSpace uniSpace1 = new UniSpace("주니의 공간1");
  UniSpace uniSpace2 = new UniSpace("주니의 공간1");

  spaceRepository.save(uniSpace1);
  spaceRepository.save(uniSpace2);

  uniCustomer.addSpace(uniSpace1);
  uniCustomer.addSpace(uniSpace2);

  customerRepository.save(uniCustomer);
  }
}
// insert into customers (name) values ('juny');
// update spaces set customer_id=2 where id=1;
// update spaces set customer_id=2 where id=2;

2. 다대일 (공간 - 부동산)

// 공간
public void setProperty(Property property) {
  this.property = property;
}

3. 다대다

1) JPA가 만들어주는 @ManyToMany를 사용한 경우 (공간 - 서브카테고리)

  • 중간 엔티티를 JPA가 만들어주는 경우를 말한다.
// 공간
public void addSubCategory(SubCategory subCategory) {
  this.subCategories.add(subCategory);
}

// 공간
public void removeSubCategory(SubCategory subCategory) {
  this.subCategories.remove(subCategory);
}

2) @OneToMany, @ManyToOne을 사용한 경우 (공간 - 옵션)

// 공간
public void addOption(Option option) {
  SpaceOption spaceOption = new SpaceOption(this, option);
  spaceOptions.add(spaceOption);
}

// 공간
public void removeOption(Option option) {
  SpaceOption spaceOption = new SpaceOption(this, option);
  
  spaceOptions.remove(spaceOption);
  spaceOption.setSpace(null);
  spaceOption.setOption(null);
}

4. 일대일 (유저 - 호스트)

// 유저
public void setHost(Host host) {
  this.host = host;
}

참고자료

  • 자바 ORM 표준 JPA 프로그래밍 - 김영한
profile
꾸준하게

0개의 댓글