Published on

transaction.on_commit으로 알림 발송 후처리 안전하게 실행하기

Authors

최근 알림(알림톡) 발송 로직을 전체적으로 정리할 일이 있었습니다.

결제나 주문처럼 트랜잭션으로 묶인 로직 안에서 발송 함수를 호출하는 코드들을 보면서, 트랜잭션과 발송의 실행 시점에 대해 다시 정리하게 되었는데요.

이 과정에서 transaction.on_commit을 적용한 내용과 동작 방식을 기록합니다.

1. 문제 상황: 트랜잭션 안에서의 발송

주문 완료 시 알림톡을 보내는 전형적인 코드입니다.

def complete_order(request):
    with transaction.atomic():
        order = Order.objects.create(...)
        payment = process_payment(order)

        # 주문 완료 알림 발송
        send_order_notification.delay(order_id=order.id)

        do_something_else(order)  # 이후 로직에서 예외가 나면?

    return Response(...)

이 코드는 두가지 문제를 가지고 있습니다.

1.1 롤백되어도 발송 요청은 이미 나갔다

send_order_notification.delay() 호출 자체는 DB 작업이 아니기 때문에 트랜잭션과 무관하게 즉시 실행됩니다.

만약 do_something_else(order)에서 예외가 발생하면 주문 데이터는 롤백되어 사라지지만, 발송 task는 이미 큐에 들어간 후 입니다.

이때 결과는 task의 구조에 따라 두 갈래로 나뉩니다.

  • 전화번호와 발송 문구를 인자로 전부 넘기는 구조라면, 존재하지 않는 주문에 대한 "주문이 완료되었습니다" 알림톡이 그대로 발송됩니다. 알림을 받은 유저는 당연히 CS로 이어집니다.
  • 위 예시처럼 order_id만 넘기고 워커에서 재조회하는 구조라면, 롤백된 주문을 찾지 못해 워커에서 DoesNotExist로 실패합니다.

어느 쪽이든 정상적인 동작은 아닙니다.

1.2 커밋 전에 워커가 먼저 조회한다

더 미묘한 문제는 정상 케이스에서도 발생합니다.

Celery 워커가 task를 받아서 Order.objects.get(id=order_id)를 실행하는 시점이, Django 쪽 트랜잭션이 커밋되는 시점보다 빠를 수 있습니다.

커밋 전의 데이터는 다른 커넥션에서 보이지 않기 때문에 워커에서는 DoesNotExist가 발생합니다.

로컬에서는 재현이 잘 안되다가 운영에서 간헐적으로 발생하는, 전형적인 race condition 유형입니다. retry를 걸어서 넘어갈 수도 있지만 원인을 그대로 둔 해결책이라 찝찝함이 남습니다.

2. transaction.on_commit의 동작 방식

transaction.on_commit은 현재 트랜잭션이 커밋된 이후에 실행할 함수를 등록하는 기능입니다.

from django.db import transaction

def complete_order(request):
    with transaction.atomic():
        order = Order.objects.create(...)
        payment = process_payment(order)

        transaction.on_commit(
            lambda: send_order_notification.delay(order_id=order.id)
        )

        do_something_else(order)

    return Response(...)

이렇게 하면 위의 두 문제가 모두 해결됩니다.

  • 롤백되면 콜백은 실행되지 않습니다. 알림톡도 발송되지 않습니다.
  • 커밋이 완료된 후에 .delay()가 호출되기 때문에, 워커는 항상 커밋된 데이터를 조회합니다.

직접 확인해본 동작들은 아래와 같습니다.

# 1. 트랜잭션 밖에서 호출하면 즉시 실행된다
transaction.on_commit(func)  # atomic 블록 밖 -> 바로 실행

# 2. 롤백되면 실행되지 않는다
with transaction.atomic():
    transaction.on_commit(func)
    raise ValueError()  # func는 영원히 실행 안됨

# 3. 중첩 atomic 에서는 가장 바깥 블록이 커밋될때 실행된다
with transaction.atomic():        # 바깥
    with transaction.atomic():    # 안쪽 (savepoint)
        transaction.on_commit(func)
    # 안쪽 블록이 끝나도 실행되지 않는다
# 바깥 블록 커밋 시점에 실행된다

3번이 실무에서 중요한데, 서비스 레이어의 함수들이 각자 atomic을 걸고 서로 호출하는 구조라면 콜백은 결국 요청 전체를 감싼 가장 바깥 트랜잭션이 커밋될 때 한번에 실행됩니다.

즉 안쪽 함수 입장에서는 "커밋 직후 실행"이 아니라 생각보다 늦게 실행될 수 있다는 점을 알아두면 좋습니다.

3. 실무에서 사용한 패턴

3.1 인자가 많으면 내부 함수로

lambda로 넘기기에 인자가 많아지면 가독성이 급격히 떨어져서, 내부 함수를 정의해서 등록하는 패턴을 주로 사용했습니다.

def create_notification(self, category, user, phone_number, is_immediate_send=False):
    with transaction.atomic():
        notification = Notification.objects.create(...)

        def send_notification():
            task_func = (
                send_notification_task
                if is_immediate_send
                else send_notification_task.delay
            )
            task_func(
                notification_id=notification.id,
                phone_number=phone_number,
                content=notification.content,
            )

        transaction.on_commit(send_notification)

    return notification

클로저라서 notification 객체를 자연스럽게 캡처할 수 있고, 즉시발송/비동기발송 분기 같은 로직도 콜백 안에 담을 수 있습니다.

3.2 콜백에서 예외가 나면 다음 콜백이 실행되지 않는다

하나의 트랜잭션에 콜백을 여러개 등록하면 등록한 순서대로 실행되는데, 중간의 콜백이 예외를 던지면 그 뒤의 콜백들은 실행되지 않습니다.

with transaction.atomic():
    transaction.on_commit(callback_a)  # 실행됨
    transaction.on_commit(callback_b)  # 여기서 예외 발생
    transaction.on_commit(callback_c)  # 실행 안됨!

발송 로직처럼 실패할 수 있는 콜백이라면 Django 4.2부터 추가된 robust=True 옵션을 사용할 수 있습니다. 예외가 나도 로그만 남기고 다음 콜백이 계속 실행됩니다.

transaction.on_commit(callback_b, robust=True)

robust 옵션이 없는 버전이라면 콜백 내부에서 try/except로 감싸는 것이 안전합니다. DB 작업은 이미 성공했는데 알림 발송 실패 때문에 API 전체가 500으로 응답하는 상황은 막아야 하기 때문입니다.

3.3 테스트 코드에서의 함정

TestCase는 각 테스트를 트랜잭션으로 감싸고 끝나면 롤백하는 방식이라, 커밋이 일어나지 않습니다. 즉 on_commit에 등록한 콜백이 테스트에서는 실행되지 않습니다.

처음에는 발송 함수를 mock 해두고 "왜 호출이 안되지?" 하면서 헤맸는데, Django 3.2부터 제공되는 captureOnCommitCallbacks로 해결할 수 있었습니다.

class OrderTestCase(TestCase):
    def test_order_notification(self):
        with self.captureOnCommitCallbacks(execute=True) as callbacks:
            response = self.client.post('/api/orders/', data)

        self.assertEqual(len(callbacks), 1)  # 콜백이 1개 등록되었고, execute=True로 실행됨

execute=True를 주면 블록이 끝날때 캡처된 콜백들을 실제로 실행해줍니다.

결제·주문처럼 "DB 상태가 확정된 후에만 일어나야 하는 일"(알림, 캐시 무효화, 외부 API 호출 등)은 전부 on_commit 대상이라고 생각하게 되었습니다.

반대로 트랜잭션 안에서 외부 세계에 영향을 주는 코드를 보면 일단 의심하게 되는 습관도 생겼습니다.

작은 함수 하나지만 롤백과 race condition 두가지를 동시에 해결해주는, 비용 대비 효과가 아주 좋은 도구입니다.

참고 자료

  • hongreat 블로그의 글을 봐주셔서 감사합니다!^^
  • 내용에 잘못된 부분이나 의문점이 있으시다면 댓글 부탁 & 환영 합니다~!
  • (하단의 버튼을 누르시면 댓글을 보거나 작성할 수 있습니다.)
Buy Me A Coffee