JPA - Hibernate는 일대일 매핑(@OneToOne)에 unique constraint를 자동으로 생성해주나요?

@melonturtle · March 01, 2024 · 12 min read

최근 프로젝트 코드를 작성하면서, @OneToOne 일대일 단방향 매핑을 할 필요가 있었다. 아직 개발 단계라 Hibernate가 만들어주는 DDL을 사용하고 있었는데, @OneToOne 사용 시 외래키 제약사항을 자동으로 걸어주는걸 확인할 수 있었다.

예를 들어 아래처럼 ItemTag 엔티티가 일대일 관계로 있다고 해보자. 외래키 item_idTag 테이블에 포함될 것이다.

tag item diagram

@Entity
public class Tag {
	// ...
	@OneToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "item_id")
	private Item item;
}

그리고 생성되는 DDL을 보면 unique constraint가 만들어진다.

one to one unique constraint

@JoinColumn은 아래와 같이 default로 unique=false이다. 근데도 자동으로 unique constraint를 만들어주고, unique=false를 명시적으로 작성해줘도 만들어주는 걸 확인할 수 있었다.  JoinColumn

@OneToOne일 때 unique?

사실 이건 올바른 동작이다. @OneToOne인데 unique constraint가 없다면? 사실상 @ManyToOne과 별 차이가 없게 되고, 실수로라도 같은 외래키를 가진 데이터를 insert하게 된다면 올바르지 않은 데이터임에도 알아차리지 못할 것이다.

JPA 명세 - Unidirectional OneToOne Relationships

JPA 명세를 한 번 살펴보자.

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.

외래키 컬럼을 매핑하는 테이블의 primary key와 같은 type이어야하고, unique key constraint가 있어야 한다는 표현이 있다.

그러므로 올바르게 동작하는 것이다. 그런데도 이 글을 쓰는 이유는 Hibernate의 동작 때문이다.

Hibernate 6.2 - Unidirectional @OneToOne

JPA 명세상 올바르게 동작하는 것이라는 걸 확인했지만, 그래도 Hibernate의 동작도 확인해보고 싶어서 Hibernate의 userguide도 한 번 살펴봤다.

프로젝트에서 쓰고 있던 spring-boot-starter-data-jpa 의존성에서 hibernate 6.2 버전을 사용하고 있으므로 해당 버전의 가이드를 살펴봤다.

그런데, Unidirectional, 단방향에는 unique constraint 관련된 말이 없고, Bidirectional, 양방향 매핑일 때에만 아래와 같이 unique constraint 언급이 있었다.

When using a bidirectional @OneToOne association, Hibernate enforces the unique constraint upon fetching the child-side. If there are more than one children associated with the same parent, Hibernate will throw a org.hibernate.exception.ConstraintViolationException. Continuing the previous example, when adding another PhoneDetails, Hibernate validates the uniqueness constraint when reloading the Phone object.

  • 양방향 @OneToOne 매핑일때 Hibernate는 child 쪽에서 가져올때 unique constraint를 적용한다는 말이었다.

단방향에는 이런 말이 없고, 양방향에만 이런 말이 있다면 단방향일땐 적용이 안된다는 뜻일 것이다. 근데 아무리 실행해봐도 단방향일때도 항상 unique constraint를 적용하고 있었다.

그래서 Hibernate는 오픈소스니까, Github에서 저장소를 확인해봤다. 하이버네이트는 이슈를 지라로 관리하고 있어서 지라에 검색을 하던 도중 다음과 같은 예전 이슈를 확인할 수 있었다.

missing unique constraints from optional @OneToOne

Currently Hibernate never automatically produces unique constraints on foreign key columns mapped using @OneToOne unless the association is explicitly marked optional=false.

Now, the problem is that, without the unique constraint, there is simply no difference at all between @OneToOne and @ManyToOne and so you can use @OneToOne as a bad @ManyToOne and, well, apparently people do, because fixing this broke multiple badly-written tests!

  • 하이버네이트는 @OneToOne 연관관계가 optional=false일 때 외래키에 unique constraint를 생성하지 않고 있다.
  • 그리고 문제는 unique constraint 없이는 @OneToOne@ManyToOne 사이 차이점이 없으므로 많은 사람들이 @OneToOne임에도 @ManyToOne처럼 쓰고 있다는 것이다.

@OneToOne은 기본적으로 optional=false로 되어있다. 따라서 내가 위에 작성한 코드처럼, 별도로 optional=true를 하지 않는 이상 false이다. 즉 내 상황이 이슈에서 언급한 상황과 같았다.

그리고 그 전에 이슈를 해결하지 않고 있던 이유는 몇몇 데이터베이스에서 nullable column일 경우 null 역시도 유니크한 값으로 처리해서, 해당 컬럼 값이 정말로 값이 null이 아니라, 비어있어서 null일 경우에도 중복으로 처리하므로 unique constraint를 걸 수 없던 경우가 있었던 듯하다.

이 이슈는 현재 해결되었고, 6.2.0.CR1에서 해결되었다고 나와있다.

그리고 Migration Guide에 이 내용을 적어놓자고 나와있다.

Note that we need to mention this in the migration guide, since user programs may have been relying on the missing unique constraint, especially when updating a one-to-one association.

그럼 Migration Guide를 한 번 살펴보자.

Hibernate 6.2 Migration Guide - UNIQUE constraint for optional one-to-one mappings

Starting in 6.2, those UNIQUE constraints are now created.

이슈에서 언급한 것과 마찬가지로 6.2 버전부턴 unique constraint가 생긴다고 나와있다. 그러면 내가 쓰고 있던 버전이 6.2라서 그 전 버전과 달리 unique constraint가 생기는 건지 예전 버전을 한번 확인해보자.

Hibernate 6.1 - @OneToOne

gradle에서 현재 사용하던 의존성은 그대로 하고 hibernate 버전만 내려줬다.

build.gradle

dependencies {
    implementation ('org.hibernate:hibernate-core') {
       version {
          strictly '6.1.7.Final'
       }
    }
    // ...
}

// ...

ext['hibernate.version'] = '6.1.7.Final'

실행시 로그를 보면 아래와 같이 버전이 잘 적용된 걸 확인할 수 있다. version checking

생성된 테이블 정보

버전을 내리고 실행해보자 로그에도 unique constraint를 볼 수 없었고, 테이블 DDL을 확인해보면 역시 적용되지 않았다.

SHOW CREATE TABLE tag;

6 1 DDL

다시 버전을 6.2로 올리고 확인해보면 unique constraint가 적용되어있는 걸 볼 수 있다.

6 2 DDL

Hibernate 6.2 User Guide

즉 내가 쓰는 버전이 6.2기 때문에 optional에도 unique constraint가 적용되었고, 이 변경사항이 user guide에는 업데이트 되지 않은 것으로 보인다.

하이버네이트 홈페이지에서 Hibernate ORM 6.2 series release를 확인해보면, 첫 버전이 2022-12-22일에 나왔으므로 아직 예전 버전을 쓴다면 6.2 이상으로 올리면서 이 사실을 인지하지 못할 수도 있을 것 같다.

6 2 release

또 나처럼 user guide를 보고 헷갈려하는 사람이 있을 것 같아 Hibernate에 Jira Issue를 만들었다.

  • 그리고 실제로 검색해봤을때

    • 인프런 질문
    • stackoverflow 질문
    • 이런 질문들이 있었고,
    • 예전 버전 기준으로 @OneToOne은 1:1을 보장해주지 않는다는 내용의 글들이 있어 unique 제약조건이 자동으로 추가됨이 guide에 나와있으면 좋을 것 같았다.

그리고 아래와 같은 댓글이 달렸다.

Yeah, I mean, to me that whole section of the documentation is just confusing to the point that I don’t really understand what it’s trying to say, and if I did understand, I would probably think it’s simply wrong.


It seems to claim that whether an association is bidirectional on the Java side affects the database schema, which isn’t right.


And it makes this very weird statement:

When using a bidirectional @OneToOne association, Hibernate enforces the unique constraint upon fetching the child-side. If there are more than one children associated with the same parent, Hibernate will throw a org.hibernate.exception.ConstraintViolationException.

What the fuck? The unique constraint is enforced by the database, not by Hibernate! And this has absolutely nothing to do with fetching.


Nor does this section mention anything about @OneToOne associations mapped to a primary key, which to me is usually the most natural mapping.


A proper explanation of @OneToOne mappings in Hibernate can be found here: An Introduction to Hibernate 6

  • 즉 애초에 단방향, optional일 때도 unique constraint 제약사항이 반영된다는 내용만 빠진게 아니라 user guide에 @OneToOne 관련 부분이 이상하게 적혀있다는 것이다.
  • 다시 읽어보면 그렇다. 데이터베이스에 unique constraint를 적용한다는 말이 아니고, child side에서 조회해올때 unique constraint를 강제로 적용하고 아니면 ConstraintViolationException을 던진다고 나와있는데, 이는 애초에 실제와 다른 말이다.
  • @OneToOne일 땐 primary key로 매핑하는게 가장 자연스러운 매핑이라고 생각하는데, 이 부분이 아예 적혀있지 않다는 것이다.

그리고 @OneToOne 매핑에 대해 더 적절한 설명이 적힌 문서를 알 수 있었다: An introduction to Hibernate 6

The simplest sort of one-to-one association is almost exactly like a @ManyToOne association, except that it maps to a foreign key column with a UNIQUE constraint.

보면 unique constraint가 적용된다는 말도 적혀있었다.


One-to-one(second way) 섹션을 보면 작성자가 말한 매핑 방법인 두 테이블 간 기본키를 공유하여 매핑하는 방식이 나와 있다.

An arguably more elegant way to represent such a relationship is to share a primary key between the two tables.

@Entity
class Author {
    @Id
    Long id;

    @OneToOne(optional=false, fetch=LAZY)
    @MapsId
    Person author;

    ...
}
  • Person을 통해 Author의 기본키를 세팅할 수 있다. 그러므로 외래키 컬럼이 추가로 필요가 없다.

이 방법은 @OneToOne 매핑이 optional이 아니라면 용이할 것이다. 또 두 객체 간의 생성 순서가 지켜져야하므로 주의해서 사용한다면 좋을 것 같다.

결론

결론은 Hibernate 6.2 부터는 @OneToOne 매핑시 항상 외래 키 컬럼에 unique constraint를 적용한다.

@melonturtle
공부하면서 남긴 기록입니다. 문제가 있다면 언제든지 알려주세요!