본문 바로가기

사이드 프로젝트/Http 요청 순차 처리 솔루션 개발 프로젝트

HTTP 요청 순차 처리 솔루션 개발 프로젝트 - 1

안녕하세요.

이번 글에서는 HTTP 요청을 순차 처리하기 위한 솔루션을 개발한 개인 프로젝트에 대해서 소개하고자 합니다.

이번 1편에서는 구현한 프로그램의 개요, 전체적인 구조 및 동작 방식, 그리고 관련 기술 선택 근거에 대해서 설명하도록 하겠습니다.

 

전체 소스 : https://github.com/chrismrkr/bypass-server

1. 프로젝트 개요

Spring 기반의 서블릿 컨테이너는 멀티 스레드로 클라이언트 요청을 처리합니다. 그러므로, 클라이언트의 여러 요청이 멀티 스레드를 통해서 동시에 처리될 수 있다는 것을 의미합니다. 그렇다면 'Spring 기반 서블릿 컨테이너가 클라이언트의 특정 요청들을 순차적으로 처리하기 위해서는 어떻게 해야할까?' 라는 기술적 호기심을 생겼고, 이를 해결하기 위해서 프로그램을 개발하였습니다.

 

순차적으로 요청이 처리되어야 하는 첫번째 예시는 아이템 주문입니다. 가령, 포인트를 차감하여 아이템을 구매하는 경우 아래의 흐름으로 트랜잭션이 진행됩니다.

 

아이템 구매 트랜잭션

 

만약 트랜잭션의 격리성이 Repeatable Read이고 아이템 주문 트랜잭션이 동시에 여러 개가 실행 된다면, 재고 또는 포인트 상태에 경쟁상태(Race Condition)가 발생하여 트랜잭션 결과가 의도하지 않은 상태로 Commit될 수 있습니다. 물론, 격리성을 Serializable로 높이거나 비관적 락을 사용하여 문제를 해결할 수 있지만, 아이템 환불 및 구매 트랜잭션이 동시에 진행되는 경우에 교착상태(Dead Lock)이 발생할 수 있습니다. 그러므로, 이러한 경우에 동시에 요청되는 여러 트랜잭션에 대한 순차 처리 방법이 필요합니다.

 

두번째 예시는 송금입니다. 예를 들어, 2명의 사용자가 정산 등의 이유로 서로 동시에 계좌 송금을 할 수 있고 이 경우에 트랜잭션은 아래와 같이 진행됩니다.

계좌 송금 트랜잭션

 

이 경우에도 동일하게 격리수준이 Repeatable Read이면 의도하지 않은 트랜잭션 결과가 발생할 수 있고, 격리 수준을 높이는 경우에 교착상태(Dead Lock)이 발생할 수 있습니다. 물론, 트랜잭션을 (사용자 A 계좌 조회 - 계좌 차감), (사용자 B 계좌 조회 - 계좌 증가)로 나누어 처리할 수 있지만, 격리수준에 따른 잠재적인 교착상태 위험성과 트랜잭션 실패 시 보상 트랜잭션 구현 복잡성 등 여러 부작용이 존재합니다. 그러므로, 위와 같은 경우에도 동시 요청에 대한 순차적인 처리 방법이 필요합니다.

 

2. 프로그램 구조 및 동작 방식

클라이언트의 동시 요청을 순차적으로 처리하기 위해서 프로그램을 아래와 같은 구조로 설계하였습니다.

요청 순차 처리 프로세스

 

  • 1. Servlet Request

클라이언트가 요청을 보내면, 어플리케이션은 이를 순차적으로 처리할지 또는 일반적으로 처리할지를 결정합니다. 이는 어플리케이션 개발자가 코드 레벨에서 결정합니다. 

  • 2. PUB Request Event & Listen Result

 순차 처리가 필요한 요청인 경우, 이벤트 브로커에 이벤트 메세지를 발행(Publish)합니다. 순차 처리가 필요한 메세지끼리는 반드시 동일한 파티션에 포함되어야 합니다. 이벤트 메세지에는 실행해야 할 트랜잭션 서비스 메소드와 파라미터 등이 포함됩니다. 그리고, 이벤트 메세지가 처리 완료된 결과를 응답 받기 위해서 메세지 큐 채널을 구독(Subscribe)합니다.

  • 3. SUB Request Event & Execute Service

이벤트 브로커에 발행된 메세지를 구독(Subscribe)합니다. 이벤트 메세지에 기록된 트랜잭션 서비스 메소드와 파라미터를 참조하여 적절할 서비스를 실행합니다. 

  • 4. PUB Service Result

트랜잭션 서비스를 완료한 이후, 메세지 큐 채널로 처리 결과를 발행(PUB)합니다. 또한, 이벤트 브로커에 등록된 다음 메세지 처리를 위해 Ack를 전달합니다.

  • 5. Return Result

메세지 큐 채널을 통해 서비스 결과를 구독(SUB)하고, 이를 클라이언트에 응답합니다.

 

3. 기술 선택

  • 이벤트 브로커

클라이언트의 요청을 순차적으로 처리하기 위해서 이벤트 스트림을 활용하였습니다. 이벤트 스트림은 여러 생산자가 메세지를 발행할 수 있다는 특징과 오프셋을 통해 메세지를 순차적으로 읽을 수 있다는 특징을 갖습니다. 클라이언트의 요청을 다수의 생산자(서블릿 컨테이너)가 개별적으로 받아서 이를 이벤트 스트림에 기록하고, 순차적으로 읽어서 서비스를 처리할 수 있게 만들기 위해 이벤트 스트림을 선택하였습니다.

이벤트 스트림으로는 Redis Stream과 Kafka 2가지 중 확장성을 고려하여 Kafka를 선택했습니다. Redis Stream은 한 Topic 당 하나의 파티션만을 갖지만, Kafka는 Topic 당 여러 파티션을 가질 수 있습니다. 순차적으로 처리되어야 할 메세지들은 동일한 파티션에 포함되어야하고, 그렇지 않은 경우에는 서로 다른 파티션에 포함되어도 무방합니다. Redis Stream을 선택하면 순차적으로 처리할 필요가 없는 메세지 끼리도 동일한 파티션에 포함되며 이를 방지하기 위해서는 새로운 Topic 생성이 필요합니다. 반면, Kafka를 선택하면, Topic 추가 생성 없이 파티션을 추가하여 순차 처리되어야 할 메세지 끼리 동일한 파티션에 발행되도록 Partitioner를 조정할 수 있습니다. 이처럼, 확장성의 이유를 고려했을 때 Kafka를 이벤트 스트림으로 채택하였습니다.

 

  • 메세지 큐

메세지 큐는 이벤트 스트림과 달리 인스턴스와 인스턴스 사이를 직접 일대일로 연결하여 메세지를 주고받기 위해서 사용합니다. 클라이언트의 요청을 받은 컨테이너와 요청을 실제로 처리하는 컨테이너 사이에 메세지를 주고 받기 위해서 메세지 큐가 필요했습니다.

메세지 큐로는 Redis와 RabbitMQ를 고려했고, Redis의 PUB/SUB 기능을 메세지 큐로 활용했습니다. Redis의 메세지 큐는 fire-and-forget으로 동작하여 생산자는 소비자가 메세지를 정상적으로 받았는지의 여부를 판단하지 않습니다. 프로그램에서의 메세지 큐는 단순히 서비스 처리 결과를 클라이언트에 전달하기 위한 용도로 사용하고 추가적인 고급 기능(메세지 저장 등)에 사용되지 않으므로 Redis를 메세지 큐로 활용했습니다. 물론, 신뢰성 있는 메세지 전달이 필요한 상황이라면 RabbitMQ의 Ack 등이 필요합니다.

 

 

2편에는 프로그램의 구체적인 로직과 사용 방법 등에 대해서 소개하도록 하겠습니다.

감사합니다.