🔥 동시성과 Reserved Concurrency: 폭주를 막는 법
강의 목차

새벽 5시 12분에 PagerDuty 알림이 한 번 떴다. 알림은 한 줄이었다. OrderProcessor 함수의 Throttles 메트릭이 5분간 Sum 4,712건. 같은 그래프 위에 ConcurrentExecutions가 1,000에 닿았다 내려간 봉우리 일곱 개가 있었다. 처음에는 OrderProcessor가 너무 바쁜 줄 알았다. 콘솔을 한 단계 더 짚어 보니 그 함수의 동시 실행 평균은 220이었다. 1,000을 채운 건 같은 계정의 다른 함수다. 1시간에 한 번 도는 보고서 생성 함수가 for 루프 안에서 자기 자신을 비동기로 부르고 있었다. 그날 처음으로 함수 하나의 폭주가 다른 함수의 응답 화면에 어떻게 닿는지 진지하게 짚었다.
Handler와 실행 모델: 이벤트가 들어오면에서 동시성을 동시에 도는 invoke 수의 합으로, 그리고 계정·리전 1,000 기본 한도와 Reserved Concurrency·Provisioned Concurrency 두 손잡이를 한 줄씩 적어 두었다. 이 글은 그 위에 운영 한 단계를 더한다. 1,000이라는 숫자가 어디서 와서 어디서 사용자에게 닿는지, Reserved Concurrency라는 한 손잡이가 왜 최대값이자 최소값이라는 두 얼굴을 한꺼번에 갖는지, 그리고 그 손잡이를 0으로 돌렸을 때 무엇이 일어나는지를 짚는다.
1,000이라는 천장은 한 곳이 아니다
AWS 공식 문구를 그대로 옮기면 이렇다. "By default, Lambda provides your account with a total concurrency limit of 1,000 concurrent executions across all functions in an AWS Region." 한 계정, 한 리전, 모든 함수의 동시 실행 합계. 이게 첫째 천장이다. 1,000은 소프트 한도라 Service Quotas 콘솔에서 인상 신청을 할 수 있다 (Concurrent executions 쿼터). 그래서 1,000을 상수로 외우면 위험하다. 어느 계정은 5,000일 수도 있고 어느 계정은 10,000일 수도 있다. 한편 AWS Lambda 쿼터 문서에 따르면 새로 만든 계정은 기본 1,000보다 낮은 동시 실행 한도와 메모리 한도로 시작하고, 사용량에 따라 AWS가 자동으로 한도를 키운다. 1,000은 오래된 계정의 default에 가깝지 모든 새 계정의 출발점이 아니다.
둘째 천장은 함수당 ramp 속도다. 같은 1,000을 한 함수가 한꺼번에 차지할 수 있느냐는 별개의 질문이다. AWS는 2023년 11월 26일부터 12월 중순에 걸쳐 (AWS Lambda functions now scale 12 times faster) 함수당 스케일링 모델을 새로 도입했다. 새 모델 한 줄은 이렇다. "In each AWS Region, and for each function, your concurrency scaling rate is 1,000 execution environment instances every 10 seconds (or 10,000 requests per second every 10 seconds)." 동기 호출(API Gateway 같은 RequestResponse 트래픽)을 받는 함수가 10초마다 1,000개씩 새 실행 환경을 만들 수 있다. 즉 1분이면 6,000까지 갈 수 있다. 그 전에는 계정 단위로 첫 1분에 500–3,000(리전별로 다름) burst 후 매 분 +500이라는 한 갈래만 있었다. AWS는 그 모델을 거두었다. 지금은 함수마다 독립적으로 ramp가 진행되고, 합계가 계정 천장 ①에 닿는 시점에서 throttle이 동작한다.
두 천장이 어떻게 만나는지 한 사례로 짚는다. 계정 한도 1,000, 함수 두 개. 함수 A가 갑자기 트래픽 1,500 RPS를 받는다. 첫 10초에 A의 동시 실행은 0 → 1,000까지 ramp가 가능하지만, 같은 리전에 함수 B도 200 동시로 돌고 있다면 A는 800까지만 ramp되고 나머지를 Lambda가 throttle한다. ramp 속도와 계정 천장 중 먼저 닿는 쪽이 사용자 화면에 나타난다. 거의 모든 사고는 두 번째 경우에서 발생한다. 한 함수의 ramp는 멀쩡한데 계정 풀이 비어 있는 케이스다.

Throttling이 사용자에게 닿는 방식은 호출 방식마다 다르다
Handler와 실행 모델: 이벤트가 들어오면에서 호출 방식 셋(동기·비동기·폴링)이 핸들러는 같지만 라이프사이클은 다르다고 적어 두었다. Throttling이 그 차이를 가장 노골적으로 드러낸다.
동기 호출이 throttle 처리되면 caller가 즉시 429 TooManyRequestsException을 받는다. AWS 공식 문구: "Throttled requests and other invocation errors don't count as either Invocations or Errors." 이 한 줄이 무겁다. Errors 메트릭 알람만 깔아 두면 throttle은 조용히 새는 신호가 된다. API Gateway 뒤에 붙은 함수가 throttle 처리되면 사용자는 그 화면에서 5xx를 마주한다. retry는 호출자 책임이다. 나는 처음 throttle 사고를 잡을 때 Errors 그래프만 보고 "이상 없음"으로 닫았다가 같은 날 오후에 같은 알림을 또 받았다. 그날 이후로 모든 함수에 Throttles 알람을 따로 깐다.
비동기 호출은 다르게 동작한다. InvocationType: Event로 들어온 호출은 Lambda 내부 큐가 한 번 흡수한다. caller는 이미 202 Accepted를 받고 떠났다. 그 다음에 일이 어떻게 처리되느냐는 에러의 종류에 따라 두 갈래로 나뉜다. AWS 공식 문구: "For throttling errors (429) and system errors (500-series), Lambda returns the event to the queue and attempts to run the function again for up to 6 hours by default. The retry interval increases exponentially from 1 second after the first attempt to a maximum of 5 minutes." 즉 throttle/시스템 에러는 6시간 동안 1초→5분 exp backoff를 적용한다. 함수 코드가 던진 에러는 별도 정책으로 처리한다. MaximumRetryAttempts 기본 2회(1분 후, 그리고 다시 2분 후) 이후 폐기다. 두 정책이 한 함수 안에 같이 있다.
MaximumEventAgeInSeconds(기본 21,600초 = 6시간)를 지나면 어느 갈래든 Lambda가 이벤트를 폐기한다. 사라지는 메시지를 잡으려면 DLQ나 on-failure destination을 따로 둬야 한다. 사용자가 보는 건 늦게 처리된 결과나 6시간 후 폐기된 메시지다.
폴링(event source mapping)은 또 다른 형식이다. SQS 트리거 함수를 Lambda가 throttle하면 메시지는 큐에 그대로 남는다. VisibilityTimeout이 지나면 다시 보이고, 다시 폴링이 발생하고, 다시 throttle이 발생하는 cycle을 만든다. caller(=Lambda 폴러)가 retry를 하니까 사용자는 lag만 마주한다. 큐의 ApproximateAgeOfOldestMessage가 천천히 자라는 신호가 거기서 나온다. 메트릭으로는 함수의 Throttles(Sum)와 큐의 ApproximateAgeOfOldestMessage(Maximum)를 같이 봐야 어디서 막혔는지 알아낼 수 있다.

Reserved Concurrency는 두 얼굴이다
AWS 공식 문구를 그대로 가져온다. "Reserved concurrency sets the maximum and minimum number of concurrent instances that you want to allocate to your function. When you dedicate reserved concurrency to a function, no other function can use that concurrency." 한 함수에 Reserved를 200으로 설정했다는 건 최대 200까지만 도는 동시에 항상 200까지는 AWS가 그 함수에 묶어 둔다는 뜻이다. max이자 min. 이 두 얼굴을 따로따로 짚는다.
첫째 얼굴, 격벽으로서. 같은 계정의 다른 함수가 폭주해 계정 풀 1,000을 다 차지해도 Reserved 200이 설정된 내 함수는 항상 200까지 슬롯을 확보한다. 새벽 5시의 그 사고에서 OrderProcessor가 throttle을 받은 이유가 정확히 이 대목이다. Reserved를 안 설정해 둔 함수는 옆 함수의 폭주에 그대로 영향을 받는다. 그 사고 이후 결제·주문 같은 핵심 함수에는 Reserved 200을 깔았다. 무료다. "Configuring reserved concurrency for a function incurs no additional charges."
둘째 얼굴, 셔터로서. 같은 200이라는 숫자가 최대값도 된다. 함수가 Reserved 200으로 묶여 있으면 호출이 1,000개 들어와도 동시 실행은 200을 넘지 않고, 나머지를 Lambda가 throttle한다. 이걸 limiter로 쓰는 경우가 있다. 다운스트림에 RDS 같은 연결 수에 약한 자원이 있을 때, Lambda가 한꺼번에 1,000개씩 connection을 만들면 RDS의 max_connections가 먼저 한도에 부딪힌다. Lambda 쪽 Reserved 50을 깔면 동시 connection을 50으로 묶어 둘 수 있다. RDS Proxy로 한 번 더 흡수하는 게 정석이지만, 그 전 단계에서도 Reserved가 튼튼한 셔터다.
같은 200이 한쪽에서는 지키는 격벽이고 다른 쪽에서는 조이는 셔터다. 이 모순이 Reserved를 처음 봤을 때 머릿속이 복잡한 이유다. 최대냐 최소냐를 물으면 답이 안 나온다. 둘 다, 같은 한도 안에서.

Reserved=0은 함수의 kill switch다
가장 인상 깊었던 운영 패턴이다. AWS 문구: "To intentionally throttle a function, set its reserved concurrency to 0. This stops your function from processing any events until you remove the limit." 0은 최대 0개의 동시 실행이라는 뜻이라 함수는 모든 호출을 throttle 처리한다. 즉 한 줄 명령으로 함수를 살아 있는 채로 비활성화한다. aws lambda put-function-concurrency --function-name OrderProcessor --reserved-concurrent-executions 0. 명령 한 줄에 그 함수의 모든 호출이 멈춘다.
언제 쓰느냐. 운영 중인 비동기 처리 함수가 외부 API를 잘못 호출해 돈을 쏟고 있다는 알림이 떴을 때, 코드 수정과 배포에는 5–10분이 든다. 그 사이 매분 청구서가 자란다. Reserved=0을 설정하면 즉시 멈춘다. 다만 비동기 함수에서 Reserved=0의 데이터 흐름은 한 번 더 짚을 대목이다. AWS 공식 문구로는 비동기 함수가 Reserved=0인 동안 들어오는 새 이벤트는 retry 없이 즉시 DLQ나 on-failure destination으로 흘러간다 (Retaining async invocation records). 즉 DLQ를 미리 깔아 두지 않으면 그 사이 들어온 메시지는 회수할 수 없다. 운영 사고 시 Reserved=0을 박기 전에 그 함수에 DLQ가 붙어 있는지 한 번 확인하는 게 정석이다.
다만 같은 손잡이가 위험한 경우도 있다. 동기 호출 함수에 Reserved=0을 설정하면 사용자는 그 화면에서 즉시 throttle 응답(API Gateway 등 통합 트리거를 거치면 일반적으로 5xx)을 마주한다. 점검 모드 페이지로 라우팅을 돌릴 게 아니면 함부로 쓰면 안 된다. 비동기 함수 중지에는 DLQ 확인이 짝이고, 동기 함수 중지에는 사용자 경험 대비가 짝이라는 두 갈래로 외워 둔다.
100이라는 숨은 buffer가 있다
Reserved를 함수마다 짙게 깔다 보면 묘한 한도에 부딪힌다. AWS 문구: "You can reserve up to the Unreserved account concurrency value minus 100. The remaining 100 units of concurrency are for functions that aren't using reserved concurrency." 즉 계정 한도가 1,000이라면 모든 함수의 Reserved 합계는 900을 넘을 수 없다. 100은 항상 Reserved를 안 설정한 함수들을 위해 남겨 둔다.
수식으로 풀면 이렇다. unreserved_account = account_limit − sum(reserved_per_function). 그리고 unreserved_account ≥ 100 조건이 항상 성립해야 한다. 계정 1,000에 함수 다섯 개에 각각 Reserved 200을 박으려고 하면 합계 1,000이라 마지막 함수의 200 설정을 거부한다. PutFunctionConcurrency가 InvalidParameterValueException을 출력한다. 가장 큰 reserved 묶음을 180으로 줄이거나, Service Quotas로 계정 한도를 1,100으로 인상해야 한다.
이 100이 왜 있는가. AWS 입장에서는 새로 배포된 함수가 throttle 처리되지 않게 막아 두는 안전판이다. 운영 중에 새 함수를 배포하고 트래픽 0에서 1로 올라갈 때 다른 모든 함수의 Reserved 때문에 throttle이 발생하면 첫인상부터 사고다. 100은 그 일이 안 일어나게 막는 공용 마진이다. 이 마진을 의식하면 Reserved를 100% 다 채우려 하지 않게 된다. 함수 5개에 200씩 까는 대신 진짜 보호가 필요한 둘에만 200씩, 나머지는 풀린 상태로 두는 패턴이 자연스럽다.
Provisioned Concurrency와는 어디가 다른가
이름이 비슷해 자주 혼동한다. Cold Start: 왜 첫 호출이 느린가에서 두 손잡이를 분리해 다뤘지만, 동시성 맥락에서 차이만 다시 짚는다. Reserved는 용량 한도다. 함수가 받을 수 있는 동시 실행 수의 최대이자 최소. 추가 비용 없음. Provisioned는 pre-warm이다. N개의 실행 환경을 INIT까지 끝낸 채로 보관. GB-초당 추가 비용이 든다. Reserved는 사고 예방이고 Provisioned는 지연 예방이다. 두 개를 같이 쓰는 게 가능하고 자주 쓴다. Reserved 500 위에 Provisioned 100을 얹으면 최대 500까지 받되 그 중 100은 항상 데워 둔 상태가 함께 따라온다.
다만 한 함수에 Reserved를 너무 짙게 깔면 같은 함수의 Provisioned 한도도 줄어든다. Provisioned는 함수당 최대 unreserved_account − 100 (기본 1,000이면 최대 900)에서 출발하고, Reserved를 설정한 함수는 그 Reserved 안에서만 Provisioned를 잡을 수 있다. Reserved 200 함수에 Provisioned 200을 설정하는 건 가능하지만 Provisioned 250을 설정하면 AWS가 거부한다. 두 손잡이가 물리적으로 묶여 있는 한 단계다.
CloudWatch에서 무엇을 봐야 하는가
운영에서 가장 자주 살피는 지표 셋. Throttles(Sum)은 한 분 동안 throttle 처리된 호출 수다. ConcurrentExecutions(Maximum)은 한 분 동안 가장 높았던 동시 실행 수다. ClaimedAccountConcurrency(Maximum)은 AWS 공식 정의로 "UnreservedConcurrentExecutions plus the amount of allocated concurrency". 즉 비예약 함수의 현재 사용량과 모든 함수에 할당된 reserved/provisioned 묶음의 합으로, 계정 풀이 얼마나 차 있는지를 한 값으로 보여준다 (Monitoring concurrency).
처음 throttle 사고를 추적할 때 함수당 ConcurrentExecutions만 보면 답이 안 나온다. 한 함수가 200까지 도는데도 throttle이 발생한다면 그 함수가 reserved 200으로 묶여 한도에 닿았을 수도, 아니면 계정 풀이 다 차서 그 함수에는 슬롯이 안 남았을 수도 있다. 두 경우는 처방이 다르다. Reserved 한도면 그 함수의 Reserved를 올려야 하고, 계정 풀 고갈이면 옆 함수가 폭주한 곳을 찾아 막아야 한다. ClaimedAccountConcurrency가 account_limit에 가까운지 한 번 살피면 어느 쪽인지 즉시 구분할 수 있다.
알람을 깔 때 Throttles는 0보다 큰가를 묻는 걸 default로 둔다. throttle은 0이 정상이다. 1이라도 발생하면 사용자에게 닿는 신호이고, 1이 5분 동안 1,000으로 자라면 사고다. Errors 알람만 깔면 앞에서 본 한 줄, 즉 Throttles는 Errors에 안 들어간다는 사실 때문에 그대로 새는 신호가 남는다.
다음 편으로 넘어가기 전에
운영 첫 한 달에 가장 많이 깨지는 곳은 Reserved를 안 설정한 함수가 옆 함수의 폭주에 영향을 받는 케이스다. 핵심 함수에 Reserved 200 정도를 깔아 두면 PagerDuty 알림 횟수가 한 번 줄어든다. 무료고 한 줄 명령이라 미루지 말 것. 그리고 운영 사고 시 Reserved=0 명령을 비동기 함수에 한해서 외워 두면, 코드 수정 5분 사이 청구서가 자라는 사태를 즉시 막을 수 있다.
트리거 종류: API Gateway·SQS·EventBridge·S3에서는 호출 방식 셋이 각각 어떤 트리거에서 어떻게 들어오는지 짚는다. 동기 트리거에 Reserved를 깐 함수의 사용자 경험과, 비동기·폴링 트리거에 Reserved를 깐 함수의 큐 lag. 같은 손잡이의 두 얼굴이 트리거마다 다른 형식으로 나타난다.
참고 자료
- Understanding Lambda function scaling: 1,000 기본 한도, 100 unreserved 마진, 함수당 10초당 1,000 ramp 룰
- Configuring reserved concurrency for a function: Reserved 두 얼굴(max=min), Reserved=0 disable, 무료라는 진술
- AWS Lambda functions now scale 12 times faster when handling high-volume requests: 2023-11-26부터 함수당 독립 ramp로 전환된 시점
- How Lambda handles errors and retries with asynchronous invocation: 6시간 retry, 1초→5분 exponential backoff, MaximumRetryAttempts·MaximumEventAgeInSeconds 기본값
- Retaining records of asynchronous invocations: Reserved=0인 비동기 함수가 DLQ/on-failure destination으로 직행하는 동작
- Types of metrics for Lambda functions:
Throttles(Sum),ConcurrentExecutions(Max),ClaimedAccountConcurrency(Max) 정의 - PutFunctionConcurrency API / DeleteFunctionConcurrency API: Reserved 설정·해제 API









