올리브영 테크블로그 포스팅 SQS 기반 알림톡 처리에서 발생한 DB 커넥션 데드락 분석기
Tech

SQS 기반 알림톡 처리에서 발생한 DB 커넥션 데드락 분석기

이벤트 기반 구조 전환 이후 마주한 트랜잭션 경합 문제와 해결 과정

2025.12.30

들어가며


안녕하세요. 올리브영 클레임 스쿼드에서 백엔드 담당하고 있는 바로바로입니다.

올리브영에서는 고객분들에게 주문 완료, 배송 완료, 반품 완료 등 다양한 알림톡이 발송되고 있습니다.

저희 클레임 스쿼드는 이번에 여러 서비스에 흩어져 있던 알림톡 발송 로직을 정리하고, 이를 이벤트 기반 구조로 사내에 새롭게 구축된 Messaging-API 호출로 전환하는 작업을 진행했습니다.

하지만 진정한 개선은 기능을 완성했을 때가 아니라, 배포 후 마주한 데드락 문제를 해결하며 시스템의 한계치를 정확히 이해했을 때 이루어졌습니다. 30,000ms의 타임아웃 에러를 0으로 만들기까지, SQS와 트랜잭션 설정을 꼼꼼하게 튜닝했던 기록을 정리했습니다.

기존 알림톡 구조의 문제점


기존 알림톡 발송 구조에서는 반품 완료, 스마트반품 접수 등 각 비즈니스 로직마다 알림톡 전송 로직이 개별적으로 구현되어 있어, 공통 로직을 수정하거나 정책을 변경할 때 여러 코드를 동시에 수정해야 하는 문제가 있었습니다.

이로 인해 유지보수가 점점 어려워지고, 변경에 따른 사이드 이펙트 발생 가능성도 높아졌습니다.

또한 알림톡 전송이 실패했을 경우 이를 자동으로 재처리할 수 있는 별도의 재전송 프로세스가 마련되어 있지 않아, 장애 상황에서는 운영자가 직접 개입해야 하는 한계가 있었습니다.

기술적으로는 알림톡 발송 로직이 비즈니스 트랜잭션 내에 강하게 결합되어 있다는 점이 가장 큰 문제였습니다. 외부 API 호출 지연이나 네트워크 이슈가 발생할 경우 비즈니스 트랜잭션 전체가 대기 상태에 빠지게 되며, 이는 곧 시스템 전체의 처리량 저하로 이어질 수 있었습니다.

특히 대량의 알림톡을 배치로 발송할 때는 모놀리식 애플리케이션에 부하가 집중되어 시스템 전반의 안정성을 위협하는 구조였습니다.


SQS 기반 이벤트 알림 구조 도입


저희는 기존 알림톡 구조를 개선하기 위해 Amazon SQS 기반의 이벤트 알림 구조 도입을 검토했습니다.
이에 따라 SQS 중심으로 알림 발송 흐름을 재설계했고, 아래와 같이 구성도를 설계했습니다.

1. 구성도 설계


테스트

이벤트 기반 알림 발송 시스템 구성도

위 구성도를 바탕으로 한 알림톡 발송 프로세스는 다음과 같습니다.

  • 트리거: 각 비즈니스 로직(반품 완료 등)에서 알림 발송 API를 호출합니다.
  • 메시지 발행: 발송 API는 알림에 필요한 최소한의 데이터를 담아 SQS 메시지를 발행합니다.
  • 이벤트 소비: 컨슈머(Consumer)에서 발행된 메시지를 획득합니다.
  • 최종 발송: 컨슈머는 알림톡 종류에 따라 필요한 데이터를 가공한 뒤, Messaging API를 호출하여 최종 발송 처리를 완료합니다.

발송 실패 시에는 실패 로그를 DB에 적재하며, Amazon EventBridge를 통해 주기적으로 재시도 API를 호출하여 위 프로세스를 반복하게 됩니다.

Amazon EventBridge를 활용한 주기적 재시도 처리

알림톡 발송은 외부 API 호출을 동반하므로 네트워크 이슈 등에 의한 일시적 실패가 발생할 수 있습니다.

이런 경우에도 메시지가 유실되지 않도록 실패 이력을 테이블에 저장하고, EventBridge가 주기적으로 재시도 API를 호출해 해당 건을 재발송하도록 구현했습니다.

DLQ(Dead Letter Queue) 방식도 고려했지만, 실패 원인 추적과 개별 재처리 제어가 어렵고 운영 가시성이 낮아,
대신 DB 기반 이력 관리 + EventBridge 재시도 구조를 선택했습니다. 이를 통해 운영자가 직접 실패 건을 조회·분석하고 유연하게 대응할 수 있는 운영 가시성을 확보했습니다.




2. 알림톡 발송 로직 공통화


기존에 각 업무 로직 내부에 흩어져 있던 알림톡 전송 코드를 아래와 같이 공통 인터페이스 기반 구조 (NoticeSender)로 표준화했습니다.

//알림톡 SQS 메시지 Listener
@Component
class Listener(
    private val noticeService: NoticeService
) {
    @SqsListener
    fun consumeMessage(message: String) {
        when (val queueMessage = mapper.readValue(message, QueueMessage::class.java)) {
            noticeService.handleNotice(queueMessage)
        }
    }
}

//알림 핸들러 (다양한 알림톡 분기)
@Service
class NoticeHandler(
    private val noticeSenders: Map<String, NoticeSender>
) {
    fun handleNotice(queueMessage: QueueMessage) {
        val sender = when (queueMessage.noticeType) {
            // 반품 완료 알림톡
            RETURN_COMPLETE -> noticeSenders["returnComplete"]
            // 교환 철회 알림톡
            EXCHANGE_CANCEL -> noticeSenders["exchangeCancel"]
            // ...그 밖의 알림톡 발송
        } ?: throw IllegalArgumentException(
          "지원하지 않는 알림톡 타입이거나 Sender가 등록되지 않았습니다. noticeType=${queueMessage.noticeType}"
        )
        sender.send(queueMessage)
    }
}

//실제 알림톡 전송 프로세스 Sender
interface NoticeSender {
    //알림톡 전송 시작
    fun send(queueMessage: QueueMessage) {
        //공통 DTO
        val noticeSenderDto = NoticeSenderDto(queueMessage = queueMessage)
        //검증 및 관련 데이터 셋
        validateAndDataPut(noticeSenderDto)
        //템플릿 생성
        createTemplate(noticeSenderDto)
       //Messaging API Request 생성
        createMessagingRequest(noticeSenderDto)
        //Messaging API
        callMessageApi(noticeSenderDto)
        //알림 내역 저장
        saveNoticeMessageInfo(noticeSenderDto)
    }
    //검증 및 데이터 PUT
    fun validateAndDataPut(noticeSenderDto: NoticeSenderDto);
    //템플릿 생성
    fun createTemplate(noticeSenderDto: NoticeSenderDto): Map<String, String>
    //messaging API Request 생성
    fun createMessagingRequest(noticeSenderDto: NoticeSenderDto): MessageApiRequest
    //Messaging API 호출
    fun callMessageApi(noticeSenderDto: NoticeSenderDto)
    //알림 내역 저장
    fun saveNoticeMessageInfo(noticeSenderDto: NoticeSenderDto)
    
    //...공통 구현체
}
알림톡 발송 로직 공통 인터페이스

이를 통해 알림 발송 흐름(검증 → 템플릿 생성 → 메시지 생성 → 발송 → 저장)을 하나의 파이프라인으로 일원화하고, 각 알림 유형은 별도의 Sender 구현체만 추가하면 동작하도록 설계했습니다.

이렇게 개선함으로써 알림 발송 로직이 트랜잭션 처리와 분리되어 성능 저하나 커넥션 점유의 위험을 줄일 수 있었으며, 새로운 알림이 추가될 때도 기존 코드를 수정하거나 의존성을 변경할 필요 없이, 각 알림이 독립적으로 확장·관리될 수 있는 구조를 만들었습니다.

3. 트랜잭션 결합 해소


기존 구조에서는 알림톡 발송이 비즈니스 트랜잭션 내부에서 함께 처리되면서, 알림 전송 성공 여부와 관계없이 트랜잭션이 알림 발송 완료 시점까지 유지되는 구조였습니다. 이로 인해 외부 Messaging API 호출 지연이나 실패가 발생할 경우에도 트랜잭션이 계속 유지되며, 커넥션 점유 시간이 불필요하게 길어지는 문제가 발생할 수 있었습니다.

SQS 기반 이벤트 구조로 전환한 이후에는, 비즈니스 로직이 알림톡 발송 요청을 SQS에 발행하는 시점까지만 책임지고 즉시 다음 프로세스로 진행하도록 변경되었습니다.

실제 알림톡 발송 여부나 처리 결과는 Consume 서버에서 비동기적으로 처리되기 때문에, 발송 성공·실패와 관계없이 비즈니스 트랜잭션은 빠르게 종료됩니다.

이를 통해 트랜잭션 경합과 커넥션 점유를 줄이고, 알림 발송 처리량이 증가하더라도 비즈니스 로직에 미치는 영향을 최소화할 수 있는 구조를 만들 수 있었습니다.


배포 후 DB 커넥션 데드락


배포 후 안정적으로 알림톡들이 발행되고 한숨을 돌리려던 찰나… 아래의 오류가 순간 대량 발생한 것을 모니터링을 통해 확인했습니다.


이는 트랜잭션이 커넥션을 점유한 상태에서 추가 커넥션을 확보하지 못하여, 대기 시간을 초과했기 때문에 타임아웃이 발생한 에러였습니다.

우리는 해당 오류에 즉시 대응했습니다. 오류와 상황을 분석해 보니 자원 경합에 의한 데드락이었어요. 단일 원인이 아닌, 아래 세 가지 요소가 복합적으로 맞물리며 발생했습니다.


그렇다면 위의 시스템 구성 요소가 어떤 메커니즘을 통해 데드락을 유발했을까요? 내부 라이브러리 코드와 예제를 통해 상세히 살펴보겠습니다.

1. SQS 폴링 방식

Spring에서 @SqsListener를 사용하여 메시지를 소비할 경우 기본적으로 SQS는 폴링(Polling) 방식으로 메시지를 가져옵니다.


아래는 실제 메시지를 폴링하는 Spring AWS 라이브러리 코드의 일부입니다.

package org.springframework.cloud.aws.messaging.listener

//SimpleMessageListenerContainer 라이브러리 일부
public class SimpleMessageListenerContainer extends AbstractMessageListenerContainer {
    protected void startQueue(String queueName, QueueAttributes queueAttributes) {
        //...생략
        //**큐별로 비동기적 호출
        Future<?> future = getTaskExecutor()
                .submit(new SimpleMessageListenerContainer.AsynchronousMessageListener(queueName, queueAttributes));
        this.scheduledFutureByQueue.put(queueName, future);
    }
    
    private final class AsynchronousMessageListener implements Runnable {
        @Override
        public void run() {
            //**주기적 polling 시작점
            while (isQueueRunning()) {
                try {
                    //**SQS에 쌓인 메시지 가져오기
                    ReceiveMessageResult receiveMessageResult = getAmazonSqs()
                            .receiveMessage(
                                    this.queueAttributes.getReceiveMessageRequest());
                    
                    //**가져온 메시지의 프로세스가 모두 종료됐는지 확인하기 위한 CountDownLatch 생성                
                    CountDownLatch messageBatchLatch = new CountDownLatch(
                            receiveMessageResult.getMessages().size());
                            
                    //**각 메시지 처리
                    for (Message message : receiveMessageResult.getMessages()) {
                        if (isQueueRunning()) {
		                        //**메시지 처리 Executor (메시지를 @SqsListener로 전달 및 처리된 SQS 메시지 삭제 처리)
                            SimpleMessageListenerContainer.MessageExecutor messageExecutor = new SimpleMessageListenerContainer.MessageExecutor(
                                    this.logicalQueueName, message, this.queueAttributes);

                            //**메시지를 처리할 태스크 Executor(메시지 처리 Executor 종료시 CountDownLatch countDown 처리)
                            //**각 태스크는 별도의 스레드
                            getTaskExecutor().execute(new SimpleMessageListenerContainer.SignalExecutingRunnable(
                                    messageBatchLatch, messageExecutor));
                        }
                        else {
                            messageBatchLatch.countDown();
                        }
                    }
                    try {
		                    //모든 메시지가 처리가 끝날때까지 대기
                        messageBatchLatch.await();
                    }
                    catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                }
                catch (Exception e) {
                   //...생략
                }
            }
            //...생략
        }
    }
    //...생략
}
SQS 라이브러리 코드 일부

코드의 실행 프로세스를 요약하면 다음과 같습니다.

  1. 큐 이름별로 메시지 수신을 위한 폴링 스레드 실행
  2. 폴링을 통해 SQS로부터 메시지 뭉치(Batch)를 가져옴
  3. 가져온 각 메시지를 처리하기 위해 워커 스레드(Worker Thread)를 병렬로 생성
  4. CountDownLatch를 사용하여 해당 배치의 모든 메시지 처리가 끝날 때까지 대기
  5. 메시지 처리가 완료되면 다시 1번으로 돌아가 폴링 반복

여기서 중요한 점은, SQS는 수신한 메시지 개수만큼 스레드를 생성하여 병렬로 처리한다는 것입니다.

SQS는 폴링할 때 최대 처리 메시지를 MaxNumberOfMessages 옵션을 통해 설정할 수 있습니다.

@Bean
fun messageListenerFactory(): SimpleMessageListenerContainerFactory {
		val factory = SimpleMessageListenerContainerFactory()
    factory.setAmazonSqs(amazonSQSAsync())
    //**폴링을 통해 가져오는 최대 메시지 개수 5개로 설정
    factory.setMaxNumberOfMessages(5)   
    return factory
}
SQS 설정 클래스


처리 방식을 좀 더 자세히 분석해보면 MaxNumberOfMessages 옵션을 통해 아래처럼 병렬로 처리할 TaskExecutor의 스레드 개수가 설정됩니다.

package org.springframework.cloud.aws.messaging.listener

//SimpleMessageListenerContainer 라이브러리 일부
public class SimpleMessageListenerContainer extends AbstractMessageListenerContainer {
    //...생략
    protected AsyncTaskExecutor createDefaultTaskExecutor() {
		String beanName = getBeanName();
		ThreadPoolTaskExecutor threadPoolTaskExecutor = new ThreadPoolTaskExecutor();
		threadPoolTaskExecutor.setThreadNamePrefix(
				beanName != null ? beanName + "-" : DEFAULT_THREAD_NAME_PREFIX);
		int spinningThreads = this.getRegisteredQueues().size();

		if (spinningThreads > 0) {
			threadPoolTaskExecutor
					.setCorePoolSize(spinningThreads * DEFAULT_WORKER_THREADS);

			//**MaxNumberOfMessages 설정값을 통해 Executor Thread 개수 결정
			int maxNumberOfMessagePerBatch = getMaxNumberOfMessages() != null
					? getMaxNumberOfMessages() : DEFAULT_MAX_NUMBER_OF_MESSAGES;
			//**스레드 최대 개수 설정		
			threadPoolTaskExecutor
					.setMaxPoolSize(spinningThreads * (maxNumberOfMessagePerBatch + 1));
		}

		// No use of a thread pool executor queue to avoid retaining message to long in
		// memory
		threadPoolTaskExecutor.setQueueCapacity(0);
		threadPoolTaskExecutor.afterPropertiesSet();

		return threadPoolTaskExecutor;

	}
    //...생략
}
Spring SQS 라이브러리 SimpleMessageListenerContainer 코드 일부

또한 SQS 폴링시 메시지 개수 파라미터로 보낸다는 것을 확인할 수 있습니다.

image.png

SQS 메시지 폴링 요청시 maxNumberOfMessages 값 확인

하지만 이러한 SQS 메시지 처리 동작 방식이 어떻게 데드락을 유발했을까요? 다음 이슈도 같이 살펴보도록 하겠습니다.


2. Hikari DB Maximum-pool-size

Hikari maximum-pool-size는 스프링 개발자에게 많이 익숙한 옵션으로, 동시에 사용 가능한 DB 커넥션의 최대 개수를 설정합니다.

아래 코드는 병렬로 fooTransaction() 2번 호출하여 DB에 데이터를 저장하는 코드 예제입니다.

fun foo() {
    //Thread Pool 2개 SET
    val exec = Executors.newFixedThreadPool(2)
    
    (1..2).forEach { i ->
        //총 2번 병렬 호출
        exec.submit { fooService.fooTransaction(i) }
    }
}
    
@Service
class FooService(private val fooRepository: FooRepository) {
    @Transactional
    fun fooTransaction(num: Int) {
        println("num: $num Transaction Start")
        
        //저장
        val fooEntity = FooEntity(name = "$num")
        fooRepository.save(fooEntity)
        
        println("num: $num Transaction End")
    }
}
트랜잭션 예제 코드

그리고 아래와 같이 maximum-pool-size=2 설정후에 예제를 수행했습니다.

image1.png

maximum-pool-size=2 설정

수행 후 실행 로그를 보면 각 스레드1, 스레드2가 커넥션 1개씩 점유하여 병렬로 트랜잭션이 실행됩니다.

image2.png

maximum-pool-size=2 설정 후 트랜잭션 실행 로그

maximum-pool-size=1로 설정후 수행시 다음과 같이 스레드1 트랜잭션 종료 -> 스레드2의 트랜잭션이 수행되는 것을 확인할 수 있습니다.

image.png

maximum-pool-size=1 설정 후 트랜잭션 실행 로그

maximum-pool-size=1 이므로 총 사용 가능 커넥션은 1개, 스레드 2개가 병렬로 수행되어도,
커넥션 점유한 스레드1은 트랜잭션을 수행하고, 스레드2는 스레드1 트랜잭션이 끝난후 수행됩니다.

여기서! sleep을 추가하여 커넥션 점유 시간이 connection-timeout을 초과하도록 수정해봤습니다.


@Service
class FooService(private val fooRepository: FooRepository) {
    @Transactional
    fun fooTransaction(num: Int) {
        println("num: $num Transaction Start")
        
        //저장
        val fooEntity = FooEntity(name = "$num")
        fooRepository.save(fooEntity)
        
        //오래 점유
        Thread.sleep(6000)
        
        println("num: $num Transaction End")
    }
}
sleep 추가한 예제 코드

image.png

maximum-pool-size=1 설정

실행 후 로그를 확인해보니 이번 운영 이슈와 동일한 에러로그가 발생했습니다.

image.png

커넥션 타임아웃 에러 로그

위 에러 발생 상황을 정리하면 다음과 같습니다.

  1. 스레드1 트랜잭션 커넥션 점유
  2. 스레드2 트랜잭션 커넥션 점유 대기 (maximum-pool-size=1)
  3. 스레드1 6초(Sleep)동안 트랜잭션 커넥션 점유
  4. 스레드2 트랜잭션 커넥션 점유 대기 5초 초과로 인한 에러 발생 (Connection-timeout=5000ms)

3. 트랜잭션 REQUIRES_NEW

트랜잭션 REQUIRES_NEW트랜잭션 전파 옵션 중 하나입니다.

@Transactional(propagation = Propagation.REQUIRES_NEW)

특징은 다음과 같습니다.

  • 이전에 수행된 트랜잭션과 별개로 항상 새로운 트랜잭션을 생성
  • 별개의 트랜잭션을 생성하므로 커넥션도 새로 점유 → A 트랜잭션에서 REQUIRES_NEW B 트랜잭션이 실행되면 A, B 커넥션 총 2개의 커넥션을 점유
  • 별개의 트랜잭션으로 동작하므로 B 트랜잭션이 정상처리되어 커밋되고 A 트랜잭션이 롤백되어도 B 트랜잭션은 롤백 X

하지만 주의해야할 점은 REQUIRES_NEW를 사용할 시 데드락 이슈가 발생할 수 있습니다.

예제로 살펴보겠습니다.

fun fooRequiresNew() {
        //요청 Thread 2
        val exec = Executors.newFixedThreadPool(2)
        //두번 병렬 호출
        (1..2).forEach { i ->
		        //FooParentService 호출
            exec.submit { fooParentService.fooTransactionParent(i) }
        }
    }

@Service
class FooParentService(
    private val fooChildService: FooChildService,
    private val fooRepository: FooRepository
) {
    @Transactional
    fun fooTransactionParent(num: Int) {
        println("$num Parent Transaction Start")
        
        val fooEntity = FooEntity(name = "$num")
        fooRepository.save(fooEntity)
				
				//Child 트랜잭션 시작전 Sleep
        Thread.sleep(1000)
        
        //Child 트랜잭션 호출
        fooChildService.fooTransactionChild(num)
        
        println("$num Parent Transaction End")
    }
}

@Service
class FooChildService(
    private val fooRepository: FooRepository
) {
    @Transactional(REQUIRES_NEW)
    fun fooTransactionChild(num: Int) {
        println("$num Child Transaction Start")
        
        val fooEntity = FooEntity(name = "$num")
        fooRepository.save(fooEntity)
        
        println("$num Child Transaction End")
    }
}
REQUIRES_NEW 트랜잭션 예제 코드

해당 예제를 maximum-pool-size=2로 설정하고 수행시 다음과 같은 결과가 나오는 것을 확인할 수 있습니다.

image.png

maximum-pool-size=2 설정 후 예제 실행 로그

로그를 확인해보면 1번, 2번 트랜잭션 모두 Child 트랜잭션이 수행되지 못하고 데드락이 발생하여 타임아웃이 발생한 것을 볼 수 있습니다.

데드락 발생 과정은 다음과 같습니다.

  • 병렬 fooTransactionParent() 2번 호출
  • 1번 Parent 트랜잭션 커넥션 1개 점유
  • 2번 Parent 트랜잭션 커넥션 1개 점유
  • 1번 Parent 트랜잭션 Save 및 1초후 1번 Child 트랜잭션 점유를 위해 대기 (이미 커넥션은 모두 점유된 상태)
  • 1번 트랜잭션과 동일하게 2번 Child 트랜잭션 점유 대기
  • 1, 2번 Parent 트랜잭션Child 트랜잭션이 끝날때까지 계속 점유하기에 데드락 발생

그림으로 표현하면 다음과 같습니다.

image.png

트랜잭션 데드락 발생 과정

해결 과정


이제 위에서 봐온 내용을 토대로 이번 이슈가 실제로 어떻게 발생했는지 간략하게 정리하고 해결방법에 대해 말씀드리겠습니다.

1. 장애 발생 메커니즘 (Root Cause)

이번 장애는 병렬 처리 환경과 트랜잭션 전파 속성이 충돌하며 발생한 전형적인 자원 경합 문제였습니다.
아래 두 가지 설정이 맞물리면서 시스템은 데드락에 빠질 수 있는 임계 상태에 놓여 있었습니다.

  • 커넥션 점유의 불균형: SQS의 MaxNumberOfMessages 가 DB 커넥션 풀 크기보다 크게 설정되어 있었습니다. (커넥션 개수 < 최대 폴링 메시지 개수)
  • 중첩 트랜잭션의 함정: 이력 저장 로직에 REQUIRES_NEW 옵션을 사용하여, 알림 발송 1건당 최소 2개의 커넥션이 필요한 구조였습니다.

정리하면 병렬로 처리되던 각 메시지가 커넥션을 1개씩 점유한 상태에서, 이력 저장을 위해 1개의 커넥션이 더 필요했지만 가용 커넥션이 남아 있지 않아 모든 메시지 처리가 동시에 대기 상태에 빠지며 데드락이 발생했습니다


image.png

SQS 메시지 폴링 요청시 maxNumberOfMessages 값 확인

2. 병렬 처리 환경에서의 최소 커넥션 계산

해당 상황 해결을 위해 HikariCP에서 제시하는 풀 사이징 가이드를 기준으로 필요 커넥션 수를 계산해보면, 다음과 같이 정리할 수 있습니다.

(참고: Hikari CP 공식 문서)

데드락을 피하기 위한 최소 필요 커넥션 수 = Tn × (Cm − 1) + 1

• Tn: 동시에 처리되는 최대 스레드 개수
• Cm: 스레드 1개 처리 시 필요한 최대 커넥션 수

이번 사례에서는 알림톡 1건 처리 시 부모 트랜잭션 1개, 이력 저장을 위한 REQUIRES_NEW 트랜잭션 1개로, Cm = 2인 구조였습니다.

즉, SQS 폴링으로 동시에 처리되는 메시지 수(Tn)가 증가할수록, 필요한 커넥션 수 역시 증가하는 구조라 볼 수 있습니다. (Cm = 1 인 경우 필요 커넥션 개수는 1개로 고정)


3. 커넥션 요구량 제어를 통한 문제 해결

이번 이슈의 해결 방법은 필요 커넥션 수를 조절하면 되기에 아래처럼 간단하게 수정하여 해결했습니다.

  • REQUIRES_NEW 옵션이 필요없는 로직이었기에 REQUIRES_NEW 옵션 제거 (Cm = 1)
  • SQS 최대 폴링 메시지 개수를 커넥션 개수와 같거나 적게 변경 (메시지 개수 <= 커넥션 개수)

이러한 설정을 조정함으로써, 알림 처리 과정에서 동시에 요구되던 커넥션 수를 효과적으로 줄일 수 있었습니다.

REQUIRES_NEW 옵션 제거를 통해 알림 한 건당(스레드) 추가 트랜잭션 생성을 방지했고, SQS 폴링 시 한 번에 처리되는 메시지 개수를 제한함으로써 병렬로 실행되는 트랜잭션 수 또한 제어할 수 있었습니다.

그 결과, 메시지 처리량이 증가하는 상황에서도 커넥션 경합과 데드락이 발생하던 구조를 해소할 수 있었습니다.



마치며


이번 사례는 단순한 설정 오류라기보다는, 서로 다른 구성 요소들이 병렬 처리 환경에서 어떻게 맞물려 동작하는지에 대한 이해가 충분히 연결되지 않았던 지점에서 비롯된 이슈였습니다. 이를 통해 기능 자체의 구현뿐만 아니라, 해당 기능이 어떤 실행 환경과 처리 흐름 속에서 동작하는지를 함께 고려하는 것이 얼마나 중요한지 다시 한 번 되돌아보게 되었습니다.

이 글을 읽고 계신 분들 역시 비동기 처리 구조를 설계할 때, 각 설정과 옵션이 병렬 환경에서 어떤 영향을 미칠 수 있는지 한 번쯤 점검해보시면 좋을 것 같습니다.

저희는 앞으로도 이러한 경험들을 바탕으로, 시스템의 동작 방식과 구조적인 특성을 함께 고려하는 개발을 이어가고자 합니다.

긴 글 읽어주셔서 감사합니다.

DeadlockSQSTransaction
올리브영 테크 블로그 작성 SQS 기반 알림톡 처리에서 발생한 DB 커넥션 데드락 분석기
🐷
바로바로 |
Back-end Engineer
바로 개발해보자!