AWS Lambda에서 Python 로깅을 커스터마이징할 때는 일반적인 Python 환경과 다른 런타임 동작을 이해해야 합니다. 특히 기본 로그 핸들러를 무작정 교체하면 로그가 중복 출력되거나 요청을 식별하는 aws_request_id가 사라질 수 있습니다. 이번 글에서는 Lambda의 로깅 초기화 과정과 필터의 역할을 살펴보고, 기존 요청 추적 기능을 유지하면서 JSON 로그를 출력하는 방법을 알아보겠습니다.
AWS Lambda에서 JSON 로그를 사용해야 하는 이유
구조화된 로그는 애플리케이션의 실행 상태를 분석하고 오류를 추적하는 데 유용합니다. AWS Lambda에서 발생한 로그는 Amazon CloudWatch Logs에 저장되며, CloudWatch Logs Insights를 이용하면 저장된 데이터를 조건별로 검색하고 분석할 수 있습니다.
일반적인 문자열 로그의 한계
Python에서 다음과 같이 로그를 기록하면 사람이 읽기에는 편리하지만, 메시지 내부의 개별 속성을 기준으로 분석하기에는 불편할 수 있습니다.
import logging
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def handler(event, context):
logger.info("User login completed")
이 방식은 간단한 상태 확인에는 적합하지만, 사용자 ID, 처리 시간, 요청 유형 등 여러 정보를 함께 기록하고 검색하려면 추가적인 문자열 파싱이 필요할 수 있습니다.
반면 JSON 형식으로 로그를 기록하면 각 항목을 독립적인 필드로 관리할 수 있습니다.
{
"level": "INFO",
"message": "User login completed",
"user_id": "user-123",
"duration_ms": 42
}
이러한 구조에서는 로그 메시지 전체를 문자열로 검색하는 대신 특정 필드의 값을 기준으로 조회할 수 있습니다. 단, JSON 객체를 CloudWatch에서 개별 필드로 활용하려면 실제 로그 출력 형식과 CloudWatch의 로그 필드 자동 탐지 및 파싱 동작도 함께 고려해야 합니다.
매번 json.dumps를 호출하지 않는 방법
기존 코드에서 딕셔너리를 로그로 출력하려면 json.dumps()를 직접 호출하는 방법을 사용할 수 있습니다.
import json
import logging
logger = logging.getLogger(__name__)
def handler(event, context):
data = {
"user_id": "user-123",
"action": "login",
}
logger.info(json.dumps(data, ensure_ascii=False))
하지만 로그를 남길 때마다 직렬화 코드를 작성하면 중복이 발생하고, 로그 형식을 변경할 때 여러 부분을 수정해야 합니다.
이를 개선하려면 Python의 logging.Formatter를 상속한 사용자 정의 포매터를 구현할 수 있습니다. 이 포매터는 로그 레코드를 JSON 문자열로 변환하므로, 호출부에서는 딕셔너리를 직접 전달할 수 있습니다.
Python logging의 Formatter를 이용한 JSON 로그 구현
사용자 정의 Formatter는 로그 레벨, 발생 시각, 코드 위치, 메시지를 일관된 JSON 구조로 변환합니다. 그러나 포매터를 구현하는 것만으로는 Lambda 런타임의 기존 로깅 설정과 충돌하지 않는다는 보장이 없습니다.
JsonFormatter 클래스 작성하기
다음 코드는 로그 레코드의 주요 정보를 JSON 객체로 구성하는 예시입니다.
import json
import logging
from datetime import datetime, timezone
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
log_message = {
"level": record.levelname,
"timestamp": datetime.fromtimestamp(
record.created,
tz=timezone.utc,
).isoformat(timespec="milliseconds").replace("+00:00", "Z"),
"line": (
f"{record.pathname}/{record.module}"
f"::{record.funcName}::{record.lineno}"
),
}
if isinstance(record.msg, dict):
log_message["message"] = record.msg
elif isinstance(record.msg, str):
log_message["message"] = record.getMessage()
else:
log_message["message"] = str(record.getMessage())
return json.dumps(log_message, ensure_ascii=False)
여기서 record는 Python logging 모듈이 생성하는 LogRecord 객체입니다. 로그 레벨과 파일 경로, 함수 이름, 코드 줄 번호, 메시지 등 로그 이벤트와 관련된 정보를 포함합니다.
record.msg가 딕셔너리라면 해당 객체를 JSON의 message 필드에 그대로 넣습니다. 문자열이나 다른 자료형은 별도의 규칙에 따라 처리합니다.
또한 시간 정보는 UTC 기준으로 변환하고 밀리초까지 표시하도록 구성했습니다. 이를 통해 서로 다른 실행 환경에서 발생한 로그를 시간순으로 비교하기 쉬워집니다.
루트 로거에 핸들러를 추가할 때 발생하는 문제
이제 사용자 정의 포매터를 적용하는 로거 초기화 함수를 작성해 보겠습니다.
def get_logger(level: int = logging.INFO):
logger = logging.getLogger()
logger.setLevel(level)
handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())
logger.addHandler(handler)
return logger
logger = get_logger()
def handler(event, context):
logger.info({"action": "login", "status": "success"})
일반적인 Python 환경에서 직접 실행하면 의도한 JSON 로그가 출력될 수 있습니다. 그러나 AWS Lambda에서는 동일한 메시지가 서로 다른 형식으로 두 번 출력되는 현상이 나타날 수 있습니다.
이 현상은 JSON 포매터 자체의 문제가 아니라, Lambda 런타임이 이미 구성한 핸들러에 새로운 핸들러를 추가했기 때문입니다.
AWS Lambda Python 런타임은 로깅을 어떻게 초기화할까?
AWS Lambda는 사용자 핸들러를 실행하기 전에 런타임 차원의 로깅 설정을 수행합니다. 이 과정에서 기본 핸들러와 요청 ID를 관리하는 필터 등이 구성될 수 있으므로, 일반 Python 프로그램과 동일한 방식으로 로거를 초기화하면 예상하지 못한 결과가 발생할 수 있습니다.
일반 Python과 Lambda의 로그 형식 비교
일반 Python 환경에서는 다음 코드가 대표적인 형태의 로그를 출력합니다.
import logging
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def handler(event, context):
logging.info("Hello, world!")
일반적인 기본 설정에서는 다음과 비슷한 결과를 확인할 수 있습니다.
INFO:root:Hello, world!
AWS Lambda에서는 런타임의 로깅 설정에 따라 다음과 같은 형태로 출력될 수 있습니다.
[INFO] 2025-01-17T18:13:25.159Z 1e751cb1-d726-4f36-b017-c89889457d8d Hello, world!
두 출력의 차이는 로그 레벨뿐 아니라 타임스탬프와 요청 식별자 등 추가 정보의 포함 여부에서도 나타납니다.
Lambda의 로깅 동작을 더 깊이 이해하려면 AWS가 공개한 AWS Lambda Python Runtime Interface Client 저장소를 살펴보는 것이 좋습니다. 이 저장소에는 Python 핸들러를 호출하고 런타임 환경을 관리하는 구현이 포함되어 있습니다.
런타임의 로깅 설정과 핸들러 등록 과정
Lambda 런타임은 요청을 수신하고 사용자 코드에 전달하기 전에 필요한 초기화 작업을 수행합니다. 이 과정에는 로그 출력 형식에 따른 핸들러 설정과 요청별 정보를 로그 레코드에 연결하는 작업이 포함될 수 있습니다.
Python의 로깅 시스템에서 Logger는 로그 이벤트를 생성하고, Handler는 이벤트를 출력 대상으로 전달하며, Formatter는 출력 형식을 결정합니다. Filter는 로그 이벤트를 필터링하거나 레코드에 추가 정보를 주입하는 데 활용할 수 있습니다.
따라서 Lambda에서 이미 구성한 핸들러가 존재하는 상태에서 새로운 핸들러를 추가하면 동일한 로그 이벤트가 여러 출력 경로로 전달될 수 있습니다.
이때 두 핸들러가 서로 다른 포매터를 사용한다면, 같은 이벤트가 하나는 기본 텍스트 형식으로, 다른 하나는 사용자 정의 JSON 형식으로 출력될 수 있습니다.
Lambda 중복 로그를 제거할 때 주의할 점
중복 출력을 해결하기 위해 기존 핸들러를 제거하는 것은 가능하지만, 요청 추적 기능까지 함께 사라질 수 있다는 점을 주의해야 합니다. 단순히 로그가 한 번만 출력되는지만 확인해서는 올바른 해결 여부를 판단하기 어렵습니다.
기존 핸들러를 제거하는 방식
다음 코드는 루트 로거에 등록된 핸들러를 모두 제거한 다음 새로운 핸들러를 등록하는 방법입니다.
def get_logger(level: int = logging.INFO):
logger = logging.getLogger()
logger.setLevel(level)
logger.handlers.clear()
handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())
logger.addHandler(handler)
return logger
기존 핸들러가 제거되므로 동일한 로그가 여러 번 출력되는 현상은 해결될 수 있습니다.
하지만 이 코드는 Lambda 런타임이 제공하는 기본 로깅 기능까지 제거할 수 있습니다. 특히 기존 핸들러에 연결된 필터가 aws_request_id를 로그 레코드에 추가하고 있었다면, 해당 기능을 더 이상 사용할 수 없게 됩니다.
따라서 운영 환경에서는 단순히 logger.handlers.clear()를 호출하기 전에 기존 핸들러가 어떤 역할을 하는지 확인해야 합니다.
Request ID가 사라지는 이유
AWS Lambda에서 aws_request_id는 개별 함수 호출을 구분하는 데 중요한 정보입니다. 요청이 실패했을 때 해당 요청과 관련된 로그만 찾아보거나, 특정 호출에서 발생한 문제를 재현하는 데 활용할 수 있습니다.
Lambda의 기본 로깅 설정에서는 LambdaLoggerFilter와 같은 필터가 로그 레코드에 요청 ID를 추가하는 역할을 수행할 수 있습니다. 런타임 구현에서 _GLOBAL_AWS_REQUEST_ID와 같은 내부 상태를 이용해 요청 정보를 전달하는 방식도 확인할 수 있습니다.
기존 핸들러를 제거하면 해당 핸들러에 연결된 필터도 함께 제거됩니다. 그 결과 사용자 정의 포매터에서 다음과 같이 접근할 경우 오류가 발생할 수 있습니다.
def format(self, record):
request_id = record.aws_request_id
필터가 해당 속성을 추가하지 않았다면 LogRecord 객체에 aws_request_id가 존재하지 않을 수 있습니다. 이때는 다음과 같은 AttributeError가 발생합니다.
AttributeError: 'LogRecord' object has no attribute 'aws_request_id'
즉, 포매터에서 속성을 읽는 코드만 추가한다고 해서 요청 ID가 자동으로 생성되는 것은 아닙니다. 요청 ID를 주입하는 기능과 그 값을 출력하는 기능은 서로 구분해서 이해해야 합니다.
Request ID를 유지하면서 JSON 로그를 출력하는 방법
기존 Lambda 핸들러와 필터를 유지한 상태에서 포매터만 변경하면 런타임의 요청 추적 기능을 보존하면서 로그 형식을 커스터마이징할 수 있습니다. 이는 기본 로깅 설정을 불필요하게 제거하지 않는 방법입니다.
기존 핸들러의 포매터를 교체하기
Lambda의 기본 TEXT 로그 형식에서는 다음과 비슷한 포맷 문자열을 확인할 수 있습니다.
[%(levelname)s] %(asctime)s.%(msecs)03dZ %(aws_request_id)s %(message)s
여기서 %(aws_request_id)s는 로그 레코드에 추가된 요청 ID를 출력하는 데 사용됩니다. 다만 실제 기본 포맷은 Lambda의 로그 형식 설정이나 런타임 버전에 따라 달라질 수 있습니다.
기존 핸들러를 제거하는 대신 등록된 핸들러의 포매터만 교체하면 다음과 같이 구현할 수 있습니다.
def get_logger(level: int = logging.INFO):
logger = logging.getLogger()
logger.setLevel(level)
for handler in logger.handlers:
handler.setFormatter(JsonFormatter())
return logger
이 방식은 새로운 핸들러를 무조건 추가하지 않으므로 중복 출력을 유발할 가능성을 줄여 줍니다. 동시에 기존 핸들러에 연결된 필터를 유지할 수 있어, 런타임에서 제공하는 요청 ID 주입 기능도 보존할 수 있습니다.
다만 모든 핸들러가 동일한 역할을 수행한다고 가정해서는 안 됩니다. 실제 환경에서는 핸들러 목록을 확인하고, 변경 대상이 Lambda의 기본 핸들러인지 검증하는 것이 안전합니다.
JSON 로그에 Request ID 포함하기
기존 필터가 aws_request_id를 정상적으로 주입한다면 사용자 정의 포매터에서 해당 값을 활용할 수 있습니다.
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
log_message = {
"level": record.levelname,
"request_id": getattr(record, "aws_request_id", None),
"timestamp": datetime.fromtimestamp(
record.created,
tz=timezone.utc,
).isoformat(timespec="milliseconds").replace("+00:00", "Z"),
"message": (
record.msg
if isinstance(record.msg, dict)
else record.getMessage()
),
"source": {
"file": record.pathname,
"function": record.funcName,
"line": record.lineno,
},
}
return json.dumps(log_message, ensure_ascii=False)
getattr()을 사용하면 해당 속성이 존재하지 않는 경우에도 포매터가 즉시 예외를 발생시키는 것을 방지할 수 있습니다. 다만 요청 ID가 None으로 기록된다면 이는 추적 정보가 정상적으로 연결되지 않았다는 신호일 수 있으므로, 런타임 설정과 필터 적용 여부를 별도로 확인해야 합니다.
또한 record.msg가 딕셔너리인 경우 위 코드는 해당 딕셔너리를 그대로 메시지로 사용합니다. 문자열 메시지에서는 record.getMessage()를 호출해 로깅 모듈의 문자열 포맷 처리를 반영합니다. 운영 환경에서 임의의 자료형을 모두 지원하려면 메시지 변환 규칙도 명확하게 정의하는 것이 좋습니다.
Lambda 실행 컨텍스트를 활용하는 대안
기존 필터를 유지하기 어려운 환경에서는 Lambda가 핸들러에 전달하는 context 객체를 활용해 요청 ID를 로그에 추가할 수 있습니다. 이 방법은 요청 ID를 애플리케이션 코드에서 명시적으로 관리하고 싶을 때 유용합니다.
context 객체에서 요청 ID 가져오기
Lambda 핸들러는 event와 context를 인자로 전달받습니다. context.aws_request_id를 이용하면 현재 호출의 요청 식별자를 가져올 수 있습니다.
import json
import logging
logger = logging.getLogger(__name__)
def lambda_handler(event, context):
request_id = context.aws_request_id
logger.info(
json.dumps(
{
"request_id": request_id,
"message": "Lambda function started",
},
ensure_ascii=False,
)
)
return {
"statusCode": 200,
"body": json.dumps({"status": "success"}),
}
이 방식은 런타임의 기본 필터가 아닌 핸들러에서 요청 ID를 가져오기 때문에, 사용자 정의 로깅 시스템을 별도로 구성할 때도 활용할 수 있습니다.
다만 모든 로그 호출부에서 요청 ID를 직접 추가하면 코드가 반복될 수 있습니다. 이를 줄이려면 데코레이터나 로깅 어댑터를 이용해 호출 컨텍스트를 일관되게 전달하는 구조를 고려할 수 있습니다.
예를 들어 일부 사용자 정의 로거 구현에서는 데코레이터를 통해 Lambda 컨텍스트를 주입하는 기능을 제공합니다. 관련 구현 사례는 Qiita의 AWS Lambda 로깅 관련 글에서도 참고할 수 있습니다. 다만 특정 라이브러리의 데코레이터 API는 사용하는 패키지의 실제 구현과 버전을 확인한 후 적용해야 합니다.
운영 환경에서 안전한 Lambda 로깅을 구성하는 방법
안정적인 로깅 시스템을 만들려면 출력 형식뿐 아니라 요청 추적, 예외 처리, 핸들러 중복 여부까지 함께 검증해야 합니다. 개발 환경에서 정상적으로 출력되는 코드라도 Lambda 런타임에서는 다른 결과가 나타날 수 있습니다.
구현 전에 확인할 항목
다음 항목을 점검하면 로깅 설정을 변경할 때 발생할 수 있는 문제를 줄일 수 있습니다.
- 핸들러 중복 등록 여부: 초기화 코드가 반복 실행될 때 동일한 핸들러가 계속 추가되는지 확인합니다.
- 기존 필터 유지 여부: 요청 ID 등 런타임에서 주입하는 속성이 사라지지 않는지 점검합니다.
- JSON 직렬화 가능 여부: 메시지에 날짜 객체나 사용자 정의 클래스처럼 기본 JSON 인코더가 처리하지 못하는 값이 포함되는지 확인합니다.
- 요청 ID의 유효성: 실제 Lambda 호출에서 aws_request_id가 정상적으로 기록되는지 검증합니다.
- 로그 형식 설정: Lambda의 TEXT 또는 JSON 로그 형식 설정과 사용자 정의 포매터가 충돌하지 않는지 확인합니다.
- 민감 정보 노출 방지: 인증 토큰, 비밀번호, 개인정보 등이 로그에 포함되지 않도록 필터링합니다.
특히 Lambda에서 JSON 로그 형식을 자체적으로 지원하는 환경이라면 사용자 정의 포매터를 반드시 구현해야 하는지 먼저 검토할 필요가 있습니다. 런타임이 제공하는 구조화된 로깅 기능을 활용하면 직접 관리해야 할 코드가 줄어들 수 있습니다.
최종적으로 선택할 로깅 전략
Lambda의 로깅을 커스터마이징할 때 가장 중요한 원칙은 기존 런타임 기능을 이해한 후 필요한 부분만 변경하는 것입니다.
단순히 로그 형식을 변경하려는 목적이라면 기존 핸들러의 포매터를 수정하는 방법부터 검토하는 것이 좋습니다. 반대로 핸들러를 완전히 교체해야 한다면 요청 ID를 비롯한 런타임 메타데이터가 어떤 경로로 전달되는지 확인하고, 필요한 정보를 context 객체 등을 통해 명시적으로 연결해야 합니다.
이러한 구조를 이해하면 중복 로그 문제와 aws_request_id 누락 문제를 각각 독립적으로 분석할 수 있습니다. 결과적으로 CloudWatch Logs Insights에서 검색하기 쉬운 JSON 로그를 구성하면서도, 장애 분석에 필요한 요청 추적 기능을 안정적으로 유지할 수 있습니다.