인프런/실전! 코틀린과 스프링 부트로 도서관리 애플리케이션 개발하기 (Java

1) 테스트 코드, 자바 어플리케이션을 코틀린으로 리팩토링

backend dev 2025. 1. 14.

 

 

1. 순수 코틀린 코드로  사칙연산 계산기 테스트코드 짜보기

 

data class로 만들어서 생성된 equals로 테스트를 짜보는 방법

data class Calculator(
    private var number:Int
) {

    fun add(operand: Int) {
        this.number += operand
    }
    fun minus(operand: Int) {
        this.number -= operand
    }
    fun multiply(operand: Int) {
        this.number *= operand
    }
    fun divide(operand: Int) {
        if (operand == 0) {
            throw IllegalArgumentException("0으로 나눌 수 없습니다")
        }
        this.number /= operand
    }
    
}

 

fun main() {
    val calculatorTest = CalculatorTest()
    calculatorTest.addTest()
}

class CalculatorTest {

    fun addTest() {
        val calculator = Calculator(5)
        calculator.add(3)

        val expectedCalculator = Calculator(8)

        // 코틀린에서 ==, != 는 객체의 동등성 비교 , === !==는 객체의 주소값 비교
        if (calculator != expectedCalculator) {
            throw IllegalArgumentException()
        }

    }

}

 

코틀린에서 ==, != 는 객체의 동등성 비교 , === !==는 객체의 주소값 비교

 

 

 

 

backing property를 이용하여 필드값을 안전하게 가져와서 비교하는 방법과

프로퍼티에 setter를 private로 설정하는 방법

class Calculator(
    private var _number:Int,
    number2:Int
) {
    val number:Int // backing property를 사용하는 방법
        get()=this._number

    var number2:Int = number2 // 일반 프로퍼티 생성후 setter를 private로 설정하는 방법
        private set


    fun add(operand: Int) {
        this._number += operand
    }
    fun minus(operand: Int) {
        this._number -= operand
    }
    fun multiply(operand: Int) {
        this._number *= operand
    }
    fun divide(operand: Int) {
        if (operand == 0) {
            throw IllegalArgumentException("0으로 나눌 수 없습니다")
        }
        this._number /= operand
    }

}

 

backing property 방법은 

Calculator의 프로퍼티를 밖에서 직접 접근 못하게 private로 해두고, 

접근용 프로퍼티를 만들어서 getter를 정의해서 해당 private 프로퍼티의 값을 반환하도록 한다.

 

일반 프로퍼티 setter private 방법은

그냥 private해둬서 밖에서 접근 못하게 한다.

fun main() {
    val calculatorTest = CalculatorTest()
    calculatorTest.addTest()
}

class CalculatorTest {

    fun addTest() {
        // given
        val calculator = Calculator(5)
        calculator.add(3)

        // when
        val expectedCalculator = Calculator(8)

        // then
        if (calculator.number != 8) {
            throw IllegalArgumentException()
        }

    }

}

 

 

 

2 Junit 사용하는 테스트코드 짜보기

 

Jnit5 어노테이션

@Test : 테스트 메소드를 지정한다. 테스트 메소드를 실행하는 과정에서 오류가 없으면 성공이다.


@BeforeEach : 각 테스트 메소드가 수행되기 전에 실행되는 메소드를 지정한다.


@AfterEach : 각 테스트가 수행된 후에 실행되는 메소드를 지정한다.


@BeforeAll : 모든 테스트를 수행하기 전에 최초 1회 수행되는 메소드를 지정한다.
- 코틀린에서는 @JvmStatic 을 붙여 주어야 한다.

@AfterAll : 모든 테스트를 수행한 후 최후 1회 수행되는 메소드를 지정한다.
- 코틀린에서는 @JvmStatic 을 붙여 주어야 한다.

 

 

Junit5를 사용하면, 메소드 단위로 테스트를 실행시킬 수 있고 클래스 단위로 테스트를 실행 시킬 수도 있다.

 

class JunitTest {

    // beforeAll,afterAll은 static 함수여야하는데
    // 코틀린은 static 필드,함수는 companion object안에서 정의한다.
    companion object {
        @BeforeAll
        @JvmStatic
        fun beforeAll() {
            println("before all")
        }

        @AfterAll
        @JvmStatic
        fun afterAll() {
            println("after all")
        }
    }


    @BeforeEach
    fun beforeEach() {
        println("before")
    }

    @AfterEach
    fun afterEach() {
        println("after")
    }

    @Test // 해당 어노테이션을 붙여주면 클래스단위,메소드 단위로 테스트를 실행할 수 있게된다.
    fun test1() {
        println("test1")
    }

    @Test
    fun test2() {
        println("test2")
    }

    @Test
    fun test3() {
        println("test3")
    }
}

 

 

 

 

Jnit을 이용하여 계산기 테스트 코드를 리팩토링 해보자

class JunitCalculatorTest {

    @Test
    fun addTest() {
        // given
        val calculator = Calculator(5)

        // when
        calculator.add(3)

        // then
        assertThat(calculator.number).isEqualTo(8)
    }
}

 

AssertProvider가 매개변수인것을 import한다.

 

 

 

위에서 사용된 단언문

assertThat(calculator.number).isEqualTo(8)

assertThat(확인하고싶은값).isEqualTo(기댓값)

 

 

단언문이란?

단언문은 특정 조건이 참인지 확인하는 문장으로, 프로그래밍에서 사용됩니다. 

조건이 거짓인 경우, AssertError를 발생시켜 프로그램을 비정상 종료시킵니다. 
단언문의 예시로는 assertThat(owner.getLastName()).isEqualTo(newLastName) 등이 있습니다. 

 

 

 

자주 사용되는 단언문

 

val isNew = true
assertThat(isNew).isTrue
assertThat(isNew).isFalse

주어진 값이 true인지 / false인지 검증한다.

 

val people = listOf(Person("A"), Person("B"))
assertThat(people).hasSize(2)

주어진 컬렉션이 size가 원하는 값인지 검증한다.

 

val people = listOf(Person("A"), Person("B"))
assertThat(people).extracting("name").containsExactlyInAnyOrder("A", "B")

주어진 컬렉션 안의 item 들에서 name 이라는 프로퍼티를 추출한 후 (extracting),

그 값을 검증한다. [ contains ]

이때 순서는 중요하지 않다. ( InAnyOrder)

 

 

val people = listOf(Person("A"), Person("B"))
assertThat(people).extracting("name").containsExactly("A", "B")

 

주어진 컬렉션 안의 item 들에서 name 이라는 프로퍼티를 추출한 후 (extracting),

그 값을 검증한다  [ contains ]

이때 순서도 중요하다 [ InAnyOrder가 없으므로 ]

 

 

assertThrows<IllegalArgumentException> {
function1()
}

assertThrows<IllegalArgumentException>({
    println()
})

assertThrows<IllegalArgumentException> {
    println()
}

assertThrows는 주어진 함수를 실행했을때 지정된 예외가 발생하는지 검증한다.

마지막 파라미터가 함수일 경우 소괄호없이 중괄호로 파라미터 전달 가능하다.

 

즉 위 코드는 function1을 실행중 IllegalArgumentException이 발생하는지 검증

 

 

 

val message = assertThrows<IllegalArgumentException> {
function1()
}.message
assertThat(message).isEqualTo("잘못된 값이 들어왔습니다")

.message를 붙여주면 반환값으로 message를 반환해주게 되고 에러 메시지를 확인할 수 있다.

 

 

 

계산기에서 나눗셈 테스트는 다음과 같이 작성할 수 있다.

@Test
fun divideExceptionTest() {
    // given
    val calculator = Calculator(5)
    // when & then
    val message = assertThrows<IllegalArgumentException> {
        calculator.divide(0)
    }.message
    assertThat(message).isEqualTo("0으로 나눌 수 없습니다")
}

@Test
fun divideExceptionTest2() {
    // given
    val calculator = Calculator(5)
    // when & then
    assertThrows<IllegalArgumentException> {
        calculator.divide(0)
    }.apply {
        assertThat(message).isEqualTo("0으로 나눌 수 없습니다")
    }
}

 

assertThrows<IllegalArgumentException> {
    calculator.divide(0)
}

는 IllegalArgumentException을 반환하고

apply를 통해 해당 예외의 메시지를 검증한다.

 

 

 

Spring Boot의 각 계층을 테스트하는 방법

Service 테스트의 경우 단위 테스트를 기본으로 하고, 주요 통합 시나리오를 보완적으로 작성

 

 

UserService의 테스트 코드

@SpringBootTest
class UserServiceTest @Autowired constructor( // 테스트에서는 @Autowired constructor가 생략 불가능하다.
    private val userRepository: UserRepository,
    private val userService: UserService
) {
    @AfterEach
    fun clean() {
        userRepository.deleteAll()
    }

    @Test
    @DisplayName("유저 저장이 정상 동작한다.")
    fun saveUserTest() {
        // given
            val request = UserCreateRequest("gil",null)

        // when
            userService.saveUser(request)

        // then
            val results = userRepository.findAll()
            assertThat(results).hasSize(1)
            assertThat(results[0].name).isEqualTo("gil")
            assertThat(results[0].age).isNull()
            /*
            자바코드로 되어있는 User이니까
            코틀린에서는 name,age 필드가 null이 가능한 Integer인지 null이 불가능한 Integer인지
            알수없다 -> 플랫폼 타입
            그래서 자바코드 getter에 @Nullable, @Notnull같은 어노테이션을 붙여줘야한다.
            -> 코틀린에서는 프로퍼티 접근법을 사용하니까
             */
    }

    @Test
    @DisplayName("유저 조회가 정상 동작한다.")
    fun getUsersTEst() {
        // given
        userRepository.saveAll(listOf(
            User("A",20),
            User("B",null),
        ))

        // when
        val results = userService.getUsers()

        // then
        assertThat(results).hasSize(2) // [UserResponse(), UserResponse()]
        assertThat(results).extracting("name").containsExactlyInAnyOrder("A", "B") // ["A","B"]
        assertThat(results).extracting("age").containsExactlyInAnyOrder(20,null)

    }

    @Test
    @DisplayName("유저 업데이트가 정상 동작한다.")
    fun updateUserNameTest() {
        // given
        val savedUser = userRepository.save(User("A",null))
        val request = UserUpdateRequest(savedUser.id, "B")

        // when
        userService.updateUserName(request)

        // then
        val result = userRepository.findAll()[0]
        assertThat(result.name).isEqualTo("B")
    }

    @Test
    @DisplayName("유저 삭제가 정상 동작한다.")
    fun deleteUserTest() {
        // given
        userRepository.save(User("A", null))

        // when
        userService.deleteUser("A")

        // then
        assertThat(userRepository.findAll().isEmpty())
    }




}

 

 

 

jpa를 사용할때 @Entity를 붙이게되면 매개변수가 없는 기본 생성자를 요구한다.

 

plugins {
    id 'org.jetbrains.kotlin.plugin.jpa' version '1.6.21'
}

해당 플러그인을 추가하면 기본생성자 에러를 해결해준다..

 

org.jetbrains.kotlin.plugin.jpa 플러그인은 다음과 같은 작업을 수행합니다:

  1. @Entity, @Embeddable, @MappedSuperclass 같은 JPA 관련 애노테이션이 붙은 클래스에
    자동으로 파라미터가 없는 기본 생성자를 추가합니다.
  2. 이를 통해 JPA가 프록시 객체를 생성하거나 리플렉션을 사용할 때 필요한 요구사항을 충족시킵니다.

 

 

에러 해결

rg.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name

이 에러는 코틀린 클래스에 대한 리플렉션을 할 수 없어 발생하는데,
이를 해결하기 위해 Kotlin 리플렉션 라이브러리를 넣어주어야 한다.
리플렉션이란, 클래스나 메소드 등을 런타임으로 제어하기 위한 기술을 의미한다.

 

Spring Data JPA는 Kotlin에서 리플렉션을 통해 엔티티와 관련된 작업(예: 메서드 호출, 프로퍼티 접근 등)을 수행하기 때문에, kotlin-reflect가 필수입니다.

 

 

User 엔티티 코틀린으로 변환해보기 

@Entity
class User(

    var name: String,
    val age: Int?,
    // Java에서는 cascade = CascadeType.ALL처럼 단일 값을 지정할 수 있지만, 코틀린에서는 어노테이션에 배열 형태를 사용해야 합니다.
    @OneToMany(mappedBy = "user", cascade = [CascadeType.ALL], orphanRemoval = true)
    val userLoanHistories: MutableList<UserLoanHistory> = mutableListOf(),

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null,
// = null을 적어줘서 값이 없을 경우 자동으로 들어갈 기본값을 설정해주었다 == Default Prameter
// Default Parameter는 제일아래에 놓는것이 관행
    ) {

    init {
        if (name.isBlank()) {
            throw IllegalArgumentException()
        }

    }

    fun updateName(name: String) {
        this.name = name
    }

    fun loanBook(book: Book) {
        this.userLoanHistories.add(UserLoanHistory(this, book.name, false))
    }

    fun returnBook(bookname: String) {
        this.userLoanHistories.first { history -> history.bookName == bookname }.doReturn()
    }

}

 

public void returnBook(String bookName) {
  JavaUserLoanHistory targetHistory = this.userLoanHistories.stream()
      .filter(history -> history.getBookName().equals(bookName))
      .findFirst()
      .orElseThrow();
  targetHistory.doReturn();
}

이 긴 코드가

fun returnBook(bookname: String) {
    this.userLoanHistories.first { history -> history.bookName == bookname }.doReturn()
}

코틀린 적용시 이렇게 된다.

 

 

val savedUser = userRepository.save(User("A",null))
val request = UserUpdateRequest(savedUser.id!!, "B")

자바에서는 nullable이 없으면 not-null 취급을 한다.

savedUser의 id는 null가능 타입으로 되어있지만

로직상 save후 id는 null이 불가능하니까 !!를 통해 null이 아님을 명시해서 에러를 없앤다.

 

 

변경가능한 필드의 getter는 열어두고 싶지만 setter는 닫아두고 싶다면

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

방법 중 하나 backing property를 사용하는 방법이다.

 

 

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

커스텀 setter를 만들어주는 방법을 이용해서 setter의 접근제어자를 private로 설정한다.

 

두 방법 모두 괜찮지만, 프로퍼티가 많아지면 번거롭다는 단점이 있다.

 

또 다른 방법은 setter를 열어 두되 사용하지 않는 방법이다.

class User(
    var name: String,
)

이 방법은 개발팀에서 컨벤션으로 정하고 지켜야한다.

결국 Trade-Off의 영역이라 생각하고, 팀간의 컨벤션을 잘 맞추는 것이 중요하지 않을까 싶다.

 

 

 

꼭 primary constructor안에 모든 파라미터를 넣어야할까?

 

User 클래스 주생성자 안에 있는 userLoanHistories 와 id 는 꼭 주생성자 안에 있을 필요가 없다.

아래와 같이 코드가 바뀔 수 있는 것이다.

@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
            
    fun updateName(name: String) {
        this.name = name
    }
    fun loanBook(book: Book) {
        this.userLoanHistories.add(UserLoanHistory(this, book.name))
    }
    fun returnBook(bookName: String) {
        this.userLoanHistories.first {history -> history.bookName == bookName }.doReturn()
    }
}

 

 

위 코드도 잘 동작한다. 그렇다면 어떻게 해야 더 좋을까? 둘다  큰 상관이 없다

테스트를 하기 위한 객체를 만들어 줄 때도

정적 팩토리 메소드를 사용하다 보니 프로퍼티가 안에 있건, 밖에 있건 두 경우 모두 적절히 대응 할 수 있다

그래서 주생성자에 프로퍼티를 다 넣던지,

어떤건 주생성자에 넣고, 어떤건 클래스 바디에 넣을건지를 팀끼리 대화로 컨벤션을 정하면 된다.

 

코틀린에서 정적 팩토리 메소드 사용한 예시

@Entity
class User private constructor(

    var name: String,
    val age: Int?,
    // Java에서는 cascade = CascadeType.ALL처럼 단일 값을 지정할 수 있지만, 코틀린에서는 어노테이션에 배열 형태를 사용해야 합니다.
    @OneToMany(mappedBy = "user", cascade = [CascadeType.ALL], orphanRemoval = true)
    val userLoanHistories: MutableList<UserLoanHistory> = mutableListOf(),

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

    ) {

    init {
        if (name.isBlank()) {
            throw IllegalArgumentException()
        }

    }

    companion object {
        // 정적 팩토리 메소드
        fun of(name:String, age:Int): User {
            return User(name,age)
        }
    }
    

}

 

 

 

JPA와 data class

 

JPA Entity는 data class를 피하는 것이 좋다.

 

data class는 equals, hashCode, toString 등의 함수를 자동으로 만들어준다.

 

사실 원래 세 함수는 JPA Entity와 궁합이 그렇게 좋지 못했다.

 

연관관계 상황에서 문제가 될 수 있는 경우들이 존재했기 때문이다.

 

 

 

1 : N 연관관계를 맺고 있는 상황에서 User 쪽에 equals()가 호출된다면,

User는 본인과 관계를 맺고 있는 UserLoanHistory의 equals()를 호출하게 되고,

다시 UserLoanHistory 는 본인과 관계를 맺고 있는 User의 equals() 를 호출하게 된다.

때문에 JPA Entity는 data class를 피하는 것이 좋다.

 

equals() 및 hashCode(), toString()는 순환참조를 발생시킬 수 있으므로 Entity에서 사용을 조심해야한다.

[ 직접 구현하거나 , exclude 속성을 이용하여 순환참조 발생할 수 있는 필드를 제외한다던지 ... 그런식을 구현해야한다. ] 

 

 

작은 TIP

Entity (Class) 가 생성되는 로직을 찾고 싶은 경우, constructor 지시어를 명시적으로 작성하고 추적하면 훨씬 편하다.

어디서 사용되는지 확인가능

 

 

 

 

@Transactional 이슈

 

 

 

@Transactional 어노테이션이 적용된 메서드가 오버라이드 가능해야 한다는 JPA 및 Spring의 기본 규칙 때문입니다.

Kotlin에서는 클래스와 메서드가 기본적으로 final로 선언되므로,

@Transactional과 같은 프록시 기반 AOP 어노테이션을 사용할 때 문제가 발생할 수 있습니다.

 

 

 

  • Kotlin의 기본 final 클래스 및 메서드
    • Kotlin에서는 클래스와 메서드가 명시적으로 open으로 선언되지 않으면 기본적으로 final로 처리됩니다.
    • Spring의 AOP는 프록시 기반으로 동작하며, 이를 위해 메서드를 오버라이드할 수 있어야 합니다. final 메서드는 오버라이드가 불가능하므로 AOP가 적용되지 않습니다.
  • Spring의 기본 AOP 동작
    • Spring은 기본적으로 클래스 프록시(CGLIB) 또는 인터페이스 프록시(JDK 동적 프록시)를 사용합니다.
    • 클래스가 final일 경우, CGLIB 프록시를 생성할 수 없으므로 에러가 발생합니다.

 

 

해결 방법

1. 클래스와 메서드를 open으로 선언

Kotlin에서 open 키워드를 사용해 클래스와 메서드를 오버라이드 가능하게 만듭니다.

@Service
open class UserService( // 생성자가 하나니까 @Autowired constructor는 생략가능하다.
    private val userRepository: UserRepository,
) {

    @Transactional
    open fun saveUser(request: UserCreateRequest) {
        // 비즈니스 로직
    }
}

클래스,메소드 둘다

 

2. Spring의 all-open 플러그인 사용

spring 플러그인을 적용하면 Spring 관련 어노테이션이 붙은 클래스와 메서드를 자동으로 open 처리해줍니다.

build.gradle.kts 설정

 

plugins {
    id("org.jetbrains.kotlin.plugin.spring") version "1.9.0" // Kotlin 버전에 맞는 플러그인 버전 사용
}

기본 동작

  • @Component, @Service, @Repository, @Transactional과 같은 어노테이션이 붙은 클래스나 메서드는 자동으로
    open 처리됩니다.
  • 별도로 open 키워드를 작성할 필요가 없습니다.

 

 

UserService 코틀린으로 전환

@Service
class UserService (// 생성자가 하나니까 @Autowired constructor는 생략가능하다.
    private val userRepository:UserRepository,
) {

    @Transactional
    fun saveUser(request: UserCreateRequest) {
        val newUser = User(request.name, request.age)
        userRepository.save(newUser)
    }

    @Transactional(readOnly = true)
    fun getUsers(): List<UserResponse> {
        return userRepository.findAll()
            .map { user -> UserResponse(user) }
    }

    @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)
    }
}

 

::IllegalArgumentException

이 부분은 생성자를 참조하는 부분이다.

[ 함수참조,생성자참조,프로퍼티 참조 부분 공부 ]

 

 

 

 

 

댓글