[Java to Kotlin] 자프링 서버를 코프링 서버로 리팩토링

bebeis·2025년 11월 17일

서브 미션

  • Java로 작성된 도서관리 애플리케이션을 Kotlin으로 완전히 리팩토링 한다.

도메인(JPA 엔티티)

  • 도메인 객체를 POJO로 만들고, JPA 엔티티와 매핑하는 프로세스를 만들어도 되지만, 실용적인 관점에서 도메인 객체와 엔티티를 하나의 클래스에서 사용한다.

Book

@Entity
class Book(
    val name: String,

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null,
)
  • id는 Insert 전까지 null일 수 있으므로 Long? 타입 사용
    • default 파라미터 활용
  • JPA를 사용하기 위해서는 기본 생성자가 존재해야 한다. (없으면 에러 발생)
    • 그렇다면, 생성자를 추가로 만들어야 할까?
    • kotlin-jpa 플러그인은 이런 문제를 해결해준다.

kotlin-jpa 플러그인

// build.gradle
plugins {
    id "org.jetbrains.kotlin.plugin.jpa" version "1.6.21"
}
  • 기본 생성자 문제 + 클래스/필드의 final 문제를 자동으로 처리해준다.
  • 컴파일 시점에 protected constructor() : this(name = "", id = null)과 같은 코드를 만들어준다.
  • 또한JPA는 엔티티를 상속해서 프록시 객체를 만들기 때문에 클래스가 open 이어야 한다. kotlin-jpa는 자동으로 엔티티 클래스를 open 시켜준다.
    • Lazy Loading을 위한 필드도 open으로 자동 변경해준다.

강의를 들으면서 알게 되었는데, default parameter는 제일 아래에 위치하는게 컨벤션이라고 한다.

Kotlin 리플렉션 에러

nested exception is java.lang.NoClassDefFoundError: kotlin/reflect/full/KClasses

  • 코틀린 클래스에 대한 리플렉션을 할 수 없어 에러가 발생한다. Kotlin 리플렉션 라이브러리를 넣어주어야 한다.
dependencies {
    implementation("org.jetbrains.kotlin:kotlin-reflect:1.6.21")
}

어노테이션 프로퍼티

@Entity
class User(
    var name: String,
    
    val age: Int?,
    
    @OneToMany(mappedBy = "user", cascade = [CascadeType.ALL], orphanRemoval = true)
    val userLoanHistories: MutableList<UserLoanHistory> = mutableListOf(),
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null,
) {
}

Java에서는 CascadeType[] cascade()라는 @OneToMany 필드에 cascade = Cascade.ALL을 바로 넣어주어도 됐지만, Kotlin에서는 배열 타입 어노테이션 필드에는 정확히 배열을 넣어 주어야 한다.

코틀린에서 JPA 프록시 활용 시 주의해야 할 점

  • Kotlin은 기본적으로 Class도 final, 함수도 final이다. 하지만 JPA를 사용할 때 Proxy Lazy Fetching을 완전히 이용하려면 클래스는 상속가능해야 한다.
  • 일반적인 class로 선언하면 @OneToMany에 있어서는 Lazy Fetching이 동작하지만 @ManyToOne에 대해서는 Lazy Fetching 옵션을 명시적으로 주더라도 동작하지 않는다.
  • all-open 기능을 통해 @Entity 클래스들은 Decompile을 했을 때도 class가 열려 있게끔 처리해줘야 하는데, 매우 번거롭다.
  • 이 부분을 플러그인이 처리해준다.
plugins {
  id "org.jetbrains.kotlin.plugin.allopen" version "1.6.21"
}

*// plugins, dependencies와 같은 Level (즉 build.gradle 최상단)*
allOpen {
  annotation("javax.persistence.Entity")
  annotation("javax.persistence.MappedSuperclass")
  annotation("javax.persistence.Embeddable")
}

Setter를 열어둘까 말까?

@Entity
class User(
    var name: String,
    val age: Int?,
    // ...
) {
    fun updateName(name: String) {
        this.name = name
    }
}
  • setter가 public으로 열려있다. 외부에서 setter를 사용할 수도 있다.

그렇다면 setter를 막아볼까?

backing property

class User(
    private var _name: String
) {
    val name: String
        get() = this._name
}

custom setter

class User(
    name: String // 프로퍼티가 아닌, 생성자 인자로만 name을 받는다
) {
    var name = name
        private set
}

두 방식 모두 외부에서 setter에 접근하는 것을 막았지만, 코드가 깔끔해지지 않는다. 프로퍼티가 더 많아지면 반복되는 부분이 더 많아질 것이다.

setter를 열어두고 사용하지 않을 것인지, 아니면 setter 자체를 열어두지 않을 것인지 컨벤션을 맞추고 활용하는게 중요할 것 같다.

생성자 안의 프로퍼티, 클래스 body 안의 프로퍼티

@Entity
class User(
    var name: String,

    val age: Int?,

    @OneToMany(mappedBy = "user", cascade = [CascadeType.ALL], orphanRemoval = true)
    val userLoanHistories: MutableList<UserLoanHistory> = mutableListOf(),

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null,
) {

    init {
		// ...

userLoanHistoriesid는 default parameter를 가지고, 사실상 주생성자 안에 있을 필요가 없다.

@Entity
class User(
    var name: String,
    val age: Int?,
) {
    @OneToMany(mappedBy = "user", cascade = [CascadeType.ALL], orphanRemoval = true)
    val userLoanHistories: MutableList<UserLoanHistory> = mutableListOf()
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null
    
    // ... functions
}

두 가지 방법 모두 괜찮다고 생각한다. 다만, 명확한 기준을 가지고 일관성 있게 적용하는 것이 중요할 것 같다.

JPA와 data class

JPA Entity는 data class를 피하는 것이 좋다. data classequals, hashCode, toString 등의 함수를 자동으로 만들어주는데, 문제는 양방향 연관관계를 가진 엔티티 간의 순환 참조가 equals()에서 발생할 수 있기 때문이다.

도메인 객체에 명시적으로 constructor 키워드 붙여주기

Entity (Class)는 여러 곳에서 생성될 수 있다. Entity가 생성되는 곳을 추적하기 위해선 constructor 지시어를 명시적으로 작성하고 추적하면 훨씬 편하다.

  • constructor 없이도 기본적으로 사용되는 곳을 추척할 수 있긴 한데, constructor를 쓰면 생성되는 로직만을 편하게 찾을 수 있음

Persistence Layer(Repository)

자바에서 JPA에서는 쿼리 메서드로 반환되는 타입을 원하는대로 작성할 수 있었다.

  • Member
  • Optional<Member>
  • List<Member>

Kotlin에서는 Optional을 사용하지 않아도 null 가능성을 표시할 수 있다. 이 부분을 고려해서 리팩토링하면 된다.

Service Layer

@Service
class UserService(
    private val userRepository: UserRepository,
) {
}
@Transactional
fun saveUser(request: UserCreateRequest) {
    // ...
}

이렇게 구현했더니 에러가 발생했다.

Error: Methods annotated with '@Transactional' must be overridable

@Transactional은 프록시로 동작한다. CGLIB였나 그걸로 동작했던 걸로 기억한다. 이를 위해선 메서드 상속이 가능해야 한다.
하지만 Kotlin에서는 기본적으로 final class, final method이다. 즉, 클래스에 대한 상속이나 메소드에 대한 오버라이드가 불가능하다.

뭔가 이런 문제도 해결해줄 수 있는 플러그인이 있지 않을까?
kotlin-spring 플러그인을 사용하면 필요한 클래스에 대해 상속과 오버라이드를 자동 허용해준다.

plugins {
    id "org.jetbrains.kotlin.plugin.spring" version "1.6.21"
}

DTO 변환 예시

@Transactional(readOnly = true)
fun getUsers(): List<UserResponse> {
    return userRepository.findAll()
        .map { user -> UserResponse(user) }
		// 또는 메서드 참조 활용
		// .map(::UserResponse)
}

조회

Jpa Repository에서 Optional을 사용하는 경우 다음과 같이 작성해야 한다.

@Transactional
fun updateUserName(request: UserUpdateRequest) {
    val user = userRepository.findById(request.id).orElseThrow(::IllegalArgumentException)
    user.updateName(request.name)
}

@Transactional
fun deleteUser(name: String) {
    val user = userRepository.findByName(name).orElseThrow(::IllegalArgumentException)
    userRepository.delete(user)
}

Optional을 사용하지 않고 null 허용 타입을 사용하면 다음과 같이 코드를 바꿀 수 있다.

@Transactional
fun loanBook(request: BookLoanRequest) {
    // ...
    val book = bookRepository.findByName(request.bookName) ?: throw IllegalArgumentException()
    val user = userRepository.findByName(request.userName) ?: throw IllegalArgumentException()
    
    user.loanBook(book)
}

?: throw IllegalArgumentException() 부분이 뭔가 반복된다. 별도의 메서드로 분리시켜보자.

fun fail(): Nothing {
    throw IllegalArgumentException()
}

문제는 findById() 메서드의 경우 CRUD Repository에 따라 Optional을 반환한다.
findById()를 사용하는 부분만 orElseThrow()를 사용해야 할까?

코틀린의 확장함수를 사용하면 Optional이 아닌 nullable한 타입으로 반환할 수 있게 만들 수 있다.

fun <T, ID> CrudRepository<T, ID>.findByIdOrNull(id: ID): T? = findById(id).orElse(null)
@Transactional
fun updateUserName(request: UserUpdateRequest) {
    val user = userRepository.findByIdOrNull(request.id) ?: fail()
    user.updateName(request.name)
}

또는 ?: fail() 부분을 확장함수로 밀어 넣을 수도 있다.

fun <T, ID> CrudRepository<T, ID>.findByIdOrThrow(id: ID): T {
    return this.findByIdOrNull(id) ?: fail()
}
@Transactional
fun updateUserName(request: UserUpdateRequest) {
    val user = userRepository.findByIdOrThrow(request.id)
    user.updateName(request.name)
}

인텔리제이 Convert Java File to Kotlin File 기능 활용하기

코틀린 코드가 자바 코드로 변경된다. 하지만 앞서 자바 코드를 코틀린에서 사용할 때와 비슷하게, 자바 코드를 코틀린 코드로 자동 변경할 때도 nullable을 신경써야 한다. (이 부분은 IDE가 완벽하게 처리하지 못한다)

의도에 맞게 타입을 적절히 변경해주자.

예시

public class BookLoanRequest {
    private String userName;
    private String bookName;
    
    public BookLoanRequest(String userName, String bookName) {
        this.userName = userName;
        this.bookName = bookName;
    }
    // getters...
}

IDE 변환 후

class BookLoanRequest(val userName: String, val bookName: String)

또한 어떤 경우에는 constructor를 사용하지 않고 init 블럭만을 활용하는 코드로 바꿔주기도 한다. 더 깔끔한 코드로 변경해주자.

DTO의 data class

DTO의 경우는 class 대신 data class가 적합하다. 현재는 equals, hashCode, toString을 사용하고 있지 않지만 DTO의 의미를 생각해 보았을 때 언제라도 사용될 수 있기 때문이다.

classdata class로 바꿔주고 클래스 포맷팅을 정리해주자.

data class BookLoanRequest(
    val userName: String,
    val bookName: String
)

Controller

Controller도 크게 특별한 부분은 없다.

@RestController
class UserController(
    private val userService: UserService,
) {
    @PostMapping("/user")
    fun saveUser(@RequestBody request: UserCreateRequest) {
        userService.saveUser(request)
    }

    @GetMapping("/user")
    fun getUsers(): List<UserResponse> {
        return userService.getUsers()
    }

    @PutMapping("/user")
    fun updateUserName(@RequestBody request: UserUpdateRequest) {
        userService.updateUserName(request)
    }

    @DeleteMapping("/user")
    fun deleteUser(@RequestParam name: String) {
        userService.deleteUser(name)
    }
}
  • 만약 @RequestParam name: String?과 같이 null이 허용된다면, @RequestParam(required = false)를 해주지 않더라도 API 요청시 name이라는 쿼리 파라미터는 선택 파라미터가 된다.

Boot Application

@SpringBootApplication
class LibraryAppApplication

fun main(args: Array<String>) {
    runApplication<LibraryAppApplication>(*args)
}
  • String[] args 대신 args: Array<String> 사용
  • 스프레드 연산자 *args 사용

특이한 부분이 있다면, Spring에서 CrudRepository와 관련해 Kotlin을 지원하기 위해 findByIdOrNull을 만들어 두었던 것처럼 SpringBootApplication 역시 runApplication()이라는 inline 함수를 만들어 두었다.

Jackson 라이브러리가 코틀린 클래스 생성 실패

Error: Resolved [org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot construct instance of com.group.libraryapp.dto.book.request.BookRequest ...]

이 에러는 Jackson이 Kotlin Class를 생성하지 못해 발생하는 에러이다. Jackson은 자바 라이브러리이므로, 코틀린의 필드 기반 규칙, null 타입 같은 걸 이해하지 못해 에러가 발생할 수 있다.

이런 문제 또한 Kotlin Jackson Module을 의존성에 추가해 해결할 수 있다.

implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.13.3'
profile
단순함은 복잡함을 이긴다.

0개의 댓글