목록 보기
스포카에서 Kotlin으로 JPA Entity를 정의하는 방법
기타

스포카에서 Kotlin으로 JPA Entity를 정의하는 방법

spoqa
spoqa
2022년 8월 16일

안녕하세요. 키친보드 팀의 백엔드 프로그래머 남경호입니다. 최근 Kotlin이 서버 언어로 주목받기 시작하면서 Kotlin + Spring으로 서버를 개발하는 케이스가 많아졌습니다. 그러면서 자연스레 Kotlin으로 JPA를 사용하는 사례 또한 많아졌는데요. 다만 Kotlin으로 JPA를 사용하다 보면, 정확하게는 Entity를 정의하다 보면 Kotlin의 언어적 특성과 잘 맞지 않는 부분을 많이 발견하게 됩니다. JPA 는 Java Persistence API의 약자로 Java 진영의 ORM 표준을 말합니다. 그래서 데이터베이스를 매핑해주는 Entity를 정의할 때 Java를 기준으로 Entity를 정의하기 쉽게 만들어져 있기 때문입니다. 이 글은 스포카에서 Kotlin으로 JPA Entity를 보다 Entity 답게 사용하기 위해 고민한 내용을 담은 것입니다. 그동안 Kotlin을 사용하면서 Java로 Entity를 정의했을 때의 차이와 최대한 Entity의 컨셉을 해치지 않으면서 어떻게 Kotlin답게 작성하면 좋을지 고민한 것들을 적어보려고 합니다. 문제상황 Kotlin + JPA를 소개하는 블로그나 영상들을 보면 Entity를 정의한 부분에서 아쉬운 코드들을 많이 볼 수 있습니다. 물론 주제가 Entity를 좀 더 객체 지향적으로 정의하는 것이 아니라 Java보다 Kotlin으로 JPA를 사용했을 때 장점을 소개하는 것에 초점이 맞춰져 있다 보니 그러리라 생각이 듭니다. 하지만 저는 ORM에서 가장 중요한 것은 도메인 모델인 Entity라 생각합니다. 비즈니스 로직이 가장 풍부해야 하며 도메인이 가진 특징을 가장 잘 표현해줄 수 있어야 합니다. 그래서 Entity를 정의할 때 JPA의 규격을 위반하지 않으면서 Kotlin이 추구하는 개발 철학에 맞게 작성하면 좋다고 생각합니다. 그럼 제가 생각하는 안티 패턴 사례를 좀 더 자세히 살펴보겠습니다. 무분별한 mutable property 사용 Kotlin은 불변(immutable)을 지향합니다. 변수(mutable)는 부작용이 많기 때문이지요. 앞서 말했다 시피 JPA는 java에 맞게 ORM을 사용하기 위한 API를 제공해줍니다. 그래서 기본적으로 mutable property에 초점이 맞춰져 있습니다. 사실 Entity가 가진 특성을 살펴보면 JPA가 java의 특성에 맞게 mutable property를 사용하는 것이 아니라 Entity의 특성에 따라 mutable property를 사용한 것이 맞는다는 생각이 듭니다. (Entity에 대한 글 을 참고해 주세요) 아무튼 그래서 Kotlin으로 Entity를 정의했음에도 불구하고 아래와 같은 코드를 쉽게 접할 수 있습니다. @Entity class Person( @ID var id: Long,

@Column var name: String,

@Column var age: Int, ) 위 코드의 문제점은 무엇일까요? 바로 캡슐화가 되어있지 않다는 것입니다. 그래서 Entity가 가진 상태를 그대로 노출하는 것은 차치하고 그 상태를 외부에서 아무런 제약 없이 변경할 수 있습니다. 심지어 Entity에서 바꾸지 말아야 할 식별자까지 바꿀 수 있도록 정의한 것은 정말 치명적입니다. 다만 Kotlin에서 클래스가 가진 상태는 field가 아니라 property입니다. (Kotlin 공식 문서 를 참고해 주세요) 즉, 아래 Java 코드와 같이 field를 그대로 노출한 코드와 위 Kotlin 코드와는 차이가 있습니다. @Entity class Person ( @ID long id

@Column String name

@Column int age

public Foo(long id, String name, int age) ( this.id = id this.name = name this.age = age ) ) 왜냐하면 Property는 Field를 외부에 직접 노출하지 않고 Setter와 Getter를 통해 노출하는 것이기 때문입니다. 만약 위 Java 코드에 Field를 그대로 노출하지 않고 Property로 노출하도록 하면 아래와 같이 적어볼 수 있겠습니다. @Entity public class Foo ( @ID private long id

@Column private String name

@Column private int age

public Foo(long id, String name, int age) ( this.id = id this.name = name this.age = age )

public void setId(long id) ( this.id = id )

public long getId() ( return id )

public void setName(String name) ( this.name = name )

public String getNmae() ( return name )

public void setAge(int age) ( this.age = age )

public int getAge() ( return age ) ) 다시 주제로 돌아와서 저는 Field로 노출하든 Property로 노출하든 Entity가 가진 내부 상태의 변경을 직접 노출하도록 정의하는 것은 좋지 못하다고 생각합니다. 내부 상태를 변경하는 것은 특정 행위를 통해 이루어집니다. foo.age = 3 또는 foo.setAge(3)와 같은 코드는 Entity의 상태를 바꾸는 목적을 표현해주지 못합니다. 차라리 foo.age의 Setter는 외부에 노출하지 않고 foo.getOld()와 같이 표현하는 게 더 나을 것입니다. Data class 활용 다음으로 가장 많이 발견되는 사례는 바로 Data class를 이용한 Entity를 정의하는 사례입니다. Kotlin의 Data class 는 데이터를 전달하기 위한 용도로 사용하는 것을 목적으로 만들어진 클래스입니다. 데이터를 전달하는 구조체는 사용자가 전달한 데이터의 원본을 유지하는 것이 중요합니다. 그래서 저는 Data class를 사용할 때 꼭 필요한 경우가 아니라면 불변변수(immutable)를 사용합니다. 앞서 Entity에 대한 글 을 보았다면 알 수 있겠지만 Entity는 식별자 외에는 생명주기 동안 상태가 변경될 수 있습니다. 그리고 Entity는 특정 생명주기를 가지고 비즈니스 요구사항을 수행하는 객체이므로 단순히 데이터를 전달하기 위한 용도로 사용하는 Data class와는 성격이 다르다고 볼 수 있습니다. 오히려 Value Object와 유사한 성격을 가진다고 볼 수 있습니다. (Value Object에 대한 글을 참고해 주세요) 그러면 왜 사람들은 Data class를 사용할까요? 매개변수가 없는 생성자를 사용하지 못한다는 제약조건이 있음에도 말입니다. 아마 Data class가 copy와 equals, hashCode, toString을 기본으로 제공해주기 때문이지 않겠느냐고 조심스럽게 예상해봅니다. 아니라면 Entity를 단순히 DB 테이블의 상태를 전달해주는 객체로 본다는 것인데, 요즘 많은 블로그나 영상을 통해 기존에 사용하던 Entity가 도메인 모델이 아닌 Object Mapper로써의 역할만 하도록 정의하는 방식이 좋지 않다는 것을 배워가고 있기 때문에 Kotlin으로 JPA를 사용하고 있는 개발자라면 전자의 이유가 클 것이라는 게 제 생각입니다. 하지만 Kotlin Data class를 소개하는 문서에서 보면 아래와 같은 문구를 볼 수 있습니다. The compiler automatically derives the following members from all properties declared in the primary constructor 즉, 기본 생성자에 정의한 Property 들만 copy와 equals, hashCode, toString함수들에 활용된다는 것입니다. 그래서 아래와 같이 Data class를 정의하고 equals를 호출하면 예상치 못한 결과를 볼 수 있습니다. data class Person(val name: String) ( var age: Int = 0 )

val person1 = Person("John") val person2 = Person("John") person1.age = 10 person2.age = 20

person1 == person2 // true 위와 같은 상황을 피하기 위해서는 기본생성자에 모든 Property를 정의해야 합니다. 하지만 Entity를 생성할 때 모든 상태를 생성자 매개변수로 받는 것이 과연 좋은 디자인일까요? 저는 꼭 필요한 매개변수로 받고 필요한 상태는 Entity 내부에서 기본값으로 정의하도록 하는 게 좋다고 생각합니다. 아래와 같이 주문 상태를 가진 주문 Entity를 정의한다고 가정해 보겠습니다. Data class로 정의한다면 아래와 같이 정의할 수 있을 것입니다. @Entity data class Order( @Id val id: UUID,

@Column val orderAt: LocalDateTime,

@Column val state: OrderState, ) 만약 주문서가 최초 생성될 때 주문상태가 SUBMITTED 상태로 해야 한다면 어떻게 할 수 있을까요? 아마 아래와 같이 사용하는 코드에서 매개변수로 넣어주거나 기본값을 주려고 할 것입니다. @Entity data class Order( @Id val id: UUID,

@Column val orderAt: LocalDateTime,

@Column val state: OrderState = OrderState.SUBMITTED, )

Order( id = UUID.randomUUID(), orderAt = LocalDateTime.now(), state = OrderState.SUBMITTED, ) 하지만 만약에 사용자가 주문을 생성할 때 상태를 SUBMITTED가 아닌 다른 값으로 넣는다면 어떨까요? 위와 같은 방법으로는 이와 같은 사용을 막을 방법이 없을 것입니다. Order( id = UUID.randomUUID(), orderAt = LocalDateTime.now(), state = OrderState.CANCELED, ) 그래서 아래와 같이 생성자 매개변수로 두지 않고 Entity가 생성될 때 기본값을 가지도록 할 수 있습니다. 하지만 이렇게 정의한다면 앞서 보여준 사례처럼 copy와 equals, hashCode, toString 함수들을 원하는 대로 활용할 수 없게 됩니다. 결국 Data class를 활용하는 것보다 일반 클래스를 사용하는 것이 더욱 자유로운 설계를 할 수 있습니다. @Entity data class Order( @Id val id: UUID,

@Column val orderAt: LocalDateTime, ) ( @Column val state: OrderState = OrderState.SUBMITTED )

Order( id = UUID.randomUUID(), orderAt = LocalDateTime.now(), ) 한편 Entity의 동등성을 Data class의 equqls와 같이 모든 Property에 대한 비교를 통해 보장해야 할까요? 다시 Entity에 대한 글 을 보면 Entity에 대한 동일성은 오로지 식별자를 통해서 이루어진다는 것을 알 수 있습니다. 즉 생명주기 동안 Person이라는 A Entity는 언제든지 이름 또는 나이가 바뀔 수 있습니다. 이름이나 나이가 바뀌었다고 해서 A Entity가 다른 Entity가 될 수 없는 것입니다. Kotlin에서 Java와 같이 equals를 따로 재정의하지 않으면 참조 비교를 통해 동일성을 확인합니다. 그래서 만약 프로그램에서 동일한 식별자를 가진 A Entity를 다른 코드에서 조회(좀 더 자세히 말하자면 영속화된 데이터를 메모리로 불러온 경우)하여 동일한 Entity인지 비교한다면 동일하다고 판단하지 않을 수 있습니다. 같은 식별자를 가진 Entity는 동일한 객체라고 판단해야 하므로 아래와 같이 equals와 hashCode를 재정의해주어야 합니다. 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) ) lateinit 사용 다음은 연관관계 정의 시 lateinit을 사용하는 경우입니다. Kotlin에서 lateinit을 통해 초기화를 뒤로 미룰 수 있는 코드를 작성할 수 있습니다. 주로 초기화에 비용이 많이 발생하는 경우 코드를 사용하는 시점까지 초기화를 미루어 꼭 필요한 경우에 초기화를 해 성능향상 및 자원 효율성에 도움을 주려는 용도로 사용합니다. 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

) 그래서 아래와 같이 생성자 매개변수로 전달받거나 기본값을 넣어줘야 합니다. @Entity class Person( id: UUID, name: String, ) ( @Id val id: UUID = id

@Column
val name: String = name

) @Entity class Person ( @Id val id: UUID = UUID.randomUUID()

@Column
val name: String = ""

) 일반적으로 Kotlin으로 JPA Entity를 정의할 때 Column만 존재하는 경우 lateinit을 사용하는 경우는 거의 없습니다. 위와 같이 생성자 매개변수를 활용하거나 기본값을 넣어주기 때문이죠. 문제는 연관관계를 정의할 때입니다. 이는 Java에서 JPA를 사용할 때 사용하던 패턴을 그대로 가져오다 보니 생긴 안 좋은 패턴이지 않나 생각됩니다. 사용사례를 한번 살펴보겠습니다. 사용자가 게시판을 작성하는 기능을 만든다고 가정해보겠습니다. 하나의 사용자는 여러 게시판을 작성할 수 있고 게시판은 작성자 정보를 저장합니다. 이 기능을 구현하기 위해 Java로 Entity를 아래와 같이 정의해보겠습니다. @Entity public class Board ( @Id @GeneratedValue private UUID id

@Column
private String title

@Column
private UUID writerId

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "writerId")
private User writer

public Board(String title, UUID writerId) (
    this.title = title
    this.writerId = writerId
)

// 생략...

) 생성자를 보면 writerId는 초기화해주지만 writer는 초기화해 주지 않는 것을 볼 수 있습니다. 하지만 Java는 이렇게 사용해도 컴파일 시 오류가 발생하지 않습니다. 기본적으로 초기화해주지 않으면 null 값을 가지기 때문이죠. Kotlin으로 위 Entity를 다시 정의해 보겠습니다. @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 ) 이렇듯 연관관계를 정의할 때 lateinit을 활용하는 경우를 많이 볼 수 있습니다. 컴파일도 잘되기 때문에 아무 문제가 없어 보입니다. 심지어 아래와 같이 조회기능을 만들어 사용해보면 정상적으로 User가 조회됩니다. @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)
)

) SQL 로그를 봐도 우리가 원했던 대로 잘 조회하는 것을 볼 수 있습니다. Hibernate: insert into "user" (name, id) values (?, ?) Hibernate: insert into board (title, writer_id, id) values (?, ?, ?) Hibernate: select board0_.id as id1_1_0_, board0_.title as title2_1_0_, board0_.writer_id as writer_i3_1_0_ from board board0_ where board0_.id=? Hibernate: select user0_.id as id1_5_0_, user0_.name as name2_5_0_ from "user" user0_ where user0_.id=? 그렇다면 잘 동작하는데 무엇이 문제일까요? 문제는 영속화한 데이터를 조회할 때가 아니라 Entity를 생성한 직후 해당 Entity를 다룰 때 발생합니다. 예를 들어 저장을 위해 Entity를 생성하고 난 후 writer를 이용하여 특정한 기능을 수행하기 위해 아래와 같이 writer를 조회해 보겠습니다. 그럼 런타임 오류가 발생합니다. val user = User("홍길동") val board = Board2("게시판", user.id) val writer = board.writer // error: lateinit property writer

댓글 0

댓글을 작성하려면 로그인이 필요합니다.

댓글을 불러오는 중...