Django 5.2 커스텀 미들웨어와 시그널 핸들링: 기술 면접 완벽 가이드

Django 5.2 커스텀 미들웨어 작성법과 시그널 핸들링 패턴을 기술 면접 관점에서 심층 분석합니다. 비동기 미들웨어, 커스텀 시그널 등 실무 예제를 포함합니다.

Django 5.2 커스텀 미들웨어와 시그널 핸들링 기술 면접 준비 가이드

Django 기술 면접에서 미들웨어와 시그널은 단골 출제 주제입니다. 두 개념 모두 Django 애플리케이션의 요청 흐름과 이벤트 처리를 제어하는 핵심 메커니즘이며, 시니어 개발자에게 요구되는 아키텍처 수준의 이해도를 평가하는 데 적합하기 때문입니다. Django 5.2에서는 비동기 미들웨어 지원이 안정화되고 시그널 시스템에도 asendasend_robust 같은 비동기 디스패치 메서드가 추가되면서, 면접에서 다루는 범위가 더욱 넓어졌습니다. 이 글에서는 커스텀 미들웨어의 구조와 작성법, 시그널의 동작 원리와 실무 패턴, 그리고 면접에서 자주 등장하는 함정 질문까지 체계적으로 정리합니다.

면접 핵심 요약 가이드

Django 미들웨어는 MIDDLEWARE 리스트의 위에서 아래로 요청을 처리하고, 응답은 아래에서 위로 역순으로 처리합니다. 시그널은 발행-구독(publish-subscribe) 패턴을 따르며, 발신자(sender)가 이벤트를 브로드캐스트하면 수신자(receiver)가 직접적인 결합 없이 반응합니다. 두 개념 모두 시니어 Django 면접에서 자주 출제되는데, 이는 단순한 문법 지식이 아닌 아키텍처적 이해도를 검증하기 위함입니다.

미들웨어의 기본 구조와 동작 원리

Django 미들웨어는 요청(request)과 응답(response) 사이에 위치하여 모든 HTTP 요청을 가로채고 처리하는 프레임워크 수준의 훅(hook)입니다. Django 2.0 이후부터는 클래스 기반의 미들웨어 구조가 표준이 되었으며, __init____call__ 두 메서드만으로 기본 미들웨어를 구현할 수 있습니다.

python
# middleware.py - Basic middleware structure
class SimpleMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response
        # One-time configuration happens here at server start

    def __call__(self, request):
        # Code executed BEFORE the view (and later middleware)
        response = self.get_response(request)
        # Code executed AFTER the view (on the way back)
        return response

__init__ 메서드는 서버가 시작될 때 단 한 번 호출되며, get_response 콜러블을 인자로 받습니다. 이 콜러블은 미들웨어 체인에서 다음 미들웨어 또는 최종적으로 뷰 함수를 호출하는 역할을 합니다. __call__ 메서드는 모든 요청마다 호출되며, self.get_response(request) 호출 전후로 요청과 응답을 각각 조작할 수 있습니다.

면접에서는 get_response 호출 전후의 실행 순서를 정확히 설명할 수 있는지가 핵심 평가 포인트입니다. get_response 호출 이전의 코드는 요청이 뷰에 도달하기 전에 실행되고, 이후의 코드는 뷰가 응답을 반환한 후에 실행됩니다.

실무형 미들웨어: 요청 로깅 구현

실제 프로젝트에서 가장 흔하게 작성하는 커스텀 미들웨어 중 하나가 요청 로깅 미들웨어입니다. 다음 예제는 요청 메서드, 경로, 상태 코드, 처리 시간, 클라이언트 IP를 구조화된 형태로 기록하며, 예외 발생 시에도 별도의 로그를 남기는 미들웨어입니다.

python
# apps/core/middleware.py
import logging
import time

logger = logging.getLogger('django.request')


class RequestLoggingMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        start_time = time.monotonic()
        # Attach metadata before view processing
        request.start_time = start_time

        response = self.get_response(request)

        duration = time.monotonic() - start_time
        logger.info(
            'method=%s path=%s status=%d duration=%.3fs ip=%s',
            request.method,
            request.get_full_path(),
            response.status_code,
            duration,
            request.META.get('REMOTE_ADDR'),
        )
        return response

    def process_exception(self, request, exception):
        # Called only when the view raises an exception
        duration = time.monotonic() - getattr(request, 'start_time', 0)
        logger.error(
            'method=%s path=%s exception=%s duration=%.3fs',
            request.method,
            request.get_full_path(),
            str(exception),
            duration,
        )
        return None  # Let Django's default exception handling continue

이 코드에서 주목할 점은 time.monotonic()의 사용입니다. time.time()과 달리 시스템 시계 변경(NTP 동기화 등)에 영향을 받지 않으므로, 경과 시간 측정에 더 적합합니다. 면접에서 time.time() 대신 time.monotonic()을 사용하는 이유를 물을 수 있으니 이 차이점을 숙지해야 합니다.

process_exception 훅은 뷰에서 예외가 발생했을 때만 호출되며, None을 반환하면 Django의 기본 예외 처리가 계속 진행됩니다. HttpResponse 객체를 반환하면 해당 응답이 클라이언트에게 바로 전달됩니다.

미들웨어 순서의 중요성

미들웨어의 등록 순서는 애플리케이션의 동작에 직접적인 영향을 미칩니다. MIDDLEWARE 리스트에서 위에 위치한 미들웨어가 먼저 요청을 처리하고, 응답은 역순으로 처리합니다.

python
# settings.py - Middleware ordering matters
MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    # Custom middleware placed after Django's core stack
    'apps.core.middleware.RequestLoggingMiddleware',
]

커스텀 미들웨어를 Django 내장 미들웨어 뒤에 배치한 이유가 있습니다. RequestLoggingMiddleware에서 request.user 정보에 접근하려면 AuthenticationMiddleware가 먼저 실행되어 사용자 인증이 완료되어 있어야 합니다. 만약 순서가 뒤바뀌면 request.userAnonymousUser 또는 아예 존재하지 않는 속성이 될 수 있습니다.

면접에서는 "SecurityMiddleware를 가장 위에 두는 이유"와 "SessionMiddleware가 AuthenticationMiddleware보다 먼저 와야 하는 이유"를 자주 묻습니다. 세션이 먼저 로드되어야 인증 미들웨어가 세션에서 사용자 정보를 읽을 수 있기 때문입니다.

Django 5.2의 비동기 미들웨어

Django 5.2에서는 비동기 미들웨어 지원이 완전히 안정화되었습니다. async_capablesync_capable 속성을 통해 미들웨어의 동작 모드를 명시적으로 선언할 수 있습니다.

python
# apps/core/middleware.py
import asyncio
import time


class AsyncTimingMiddleware:
    # Mark this middleware as async-capable
    async_capable = True
    sync_capable = False

    def __init__(self, get_response):
        self.get_response = get_response

    async def __call__(self, request):
        start = time.monotonic()
        # get_response is awaitable in async context
        response = await self.get_response(request)
        duration = time.monotonic() - start
        response['X-Request-Duration'] = f'{duration:.4f}s'
        return response

sync_capable = False로 설정하면 이 미들웨어는 반드시 비동기 컨텍스트에서만 실행됩니다. 동기 뷰가 요청을 처리하는 경우 Django가 자동으로 스레드 풀에서 실행하도록 래핑합니다. 반대로 async_capable = True, sync_capable = True로 설정하면 동기와 비동기 모두에서 동작하는 듀얼 모드 미들웨어가 됩니다.

면접에서는 비동기 미들웨어와 동기 뷰가 혼재할 때의 성능 영향, 그리고 Django의 ASGI와 WSGI 배포 환경에서의 차이점을 질문하는 경우가 많습니다. ASGI 서버(Uvicorn, Daphne 등)를 사용해야 비동기 미들웨어의 성능 이점을 온전히 활용할 수 있다는 점을 명확히 이해해야 합니다.

Django 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

시그널의 동작 원리와 post_save 패턴

Django 시그널은 프레임워크 내에서 특정 이벤트가 발생했을 때 이를 구독하는 함수들에게 알림을 보내는 발행-구독 메커니즘입니다. 가장 대표적인 사용 사례는 모델 인스턴스가 저장될 때 관련 작업을 수행하는 것입니다.

python
# apps/accounts/signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.contrib.auth import get_user_model
from apps.accounts.models import UserProfile

User = get_user_model()


@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):
    # 'created' is True only on INSERT, False on UPDATE
    if created:
        UserProfile.objects.create(
            user=instance,
            display_name=instance.get_full_name() or instance.username,
        )

@receiver 데코레이터는 post_save 시그널에 create_user_profile 함수를 수신자로 등록합니다. sender=User를 지정하여 User 모델의 저장 이벤트에만 반응하도록 필터링합니다. created 매개변수는 해당 저장이 새 레코드 생성(INSERT)인지 기존 레코드 수정(UPDATE)인지를 구분하는 불리언 값입니다.

면접에서 "post_save에서 instance.save()를 호출하면 어떻게 되는가?"라는 질문이 자주 나옵니다. 답은 무한 재귀가 발생한다는 것입니다. save() 호출이 다시 post_save 시그널을 트리거하기 때문입니다. 이를 방지하려면 update_fields 매개변수를 사용하거나 QuerySet.update()를 활용해야 합니다.

pre_save 시그널과 데이터 정규화

pre_save 시그널은 모델 인스턴스가 데이터베이스에 저장되기 직전에 발생합니다. 데이터 정규화, 유효성 검사, 자동 필드 생성 등에 적합합니다.

python
# apps/blog/signals.py
from django.db.models.signals import pre_save
from django.dispatch import receiver
from django.utils.text import slugify
from apps.blog.models import Article


@receiver(pre_save, sender=Article)
def auto_generate_slug(sender, instance, **kwargs):
    if not instance.slug:
        base_slug = slugify(instance.title)
        slug = base_slug
        counter = 1
        # Ensure slug uniqueness
        while Article.objects.filter(slug=slug).exclude(pk=instance.pk).exists():
            slug = f'{base_slug}-{counter}'
            counter += 1
        instance.slug = slug

이 패턴은 블로그 게시물의 슬러그를 제목에서 자동 생성하면서 고유성을 보장합니다. exclude(pk=instance.pk)는 자기 자신과의 충돌을 방지하는 중요한 처리입니다. pre_save에서는 인스턴스의 속성을 직접 수정하면 해당 변경 사항이 자동으로 데이터베이스에 반영되므로 별도의 save() 호출이 필요 없습니다.

시그널 등록: AppConfig.ready() 패턴

시그널 수신자를 올바르게 등록하는 것은 Django 프로젝트에서 자주 발생하는 실수 중 하나입니다. 가장 권장되는 방법은 AppConfig.ready() 메서드에서 시그널 모듈을 임포트하는 것입니다.

python
# apps/accounts/apps.py
from django.apps import AppConfig


class AccountsConfig(AppConfig):
    default_auto_field = 'django.db.models.BigAutoField'
    name = 'apps.accounts'

    def ready(self):
        # Import signals module to register receivers
        import apps.accounts.signals  # noqa: F401

ready() 메서드는 Django 애플리케이션 레지스트리가 완전히 채워진 후에 호출되므로, 모든 모델이 로드된 상태에서 시그널을 안전하게 등록할 수 있습니다. # noqa: F401 주석은 사용하지 않는 임포트에 대한 린터 경고를 무시하기 위한 것입니다.

면접에서는 "시그널이 등록되지 않아 작동하지 않는 이유"를 디버깅하는 시나리오가 출제되기도 합니다. 대부분의 경우 ready()에서 시그널 모듈을 임포트하지 않았거나, INSTALLED_APPS에 앱 설정 클래스 경로(apps.accounts.apps.AccountsConfig)가 아닌 단순 앱 이름만 등록한 것이 원인입니다.

면접에서 자주 나오는 함정 질문

시그널과 관련된 면접 질문 중에서 가장 빈번하게 등장하는 함정이 있습니다. QuerySet.update()bulk_create() 메서드가 시그널을 트리거하지 않는다는 점입니다.

시그널과 QuerySet.update()의 관계

Django의 QuerySet.update()bulk_create() 메서드는 모델의 save() 메서드를 완전히 우회합니다. 이는 pre_savepost_save 시그널이 발생하지 않는다는 것을 의미합니다. QuerySet에 대한 delete() 호출과 개별 모델 인스턴스에 대한 delete() 호출도 동일한 차이가 있습니다. 이것은 면접에서 가장 자주 출제되는 Django 시그널 관련 질문 중 하나입니다.

이 동작을 정확히 이해하고 있어야 "시그널에 의존하는 로직이 특정 상황에서 작동하지 않는 이유"를 설명할 수 있습니다. 대량 작업에서도 시그널 로직이 필요하다면, 명시적으로 개별 인스턴스를 순회하며 save()를 호출하거나, 시그널 대신 서비스 레이어에서 해당 로직을 직접 실행하는 방식을 고려해야 합니다.

커스텀 시그널로 비즈니스 로직 분리하기

Django의 내장 시그널 외에도 프로젝트 고유의 이벤트를 정의하여 비즈니스 로직 간의 결합도를 낮출 수 있습니다. 주문 처리 시스템을 예로 들면, 주문 완료 이벤트를 커스텀 시그널로 정의하고 알림, 재고 관리, 분석 등 다양한 수신자가 독립적으로 반응하도록 설계할 수 있습니다.

python
# apps/orders/signals.py
from django.dispatch import Signal

# Define custom signals with documentation
order_completed = Signal()  # Sent after payment confirmation
order_refunded = Signal()   # Sent after refund processing


# apps/orders/services.py
from apps.orders.signals import order_completed


def complete_order(order):
    order.status = 'completed'
    order.save()
    # Dispatch signal with relevant context
    order_completed.send(
        sender=order.__class__,
        order=order,
        total=order.total_amount,
    )


# apps/notifications/receivers.py
from django.dispatch import receiver
from apps.orders.signals import order_completed


@receiver(order_completed)
def send_order_confirmation_email(sender, order, **kwargs):
    # Email logic decoupled from order processing
    from apps.notifications.services import send_email
    send_email(
        to=order.customer.email,
        template='order_confirmation',
        context={'order': order},
    )

이 패턴의 핵심 장점은 orders 앱이 notifications 앱의 존재를 전혀 알 필요가 없다는 것입니다. 주문 서비스는 시그널을 발송하기만 하고, 이메일 전송은 별도의 수신자가 처리합니다. 새로운 요구사항(예: 주문 완료 시 재고 업데이트)이 추가되더라도 주문 처리 코드를 수정할 필요 없이 새로운 수신자만 등록하면 됩니다.

면접에서는 send()send_robust()의 차이를 묻는 경우가 많습니다. send()는 수신자 중 하나가 예외를 발생시키면 나머지 수신자가 실행되지 않지만, send_robust()는 모든 수신자를 실행하고 예외를 결과 리스트에 포함시킵니다. 프로덕션 환경에서는 send_robust()가 더 안전한 선택입니다.

Django 5.2 LTS

Django 5.2는 장기 지원(Long-Term Support) 릴리스로, 2028년 4월까지 보안 업데이트를 제공받습니다. 비동기 미들웨어와 시그널 개선 사항은 이제 프로덕션 환경에서 안정적으로 사용할 수 있는 수준입니다. 면접관들은 전통적인 동기 Django 패턴과 함께 비동기 패턴에 대한 이해도를 점점 더 기대하고 있습니다.

미들웨어와 시그널 비교: 면접에서의 선택 기준

면접에서 "미들웨어와 시그널 중 어떤 것을 사용해야 하는가?"라는 질문을 받을 때, 각각의 적용 범위를 명확히 구분하여 답변할 수 있어야 합니다.

| 관심사 | 미들웨어 | 시그널 | |---|---|---| | 요청/응답 수정 | 가능 | 불가능 | | 인증 및 권한 부여 | 가능 | 불가능 | | 모델 생명주기 훅 | 불가능 | 가능 | | 횡단 관심사 (로깅, 타이밍) | 가능 | 가능하나 비권장 | | 분리된 비즈니스 로직 | 불가능 | 가능 | | Django 5.2 비동기 지원 | 완전 지원 | 완전 지원 (asend/asend_robust) | | 실행 범위 | 모든 HTTP 요청 | 특정 이벤트 (save, delete, 커스텀) |

핵심 원칙은 다음과 같습니다. HTTP 요청-응답 사이클에 개입해야 하면 미들웨어를, 모델이나 비즈니스 이벤트에 반응해야 하면 시그널을 사용합니다. 두 가지를 혼용하는 것은 코드의 추적성을 떨어뜨리므로 피하는 것이 좋습니다.

결론

Django 5.2의 미들웨어와 시그널 시스템은 기술 면접에서 개발자의 프레임워크 이해 깊이를 평가하는 핵심 주제입니다. 다음은 면접 준비 시 반드시 숙지해야 할 사항들입니다.

  • 미들웨어 실행 순서: 요청은 MIDDLEWARE 리스트의 위에서 아래로, 응답은 아래에서 위로 처리됩니다. 순서가 잘못되면 인증, 세션, CSRF 보호 등이 정상 작동하지 않습니다.
  • process_exception: 뷰에서 예외가 발생했을 때만 호출되며, None을 반환하면 기본 예외 처리가 진행되고 HttpResponse를 반환하면 해당 응답이 클라이언트에게 전달됩니다.
  • 비동기 미들웨어: async_capable = True 속성과 async def __call__ 메서드로 비동기 미들웨어를 구현하며, ASGI 서버 환경에서 최적의 성능을 발휘합니다.
  • 시그널의 한계: QuerySet.update(), bulk_create(), QuerySet.delete()save()/delete() 메서드를 우회하므로 시그널이 발생하지 않습니다.
  • 커스텀 시그널: django.dispatch.Signal을 상속하여 프로젝트 고유의 이벤트를 정의하고, 비즈니스 로직 간의 결합도를 낮출 수 있습니다.
  • 시그널 등록: AppConfig.ready() 메서드에서 시그널 모듈을 임포트하는 것이 가장 안전하고 권장되는 방법입니다.
  • send() vs send_robust(): 프로덕션 환경에서는 수신자의 예외가 다른 수신자의 실행을 막지 않는 send_robust()가 더 안전합니다.

이러한 개념들을 코드 예제와 함께 설명할 수 있다면, Django 기술 면접에서 미들웨어와 시그널 관련 질문에 자신 있게 대응할 수 있습니다.

연습을 시작하세요!

면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.

태그

#django
#python
#middleware
#signals
#interview

공유

관련 기사