본문 바로가기
DataBase

Spring boot With MongoDB - WebFlux를 통해 비동기적으로 MongoDB에 접근해보자.

by khds 2024. 4. 23.

 

들어가기

 

이전에 채팅방 채팅 내용을 저장하기 위해서 MongoDB를 사용해 본 적이 있었다. Gradle 의존성을 'spring-boot-starter-data-mongodb'로 설정하였었는데, 알고 보니 'spring-boot-starter-data-mongodb-reactive'라는 의존성이 따로 있었다. 

이 두 의존성의 차이는 동기적으로 접근할 것인가, 비동기 적으로 접근할 것인가였다. 'reactive'가 추가된 의존성이 비동기 적으로 접근하는 것이다.

찾아보니 채팅 애플리케이션에서 채팅 내용을 저장하고 조회하는 기능은 실시간성(real-time)이 중요한 요소이기에, 비동기적인 처리가 더 적합할 수 있다고 한다. 이는 아래의 비동기 처리의 장점과 동기 처리의 단점을 통해 확인할 수 있다.

 

비동기 처리의 장점

  • 응답성: 비동기 처리는 I/O 작업을 블로킹하지 않기 때문에 새로운 메시지를 빠르게 저장하고 전송할 수 있다. 사용자 경험 측면에서 중요한 역할을 한다.
  • 스케일링: 비동기적인 처리는 요청을 동시에 처리하는 데 탁월하다. 많은 사용자가 동시에 대화를 나누고 있는 경우에도 서버가 더 많은 요청을 빠르게 처리할 수 있다.
  • 유연성: 비동기 처리 방식은 다양한 클라이언트의 요구사항에 맞춰 서비스를 확장하거나 쉽게 수정할 수 있는 데 도움을 준다.
  • 비동기식 프로그래밍 모델의 일관성: 채팅 시스템이 비동기 프로그래밍 모델을 사용하면, 더 일관성 있고 성능이 뛰어난 코드를 작성할 수 있다.

 

동기 처리의 단점

  • 응답 지연: 동기 처리 방식은 요청이 완료될 때까지 기다려야하기 때문에, 느린 I/O 작업이 전체 시스템의 반응성에 영향을 줄 수 있습니다. 
  • 스케일링: 동기 처리는 블로킹되거나 시간이 오래 걸리는 작업에 대해 제약이 있을 수 있어 대용량의 사용자 요청을 처리하는 데 어려움이 있습니다. 
  • 복잡성: 동기 코드는 에러 처리가 조심스럽고 예외 상황을 다루기 어려울 수 있습니다. 

 

채팅 애플리케이션의 경우, 사용자 간의 실시간 상호작용을 지원해야 하기 때문에 요청이 매우 빈번하고 동시에 다수가 발생할 수 있다. 이러한 환경에서는 높은 동시성과 비동기 처리가 중요한 요소가 된다. 

따라서, 대부분의 경우 채팅 애플리케이션에서는 채팅 내용의 저장과 조회에 대한 기능을 비동기 처리하는 것이 더 적합할 수 있다고 한다. 이는 사용자 경험과 확장성 측면에서 뛰어난 성능을 제공하며, 높은 실시간 응답성이 요구되는 채팅 애플리케이션에 적합한 접근 방식일 것이다.

 

이 글에선 Spring boot WebFlux와 spring-boot-starter-data-mongodb-reactive 의존성을 적용하여 비동기적으로 MongoDB에 접근하는 과정을 작성한 것이다.

WebFlux에 관한 설명은 https://adjh54.tistory.com/232를 참고하길 바란다.

 

[Java] Spring Boot Webflux 이해하기 -1 : 흐름 및 주요 특징 이해

해당 글에서는 Spring Boot Webflux에 대해 이해하고 전체적인 흐름, 특징에 대해서 이해를 돕기 위해 작성한 글입니다. 1) Spring Boot Webflux 💡 Spring Boot Webflux - 반응형 및 비동기적인 웹 애플리케이션

adjh54.tistory.com

 

MongoDB 데이터베이스 설정 및 연결하는 과정은 https://khdscor.tistory.com/115를 참고하길 바란다.

 

 

본론

1. 의존성 설정

 

우선 의존성을 설정해야 한다. 비동기 접근을 위한 WebFlux와 MongoDB 의존성을 추가한다. Gradle을 기준으로 아래와 같이 의존성을 추가하자.

implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb-reactive'

 

spring-boot-starter-data-mongodb-reactive와 spring-boot-starter-data-mongodb는 모두 Spring Boot 프로젝트에서 MongoDB 데이터베이스를 연동하기 위한 스타터 키트이다. 위에서 언급했다시피 spring-boot-starter-data-mongodb는 동기적 접근이고, spring-boot-starter-data-mongodb-reactive 는 비동기적 접근이라 이해할 수 있다. 

자세히 살펴보면 spring-boot-starter-data-mongodb는 아래와 같은 특징이 있다.

  1. Spring Data MongoDB의 전통적인 블로킹 방식의 데이터 액세스를 제공한다. 
  2. MongoDB 데이터베이스와의 통신은 주로 블로킹 I/O 작업을 사용한다.
  3. 일반적인 동기식 프로그래밍 모델을 따르며, 요청 처리 중 다른 요청을 처리하지 않기 위해 스레드가 차단될 수 있다.


아래는 spring-boot-starter-data-mongodb-reactive의 특징이다.

  1. Spring Data MongoDB를 사용한 리액티브 프로그래밍 모델을 지원한다. 
  2. 비동기적이고 논블로킹으로 데이터베이스 작업을 수행한다. 
  3. 이 스타터는 Project Reactor와 같은 리액티브 프로그래밍 라이브러리를 사용하여 데이터 스트림을 처리하고 백 프레셔(backpressure)를 관리한다. 
  4. 높은 동시성과 확장성을 제공하기에 높은 트래픽의 웹 애플리케이션에 적합할 수 있다.


요약하자면, 기본적으로 두 스타터 모두 Spring Boot와 MongoDB를 연동할 때 사용하지만, spring-boot-starter-data-mongodb-reactive는 비동기적, 논블로킹 방식의 처리를 지원하여 더 많은 동시 요청 처리와 효율적인 자원 사용이 가능하다. 반면, spring-boot-starter-data-mongodb는 더 전통적인 동기식 접근 방식을 따른다. 사용상의 선택은 애플리케이션의 요구 사항과 아키텍처에 따라 결정됩니다.


실시간으로 많은 메시지를 처리해야 하는 채팅 애플리케이션의 경우, 리액티브 프로그래밍 모델을 사용하여 비동기적이고 논블로킹 방식으로 데이터베이스 작업을 수행할 수 있어 높은 동시성과 빠른 처리 속도를 지원하는 spring-boot-starter-data-mongodb-reactive가 조금 더 적합할 수 있다.  

 

2. Repository 설정

 

이 글에서는 Spring data MongoDB를 사용할 것이다.

아래와 같이 Repository를 설정한다. reactive가 추가된 점, 반환타입이 Mono, Flux인 점이 다르다. 

public interface TestRepository extends ReactiveMongoRepository<ChattingContent, String> {
    Flux<ChattingContent> findChattingContentByName(String name);
}

 

 

3. config 설정

 

 config 파일을 통해서 세부적인 내용들을 수정할 수 있으며, 이 글에선 데이터 저장 시 _class 필드로 패키지 정보가 자동생성되지 않도록만 설정할 것이다.

만약 이를 설정하지 않는다면 아래와 같이 패키지 정보가 자동으로 추가될 것이다. 

 

아래의 코드를 추가하여 문제를 해결할 수 있다.

@Configuration
public class MongodbConfig implements InitializingBean {

    @Autowired
    @Lazy
    private MappingMongoConverter mappingMongoConverter;

    @Override
    public void afterPropertiesSet() throws Exception {
        mappingMongoConverter.setTypeMapper(new DefaultMongoTypeMapper(null));
    }
}

 

 

4. Controller  작성(1) - 데이터 저장 시 Mono 타입을 반환하여 비동기적 호출


이제 데이터를 저장, 조회하는 API를 작성해보자. 

MongoDB에 접근은 비동기적으로 진행하였지만, Controller 단에서 동기적으로 접근한다면 전혀 의미가 없을 것이다. 그렇기에 Controller 단 또한 비동기적으로 호출돼야 한다.

일반적으로 RestController를 다루는 것은 동기적 접근 방식이지만, 반환타입을 Mono, Flux 타입으로 설정해주는 것만으로도 간단하게 비동기 접근으로 구현할 수 있다.

@Autowired
private TestRepository testRepository;

@PostMapping("/save")
public Mono<ChattingContent> save(@RequestParam("name") String name, @RequestParam("age") Long age) {
    ChattingContent content = new ChattingContent(name, age);

    return testRepository.save(content);
}



 

물론 위와 같이 구현하고 데이터베이스 접근을 동기적으로 하면 의미가 없는 것은 마찬가지이다. 참고로  Sql Mapper(Mybatis)나 ORM(JPA)을 사용하는 것은 동기적으로 데이터베이스에 접근하는 것이다.

 

나는 여기서 의문을 하나 가졌었다. Mono 타입으로 반환하는 것은 WebFlux를 사용하는 것이라고 해도 클라이언트 입장에서는 호출을 기다리는 것이 아닌가?라는 의문이다. 결국 동기적인 것이지 않나? 

하지만 이는 잘못된 생각이었다.

아래의 비동기 처리 과정을 봐보자.

  1. 요청 수신: 사용자로부터 요청이 서버에 도착하고, 해당 요청에 대응하는 컨트롤러 메서드가 호출된다.

  2. Mono 객체 반환: 컨트롤러 메서드에서는 데이터베이스 조회 등의 작업을 실행하고, 이 작업의 결과를 담을 Mono 객체를 즉시 반환한다. 이 Mono는 아직 데이터를 담고 있지 않으며, 데이터베이스 조회 작업이 완료될 때 결과를 통지할 준비가 된 상태이다.

  3. 메서드 종료 및 비동기 작업 계속: 함께 반환된 Mono 객체와는 독립적으로, 컨트롤러 메서드는 종료된다. 한편, 데이터베이스로의 쿼리는 비동기적으로 계속 실행되고 있는다. 이 과정에서 메인 스레드는 차단되지 않으므로, 서버는 다른 요청을 처리할 수 있게 된다.

  4. 데이터 로드 완료: 비동기 쿼리가 완료되면, Mono 객체는 데이터베이스로부터 결과를 수신하고, 이를 구독하고 있는 사용자에게 결과를 자동으로 밀어내게(push) 된다. 이는 클라이언트와 서버 사이에 열려 있는 연결을 통해 이루어진다(예: HTTP/2).

  5. 응답 전송: 사용자는 요청한 데이터를 비동기적으로 전달받게 된다.

 

이렇듯 Mono 객체를 반환하는 과정은 비동기적으로 동작하며 클라이언트는 데이터를 기다리는 동안에도 다른 작업을 처리할 수 있고(논블로킹), 최초 사용자 입장에서 데이터가 준비되는 즉시 결과를 받을 수 있기 때문에 사용자 경험(UX)이 향상된다.

이 방식은 WebFlux를 사용하는 애플리케이션에서 흔히 볼 수 있는 패턴이며, 현대의 고성능 웹 애플리케이션을 구축하는 데 종종 사용된다고 한다.

 

 

5. Controller  작성(2) - 데이터 저장 시 반환 값 없이 Status 코드만 응답할 경우

 

Post 요청을 할 경우, 데이터를 받지는 않고 요청이 완료되었다는 응답 코드만을 확인하는 경우가 자주 사용될 것이다. 실제로 필자도 그러했다. 그럴 땐 아래와 같이 작성하자. 

@Autowired
private TestRepository testRepository;


@PostMapping("/save")
public Mono<ResponseEntity<Void>> save(@RequestParam("name") String name, @RequestParam("age") Long age) {
	ChattingContent content = new ChattingContent(name, age);

    return testRepository.save(content).map(c -> ResponseEntity.status(HttpStatus.CREATED).build());
}

 

save 시 반환되는 Mono 객체를 map을 통해 가공하여 201 응답  코드만을 반환하도록 하였다. 

 

 

6. Controller  작성(3) - 데이터 조회 시 Mono객체 내 ResponseEntity를 반환할 경우

 

프로젝트를 할 때마다 클라이언트가 조회 시 ResponseEntity 로 보내려는 DTO를 묶어서 반환하는 경우가 많았다.

이를 Mono 객체로 묶는 것은 사실 위에서 Void를 반환한 경우와 크게 다를 것이 없다. 아래의 코드를 봐보자.

@GetMapping("/find")
public Mono<ResponseEntity<ChattingContent>> find(@RequestParam("id") String id) {
    Mono<ChattingContent> test = testRepository.findById(id);
    return test.map(ResponseEntity::ok).defaultIfEmpty(ResponseEntity.notFound().build());
}

 

 

만약 해당하는 데이터가 있다면 200 Status 코드와 함께 데이터를 반환하고, 해당하는 데이터가 없다면 204 Status 코드를 반환하는  코드이다. 위와 같이 map을 통해 가공하여 데이터를 반환하는 것을 알 수 있다.

 

한 가지 더 언급하자면, 필자는 여기서 ResponseEntity<Mono<ChattingContent>>와 Mono<ResponseEntity<ChattingContent>>를 혼동한 적이 있다. 

두 개 다 문제없이 동작하지만 비동기적, 동기적 관점으로 보았을 때는 큰 차이가 있다.

ResponseEntity<Mono> 구조는 ResponseEntity 객체가 외부에 배치되고, Mono<ChattingContent>가 ResponseEntity의 본문(body)으로 존재하는 형태이다. 이 방식의 문제점은 ResponseEntity가 Mono 타입의 핸들링과 리액티브 실행 모델을 제대로 활용하지 못한다는 것이다.

즉, 리액티브 스트림의 구독이 해당 Mono 내부에서만 발생하며, ResponseEntity 자체는 리액티브 방식으로 처리되지 않고, 흔히 사용하는 동기적 접근 방식인 것이다. 결과적으로 이는 잘못 구성된 방식이다.


반면에 Mono<ResponseEntity> 구조는 전체 ResponseEntity<ChattingContent>를 Mono로 감싸는 방식이다. 이 경우, ResponseEntity와 그 내부의 데이터 모두가 리액티브하게 처리된다.
Mono가 ResponseEntity를 포함하고 있기 때문에, 응답과 관련된 모든 것이 리액티브 스트림의 일부로 관리된다. 이는 ResponseEntity의 생성과 관리가 리액티브 스트림의 구독과 함께 이루어짐을 의미한다.
결과적으로, 응답의 상태 코드나 헤더와 같은 추가적인 HTTP 응답 설정도 동적으로 처리할 수 있으며, 리액티브 프로그래밍 모델의 이점을 살릴 수 있다.

 

 

7. Controller  작성(4) -  데이터 List 조회

 

Mono 객체가 단일 데이터라면 Flux는 다량의 데이터를 반환한다. Flux를 반환하는  findChattingContentByName() 메서드를 호출하여 List를 조회하도록 하는 코드는 아래와 같다.

@GetMapping("/find")
public Mono<ResponseEntity<List<ChattingContent>>> find(@RequestParam("name") String name) {

    return testRepository.findChattingContentByName(name).collectList().map(ResponseEntity::ok)
            .defaultIfEmpty(ResponseEntity.notFound().build());
}

 

 

여기서 의문을 가질 수 있다. 반환타입이 Flux인 Repository 메서드를 호출하면서 Controller 단에서는 왜 Mono 객체로 반환하는가? 만약 아래의 코드로 구현한다면 리스트 내 각각의 데이터마다 헤더와 Status코드를 갖게 된다.

GetMapping("/find")
public Flux<ResponseEntity<ChattingContent>> find(@RequestParam("name") String name) {

    return testRepository.findChattingContentByName(name).map(ResponseEntity::ok)
            .defaultIfEmpty(ResponseEntity.notFound().build());
}

 

 

 

 

만약 아래와 같은 코드로 작성한다면 맨 처음 조회 응답은 같겠지만 ResponseEntity를 활용할 수 없다. 

@GetMapping("/find")
    public Flux<ChattingContent> find(@RequestParam("name") String name) {

        return test3Repository.findChattingContentByName(name);
    }

 

 

그렇기에 맨 처음 코드 방식을 추천한다. 단일 데이터를 Mono 객체로 반환할 때와 크게 다를 바 없다. 다른 점은 collectList()를 호출하는 점인데, findChattingContentByName() 메서드는 위에 Repository 선언 부분에서 확인할 수 있듯이 반환타입이 Flux이다.

이 Flux 스트림의 collectList() 메서드를 사용하여 Flux를 List<ChatMessage>로 변환하고, 이를 Mono로 래핑 한다.


이후 map 함수를 사용하여 리스트를 ResponseEntity에 담아 클라이언트에게 200 OK 상태로 반환한다.


만약 결과가 없다면 defaultIfEmpty 메서드를 사용하여 404 Not Found 상태를 반환한다.

 

 

참고

https://adjh54.tistory.com/232

 

[Java] Spring Boot Webflux 이해하기 -1 : 흐름 및 주요 특징 이해

해당 글에서는 Spring Boot Webflux에 대해 이해하고 전체적인 흐름, 특징에 대해서 이해를 돕기 위해 작성한 글입니다. 1) Spring Boot Webflux 💡 Spring Boot Webflux - 반응형 및 비동기적인 웹 애플리케이션

adjh54.tistory.com

 

https://adjh54.tistory.com/233

 

[Java] Spring Boot Webflux 이해하기 -2 : 활용하기

해당 페이지에서는 Spring Boot Webflux를 이용하여 실제 구현하고 활용하는 방법과 WebClient를 이용한 다른 도메인 호출 방법에 대해 공유합니다. 💡 [참고] 이전에 작성한 글을 읽고 오시면 크게 도

adjh54.tistory.com

 

https://velog.io/@answlsdud98/Monod%EC%99%80-Flux-%EA%B0%9D%EC%B2%B4-%EB%8B%A4%EB%A3%A8%EA%B8%B0

 

Mono와 Flux 객체 다루기!

MonoFlux

velog.io

https://stackoverflow.com/questions/23517977/spring-boot-mongodb-how-to-remove-the-class-column

 

Spring Boot & MongoDB how to remove the '_class' column?

When inserting data into MongoDB Spring Data is adding a custom "_class" column, is there a way to eliminate the "class" column when using Spring Boot & MongoDB? Or do i need to create a custom

stackoverflow.com

https://camel-context.tistory.com/20

 

[JAVA] CountDownLatch : 다른 쓰레드를 기다리는 방법

멀티쓰레드 프로그래밍에서 쓰레드들이 모든 작업을 마친 후에 특정한 작업을 해야하는 경우가 있다. 이를 위해 다른 쓰레드들에서 일련의 작업이 완료 될 때까지 대기하도록 Sync를 맞춰주는

camel-context.tistory.com

https://findmypiece.tistory.com/276

 

[SpringBoot] SpringMVC 에서 WebClient 사용시 주의사항

WebClient 는 Spring 에서 제공하는 RestClient 의 한 종류이다. 과거에 사용되던 RestTemplate 과 비슷한 역할이라고 생각하면 되는데 RestTemplate 는 장기적으로 Deprecated 예정이기 때문에 이제는 WebClient 를

findmypiece.tistory.com

https://github.com/occidere/tobytv13-9

 

GitHub - occidere/tobytv13-9: Mono의 동작방식과 block()

Mono의 동작방식과 block(). Contribute to occidere/tobytv13-9 development by creating an account on GitHub.

github.com