Web/Kotlin & Spring

[Kotlin & Spring] - TODO 서비스에 코틀린 도입해서 리팩토링 하기 + Test Double

Hyunseo😊 2023. 6. 28. 18:42

https://github.com/digimon1740/fastcampus-todo-java

 

GitHub - digimon1740/fastcampus-todo-java

Contribute to digimon1740/fastcampus-todo-java development by creating an account on GitHub.

github.com

저는 강의에서 제공하는 위 깃허브를 참고해서 코틀린으로 마이그래이션 작업을 시작했습니다.

 

1. Kotlin DSL을 사용해 빌드 스크립트 마이그레이션

build.gradle.kts

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
    id("org.springframework.boot") version "2.7.0"
    id("io.spring.dependency-management") version "1.0.11.RELEASE"
    id("java")
    id("org.jetbrains.kotlin.jvm") version "1.6.21"
}

group = "com.fastcampus"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_17


repositories {
    mavenCentral()
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("org.springframework.boot:spring-boot-starter-web")


    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")

    runtimeOnly("com.h2database:h2")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

tasks.withType<KotlinCompile> {
    kotlinOptions {
        freeCompilerArgs = listOf("-Xjsr305=strict")
        jvmTarget = "17"
    }
}

tasks.withType<Test> {
    useJUnitPlatform()
}
  • 아마 현재는 롬복을 제거했기 때문에, 컴파일 에러가 발생할 것입니다. 점진적으로 코틀린으로 변환하면서 에러를 사라지게 해보겠습니다.

settings.gradle.kts

rootProject.name = "todo"

 

2. 컨트롤러 레이어 리팩토링

TodoController.kt

...

@RestController
@RequestMapping("/api/todos")
class TodoController(
    private val todoService: TodoService,
) {

    @GetMapping
    fun getAll() =
        ok(TodoListResponse.of(todoService.findAll()))

    @GetMapping("/{id")
    fun get(@PathVariable id: Long) =
        ok(TodoResponse.of(todoService.findById(id)))

    @PostMapping
    fun create(@RequestBody request: TodoRequest) =
        ok(TodoResponse.of(todoService.create(request)))

    @PutMapping("/{id}")
    fun update(
        @PathVariable id: Long,
        @RequestBody request: TodoRequest,
    ) = ok(TodoResponse.of(todoService.update(id, request)))

    @DeleteMapping("/{id}")
    fun delete(@PathVariable id: Long): ResponseEntity<Unit> {
        todoService.delete(id)
        return noContent().build()
    }
}

위와같이 리팩토링을 하면 간단히 로직을 짤 수 있습니다. 그 다음은 DTO부분입니다. 기존에 롬복을 통해서 작성했었는데, 컴파일 오류 나는 부분을 코틀린의 data class를 적용해서 해결해 보도록 하겠습니다.

 

model/TodoListResponse.kt

package com.fastcampus.kotlinspring.todo.api.model

import com.fastcampus.kotlinspring.todo.domain.Todo
import com.fasterxml.jackson.annotation.JsonIgnore

/**
 * @author ihyeonseo
 */
data class TodoListResponse(
    val items: List<TodoResponse>,
) {
    val size: Int
        @JsonIgnore
        get() = items.size

    fun get(index: Int) = items[index]

    companion object {
        fun of (todoList: List<Todo>) =
            TodoListResponse(todoList.map(TodoResponse::of))
    }
}

 

model/TodoResponse.kt

package com.fastcampus.kotlinspring.todo.api.model

import com.fastcampus.kotlinspring.todo.domain.Todo
import java.time.LocalDateTime

/**
 * @author ihyeonseo
 */
class TodoResponse(
    val id: Long,
    val title: String,
    val description: String,
    val done: Boolean,
    val createdAt: LocalDateTime,
    val updatedAt: LocalDateTime,
) {
    companion object {
        fun of (todo: Todo?): TodoResponse {
            checkNotNull(todo) { "Todo is null" }
            return TodoResponse(
                id = todo.id,
                title = todo.title,
                description = todo.description,
                done = todo.done,
                createdAt = todo.createdAt,
                updatedAt = todo.updatedAt,
            )
        }
    }
}

 

model/TodoRequest.kt

package com.fastcampus.kotlinspring.todo.api.model

/**
 * @author ihyeonseo
 */
data class TodoRequest(
    val title: String,
    val description: String,
    val done: Boolean = false
) {

}

 

위 코드를 매우 간단히 자바 코드에서 코틀린으로 마이그레이션 했습니다. 아직 에러 나는 부분이 많습니다. 그 이유는 domain에서 엔티티들에서 다 롬복 코드를 적어두었었기 때문입니다. 이를 이제 수정해보도록 하겠습니다. 그 전에! 먼저 서비스 레이어를 코틀린으로 짜보겠습니다. 한번에 다 바뀌는게 좋잖아요 ㅋㅎㅋ

3. 서비스 레이어 리팩토링

model/TodoService.kt

package com.fastcampus.kotlinspring.todo.service

import com.fastcampus.kotlinspring.todo.api.model.TodoRequest
import com.fastcampus.kotlinspring.todo.domain.Todo
import com.fastcampus.kotlinspring.todo.domain.TodoRepository
import org.springframework.data.domain.Sort.*
import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.server.ResponseStatusException
import java.time.LocalDateTime

/**
 * @author ihyeonseo
 */

@Service
class TodoService(
    private val todoRepository: TodoRepository,
) {

    @Transactional(readOnly = true)
    fun findAll() : List<Todo> =
        todoRepository.findAll(by(Direction.DESC, "id"))

    @Transactional(readOnly = true)
    fun findById(id: Long): Todo =
        todoRepository.findByIdOrNull(id)
            ?: throw ResponseStatusException(
                HttpStatus.NOT_FOUND)

    @Transactional
    fun create(request: TodoRequest?) : Todo {
        // change request ot non-null
        checkNotNull(request) { "TodoRequest is null" }

        val todo = Todo(
            title = request.title,
            description = request.description,
            done = request.done,
            createdAt = LocalDateTime.now()
        )

        return todoRepository.save(todo)
    }

    @Transactional
    fun update(id: Long, request: TodoRequest?) : Todo {
        checkNotNull(request) { "TodoRequest is null" }
        return findById(id).let {
            it.update(request.title, request.description, request.done)
            todoRepository.save(it)
        }
    }

    fun delete(id: Long)  = todoRepository.deleteById(id)
}
  • 여기서는 팁은 request: TodoRequest?로 nullable로 create, update 서비스 로직에 인자로 들어오는 것을 볼 수 있습니다. 하지만 checkNotNull에서 이가 null이면 throw, null이 아니라면 그냥 TodoRequest 타입으로 바꾸어 줍니다.
  • 그리고 todoRepository.findByIdOrNull(id)는 spring data jpa의 코틀린 확장 함수입니다. 기존에는 findById를 한다음에 orElseNull까지 해서 기본값까지 지정해 주었었습니다.

4. 도메인 레이어 리팩토링

domain/Todo.kt

package com.fastcampus.kotlinspring.todo.domain

import java.time.LocalDateTime
import javax.persistence.*
import javax.persistence.GenerationType.*

/**
 * @author ihyeonseo
 */

@Entity
@Table(name = "todos")
class Todo(
    @Id @GeneratedValue(strategy = IDENTITY)
    val id: Long? = 0,

    @Column(name = "title")
    var title: String,

    @Lob @Column(name = "description")
    var description: String,

    @Column(name = "done")
    var done : Boolean,

    @Column(name = "created_at")
    var createdAt: LocalDateTime,

    @Column(name = "updated_at")
    var updatedAt: LocalDateTime? = null,
) {
    fun update(title: String, description: String, done: Boolean) {
        this.title = title
        this.description = description
        this.done = done
        this.updatedAt = LocalDateTime.now()
    }
}

 

domain/TodoRepository.kt

package com.fastcampus.kotlinspring.todo.domain

import org.springframework.data.jpa.repository.JpaRepository

/**
 * @author ihyeonseo
 */
interface TodoRepository: JpaRepository<Todo, Long> {
    fun findAllByDoneIsFalseOrderByIdDesc(): List<Todo>?
}

 

그리고 마지막으로 이전에 작성했던 코드에 남아있던 컴파일 오류까지 해결해 주겠습니다.

model/TodoResponse.kt

package com.fastcampus.kotlinspring.todo.api.model

import com.fastcampus.kotlinspring.todo.domain.Todo
import java.time.LocalDateTime

/**
 * @author ihyeonseo
 */
class TodoResponse(
    val id: Long,
    val title: String,
    val description: String,
    val done: Boolean,
    val createdAt: LocalDateTime,
    val updatedAt: LocalDateTime?,
) {
    companion object {
        fun of (todo: Todo?): TodoResponse {
            checkNotNull(todo) { "Todo is null" }
            checkNotNull(todo.id) { "Todo Id is null" }

            return TodoResponse(
                id = todo.id,
                title = todo.title,
                description = todo.description,
                done = todo.done,
                createdAt = todo.createdAt,
                updatedAt = todo.updatedAt,
            )
        }
    }
}

 

updatedAt을 nullable로 바꿨과 todo.id가 null인지 아닌지 assert하는 구문을 추가해주었습니다. 여기서 타입 단언을 통해서 해결할 수도 있긴 합니다. 그리고 mockk을 사용해서 테스트를 작성하기 전에 Test Double에 대해 좀만 알아보고 가겠습니다.

 

5. Test Double

위와같은 예제를 살펴보겠습니다. SUT는 주문서 service 클래스 중 생성 로직입니다. 

 

OrderService의 createOrder()는 아래와 같은 절차를 따르게 됩니다.

  1. OrderService.findOrderList()로 기존 주문서 조회
  2. 주문서가 있다면 중복으로 간주해 OrderDuplicateException 발생
  3. OrderRepository.createOrder()로 주문서 생성
  4. Argument로 넘어온 isNotify가 true이면 NotificationClient.notifyToMobile를 이용해 알림 생성

일단 위 절차를 따르는 코드는 아래와 같습니다.

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final NotificationClient notificationClient;

    public void createOrder(Boolean isNotify){
        List<Order> orderList = orderRepository.findOrderList();
        if(orderList.size() > 0){
            throw new OrderDuplicateException();
        }

        orderRepository.createOrder();

        if(isNotify){
            notificationClient.notifyToMobile();
        }
    }
}
@Repository
public class OrderRepository {
    public List<Order> findOrderList(){
        System.out.println("real OrderRepository findOrderList");
        return Collections.emptyList();
    }

    public void createOrder(){
        System.out.println("createOrder success");
    }
}
@Component
public class NotificationClient {
    public void notifyToMobile(){
        System.out.println("notify to mobile success");
    }
}

Test Double없이 위 로직을 테스트하려면 아마 아래와 같은 테스트 코드를 작성하게 될 것입니다.

class BasicTests {
    private OrderService orderService;

    @Test
    void createOrderTest() {
        OrderRepository orderRepository = new OrderRepository();
        NotificationClient notificationClient = new NotificationClient();

        orderService = new OrderService(orderRepository, notificationClient);

        orderService.createOrder(true);
    }

}

그리고 위 TEST를 선행하기 위해서는 아래 조건들이 선행되어야 할 것입니다.

  1. OrderRepository가 사용할 RDB connection 세팅
  2. RDB에 로직 테스트 조건에 맞는 데이터 세팅
  3. NoficiationClient가 사용할 Notification Server연결
  4. Notification이 성공했을 때의 데이터 롤백 처리

테스트할 간단한 로직을 위해서 세팅할 사항만 해도 너무 많습니다. 사실 로직이 간단해서 이 정도이고 여러 자원을 연결할 수록 사전 작업은 많아지게 됩니다. 사실 생각해보면 정작 OrderService의 createOrder()에서는 위 세팅 사항들, DB에 데이터가 제대로 들어갔는지, noti가 정상적으로 갔는지는 관심사가 아닙니다.

  1. orderRepository.findOrderList()의 결과가 존재할 때 OrderDuplicationException이 발생하는가
  2. orderRepository.createOrder()가 1번 실행되는지
  3. isNotify에 따라서 notificationClient.notifyToMobile()가 실행되는지

가 주요 관심사 일 것입니다.

 

그래서 위 상황들을 위해 이런 문제 영역을 메소드의 실제 내부 동작은 실행되지 않고 상황 설정만 할 수 있도록 해결한 것이 Test Double입니다.

이렇게 되면 정상 적으로 우리의 관심사를 테스트 할 수 있습니다. 그리고 stub을 해주지 않은메소드들은 Mockito가 메소드의 type별로 정의된 DEFAULT메소드가 실행되기 때문에 정상적으로 돌아갈 수 있었던 것입니다. 그리고 이와 가은걸 @Mock을 통해 작성을 할 수도 있습니다.

그리고 InjectMocks라는 어노테이션을 사용한다면 해당 클래스가 필요한 의존성과 맞는 Mock객체들을 감지해서 해당 클래스의 객체가 만들어질 때 사용하여 객체를 만들고 해당 변수에 객체를 주입하게 됩니다.

그리고 OrderRepository의 메소드 중 createOrder()는 stub하고 findOrderList()는 실제 기능을 그대로 사용하고 싶은 경우가 생길 수 있고 생각보다 빈번하게 발생합니다. 즉, 하나의 객체를 선택적으로 stub할 수 있도록 하는 기능이 @Spy (=Mockito.spy)입니다.

그리고 @SpringBootTest는 SpringBoot 컨텍스트를 이용하여 테스트를 가능하도록 해주는 어노테이션입니다. 즉, @Autowired라는 강력한 어노테이션으로 컨텍스트에서 알아서 생성된 객체를 주입받아 테스트를 진행할 수 있도록 합니다.

@SpringBootTest
class BasicSpringTests {

    @Autowired
    private OrderService orderService;

    @Test
    void createOrderTest() {
        orderService.createOrder(true);
    }
}

하지만 이 방식은 위에서 살펴본 최초의 테스트 코드와 동일한 문제점이 있으며 테스트가 실행되기 위해서 해야할 일도 많아지게 됩니다. 그래서 우리는 @Mock과 비슷한 @MockBean을 사용합니다. 이는 @Mock과는 다르게 spring 영역에 있는 어노테이션이라는 것을 알 수 있습니다. @MockBean은 스프링 컨텍스트에 mock객체를 등록하게 되고 스프링 컨텍스트에 의해 @Autowired가 동작할 때 등록된 mock객체를 사용할 수 있도록 동작합니다. 이는 엄밀히 따지면 @InjectMocks를 사용하면 동작하지 않습니다. 

그리고 @MockBean에서 Spy의 개념만 변경된 것이 @SpyBean입니다.

@SpyBean을 사용할 때 주의해야 할 점은 @SpyBean이 인터페이스일 경우 해당 인터페이스를 구현하는 실제 구현체가 꼭 스프링 컨텍스트에 등록되어 있어야 합니다. @SpyBean은 실제 구현된 객체를 감싸는 프록시 객체 형태이기 때문에 스프링 컨텍스트에 실제 구현체가 등록되어 있어야 합니다. 

public interface SampleRepository {
    public String sampleMethod();
}
@RequiredArgsConstructor
@Service
public class SampleService {
    private final SampleRepository sampleRepository;

    public void sampleServiceMethod(){
        sampleRepository.sampleMethod();
    }

}
@SpringBootTest
public class SpyBeanFailTests {
    @SpyBean
	//@MockBean
    private SampleRepository sampleRepository;

    @Autowired
    private SampleService sampleService;

    @Test
    public void failTest(){
        sampleRepository.sampleMethod();
        sampleService.sampleServiceMethod();
    }
}

위 예시에서 SampleRepository의 interface만 있는 상태에서 구현체가 없는 상태에서 @SpyBean을 사용하려는 예시입니다. 여기서 @MockBean을 사용하면 스프링 컨텍스트에 등록된 구현체를 사용하는 것이 아닌 껍데기만 가진 mock객체를 스프링 컨텍스트에 등록하는 것이기 때문에 SampleService의 의존성도 mock객체로 해결되고 위 테스트는 성공하게 됩니다.

 

6.  테스트 코드 리팩토링

이제 Test코드를 코틀린으로 마지막으로 리팩토린 하겠습니다. 이 TODO서비스의 테스트 코드는 TDD로 작성되었으며, MockBean을 사용했습니다.

package com.fastcampus.kotlinspring.todo.service

import com.fastcampus.kotlinspring.todo.domain.Todo
import com.fastcampus.kotlinspring.todo.domain.TodoRepository
import com.ninjasquad.springmockk.MockkBean
import io.mockk.every
import org.assertj.core.api.AssertionsForClassTypes.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.data.repository.findByIdOrNull
import org.springframework.test.context.junit.jupiter.SpringExtension
import java.time.LocalDateTime

/**
 * @author ihyeonseo
 */
@ExtendWith(SpringExtension::class)
class TodoServiceTests {

    @MockkBean
    lateinit var repository: TodoRepository

    lateinit var service: TodoService

    val stub: Todo by lazy {
        Todo(
            id = 1,
            title = "테스트",
            description = "테스트 상세",
            done = false,
            createdAt = LocalDateTime.now(),
            updatedAt = LocalDateTime.now(),
        )
    }

    @BeforeEach
    fun setUp() {
        service = TodoService(repository)
    }

    @Test
    fun `한개의 TODO를 반환해야 한다`() {
        // Given
        every { repository.findByIdOrNull(1) } returns stub

        // When
        val actual = service.findById(1L)

        // Then
        assertThat(actual).isNotNull
        assertThat(actual).isEqualTo(stub)
    }
}

mockk을 위해 build.gradle.kts에 관련 의존성을 추가해주고, 이를 작성했습니다. @MockkBean을 통해 레포지토리를 mock 객체로 만들었고, 서비스에 이를 주입해주었습니다. 그리고 stub도 by lazy로 불변성 객체로 하나 만들어주었습니다.

정상 작동하는 것을 확인할 수 있습니다.

 

https://github.com/eunoiahyunseo/KOTLIN-TODO-APP

 

GitHub - eunoiahyunseo/KOTLIN-TODO-APP: 자바로 작성된 TODO 앱을 코프링으로 마이그레이션하고 리팩토링

자바로 작성된 TODO 앱을 코프링으로 마이그레이션하고 리팩토링 함. Contribute to eunoiahyunseo/KOTLIN-TODO-APP development by creating an account on GitHub.

github.com

마이그래이션과 리팩토링한 코드는 위에 올렸습니다.