🔥 Handler와 실행 모델: 이벤트가 들어오면
강의 목차

콘솔에서 Lambda 함수를 처음 만들면 코드 편집기에 짧은 한 토막이 떠 있다. Python을 골랐다면 def lambda_handler(event, context): 한 줄과 return {'statusCode': 200, 'body': 'Hello'} 한 줄. Node.js를 골랐다면 export const handler = async (event, context) => { return { statusCode: 200, body: 'Hello' } }. 인자 이름은 마음대로 바꿔도 된다. 다만 위치는 정해져 있다. 첫 인자가 event, 둘째 인자가 context다.
그 두 인자만 보면 모든 호출이 똑같이 들어오는 것 같다. SDK로 직접 부르든, S3 업로드 알림이 들어오든, SQS 큐에 메시지가 쌓였든, 함수 입장에서는 어차피 (event, context) 한 쌍이 들어오고 한 번 빠져나갈 뿐이다. 그런데 같은 핸들러가 호출 방식에 따라 전혀 다른 라이프사이클을 산다. 응답 시간을 누가 기다리느냐, 실패하면 누가 다시 호출하느냐, 동시에 몇 개가 도느냐가 호출 방식마다 다르다. 나는 이 차이를 모르고 핸들러 한 줄을 똑같이 써서 운영 환경에 배포했다가 한 번 크게 데인 적이 있다. SQS 트리거에 await fetch() 결과만 보고 return했더니 같은 메시지가 세 번씩 들어왔다.
그래서 이 글에서는 핸들러 시그니처 자체보다도, 그 시그니처 뒤에서 호출 방식 셋이 어떻게 다르게 굴러가는지 짚는다. Lambda란 무엇인가: 서버리스의 경계에서 본 한 함수, 즉 15분 timeout과 stateless 추상 위에서 이 셋이 어떻게 다르게 동작하는지가 주제다.
핸들러 시그니처: event, context, 반환값
언어마다 형식이 조금씩 다르지만 공식 문서가 요구하는 골격은 같다. 입력 인자 두 개, 즉 event 객체 하나와 context 객체 하나, 그리고 반환값 한 개다. 그 골격 위에 언어별 컨벤션이 따라온다.
Python 핸들러는 def lambda_handler(event, context) 처럼 두 인자를 받는 일반 함수다. 반환값은 json.dumps()로 직렬화 가능한 자료형(dict, list, 숫자, 문자열, bool, None) 중 하나. Node.js는 두 갈래다. async 함수로 짜면 async (event, context) => {...} 형식이 되고, 콜백 스타일로 짜면 (event, context, callback) => callback(null, result) 형식이 된다. async 쪽이 표준이고 공식 가이드도 이쪽을 권한다.
def lambda_handler(event, context):
print(f"request_id={context.aws_request_id}")
print(f"remaining_ms={context.get_remaining_time_in_millis()}")
return {"statusCode": 200, "body": event.get("body", "")}def lambda_handler(event, context):
print(f"request_id={context.aws_request_id}")
print(f"remaining_ms={context.get_remaining_time_in_millis()}")
return {"statusCode": 200, "body": event.get("body", "")}export const handler = async (event, context) => {
console.log(`request_id=${context.awsRequestId}`);
console.log(`remaining_ms=${context.getRemainingTimeInMillis()}`);
return { statusCode: 200, body: event.body ?? "" };
};export const handler = async (event, context) => {
console.log(`request_id=${context.awsRequestId}`);
console.log(`remaining_ms=${context.getRemainingTimeInMillis()}`);
return { statusCode: 200, body: event.body ?? "" };
};자바·고·러스트도 같은 골격을 변주한다. Java는 RequestHandler<I, O> 인터페이스를 구현해 handleRequest(I event, Context context)를 채우고, Go는 두 인자 시그니처일 때 첫 인자를 반드시 context.Context로 적어야 한다(언어 표준 라이브러리의 컨벤션을 따르느라 인자 순서가 반대다). 같은 골격이라는 점만 잡고 가면 된다. 어떤 언어로 써도 두 인자에 들어오는 의미는 동일하다. event는 트리거가 채우고, context는 Lambda 런타임이 직접 만든다.
event: 누가 채워서 보내는가
event 인자에 들어오는 JSON은 호출한 쪽이 결정한다. 핸들러는 그 구조를 받아서 처리할 뿐이다. 그래서 트리거를 바꾸면 같은 함수의 event 구조가 통째로 바뀐다.

API Gateway의 REST/HTTP API가 호출하면 event는 proxy event envelope이라는 정해진 형식으로 들어온다. httpMethod, path, queryStringParameters, headers, body(문자열), isBase64Encoded(boolean), requestContext(요청자 IP·인증 결과 등) 같은 키들을 API Gateway가 직접 만들어 넣는다. 같은 함수에 S3 업로드 알림이 트리거로 걸리면 envelope이 완전히 다르다. Records[] 배열이 최상위에 오고, 각 레코드 안에 s3.bucket.name, s3.object.key, eventName(ObjectCreated:Put 같은 이벤트 종류)이 들어 있다. 같은 핸들러가 event["Records"]를 가정했는데 API Gateway 트리거를 거쳐 들어오면 런타임이 즉시 KeyError를 띄운다.
직접 SDK로 부르면 더 단순하다. boto3의 lambda.invoke(FunctionName=..., Payload=b'{"x": 1}')을 호출하면 그 JSON 본문이 그대로 event 인자에 들어온다. envelope 같은 건 없다. 트리거가 아니라 SDK 클라이언트가 호출자라서, 클라이언트가 보낸 그대로의 구조가 도착한다. 이 차이가 흐릿해서 같은 함수를 콘솔 테스트 이벤트로는 통과시키고 API Gateway 뒤에 붙이면 깨지는 경우가 종종 발생한다. 콘솔 테스트 이벤트는 trigger envelope을 흉내만 낸 샘플 JSON이고, 실제 호출에서는 트리거가 envelope을 다시 만들어 끼워 넣는다.
요약하면 event 안에 무엇이 들어 있는지는 함수의 책임이 아니라 트리거의 책임이다. 함수 작성자는 어떤 트리거를 붙일 계획인가에 맞춰 envelope 스키마를 먼저 알아야 핸들러 본문이 깨지지 않는다.
context: 함수의 자기 인식
event가 바깥에서 들어온 데이터라면, context는 지금 도는 한 호출 자체에 대한 메타데이터다. 트리거가 누구든 상관없이 Lambda 런타임이 모든 호출에 같은 형식으로 직접 만들어 넣는다. Python 컨텍스트 가이드와 Node.js 컨텍스트 가이드가 같은 필드를 다른 표기로 쓴다.
자주 쓰는 항목을 추리면 다음과 같다.
aws_request_id(Node.js:awsRequestId): 이 한 호출의 고유 ID. 로그에 같이 적어 두면 CloudWatch에서 한 invoke를 추적할 수 있다.function_name,function_version,invoked_function_arn: 자기 자신이 누구인지. version에는 alias로 호출됐을 때 실제로 도는 numeric version이 들어와서 카나리 배포 디버깅에 쓴다.memory_limit_in_mb: 자기 함수에 할당된 메모리. 코드 안에서 worker 풀 크기를 메모리에 맞춰 동적으로 줄이는 곳에 쓴다.log_group_name,log_stream_name: 이 인스턴스가 쓰고 있는 CloudWatch Logs 위치. 외부 시스템에 로그 위치를 알려 줄 때 참고한다.identity: 모바일 앱 SDK(Cognito Identity)로 호출됐을 때 사용자 식별자.client_context: 모바일/IoT 클라이언트가 보낸 컨텍스트 페이로드.get_remaining_time_in_millis()(Node.js:getRemainingTimeInMillis()): 지금 시점에 남은 실행 시간(ms). 함수 timeout이 30초로 잡혀 있고 호출 시작 후 22초가 흘렀으면 이 함수는8000을 돌려 준다.
마지막 항목인 남은 시간이 운영에서 가장 자주 쓴다. 외부 API 호출을 N번 반복하는 핸들러를 가정해 보자. 한 번에 평균 2초가 든다고 치고 timeout이 30초라면, 매 호출 직전에 if context.get_remaining_time_in_millis() < 3000: break을 걸어 두는 식으로 Lambda가 강제로 잘라 버리기 전에 우아하게 종료할 수 있다. timeout 직전에 freeze로 잘리면 진행 중이던 외부 트랜잭션이 어정쩡하게 남는데, 남은 시간을 보고 직접 끊으면 마무리 처리가 가능하다. context는 디버깅용 부속 정보가 아니라, 작업을 끝낼지 중간 결과를 남기고 빠질지를 결정할 때 직접 읽는 제어 입력이다.
context.identity는 User, Group, Role: 세 가지 주체의 차이에서 본 STS 임시 자격증명 흐름과 그대로 닿아 있다. Cognito가 발급한 임시 자격증명으로 invoke가 들어왔을 때 identity 안에 그 sub와 issuer가 들어 있다. 그래서 IAM Execution Role이 함수가 무엇을 할 수 있는가를 정한다면, context.identity는 지금 호출한 사용자가 누구인가를 핸들러 안에서 직접 들여다볼 수 있게 하는 통로다.
호출 방식 셋: 같은 핸들러, 다른 라이프사이클
같은 (event, context) 한 쌍을 받아도 호출 방식이 다르면 실행 환경 입장에서 일어나는 일이 다르다. 공식 문서는 이걸 세 갈래로 나눈다. 즉 동기(RequestResponse), 비동기(Event), 폴링(event source mapping)이다.

1) 동기 (RequestResponse)
호출자는 응답을 받을 때까지 대기한다. 함수가 return하기 전까지 호출 측 connection이 열려 있다. SDK로는 Invoke API를 부를 때 InvocationType=RequestResponse(기본값)로 설정하는 경로고, 트리거 쪽에서는 API Gateway·ALB·Lambda Function URL이 동기 호출을 만든다. 응답 페이로드 한도는 요청·응답 모두 6MB. 함수가 던진 JSON이 그대로 호출자에게 돌아간다.
이 방식의 함정은 콜드스타트가 그대로 사용자 SLA에 영향을 준다는 점이다. API Gateway 뒤에 붙은 함수에 최근 5분 동안 호출이 한 번도 들어오지 않았다면 첫 호출은 freeze된 컨테이너가 아닌 새 컨테이너에서 출발한다. Init 단계가 끝날 때까지 호출자는 빈 화면을 본다. 사용자가 즉시 응답을 봐야 하는 화면(검색·로그인·결제 시작)에서 콜드스타트 0.8초가 SLA를 흔드는 곳이 바로 동기 호출 경로다. 그래서 핫 패스에 동기 Lambda를 깔 때는 Provisioned Concurrency로 인스턴스를 미리 깨워 두거나, Lambda란 무엇인가: 서버리스의 경계에서 본 SnapStart로 init 비용을 잘라 두는 옵션을 함께 검토한다.
응답을 조각조각 흘려 보내는 변종이 하나 더 있다. Function URL 전용으로 공개된 response streaming인데, Node.js 관리형 런타임이 awslambda.streamifyResponse((event, responseStream, context) => ...) 형식으로 작성하는 길을 직접 제공한다. 한 번에 6MB가 아니라 기본 20MB(소프트 한도, 인상 가능)까지 흘릴 수 있어서 LLM 응답·대용량 CSV·실시간 진행 상태 같은 곳에 쓴다. 다만 streaming은 Node.js 관리형 런타임만 직접 지원하고, Python 같은 다른 런타임은 custom runtime extensions API를 통해야 한다는 제약이 있다.
2) 비동기 (Event)
호출자가 응답을 기다리지 않는다. SDK로는 Invoke를 부를 때 InvocationType=Event로 보내는 경로고, 서비스 트리거 중 S3 EventNotification, SNS, EventBridge 규칙이 비동기로 함수를 부른다. 호출자에게는 202 Accepted가 즉시 떨어지고, 실제 함수 실행은 Lambda가 내부 큐에 넣어 두고 가져가서 돈다.
이 모델의 페이로드 한도는 1MB로 동기보다 좁다. 2025년 10월 전까지는 256KB였는데 10배 가까이 증가하는 변경이 풀려서 좀 더 풍부한 이벤트를 넣을 수 있게 됐다. 다만 첫 256KB까지가 1 request로 청구되고 그 이상은 64KB 단위로 추가 request 비용이 붙는다는 디테일이 같이 따라온다.

비동기 호출의 진짜 차이는 Lambda가 자동으로 retry를 수행한다는 점에 있다. 함수가 예외를 던지거나 timeout으로 잘리면 Lambda는 그 이벤트를 기본 2번 더 재시도한다. 첫 시도와 두 번째 사이 1분, 두 번째와 세 번째 사이 2분 간격으로. 재시도 횟수는 0~2 사이에서 조정할 수 있고(MaximumRetryAttempts), 이벤트가 큐에서 살아 있는 시간은 60초~6시간 사이로 잡을 수 있다(MaximumEventAgeInSeconds, 기본 6시간). 그래도 끝까지 실패하면 DLQ나 OnFailure destination으로 보낸다.
자동 retry가 편리한 만큼 핸들러 작성자가 idempotency 책임을 떠안는다. 같은 이벤트가 최대 3번 들어올 수 있으니, 결제 처리 같은 핸들러는 같은 결제 ID가 두 번째로 들어오면 두 번째 청구를 만들지 않게 자기 안에 dedup 키를 들고 있어야 한다. 내가 SQS 트리거에서 데인 사고도 정확히 이 패턴이었다. await fetch() 결과를 그대로 받아 return했는데, 함수 자체는 성공했지만 fetch 응답 코드가 5xx이라 호출 측 시스템 입장에서 처리 안 된 메시지가 됐고, 다음 retry에서 같은 외부 결제가 두 번 더 일어났다.
3) 폴링 (event source mapping)
세 번째는 Lambda가 호출자다. 정확히는 Lambda 서비스 안의 event source mapping이라는 리소스가 SQS·Kinesis·DynamoDB Streams·MSK/Kafka 같은 큐·스트림을 주기적으로 폴링해서, 메시지가 있으면 모아서 한 번에 핸들러를 부른다. 호출자 관점에서는 핸들러를 동기로 부르는 구조지만, 사용자 관점에서는 우리 함수가 큐를 듣고 있는 것처럼 동작한다.
폴링 방식의 핵심은 batch 단위 호출이다. SQS 표준 큐 기준으로 한 번 부를 때 1~10,000개 메시지가 한 invoke에 들어오고(다만 모든 메시지가 6MB 페이로드 한도 안에 들어가야 한다), 핸들러는 event["Records"]를 한 번에 처리한다. Kinesis·DDB Streams도 같은 batch 형식인데, 공식 문서는 한 가지 운영 룰을 분명히 적어 둔다. SQS 큐의 visibility timeout을 함수 timeout의 6배 이상으로 설정하라. 이유는 throttling이나 retry 사이에 같은 batch가 다른 컨테이너로 다시 분배될 시간을 줘야 하기 때문이다. 함수 timeout 30초·배치 윈도우 20초면 visibility timeout은 30 × 6 + 20 = 200초가 권장값.
batch 안의 일부 메시지만 실패하는 경우를 위해서는 ReportBatchItemFailures를 켜고 핸들러가 응답에 batchItemFailures: [{itemIdentifier: ...}]를 채워 보낸다. 그러면 Lambda가 그 메시지만 큐에 되돌리고 나머지는 정상 삭제한다. 이걸 안 켜고 한 메시지에서 예외만 던지면 batch 전체가 실패 처리되어 같은 메시지가 또 들어오는 패턴이 SQS+Lambda 운영의 단골 지뢰다.
세 갈래를 한 표로 비교하면 다음과 같다.
| 항목 | 동기 (RequestResponse) | 비동기 (Event) | 폴링 (Event source mapping) |
|---|---|---|---|
| 호출 트리거 예 | API Gateway, ALB, Function URL, SDK Invoke | S3 알림, SNS, EventBridge, SDK Invoke (Event) | SQS, Kinesis, DDB Streams, MSK |
| 호출자 응답 | 함수 결과 그대로 | 202 Accepted 즉시 | (호출자 = Lambda 자신) |
| 페이로드 한도 | 6MB request/response | 1MB (소프트, 2025-10 인상) | 6MB batch 합계 |
| 자동 retry | 없음 (호출자 책임) | 기본 2회, 1·2분 간격 | Stream/Queue별 다름 |
| 실패 처리 | 호출자가 캐치 | DLQ / OnFailure destination | DLQ / Bisect / ReportBatchItemFailures |
| 동시성 모델 | 호출자가 만든 만큼 burst | Lambda 내부 큐가 흡수 후 처리 | Mapping 폴러가 batch 단위로 생성 |
호출 방식이 핸들러 코드에 강제하는 것
같은 (event, context) 시그니처를 쓰지만, 호출 방식에 따라 핸들러 안에 들어가야 하는 코드 구조가 다르다.
동기 방식은 반환 값이 곧 응답이다. API Gateway 뒤라면 {statusCode, headers, body, isBase64Encoded} 형식으로 정확히 맞춰서 돌려줘야 게이트웨이가 HTTP 응답으로 풀 수 있다. timeout이 사용자 SLA를 결정하므로 context.get_remaining_time_in_millis()로 외부 호출을 직접 끊어 주는 코드가 들어간다.
비동기 방식은 반환 값을 아무도 안 본다. return None 해도 호출 측에서 못 본다. 대신 핸들러 안에 idempotency 처리가 한 줄 들어가야 한다. 같은 결제 ID가 두 번째로 들어오면 dedup 테이블을 보고 즉시 return하는 식이다. 그리고 retry 정책을 함수 자체가 들고 가지 말아야 한다. Lambda가 재시도해 줄 텐데 함수 안에서 또 for retry in range(3)을 돌면 3 × 3 = 9회 호출이 나갈 수 있다.
폴링 방식은 batch 처리가 들어간다. event["Records"]를 돌면서 한 메시지가 실패해도 batch 전체를 깨지 않게 try/except로 모아서 batchItemFailures에 넣는 코드가 표준 패턴이다. 그리고 ReportBatchItemFailures를 event source mapping 설정에서도 켜 둬야 핸들러의 응답 형식이 의미를 갖는다. 코드와 인프라 설정이 짝이 맞아야 동작한다.
세 방식이 같은 시그니처를 공유한다는 점이 처음에는 편리해 보이지만, 운영에 올라간 다음에는 호출 방식을 바꿀 때마다 핸들러 안의 idempotency, 반환값, batch 처리 세 부분을 같이 갈아엎어야 한다. 같은 함수로 동기와 비동기를 동시에 받겠다고 만들면, 두 방식의 가정이 충돌해서 어느 쪽도 깔끔하지 않다. 함수 한 개에는 호출 방식 한 가지를 일찍 고정해 두는 편이 운영 비용을 절약한다.
동시성과 빌링: 같은 함수가 동시에 몇 개나 도는가
호출 방식이 다르면 같은 핸들러가 동시에 도는 인스턴스 수도 다르다. Lambda 동시성 모델을 한 문장으로 옮기면 이렇다. 지금 동시에 돌고 있는 invoke 수의 합이 그 함수의 concurrency다.
기본값은 계정·리전 단위 1,000 동시성이다. 한 리전 안의 모든 Lambda 함수가 이 1,000을 나눠 쓴다. 한 함수가 burst로 1,200개 호출을 한꺼번에 받으면 1,000은 처리되고 200은 throttling으로 잘려 호출자에게 429가 돌아간다(동기), 혹은 retry 큐로 들어간다(비동기·폴링). 1,000이 부족하면 AWS Support로 인상 신청을 할 수 있는 소프트 한도다.

특정 함수가 다른 함수의 동시성을 침범하지 않도록 Reserved Concurrency를 그 함수에 지정해 둘 수 있다. 예를 들어 결제 처리 함수에 200을 reserve하면 그 200은 다른 함수가 쓸 수 없는 동시에, 결제 함수도 200을 넘게 돌 수 없다. 즉 최대값이자 최소값이다. 0으로 지정하면 사실상 함수를 비활성화하는 효과가 나서 사고 차단 스위치로도 쓴다.
콜드스타트를 잘라 두고 싶으면 Provisioned Concurrency를 켠다. 미리 N개의 실행 환경을 init까지 끝낸 채 띄워 두고, 그 N개가 호출을 즉시 받게 한다. Reserved와 달리 추가 비용이 든다. 띄워 둔 시간만큼 AWS가 GB-초 요금을 호출자 계정에 부과한다. 100만 호출이 띄엄띄엄 들어오는 환경에서는 콜드스타트가 한 번씩 나도 사용자 경험에 큰 영향이 없으니 안 켜는 편이 싸고, 트래픽이 일정하고 P99 응답 시간이 SLA에 묶여 있는 워크로드(주가 차트 API, 검색 자동완성)에서는 켜는 편이 답이다. 한 계정의 모든 함수가 동시에 켤 수 있는 Provisioned Concurrency 합은 (unreserved account concurrency − 100)까지로 묶여 있어서, 1,000 한도라면 한 함수에 최대 900까지 reserve할 수 있다.
그래서 핸들러에 무엇을 적지 말아야 하나
세 호출 방식을 한 함수가 다 받게 짜지 말 것. 동기·비동기·폴링은 idempotency 가정과 retry 가정이 다른데, 한 함수에 다 적어 두면 실수로 결제가 두 번 일어나거나 batch가 통째로 날아가는 사고가 생긴다.
핸들러 안에서 retry 루프를 돌리지 말 것. 비동기와 폴링은 Lambda가 retry를 가져간다. 핸들러 안에 또 retry를 두면 N × M번 외부 시스템에 부담을 준다. 외부 호출 한 번 실패하면 예외를 그대로 던지고 Lambda의 retry에 맡기는 편이 일관성 있다.
핸들러 바깥(전역)에 호출 사이에 살아 있어야 의미가 있는 상태를 두지 말 것. 같은 컨테이너를 Lambda가 freeze↔thaw로 다시 활용한다는 점은 Lambda란 무엇인가: 서버리스의 경계에서 본 구조지만, 그 재활용은 우연이다. 한 호출의 결과를 다음 호출에서 읽어야 한다면 DynamoDB·ElastiCache 같은 외부 저장소가 답이지 핸들러 바깥 변수가 아니다. 캐시 힌트로는 써도 좋다. 다음 호출에서 그 변수가 살아 있으면 운이 좋은 거고, 비어 있어도 핸들러가 정상 동작해야 한다.
마지막으로, event 구조를 함수가 가정하지 말 것. 같은 함수에 다른 트리거를 붙이면 envelope이 통째로 바뀌므로, 핸들러 첫 줄에 if "Records" in event 같은 분기를 두는 식으로 호출자별 가드를 만들어 두는 편이 운영 사고가 줄어든다. 더 깔끔한 길은 트리거당 함수 한 개씩 두는 길이다. Lambda 함수 자체는 무료(코드만 들어 있고 호출이 없으면 비용 0)이므로, 작은 함수 여러 개를 두는 비용보다 envelope이 흐릿해진 함수 하나를 디버깅하는 비용이 훨씬 비싸다.
호출 방식을 먼저 고정하고 나면 다음으로 짚을 것은 이 핸들러가 어떤 IAM 권한 집합을 요구하는가다. 트리거별 이벤트 구조와 함수에 필요한 권한 범위는 이어지는 글에서 함께 설명한다.
참고 자료
- Define Lambda function handler in Python: 시그니처와 반환값 직렬화 룰
- Using the Lambda context object to retrieve Python function information: context 필드와 메서드 일람
- Understanding Lambda function invocation methods: 동기·비동기·폴링 방식 정의
- Invoking a Lambda function asynchronously: 비동기 retry·DLQ·destinations
- How Lambda processes records from stream and queue-based event sources: event source mapping 동작
- AWS Lambda increases async payload to 1 MB (2025-10): async 페이로드 한도 변경
- Understanding Lambda function scaling: 1,000 기본 한도와 Reserved/Provisioned 차이
- Configuring response streaming: Function URL streaming 시그니처와 한도











