🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

반응형 프로그래밍 기초 완벽 가이드 - 슬라이드 1/7
A

AI Generated

2025. 12. 21. · 2 Views

반응형 프로그래밍 기초 완벽 가이드

스프링 웹플럭스와 Reactor를 활용한 반응형 프로그래밍의 핵심 개념을 실무 스토리로 쉽게 배웁니다. Mono, Flux, 백프레셔까지 초급자도 이해할 수 있도록 술술 읽히는 이북 스타일로 구성했습니다.


목차

  1. 반응형_프로그래밍이란
  2. Reactor_라이브러리
  3. Mono와_Flux_이해
  4. 동기_vs_비동기_처리
  5. Publisher와_Subscriber
  6. 백프레셔_개념

1. 반응형 프로그래밍이란

어느 날 김개발 씨가 회사에서 새로운 프로젝트 킥오프 미팅에 참석했습니다. 팀장님이 말씀하셨습니다.

"이번 프로젝트는 반응형 프로그래밍으로 진행합니다." 김개발 씨는 고개를 끄덕였지만, 사실 반응형 프로그래밍이 정확히 무엇인지 잘 몰랐습니다.

반응형 프로그래밍은 데이터의 흐름과 변화 전파에 중점을 둔 프로그래밍 패러다임입니다. 마치 신문 구독처럼, 데이터가 발행되면 구독자들이 자동으로 알림을 받아 처리하는 방식입니다.

이를 통해 비동기 데이터 스트림을 효율적으로 다룰 수 있으며, 시스템 자원을 최적화할 수 있습니다.

다음 코드를 살펴봅시다.

// 전통적인 방식 - 동기 처리
public List<User> getUsers() {
    return userRepository.findAll(); // 완료될 때까지 대기
}

// 반응형 방식 - 비동기 처리
public Flux<User> getUsersReactive() {
    return userRepository.findAll() // 즉시 반환
        .map(user -> {
            // 데이터가 도착하면 자동으로 처리
            return user;
        });
}

김개발 씨는 미팅이 끝난 후 선배 개발자 박시니어 씨를 찾아갔습니다. "선배님, 반응형 프로그래밍이 정확히 뭔가요?" 박시니어 씨는 커피를 한 모금 마시고 설명을 시작했습니다.

반응형 프로그래밍이란 무엇일까요? 쉽게 비유하자면, 반응형 프로그래밍은 마치 신문 구독 서비스와 같습니다.

여러분이 신문을 구독하면, 매일 아침 새로운 신문이 발행될 때마다 자동으로 집 앞에 배달됩니다. 여러분은 우체국에 가서 "새 신문 나왔어요?"라고 물어볼 필요가 없습니다.

이처럼 반응형 프로그래밍도 데이터가 준비되면 자동으로 알림을 받아 처리하는 방식입니다. 전통적인 프로그래밍 방식은 어땠을까요?

기존의 명령형 프로그래밍에서는 개발자가 모든 과정을 직접 제어해야 했습니다. 데이터베이스에서 데이터를 가져오는 동안 프로그램은 멈춰서 기다려야 했습니다.

사용자가 100명이면 100번 대기해야 했고, 그동안 서버의 스레드는 아무것도 하지 못한 채 블로킹되어 있었습니다. 더 큰 문제는 트래픽이 몰릴 때였습니다.

수천 명의 사용자가 동시에 접속하면 스레드가 부족해져서 서버가 다운되는 일이 빈번했습니다. 바로 이런 문제를 해결하기 위해 반응형 프로그래밍이 등장했습니다.

반응형 프로그래밍을 사용하면 논블로킹 방식으로 처리가 가능해집니다. 데이터를 요청하고 기다리는 대신, 요청만 보내고 바로 다른 작업을 할 수 있습니다.

데이터가 준비되면 자동으로 콜백이 호출되어 처리됩니다. 또한 이벤트 기반 아키텍처를 통해 시스템 자원을 효율적으로 사용할 수 있습니다.

무엇보다 높은 동시성 처리가 가능하다는 큰 이점이 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 전통적인 방식을 보면 findAll() 메서드가 완료될 때까지 스레드가 대기하는 것을 알 수 있습니다. 이 부분이 블로킹입니다.

반면 반응형 방식에서는 Flux<User>를 즉시 반환합니다. 데이터는 나중에 스트림으로 흘러들어오고, map 연산자가 각 데이터를 받을 때마다 자동으로 처리합니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 대규모 이커머스 플랫폼을 개발한다고 가정해봅시다.

블랙프라이데이 같은 특별 세일 기간에는 수만 명의 사용자가 동시에 접속합니다. 전통적인 방식이라면 수천 개의 스레드가 필요하고, 서버 비용이 기하급수적으로 증가합니다.

하지만 반응형 프로그래밍을 활용하면 적은 수의 스레드로도 효율적으로 처리할 수 있습니다. 넷플릭스, 스포티파이 같은 글로벌 기업에서 이런 패턴을 적극적으로 사용하고 있습니다.

하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 반응형 스트림 안에서 블로킹 API를 호출하는 것입니다.

예를 들어 block() 메서드를 사용하거나, 일반적인 JDBC를 사용하면 반응형의 장점이 사라집니다. 따라서 완전한 논블로킹 스택을 유지해야 합니다.

다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 설명을 들은 김개발 씨는 고개를 끄덕였습니다.

"아, 그래서 스프링 웹플럭스를 사용하는 거군요!" 반응형 프로그래밍을 제대로 이해하면 더 효율적이고 확장 가능한 시스템을 설계할 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - 반응형 프로그래밍은 모든 상황에 적합하지 않습니다. CPU 집약적인 작업보다는 I/O 집약적인 작업에 효과적입니다.

  • 디버깅이 어려울 수 있으므로, 충분한 로깅과 모니터링을 준비하세요.
  • 팀 전체가 반응형 패러다임을 이해해야 유지보수가 쉬워집니다.

2. Reactor 라이브러리

김개발 씨가 반응형 프로그래밍을 검색하다가 Reactor라는 라이브러리를 발견했습니다. "이게 스프링 웹플럭스의 핵심이라고?" 박시니어 씨가 옆에서 말했습니다.

"맞아요. Reactor는 리액티브 스트림즈 사양을 구현한 라이브러리예요."

Reactor는 스프링 생태계에서 사용하는 리액티브 프로그래밍 라이브러리입니다. 마치 건축에서 벽돌과 시멘트가 필요하듯이, 반응형 애플리케이션을 만들 때 필요한 핵심 도구입니다.

Reactor는 MonoFlux라는 두 가지 주요 타입을 제공하며, 다양한 연산자를 통해 데이터 스트림을 조작할 수 있습니다.

다음 코드를 살펴봅시다.

// Reactor 의존성
// implementation 'io.projectreactor:reactor-core:3.5.0'

import reactor.core.publisher.Mono;
import reactor.core.publisher.Flux;

public class ReactorExample {
    public void basicExample() {
        // 단일 값 발행
        Mono<String> mono = Mono.just("Hello Reactor");

        // 여러 값 발행
        Flux<Integer> flux = Flux.range(1, 5)
            .map(i -> i * 2)  // 각 값을 2배로
            .filter(i -> i > 5);  // 5보다 큰 값만

        // 구독하여 실제 실행
        flux.subscribe(System.out::println);
    }
}

김개발 씨는 궁금증이 생겼습니다. "선배님, Reactor 말고 다른 라이브러리는 없나요?" 박시니어 씨가 대답했습니다.

"RxJava도 있지만, 스프링 팀에서 만든 Reactor가 스프링과 가장 잘 맞아요." Reactor 라이브러리란 정확히 무엇일까요? 쉽게 비유하자면, Reactor는 마치 레고 블록 세트와 같습니다.

레고 블록으로 집도 만들고 자동차도 만들 수 있듯이, Reactor의 연산자들을 조합하면 복잡한 비동기 로직을 쉽게 구현할 수 있습니다. 각 연산자는 특정한 기능을 담당하며, 이들을 체이닝하여 원하는 데이터 처리 파이프라인을 만들 수 있습니다.

Reactor가 없던 시절에는 어땠을까요? 개발자들은 콜백 지옥에 빠지곤 했습니다.

비동기 작업을 처리하려면 콜백 안에 콜백을 넣고, 그 안에 또 콜백을 넣는 식으로 코드가 복잡해졌습니다. 에러 처리도 각 콜백마다 따로 해야 했고, 코드 가독성은 형편없었습니다.

더 큰 문제는 백프레셔를 직접 구현해야 했다는 점입니다. 데이터가 너무 빠르게 들어오면 시스템이 과부하로 죽어버리는 일이 빈번했습니다.

바로 이런 문제를 해결하기 위해 Reactor가 등장했습니다. Reactor를 사용하면 선언적인 방식으로 비동기 로직을 작성할 수 있습니다.

map, filter, flatMap 같은 익숙한 함수형 연산자를 사용하여 코드를 읽기 쉽게 만들 수 있습니다. 또한 자동 백프레셔를 지원하여 데이터 흐름을 자동으로 조절합니다.

무엇보다 리액티브 스트림즈 표준을 준수하여 다른 리액티브 라이브러리와도 호환된다는 큰 이점이 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 Mono.just()는 단일 값을 감싸는 퍼블리셔를 생성합니다. 다음으로 Flux.range(1, 5)는 1부터 5까지의 정수를 순차적으로 발행합니다.

map 연산자는 각 값을 변환하고, filter 연산자는 조건에 맞는 값만 통과시킵니다. 마지막으로 subscribe()를 호출해야 실제로 데이터가 흐르기 시작합니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 실시간 주식 거래 시스템을 개발한다고 가정해봅시다.

초당 수천 개의 주식 시세 데이터가 들어옵니다. Reactor를 활용하면 이 데이터를 필터링하고, 변환하고, 집계하는 작업을 파이프라인으로 구성할 수 있습니다.

카카오, 네이버 같은 국내 대기업에서도 Reactor를 적극적으로 도입하고 있습니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 subscribe()를 여러 번 호출하는 것입니다. 구독할 때마다 새로운 실행이 시작되므로, 의도치 않은 중복 처리가 발생할 수 있습니다.

따라서 한 번만 구독하고, 여러 소비자가 필요하면 share() 연산자를 사용해야 합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.

박시니어 씨가 말했습니다. "Reactor는 처음엔 어렵게 느껴지지만, 익숙해지면 엄청 강력한 도구예요." Reactor를 제대로 이해하면 복잡한 비동기 로직도 깔끔하게 작성할 수 있습니다.

여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - Reactor는 콜드 퍼블리셔입니다. subscribe()를 호출하기 전까지는 아무 일도 일어나지 않습니다.

  • 디버깅할 때는 log() 연산자를 체이닝하면 각 단계의 데이터 흐름을 확인할 수 있습니다.
  • 공식 문서의 마블 다이어그램을 보면 각 연산자의 동작을 시각적으로 이해할 수 있습니다.

3. Mono와 Flux 이해

김개발 씨가 코드를 작성하다가 고민에 빠졌습니다. "Mono를 써야 할까, Flux를 써야 할까?" 박시니어 씨가 웃으며 말했습니다.

"간단해요. 하나면 Mono, 여러 개면 Flux예요."

Mono는 0개 또는 1개의 데이터를 발행하는 퍼블리셔이고, Flux는 0개부터 N개의 데이터를 발행하는 퍼블리셔입니다. 마치 상자에 비유하면, Mono는 최대 1개의 물건만 담을 수 있는 작은 상자이고, Flux는 여러 개의 물건을 담을 수 있는 큰 컨테이너입니다.

이 둘을 적재적소에 사용하는 것이 반응형 프로그래밍의 핵심입니다.

다음 코드를 살펴봅시다.

import reactor.core.publisher.Mono;
import reactor.core.publisher.Flux;

public class MonoFluxExample {
    // Mono: 단일 사용자 조회
    public Mono<User> findUserById(Long id) {
        return userRepository.findById(id);  // 0 또는 1개
    }

    // Flux: 여러 사용자 조회
    public Flux<User> findAllUsers() {
        return userRepository.findAll();  // 0개 이상
    }

    // Mono를 Flux로 변환
    public Flux<User> monoToFlux(Mono<User> mono) {
        return mono.flux();
    }

    // Flux를 Mono로 변환
    public Mono<Long> fluxToMono(Flux<User> flux) {
        return flux.count();  // 전체 개수를 Mono로
    }
}

김개발 씨는 여전히 헷갈렸습니다. "그럼 언제 Mono를 쓰고 언제 Flux를 써야 하나요?" 박시니어 씨가 화이트보드에 그림을 그리기 시작했습니다.

Mono와 Flux는 어떻게 다를까요? 쉽게 비유하자면, Mono는 마치 택배 상자 하나와 같습니다.

주문한 물건이 도착하거나, 품절이면 빈 상자가 올 수도 있습니다. 하지만 절대 두 개의 물건이 동시에 오지는 않습니다.

반면 Flux는 마치 컨베이어 벨트와 같습니다. 공장에서 제품이 계속 흘러나오듯이, 데이터가 여러 개 연속으로 발행됩니다.

이처럼 Mono와 Flux는 발행할 데이터의 개수에 따라 구분됩니다. 잘못 선택하면 어떤 문제가 생길까요?

예를 들어 사용자 한 명의 정보를 조회하는데 Flux를 사용하면 어떻게 될까요? 기능적으로는 문제없지만, 코드를 읽는 사람이 혼란스러워합니다.

"여러 명을 조회하나?" 하고 오해할 수 있습니다. 반대로 여러 명의 정보를 조회해야 하는데 Mono를 사용하면 첫 번째 데이터만 받고 나머지는 무시됩니다.

더 큰 문제는 메모리 낭비입니다. Mono를 List로 감싸서 사용하면 모든 데이터를 메모리에 한 번에 로딩하게 되어 반응형의 장점이 사라집니다.

바로 이런 이유로 적재적소에 선택하는 것이 중요합니다. Mono를 사용하면 단일 결과 처리가 명확해집니다.

REST API에서 ID로 조회할 때, 로그인 결과를 반환할 때 같은 상황에서 적합합니다. Flux를 사용하면 스트리밍 처리가 가능해집니다.

목록 조회, 실시간 이벤트 수신, 파일 읽기 같은 상황에서 메모리 효율적으로 처리할 수 있습니다. 무엇보다 타입 안정성을 통해 컴파일 시점에 실수를 방지할 수 있다는 큰 이점이 있습니다.

위의 코드를 한 줄씩 살펴보겠습니다. 먼저 findUserById는 ID로 단일 사용자를 조회하므로 Mono를 반환합니다.

사용자가 없을 수도 있으니 Optional과 비슷한 개념입니다. 다음으로 findAllUsers는 모든 사용자를 조회하므로 Flux를 반환합니다.

flux() 메서드로 Mono를 Flux로 변환할 수 있고, count()로 Flux의 개수를 세어 Mono로 만들 수 있습니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 소셜 미디어 피드를 개발한다고 가정해봅시다. 사용자 프로필 조회는 Mono를 사용하고, 피드 목록 조회는 Flux를 사용합니다.

댓글 작성 API는 생성된 댓글 하나를 반환하므로 Mono를 사용합니다. 실시간 알림 스트림은 계속 데이터가 들어오므로 Flux를 사용합니다.

이렇게 명확하게 구분하면 코드의 의도가 분명해집니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 Flux를 List로 변환하기 위해 collectList()를 남발하는 것입니다. 이렇게 하면 모든 데이터를 메모리에 쌓아야 하므로, 스트리밍의 장점이 사라집니다.

따라서 꼭 필요한 경우가 아니라면 스트림 상태로 유지하는 것이 좋습니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.

박시니어 씨의 설명을 들은 김개발 씨는 이해했다는 표정을 지었습니다. "아, 그럼 단수냐 복수냐로 구분하면 되는군요!" Mono와 Flux를 제대로 구분하면 코드의 의도가 명확해지고 성능도 향상됩니다.

여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - REST API에서 단건 조회는 Mono, 목록 조회는 Flux를 사용하세요.

  • Mono.empty()와 Flux.empty()로 빈 결과를 표현할 수 있습니다.
  • just(), fromIterable(), fromStream() 등 다양한 생성 메서드를 활용하세요.

4. 동기 vs 비동기 처리

김개발 씨가 성능 테스트 결과를 보고 놀랐습니다. "왜 동시 사용자가 1000명만 넘어도 응답 시간이 급격히 느려질까요?" 박시니어 씨가 설명했습니다.

"지금 코드가 전부 동기 방식이거든요. 비동기로 바꿔야 해요."

동기 처리는 작업이 완료될 때까지 기다리는 방식이고, 비동기 처리는 작업을 시작하고 바로 다음 작업을 진행하는 방식입니다. 마치 세탁기에 빨래를 돌리는 것에 비유할 수 있습니다.

동기 방식은 세탁기 앞에서 빨래가 끝날 때까지 기다리는 것이고, 비동기 방식은 세탁기를 돌려놓고 다른 집안일을 하는 것입니다.

다음 코드를 살펴봅시다.

// 동기 방식 - 블로킹
public User getUserSync(Long id) {
    User user = userRepository.findById(id).get();  // 대기
    String email = emailService.getEmail(user.getId());  // 대기
    user.setEmail(email);
    return user;  // 총 2초 소요 (1초 + 1초)
}

// 비동기 방식 - 논블로킹
public Mono<User> getUserAsync(Long id) {
    return userRepository.findById(id)
        .flatMap(user ->
            emailService.getEmail(user.getId())  // 병렬 처리
                .map(email -> {
                    user.setEmail(email);
                    return user;
                })
        );  // 총 1초 소요 (동시 실행)
}

김개발 씨는 의문이 생겼습니다. "그럼 비동기가 무조건 빠른 건가요?" 박시니어 씨가 고개를 저었습니다.

"아니요. 상황에 따라 다릅니다.

CPU 집약적인 작업은 오히려 동기가 나을 수도 있어요." 동기와 비동기는 정확히 무엇이 다를까요? 쉽게 비유하자면, 동기 처리는 마치 은행 창구에서 한 명씩 순서대로 업무를 보는 것과 같습니다.

앞사람의 업무가 끝나야 다음 사람이 처리됩니다. 모든 직원이 고객을 기다리며 블로킹됩니다.

반면 비동기 처리는 마치 패스트푸드점의 주문 시스템과 같습니다. 주문을 받고 번호표를 주면, 직원은 바로 다음 손님을 받습니다.

주문한 음식은 준비되는 대로 호출합니다. 이처럼 동기와 비동기는 대기 시간을 어떻게 활용하느냐에 따라 구분됩니다.

동기 방식만 사용하면 어떤 문제가 생길까요? 서버에는 제한된 수의 스레드 풀이 있습니다.

동기 방식에서는 각 요청마다 스레드 하나가 할당되고, 응답이 올 때까지 그 스레드는 아무것도 하지 못합니다. 만약 데이터베이스 응답이 1초 걸린다면, 그 1초 동안 스레드는 낭비됩니다.

사용자가 늘어나면 스레드가 부족해져서 쓰레드 풀 고갈이 발생합니다. 더 큰 문제는 컨텍스트 스위칭 비용입니다.

스레드가 많아질수록 OS는 스레드를 전환하느라 CPU를 낭비하게 됩니다. 바로 이런 문제를 해결하기 위해 비동기 처리가 필요합니다.

비동기 방식을 사용하면 적은 수의 스레드로도 많은 요청을 처리할 수 있습니다. 스레드가 I/O를 기다리는 대신 다른 작업을 처리합니다.

또한 논블로킹 I/O를 통해 CPU와 메모리를 효율적으로 사용합니다. 무엇보다 높은 처리량을 얻을 수 있다는 큰 이점이 있습니다.

동일한 하드웨어로 훨씬 많은 사용자를 수용할 수 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 동기 방식을 보면 findById()가 완료될 때까지 스레드가 대기합니다. 그 다음 getEmail()도 완료될 때까지 또 대기합니다.

총 2초가 걸립니다. 반면 비동기 방식에서는 flatMap으로 두 작업을 체이닝하지만, 실제 실행은 논블로킹으로 진행됩니다.

스레드는 대기하지 않고 다른 요청을 처리할 수 있습니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 여행 예약 플랫폼을 개발한다고 가정해봅시다. 사용자가 검색하면 항공권, 호텔, 렌터카 정보를 동시에 조회해야 합니다.

동기 방식이라면 항공권 조회하고, 호텔 조회하고, 렌터카 조회하여 총 3초가 걸립니다. 하지만 비동기 방식으로 세 개를 동시에 조회하면 1초 만에 결과를 받을 수 있습니다.

야놀자, 여기어때 같은 플랫폼에서 이런 패턴을 활용하고 있습니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 CPU 집약적인 작업에 비동기를 남발하는 것입니다. 예를 들어 복잡한 수학 계산은 I/O 대기가 없으므로, 비동기로 해도 성능 향상이 없습니다.

오히려 컨텍스트 스위칭 오버헤드로 느려질 수 있습니다. 따라서 I/O 바운드 작업에만 비동기를 사용해야 합니다.

다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨가 조언했습니다.

"API 호출이나 데이터베이스 조회처럼 네트워크 I/O가 있는 곳에 비동기를 적용하세요." 동기와 비동기를 제대로 구분하면 시스템 자원을 최적화하고 더 많은 트래픽을 처리할 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - 외부 API 호출, 데이터베이스 쿼리 같은 I/O 작업에 비동기가 효과적입니다.

  • WebClient는 비동기, RestTemplate은 동기 방식입니다. 웹플럭스에서는 WebClient를 사용하세요.
  • 비동기 코드는 디버깅이 어려우므로, 충분한 테스트를 작성하세요.

5. Publisher와 Subscriber

김개발 씨가 코드를 실행했는데 아무 일도 일어나지 않았습니다. "왜 데이터가 안 나오죠?" 박시니어 씨가 코드를 보더니 말했습니다.

"subscribe()를 호출 안 했네요. Publisher는 Subscriber가 없으면 아무것도 안 해요."

Publisher는 데이터를 발행하는 주체이고, Subscriber는 데이터를 구독하여 소비하는 주체입니다. 마치 유튜브의 크리에이터와 구독자 관계와 같습니다.

크리에이터가 영상을 올려도, 구독자가 없으면 아무도 보지 않습니다. 구독자가 알림을 켜야 새 영상이 올라올 때마다 알림을 받습니다.

다음 코드를 살펴봅시다.

import reactor.core.publisher.Flux;
import org.reactivestreams.Subscriber;
import org.reactivestreams.Subscription;

public class PubSubExample {
    public void publisherSubscriberExample() {
        // Publisher 정의
        Flux<String> publisher = Flux.just("A", "B", "C");

        // Subscriber 정의
        publisher.subscribe(new Subscriber<String>() {
            private Subscription subscription;

            @Override
            public void onSubscribe(Subscription s) {
                this.subscription = s;
                s.request(1);  // 1개씩 요청
            }

            @Override
            public void onNext(String item) {
                System.out.println("받은 데이터: " + item);
                subscription.request(1);  // 다음 데이터 요청
            }

            @Override
            public void onError(Throwable t) {
                System.err.println("에러 발생: " + t.getMessage());
            }

            @Override
            public void onComplete() {
                System.out.println("완료!");
            }
        });
    }
}

김개발 씨는 궁금해졌습니다. "그럼 subscribe()만 하면 되나요?" 박시니어 씨가 설명했습니다.

"기본적으로는 그렇지만, 실제로는 onSubscribe, onNext, onError, onComplete라는 4가지 이벤트가 있어요." Publisher와 Subscriber는 어떻게 동작할까요? 쉽게 비유하자면, 이 관계는 마치 신문사와 구독자의 관계와 같습니다.

신문사가 아무리 좋은 기사를 써도, 구독 신청을 하지 않으면 신문이 배달되지 않습니다. 구독 신청을 하면 계약이 체결되고, 이후 매일 신문이 배달됩니다.

만약 배달 중 문제가 생기면 고객센터에 연락이 오고, 구독 기간이 끝나면 완료 통지가 옵니다. 이처럼 Publisher와 Subscriber도 명확한 생명주기를 가지고 통신합니다.

구독하지 않으면 어떤 문제가 생길까요? 많은 초보 개발자가 Flux나 Mono를 만들고 실행되기를 기대하지만, 실제로는 아무 일도 일어나지 않습니다.

이것을 콜드 퍼블리셔라고 합니다. 구독하기 전까지는 데이터 발행이 시작되지 않습니다.

마치 CD 플레이어에 CD를 넣어도, 재생 버튼을 누르지 않으면 음악이 나오지 않는 것과 같습니다. 더 큰 문제는 메모리 누수입니다.

구독을 해제하지 않으면 리소스가 계속 잡혀있게 됩니다. 바로 이런 이유로 명시적인 구독 관리가 중요합니다.

subscribe()를 호출하면 onSubscribe 이벤트가 먼저 발생합니다. 여기서 Subscription 객체를 받아 데이터를 몇 개나 요청할지 제어할 수 있습니다.

그 다음 데이터가 발행될 때마다 onNext가 호출됩니다. 에러가 발생하면 onError가 호출되고 스트림이 종료됩니다.

모든 데이터가 정상적으로 발행되면 onComplete가 호출됩니다. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 Flux.just()로 Publisher를 생성합니다. 이 시점에는 아무것도 실행되지 않습니다.

subscribe()를 호출하는 순간 onSubscribe가 실행되며 Subscription 객체를 받습니다. request(1)로 1개의 데이터를 요청하면 onNext가 호출되어 데이터를 받습니다.

이 과정이 반복되고, 마지막에 onComplete가 호출됩니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 실시간 채팅 애플리케이션을 개발한다고 가정해봅시다. 서버는 메시지를 발행하는 Publisher 역할을 하고, 각 사용자의 브라우저는 Subscriber 역할을 합니다.

사용자가 채팅방에 입장하면 구독을 시작하고, 퇴장하면 구독을 해제합니다. 새 메시지가 올 때마다 onNext가 호출되어 화면에 표시됩니다.

슬랙, 디스코드 같은 플랫폼이 이런 패턴을 사용합니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 subscribe()를 여러 번 호출하는 것입니다. 구독할 때마다 별도의 실행이 시작되므로, 데이터베이스 쿼리가 중복 실행될 수 있습니다.

따라서 한 번만 구독하거나, 여러 Subscriber가 필요하면 share() 연산자를 사용해야 합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.

박시니어 씨가 조언했습니다. "subscribe()를 잊지 마세요.

그리고 꼭 필요한 경우가 아니라면 간단한 람다 형태로 구독하는 게 편해요." Publisher와 Subscriber의 관계를 제대로 이해하면 반응형 스트림의 생명주기를 완벽히 제어할 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - 대부분의 경우 간단한 subscribe(data -> ...) 람다 형태로 충분합니다.

  • 프로덕션 환경에서는 반드시 onError 핸들러를 구현하세요.
  • Disposable을 저장해두면 나중에 구독을 취소할 수 있습니다.

6. 백프레셔 개념

김개발 씨의 애플리케이션이 갑자기 다운되었습니다. 로그를 보니 메모리 부족 에러였습니다.

"왜 메모리가 이렇게 많이 쓰이죠?" 박시니어 씨가 분석했습니다. "Publisher가 너무 빠르게 데이터를 발행하고 있네요.

백프레셔를 적용해야 해요."

백프레셔는 데이터 생산자와 소비자의 속도 차이를 조절하는 메커니즘입니다. 마치 고속도로의 톨게이트와 같습니다.

차들이 한꺼번에 몰려오면 정체가 생기므로, 톨게이트에서 속도를 조절하여 흐름을 제어합니다. 백프레셔도 데이터가 너무 빠르게 들어오면 소비자가 처리할 수 있는 만큼만 요청하여 시스템을 보호합니다.

다음 코드를 살펴봅시다.

import reactor.core.publisher.Flux;
import reactor.core.scheduler.Schedulers;
import java.time.Duration;

public class BackpressureExample {
    public void backpressureStrategies() {
        // 전략 1: 버퍼링
        Flux.range(1, 1000)
            .onBackpressureBuffer(100)  // 100개까지 버퍼링
            .subscribe(data -> {
                // 느린 처리
                Thread.sleep(100);
            });

        // 전략 2: 드롭
        Flux.interval(Duration.ofMillis(1))
            .onBackpressureDrop()  // 처리 못하면 버림
            .subscribe(System.out::println);

        // 전략 3: 최신 값만 유지
        Flux.interval(Duration.ofMillis(1))
            .onBackpressureLatest()  // 가장 최신 값만
            .subscribe(System.out::println);

        // 전략 4: 에러 발생
        Flux.range(1, 1000)
            .onBackpressureError()  // 처리 못하면 에러
            .subscribe(System.out::println);
    }
}

김개발 씨는 처음 듣는 용어였습니다. "백프레셔가 뭔가요?" 박시니어 씨가 물컵을 들고 설명했습니다.

"물을 컵에 따를 때 천천히 따르잖아요? 너무 빨리 따르면 넘치니까요.

백프레셔도 똑같아요." 백프레셔란 정확히 무엇일까요? 쉽게 비유하자면, 백프레셔는 마치 식당의 주방과 홀의 관계와 같습니다.

주방에서 요리를 너무 빨리 만들어내면 홀 직원이 서빙을 못 따라갑니다. 테이블이 꽉 차고, 음식은 식어버리고, 혼란이 생깁니다.

이때 홀 직원이 주방에게 "잠깐만 천천히 만들어주세요"라고 요청하는 것이 백프레셔입니다. 이처럼 소비자가 생산자에게 속도 조절을 요청하는 메커니즘입니다.

백프레셔가 없으면 어떤 문제가 생길까요? 초당 10만 건의 로그 데이터가 들어오는데, 데이터베이스는 초당 1000건밖에 저장하지 못한다고 가정해봅시다.

백프레셔가 없으면 나머지 99000건은 메모리에 쌓입니다. 1초만 지나도 메모리에 10만 건, 10초면 100만 건이 쌓여서 OutOfMemoryError가 발생합니다.

더 큰 문제는 시스템 전체가 다운된다는 점입니다. 한 컴포넌트의 과부하가 전체 시스템을 마비시킵니다.

바로 이런 문제를 해결하기 위해 백프레셔 전략이 필요합니다. 첫 번째 전략은 버퍼링입니다.

일정 개수까지는 메모리에 쌓아두고, 소비자가 처리할 수 있을 때 전달합니다. 임시 저장소 역할을 합니다.

두 번째는 드롭입니다. 처리하지 못하는 데이터는 과감하게 버립니다.

모니터링 지표처럼 일부 손실이 허용되는 경우에 유용합니다. 세 번째는 최신값 유지입니다.

가장 최근 데이터만 유지하고 나머지는 버립니다. 실시간 주가처럼 최신 정보만 중요한 경우에 적합합니다.

위의 코드를 한 줄씩 살펴보겠습니다. 먼저 onBackpressureBuffer(100)는 최대 100개까지 버퍼에 쌓습니다.

버퍼가 가득 차면 더 이상 받지 않습니다. onBackpressureDrop()는 소비자가 처리할 수 없는 데이터를 즉시 버립니다.

onBackpressureLatest()는 가장 최근 값 하나만 유지하고 중간 값들은 버립니다. onBackpressureError()는 처리할 수 없으면 에러를 발생시켜 명시적으로 알립니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 IoT 센서 데이터 수집 시스템을 개발한다고 가정해봅시다.

수천 개의 센서가 초당 여러 번 데이터를 보냅니다. 모든 데이터를 저장할 필요는 없고, 1초마다 평균값만 저장하면 됩니다.

이때 onBackpressureBuffer()로 1초치 데이터를 모았다가, 평균을 계산하여 저장합니다. 삼성 스마트팩토리, LG 스마트홈 같은 시스템에서 이런 패턴을 활용합니다.

하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 무조건 버퍼 크기를 크게 잡는 것입니다.

버퍼가 크면 메모리를 많이 소비하고, 결국 같은 문제가 발생합니다. 따라서 적절한 버퍼 크기를 프로파일링하여 결정해야 합니다.

또한 데이터 특성에 맞는 전략을 선택해야 합니다. 금융 거래 데이터는 절대 버리면 안 되지만, 실시간 센서 데이터는 일부 손실이 허용될 수 있습니다.

다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨가 조언했습니다.

"백프레셔 전략을 적용한 후 부하 테스트를 꼭 해보세요. 실제 운영 환경에서 어떻게 동작하는지 확인해야 해요." 백프레셔를 제대로 이해하고 적용하면 안정적이고 확장 가능한 시스템을 만들 수 있습니다.

여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - 중요한 데이터는 버퍼 전략, 손실 가능한 데이터는 드롭이나 최신값 전략을 사용하세요.

  • request(Long.MAX_VALUE)는 백프레셔를 비활성화하므로 주의하세요.
  • 프로덕션 환경에서는 모니터링을 통해 버퍼 오버플로우를 감지하세요.

이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!

#Spring#WebFlux#Reactor#Mono#Flux

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.

함께 보면 좋은 카드 뉴스