본문 바로가기
개발 - Coding/Java & Spring

[JPA] @OneToOne 관계와 UNIQUE 제약조건

by dev_jinyeong 2024. 4. 8.

@OneToOne 관계에 대해서

JPA에서는 일대일 연관관계를 맺는 엔티티를 표현하기 위해서 @OneToOne 애노테이션을 지원합니다.

일대일 연관관계에 대해서 JPA는 문서에서 3가지 예시 케이스를 제공하고 있습니다.

  1. FK 컬럼을 매핑하는 일대일 연관관계
  2. PK를 공유하는 일대일 연관관계
  3. Embeddable 클래스를 통한 일대일 연관관계

각각의 케이스를 예제로 살펴보면 다음과 같습니다.

 

FK 컬럼을 매핑하는 일대일 연관관계

     // On Customer class:
 
     @OneToOne(optional=false)
     @JoinColumn(
     	name="CUSTREC_ID", unique=true, nullable=false, updatable=false)
     public CustomerRecord getCustomerRecord() { return customerRecord; }
 
     // On CustomerRecord class:
 
     @OneToOne(optional=false, mappedBy="customerRecord")
     public Customer getCustomer() { return customer; }

 

 

PK를 공유하는 일대일 연관관계

     // On Employee class:
 
     @Entity
     public class Employee {
     	@Id Integer id;
     
     	@OneToOne @MapsId
     	EmployeeInfo info;
     	...
     }
 
     // On EmployeeInfo class:
 
     @Entity
     public class EmployeeInfo {
     	@Id Integer id;
     	...
     }

 

 

Embeddable 클래스를 통한 일대일 연관관계

    @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;
         ... 
     }

 

이 중 가장 자주 쓰이는 것은 FK 컬럼을 매핑하는 일대일 연관관계입니다.

일반적으로 이 케이스로만 자주 사용하게 되는데, 이것과 관련하여 혼동될 수 있는 지점을 발견하여 공유하려 합니다.

@OneToOne의 제약조건

위에서 JPA에서 제공하는 @OneToOne이 사용되는 3가지 케이스를 살펴보면, 공통점이 있습니다.

일대일 연관관계에서는 PK나 FK를 매핑하여 사용한다는 점입니다.

일대일 연관관계는 Source, Target 엔티티가 있다고 할 때 Source의 PK를 공유하거나, Target의 PK를 FK로 가지고 있는 관계입니다.

-> 즉 UNIQUE 제약 조건이 무조건 발생함 (무결성 제약조건 중 개체 무결성, 참조 무결성)

-> 반대로 PK를 공유하거나 Target의 PK를 FK로 가지고 있지 않아 UNIQUE가 아니면 일대일 관계가 아님

 

이 부분까지는 어색하지 않지만, 다음과 같은 옵션들 때문에 혼동이 발생하게 됩니다.

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

public @interface OneToOne {

    // 생략

    /** 
     * (Optional) Whether the association is optional. If set 
     * to false then a non-null relationship must always exist.
     */
    boolean optional() default true;

}

 

@OneToOne의 애노테이션의 명세를 보면, optional 옵션을 제공하고 있습니다.

이 옵션을 통해서 이 연관관계가 필수인지 판단하고 있고, 만약 false로 설정되면 non-null 연관관계가 반드시 존재해야 합니다.

 

@OneToOne과 함께 쓰이는 @JoinColumn를 살펴보면 unique, nullable 옵션이 존재합니다.

@Repeatable(JoinColumns.class)
@Target({METHOD, FIELD})
@Retention(RUNTIME)
public @interface JoinColumn {

    // 생략
   

    /**
     * (Optional) Whether the property is a unique key.  This is a
     * shortcut for the <code>UniqueConstraint</code> annotation at
     * the table level and is useful for when the unique key
     * constraint is only a single field. It is not necessary to
     * explicitly specify this for a join column that corresponds to a
     * primary key that is part of a foreign key.
     */
    boolean unique() default false;

    /** (Optional) Whether the foreign key column is nullable. */
    boolean nullable() default true;
}

 

unique 옵션은 프로퍼티가 unique key 인지 설정할 수 있고, nullable은 이 컬럼에 null 값이 들어갈 수 있는지 설정합니다.

 

이러한 옵션들 때문에 @OneToOne 연관관계를 적용할 때, 혼동이 발생할 수 있습니다.

 

@OneToOne 연관관계는 PK를 공유하거나, Target의 PK를 FK로 갖는 관계

-> UNIQUE 제약조건이 있을 수 밖에 없음

-> 그런데 @OneToOne 연관관계에서는 어떤 경우에든 unique한 키를 가져야 함

 

이러한 여러 조건을 고려하여 볼 때, @OneToOne의 optional과 @JoinColumn unique, nullable의 관계는 혼동하기 쉽게 되어 있습니다. 예를 들어, optional=true, unique=false로 되어 있으면 어떤 식으로 제약조건이 걸려야 할까요? 여기서 nullable = false 조건이 추가된다면 어떻게 될까요?

@OneToOne 관계 실험

다음과 같이 엔티티를 설정하고 실험해보겠습니다.

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

	@OneToOne(fetch = FetchType.LAZY, optional = true)
	@JoinColumn(name = "profile_id", unique = false, nullable = true)
	private Profile mappedProfile;
}
@Entity
@Data
public class Profile {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	@OneToOne(mappedBy = "mappedProfile", optional = true, fetch = FetchType.LAZY)
	private Member member;
}

 

아주 간단한 예시입니다. 여기서 optional, unique, nullable 값을 바꿔가며 실험해보겠습니다.

 

1. optional = true, unique = false, nullable = true

Hibernate: create table member (id bigserial not null, profile_id bigint unique, primary key (id))
Hibernate: create table profile (id bigserial not null, primary key (id))

 

@JoinColumn에서 unique = false로 설정했지만 unique 제약조건이 생성되었습니다.

 

2. optional = true, unique = true, nullable = true

Hibernate: create table member (id bigserial not null, profile_id bigint unique, primary key (id))
Hibernate: create table profile (id bigserial not null, primary key (id))

 

1번과 동일한 결과가 출력됩니다.

 

3. optional = true, unique = false, nullable = false

Hibernate: create table member (id bigserial not null, profile_id bigint not null unique, primary key (id))
Hibernate: create table profile (id bigserial not null, primary key (id))

 

not null, unique 제약조건이 생성되는 것을 확인할 수 있습니다.

 

4. optional = false, unique = false, nullable = true

Hibernate: create table member (id bigserial not null, profile_id bigint not null unique, primary key (id))
Hibernate: create table profile (id bigserial not null, primary key (id))

 

이 경우에도 동일하게 not null, unique 제약조건이 생성되는 것을 확인할 수 있습니다.

 

결론적으로 말하면, @OneToOne 관계를 사용하면 @JoinColumn에서 unique = false로 설정해도 무조건 UNIQUE 제약조건이 설정됩니다.

간단하게 정리해보면 다음과 같습니다.

  1. @OneToOne을 사용하면 UNIQUE 제약조건은 무조건 설정됨
  2. @OneToOne, @JoinColumn 어느쪽에서든 제약조건을 추가하면 다른 쪽에서 반대 옵션을 설정해도 추가한 걸로 DDL이 생성됨

이러한 동작 때문에 @OneToOne을 사용하는 데 있어서 주의가 필요합니다.

먼저 서로 배타적인 옵션을 다른 애노테이션에서 설정할 수 있어, 개발자가 혼동할 수 있는 여지를 제공합니다.

둘째로 @OneToOne에서 UNIQUE 제약조건을 강제한다는 내용은 코드상 명시되지 않았습니다.

JPA와 Hibernate의 일대일 관계 정의

2.11.1. Bidirectional OneToOne Relationships
Assuming that:
Entity A references a single instance of Entity B.Entity B references a single instance of Entity A.Entity A is specified as the owner of the relationship.
The following mapping defaults apply:
Entity A is mapped to a table named A.Entity B is mapped to a table named B.Table A contains a foreign key to table B. The foreign key column name is formed as the concatenation of the following: the name of the relationship property or field of entity A; " _ "; the name of the primary key column in table B. The foreign key column has the same type as the primary key of table B and there is a unique key constraint on it.

2.11.3.1. Unidirectional OneToOne Relationships
The following mapping defaults apply:
Entity A is mapped to a table named A.Entity B is mapped to a table named B.Table A contains a foreign key to table B. The foreign key column name is formed as the concatenation of the following: the name of the relationship property or field of entity A; " _ "; the name of the primary key column in table B. The foreign key column has the same type as the primary key of table B and there is a unique key constraint on it.

 

JPA에서 단방향, 양방향 일대일 연관관계를 보면, 공통적으로 한 테이블이 다른 테이블의 PK를 FK로 가지고 있는 경우라고 명시하고 있습니다. 즉, JPA의 정의에서는 일대일 관계에서 UNIQUE 제약조건은 무조건 적용되어야 하고, 필수는 아니지만 대부분의 경우 NOT NULL 제약조건도 걸리는 것이 맞습니다. (일반적인 유즈케이스에서)

 

Hibernate에서는 다음과 같이 설명하고 있습니다.

A "true" one-to-one mapping is one in which both sides use the same primary-key value and the foreign-key is defined on the primary-key column to the other primary-key column. A "logical" one-to-one is really a many-to-one with a UNIQUE contraint on the key-side of the foreign-key.

 

실제 일대일 연관관계는 PK를 공유하거나, 상대방의 PK를 FK로 가지고 있는 경우고, 논리적인 일대일 연관관계는 @ManyToOne에서 FK에 유니크 제약조건을 건 것이라고 정의합니다. (@OneToOne 자바독과 동일한 설명)

 

JPA와 Hibernate의 정의를 보면, UNIQUE 제약조건은 @OneToOne 관계에서 강제되는 것이 맞습니다.

PK를 공유하거나, 상대방의 PK를 FK로 갖는 경우 모두 UNIQUE한 제약조건을 가질 수 밖에 없기 때문입니다.

-> 무결성 제약조건 중 개체 무결성, 참조 무결성에 해당됨

 

실제로도 Hibernate 문서를 보면, @OneToOne은 @ManyToOne과 @UniqueConstraints를 동시에 사용하는 것과 같은 것이라고 이야기하고 있습니다.

Hibernate <6.2 에서 발생하는 이슈

단, 이러한 제약조건은 Hibernate >6.2에서만 적용됩니다. Hibernate 6.2 미만 버전에서는 @OneToOne 제약조건에 UNIQUE 제약조건을 자동으로 삽입하지 않았습니다. 이는 DBMS마다 구현이 조금씩 다르기 때문입니다.

 

예를 들어 SQL Server, DB2, Sybase 같은 DBMS에서는 UNIQUE 제약조건을 검사할 때, null=null 방법을 이용했고 이는 Hibernate에서 UNIQUE 제약조건을 적용하는 의도된 방향이 아닙니다. null=null 연산은 false, undefined 등의 값을 반환하기 때문입니다.

 

이러한 한계점 때문에 Hiberante에서는 원래 @OneToOne 관계에 UNIQUE 제약조건을 넣지 않았습니다. 즉, 실제로는 DDL을 자동 생성할 때 @OneToOne과 @ManyToOne이 같은 DDL 문으로 생성되었던 것입니다.

 

그러나 최신 버전에서 SQL Server, DB2의 경우 이를 개선하였고 null을 제외하고 UNIQUE 제약조건을 계산하는 방법을 지원하게 되어 Sybase를 제외하고 UNIQUE 제약조건을 넣게 되었습니다.

 

이는 여러 DBMS를 고려해야 하는 Hibernate 입장에서 어쩔 수 없는 절충이었던 셈입니다.

 

참고: https://hibernate.atlassian.net/browse/HHH-15767

 

[HHH-15767] - Hibernate JIRA

 

hibernate.atlassian.net

JPA는 DBMS 명세를 강제할 수 없다

추가로 주의할 점은 지금까지 설명한 모든 내용은 DDL 자동 생성에 관련된 내용이라는 것입니다.

 

JPA는 기본적으로 DBMS와 독립적인 존재입니다. JPA 단에서는, 사용자가 작성한 로직에 맞춰 객체지향적으로 데이터베이스를 다룰 수 있게 쿼리를 나가게 해줄 뿐, 실제 테이블 명세가 어떻게 되어야 하는지를 강제할 수 없습니다.

 

따라서 @OneToOne, @JoinColumn에서 설정한 내용은 DDL 자동 생성할 때 고려되어 나가게 되고, 만약 테이블을 직접 생성하는 경우에는 이러한 제약 조건이 없을 수도 있습니다.

 

예를 들어 UNIQUE 제약 조건을 걸지 않고 위의 예제의 Member, Profile을 생성해서 삽입한 경우, insert 문은 문제 없이 값을 삽입하게 됩니다.

 

그러나 SELECT 문으로 값을 가져오게 될 경우, 단일 객체를 기대했으나 여러 객체가 생성되게 되므로 오류가 발생하게 됩니다.

-> 한 번 데이터 무결성을 어기면 고쳐주기 전까지 SELECT 계속 불가

 

즉 논리적으로 존재해야 하는 제약조건을 어겨 데이터 무결성을 위배할 수 있고, 애플리케이션 단에서 알아차릴 수 없는 이유로 에러가 지속적으로 발생하게 되므로 주의해야 합니다.