Spring Framework/WebFlux

WebFlux + Coroutine + R2DBC로 CRUD 구현해보기

Junuuu 2023. 12. 25. 00:01
728x90

개요

정확한 동작까지는 모르더라도 WebFlux + Coroutine + R2DBC를 활용하여 CRUD를 구현해보고자 합니다.

 

MVC만 다루던 개발자로써 모르는 개념이 다수 등장할 수 있습니다.

저는 개인적으로 아래의 개념에 대해서 잘 모른다고 느꼈고 이번 포스팅 이후에 하나씩 알아가보려고 합니다.

  • r2dbc
  • Mono와 Flux, Flow
  • CoroutineCrudRepository
  • coRouter와 @RestController의 차이

 

의존성 추가

dependencies {
	implementation("org.springframework.boot:spring-boot-starter-webflux")
	implementation("org.springframework.boot:spring-boot-starter-data-r2dbc")

	implementation("io.r2dbc:r2dbc-h2")
	runtimeOnly("com.h2database:h2")

	implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
	implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")

	testImplementation("io.projectreactor:reactor-test")
}

webflux, r2dbc, h2를 추가하였습니다.

 

application.yml

spring:
  r2dbc:
    username: sa
    password:
    url: r2dbc:h2:mem:///test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE

  h2:
    console:
      enabled: true
      path: /h2-console
  sql:
    init:
      mode: always

 

h2 database를 활용하기 위한 설정을 구성합니다.

 

resources/schema.sql

CREATE TABLE IF NOT EXISTS member
(
    id bigint NOT NULL AUTO_INCREMENT,
    name VARCHAR(255),
    PRIMARY KEY (id)
);

spring boot에서는 데이터베이스 초기 설정을 위해 DDL(schema.sql), DML(data.sql)을 사용할 수 있습니다.

resources/data.sql

insert into member (name) values ('James')
insert into member (name) values ('Josh')
insert into member (name) values ('jun')
insert into member (name) values ('Roman')

 

 

Member

@Table
data class Member(
    @Id val id: Long = 0,
    val name: String,
)

 

 

Repository

@Repository
interface MemberRepository: CoroutineCrudRepository<Member, Long>{}

 

CoroutineCrudRepository를 활용합니다.

기본적으로 CRUD에 대한 기능을 제공합니다.

 

Persistence Layer 테스트

@SpringBootTest
class WebFluxApplicationTests @Autowired constructor(
	private val memberRepository: MemberRepository,
) {

	@Test
	fun contextLoads() {
	}

	@Test
	fun `DB에 junwoo 라는 member를 넣으면 5명이 조회되어야 한다`(){
		runBlocking {
			memberRepository.save(Member(name = "junwoo"))
			val members = memberRepository.findAll()
			Assertions.assertNotNull(members.last().id)
			Assertions.assertEquals(members.count(), 5)
		}
	}
}

 

기존에 데이터베이스 세팅이 4개가 되어있기 때문에 junwoo라는 Member로 신규로 넣으면 총 5명의 회원이 조회되어야 합니다.

 

Handler(Service) 구현

@Component
class MemberHandler(private val repository: MemberRepository) {
    suspend fun getAllMembers(): Flow<Member> {
        return repository.findAll()
    }

    suspend fun getMemberById(id: Long): Member? {
        return repository.findById(id)
    }

    suspend fun createMember(member: Member): Member {
        return repository.save(member)
    }

    suspend fun updateMember(id: Long, updatedMember: Member): Boolean {
        val existingMember = repository.findById(id)
        return if (existingMember != null) {
            repository.save(updatedMember.copy(id = id))
            true
        } else {
            false
        }
    }

    suspend fun deleteMember(id: Long): Boolean {
        val existingMember = repository.findById(id)
        return if (existingMember != null) {
            repository.deleteById(id)
            true
        } else {
            false
        }
    }
}

 

repository의 구현을 활용합니다.

다만 특이사항으로는 suspend 키워드가 붙어있습니다.

 

Router(Controller) 구현

@Configuration
class MemberRouter(private val handler: MemberHandler) {
    @Bean
    fun memberRoutes(): RouterFunction<ServerResponse> = coRouter {
        GET("/members") { request ->
            val members = handler.getAllMembers()
            ok().bodyAndAwait(members)
        }

        GET("/members/{id}") { request ->
            val memberId = request.pathVariable("id").toLong()
            val member = handler.getMemberById(memberId)
            if (member != null) {
                ok().bodyValueAndAwait(member)
            } else {
                notFound().buildAndAwait()
            }
        }

        POST("/members") { request ->
            val member = request.bodyToMono<Member>().awaitSingle()
            val savedMember = handler.createMember(member)
            created(create("/members/${savedMember.id}")).bodyValueAndAwait(savedMember)
        }

        PUT("/members/{id}") { request ->
            val memberId = request.pathVariable("id").toLong()
            val updatedMember = request.bodyToMono<Member>().awaitSingle()
            val result = handler.updateMember(memberId, updatedMember)
            if (result) {
                ok().buildAndAwait()
            } else {
                notFound().buildAndAwait()
            }
        }

        DELETE("/members/{id}") { request ->
            val memberId = request.pathVariable("id").toLong()
            val result = handler.deleteMember(memberId)
            if (result) {
                noContent().buildAndAwait()
            } else {
                notFound().buildAndAwait()
            }
        }
    }
}

coRouter를 활용하여 Endpoint들을 정의할 수 있습니다.

bodyToMono 등의 메서드등을 활용해 볼 수 있습니다.

 

Http로 통합 시나리오 테스트 하기

### 전체회원조회
GET localhost:8080/members


### 특정회원조회 - 존재
GET localhost:8080/members/1

### 특정회원조회 - 존재하지 않음
GET localhost:8080/members/100

### 회원가입
POST localhost:8080/members
Content-Type: application/json

{
  "name" : "jun1"
}

### 수정
PUT localhost:8080/members/5
Content-Type: application/json

{
  "name" : "updated"
}

### 삭제
DELETE localhost:8080/members/5

 

 

 

 

참고자료

https://www.youtube.com/watch?v=pXtTp4Uxuhk

https://github.com/bezkoder/spring-boot-r2dbc-h2-example/blob/master/src/test/java/com/bezkoder/spring/r2dbc/h2/SpringBootR2dbcH2ExampleApplicationTests.java