OneToOne에 대해서

OneToOne에 대해서

JPA 사용 시 만악의 근원, @OneToOne에 대한 고찰

 

JPA를 사용하면서 @OneToOne에 대해 느낀바로는 만악의 근원에 가깝다는 것이다.

여타 매핑과 차별되는 점은 (@ManyToOne, @ManyToMany, @OneToMany)

이 녀석은 기본적으로 EAGER로 동작한다는 것이다.

@ManyToOne도 기본적으로 EAGER로 동작하기는 하는데 내부적인 동작에 큰 차이가 있다.

아래는 @OneToOne의 전체적인 명세이다.

 

Specifies a single-valued association to another entity that has one-to-one multiplicity. It is not normally necessary to specify the associated target entity explicitly since it can usually be inferred from the type of the object being referenced. If the relationship is bidirectional, the non-owning side must use the mappedBy element of the OneToOne annotation to specify the relationship field or property of the owning side. The OneToOne annotation may be used within an embeddable class to specify a relationship from the embeddable class to an entity class. If the relationship is bidirectional and the entity containing the embeddable class is on the owning side of the relationship, the non-owning side must use the mappedBy element of the OneToOne annotation to specify the relationship field or property of the embeddable class. The dot (“.”) notation syntax must be used in the mappedBy element to indicate the relationship attribute within the embedded attribute. The value of each identifier used with the dot notation is the name of the respective embedded field or property.

 

Example 1: One-to-one association that maps a foreign key column
  
      // On Customer class:
  
      @OneToOne(optional=false)
      @JoinColumn(
      	name="CUSTRECID", unique=true, nullable=false, updatable=false)
      public CustomerRecord getCustomerRecord() { return customerRecord; }
  
      // On CustomerRecord class:
  
      @OneToOne(optional=false, mappedBy="customerRecord")
      public Customer getCustomer() { return customer; }
  
  
      Example 2: One-to-one association that assumes both the source and target share the same primary key values. 
  
      // On Employee class:
  
      @Entity
      public class Employee {
      	@Id Integer id;
      
      	@OneToOne @MapsId
      	EmployeeInfo info;
      	...
      }
  
      // On EmployeeInfo class:
  
      @Entity
      public class EmployeeInfo {
      	@Id Integer id;
      	...
      }
  
  
      Example 3: One-to-one association from an embeddable class to another entity.
  
      @Entity
      public class Employee {
         @Id int id;
         @Embedded LocationDetails location;
         ...
      }
  
      @Embeddable
      public class LocationDetails {
         int officeNumber;
         @OneToOne ParkingSpot parkingSpot;
         ...
      }
  
      @Entity
      public class ParkingSpot {
         @Id int id;
         String garage;
         @OneToOne(mappedBy="location.parkingSpot") Employee assignedTo;
          ... 
      } 

 

@OneToOne인데 양방향 관계인 경우 관계의 주격이 아닌 측에서 mappedBy를 선언하라고 돼있다.

여타 다른 매핑과 크게 다를 게 없어 보이는 설명과 예제들이다.

 

@Target({METHOD, FIELD}) 
@Retention(RUNTIME)

public @interface OneToOne {

    Class targetEntity() default void.class;

    CascadeType[] cascade() default {};

    FetchType fetch() default EAGER;

    boolean optional() default true;

    String mappedBy() default "";

    boolean orphanRemoval() default false;
}


public enum CascadeType { 

    / Cascade all operations */
    ALL, 

    / Cascade persist operation */
    PERSIST, 

    / Cascade merge operation */
    MERGE, 

    / Cascade remove operation */
    REMOVE,

    / Cascade refresh operation */
    REFRESH,

    /
     * Cascade detach operation
     *
     * @since 2.0
     * 
     */   
    DETACH
}

 


 

Class targetEntity() default void.class;

(Optional) The entity class that is the target of the association.
Defaults to the type of the field or property that stores the association

 

기본값이 필드의 클래스이므로 굳이 적지 않아도 된다.

많이 보이는데 정확히 뭐 하는 건지 몰랐었어서 헷갈렸는데 알고 보니 엔간하면 그냥 생략하는 게 좋은 것 같다.

 

@OneToOne(targetEntity = Test.class)
@JoinColumn(name = "TESTSEQ", referencedColumnName = "TESTSEQ")
private Test test;


@OneToOne
@JoinColumn(name = "TESTSEQ")
private Test test;

 

위와 아래는 같은 코드이다.

위의 코드는 이미 내부적으로 Default로 선언돼있는 코드를 굳이 또 선언하였다.

아래 코드는 이미 선언돼있는 코드들을 생략하여 작성한 형태이다.

 


 

CascadeType[] cascade() default {};

(Optional) The operations that must be cascaded to the target of the association.
By default no operations are cascaded.

 

관계가 맺어져 있는 엔티티에 변경사항이 생길 경우 같이 변경될지의 여부다.

기본값은 사용하지 않음이며, 잘 사용하면 매우 편리한 기능이 될 수 있다.

하지만 제대로 알지 못하고 사용하면 데이터가 통째로 꼬여버리거나,

드랍되거나 하는 대참사가 발생할 수 있으므로 사전에 충분한 학습을 하고 사용해야 한다.

 


 

String mappedBy() default "";

(Optional) The field that owns the relationship.
This element is only specified on the inverse (non-owning) side of the association.

 

양방향 매핑 시 연관관계의 주인을 명시적으로 선언해준다.

이 옵션을 사용해 관계를 엮어 줄 경우 주격이 아니더라도 엔티티 그래프 탐색이 가능해진다.

다만 CUD(Create, Update, Delete)에 대해서는 주격 엔티티에서만 정상 동작하므로 주의가 필요하다.

주격이 아닌 엔티티에서 선언하면 된다.

 


 

boolean orphanRemoval() default false;
(Optional) Whether to apply the remove operation to entities that have been removed
from the relationship and to cascade the remove operation to those entities.

 

보통 1:N 관계 테이블 설정할 때 옵션을 추가해준다.

PK(JoinColumn) 값이 null로 변한 자식은 고아 객체라고 한다.

부모 객체와의 연결점을 잃어버렸다는 뜻이다.

orphanRemoval 옵션은 바로 이 고아 객체를 자동으로 삭제해주는 역할을 한다.

보통 자식 엔티티의 변경이 있다면 insert > update > update > delete 순으로 이어지는데

orphanRemoval 옵션을 적용하면 insert > update > delete 순으로 변경된다.

변경된 자식을 먼저 insert 하고, 기존의 자식을 null로 update 한다.

그리고 기존 null 처리된 자식을 delete 한다.

 


 

FetchType fetch() default EAGER;

(Optional) Whether the association should be lazily loaded or must be eagerly fetched. 
The EAGER strategy is a requirement on the persistence provider runtime that the associated entity must be eagerly fetched. 
The LAZY strategy is a hint to the persistence provider runtime.



boolean optional() default true;

(Optional) Whether the association is optional. 
If set to false then a non-null relationship must always exist.

 

@OneToOne 사용을 지양해야 하는 가장 큰 원인 두 가지다.

 

public class A {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @OneToOne
    @JoinColumn(name = "bid")
    private B b;
}
	

public class B {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @OneToOne
    @JoinColumn(name = "cid")
    private C c;
}

public class C {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
}

 

일대일 단방향이다.

A 엔티티를 findById를 통해 조회하면

A+B가 날아가고, 이후 B+C가 날아간다.

EAGER로 동작하며 동시에 N+1문제가 발생한다.

 

원인은 무엇이고 해결은 어떻게 해야 할까?

 

optional은 기본적으로 true로 설정돼있다.

이 말인즉슨, 관계를 맺은 엔티티가 nullable 하다는 것인데,

이렇게 되면 하이버네이트는 매핑된 엔티티가 null이었을 경우의 수를 배제할 수 없기 때문에 프록시를 채워줄 수 없다.

그래서 optional=true, @OneToOne 매핑이 돼있는 경우 (=모두 기본값인 경우)

하이버네이트는 위의 가능성으로 인해 일단 쿼리를 날려보지 않고서는(EAGER) null을 채워줘야 할지,

프록시를 채워줘야 할지(LAZY) 알 수 없기 때문에 무조건 쿼리를 한번 날려보게 되며,

이러한 내부 동작 원리로 인해 무조건적으로 EAGER로 동작하고 N+1문제가 발생하는 것이다.

 

내부적으로 이런 동작을 하고 세부적으로 파고들면 훨씬 더 복잡하다.

더 파보려다가 머리가 지끈지끈거려서 관뒀다.

아무튼 일반적으로 JPA를 사용하는 모든 개발자가 이런 내부 동작을 자세히 알기 어렵고

이로 인해 N+1문제가 자주 발생하기 때문에 가급적 @OneToOne 사용을 자제해야 한다.

 

해결방법은 간단하다.

optional=false로 지정하여 not null. 즉, 엔티티가 무조건 있음을 보장해준다면

하이버네이트는 null일 수도 있는 경우의 수를 완벽히 배제하여 프록시를 채워줄 수 있게 되기 때문에 LAZY설정이 동작하게 된다.

이러면 즉시로딩이 아닌 지연로딩이 되므로 N+1문제 또한 해결할 수 있다.

 

@OneToOne(fetch = FetchType.LAZY, optional = false) // Not Null

 

하지만 모든 걸 다 떠나서 애초에 @OneToOne 관계를 맺는다는 것은 애시당초 DB설계를 잘못했을 가능성이 매우 높다.

굳이 테이블을 분리하여 @OneToOne 관계를 맺어야만 하는 상황인지부터 다시 점검해봐야 한다.

 

그래서 개인적으로 @ManyToOne 단방향 관계만 사용하는 것을 선호하며,

그 이상의 복잡한 관계가 생길 경우 DB설계를 검토하거나, Querydsl을 사용하는 편이다.

마지막으로 다른 매핑 방식은 이러한 문제가 발생하지 않는 이유가 뭔고 하니…

 

@OneToOne을 제외한 다른 매핑 방식들은 null이건 아니건 Collection을 채워주면 되기 때문에

이 경우 Collection Wrapper라는 것을 이용하게 되어 LAZY가 먹히는 것이며,

@ManyToOne은 전통적인 RDB의 N:1 관계로 역시 값이 항상 존재하기 때문에 LAZY가 먹힌다.

 


© 2022. All rights reserved.