[열심히 삽질] DB Connection Pool 고갈과 Thread Blocking 문제를 해결해보자 (후편) : RabbitMQ 도입과 비동기 처리를 통한 트래픽 제어

2026. 1. 11. 01:49How to become a real programmer/Projects

 

지난 이야기 : 가상 스레드는 은탄환(Silver Bullet)이 아니었다.

 

 

[열심히 삽질] DB Connection Pool 고갈과 Thread Blocking 문제를 해결해보자 (전편) : Facade 패턴 도입, 메

안녕하십니까. 싸피를 다니며 개인 프로젝트를 하고 있는데요, 이전에 만들어 둔 flutter 프로젝트의 백엔드를 Springboot로 개발해보자! 싶어 기능 개발을 하고 연결을 하던 중. 문제가 생긴 경험을

minddokddok.tistory.com

 

저번 글에서 저는 "Gemini API 응답을 기다리는 3초 동안 스레드가 차단(Blocking)되는 문제"를 해결하기 위해 Java 21의 Virtual Thread를 도입했습니다.

 

결과는 어땠냐고요? 화려하게 망했습니다.

 

가상 스레드는 Tomcat의 문지기 역할을 하던 스레드 제한(200개)을 없애버렸고, 그 결과 300명의 요청이 수문장 없이 DB로 직행했습니다. "식당 좌석을 무한대로 늘렸더니, 주방(DB)에 주문서 300장이 1초 만에 쏟아져 요리사들이 파업한 상황"이 된 거죠.

 

결국 깨달았습니다. "수용량(Throughput)을 늘리는 것보다 중요한 건, 우리 시스템이 감당할 수 있는 속도로 처리하는 것(Flow Control)이다."

 

그래서 이번에는 무작정 문을 여는 것이 아니라, '대기표 시스템'을 도입하기로 했습니다.

 

바로 메시지 큐(Message Queue), RabbitMQ입니다.

 

귀엽게 생겼다~

 


1. 전략 변경 : 동기(Sync)에서 비동기(Async)로

기존 구조의 가장 큰 문제는 사용자가 요청을 보내면 "AI가 고민하고 DB에 저장할 때까지(약 3~4초)" 사용자와 서버, DB가 모두 묶여 있다는 점이었습니다.

 

[AS-IS: 동기 방식]

사용자: "채팅 보낼게!" -> (서버: AI 호출 대기... DB 저장 대기...) -> "자, 여기 응답!" (3초 소요)

문제점: 3초 동안 서버 리소스 점유, 트래픽 몰리면 DB 커넥션 고갈.

 

이걸 끊어내야 합니다. 사용자의 요청을 '처리'하는 게 아니라, 일단 '접수'만 받는 구조로 바꿉니다.

 

 

[TO-BE: 비동기 방식]

사용자: "채팅 보낼게!" -> (서버: 큐에 넣음) -> "접수 완료! 스털링이 고민 중이야." (0.01초 소요) ... (백그라운드에서 별도 일꾼이 하나씩 처리) ...

서버: (WebSocket) "다 됐어! 확인해 봐!"

 

이렇게 하면 사용자는 기다릴 필요가 없고, 서버는 당장 무거운 작업을 하지 않아도 됩니다. 이 구조를 위해 RabbitMQ를 중간 버퍼(Buffer)로 두었습니다.


2. RabbitMQ 설정과 구조 변경

우선 DockerCompose에 RabbitMQ를 띄우고, Spring Boot와 연동 설정을 했습니다. AI 요청을 담아둘 monkey.ai.queue라는 대기열을 만들었죠.

 

[RabbitMqConfig]

package org.example.nosmoke.config;

import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.core.TopicExchange;
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.amqp.support.converter.MessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;



@Configuration
public class RabbitMqConfig {

    public static final String EXCHANGE_NAME = "monkey.exchange";
    public static final String QUEUE_NAME = "monkey.ai.queue";
    public static final String ROUTING_KEY = "monkey.ai.request";

    // 큐 생성 (AI 요청을 쌓아 둘 버퍼)
    @Bean
    public Queue aiQueue(){
        return new Queue(QUEUE_NAME, true);
    }

    // 익스체인지 생성
    @Bean
    public TopicExchange exchange(){
        return new TopicExchange(EXCHANGE_NAME);
    }

    // 큐와 익스체인지 바인딩
    @Bean
    public Binding binding(Queue queue, TopicExchange exchange){
        return BindingBuilder.bind(queue).to(exchange).with(ROUTING_KEY);
    }

    // 메시지 컨버터(Java 객체 -> JSON 변환)
    @Bean
    public MessageConverter converter(){
        return new Jackson2JsonMessageConverter();
    }

    // RabbitTemplate 설정
    @Bean
    public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory){
        RabbitTemplate template = new RabbitTemplate(connectionFactory);
        template.setMessageConverter(converter());
        return template;
    }

}

 

그리고 기존에 MonkeyService가 직접 AI를 호출하던 부분을, "큐에 메시지(이벤트)를 발행(Publish)하는" 코드로 변경했습니다. 이제 Facade는 무거운 짐을 지지 않습니다. 그저 "이거 나중에 처리해 줘"라고 쪽지를 날릴 뿐입니다.

 

[MonkeyFacade]

package org.example.nosmoke.service.monkey;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.example.nosmoke.config.RabbitMqConfig;
import org.example.nosmoke.dto.monkey.MonkeyAiRequestEvent;
import org.example.nosmoke.dto.monkey.MonkeyChatContextDto;
import org.example.nosmoke.dto.monkey.MonkeyMessageResponseDto;
import org.example.nosmoke.entity.MonkeyMessage;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.stereotype.Component;

import java.time.Instant;
import java.util.List;

@Slf4j
@Component
@RequiredArgsConstructor
public class MonkeyFacade {

    private final MonkeyService monkeyService;
    // Facade 에서 이제 더이상 AI를 직접 부르지 않아도 되기에 AiService가 아닌 RabbitTemplate 부름
    private final RabbitTemplate rabbitTemplate;
    private final SimpMessagingTemplate messagingTemplate;
    private final TaskScheduler taskScheduler;


    // 채팅 기능
    public String chatWithSterling(Long userId, String userMessage){
        MonkeyChatContextDto context = monkeyService.getChatContext(userId);

        String prompt = monkeyService.createPersonPrompt(context, userMessage);

        rabbitTemplate.convertAndSend(
                RabbitMqConfig.EXCHANGE_NAME,
                RabbitMqConfig.ROUTING_KEY,
                new MonkeyAiRequestEvent(userId, prompt)
        );

        log.info(">>> AI 요청 큐 발행 완료 (User: {}) ", userId);

        return "스털링이 고민을 시작했습니다...";
    }

    // 건강 분석 기능
    public String analyzeHealth(Long userId){
        MonkeyChatContextDto context = monkeyService.getChatContext(userId);

        if (context.getSmokingInfo() == null || context.getSmokingInfo().getQuitStartDate() == null) {
            return "아직 금연 정보를 등록하지 않으셨군요. 정보 탭에서 금연 시작일을 설정해주세요!";
        }

        String prompt = monkeyService.createHealthAnalysisPrompt(context);

        rabbitTemplate.convertAndSend(
                RabbitMqConfig.EXCHANGE_NAME,
                RabbitMqConfig.ROUTING_KEY,
                new MonkeyAiRequestEvent(userId, prompt)
        );

        log.info(">>> 건강 보고서 큐 발행 완료 (User: {}) ", userId);

        return "건강 보고서를 작성 중 입니다..";
    }

    public void sendWelcomeMessage(Long userId){
        String welcomeText = "어서오세요! 금연 도우미 스털링입니다.\n오늘 몸 상태는 좀 어떠신가요?";

        MonkeyMessageResponseDto responseDto = MonkeyMessageResponseDto.builder()
                .content(welcomeText)
                .messageType("PROACTIVE")
                .build();

        taskScheduler.schedule(() -> {
            try {
                messagingTemplate.convertAndSend("/sub/channel/" + userId, responseDto);
                log.info(">>> [Proactive] 스털링이 먼저 인사를 건넸습니다. (To: {})", userId);
            } catch (Exception e) {
                log.error(">>> 웰컴 메시지 전송 중 에러", e);
            }
        }, Instant.now().plusMillis(500));
    }

    // 메세지 조회
    public List<MonkeyMessage> findMessagesByUserId(Long userId) {
        return monkeyService.findMessagesByUserId(userId);
    }

}

 


3. 신의 한 수 : concurrency = "1"

이제 큐에 쌓인 주문서를 처리할 주방 보조(Consumer)를 고용해야 합니다. 여기서 이번 트러블 슈팅의 핵심, "트래픽 제어(Rate Limiting)"가 등장합니다.

 

만약 큐에 쌓인 메시지를 처리하는 컨슈머(Consumer) 스레드를 100개로 설정하면 어떻게 될까요? 결국 이전과 똑같이 100개의 DB 커넥션을 동시에 쓰게 되어 DB가 또 터질 겁니다.

 

그래서 저는 극단적인 처방을 내렸습니다. "요리사는 딱 한 명만 둔다."

 

[AiRequestConsumer.java 코드가 들어갈 자리]

(@RabbitListener에 concurrency = "1"이 설정된 코드를 강조해서 보여주세요.)

 

concurrency = "1". 이 설정 덕분에, 300명이 아니라 3,000명이 동시에 채팅을 걸어도 DB에 접근하는 스레드는 오직 1개로 유지됩니다.

 

"물밀듯이 들어오는 요청을 댐(Queue)에 가둬두고, 수로(Consumer)를 통해 졸졸 흐르게 만드는 것." 이것이 제가 찾은 해답이었습니다.


4. 결과 분석 : 이것은 '방어'를 넘어선 '통제'다

 

구조 변경 후, 다시 긴장되는 마음으로 300명 동시 접속 테스트(k6)를 돌렸습니다. 그리고 모니터링 대시보드를 본 순간, 전율이 일었습니다.

 

 

 

아깐 빨간 뭉탱이가 있던 Errors가.. 감격입니다 ㅠㅠ

 

(1) 생존 신고 : 실패율 0%

가장 먼저 눈에 띄는 건 에러율(Error Rate)이었습니다.

  • Before: 300명 중 180명 실패 (실패율 60%), Timeout 발생
  • After: 300명 전원 성공 (실패율 0%), Timeout 0건

서버는 단 한 명의 사용자도 내치지 않고 "접수 완료" 응답을 보냈습니다. 사용자는 더 이상 30초 동안 멈춘 화면을 보지 않아도 됩니다. 평균 응답 속도는 10초대에서 0.8초(800ms)로 줄어들었습니다. ㅠㅠ 이야 기술 참 좋다

 

(2) 로그의 미학 : 완벽한 순차 처리

로그를 뜯어보니 concurrency = "1"의 위력이 적나라하게 보였습니다.

보이시나요? 앞사람의 처리가 끝나자마자(DB 저장이 완료되자마자) 정확히 뒷사람의 작업을 시작합니다. 마치 잘 훈련된 군인들의 제식 훈련처럼, DB에는 1초에 딱 1~2번의 트랜잭션만 발생합니다.

 

300명이 밖에서 문을 두드려도, 주방(DB)은 아주 평화롭게 자기 페이스대로 요리를 하고 있는 겁니다. 주모 ~ 웍 돌려~~


5. 간단 느낀점 : 기술적 겸손함을 배우다

이번 삽질을 통해 뼈저리게 느낀 점이 있습니다.

  1. 확장이 능사가 아니다: 가상 스레드 같은 신기술로 처리량(Throughput)을 늘리는 건 쉽습니다. 하지만 그 뒤에 있는 리소스(DB)가 받아주지 못하면 그건 '성능 향상'이 아니라 '서비스 거부(DoS) 공격'이 됩니다.
  2. 비동기의 미학: 사용자를 기다리게 하지 않는 것(Non-blocking)이 사용자 경험(UX)뿐만 아니라 시스템 안정성에도 결정적인 역할을 한다는 것을 깨달았습니다.
  3. 제어의 중요성: 트래픽을 있는 그대로 다 맞는 게 아니라, "내가 처리할 수 있는 만큼만 받아서 처리하는(Backpressure)" 설계가 진정한 고가용성 시스템의 핵심이었습니다.

6. 그렇다면, Virtual Thread는 쓸모없었던 걸까요?

 

이 긴 삽질 끝에 문득 그런 생각이 들었습니다.

 

 

"결국 Virtual Thread 때문에 DB가 터졌던 거잖아? 그럼 얘는 안 쓰는 게 나았을까?"

 

난 정말 삽질한걸까...?

 

 

결론부터 말하자면 다행히 아닙니다. 오히려 이 시스템이 RabbitMQ와 만났을 때, Virtual Thread는 비로소 제 역할을 찾았습니다!

 

(1) Virtual Thread의 진짜 역할 : 엄청나게 큰 대문

이전에는 톰캣 스레드가 200개뿐이라, 201번째 손님부터는 문밖에서 기다려야 했습니다(Connection Refused/Wait). 하지만 Virtual Thread를 켰기 때문에 300명이든 1,000명이든 "일단 가게 안으로 들어오게(Accept)" 할 수 있었습니다.

 

만약 Virtual Thread가 없었다면? RabbitMQ에 메시지를 넣기도 전에, 톰캣 앞단에서 막혀서 "주문조차 못 하는" 상황이 발생했을 겁니다.

  • Virtual Thread: 손님을 받는 역할 (High Throughput)
  • RabbitMQ: 손님을 줄 세우는 역할 (Backpressure)
  • Consumer: 손님을 처리하는 역할 (Rate Limiting)

이 세 박자가 맞았기 때문에 실패율 0%가 나온 것입니다.

 

(2) Throughput(처리량)과 Resource(리소스)의 오해

저는 처음에 "Virtual Thread를 쓰면 성능이 좋아진다"고 막연히 생각했습니다. 하지만 이번 경험으로 깨달았습니다.

"Virtual Thread는 도로(Road)를 넓혀주는 기술이지, 도착지(DB)의 주차장을 넓혀주는 기술이 아니다."

 

도로가 넓어져서 차가 쌩쌩 달리기 시작했는데, 도착지인 톨게이트(DB)가 그대로라면? 당연히 톨게이트 앞에서 대참사(병목)가 일어납니다.

 

제가 했던 실수는 "도로만 넓혀놓고 톨게이트 대책을 안 세운 것"이었지, 도로를 넓힌 것(Virtual Thread 도입) 자체가 잘못은 아니었습니다.

 

그렇다고 도로에 병목이 생기진 않으니(virtual thread 에서는 주차장 대기 걸어놓고 할 일 합니다 : 스레드는 할 일 합니다) 나이스 한 선택이었죠.


7. 최종 결론 : 비동기 아키텍처의 완성

 

 

 

이번 트러블 슈팅을 통해 "안정적인 대용량 처리 아키텍처"에 대한 저만의 정의를 내릴 수 있게 되었습니다.

  1. 입구(Tomcat + Virtual Thread): 최대한 많은 요청을 거부 없이 받아낸다.
  2. 완충지대(RabbitMQ): 폭주하는 트래픽을 안전하게 받아내어 큐에 쌓는다.
  3. 출구(Consumer + DB): 우리 DB가 체하지 않는 속도(concurrency=1)로 꾸준하게 처리한다.

"무작정 빠르게(Fast)"가 아니라 "꾸준하고 안전하게(Reliable)". 이것이 챗봇 '스털링'이 300명의 주인님을 동시에 모시면서도 당황하지 않게 된 비결입니다.

 

이제 스털링(챗봇)은 아무리 많은 주인이 찾아와도 당황하지 않고, 차분하게 한 분 한 분 상담을 해드릴 수 있게 되었습니다.

(Gemini 서버 때문에 조금 늦게 답장하더라도, 절대 무시하진 않으니까요 크큭)

 

긴 트러블 슈팅 과정을 함께 지켜봐 주셔서 감사합니다. 끝!

 

 

 

- Q&A -

 

Q. 그냥 DB 커넥션 풀을 늘리면 안 되나요?

 

RabbitMQ 도입 없이 maximum-pool-size를 10개에서 100개로 늘렸다면 어떻게 되었을까요?

 : 

1. DB 서버가 물리적으로 터집니다 (CPU/Memory 폭발)

  • 상황: 가상 스레드(Virtual Thread) 덕분에 애플리케이션 서버는 300명이든 1,000명이든 다 받아줍니다.
  • 풀 증가: 커넥션 풀을 100개로 늘리면, 순간적으로 100개의 쿼리가 동시에 DB로 날아갑니다.
  • 결과: DB(MySQL) 입장에서는 동시 실행 쿼리가 10개일 때는 널널했지만, 100개가 되면 CPU가 미친 듯이 치솟고, 컨텍스트 스위칭(Context Switching) 비용이 급증합니다.
  • 비유: 주방(DB)에 화구 10개가 있을 때는 요리사들이 여유롭게 움직였는데, 화구를 100개로 늘리니 요리사들이 서로 부딪히고, 주방 온도가 500도가 되어 다 같이 쓰러지는 꼴입니다.

2. '밑 빠진 독에 물 붓기'입니다.

  • 가상 스레드는 수천, 수만 개의 동시 요청을 처리할 수 있습니다.
  • 하지만 DB 커넥션을 수천 개로 늘릴 수는 없습니다. (보통 수백 개가 한계)
  • 결국 "가상 스레드의 처리량(무한대) vs DB의 처리량(유한함)" 사이의 체급 차이가 너무 큽니다. 커넥션 풀을 아무리 늘려도 가상 스레드가 받아내는 물량을 따라갈 수 없습니다.

3. 여전히 '블로킹' 문제는 남습니다.

  • 커넥션 풀을 늘려도, 101번째 사용자는 여전히 100개의 커넥션 중 하나가 반납될 때까지 기다려야(Blocking) 합니다.
  • 사용자 입장에서는 33초 기다리던 게 15초 정도로 줄어들 뿐, "즉각적인 응답(Non-blocking)" 경험은 줄 수 없습니다.

 

Q. RabbitMQ도 쓰고, DB 커넥션 풀도 늘리면 어떻게 되나요?

이 질문은 시스템 튜닝의 핵심을 찌르는 아주 훌륭한 질문입니다. 만약 RabbitMQ를 도입한 상태에서 DB 커넥션 풀(HikariCP)을 10개에서 20개로 늘리고, RabbitMQ의 작업자(Concurrency)도 1명에서 20명으로 늘린다면 어떤 일이 벌어질까요?

 : 

1. 처리 속도가 20배 빨라집니다 (Throughput 증가)

  • 현재 (concurrency=1): 1초에 1건 처리. 300명 대기열을 다 비우는 데 300초가 걸림. (안정적이지만 느림)
  • 변경 후 (concurrency=20): 1초에 20건 처리. 300명 대기열을 15초 만에 다 비움. (안정적이면서 빠름!)

2. 여전히 DB는 안전합니다 (Safety)

  • 가상 스레드만 썼을 때는 300개, 1,000개의 요청이 제어 없이 DB를 때렸습니다. (폭주)
  • 하지만 지금은 우리가 설정한 concurrency=20 이라는 명확한 **상한선(Limit)**이 있습니다.
  • DB 입장에서는 1,000명이 오든 10,000명이 오든, 딱 우리가 허용한 20명하고만 상대하면 됩니다. 20개 정도의 동시 접속은 MySQL이 웃으면서 처리할 수 있는 수준이죠.

3. 이것이 바로 '압력 조절 밸브'입니다

이 구조의 가장 큰 장점은 우리가 DB의 상태를 봐가면서 밸브를 돌릴 수 있다는 점입니다.

  • DB가 좀 힘들어한다? -> concurrency를 20에서 10으로 줄임. (처리 속도는 늦어지지만 DB 보호)
  • DB가 너무 널널하고 사용자가 느리다고 불평한다? -> concurrency를 20에서 50으로 늘림. (DB 자원을 더 써서 속도 향상)

 

진짜 끝!