예외처리(Error Handling)하는 방법
API를 구현할 때 고려해야 할 것 중 하나는 “에러핸들링 처리를 어떻게 할 것인가”이다
기본적으로 예외처리는 try ~ except 절을 사용한다.
try:
pass
except Exception as e:
pass
이때 except절은 보통 세분화해서 남긴다
try:
pass
except ExTable.DoesNotExist:
pass
except Exception as e:
pass
예외처리를 하는 이유는 서비스의 예외적인 에러를 원하는 방식으로 핸들링 하기 위해서다.
API를 작업하면서 예상 할 수 있는 에러가 있고 예상하지 못하는 에러가 있을 수 밖에 없다.
아무리 완벽하게 짰다고 한들 에러는 발생할 수 있다. 그렇기 때문에 해당 버그를 좀 더 빠르고 정확하게 찾기 위해서 except절에 에러 로그를 달아준다
내 프로젝트에는 이미 만들어진 로그 모듈을 import받아 사용하고 있다.
import common.logger import set_logger
_logger = set_logger("traceback")
try:
_logger.info("Success. (data: request_data)")
except ExTable.DoesNotExist:
_logger.error("Failed. (data: request_data)")
except Exception as e:
_logger.error("Failed. (data: request_data)")
로그를 남기는 경우 아래와 같은 이미지처럼 날짜와 작업에 대한 msg, 작업에 대한 data를 볼 수 있다. 오류 발생시에 디버깅하기 훨씬 편하다.
바쁜 작업이 어느정도 마무리 되었으니 내가 맡은 프로젝트의 에러 핸들링 부분을 커스텀하려고 한다.
현재 내가 구현한 에러 핸들링 방식은 에러와 같다
# config/api.py
def api_exc_handler(request, exc):
return exc.to_json_res()
def exc_handler(request, exc):
_logger = set_logger("traceback")
_logger.error(f"{traceback.format_exc()}")
return JsonResponse({"error": traceback.format_exc()}, status=500)
api.add_exception_handler(Exception, exc_handler)
api.add_exception_handler(ApiAdminError, api_exc_handler)
# admin/api.py
class ApiAdminError(Exception):
ALREADY_EXIST_ID = ("입력한 아이디가 이미 존재합니다.", "000302", 400)
DUPLICATE_PHONE_NUMBER = ("이미 핸드폰 번호가 존재합니다.", "000303", 400)
NO_INPUT_VALUE = ("모든 값을 입력해주세요.", "000001", 400)
def __init__(self, error_obj: tuple) -> None:
self.data = error_obj
def to_json_res(self) -> dict:
return JsonResponse(
{"ret_code": self.data[1], "error": self.data[0]},
status=self.data[2],
)
@router.post( # 계정 추가
"/account",
tags=[""],
summary="",
description=desc_create_account,
auth=AuthBearer("True"),
response=AccountOutput,
)
def create_partner_account(request, data: AccountInfo):
def _validate_user_input() -> tuple:
if (
(not data.id)
or (not data.pwd)
or (not data.name)
or (not data.account_holder)
):
_logger.error(
f"failed create partner account"
f" {ApiAdminError.NO_INPUT_VALUE[0]}"
)
raise ApiAdminError(ApiAdminError.NO_INPUT_VALUE)
if TblPartnerMaster.objects.filter(
site_id=data.id, status="Normal"
).exists():
_logger.error(
f"failed create partner account"
f" {ApiAdminError.ALREADY_EXIST_ID[0]} (site_id: {data.id})"
)
raise ApiAdminError(ApiAdminError.ALREADY_EXIST_ID)
phone_number = None
if data.phone_number:
_logger.error(
f"{ApiAdminError.DUPLICATE_PHONE_NUMBER[0]} "
f"phone_number: {phone_number}, "
f"account_holder: {data.account_holder}"
)
에러 발생시 어떤식으로 핸들링 처리되고 있는지만 보자
- API error발생시 각 view마다 Exception을 상속받은 error class를 구현
- 불필요한 코드 낭비
- 발생한 error값들이 각 view마다 작성되어 있음
- error값들을 한번에 파악하기 힘듦
- Api error / Traceback error 발생시 모두 로그를 직접 아래에 달아줌
- 로그 포맷이 일정하지 않은 단점이 있음
내가 처리하고 싶은 방식은
- error code를 하나의 파일로 모아 관리하고 싶음
- 크게 ApiError(예상한 에러) 와 Traceback error(예상치 못한 에러)로 나눠서 통일화된 로그값과 반환값으로 내려주고 싶음
- 정해진 응답 양식대로 값을 반환하기 위해 200, 400, 500에 대한 반환값 구별 필요
- 성공/실패에 대한 로그에 요청값이 반환 될 수 있게 처리
사용 프레임워크
- Django4.0 (django-ninja)
- 200, 400, 500에 대한 공통 반환값 작성
- common/schema라는 곳에 공통 스키마를 먼저 작성했다
- 200의 경우 반환되는 값이 다 다르기 때문에 공통 스키마를 상속받아 해당 반환되는 값에 맞춰 스키마를 작성해줬다
#common/schema.py class CommonSuccessOutPut(Schema): status: str data: dict class CommonFailedOutPut(Schema): status: str code: str message: str
# 해당view.py/schema.py from schema import CommonSuccessOutPut class ReadAdminInfoOutput(CommonSuccessOutPut): # 반환값에 맞게 커스텀 data = { "key1": "value1", "key2": [{"key2_2": "value2_1"}] }
- common/error.py에 Error 코드값들을 Enum 타입으로 관리하도록 수정했다
from enum import Enum
class ErrorCode:
class Common(Enum):
UNKNOWN_ERROR = ("000099", "분류되지 않는 에러 발생입니다", "따로 핸들링 되지 못한 에러일때")
- common/error.py에 Exception을 상속받은 ApiError 클래스를 구현했다
- 예상했던 에러들은 ApiError를 타게 된다.
- 포맷된 로그 값을 리턴한다. (get_log_str)
- 클라이언트에 반환할 포맷된 값을 리턴한다. (get_json_res)
# common/error.py class ApiError(Exception): def __init__( self, error_obj: Optional[Enum] = None, req_data: Optional[dict] = None, code: Optional[int] = None, message: Optional[str] = None, ): if error_obj: error_obj = error_obj.value self.code = error_obj[0] self.message = error_obj[1] else: self.code = code self.message = message self.req_data = str(req_data) def get_log_str(self): return f"ApiError - code: {self.code}, request_data: {self.req_data}, message: {self.message}" def get_json_res(self): server_error_code_list = [ ErrorCode.Common.UNKNOWN_ERROR.value[0] ] if self.code in server_error_code_list: return err_json_res("Failed", str(self.code), self.message, 500) else: return err_json_res("Failed", str(self.code), self.message, 400)
성공시 반환하는 공통 포맷과 에러값을 반환하는 포맷을 만들었다
# func.py def err_json_res( status: str, code: str, message: str, stat: int ) -> Dict: data = {"status": status, "code": code, "message": message} return JsonResponse( data, json_dumps_params={"ensure_ascii": False}, # 유니코드로 표현 status=stat ) def success_json_res( status: str, stat: int, result: Optional[dict] = {}, ) -> Dict: data = {"status": status, "data": result} return JsonResponse( data, json_dumps_params={"ensure_ascii": False}, status=stat )
API단에서 에러 발생시 아래의 함수를 탈 수 있게 처리했다
# config/api.py def api_handler(request: object, error: ApiError) -> dict: _logger = set_logger("traceback") _logger.error(f"{error.get_log_str()}\n{traceback.format_exc()}") return exc.to_json_res() api.add_exception_handler(ApiError, api_handler)
에러 발생시 아래와 같이 ApiError로 raise 처리한다
try: cursor = connection.cursor() cursor.execute(raw_query) query_result = cursor.fetchall() except Exception as e: raise ApiError(ErrorCode.Common.FAILED_READ)
변경 후 로그를 공통 포맷으로 유지할 수 있었고 매번 API에 로그를 일일히 달아줬는데 이런 중복을 없앨 수 있었다. 현재는 /var/log/traceback이라는 곳에 로그를 남기고 있는데 한가지 고려를 해봐야 할 점은 바로 “각 app마다 로그 파일을 생성하는 것이다”
기존 핸들링 방식은 각 app마다 전역으로 로그 파일 네임을 정해 해당 파일에 로그를 남기는 방식이었다. 하지만 변경된 방식에서는 “api_handler” 함수에서 하나의 로그 파일에 남기고 있다. 이 부분은 정답이 없기 때문에 내가 편한 방식대로 변경하면 될 듯 싶다