프로그래밍/Python

🎭 Mock 처리 부족: 외부 의존성 때문에 테스트가 불안정한 경우

Tiboong 2026. 2. 17. 07:57

🚨 이런 상황, 겪어보셨나요?

"테스트를 돌리는데 외부 API가 실제로 호출되어서 결제가 됐습니다."

"테스트가 네트워크 상태에 따라 될 때도 있고 안 될 때도 있습니다."

"CI 서버에서 외부 API 호출이 차단돼서 테스트가 전부 실패합니다."

"이메일 발송 테스트를 돌렸더니 고객한테 실제로 메일이 갔습니다."

 

Django 프로젝트는 결제 시스템, 이메일 서비스, SMS 발송, 외부 API, AWS S3 등 수많은 외부 서비스와 연동됩니다. 이런 외부 의존성을 Mock으로 대체하지 않으면, 테스트가 느리고, 불안정하고, 심지어 실제 결제나 실제 메일 발송 같은 사고가 발생합니다.


🔥 실제 상황: 테스트가 실제 결제를 일으키다

한 스타트업에서 주문 기능을 개발하고 있었습니다.

# services.py
import requests

def process_payment(order):
    """결제 처리"""
    response = requests.post(
        '<https://api.payment.com/v1/charge>',
        json={
            'amount': order.total,
            'card_token': order.card_token,
        }
    )
    if response.status_code == 200:
        order.status = 'paid'
        order.payment_id = response.json()['payment_id']
        order.save()
        return True
    return False

 

개발자가 작성한 테스트:

# ❌ Mock 없이 실제 API 호출
class PaymentTest(TestCase):
    def test_payment_success(self):
        order = Order.objects.create(
            total=50000,
            card_token='tok_test_1234'
        )
        result = process_payment(order)  # 실제 결제 API 호출!
        self.assertTrue(result)

 

발생한 일:

  1. 테스트 환경에서 실제 결제가 되었습니다 — 개발용 카드 토큰이라 다행히 실제 청구는 안 됐지만, 결제 건수가 로그에 남았습니다
  2. 테스트가 네트워크에 의존 — 외부 API가 느리면 테스트도 느리고, API가 죽으면 테스트도 실패
  3. CI에서 외부 네트워크 차단 — 보안 정책으로 외부 통신이 차단된 CI 서버에서 테스트 전체 실패

🎯 Mock이 뭔데, 왜 필요할까?

영화 세트 비유로 이해하기

Mock을 이해하려면 영화 세트장을 떠올려보세요.

전쟁 영화를 찍을 때, 실제로 탱크를 몬고 오지 않습니다. **탱크 모형(몽업)**을 만들어서 촬영합니다. 실제 탱크를 가져오면 비용도 엄청나고, 촬영 장소가 부서질 수도 있고, 무엇보다 위험합니다.

테스트에서의 Mock도 마찬가지입니다. 실제 외부 서비스를 호출하는 대신, 가짜 객체로 대체하여 우리 코드의 로직만 테스트하는 것입니다.

Mock이 필요한 대상

외부 API 호출(requests, httpx), 이메일 발송(send_mail), SMS/푸시 알림, 결제 처리(PG사 연동), 파일 스토리지(S3, GCS), 현재 시간(datetime.now), 랜덤 값(random), Celery 비동기 작업 등이 모두 Mock 대상입니다.

원칙: 우리 통제 밖의 것, 느린 것, 비용이 드는 것, 비결정적인 것은 Mock으로 대체합니다.


🛠️ 실전 적용: Python Mock 활용법

1단계: 기본 — unittest.mock.patch

Python 표준 라이브러리의 unittest.mock이 가장 기본적인 도구입니다.

# ✅ 결제 API를 Mock으로 대체
from unittest.mock import patch, MagicMock
from django.test import TestCase

class PaymentTest(TestCase):
    @patch('orders.services.requests.post')
    def test_payment_success(self, mock_post):
        # Mock이 반환할 값 설정
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            'payment_id': 'pay_test_123'
        }
        mock_post.return_value = mock_response

        # 테스트 실행
        order = Order.objects.create(
            total=50000,
            card_token='tok_test'
        )
        result = process_payment(order)

        # 검증
        self.assertTrue(result)
        order.refresh_from_db()
        self.assertEqual(order.status, 'paid')
        self.assertEqual(order.payment_id, 'pay_test_123')

        # Mock이 올바른 파라미터로 호출됐는지 검증
        mock_post.assert_called_once_with(
            '<https://api.payment.com/v1/charge>',
            json={
                'amount': 50000,
                'card_token': 'tok_test',
            }
        )

 

핵심은 @patch 데코레이터입니다. requests.post를 가짜 객체로 대체하고, 반환값을 우리가 정의합니다. 실제 네트워크 호출은 일어나지 않습니다.


2단계: 실패 시나리오 테스트

Mock의 진짜 가치는 실패 상황을 쉽게 재현할 수 있다는 것입니다. 실제 결제 API를 실패시키려면 카드를 정지시켜야 하지만, Mock으로는 한 줄이면 됩니다.

class PaymentTest(TestCase):
    @patch('orders.services.requests.post')
    def test_payment_failure(self, mock_post):
        """결제 실패 시 동작 검증"""
        mock_response = MagicMock()
        mock_response.status_code = 402  # 결제 실패
        mock_post.return_value = mock_response

        order = Order.objects.create(total=50000, card_token='tok_test')
        result = process_payment(order)

        self.assertFalse(result)
        order.refresh_from_db()
        self.assertNotEqual(order.status, 'paid')

    @patch('orders.services.requests.post')
    def test_payment_timeout(self, mock_post):
        """결제 API 타임아웃 시 동작 검증"""
        mock_post.side_effect = requests.Timeout("Connection timed out")

        order = Order.objects.create(total=50000, card_token='tok_test')
        with self.assertRaises(requests.Timeout):
            process_payment(order)

    @patch('orders.services.requests.post')
    def test_payment_network_error(self, mock_post):
        """네트워크 오류 시 동작 검증"""
        mock_post.side_effect = requests.ConnectionError()

        order = Order.objects.create(total=50000, card_token='tok_test')
        with self.assertRaises(requests.ConnectionError):
            process_payment(order)

 

side_effect를 사용하면 Mock이 예외를 발생시키도록 설정할 수 있습니다. 실제 운영 환경에서 발생할 수 있는 타임아웃, 네트워크 오류 등을 안전하게 테스트할 수 있습니다.


3단계: 이메일, Celery 등 다양한 외부 의존성 Mock

# 이메일 발송 Mock
class NotificationTest(TestCase):
    @patch('notifications.services.send_mail')
    def test_order_confirmation_email(self, mock_send_mail):
        """주문 확인 이메일 발송 테스트"""
        send_order_confirmation(order_id=1)

        mock_send_mail.assert_called_once()
        call_args = mock_send_mail.call_args
        self.assertIn('주문 확인', call_args[1]['subject'])
        self.assertEqual(
            call_args[1]['recipient_list'],
            ['customer@test.com']
        )
# 현재 시간 Mock — 시간 의존적 로직 테스트
from freezegun import freeze_time

class CouponTest(TestCase):
    @freeze_time('2025-12-31 23:59:59')
    def test_coupon_not_expired(self):
        """만료 직전 쿠폰"""
        coupon = Coupon.objects.create(
            code='NEWYEAR',
            expires_at='2026-01-01 00:00:00'
        )
        self.assertTrue(coupon.is_valid())

    @freeze_time('2026-01-01 00:00:01')
    def test_coupon_expired(self):
        """만료 직후 쿠폰"""
        coupon = Coupon.objects.create(
            code='NEWYEAR',
            expires_at='2026-01-01 00:00:00'
        )
        self.assertFalse(coupon.is_valid())
# Celery 비동기 작업 Mock
from unittest.mock import patch

class OrderTest(TestCase):
    @patch('orders.tasks.send_notification.delay')
    def test_order_triggers_notification(self, mock_task):
        """주문 생성 시 알림 태스크 호출 검증"""
        order = create_order(product_id=1, quantity=2)

        mock_task.assert_called_once_with(order.id)

4단계: DRF API 테스트에서 Mock 활용

DRF의 APITestCase와 Mock을 결합하면 외부 연동이 포함된 API 엔드포인트를 안전하게 테스트할 수 있습니다.

from rest_framework.test import APITestCase
from rest_framework import status
from unittest.mock import patch, MagicMock

class OrderAPITest(APITestCase):
    def setUp(self):
        self.user = User.objects.create_user(
            username='tester', password='pass1234'
        )
        self.client.force_authenticate(user=self.user)

    @patch('orders.services.requests.post')
    def test_create_order_api(self, mock_payment):
        """주문 생성 API 테스트"""
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            'payment_id': 'pay_123'
        }
        mock_payment.return_value = mock_response

        response = self.client.post('/api/v1/orders/', {
            'product_id': 1,
            'quantity': 2,
            'card_token': 'tok_test',
        })

        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        self.assertEqual(response.data['data']['status'], 'paid')

🐛 자주 발생하는 실수와 디버깅

실수 1: patch 경로를 잘못 지정하는 경우

Mock에서 가장 흔한 실수는 patch 경로를 잘못 지정하는 것입니다.

# services.py
import requests  # requests를 import

def call_external_api():
    return requests.get('<https://api.example.com/data>')
# ❌ 원본 모듈을 patch — 동작하지 않음
@patch('requests.get')  # requests 모듈 자체를 patch
def test_api(self, mock_get):
    call_external_api()  # 실제 requests.get이 호출됨!
# ✅ 사용하는 쪽에서 patch
@patch('myapp.services.requests.get')  # services.py에서 쓰는 requests를 patch
def test_api(self, mock_get):
    call_external_api()  # Mock이 호출됨

 

원칙: "where it's used, not where it's defined" — patch는 대상이 정의된 곳이 아니라 사용되는 곳을 기준으로 합니다.


실수 2: Mock의 반환값을 설정하지 않는 경우

# ❌ 반환값 없이 테스트
@patch('orders.services.requests.post')
def test_payment(self, mock_post):
    result = process_payment(order)
    # mock_post.return_value는 MagicMock 객체
    # response.status_code는 MagicMock — 200이 아님!
    # 테스트가 예상과 다르게 동작

 

Mock의 반환값을 설정하지 않으면 MagicMock 객체가 반환됩니다. MagicMock은 어떤 속성에 접근해도 에러가 나지 않고 또 다른 MagicMock을 반환하므로, 테스트가 엉뚱한 결과를 내도 통과해버릴 수 있습니다.

반드시 return_value 또는 side_effect를 설정하세요.


실수 3: 과도한 Mocking

# ❌ 모든 것을 Mock하면 테스트의 의미가 없어짐
@patch('orders.services.calculate_total')
@patch('orders.services.validate_stock')
@patch('orders.services.apply_discount')
@patch('orders.services.process_payment')
@patch('orders.services.send_notification')
def test_create_order(self, mock_notif, mock_pay, mock_disc, mock_stock, mock_total):
    mock_total.return_value = 50000
    mock_stock.return_value = True
    mock_disc.return_value = 45000
    mock_pay.return_value = True
    # ... 내부 로직이 전부 Mock이면 뭐를 테스트하는 건지?

 

Mock은 경계에서만 사용합니다. 우리 코드 내부의 로직(calculate_total, validate_stock 등)은 실제로 실행되어야 테스트의 의미가 있습니다. 우리 통제 밖의 외부 의존성만 Mock으로 대체하세요.


실수 4: Mock 호출 검증을 빼먹는 경우

# ❌ Mock이 호출되었는지 검증 안 함
@patch('orders.services.send_mail')
def test_order_email(self, mock_send):
    create_order(product_id=1)
    # send_mail이 호출되었는지 확인 없음
    # 이메일 로직이 삭제되어도 테스트는 통과
# ✅ Mock 호출 검증 포함
@patch('orders.services.send_mail')
def test_order_email(self, mock_send):
    create_order(product_id=1)

    # 이메일이 정확히 1번 호출되었는지
    mock_send.assert_called_once()

    # 올바른 수신자에게 보냈는지
    call_kwargs = mock_send.call_args[1]
    self.assertIn('customer@test.com', call_kwargs['recipient_list'])

 

Mock은 "대체" 외에도 **"검증"**의 역할을 합니다. 올바른 파라미터로 호출되었는지, 몇 번 호출되었는지를 반드시 검증해야 테스트의 의미가 있습니다.


✅ 베스트 프랙티스 체크리스트

설계 단계

  • [ ] 외부 의존성 목록을 작성했는가? (결제, 이메일, SMS, 외부 API 등)
  • [ ] 각 외부 의존성에 대한 Mock 전략을 정했는가?
  • [ ] Mock의 경계를 명확히 정의했는가? (내부 로직은 실제, 외부만 Mock)

개발 단계

  • [ ] patch 경로가 "사용하는 쪽"을 기준으로 되어 있는가?
  • [ ] Mock의 return_value를 적절히 설정했는가?
  • [ ] 성공/실패/예외 시나리오를 모두 테스트하는가?
  • [ ] Mock 호출 검증(assert_called)을 포함했는가?

운영 단계

  • [ ] CI에서 외부 네트워크 없이 테스트가 동작하는가?
  • [ ] 테스트 실행 시 실제 외부 호출이 발생하지 않는가?
  • [ ] 외부 API 변경 시 Mock도 함께 업데이트하는 프로세스가 있는가?

🎯 결론: Mock은 테스트의 독립성을 지키는 방패막입니다

Mock 처리 부족은 테스트가 외부 세계에 의존하게 만들어 느리고, 불안정하고, 위험한 테스트를 만듭니다.

기억해야 할 핵심 원칙

  1. 외부 의존성은 반드시 Mock하세요: 결제, 이메일, SMS, 외부 API를 실제로 호출하는 테스트는 사고의 씨앗입니다.
  2. patch 경로는 "사용하는 쪽": 원본 모듈이 아니라 원본을 import하는 모듈을 기준으로 경로를 작성합니다.
  3. 실패 시나리오를 테스트하세요: Mock의 진짜 가치는 타임아웃, 네트워크 오류 등 실패 상황을 쉽게 재현할 수 있다는 것입니다.
  4. 경계에서만 Mock하세요: 내부 로직까지 Mock하면 테스트의 의미가 사라집니다.

💼 Django 테스트 전략 컨설팅

외부 연동이 많은 프로젝트일수록 Mock 전략이 중요합니다.

이런 고민이 있으시다면:

  • 테스트에서 외부 API가 실제로 호출되는 게 불안하다면
  • Mock 설정이 복잡해서 테스트 코드가 프로덕션 코드보다 길다면
  • CI에서 외부 연동 테스트가 랜덤하게 실패한다면

크몽에서 Django 전문 컨설팅을 제공합니다.

👉 [크몽에서 Django 컨설팅 문의하기]