Spring Boot, CRUD API 개발을 위한 기록 (WebFlux 아키텍처 구성과 API)

Spring Boot 기반으로 WebFlux, CRUD API 를 개발하기 위한 기록을 남기도록 한다.

본격적인 프로젝트를 시작하면서 Spring Boot의 기본적인 WebFlux 아키텍쳐와 흐름에 대해 알아보고 API 를 구현 해보기로 한다.

TaskVibes: ADHD Daily Planner - Apps on Google Play
adhd, alarm, daily, planner, geofence

1. 아키텍처 확인

기본적인 API 를 호출 했을때 요청에서 응답까지 구성할 Spring Boot 내부의 흐름을 따라가 보기로 한다.

Spring Boot, WebFlux Flow Architecture
TaskVibes: ADHD Daily Planner - Apps on Google Play
adhd, alarm, daily, planner, geofence

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

TaskVibes: ADHD Daily Planner - Apps on Google Play
adhd, alarm, daily, planner, geofence

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

TaskVibes: ADHD Daily Planner - Apps on Google Play
adhd, alarm, daily, planner, geofence
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

TaskVibes: ADHD Daily Planner - Apps on Google Play
adhd, alarm, daily, planner, geofence