
plugins {
id("org.springframework.boot") version "3.2.3"
id("io.spring.dependency-management") version "1.1.4"
kotlin("jvm") version "1.9.22"
kotlin("kapt") version "1.9.22"
// 여기
kotlin("plugin.spring") version "1.9.22"
}
@Component, @Async, @Transactional, @Cacheable, @SpringBootTest 사용하기 위함.
Thanks to meta-annotations support, classes annotated with @Configuration, @Controller, @RestController, @Service or @Repository are automatically opened since these annotations are meta-annotated with @Component.
Of course, you can use both
kotlin-allopenandkotlin-springin the same project.
Kotlin은 기본적으로 클래스가 public final class로 컴파일되어 Spring AOP 적용이 되질 않는다. 따라서 적용 대상 클래스들에 대해 명시적 open 키워드가 필요하다. kotlin-spring plugin은 Kotlin 환경에서 Spring AOP 적용 대상 Bean들에 대해 명시적 open 키워드를 작성하지 않고도 사용할 수 있도록 열어준다.
plugins {
kotlin("plugin.jpa") version "1.9.22"
kotlin("plugin.noarg") version "1.9.22"
}
// ...
noArg {
annotation("jakarta.persistence.Entity")
annotation("jakarta.persistence.MappedSuperclass")
annotation("jakarta.persistence.Embeddable")
}
Also notice that this is not provider dependent. It is a JPA sepcification.
JPA v2.0 JSR-317 and v2.1 JSR-338 says:
The entity class must have a no-arg constructor. The entity class may have other constructors as well. The no-arg constructor must be public or protected.
위 명세의 설명처럼, JPA를 사용하려면 매개변수가 없는 기본 생성자가 열려있어야 한다. Kotlin은 컴파일 시 모든 클래스에 대해 기본적으로 public final class가 되고 기본 생성자가 생성되지 않는다. 일일이 no-arg constructor를 코드에 작성하기도 불편하다. 따라서 위 gradle 처럼 JPA 관련 애노테이션이 붙어있는 클래스 들에 대해 기본 생성자를 자동으로 만들도록 플러그인을 사용하는 것이 좋다.
만약 런타임에 해당 플러그인 없이 사용하면 InstantiationException이 발생한다.
javax.persistence.PersistenceException: org.hibernate.InstantiationException: No default constructor for entity:
: me.ramos.guide.domain.Player
Kotlin All-open compiler plugin
allOpen {
annotation("jakarta.persistence.Entity")
annotation("jakarta.persistence.Embeddable")
annotation("jakarta.persistence.MappedSuperclass")
}
앞서 spring plugin과 마찬가지다. 프록시 적용을 하기 위해 열어준다. (ex. JPA lazy-loading)
Kotlin으로 JPA를 사용한 프로젝트 예시를 구글링해보면 무지성으로 data class에 @Entity 애노테이션을 달고 엔티티 매핑을 하는 사람들이 꽤 많다.
해당 방법이 가능하긴 하지만, 개인적으론 지양한다.
우선 Kotlin에서 data class는 equals, hashCode, toString이 자동으로 구현된다. 하지만 이 특성이 다음과 같은 문제들을 야기한다.
data class는 또한 기본적으로 open이 안되기 때문에 Proxy 동작이 되질 않는다. all-open 플러그인을 사용하면 open 할 순 있지만, lazy-loading 시 불필요한 쿼리가 발생한다. toString, hashCode, equals 메서드가 기본적으로 구현되기 때문에 원하지 않는 쿼리까지 뽑히게 되는 것이다.
따라서 JPA 명세와 반대되는 행위라서 일반 class로 엔티티 매핑을 하자.
그렇다면, 어떻게 하는게 좋을까?
package me.ramos.guide.domain
import jakarta.persistence.*
@Entity
@Table(name = "players")
class Player(
@Column(name = "name", nullable = false, length = 50)
var name: String,
@Column(name = "back_number")
var backNumber: Int,
@Column(name = "nationality", nullable = false, length = 50)
var nationality: String,
) {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id")
var team: Team? = null
fun updateBackNumber(backNumber: Int) {
this.backNumber = backNumber;
}
// 연관관계 편의 메소드
fun initTeam(team: Team?) {
this.team = team
if (team != null && !team.players.contains(this)) {
team.players.add(this)
}
}
}
var 타입을 지정하자.null 가능성이 있는 필드들은 var 타입과 함께 ? = null을 만들어두자.@Id 컬럼이 이에 해당한다.@Id 컬럼이 IDENTITY 전략을 사용할 경우, 초깃값을 0으로 두는 경우가 간혹 보이는데, null 가능 필드를 사용하면 Wrapper 타입이 사용되므로 null로 두는게 맞다.null인 경우이기 때문이다.null 불가능 필드인 경우 primitive 타입이 사용되므로 0L로 초깃값을 두자.Number의 하위 타입인 경우 해당 값이 0인 경우에만 새로운 엔티티로 판단한다.Spring Data JPA에서 새로운 엔티티를 판단하는 근거는 아래와 같다.
@Transactional
@Override
public <S extends T> S save(S entity) {
Assert.notNull(entity, "Entity must not be null.");
if (entityInformation.isNew(entity)) {
em.persist(entity);
return entity;
} else {
return em.merge(entity);
}
}
@Override
public boolean isNew(T entity) {
if (!versionAttribute.isPresent()
|| versionAttribute.map(Attribute::getJavaType).map(Class::isPrimitive).orElse(false)) {
return super.isNew(entity);
}
BeanWrapper wrapper = new DirectFieldAccessFallbackBeanWrapper(entity);
return versionAttribute.map(it -> wrapper.getPropertyValue(it.getName()) == null).orElse(true);
}
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));
}
근데, 프로퍼티를 생성자에 넣냐 body에 넣냐에 대한 부분은 방법이 많다. Mockk 같은 테스트 라이브러리를 사용한다면 위에 제시한 예시 코드처럼 class body에 선언했을 경우, id를 넣어줄 방법이 없어 문제가 된다.
생성자 최하단에 id를 선언해보자.
package me.ramos.guide.domain
import jakarta.persistence.*
@Entity
@Table(name = "players")
class Player(
@Column(name = "name", nullable = false, length = 50)
var name: String,
@Column(name = "back_number")
var backNumber: Int,
@Column(name = "nationality", nullable = false, length = 50)
var nationality: String,
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null
) {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id")
var team: Team? = null
fun updateBackNumber(backNumber: Int) {
this.backNumber = backNumber;
}
// 연관관계 편의 메소드
fun initTeam(team: Team?) {
this.team = team
if (team != null && !team.players.contains(this)) {
team.players.add(this)
}
}
}
id에 default parameter를 넣어두었기에 필요하지 않을땐 null을 전달할 필요도 없고 테스트 코드에서 id 주입이 필요하다면 그땐 명시적으로 임의의 id를 전달할 수도 있다.
(이 문제는 선호도 차이일 것 같아 결론을 못짓겠다...)