Spring Boot, CRUD API 개발을 위한 Reactive MongoDB 연동
Spring Boot 기반으로 Reactive MongoDB 를 사용하여 CRUD API 를 개발하기 위한 기록을 남기도록 한다.
1. 환경 세팅
1) MongoDB Docker 설정
docker pull mongo
docker run --name mongodb -d -p 27017:27017 mongo
2) MongoDB Client 설치

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 값을 생성하고 할당하지 않음 (수동설정)
- ObjectId 타입
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