Kotlin

Kotlin Springboot Rest API 만들기 3 - Entity 만들기

pepega 2023. 1. 3. 21:20

전체 코드

https://github.com/GHGHGHKO/pepega-blog-kotlin

 

이전 포스팅에서는

HelloWorld API를 개발 후

Postman을 활용하여 API를 호출하였습니다.

 

이번 포스팅에서는

앞으로 만들 Entity 구조를 간략하게 설명하고

예시로 우아하게 Entity를 구성하는 방법에 대해 작성하였습니다.

불필요한 쿼리는 사용하지 않는게 좋으니까요!

 

다음 포스팅에서 로그인과 회원가입을 만들어보겠습니다!

 

 

 

1. domain(entity) 생성

JPA에서 가장 중요하다고 생각되는 것이 entity입니다.

https://spoqa.github.io/2022/08/16/kotlin-jpa-entity.html

 

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

도도카트 서비스를 개발할 때 JPA를 사용하면서 좀더 Entity를 잘 정의하는 방법에 대한 고민거리를 소개합니다.

spoqa.github.io

위 블로그에서 많은 도움을 받았습니다. 감사합니다!

 

package 밑에 (본 글에서는 com.example.pepega)

common.domain package 밑에 UserMaster.kt 를 생성합니다.

 

위처럼 간단하게 entity를 생성하니

 

primary key (ID)가 없다고 합니다.

 

모든 entity에는 primary key가 필요합니다.

중복으로 비슷한 코드를 호출하는 것은 DRY하기 때문에

상시로 있고 비슷한 데이터가 들어가는 entity는

BaseEntity 라는 abstract class를 생성하여

모든 entity에서 BaseEntity를 상속 받도록 만들겁니다.

 

2. BaseEntity 생성

common.domain package 밑에 BaseEntity.kt 를 생성합니다.

 

 

@MappedSuperclass

위 annotation이 선언된 class는 entity가 아닙니다.

상속, 코드 재사용을 위한 추상 클래스로

빨간 동그라미는 BaseEntity가 만든 것

 

공통으로 사용하는 매핑 정보를 주입해주는 역할을 합니다.

자세한 내용은 아래를 참고했습니다.

https://ict-nroo.tistory.com/129

 

[JPA] @MappedSuperclass

@MappedSuperclass객체의 입장에서 공통 매핑 정보가 필요할 때 사용한다.id, name은 객체의 입장에서 볼 때 계속 나온다.이렇게 공통 매핑 정보가 필요할 때, 부모 클래스에 선언하고 속성만 상속 받아

ict-nroo.tistory.com

 

Persistable

Spring Data JPA의 save 함수를 보면

Nullable 타입을 유도하고 있습니다.

 

 

 

코드를 확인해보면

entity가 새롭게 생성되었으면

EntityManager.persist를

 

이미 존재하는 entity 라면

EntityManager.merge를 실행합니다.

entity의 신규 여부는

위 메서드를 사용합니다.

 

entity가 isPrimitive가 아니면 (참조타입이면)

null 여부(return id == null)를 통해

새롭게 생성된 ID인지에 대한 여부를 확인합니다.

 

여기서 ID(primary key)는 nullable한 타입일 수 있다는 의미로 확인됩니다.

 

if 조건이 하나 더 있습니다.

id가 숫자 타입인 경우 (원시타입) 0L 이면

새롭게 생성된 entity인지 판단하여 return 합니다.

 

애플리케이션 내에서 생성된 entity는

영속화를 하기 전까지는 모두 0인 ID를 가집니다.

 

그렇다고 해서 이 entity들이 모두 같은 entity는 아닙니다.

 

entity의 primary key를 다루는 방법은

entity 생성 시 primary key를 함께 생성하면

 

모두 0인 ID를 가지지 않고

ID에 UUID를 사용하여 중복을 제거하기 때문에

참조 타입이 null을 가지게 됩니다.

 

Persistable 구현하기

primary key를 영속화 하기 전

미리 생성할 경우 주의할 점이 있습니다.

 

primary key가 참조타입 변수(여기서는 UUID)이면

null인 경우에만

신규 생성 entity로 간주하여 EntityManager.persist를 호출합니다.

 

여기서

entity를 생성 할 때 UUID를 같이 생성하여 영속화를 하면

참조타입 변수가 null이 아니기 때문에

EntityManager.persist 가 호출되는 것이 아닌

EntityManager.merge 가 호출됩니다. (update query)

 

예시를 만들어보겠습니다.

HelloWorld entity를 생성합니다.

 

allopen

Kotlin으로 JPA를 사용 할 때

위와 같은 Entity에 allopen 옵션과 no-args constructor 옵션을 주어야 한다고 합니다.

아래와 같습니다.

 

https://start.spring.io/

위 링크로 Kotlin JPA 프로젝트를 생성했다면 아래와 같은 플러그인이 추가된 것을 확인 할 수 있습니다.

Kotlin에서는 Spring plugin을 통해

모든 클래스에 open(open class Helloworld()) 키워드를 선언하지 않아도 되도록

All-open plugin을 포함하고 있습니다.

 

Jpa plugin을 통해 JPA 관련 클래스를 사용하는 데 문제 없도록

생성자에 매개변수가 없는 No-args plugin도 포함합니다.

 

Entity를 Decompiler로 확인해보면 아래와 같습니다.

 

Decomplied

 

Hibernate의 사용자 가이드 문서를 확인하면

Entity와 Entity가 가진 인스턴스 변수들은

final이 아니어야 한다고 합니다.

Hibernate에서 Entity는 final일 수 있지만 lazy loading을 위한 프록시를 생성할 수 없습니다.

 

build.gradle.kts에 아래와 같이 allOpen 설정을 해줍니다.

 

SpringBoot 3.x.x

 

SpringBoot 2.x.x

 

다시 decompile을 해보니 final 키워드가 사라진 것을 확인 할 수 있습니다.

 

Persistable 진짜구현하기

 

테스트 코드는 아래와 같습니다. (위에 있는 HelloWorld.kt) 기반으로 했습니다

 

Entity를 생성 할 때 UUID가 같이 생성됩니다.

Entity를 생성 할 때 함께 UUID를 생성해서

영속화를 하면 EntityManager.persist가 아닌 EntityManager.merge 함수가 호출됩니다.

1. EntityManager.merge 함수 호출에 따른 select 쿼리 조회 후

Hibernate: select h1_0.id,h1_0.name from foo h1_0 where h1_0.id=?

 

2. update 쿼리가 실행되는 것이 아닌 insert 쿼리가 호출되어

Hibernate: insert into foo (name, id) values (?, ?)

 

3. entity가 새로 생깁니다.

assertEquals(helloWorld.name, "pepega")

뭔가 깔끔하지 않습니다. (그냥 insert만 하면 좋은데요)

 

이러한 문제를 해결하기 위해

Persistable 인터페이스를 제공합니다.

 

Persistable 인터페이스는

getIdisNew 함수를 제공합니다.

 

여기서 isNew는

위에서 언급한 entityInformation.isNew(entity)에서 활용됩니다.

 

Persistable 인터페이스를 구현한

Entity를 영속화하면

JpaPersistableEntityInformation.isNew 함수가 호출됩니다.

여기서 Persistable.isNew 함수를 호출합니다.

 

이제 위 코드에서 예시로 들었던

Entity에 Persistable을 구현하겠습니다.

 

 

Hibernate: insert into foo (name, id) values (?, ?)

insert 쿼리만 실행된 것을 확인 할 수 있습니다.

 

 

뭔가 이상합니다

isNew = true면 안될 것 같은 느낌이 듭니다

 

바로 delete를 호출 할 때

삭제 쿼리가 실행되지 않습니다.

 

SimpleJpaRepository.delete 함수는 아래와 같습니다.

 

if (entityInformation.isNew(entity)) {
	return;
}

위 코드를 보니

entity가 isNew면 delete 함수가 종료됩니다.

 

실제로 테스트를 해보겠습니다.

Hibernate: insert into foo (name, id) values (?, ?)

insert 쿼리만 실행되었습니다.

entityInformation.isNew가 true로 반환되어서 delete 코드가 실행되지 않았습니다.

 

isNew가 true인 경우는

이제 막 entity를 생성해서 영속화 하기 전까지 입니다.

 

그렇다면, 영속화 한 이후와 Entity를 조회했을 때(PostLoad annotation) 혹은

Entity를 저장 한 후(PostPersist annotation) 입니다.

이 때는 isNew가 false가 되어야 합니다.

 

이 부분을 해결하기 위해

JPA의 @PostPersist와 @PostLoad를 활용하기로 하였습니다.

  • before persist is called for a new entity – @PrePersist
  • after persist is called for a new entity – @PostPersist
  • before an entity is removed – @PreRemove
  • after an entity has been deleted – @PostRemove
  • before the update operation – @PreUpdate
  • after an entity is updated – @PostUpdate
  • after an entity has been loaded – @PostLoad

 

 

Hibernate: insert into foo (name, id) values (?, ?)
Hibernate: select h1_0.id,h1_0.name from foo h1_0 where h1_0.id=?
Hibernate: delete from foo where id=?

insert 후

delete가 된 것을 확인했습니다. (delete 전 select 문이 확인되는데.. 추후 수정이 필요해보입니다)

 

 

공통 동일성 보장

kotlin에서는 Data class를 사용하면

copy, equals, hashCode, toString을 기본으로 제공해줍니다.

 

위 코드에서는 분명히 서로 다른 객체 같은데

equals(==)을 사용하니 테스트가 통과됩니다.

 

kotlin에서는 java와 같이 equals를 따로 재정의 하지 않으면

참조 비교를 통해 동일성을 확인합니다.

 

동일한 객체는 동일한 메모리 주소를 가지기 때문에 동일한 객체는 동일한 hashCode를 가져야 합니다.

equals를 재정의 한다면 hashCode도 반드시 함께 재정의 해야 합니다.

 

테스트용 Entity를 조금 수정하였습니다.

 

상속 할 수 있는 CommonEntity를 생성합니다.

 

HelloWorld.kt에 CommonEntity를 상속합니다.

 

BoringHelloWorld.kt에 CommonEntity를 상속합니다.

 

위와 같이 테스트 코드를 생성했습니다.

결과는 실패가 나옵니다.

테스트 코드를 디버깅 하니

아래와 같은 결과가 확인됩니다.

디버깅 포인트

이유는

Hibernate Proxy 때문입니다.

Hibernate는 성능 최적화를 위해

연관관계 조회 시

꼭 필요할 때까지 조회 쿼리 호출은 지연시키는

지연 조회(Lazy Loading)을 지원합니다.

 

연관관계를 조회하는 쿼리가 실행되어

실제 Entity가 생성되기 전까지

Proxy 객체를 미리 생성하여 넣어두었다가

실제 조회가 되면

Entity를 Proxy와 교체하는 방식으로 작동합니다.

 

이 문제를 해결하는 방법은

지연 로딩이 아닌 즉시 조회(Eager)를 사용하면 됩니다.

 

HelloWorld를 조회 할 때

BoringHelloWorld를 같이 조회하면 HibernateProxy로 Entity를 대신하지 않을겁니다.

 

 

this == other

 

뭔가 찝찝합니다..

 

Entity 조회 시

연결된 상관관계를 사용해야 하는 상황이라면

해결책이 될 수 있으나

 

사용하지도 않는 쿼리를 실행하는건

분석도 힘들고 성능도 좋지 않을 것 같습니다.

 

추가로 N+1문제도 발생 할 수 있을 것 같습니다.

 

여기서 해결 할 수 있는 방법은

equals를 재정의하는 것입니다.

 

클래스 타입을 체크하는 부분에서

HibernateProxy인 경우 제외하도록 했습니다.

 

식별자를 가져 올 때

HibernateProxy 객체이면 

obj.hibernateLazyInitializer.identifier

을 통해 식별자를 가져옵니다.

HibernateProxy 객체가 아니면

CommonEntity.id를 가져오도록 하였습니다.

 

EAGER -> LAZY로 변경하고

디버깅을 해보니

false가 나오지 않는다!

정상 반영이 확인됩니다.

 

테스트코드를 다시 실행하였습니다.

 

 

 

참고자료

https://techblog.woowahan.com/2675/

https://kotlinlang.org/docs/all-open-plugin.html#gradle

https://kotlinlang.org/docs/no-arg-plugin.html#gradle

https://kotlinlang.org/docs/no-arg-plugin.html

https://spoqa.github.io/2022/08/16/kotlin-jpa-entity.html

https://www.baeldung.com/jpa-entity-lifecycle-events

https://mangkyu.tistory.com/101