Spring Boot, CRUD API 개발을 위한 Reactive MongoDB 연동

Spring Boot 기반으로 Reactive MongoDB 를 사용하여 CRUD API 를 개발하기 위한 기록을 남기도록 한다.

Spring Boot, CRUD API 개발을 위한 Reactive MongoDB 연동
Photo by Rubaitul Azad / Unsplash

1. 환경 세팅

1) MongoDB Docker 설정
docker pull mongo
docker run --name mongodb -d -p 27017:27017 mongo
2) MongoDB Client 설치
MongoDB Compass Download (GUI)
MongoDB Compass, the GUI for MongoDB, is the easiest way to explore and manipulate your data. Download for free for dev environments.

2. 구성 및 설정

1) 디렉토리 구성
src
└── main
    └── java
        └── com.example.mongoapi
         	├── common
         	├── controller
          	├── dto
        	├── entity
         	├── repository
         	└── service

패키지 내 디렉토리 구성

2) build.gradle

build.gradle 에 의존성에 spring-boot-starter-data-mongodb-reactive 를 추가한다.

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.1.5'
    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-mongodb-reactive'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-webflux'
    compileOnly 'org.projectlombok:lombok'
    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.data.mongodb.host=localhost
spring.data.mongodb.port=27017
spring.data.mongodb.database=test

3. Member API 만들기

디렉토리 구조와 아키텍쳐에 맞는 기능들을 구성하며 사용자를 추가하는 API 를 만들어 보고자 한다.

1) 공통, Api Response 작성

API 응답시 일관된 결과값을 주기 위해서 common 에 ApiResponse 라는 이름으로 클래스를 하나 작성했다.

package com.example.mongoapi.common;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;
import lombok.Setter;

@Builder
@Setter
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.mongoapi.common;

import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class APIException {
    @ExceptionHandler(Exception.class)
    public APIResponse handleException(Exception e) {
        return APIResponse.builder()
                .code(500)
                .message(e.getMessage())
                .build();
    }
}

common/APIException.java

3) Entity 작성
  • @Document Annotation
    몽고디비 Collection 을 매핑 하도록 한다.
  • @Id Annotation
    • ObjectId 타입
      • 몽고디비가 자동으로 _id 값을 생성하고 할당
    • String 타입
      • 몽고디비가 자동으로 _id 값을 생성하고 할당
    • long 타입
      • 몽고디비가 자동으로 _id 값을 생성하고 할당하지 않음 (수동설정)
package com.example.mongoapi.entity;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;

import java.time.LocalDateTime;

@Document(collection = "member")
@Builder
@Getter
@ToString
@AllArgsConstructor
public class Member {
    @Id
    private String id;
    private String name;
    private String profileUrl;
    private Integer age;
    private LocalDateTime createdAt;
}

entity/Member.java

4) DTO 작성
package com.example.mongoapi.dto;

import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotEmpty;
import lombok.*;
import org.hibernate.validator.constraints.Length;

import java.time.LocalDateTime;
import java.util.List;

public class MemberDTO {

    @Getter
    @Setter
    @AllArgsConstructor
    @NoArgsConstructor
    @Builder
    public static class Items {
        @JsonProperty("code")
        private Integer code;

        @JsonProperty("message")
        private String message;

        @JsonProperty("data")
        private List<MemberDTO.Item> data;
    }

    @Builder
    @Setter
    @Getter
    @NoArgsConstructor
    @AllArgsConstructor
    public static class Create {
        @NotEmpty
        @Length(min = 5, max = 128)
        @JsonProperty("name")
        private String name;

        @NotEmpty
        @Length(min = 13, max = 1024)
        @JsonProperty("profile_url")
        private String profileUrl;

        @Min(10)
        @Max(90)
        @JsonProperty("age")
        private Integer age;

        @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
        @JsonProperty("created_at")
        private LocalDateTime createdAt;
    }

    @Builder
    @Setter
    @Getter
    @NoArgsConstructor
    @AllArgsConstructor
    public static class Item {
        @JsonProperty("id")
        private String id;
        @JsonProperty("name")
        private String name;
        @JsonProperty("profile_url")
        private String profileUrl;
        @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 작성

ReactiveMongoRepository를 상속 받고 @Query 과 @Update 을 이용해 부분 업데이트나 페이징을 하도록 했다.

package com.example.mongoapi.repository;

import com.example.mongoapi.dto.MemberDTO;
import com.example.mongoapi.entity.Member;
import org.springframework.data.domain.Pageable;
import org.springframework.data.mongodb.repository.Query;
import org.springframework.data.mongodb.repository.ReactiveMongoRepository;
import org.springframework.data.mongodb.repository.Update;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@Repository
public interface MemberRepository extends ReactiveMongoRepository<Member, String> {

    @Query("{ 'id' : ?0 }")
    @Update("{ $set: { 'name' : ?1 } }")
    Mono<Long> updateName(String id, String name);

    @Query(value = "{}", sort = "{ 'createdAt' : -1 }")
    Flux<MemberDTO.Item> findAllByPage(Pageable pageable);

    /*
    // 조인예제
    @Aggregation(pipeline = {
            "{ $match: { 'id': ?0 }}",
            "{ $lookup: { from: 'member', localField: 'member._id', foreignField: '_id', as: 'member_info' }}",
            "{ $unwind: '$member_info' }"
    })
    Mono<BoardDocument> findAllWithMemberInfo(String id);

    // 조인 페이징 예제
    @Aggregation(pipeline = {
            "{ $sort : { 'created_ts' : -1 } }",
            "{ $skip : ?0 }",
            "{ $limit : ?1 }"
            "{ $lookup: { from: 'member', localField: 'member._id', foreignField: '_id', as: 'member_info' }}",
            "{ $unwind: '$member_info' }"
    })
    Flux<BoardDocument> findAllByPage(Integer offset, Integer limit);
    *//*

}

repository/MemberRepository.java

6) Mapper 작성
package com.example.mongoapi.mapper;

import com.example.mongoapi.dto.MemberDTO;
import com.example.mongoapi.entity.Member;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Slf4j
@Service
public class MemberMapper {

    public MemberDTO.Item ToDTOItem(Member entity) {
        return MemberDTO.Item.builder()
                .id(entity.getId())
                .name(entity.getName())
                .age(entity.getAge())
                .profileUrl(entity.getProfileUrl())
                .createdAt(entity.getCreatedAt())
                .build();
    }


    public Member ToCreateEntity(MemberDTO.Create dto) {
        return Member.builder()
                .name(dto.getName())
                .profileUrl(dto.getProfileUrl())
                .age(dto.getAge())
                .createdAt(dto.getCreatedAt())
                .build();
    }

    public Member ToItemEntity(MemberDTO.Item dto) {
        return Member.builder()
                .name(dto.getName())
                .age(dto.getAge())
                .profileUrl(dto.getProfileUrl())
                .createdAt(dto.getCreatedAt())
                .build();
    }

}

mapper/MemberMapper.java

7) Service 작성
package com.example.mongoapi.service;

import com.example.mongoapi.dto.MemberDTO;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

public interface MemberService {
    Mono<MemberDTO.Item> create(MemberDTO.Create dto);

    Mono<MemberDTO.Item> select(String id);

    Mono<MemberDTO.Item> updateName(String id, String name);

    Flux<MemberDTO.Item> list(Integer page, Integer size);
}

service/MemberService.java

package com.example.mongoapi.service.impl;

import com.example.mongoapi.dto.MemberDTO;
import com.example.mongoapi.mapper.MemberMapper;
import com.example.mongoapi.repository.MemberRepository;
import com.example.mongoapi.service.MemberService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import java.time.LocalDateTime;

@Service
@Slf4j
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.Item> create(MemberDTO.Create dto) {
        dto.setCreatedAt(LocalDateTime.now());
        return memberRepository.save(memberMapper.ToCreateEntity(dto)).map(memberMapper::ToDTOItem);
    }

    @Override
    public Mono<MemberDTO.Item> select(String id) {
        return memberRepository.findById(id).map(memberMapper::ToDTOItem);
    }

    @Override
    public Mono<MemberDTO.Item> updateName(String id, String name) {
        return memberRepository.updateName(id, name)
                .flatMap(len -> {
                    return memberRepository.findById(id).map(memberMapper::ToDTOItem);
                });
    }

    @Override
    public Flux<MemberDTO.Item> list(Integer page, Integer size) {
        Pageable pageable = Pageable.ofSize(size).withPage(page - 1);
        return memberRepository.findAllByPage(pageable);
    }
}

service/impl/MemberServiceDB.java

8) Controller 작성
package com.example.mongoapi.controller;

import com.example.mongoapi.common.APIResponse;
import com.example.mongoapi.dto.MemberDTO;
import com.example.mongoapi.service.MemberService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Mono;

@Slf4j
@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(
            @Validated
            @RequestBody MemberDTO.Create memberDTO
    ) {
        return memberService.create(memberDTO).map(val -> {
            return APIResponse.builder()
                    .code(200)
                    .message("success")
                    .data(val)
                    .build();
        });
    }

    @PatchMapping("/member/name/{id}")
    public Mono<APIResponse> updateName(
            @Validated
            @PathVariable("id") String id,
            @RequestBody MemberDTO.Item memberDTO
    ) {
        return memberService.updateName(id, memberDTO.getName()).map(val -> {
            return APIResponse.builder()
                    .code(200)
                    .message("success")
                    .data(val)
                    .build();
        }).switchIfEmpty(
                Mono.just(
                        APIResponse.builder()
                                .code(404)
                                .message("id not found")
                                .build()
                )
        );
    }

    @GetMapping("/member/{id}")
    public Mono<APIResponse> select(
            @PathVariable("id") String id
    ) {
        return memberService.select(id).map(val -> {
            return APIResponse.builder()
                    .code(200)
                    .message("success")
                    .data(val)
                    .build();
        });
    }


    @GetMapping("/member/list/{page}")
    public Mono<APIResponse> list(
            @PathVariable("page") Integer page,
            @RequestParam(value = "limit", defaultValue = "10")
            Integer limit
    ) {
        return memberService.list(page, limit).collectList().map(val -> {
            return APIResponse.builder()
                    .code(200)
                    .page(page)
                    .limit(limit)
                    .message("success")
                    .data(val)
                    .build();
        });
    }

}

controller/MemberController.java