Spring Boot, CRUD API 개발을 위한 기록 (WebFlux 아키텍처 구성과 API)
Spring Boot 기반으로 WebFlux, CRUD API 를 개발하기 위한 기록을 남기도록 한다.
Spring Boot 기반으로 WebFlux, CRUD API 를 개발하기 위한 기록을 남기도록 한다.
본격적인 프로젝트를 시작하면서 Spring Boot의 기본적인 WebFlux 아키텍쳐와 흐름에 대해 알아보고 API 를 구현 해보기로 한다.
1. 아키텍처 확인
기본적인 API 를 호출 했을때 요청에서 응답까지 구성할 Spring Boot 내부의 흐름을 따라가 보기로 한다.

2. 구성 및 설정
1) 디렉토리 구성
MVC 모델과 Reactor 모델은 개념상 큰 차이가 있지만 프로젝트에서 사용하던 MVC 기본 디렉토리 구조나 파일은 큰 차이가 없다. 설명이 부족한 개념은 MVC 모델 API를 작성하면서 정리한 이전글 을 참고 하도록 한다.
src
└── main
└── java
└── com.example.webfluxapi
├── common
├── controller
├── dto
├── entity
├── repository
└── service
패키지 내 디렉토리 구성
2) build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '3.1.4'
id 'io.spring.dependency-management' version '1.1.3'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
sourceCompatibility = '17'
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-r2dbc'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'io.netty:netty-resolver-dns-native-macos:4.1.95.Final:osx-aarch_64'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'org.mariadb:r2dbc-mariadb:1.1.3'
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'io.projectreactor:reactor-test'
}
tasks.named('test') {
useJUnitPlatform()
}
3) application.properties
spring.r2dbc.url=r2dbc:mariadb://localhost:3306/spring_test
spring.r2dbc.username=root
spring.r2dbc.password=root
spring.r2dbc.pool.enabled=true
spring.r2dbc.pool.initial-size=50
spring.r2dbc.pool.max-size=50
4) schema.sql
CREATE TABLE `member` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(128) NOT NULL,
`age` int(2) NOT NULL,
`created_at` datetime NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
Member Table Schema
3. Member API 만들기
디렉토리 구조와 아키텍쳐에 맞는 기능들을 구성하며 사용자를 추가하는 API 를 만들어 보고자 한다.
1) 공통, Api Response 작성
API 응답시 일관된 결과값을 주기 위해서 common 에 ApiResponse 라는 이름으로 클래스를 하나 작성했다.
package com.example.webfluxapi.common;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;
@Builder
public class ApiResponse {
@JsonProperty("code")
private Integer code;
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonProperty("page")
private Integer page;
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonProperty("limit")
private Integer limit;
@JsonProperty("message")
private String message;
// 값이 없을 때는 생략한다.
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonProperty("data")
private Object data;
}
common/ApiResponse.java
2) 공통, RestController Advice 작성
API 응답시 일관된 에러 메시지 처리를 위해서 common 에 ApiException 라는 이름으로 클래스를 하나 작성했다.
이 클래스에서는 SpringBoot 에서 전역적으로 예외를 처리하기 위해 @RestControllerAdvice 를 사용했다. 이 어노테이션을 사용하면 예외가 발생했을 때 적절한 메시지나 객체를 리턴할 수 있다.
package com.example.webfluxapi.common;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class APIException {
@ExceptionHandler(Exception.class)
public ResponseEntity<APIResponse> handleException(Exception exp) {
APIResponse res = APIResponse.builder()
.code(500)
.message(exp.getMessage())
.build();
return new ResponseEntity<>(res, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
common/ApiException.java
3) Entity 작성
Entity 는 데이터베이스의 테이블을 나타내는 자바 클래스를 의미하며 다음과 같은 특징을 가지고 있음.
- @Entity 어노테이션
: JPA가 해당 클래스를 엔티티로 인식하게 하기 위해, 클래스 선언 위에 @Entity 어노테이션이 붙어야 한다. - 식별자 필드
: 각 엔티티 인스턴스를 유일하게 식별할 수 있는 식별자 필드가 필요한데 @Id 어노테이션으로 지정한다. - @Table 어노테이션
: 데이터베이스의 테이블 이름과 엔티티 이름이 다른 경우, @Table 어노테이션으로 테이블 이름을 지정할 수 있다. - @Column 어노테이션
: 테이블의 컬럼(column)과 엔티티의 속성(property)이 다른 경우, @Column 어노테이션으로 컬럼 이름을 지정할 수 있으며 컬럼의 길이, 널(null) 허용 여부, 유니크(unique) 여부 등도 지정할 수 있다.
package com.example.webfluxapi.entity;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
import java.time.LocalDateTime;
@Table("member")
@Builder
@Getter
@Setter
public class Member {
@Id
@Column("id")
private Integer id;
@Column("name")
private String name;
@Column("age")
private Integer age;
@CreatedDate
@Column("created_at")
private LocalDateTime createdAt;
}
entity/Member.java
4) DTO 작성
DTO(Data Transfer Object)란 계층간 데이터 교환을 위해 사용하는 객체이며 아래에서는 Member 에 대한 DTO 를 Inner Class 로 작성 해보았다.
package com.example.webfluxapi.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.*;
import java.time.LocalDateTime;
public class MemberDTO {
@Builder
@Setter
@Getter
@ToString
@NoArgsConstructor
@AllArgsConstructor
public static class Crud {
@JsonProperty("id")
private Integer id;
@JsonProperty("name")
private String name;
@JsonProperty("age")
private Integer age;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
@JsonProperty("created_at")
private LocalDateTime createdAt;
}
}
dto/MemberDTO.java
5) Repository 작성
ReactiveCrudRepository 인터페이스는 스프링데이터에서 Reactive Stream 을 기반으로 하는 프로젝트 리액터의 타입들을 사용한다.
이 인터페이스를 상속 받아서 @Query 어노테이션으로 직접 쿼리를 정의하도록 새로운 함수를 추가했다.
package com.example.webfluxapi.repository;
import com.example.webfluxapi.entity.Member;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
@Repository
public interface MemberRepository extends ReactiveCrudRepository<Member, Integer> {
@Query("SELECT * FROM member ORDER BY id DESC LIMIT :page, :size")
Flux<Member> findAllBy(Integer page, Integer size);
// Flux<Member> findAllBy(PageRequest pageable);
}
repository/MemberRepository.java
6) Mapper 작성
특정 DTO 를 Entity 로 변경하거나 그 반대로 변경해야 할 상황이 있어서 따로 mapper 클래스를 작성했다.
package com.example.webfluxapi.mapper;
import com.example.webfluxapi.dto.MemberDTO;
import com.example.webfluxapi.entity.Member;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Slf4j
@Service
public class MemberMapper {
public MemberDTO.Crud ToDTOCrud(Member entity) {
return MemberDTO.Crud.builder()
.id(entity.getId())
.name(entity.getName())
.age(entity.getAge())
.createdAt(entity.getCreatedAt())
.build();
}
public Member ToCrudEntity(MemberDTO.Crud dto) {
return Member.builder()
.name(dto.getName())
.age(dto.getAge())
.createdAt(dto.getCreatedAt())
.build();
}
}
mapper/MemberMapper
7) Service 작성
Service 클래스는 @Service 어노테이션을 붙여서 스프링 컨테이너에 빈으로 등록하고 Controller 클래스와 Repository 클래스와 협력하여 웹 요청을 처리하고 데이터베이스와의 작업을 수행한다.
package com.example.webfluxapi.service;
import com.example.webfluxapi.dto.MemberDTO;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public interface MemberService {
public Mono<MemberDTO.Crud> create(MemberDTO.Crud dto);
public Mono<MemberDTO.Crud> item(Integer id);
public Flux<MemberDTO.Crud> list(Integer page, Integer limit);
}
service/MemberService.java
package com.example.webfluxapi.service.impl;
import com.example.webfluxapi.dto.MemberDTO;
import com.example.webfluxapi.mapper.MemberMapper;
import com.example.webfluxapi.repository.MemberRepository;
import com.example.webfluxapi.service.MemberService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@Slf4j
@Service
public class MemberServiceDB implements MemberService {
private final MemberRepository memberRepository;
private final MemberMapper memberMapper;
@Autowired
public MemberServiceDB(MemberRepository memberRepository, MemberMapper memberMapper) {
this.memberRepository = memberRepository;
this.memberMapper = memberMapper;
}
@Override
public Mono<MemberDTO.Crud> create(MemberDTO.Crud dto) {
return memberRepository.save(memberMapper.ToCrudEntity(dto)).flatMap(member -> {
return memberRepository.findById(member.getId());
}).map(memberMapper::ToDTOCrud);
}
@Override
public Mono<MemberDTO.Crud> item(Integer id) {
return memberRepository.findById(id).map(memberMapper::ToDTOCrud);
}
@Override
public Flux<MemberDTO.Crud> list(Integer page, Integer limit) {
return memberRepository.findAllBy((page - 1) * limit, limit).map(memberMapper::ToDTOCrud);
}
}
service/impl/MemberServiceDB.java
8) Controller 작성
package com.example.webfluxapi.controller;
import com.example.webfluxapi.common.ApiResponse;
import com.example.webfluxapi.dto.MemberDTO;
import com.example.webfluxapi.service.MemberService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Mono;
@RestController
@RequestMapping("/v1/api")
public class MemberController {
private final MemberService memberService;
@Autowired
public MemberController(MemberService memberService) {
this.memberService = memberService;
}
@PostMapping("/member")
public Mono<ApiResponse> create(
@RequestBody
MemberDTO.Crud memberDTO
) {
return memberService.create(memberDTO)
.map(member -> ApiResponse.builder()
.code(200)
.message("ok")
.data(member)
.build())
.switchIfEmpty(Mono.just(ApiResponse.builder()
.code(500)
.message("error")
.build()));
}
@GetMapping("/member/item/{id}")
public Mono<ApiResponse> item(
@PathVariable("id")
Integer id
) {
return memberService.item(id)
.map(member -> ApiResponse.builder()
.code(200)
.message("ok")
.data(member)
.build())
.switchIfEmpty(Mono.just(ApiResponse.builder()
.code(500)
.message("error")
.build()));
}
@GetMapping("/member/list/{page}")
public Mono<ApiResponse> list(
@PathVariable("page")
Integer page,
@RequestParam(value = "limit", defaultValue = "10")
Integer limit
) {
return memberService.list(page < 1 ? 1 : page, limit)
.collectList()
.map(members -> ApiResponse.builder()
.page(page < 1 ? 1 : page)
.limit(limit)
.code(200)
.message("ok")
.data(members)
.build())
.switchIfEmpty(Mono.just(ApiResponse.builder()
.code(500)
.message("error")
.build()));
}
}
controller/MemberController.java