2026/03/23 Blog - 33

김기훈·2026년 3월 23일

TIL

목록 보기
173/194
post-thumbnail

포트폴리오 최신화하고 코딩테스트 깊게 하기위하여 몇일동안은 기본문제만 진행


코딩테스트(7287)


포트폴리오

TIL 내용 정리


개선 사항

user

  • 성능 최적화: 이메일 발송 로직의 비동기(Asynchronous) 처리

    • 이슈
      • password_service.py와 email_service.py의 send_mail(...) 함수가 동기적으로 동작함
      • 이메일을 발송하기 위해 구글 SMTP 서버와 통신하는 시간 동안
        • 사용자는 회원가입이나 메일 발송 API 응답을 하염없이 기다려야 함 (네트워크 지연 발생)
    • 해결 가이드
      • Celery + Redis (또는 장고 최신 버전의 async/await 기능)를 도입하여
        • 이메일 발송 작업을 백그라운드 워커(Worker)로 넘기기
  • 보안성 개선: 무차별 대입 공격(Brute-Force) 방어 로직 부재

    • 이슈
      • 현재 코드는 이메일 인증번호 발송 API나 로그인 API에 대해
        • 재시도 횟수 제한(Rate Limiting)이 존재하지 않음
      • 악의적인 봇이 짧은 시간에 수만 건의 로그인 시도나 이메일 발송을 요청할 경우
        • 서버 다운이나 SMTP 쿼터 초과가 발생할 수 있음
    • 해결 가이드
      • DRF에서 기본 제공하는 Throttling 기능을 도입 추천
  • 가독성 및 예외 처리 구체화

    • 이슈
      • social_login_service.py에서 requests.post()를 통해
      • 외부 서버와 통신할 때 네트워크 오류나 HTTP 상태 코드 에러에 대한 방어가 적음
    • 해결 가이드
      • requests 라이브러리의 raise_for_status() 메서드나 Timeout 설정을 명시적으로 추가하여
      • 외부 API 장애가 우리 서버의 무한 대기(Hang) 현상으로 이어지지 않도록 방어 로직을 리팩토링 하기

무차별 대입 공격(Brute-Force) 방어

  • DRF에서 제공하는 ScopedRateThrottle을 사용하면
    • 뷰(View) 단위로 아주 쉽게 IP 기반 속도 제한을 걸 수 있음
  • 하지만 실무에서 간과하기 쉬운 치명적인 문제가 존재
    • 만약 서버가 Nginx, AWS ALB 같은 로드밸런서(리버스 프록시) 뒤에 있다면
    • Django가 인식하는 IP가 실제 접속자의 IP가 아니라 로드밸런서의 IP가 될 수 있음
  • 이렇게 되면 한 유저가 로그인을 5번 시도했는데 모든 유저가 차단당하는 대참사가 발생 가능
  • 최선의 선택

    • DRF 기본 Throttling을 적용하되
    • 배포 환경이라면 반드시 settings.py에서
      • 클라이언트의 진짜 IP(X-Forwarded-For)를 식별할 수 있도록
      • 프록시 관련 설정(NUM_PROXIES 등)을 맞춰주어야 함
  • settings.py (요금제 설정 추가)

REST_FRAMEWORK = {
    # ... 기존 설정 ...
    'DEFAULT_THROTTLE_CLASSES': [
        'rest_framework.throttling.AnonRateThrottle',
        'rest_framework.throttling.UserRateThrottle'
    ],
    'DEFAULT_THROTTLE_RATES': {
        # 분당/시간당/일당 허용 횟수를 지정합니다.
        'email_send': '3/min',     # 이메일 발송은 1분에 3회까지만 허용 (봇 방어)
        'login_attempt': '5/min',  # 로그인 시도는 1분에 5회까지만 허용 (브루트포스 방어)
    },
	# 프록시 IP 식별 설정
    # 클라이언트가 보낸 HTTP_X_FORWARDED_FOR 헤더를 기반으로 진짜 유저의 IP를 찾음
    # 만약 유저가 악의적으로 가짜 IP를 헤더에 섞어 보내더라도 (IP Spoofing 방어)
    # 신뢰하는 프록시(로드밸런서, Nginx 등)의 개수만큼 뒤에서부터 역추적하여 진짜 IP를 찾아냄
    'NUM_PROXIES': 1,
}

  • apps/user/views/login.py (로그인 뷰 적용)

class LoginAPIView(APIView):
    permission_classes = [AllowAny]
    serializer_class = LoginSerializer
    
——————————————————————————————————————[비교]—————————————————————————————————————————
from rest_framework.throttling import ScopedRateThrottle

class LoginAPIView(APIView):
    permission_classes = [AllowAny]
    serializer_class = LoginSerializer

    # 해당 뷰에 IP 기반으로 요청 횟수를 제한하는 ScopedRateThrottle을 장착
    throttle_classes = [ScopedRateThrottle]

    # settings.py에 정의된 'login_attempt' (예: 5/min) 룰을 적용하도록 이름을 매칭
    throttle_scope = 'login_attempt'
  • apps/user/views/email_view.py (이메일 발송 뷰 적용)

class EmailSendView(APIView):
    """이메일 발송 요청을 처리하는 API 뷰"""

    permission_classes = [AllowAny]
    
——————————————————————————————————————[비교]—————————————————————————————————————————
from rest_framework.throttling import ScopedRateThrottle

class EmailSendView(APIView):
    """이메일 발송 요청을 처리하는 API 뷰"""
    permission_classes = [AllowAny]

    # 악의적인 이메일 폭탄(SMTP 쿼터 초과 공격)을 막기 위해 쓰로틀링을 장착
    throttle_classes = [ScopedRateThrottle]

    # settings.py에 정의된 'email_send' (예: 3/min) 룰을 적용
    throttle_scope = 'email_send'
  • NUM_PROXIES 값을 정하는 방법

    • NUM_PROXIES: None (기본값)
      • 프록시를 안 쓸 때 (로컬 개발 환경). 쓰로틀링이 접속한 바로 그 IP(공유기 등)를 잡음
    • NUM_PROXIES: 1
      • Django 앞에 Nginx 하나만 있거나, AWS ALB(로드밸런서) 하나만 있을 때. (가장 흔한 배포 환경)
    • NUM_PROXIES: 2
      • 클라이언트 ➡️ AWS CloudFront ➡️ AWS ALB ➡️ Django 처럼 거쳐오는 프록시가 2개일 때

가독성 및 예외 처리 구체화 (외부 API 통신 안정성 확보)

  • timeout 설정과 raise_for_status()를 추가
    • 서버가 Hang(무한 대기) 상태에 빠지는 것을 막는 핵심
  • 최선의 선택

    • 단순히 4xx, 5xx 에러만 잡는 것이 아니라
    • 외부 서버 아예 죽어버렸거나(ConnectionError), 5초가 넘어버리는(Timeout) 상황 등
      • requests 라이브러리에서 발생할 수 있는 모든 네트워크 관련 예외를 한 번에 묶어서 안전하게 처리
  • GitHub

class GithubLoginService:
							...
                            
		# 4. 깃허브 서버로 POST 요청을 보내 토큰을 발급받음
        token_req = requests.post(token_req_url, data=data, headers=headers)

        # 5. 응답받은 JSON 데이터에서 access_token 값만 추출
        token_json = token_req.json()
        error = token_json.get("error")

        # 6. 토큰 발급 중 에러가 발생했다면 예외를 발생시킴
        if error is not None:
            raise ValueError("GitHub 토큰을 받아오는데 실패했습니다.")

        access_token = token_json.get("access_token")

        # 7. 발급받은 토큰으로 깃허브 유저 정보를 요청할 URL
        user_req_url = "https://api.github.com/user"

        # 8. 토큰을 Authorization 헤더에 담아 GET 요청을 보냄
        user_req = requests.get(
            user_req_url, headers={"Authorization": f"Bearer {access_token}"}
        )

        # 9. 응답받은 유저 정보를 JSON 객체로 변환
        user_json = user_req.json()
        
        					...
——————————————————————————————————————[비교]—————————————————————————————————————————
class GithubLoginService:
							...

        try:
            # 4. 깃허브 서버로 POST 요청을 보내 토큰을 발급받음
            token_req = requests.post(
                token_req_url,
                data=data,
                headers=headers,
                timeout=7  # [최우선 방어] 5초 안에 응답이 없으면 즉시 Timeout 에러를 발생시켜 무한 대기를 막음
            )

            # [응답 방어] HTTP 상태 코드가 200번대가 아닌 4xx, 5xx 에러라면 즉시 HTTPError 예외를 던짐
            token_req.raise_for_status()

            # 5. 응답받은 JSON 데이터에서 access_token 값만 추출
            token_json = token_req.json()

            # 6. GitHub API 특성상 200 OK를 주면서 본문에 error를 담아 보내는 경우가 있어 이를 한 번 더 검증
            if "error" in token_json:
                raise ValueError("GitHub 토큰을 받아오는데 실패했습니다.")

            access_token = token_json.get("access_token")  # 액세스 토큰을 안전하게 꺼냅니다.

            # 7. 발급받은 토큰으로 깃허브 유저 정보를 요청할 URL
            user_req_url = "https://api.github.com/user"

            # 8. 토큰을 Authorization 헤더에 담아 GET 요청을 보냄
            user_req = requests.get(
                user_req_url,
                headers={"Authorization": f"Bearer {access_token}"},  # 헤더에 토큰을 실어 보냅니다.
                timeout=5  # 여기도 마찬가지로 5초 타임아웃을 걸어 서버가 뻗는 것을 방지합니다.
            )

            # 상태 코드가 정상이 아니면 예외를 던짐
            user_req.raise_for_status()
            
            # 9. 응답받은 유저 정보를 JSON 객체로 변환
            user_json = user_req.json()

            # 위 requests.post 나 requests.get 과정에서 Timeout, ConnectionError, HTTPError가 터지면 모두 이 곳으로 빠짐
        except RequestException as e:
            # 서버(Django)가 500 에러를 뿜으며 죽지 않도록, 커스텀 예외 메시지로 감싸서 프론트엔드에 예쁘게 전달
            raise ValueError(f"GitHub 서버와 통신 중 지연 혹은 오류가 발생했습니다. (상세: {e})")
  • Discord

class DiscordLoginService:
							...
		# 4. 토큰 요청
        token_req = requests.post(token_req_url, data=data, headers=headers)
        token_json = token_req.json()

        if "error" in token_json:
            raise ValueError("Discord 토큰을 받아오는데 실패했습니다.")

        access_token = token_json.get("access_token")

        # 5. 유저 정보 요청 URL
        user_req_url = "https://discord.com/api/users/@me"
        user_req = requests.get(
            user_req_url, headers={"Authorization": f"Bearer {access_token}"}
        )
        user_json = user_req.json()
——————————————————————————————————————[비교]—————————————————————————————————————————
class DiscordLoginService:
							...
try:
            # 4. 디스코드 서버로 POST 요청을 보내 토큰을 발급받음 (주석 수정)
            token_req = requests.post(
                token_req_url,
                data=data,
                headers=headers,
                timeout=7  # [최우선 방어] 7초 안에 응답 없으면 Timeout 발생
            )

            # [응답 방어] 상태 코드가 200번대가 아니면 예외 발생
            token_req.raise_for_status()
            token_json = token_req.json()

            # 디스코드 API 에러 검증 (에러 메시지 수정)
            if "error" in token_json:
                raise ValueError("Discord 토큰을 받아오는데 실패했습니다.")

            access_token = token_json.get("access_token")

            # 7. 발급받은 토큰으로 디스코드 유저 정보를 요청할 URL (🚨여기가 수정되었습니다!)
            user_req_url = "https://discord.com/api/users/@me"

            # 8. 토큰을 Authorization 헤더에 담아 GET 요청을 보냄
            user_req = requests.get(
                user_req_url,
                headers={"Authorization": f"Bearer {access_token}"},
                timeout=5  
            )

            # 상태 코드가 정상이 아니면 예외 던짐
            user_req.raise_for_status()
            user_json = user_req.json()

        except RequestException as e:
            # 에러 메시지도 디스코드로 수정!
            raise ValueError(f"Discord 서버와 통신 중 지연 혹은 오류가 발생했습니다. (상세: {e})")

AI

  • 보안 및 비용 최적화: 입력값 길이 제한과 Rate Limiting 부재

    • 이슈
      • 외부 LLM API(Gemini)는 요청한 텍스트의 길이에 비례하여 비용이 발생
      • 현재 뷰에서는 사용자가 보낸 text의 길이를 제한하지 않으므로
        • 악의적으로 수만 자의 텍스트를 요청하여 API 비용을 폭증시키거나
          • 쿼터를 고갈시킬 수 있음
          • 또한 무차별 요청(Rate Limiting)에 대한 방어도 없음
    • 개선 가이드
      • 단순 딕셔너리(request.data.get) 추출 대신 DRF의 Serializer를 도입하여
        • serializers.CharField(max_length=2000) 등으로
        • 요청 텍스트의 최대 길이를 반드시 제한 필요
      • 해당 View에 throttle_classes = [UserRateThrottle, AnonRateThrottle]
        • 추가하여 하루/분당 API 호출 횟수를 제한 필요
  • 성능 최적화: SDK Client 인스턴스 재사용

    • 이슈
      • openai_service.py를 보면 convert_text_tone 함수가 호출될 때마다
        • 매번 client = genai.Client(...) 객체를 새롭게 생성하고 있음
    • 개선 가이드
      • 클라이언트 초기화 비용을 줄이기 위해
        • 파일 최상단(글로벌 영역)에서 client를 한 번만 초기화해 두고
          • 여러 요청이 이를 재사용하도록 수정하는 것이 성능상 유리
          • (만약 해당 SDK가 멀티스레드 환경에서 Thread-Safe 하다는 전제 하에 적용)
  • 안정성 개선: 스트리밍 중 예외 처리의 한계

    • 이슈
      • ToneConverterAPIView의 try-except 블록은
        • 초기 연결 실패 시에는 400, 500 에러를 잘 반환함
      • 하지만 StreamingHttpResponse가 이미 시작되어
        • 클라이언트로 200 OK 상태 코드가 날아간 이후에 텍스트를 생성하다가
        • 오류(예: Google 서버 일시 끊김)가 발생하면
          • 서버는 상태 코드를 500으로 바꿀 수 없고 그냥 연결을 뚝 끊어버리게 됨
    • 개선 가이드
      • 프론트엔드가 중간 에러를 명확히 인지할 수 있도록
      • 순수 텍스트(문자열 바이트)만 yield 하는 대신 구조화된 SSE(Server-Sent Events) 포맷을 사용

보안 및 비용 최적화

  • 입력값을 검증할 시리얼라이저를 작성

from rest_framework import serializers

class ToneConvertSerializer(serializers.Serializer):
    """
    텍스트 변환 API의 입력값을 검증하는 시리얼라이저입니다.
    """
    text = serializers.CharField(
        max_length=2000, # 외부 LLM 비용 폭증을 막기 위해 2000자로 길이를 제한
        required=True,
        error_messages={
            "required": "변환할 텍스트를 입력해주세요.",
            "max_length": "텍스트는 최대 2000자까지 변환 가능합니다." # 에러 메시지
        }
    )
    tone = serializers.CharField(
        required=True,
        error_messages={
            "required": "원하시는 문체를 선택해주세요."
        }
    )
  • View

class ToneConverterAPIView(APIView):
    def post(self, request, *args, **kwargs):
        # 클라이언트가 보낸 JSON 데이터 중 'text'를 가져옴
        text = request.data.get("text")
        # 클라이언트가 보낸 JSON 데이터 중 'tone'을 가져옴
        tone = request.data.get("tone")

        # text나 tone 중 하나라도 비어있다면 (유효성 검사 실패)
        if not text or not tone:
            raise BaseCustomException(ErrorMessage.INVALID_INPUT)
							...
——————————————————————————————————————[비교]—————————————————————————————————————————
from rest_framework.throttling import UserRateThrottle, AnonRateThrottle

class ToneConverterAPIView(APIView):
    # 무차별 요청(Rate Limiting) 방어를 위해 쓰로틀링 장착
    # settings.py의 DEFAULT_THROTTLE_RATES에 설정된 규칙(예: 분당 10회 등)을 따르게 됨
    throttle_classes = [UserRateThrottle, AnonRateThrottle]

    def post(self, request, *args, **kwargs):
        # 1. 시리얼라이저를 통한 엄격한 검증 (단순 딕셔너리 추출 대체)
        serializer = ToneConvertSerializer(data=request.data)

        # 2. 유효성 검사 실패 시 (글자 수 초과 등) 즉시 400 에러를 반환하여 AI 호출을 차단합니다.
        serializer.is_valid(raise_exception=True)

        # 3. 안전하게 검증이 끝난 데이터만 꺼내어 사용합니다.
        text = serializer.validated_data.get("text")
        tone = serializer.validated_data.get("tone")
  • settings

REST_FRAMEWORK = {  
			...
    'DEFAULT_THROTTLE_RATES': {
        # AnonRateThrottle과 UserRateThrottle이 작동하기 위해 반드시 필요한 기본 설정
        'anon': '100/day',         # 익명 유저: 하루 100회로 제한하여 무분별한 API 호출 및 Swagger 긁어가기를 방지합니다.
        'user': '1000/day',
        # 분당/시간당/일당 허용 횟수를 지정
        'email_send': '3/min',     # 이메일 발송은 1분에 3회까지만 허용 (봇 방어)
        'login_attempt': '5/min',  # 로그인 시도는 1분에 5회까지만 허용 (브루트포스 방어)
    },

성능 최적화: SDK Client 인스턴스 재사용

  • Client 글로벌화
    • genai.Client는 HTTP 세션을 내부적으로 관리하므로
      • 매번 생성하면 불필요한 네트워크 핸드셰이크 시간이 낭비됨
      • 전역(Global) 영역에서 한 번만 생성하고 스레드끼리 공유하도록 빼내기
  • SSE 에러 처리
    • 스트리밍 중에 구글 서버가 죽어버리면 Django는 이미 200 OK를 내보냈기 때문에 500 에러로 바꿀 수 없음
    • 따라서 텍스트가 아닌 "구조화된 SSE 이벤트"(data: ..., event: error) 형태로 내려주어
      • 프론트엔드가 이를 파싱(Parsing)해 에러를 인지할 수 있도록 함
def convert_text_tone(text: str, tone: str) -> str:  # type: ignore
    try:
        # 1. 클라이언트가 요청한 문체(tone)에 맞는 프롬프트를 찾습니다.
        system_prompt = TONE_MAPPING.get(tone)
        if not system_prompt:
            raise BaseCustomException(ErrorMessage.UNSUPPORTED_TONE)

        # 2. 새로운 google.genai의 Client 인스턴스를 생성하면서 환경변수(settings)의 API 키를 주입
        client = genai.Client(api_key=settings.GEMINI_API_KEY)

        # 3. GenerativeModel 인스턴스를 따로 만들지 않고, Client를 통해 바로 스트리밍 생성을 요청
        response = client.models.generate_content_stream(
            # 속도와 성능이 모두 뛰어난 최신의 gemini-2.5-flash 모델을 명시적으로 지정
            model="gemini-2.5-flash",
            # 사용자가 블로그 글로 변환하고자 하는 원본 텍스트를 전달
            contents=text,
            # 4. 모델의 지시사항, 온도 등의 세부 옵션은 GenerateContentConfig 객체에 묶어서 전달
            config=types.GenerateContentConfig(
                # 앞에서 찾은 시스템 프롬프트를 모델의 기본 지시사항으로 주입
                system_instruction=system_prompt,
                # 자연스럽고 적절한 변환을 위해 창의성 정도(온도)를 0.7로 설정
                temperature=0.7,
                # 블로그 글이 잘리지 않도록 넉넉하게 토큰 제한을 2500으로 제한
                max_output_tokens=2500,
            ),
        )

        # 5. 스트리밍 응답 객체(response)에서 생성되는 텍스트 조각(chunk)을 순회
        for chunk in response:
            # 조각 안에 텍스트 데이터가 정상적으로 존재하는지 확인
            if chunk.text:
                # 일반 문자열(String)이 아닌 UTF-8 바이트(Bytes)로 인코딩하여 반환
                # 장고가 내부적으로 문자를 처리하며 대기하는 시간을 없애줌
                yield chunk.text.encode("utf-8")

    # 우리가 위에서 직접 발생시킨 '지원하지 않는 문체' 에러를 잡음
    except ValueError as ve:
        # 서버 로그에 에러 원인을 기록함
        logger.error(f"잘못된 문체 요청: {ve}")
        # 이 에러를 API View로 다시 던져서 클라이언트에게 400 에러를 내려주도록 함
        raise

    # 그 외에 구글 서버 점검, 네트워크 단절 등 예측하지 못한 모든 에러를 잡음
    except Exception as e:
        # 원인을 파악할 수 있도록 상세한 에러를 로그에 남김
        logger.error(f"Gemini API 호출 중 오류 발생: {e}")
        # 유저에게는 내부 에러 상세 내용을 숨기고, 안전하고 친절한 메시지로 덮어씌워 던짐
        raise BaseCustomException(ErrorMessage.AI_CONVERSION_FAILED)
        
——————————————————————————————————————[비교]—————————————————————————————————————————
# SDK Client 인스턴스 전역(Global) 재사용
# 파일이 로드될 때 한 번만 인스턴스를 생성하므로, 매 요청마다 발생하는 연결 초기화 비용(오버헤드)을 줄임
try:
    # 1. 새로운 google.genai의 Client 인스턴스를 생성하면서 환경변수(settings)의 API 키를 주입
    gemini_client = genai.Client(api_key=settings.GEMINI_API_KEY)
except Exception as e:
    logger.error(f"Gemini Client 초기화 실패: {e}")
    gemini_client = None  # 환경변수 문제 등으로 실패할 경우를 대비한 방어 로직


def convert_text_tone(text: str, tone: str):
    try:
        # 2. 클라이언트가 요청한 문체(tone)에 맞는 프롬프트를 찾습니다.
        system_prompt = TONE_MAPPING.get(tone)
        if not system_prompt:
            raise BaseCustomException(ErrorMessage.UNSUPPORTED_TONE)

        # 3. 클라이언트 초기화가 모종의 이유로 실패했다면 여기서 막음
        if not gemini_client:
            raise RuntimeError("AI 클라이언트를 초기화할 수 없습니다.")

        # 4. 매번 새로 만들던 client 대신, 글로벌 영역에 띄워둔 gemini_client를 재사용
        response = gemini_client.models.generate_content_stream(
            # 속도와 성능이 모두 뛰어난 최신의 gemini-2.5-flash 모델을 명시적으로 지정
            model="gemini-2.5-flash",
            # 사용자가 블로그 글로 변환하고자 하는 원본 텍스트를 전달
            contents=text,
            # 5. 모델의 지시사항, 온도 등의 세부 옵션은 GenerateContentConfig 객체에 묶어서 전달
            config=types.GenerateContentConfig(
                # 앞에서 찾은 시스템 프롬프트를 모델의 기본 지시사항으로 주입
                system_instruction=system_prompt,
                # 자연스럽고 적절한 변환을 위해 창의성 정도(온도)를 0.7로 설정
                temperature=0.7,
                # 블로그 글이 잘리지 않도록 넉넉하게 토큰 제한을 2500으로 제한
                max_output_tokens=2500,
            ),
        )

        # 6. 스트리밍 응답 객체(response)에서 생성되는 텍스트 조각(chunk)을 순회
        for chunk in response:
            if chunk.text:
                # 순수 텍스트 대신 SSE(Server-Sent Events) 표준 포맷으로 변환
                # 줄바꿈(\n) 등의 문자가 깨지지 않도록 json.dumps를 사용하여 텍스트를 감쌈
                sse_payload = json.dumps({"text": chunk.text}, ensure_ascii=False)

                # SSE 포맷 규약에 맞게 'data: {payload}\n\n' 형태로 만들어서 내보냅니다.
                yield f"data: {sse_payload}\n\n".encode("utf-8")

    # 제너레이터 실행 도중(이미 200 OK로 스트리밍이 진행되는 도중) 구글 API 등에 장애가 발생했다면
    except Exception as e:
        logger.error(f"Gemini API 스트리밍 중 오류 발생: {e}")

        # HTTP 상태 코드를 500으로 바꿀 수는 없지만, 프론트엔드가 에러임을 알 수 있게
        # event: error 라는 커스텀 이벤트를 발생시켜 에러 메시지를 보냄
        error_payload = json.dumps({"error": "AI 텍스트 생성 중 일시적인 서버 오류가 발생했습니다."}, ensure_ascii=False)

        # 프론트엔드는 이 형태를 받으면 정상 데이터 처리를 멈추고 에러 팝업을 띄울 수 있습니다.
        yield f"event: error\ndata: {error_payload}\n\n".encode("utf-8")

exception 수정

import logging
from rest_framework.views import exception_handler
from rest_framework.response import Response
from apps.core.exceptions.messages import ErrorMessage

logger = logging.getLogger("django")


def custom_exception_handler(exc, context):
    # 1. DRF 기본 핸들러 호출
    response = exception_handler(exc, context)

    # 2. 처리되지 않은 500 에러 처리
    if response is None:
        logger.error(f"[System Error] {exc}", exc_info=True)  #
        return Response(
            {
                "error_detail": ErrorMessage.SYSTEM_ERROR.message,
                "code": ErrorMessage.SYSTEM_ERROR.code,
            },
            status=ErrorMessage.SYSTEM_ERROR.status_code,
        )

    # 3. 응답 포맷 통일: {"error_detail": "...", "code": "...", "errors": {...}}
    custom_data = {
        "error_detail": response.data.get("detail", "유효하지 않은 요청입니다."),
        # BaseCustomException이면 코드가 들어가고, 아니면 "error"
        "code": getattr(exc, "default_code", "error"),
    }

    # 유효성 검사(400) 실패 시 상세 정보 유지
    if response.status_code == 400 and "detail" not in response.data:
        custom_data["errors"] = response.data

    response.data = custom_data
    return response

——————————————————————————————————————[비교]—————————————————————————————————————————
import logging
from rest_framework.views import exception_handler
from rest_framework.response import Response

from apps.core.exceptions.messages import ErrorMessage
from apps.core.exceptions.base import BaseCustomException 

logger = logging.getLogger("django")


def custom_exception_handler(exc, context):
    # 1. DRF 기본 핸들러 호출
    response = exception_handler(exc, context)

    # 2. 처리되지 않은 500 에러 (서버 셧다운 방어)
    if response is None:
        logger.error(f"[System Error] {exc}", exc_info=True)
        return Response(
            {
                "code": ErrorMessage.SYSTEM_ERROR.code,
                "message": ErrorMessage.SYSTEM_ERROR.message,
            },
            status=ErrorMessage.SYSTEM_ERROR.status_code,
        )

    # 3. 응답 포맷 통일 로직 시작
    # 프론트엔드와 약속할 공통 뼈대: {"code": "...", "message": "..."}
    custom_data = {}

    if isinstance(exc, BaseCustomException):
        # 케이스 A: 우리가 ErrorMessage Enum으로 직접 정의하고 발생시킨 예외
        custom_data["code"] = exc.default_code
        custom_data["message"] = str(exc.detail)
    else:
        # 케이스 B: DRF 기본 내장 예외 (예: AuthenticationFailed, NotFound 등)
        custom_data["code"] = getattr(exc, "default_code", "error")

        # DRF 예외는 기본적으로 'detail'이라는 키에 메시지를 담아 보냅니다.
        if isinstance(response.data, dict) and "detail" in response.data:
            custom_data["message"] = str(response.data["detail"])
        else:
            custom_data["message"] = "유효하지 않은 요청입니다."

    # 4. Serializer Validation Error (400) 특화 처리
    # 사용자가 회원가입 등에서 입력값을 잘못 보냈을 때 (detail 키가 없고 필드명: [에러] 형태로 옴)
    if response.status_code == 400 and isinstance(response.data, dict) and "detail" not in response.data:
        custom_data["code"] = ErrorMessage.INVALID_INPUT.code
        custom_data["message"] = ErrorMessage.INVALID_INPUT.message
        # 어느 필드가 틀렸는지 구체적인 이유를 'details'라는 키로 예쁘게 묶어서 보내줍니다.
        custom_data["details"] = response.data

        # 최종적으로 정제된 데이터를 응답 객체에 덮어씌웁니다.
    response.data = custom_data
    return response

결과

  • 커스텀 에러 발생 시 (raise BaseCustomException(ErrorMessage.USER_NOT_FOUND))
{
    "code": "user_not_found",
    "message": "존재하지 않는 유저입니다."
}
  • 뷰에서 serializer.is_valid(raise_exception=True)로 검증 실패 시 (400 에러)
{
    "code": "invalid_input",
    "message": "유효하지 않은 입력값입니다.",
    "details": {
        "email": ["유효한 이메일 주소를 입력하십시오."],
        "password": ["비밀번호는 8자 이상이어야 합니다."]
    }
}
  • 코드 버그로 인한 서버 터짐 방어 시 (500 에러)
{
    "code": "server_error",
    "message": "서버 내부 오류가 발생했습니다."
}


profile
안녕하세요.

0개의 댓글