최근 시작한 자취얌! 프로젝트에서 배포한 서버는 AWS EC2내부에 도커 컨테이너를 통해 배포되었으며, 프리티어와 여러 도커 컨테이너로 가상화됨을 감안하여 효율적인 리소스 활용이 대두되었습니다.
이에 따라서, 자취얌! 프로젝트의 서버에서 반응형 서버를 WebFlux와 코루틴을 이용하여 적용하였고, 데이터베이스는 R2DBC를 이용하여 논블로킹 서비스를 구축하였습니다.
그에 관한 코루틴과 WebFlux, 그리고 R2DBC를 이용한 MySQL 연동과 테스트 수행 방법에 대해서 공유하고자 합니다.
기존에 사용되던 WebMVC 방식의 블로킹 서버는 하나의 요청에 따라 순서대로 응답을 전달하는 블로킹 서버 방식으로 동시에 여러 트래픽을 처리하는데 한계가 있습니다. 이를 극복하기 위해서 현재는 비동기 방식과 반응형 방식이 뜨고 있으며 Spring Boot에서는 WebFlux를 이용하여 반응형 어플리케이션을 제작할 수 있습니다.
WebFlux는 비동기 방식을 이용하여 논블로킹 서비스를 구축할 수 있으며, 대규모 트래픽과 리소스를 효율적으로 사용할 수 있다는 장점이 있습니다. 물론, 무조건적으로 비동기 방식을 이용하는 것이 최선의 방법이 될 수는 없습니다.
Java에서는 Mono와 Flux 그리고 Reactor에 의한 함수형 프로그래밍의 패러다임으로 러닝 커브가 존재했으나, Kotlin은 코루틴이라는 비동기 솔루션을 제공하고 WebFlux도 코루틴에 대한 지원을 적극적으로 하고 있습니다.
또한, 코루틴을 이용하여 작성하면 기존의 MVC 방식과 크게 문법적인 차이가 존재하지 않았기에 쉽게 적용이 가능했습니다. 코루틴의 suspend 함수를 적용하는 것만으로도 비동기적 수행이 가능합니다.
이 과정을 TODO 서비스를 통해 소개하려 합니다.
- Spring boot 3.4.0
- Java 21
- Kotlin
- MySQL 8.0 +
아래와 같은 의존성을 추가합니다.
dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-r2dbc")
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
runtimeOnly("io.asyncer:r2dbc-mysql:1.3.0")
// 테스트 용 의존성 추가
testRuntimeOnly("com.h2database:h2")
testRuntimeOnly("io.r2dbc:r2dbc-h2")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("io.projectreactor:reactor-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test")
testImplementation("com.ninja-squad:springmockk:4.0.2")
testImplementation("io.mockk:mockk:1.13.13")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
가장 좋은 방법은 Spring Boot 프로젝트를 생성할 때 제공 의존성을 추가하면 되나, MySQL의 R2DBC 이용을 위하여 io.asyncer:r2dbc-mysql 을 maven repository에서 추가해줍니다. Spring 공식문서에는 데이터베이스와 관련하여 R2DBC를 이용하기 위한 의존성 추가에 대한 안내가 있습니다.
https://docs.spring.io/spring-data/relational/reference/r2dbc/getting-started.html
저는 테스트 환경에서는 H2 데이터베이스를 통해 단위 테스트를 수행할 예정입니다. 그렇기에 H2와 관련된 의존성도 추가되었습니다.
기타적으로 ninja-squad:springmockk과 mockk 를 추가해주었습니다. 그리고 코루틴 환경에서의 테스트를 편하게 도와주는 kotlinx-coroutines-test도 함께 추가되었습니다.
R2DBC는 기본적으로 JPA와 다르게 객체 선언으로 테이블을 정의할 수 없고 직접 테이블을 정의하는 쿼리를 작성해야 합니다.
CREATE TABLE IF NOT EXISTS todo (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
todo VARCHAR(255) NOT NULL,
is_done BOOLEAN NOT NULL,
create_at DATE NOT NULL
);
이와 같은 schema.sql 파일을 resources 하위에 위치시키면 설정에 의해서 자동적으로 테이블을 생성합니다.
기본적은 R2DBC 사용을 위하여 아래와 같은 스크립트를 추가합니다.
spring:
application:
name: 프로젝트 이름
sql:
init:
mode: always // 테이블 생성 옵션
r2dbc:
url: r2dbc:pool:mysql://[DB URL]:[DB 포트]/[데이터베이스 이름]?autoReconnect=true&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul
username: [DB USER]
password: [DB PASSWORD]
data:
r2dbc:
repositories:
enabled: true
대괄호로 처리된 부분은 사용자의 설정에 맞게 조정하시면 됩니다.
Entity는 JPA를 사용하던것과 다르게 단순하게 @Table 어노테이션과 데이터 클래스를 이용하여 정의할 수 있습니다.
@Table("todo")
data class Todo(
@Id
val id : Long? = null,
@Column("todo")
val todo : String,
@Column("is_done")
val isDone : Boolean,
@Column("create_at")
val createAt : LocalDate,
) {
fun toResponse() : TodoResponseDto = TodoResponseDto(
id = id!!,
todo = todo,
isDone = isDone,
createAt = createAt
)
}
DTO는 사용자의 요청과 응답을 전달하는 객체로 Validation을 수행하기에 용이합니다. 하지만 이번에는 Validation은 수행하지 않고, 단순히 데이터를 운반하는 역할만 하게 될 것입니다.
data class TodoRequestDto(
@JsonProperty("todo")
private val _todo : String,
@JsonProperty("isDone")
private val _isDone : Boolean,
@JsonProperty("createAt")
private val _createAt : String,
) {
val todo : String
get() = _todo
val isDone : Boolean
get() = _isDone
val createAt : LocalDate
get() = _createAt.toLocalDate()
private fun String.toLocalDate() : LocalDate
= LocalDate.parse(this, DateTimeFormatter.ofPattern("yyyy-MM-dd"))
fun toEntity() : Todo = Todo(
id = null,
todo = todo,
isDone = isDone,
createAt = createAt
)
}
data class TodoResponseDto(
val id : Long,
val todo : String,
val isDone : Boolean,
val createAt : LocalDate
)
Kotlin이 R2DBC와 사용하기 편리한 점은 CorutineCrudRepository를 제공해줍니다. 이를 통해서 기본적인 쿼리 작성 없이도 CRUD가 가능합니다.
다만, 저는 FLOW, MONO 타입이 아닌 단순한 List 형식의 데이터를 반환할 것이기 때문에 하나의 메소드를 추가해서 정의하였습니다.
interface TodoRepository : CoroutineCrudRepository<Todo, Long> {
@Query("select * from todo")
suspend fun getAllTodos() : List<Todo>
}
쿼리를 이용하여 직접 전체 조회를 수행하는 getAllTodos()를 정의하여 Todo의 배열을 반환할 수 있도록 하였습니다.
Service 계층은 요청 DTO를 입력받아 단순하게 DB의 데이터를 응답 DTO로 변환하여 반환하도록 역할을 부여했습니다.
@Service
class TodoService @Autowired constructor(
private val todoRepository: TodoRepository
) {
/**
* 투두 전체 조회
*/
suspend fun getAllTodos() : List<TodoResponseDto> {
val result = todoRepository.getAllTodos()
return result.map { it.toResponse() }
}
/**
* 투두 생성
*/
suspend fun createTodo(todoRequestDto: TodoRequestDto) : TodoResponseDto {
val result = todoRepository.save(todoRequestDto.toEntity())
return result.toResponse()
}
}
Controller는 사용자의 요청과 응답을 담당합니다.
@RestController
@RequestMapping("/api/todos")
class TodoController @Autowired constructor(private val todoService: TodoService) {
/**
* 투두 조회 Api
*/
@GetMapping
@ResponseStatus(HttpStatus.OK)
suspend fun getAllTodos() : List<TodoResponseDto> {
val result = todoService.getAllTodos()
return result
}
/**
* 투두 생성 Api
*/
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
suspend fun createTodo(@RequestBody todoRequestDto: TodoRequestDto) : TodoResponseDto {
val result = todoService.createTodo(todoRequestDto)
return result
}
}
이제 모든 서비스가 정의되었습니다. 각각의 계층을 단위 테스트를 통해 정상 동작하는지 확인하겠습니다.
테스트 환경에서는 H2 데이터베이스를 이용할 것입니다. 이를 위한 설정을 test 디렉토리 하위에 resources 경로를 생성하여 application-test.yml 이라는 스크립트 파일로 생성하겠습니다.
spring:
sql:
init:
mode: always
data:
r2dbc:
repositories:
enabled: true
r2dbc:
url: r2dbc:h2:mem:///testdb;MODE=MySQL
username: sa
password:
마찬가지로 스키마 정의를 위하여 같은 경로에 schema.sql을 위치시켜주어야 테이블을 정의할 수 있습니다.
추가적으로 data.sql 파일을 추가하여 목업 데이터를 데이터베이스에 추가하겠습니다.
INSERT INTO todo (id, todo, is_done, create_at) VALUES (1, 'todo1', false, '2024-12-10');
INSERT INTO todo (id, todo, is_done, create_at) VALUES (2, 'todo2', false, '2024-12-10');
INSERT INTO todo (id, todo, is_done, create_at) VALUES (3, 'todo3', true, '2024-12-09');
INSERT INTO todo (id, todo, is_done, create_at) VALUES (4, 'todo4', true, '2024-12-13');
이제 모든 준비가 완료되었으니, 단위테스트틀 수행해보겠습니다.
Repository 테스트는 DB가 정상적으로 동작하는지를 판단합니다.
@DataR2dbcTest
@ActiveProfiles("test")
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestPropertySource(locations = ["classpath:application-test.yml"])
class TodoRepositoryTest @Autowired constructor(
private val todoRepository: TodoRepository
) {
@Test
fun `투두를 조회하면 4개의 투두 리스트가 반환된다`() = runTest {
val result = todoRepository.getAllTodos()
assertThat(result.size).isEqualTo(4)
}
@Test
fun `투두를 성공적으로 저장한다`() = runTest {
val newTodo = Todo(null, "todo5", false, LocalDate.of(2024, 12, 10))
val result = todoRepository.save(newTodo)
with(result) {
assertThat(id).isEqualTo(5)
assertThat(todo).isEqualTo("todo5")
assertThat(isDone).isEqualTo(false)
assertThat(createAt).isEqualTo(LocalDate.of(2024, 12, 10))
}
todoRepository.delete(result)
}
}
여러 어노테이션이 추가되어있습니다. @DataR2dbcTest는 R2DBC로 연동된 DB를 테스트하기 위해 선언합니다. 그 외 어노테이션은 테스트 설정을 불러오기 위해 사용됩니다.
코루틴은 기본적으로 코루틴 스코프 내부에서 동작합니다. 따랴서, runTest 스코프를 선언하여 모든 테스트를 수행해야 합니다.
runTest 스코프는 상단에서 설명한 kotlinx-coroutines-test 의존성에서 제공합니다.
Service 계층이 정상적으로 데이터를 반환하는지 확인합니다.
class TodoServiceTest {
private val todoRepository : TodoRepository = mockk()
private val todoService : TodoService = TodoService(todoRepository = todoRepository)
@Test
fun `투두 리스트를 조회하면 TodoResponseDto리스트가 반환된다`() = runTest {
coEvery { todoRepository.getAllTodos() } returns listOf(
Todo(1, "todo1", false, LocalDate.of(2024, 12,10)),
Todo(2, "todo2", false, LocalDate.of(2024, 12,10)),
Todo(3, "todo3", true, LocalDate.of(2024, 12,9)),
Todo(4, "todo4", true, LocalDate.of(2024, 12,13)),
)
val result = todoService.getAllTodos()
coVerify(exactly = 1) { todoRepository.getAllTodos() }
assertThat(result.size).isEqualTo(4)
}
@Test
fun `투두 리스트를 생성하면 TodoResponseDto가 반환된다`() = runTest {
coEvery { todoRepository.save(any()) } returns Todo(5, "todo5", false, LocalDate.of(2024, 12, 10))
val newTodo = Todo(5L, "todo5", false, LocalDate.of(2024, 12, 10))
val result = todoRepository.save(newTodo)
coVerify(exactly = 1) { todoRepository.save(any()) }
with(result) {
assertThat(id).isEqualTo(5L)
assertThat(todo).isEqualTo("todo5")
assertThat(isDone).isFalse()
assertThat(createAt).isEqualTo(LocalDate.of(2024, 12, 10))
}
}
}
MockK를 통해 추가된 테스트 스텁은 본래 every라는 함수를 통해 목업 메소드를 수행했으나, 코루틴은 coEvery 라는 스코프를 이용해야 됩니다. 또한 coVerify를 통해 메소드 호출을 확인할 수 있습니다.
Controller가 정상적으로 동작하는지 수행합니다. WebFlux는 @WebFluxTest 어노테이션을 통해 선언하며 WebTestClient를 통해 동작 검증이 가능합니다.
@WebFluxTest(TodoController::class)
class TodoControllerTest {
@MockkBean private lateinit var todoService : TodoService
@Autowired private lateinit var webTestClient : WebTestClient
@Test
fun `사용자가 투두 리스트를 조회하면 200의 응답코드와 투두리스트를 반환한다`() = runTest {
coEvery { todoService.getAllTodos() } returns listOf(
TodoResponseDto(1, "todo1", false, LocalDate.of(2024, 12,10)),
TodoResponseDto(2, "todo2", false, LocalDate.of(2024, 12,10)),
TodoResponseDto(3, "todo3", true, LocalDate.of(2024, 12,9)),
TodoResponseDto(4, "todo4", true, LocalDate.of(2024, 12,13)),
)
webTestClient.get()
.uri("/api/todos")
.exchange()
.expectStatus().isOk
.expectBody()
.jsonPath("$[0].todo").isEqualTo("todo1")
.jsonPath("$[1].todo").isEqualTo("todo2")
.jsonPath("$[2].todo").isEqualTo("todo3")
.jsonPath("$[3].todo").isEqualTo("todo4")
coVerify(exactly = 1) { todoService.getAllTodos() }
}
@Test
fun `사용자가 투두를 생성하면 201의 응답코드와 생성한 투두를 반환한다`() = runTest {
coEvery { todoService.createTodo(any()) } returns TodoResponseDto(5, "todo5", false, LocalDate.of(2024, 12, 10))
val newTodo = TodoRequestDto("todo5", false, "2024-12-10")
webTestClient.post()
.uri("/api/todos")
.accept(MediaType.APPLICATION_JSON)
.bodyValue(newTodo)
.exchange()
.expectStatus().isCreated
.expectBody()
.jsonPath("$.todo").isEqualTo("todo5")
.jsonPath("$.isDone").isEqualTo(false)
.jsonPath("$.createAt").isEqualTo(LocalDate.of(2024, 12, 10))
coVerify(exactly = 1) { todoService.createTodo(any()) }
}
}
@MockkBean은 ninja-squad의 패키지를 이용하여 선언할 수 있고, 간단하게 MockBean을 생성할 수 있습니다.
이로써, 간단한 R2DBC CRUD 서비스를 코루틴과 WebFlux를 이용하여 구축하였습니다. 동시성 테스트도 진행하려 했으나 피곤하여 이만....