Search
🎯

Kotlin에서 JPA Entity 설계하기

목차

개요

Java에서 Kotlin으로 언어 전환을 시도한다면, 서버 개발자는 자연스럽게 Spring을 고려하지 않을 수 없다. 거기에 JPA까지 같이 고려를 하는 경우가 많아졌는데, Entity 설계를 하다보면 Kotlin의 언어적 특성과 일치되지 않는 부분들이 많이 나오게 된다.
이는 JPA가 Java에 기반을 둔 ORM이기 때문에 Entity 정의가 java를 기준으로 Entity를 정의하기 쉽게 만들어졌기 때문이다.
그럼 어떻게 이 JPA Entity를 코틀린스럽게 사용할 수 있을지 알아보자.

무분별한 Mutable Property 사용

JPA(Java Persistence API)는 Java 기반 ORM이기 때문에 Mutable Property를 기준으로 설계되었다.
Kotlin은 기본적으로 불변(Immutable)을 지원한다.
즉, Kotlin으로 JPA를 그냥 사용한다면 코틀린의 장점을 살리지 못하는 Entity 설계가 되기 쉽다.
@Entity class Person( @ID var id: Long, @Column var name: String, @Column var age: Int )
Kotlin
복사
ver. Kotlin
@Entity @AllArgsConstructor class Person { @ID long id; @Column String name; @Column int age; }
Java
복사
ver. Java
좌측은 코틀린, 우측은 자바로 작성한 Person Entity이다. 둘 다 캡슐화도 안되어있고 외부에 의한 위/변조를 막을 수 없다는 부분에서 동일하게 문제가 많은 엔티티 설계이다.
다만, 차이는 있다. kotlin가 가지는 상태는 field가 아닌 property로 자바와 같이 field를 그대로 노출하는게 아닌 내부적으로 getter/setter를 통해 노출하는 것이다. 즉, 코틀린 코드는 자바로 표현하면 다음과 같다.
@Entity @AllArgsConstructor @Getter @Setter public class Person { @ID private long id; @Column private String name; @Column private int age; }
Java
복사
물론 person.age = 3으로 바꾸는것과 person.setAge(3) 은 둘 다 동일하게 Entity의 상태 변경의 목적을 제대로 표현해주지 못한다. 차라리 person.getOld()와 같이 표현하는게 나을 것이다.

Data Class 활용

equals와 hashCode, toString등을 자동으로 구현해주는 data class로 Entity를 설계하는 경우도 많다.
Kotlin에서 Data class는 데이터전달 목적(link)으로 만들어진 클래스이고, 그렇기에 데이터의 원본을 유지하는게 중요하다.
그렇기에 꼭 필요한 상황이 아니라면 불변성(Immutable)을 확보하는게 중요하다.
하지만, Entity는 식별자 외의 정보는 변경될 수 있다.(mutable) 뿐만 아니라, Entity는 비즈니스 요구사항을 수행하는 Domain이기도 하기에 데이터 전달 목적인 Data class와는 목적이 다르다.
하지만, 그럼에도 불구하고 data class가 자동으로 제공해주는 copy, equals, hashCode, toString은 생산성을 높히는데 많은 도움을 준다.
하지만, Kotlin에서 data class는 기본 생성자에 정의한 프로퍼티들만 자동생성되는 메서드함수에서 사용된다. 즉, 다음과 같이 프로퍼티가 아닌 필드에 대해서는 대응하지 못한다.
data class Person(val name: String) { var age: Int = 0 } val person1 = Person("hansol") val person2 = Person("catsbi") person1.age = 10 person2.age = 20 person1 == person2 // true
Java
복사
이런 상황을 피하기 위해서는 기본 생성자에 모든 프로퍼티를 정의해야하는데, 이 역시 좋은 방법은 아니다. 엔티티 생성 시점에 꼭 매개변수로 받아야 하는 필드와 그렇지 않은 필드가 구분되기 때문이다. 이런 필드 들은 내부에서 기본값으로 정의되도록 하는게 필요하다.
만약, 주문(Order)라는 엔티티를 만든다고 할 때 최초 주문 상태를 SUBMITTED로 설정해야 한다면 어떻게 해야할까?
@Entity data class Order( @Id val id: UUID, @Column val orderAt: LocalDateTime, @Column val state: OrderState = OrderState.SUBMITTED )
Kotlin
복사
이런식으로 기본값을 지정해 줄 수도 있을 것이다.
하지만, 만약 사용자가 주문 엔티티 생성 시점에 주문 상태를 다르게 지정해준다면 어떻게 될까?
Order( id = UUID.randomUUID(), orderAt = LocalDateTime.now(), state = OrderState.CANCELED, )
Kotlin
복사
그렇다고 state를 생성자 매개변수가 아닌 필드로 지정하게 된다면, 위에서 언급했던 data class의 자동 생성 함수들의 지원을 받을 수 없게 될 것이다.
그런데 생각해보면 Entity의 동등성 체크는 모든 프로퍼티를 비교하는게 아니라 식별자를 통해서만 이루어진다. 즉, A라는 엔티티가 생명주기동안 이름이 바뀌고 나이가 바뀐다고 A가 B엔티티가 되는 것은 아니라는 것이다.
그렇다고, 따로 재정의를 하지 않으면 참조 비교를 통해 동일성 확인을 하기 때문에 식별자를 통한 동등성 판단을 제공하려면 equalshashCode를 재정의 해 줘야 한다.
class Person { override fun equals(other: Any?): Boolean { if (other == null) { return false } if (this::class != other::class) { return false } return id == (other as Person).id } override fun hashCode() = Objects.hashCode(id) }
Kotlin
복사

lateinit 사용

Kotlin은 lateinit 키워드를 통해 초기화를 뒤로 미룰 수 있다. 자바의 Lazy Loading을 차용한 것인데, 초기화를 최대한 뒤로 늦춰서 성능및 효율정을 높히려는 용도로 사용한다.
Kotlin에서는 Java와 다르게 초기화를 하지 않고 Property를 정의할 수 없다.
@Entity class Person { @Id val id: UUID // compile error: Property must be initialized or be abstract @Column val name: String // compile error: Property must be initialized or be abstract }
Kotlin
복사
compile error: Property must be initialized or be abstract 에러 발생
그렇기에 생성자 매개변수로 전달받거나 기본값을 작성해줘야 한다.
@Entity class Person( id: UUID, name: String, ) { @Id val id: UUID = id @Column val name: String = name }
Kotlin
복사
@Entity class Person { @Id val id: UUID = UUID.randomUUID() @Column val name: String = "" }
Kotlin
복사
일반적으로 Column만 존재하는 경우( 연관관계가 없는 경우) lateinit을 사용하는 경우는 거의 없다. 하지만, 연관관계를 정의하는 경우 기본적으로 우리는 fetch = FetchType.LAZY를 기본 전략으로 생각한다.
즉, 내가 단방향(혹은 양방향)으로 바라보고 있는 엔티티 타입 변수는 초기화되지 않을 수 있다는 것이다. Java에서는 이렇게 작성을해도 기본적으로 초기화 해주지 않으면 null로 정의되기 떄문에 에러가 발생하지 않는다.
다음은 코틀린으로 작성한 게시글 엔티티이다.
@Entity class Board( title: String, writerId: UUID, ) { @Id var id: UUID = UUID.randomUUID() @Column var title: String = title @Column var writerId: UUID = writer.id @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "writerId") lateinit var writer: User }
Kotlin
복사
게시글의 작성자 정보인 writerlateinit을 활용하고 있다. 컴파일 에러도 발생하지 않는다.
심지어 다음과 같은 테스트 코드를 작성해서 실행해봐도 정상 동작한다.
@DataJpaTest(showSql = true) class BoardRepositoryTest { @Autowired lateinit var testEntityManager: TestEntityManager @Autowired lateinit var boardRepository: BoardRepository @Test fun test_get_writer() { // Given val user = User("홍길동") val board = Board("게시판", user.id) testEntityManager.persistAndFlush(user) testEntityManager.persistAndFlush(board) testEntityManager.detach(user) testEntityManager.detach(board) // When val actual = board2Repository.getReferenceById(board.id) // Then Assertions.assertEquals("홍길동", actual.writer.name) } }
Kotlin
복사
그럼 뭐가 문제일까?
Entity 생성 직후 해당 Entity를 다룰 때 lateinit 필드를 사용하면 어떻게 될까?
val user = User("홍길동") val board = Board2("게시판", user.id) val writer = board.writer // error: lateinit property writer has not been initialized
Kotlin
복사
이 코드는 writer 엔티티 그래프 탐색 시점에 런타임 오류가 발생한다.
그 이유는 영속화된 Board를 조회할 때는 JPA가 writer를 초기화해주지만, 이제 막 생성한 Entity는 JPA가 wirter를 초기화 해주지 않았기 때문이다.
그럼 어떻게 lateinit을 사용하지 않고 Java처럼 연관관계를 정의할 수 있을까?
1.
nullable 타입으로 정의하기
: 자바에서도 변수를 초기화하지않으면 null로 기본값이 지정되듯 초기화 하지 않은 프로퍼티는 null로 보면 되지 않을까?
@Entity class Board( title: String, writerId: UUID, ) { @Id var id: UUID = UUID.randomUUID() @Column var title: String = title @Column var writerId: UUID = writer.id @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "writerId") var writer: User? = null }
Kotlin
복사
하지만, 이 방법은 코드상에서는 문제가 되지 않을 수 있지만, 좀 더 생각해보면 게시판은 있는데, 작성자는 없는게 말이 안된다. ORM의 특성을 지키기 위해 도메인의 제약조건을 위반하는게 과연 적절한 선택일까?
2.
엔티티 자체를 넣어주기
: 사실 해결책은 간단하다. 생성 시점에 생성자 파라미터로 작성자를 받아서 초기화를 해 주는 것이다.
@Entity class Board( title: String, writer: User, ) { @Id var id: UUID = UUID.randomUUID() @Column var title: String = title @ManyToOne(fetch = FetchType.LAZY, optional = false) var writer: User = writer }
Kotlin
복사

가상의 게시판 예제를 통해 알아보는 Entity 설계

Use Case

Entity

ERD

Tips

1. allopen

:kotlin에서는 JPA Entity 설계시 allopen, no-args constructor 옵션을 줘야한다.
간단하게 그 이유를 파악해보자면, 코틀린의 객체들은 기본적으로 모두 final 클래스로 불변성을 가진다. 하지만 JPA 에서는 클래스 확장 및 프록시를 만들기 위해 클래스를 상속하려 하는데 클래스가 final class라면 확장이 불가능하기 때문에 문제가 된다.
그래서 보통 코틀린으로 JPA 프로젝트를 설정할때 다음과 같은 플러그인을 추가해서 클래스들이 자동으로 open될 수 있도록 만들어준다.
kotlin("plugin.spring") version "1.7.0" kotlin("plugin.jpa") version "1.7.0"
Kotlin
복사
JPA 플러그인은 JPA 관련 클래스 생성에 문제가 없도록 생성자 매개변수가 없는 No-arg plugin도 포함되는데, 이는 JPA에서 엔티티 매핑 방식이 리플렉션을 이용한 프로퍼티 주입이기 때문이다.
하지만, 위와 같이 설정을 하더라도 Entity Decompile을 해보면 final 키워드가 있는 것을 볼 수 있다.
public final class com/example/kotlinentitytutorial/User extends com/example/kotlinentitytutorial/PrimaryKeyEntity { @Ljavax/persistence/Entity;() @Ljavax/persistence/Table;(name="`user`") // access flags 0x2 private Ljava/lang/String; name @Ljavax/persistence/Column;(nullable=false, unique=true) @Lorg/jetbrains/annotations/NotNull;() // invisible // access flags 0x11 public final getName()Ljava/lang/String; @Lorg/jetbrains/annotations/NotNull;() // invisible L0 LINENUMBER 16 L0 ALOAD 0 GETFIELD com/example/kotlinentitytutorial/User.name : Ljava/lang/String; ARETURN L1 LOCALVARIABLE this Lcom/example/kotlinentitytutorial/User; L0 L1 0 MAXSTACK = 1 MAXLOCALS = 1 // 생략... }
Kotlin
복사
Hibernate 사용자 가이드 문서에서 Entity나 Entity가 가진 인스턴스 변수는 final이 아니어야 한다고 안내하고 있다. 그렇기에 다음과 같은 설정을 추가할 필요가 있다.
allOpen { annotation("javax.persistence.Entity") annotation("javax.persistence.MappedSuperclass") annotation("javax.persistence.Embeddable") }
Kotlin
복사
위와 같이 옵션을 추가하면 이제 더 이상 final 키워드가 없는 것을 확인할 수 있다.

2. PrimaryKeyEntity

위 예제 코드를 보면 모든 Entity가 PrimaryKeyEntity를 상속받고 있다.
이게 뭐하는 클래스고, 어떤 역할을 가지는지에 대해 알아볼 필요가 있다.
첫 째로는 엔티티 공통 식별자의 필요성이다.
모든 Entity는 Primary Key가 필요하다. 보통 @Id로 지정하며 (MRS-XXX같은 경우엔)Integer, Long, UUID 등의 타입으로 정의된다.
IntegerLong 타입은 Entity간에도 키 값이 중복될 수 있고 MAX_VALUE의 차이도 있어서 UUID를 많이 선택하는 추세이다.
하지만, UUID 역시 정렬불가능하다는 단점과 Long보다 크기나 생성비용도 크다는 단점이 있다.
(물론, 이 key 값의 성능이나 크기까지 고려할 정도의 시스템이라면 ORM 사용 자체를 고려해봐야 하긴 하다.)
적어도 정렬에 대해서는 대안책이 존재하는데 ULID를 사용해서 해결할 수 있다.
ULID는 UUID와 호환성을 가지며 시간순으로 정렬할 수 있다는 특징을 가지고 있다. ULID 구현 라이브러리는 이미 많이 존재하는데, ULID Creator가 Monotinic 함수를 제공해줘서 기존 ULID가 밀리초까지만 제공되는데, Monotic ULID는 동일한 밀리초가 있을 경우 다음 생성 ULID의 밀리초를 1증가시켜 생성하도록 해준다.
이처럼 모든 Entity가 PrimaryKeyEntity를 상속받아 사용하도록 하면 공통된 PrimaryKey 를 사용하도록 할 수 있다.
두 번째로는 Nullable 타입을 방지할 수 있다.
기존 Java의 JPA에서 엔티티가 새로운지(isNew) 판단은 식별자 정보를 토대로 null인지 (long이라면 0인지)를 통해 구분한다.
public boolean isNew(T entity) { ID id = getId(entity); Class<ID> idType = getIdType(); if (!idType.isPrimitive()) { return id == null; } if (id instanceof Number) { return ((Number) id).longValue() == 0L; } throw new IllegalArgumentException(String.format("Unsupported primitive id type %s", idType)); }
Java
복사
JPA의 새로운 엔티티 판단 메서드 isNew
즉 Primary Key가 nullable 한 타입이라는 것인데, 데이터베이스에서 Primary Key는 Not Null이어야 한다. 그리고 코틀린에서도 non-null 타입을 권장한다. 타입이 Long이라서 0일 경우로 해서 non-null하게 설계한다고 해도 모든 영속화되지 않은 엔티티의 key 값이 0으로 동일하다는건 이상하다. 그렇기에 이 역시 명쾌한 해결책은 아니다.
세 번째로 채번 유발을 방지할 수 있다.
기본적으로 JPA에서 Id를 생성하는 방법은 @GeneratedValue 애노테이션에 전략을 선택해서 결정한다. 전략은 auto_increment, sequence, sequence table 등이 있는데, 모두 데이터베이스에 책임을 전가하고 부하를 유발한다.
그래서 트래픽이 큰 서비스에서는 이런 채번 활동이 상당한 부하로 작용하게 된다.
그래서
PrimaryKeyEntity 같은 클래스를 이용해 Entity 생성시 Primary Key도 함께 생성해주도록 할 수 있다. 여기서 long보다 ULID가 유리한 이유가 또 나오는데, Long타입은 랜덤한 값을 주기 모호하고 1부터 시작할수도 없다. ULID는 항상 중복되지 않는다.
그런데
PrimaryKeyEntity 를 이용해서 영속화 전 Key를 생성해주는 방식을 선택하면 주의할 점이 있다.
위에서 isNew 메서드에 대해 알아봤었는데 null인 경우에만 신규 생성 Entity로 판단한다는 점이다.
즉, 영속화 되지않은 엔티티에 Key가 할당되면 persist되는게 아닌 merge가 되버린다.
그럼 이게 어째서 문제가 될까? 다음 코드를 보자.
@Entity class Foo( name: String ) { @Id val id: UUID = UUID.randomUUID() @Column var name: String = name protected set } @Test fun test() { val foo = Foo("test") fooRepository.save(foo) fooRepository.flush() }
Kotlin
복사
Hibernate: select foo0_.id as id1_3_0_, foo0_.name as name2_3_0_ from foo foo0_ where foo0_.id=? Hibernate: insert into foo (name, id) values (?, ?)
SQL
복사
결과만 본다면 Entity는 저장되었다. 하지만 한 번 조회한 이후 저장하는 방법은 불필요한 쿼리를 실행시키게 된다. 그래서 우리는 Spring Data 에서 제공하는 Persistable 인터페이스를 구현해서 이러한 불필요한 쿼리를 막아줄 수 있다.
Persistable 인터페이스는 getId, isNew 함수를 제공하는데 isNew는 위에서 언급한 새로운 엔티티 파악에 사용된다. 그래서 Persistable 인터페이스를 구현한 Entity를 영속화 하려하면, JpaPersistableEntityInformation.isNew 함수가 호출되며 내부적으로 Persistable.isNew 함수를 호출한다.
@Override public boolean isNew(T entity) { return entity.isNew(); }
Java
복사
JpaPersistableEntityInformation.isNew
이제 위에서 작성한 Foo 객체가 Persistable을 구현하도록 해보자.
@Entity class Foo( name: String ) : Persistable<UUID> { @Id private val id: UUID = UUID.randomUUID() @Column var name: String = name protected set override fun getId(): UUID = id override fun isNew(): Boolean = true }
Kotlin
복사
이제 다시 테스트 코드를 실행하면 다음과 같이 한 번의 쿼리만 수행됨을 알 수 있다.
Hibernate: insert into foo (name, id) values (?, ?)
SQL
복사
하지만, 아직 문제는 남아있다.
위와 같이 작성하는 경우 delete 호출할 때 삭제 쿼리가 실행되지 않을 수 있다.
다음은 SimpleJpaRepository.delete 함수 코드이다.
@Override @Transactional @SuppressWarnings("unchecked") public void delete(T entity) { Assert.notNull(entity, "Entity must not be null!"); if (entityInformation.isNew(entity)) { return; } Class<?> type = ProxyUtils.getUserClass(entity); T existing = (T) em.find(type, entityInformation.getId(entity)); // if the entity to be deleted doesn't exist, delete is a NOOP if (existing == null) { return; } em.remove(em.contains(entity) ? entity : em.merge(entity)); }
Java
복사
어째서인지 이유를 파악했는가?
이유는 entityInformation.isNew(entity) 때문이다. 해당 엔티티가 새로운 엔티티라면 삭제할 필요가 없기 때문에 그대로 return한다는 것이다. 그래서 우리는 엔티티가 영속화 이후에는 isNew가 false를 반환하도록 만들어줘야 한다.
이를 해결하기위해 두 애노테이션(@PostPersist, @PostLoad)를 활용할 수 있다.
@PostPersist
: 영속화 이후 실행 애노테이션
@PostLoad
: 영속화한 데이터 조회 이후 실행 애노테이션
@Entity class Foo( name: String ) : Persistable<UUID> { @Id private val id: UUID = UUID.randomUUID() @Column var name: String = name protected set override fun getId(): UUID = id @Transient private var _isNew = true override fun isNew(): Boolean = _isNew @PostPersist @PostLoad protected fun load() { _isNew = false } }
Kotlin
복사
이제 다시 delete 함수를 호출해보면 정상적으로 삭제 쿼리를 수행함을 확인할 수 있다.
Hibernate: insert into foo (name, id) values (?, ?) Hibernate: delete from foo where id=?
SQL
복사

공통 동일성 보장

PrimaryKeyEntity 을 사용해야 하는 이유는 더 있다.
앞서 Entity는 같은 식별자를 가질 경우 동일한 객체로 판단해야 하기에 equals, hashCode를 재정의 해줘야 한다고 했다.
하지만, 모든 엔티티마다 재정의를 해주는건 매우 불편한 일이다.
그래서 PrimaryKeyEntity 에서 equals, hashCode를 재정의 한 뒤 공통으로 사용한다면 이런 불편함을 없앨 수 있지 않을까? 물론, 이게 가능한 이유는 모든 Entity가 식별자를 기준으로 동등성 판단을 할 수 있기 때문이다.
@MappedSuperclass abstract class PrimaryKeyEntity : Persistable<UUID> { @Id @Column(columnDefinition = "uuid") private val id: UUID = UlidCreator.getMonotonicUlid().toUuid() // 생략... override fun equals(other: Any?): Boolean { if (other == null) { return false } if (this::class != other::class) { return false } return id == (obj as PrimaryKeyEntity).id } override fun hashCode() = Objects.hashCode(id) }
Kotlin
복사
하지만, 아직 해결해야 할 이슈가 남아있다. 다음 테스트 코드를 실행시켜보자.
@Test fun test() { val user = User("홍길동") val boardInformation = BoardInformation(null, 1) val board = Board("게시판1", "내용1", boardInformation, user, setOf()) testEntityManager.persist(user) testEntityManager.persist(board) testEntityManager.flush() testEntityManager.clear() val actual = boardRepository.findById(board.id).get() assertTrue(user == actual.writer) }
Kotlin
복사
우리는 당연히 assertTrue(user == actual.writer) 코드가 성공할 거라 생각하지만, 현실은 다르다.
expected: <true> but was: <false> Expected :true Actual :false
Kotlin
복사
그 이유는 Hibernate Proxy 때문인데, JPA의 구현체인 Hibernate는 성능 최적화를 위해 연관관계 조회 시 꼭 필요할 때까지 조회 쿼리 호출을 지연시키는 지연 조회(Lazy Loading)을 지원하는데, 그렇기에, 실제 Entity가 실행되기 전까지는 Proxy 객체를 미리 생성해서 넣어둔 뒤 교체하는 방식이다.
즉, user의 클래스 타입이 com.xxx.xxxx.User라면 actual.writer의 클래스 타입은 com.xxx.xxxx.User$HibernateProxy$bl4kAli0 이런 식일 것이다.
그렇기에 equals 메서드의 클래스 타입 비교 구문 조건문에 걸려 false가 반환되는 것이다.
if (this::class != other::class) { return false }
Kotlin
복사
이를 해결하기 위해서 간단하게 생각해보면 지연로딩을 즉시로딩(Eager Loading)으로 로딩 전략을 바꿔도 되겠지만, 이 경우 쿼리 조회 N+1 문제를 유발할 수 있다.
그래서 생각할 수 있는 방법은 해당 프록시 타입까지 같이 고려한 equals문으로 재정의 하는 것이다.
override fun equals(other: Any?): Boolean { if (other == null) { return false } if (other !is HibernateProxy && this::class != other::class) { return false } return id == getIdentifier(other) } private fun getIdentifier(obj: Any): Serializable { return if (obj is HibernateProxy) { obj.hibernateLazyInitializer.identifier } else { (obj as PrimaryKeyEntity).id } }
Kotlin
복사
클래스 타입 부분에서 프록시 타입까지 고려를 하고 식별자 비교 시점에서는 프록시 객체일 경우 식별자 정보가 존재하는 곳에서 식별자를 가져오도록 해서, 엔티티가 프록시 타입이더라도 정상 동작하게 만들어줄 수 있다.

3. Property 접근 제어

우리는 Entity를 단순 구조체가 아닌 도메인으로 만들고, 캡슐화를 통해 프로퍼티의 변경을 최소화 할 필요가 있다.
기본적으로 Entity의 프로퍼티들은 생명주기동안 변경이 가능하다.(Mutable)
하지만, 그 변경을 최소화 할 방법을 고민해봐야 한다. 변경이 최소화 될 수록, Entity도 안전해지고 사이드 이펙트로부터 안전해질 수 있다. 코드를 통해 조금씩 개선하는 방향을 살펴보자.
User
:User라는 Entity가 있고, 이름 정보를 갖는다고 하자.
@Entity class User( @Column(nullable = false, unique = true) var name: String, ) : PrimaryKeyEntity()
Kotlin
복사
이렇게 되면 외부에서 마음대로 이름을 변경할 수 있다. 그렇기에 setter의 접근을 제한해보자.
@Entity @Table(name = "`user`") class User( name: String, ) : PrimaryKeyEntity() { @Column(nullable = false, unique = true) var name: String = name protected set }
Kotlin
복사
이제 User Entity 자신이나 상속 Entity에서만 이름을 변경할 수 있다.
아예 private로 해버리면 안되나 생각할 수 있지만 open property의 경우 private setter는 허용되지 않는다.(이전에 Entity에 대해 allOpen 옵션을 추가한걸 잊지 말자.)
생성일, 수정일과 같은 변경이 필요 없는 프로퍼티는 어떻게 하지?
@Entity @Table(name = "`user`") class User( name: String, ) : PrimaryKeyEntity() { @Column(nullable = false, unique = true) var name: String = name protected set @Column(nullable = false) val createdAt: LocalDateTime = LocalDateTime.now() }
Kotlin
복사
다만, 이 프로퍼티(createAt)은 생성자 매개변수를 통해 초기화 할 경우 IDEA에서 매개변수에서 직접 선언할 수 있으니 생성자 매개변수로 올리라는 warnning을 표시한다.
이 경우 @Suppress 애노테이션을 이용해서 경고를 없애줄 수 있지만, 무분별한 @Suppress 애노테이션은 필요한 알림까지 묻혀버리게 할 수 있는 위험성이 있다.
다른 방법으로는 다른 프로퍼티처럼 setter의 접근제어를 protected로 선언하는 방법인데, 이렇게 하면 내부적으로만 변경을 열어뒀기에 직접 객체 내부에서 변경을 하지 않는한 안전하다.
객체 자체에서 변경을 시도할 수 있지만, 이건 불변 프로퍼티(immutable)도 개발자가 변경 프로퍼티(mutable)로 바꿀 수 있는 것은 동일하다고 판단된다.

4. nullable

Java → Kotlin으로 변경하면서 가장 많이 만나는 이슈 중 하나가 nullable 이슈이다.
데이터베이스의 스키와 Entity의 스키마가 불일치 하는경우 타입이 다른건 JPA에서 잡아서 빌드 시점에 알려줄 수 있다.
하지만, nullable한 Column을 Entity에 non-nullable하게 선언한 경우에는 Column에 값이 null이라면 런타임 오류가 발생하게 된다.
Java로 Entity 작성 할 때는 모두 nullable한 Property 이기 때문에 이를 염두해두고 코드를 작성하거나 방어 코드를 통해 오류를 감지 및 조치가 가능하지만, Kotlin은 타입에서부터 non-nullable을 보장해줄 수 있다. 그리고 이러한 특징이 런타임 오류를 발생시킬 수 있다.
이 경우 @Column 애노테이션의 nullable 속성 명시를 이용해 프로퍼티의 속성 타입을 알려주면 위와같은 런타임 오류를 좀 더 막아주기 쉽고 파악하기도 쉬워질 수 있다.
@Column(nullable = false) var title: String = title protected set @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(nullable = false) var writer: User = writer protected set
Kotlin
복사

5. 외부에 노출하는 연관관계 Collection은 Immutable Collection을 노출하자.

JPA에서 연관관계의 요소 변경은 데이터베이스의 변경을 유발한다.
그렇기에 프로퍼티를 불변(val)으로 선언해도 위/변조가 가능하다.
@Entity @Table(name = "`user`") class User( name: String, ) : PrimaryKeyEntity() { @Column(nullable = false, unique = true) var name: String = name protected set @OneToMany(fetch = FetchType.LAZY, cascade = [CascadeType.ALL], mappedBy = "writer") val mutableBoards: MutableList<Board> = mutableListOf() } @DataJpaTest(showSql = true) class UserRepositoryTest { @Autowired private lateinit var userRepository: UserRepository @Autowired private lateinit var testEntityManager: TestEntityManager @Test fun test() { val user = User("홍길동") val board1 = Board("게시판", "내용", BoardInformation(null, 1), user, setOf()) val board2 = Board("게시판", "내용", BoardInformation(null, 1), user, setOf()) testEntityManager.persist(user) testEntityManager.persist(board1) testEntityManager.persist(board2) testEntityManager.flush() testEntityManager.clear() val findUser = userRepository.findById(user.id).get() val board3 = Board("게시판", "내용", BoardInformation(null, 1), user, setOf()) findUser.mutableBoards.add(board3) testEntityManager.merge(findUser) testEntityManager.flush() testEntityManager.clear() } }
Kotlin
복사
하지만, 연관관계를 변경하는것은 Entity의 특성상 필요하기 때문에 MutableList를 List로 바꿀 순 없다. 그렇기에 내부 조작용 프로퍼티와 외부 노출용 프로퍼티를 별도로 두어 관리할 수 있다.
@Entity @Table(name = "`user`") class User( name: String, ) : PrimaryKeyEntity() { @Column(nullable = false, unique = true) var name: String = name protected set @OneToMany(fetch = FetchType.LAZY, cascade = [CascadeType.ALL], mappedBy = "writer") protected val mutableBoards: MutableList<Board> = mutableListOf() val boards: List<Board> get() = mutableBoards } @Test fun test() { val user = User("홍길동") val board1 = Board("게시판", "내용", BoardInformation(null, 1), user, setOf()) val board2 = Board("게시판", "내용", BoardInformation(null, 1), user, setOf()) testEntityManager.persist(user) testEntityManager.persist(board1) testEntityManager.persist(board2) testEntityManager.flush() testEntityManager.clear() val findUser = userRepository.findById(user.id).get() val board3 = Board("게시판", "내용", BoardInformation(null, 1), user, setOf()) findUser.boards.add(board3) // compile error : Unresolved reference: add }
Kotlin
복사
하지만, 이 방식은 문제가 존재한다. 다음 코드를 보자.
@Test fun test() { val user = User("홍길동") val board1 = Board("게시판", "내용", BoardInformation(null, 1), user, setOf()) val board2 = Board("게시판", "내용", BoardInformation(null, 1), user, setOf()) testEntityManager.persist(user) testEntityManager.persist(board1) testEntityManager.persist(board2) testEntityManager.flush() testEntityManager.clear() val findUser = userRepository.findById(user.id).get() val boards = findUser.boards assertEquals(2, boards.size) val board3 = Board("게시판", "내용", BoardInformation(null, 1), user, setOf()) findUser.writeBoard(board3) assertEquals(2, boards.size) // assert error : expected: <2> but was: <3> }
Kotlin
복사
findUser.boards로 boards를 조회하는 시점에 참조 주소값을 가지고 있기에 이후 writeBoard로 게시판을 추가했을때 내용이 연동되는 것을 볼 수 있다. 그렇기에 다음과 같이 수정해서 boards에 영향을 끼치지 않게 바꿔주는게 좋다.
@Entity @Table(name = "`user`") class User( name: String, ) : PrimaryKeyEntity() { @Column(nullable = false, unique = true) var name: String = name protected set @OneToMany(fetch = FetchType.LAZY, cascade = [CascadeType.ALL], mappedBy = "writer") protected val mutableBoards: MutableList<Board> = mutableListOf() val boards: List<Board> get() = mutableBoards.toList() fun writeBoard(board: Board) { mutableBoards.add(board) } }
Kotlin
복사

출처