
Serverless: 서버도 우버처럼 탄다 (AWS Lambda의 모든 것)
서버를 직접 사거나 관리하지 마세요. 코드가 실행되는 0.1초만큼만 돈을 내는 클라우드의 혁명. FaaS의 원리부터 Cold Start 문제 해결, 그리고 비용 절감 효과까지.

서버를 직접 사거나 관리하지 마세요. 코드가 실행되는 0.1초만큼만 돈을 내는 클라우드의 혁명. FaaS의 원리부터 Cold Start 문제 해결, 그리고 비용 절감 효과까지.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

이름부터 빠릅니다. 피벗(Pivot)을 기준으로 나누고 또 나누는 분할 정복 알고리즘. 왜 최악엔 느린데도 가장 많이 쓰일까요?

매번 3-Way Handshake 하느라 지쳤나요? 한 번 맺은 인연(TCP 연결)을 소중히 유지하는 법. HTTP 최적화의 기본.

"서버 관리 없이 코드만 올리면 된다?" Serverless라는 개념을 처음 들었을 때 반신반의했다.
EC2를 써본 사람이라면 안다. 인스턴스 타입 선택, CPU/메모리 설정, OS 패치, 오토 스케일링 설정... 코드보다 인프라 관리에 더 시간이 쏟아진다. 서버가 죽으면 직접 재부팅하고, 로그 뒤지고, 원인 찾아야 한다. 그게 싫어서 Serverless를 파보게 됐다.
"Serverless"라는 단어를 처음 들었을 때, 저는 완전히 잘못 이해했습니다. "서버가 없다고? 그럼 코드가 어디서 돌아가는데?" 라고 혼란스러웠죠. 나중에 알고 보니 "서버가 없다"가 아니라 "내가 서버를 관리하지 않는다"는 뜻이었습니다. 서버는 분명히 존재합니다. 다만 그 서버의 존재를 개발자가 의식하지 않아도 된다는 게 핵심이었습니다.
이 개념을 받아들이기까지 꽤 시간이 걸렸습니다. EC2를 써본 사람이라면 알겠지만, 우리는 항상 "인스턴스 타입", "CPU 코어 수", "메모리 용량", "OS 패치" 같은 것들을 신경 써야 했습니다. 근데 서버리스는 그런 걸 전부 AWS한테 맡깁니다. 개발자는 오직 "내 코드가 뭘 하는가"만 고민하면 됩니다.
이 개념이 확 와닿았던 건, 누군가가 해준 이 비유 덕분이었습니다.
이 비유를 듣자마자 무릎을 쳤습니다. "아, 그래서 트래픽이 들쭉날쭉한 서비스에 유리하구나." 매일 아침 9시에만 사용자가 몰리는 서비스라면, EC2는 24시간 내내 비싼 인스턴스를 켜놓고 있어야 하지만, Lambda는 9시에만 함수가 실행되고 나머지 시간엔 0원입니다.
서버리스의 핵심은 FaaS (Function as a Service)입니다. 거대한 모놀리식 서버를 띄워놓는 게 아니라, 작은 함수(Function) 하나하나를 클라우드에 올립니다.
함수는 가만히 있다가, 이벤트(Event)가 발생하면 깨어납니다. 마치 소방관처럼 평소엔 대기하다가 화재 신고가 들어오면 출동하는 겁니다.
"항상 켜져 있는 서버는 없다. 사건이 터지면 그제야 실행되고, 할 일 끝나면 즉시 사라진다."
이 철학을 이해했을 때, 제가 왜 서버 모니터링에 시달렸는지 알게 됐습니다. EC2는 항상 떠 있기 때문에 "죽었는지 살았는지" 감시해야 했지만, Lambda는 필요할 때만 태어나고 즉시 사라지니까 모니터링할 게 없습니다.
예를 들어 회원 가입 API를 만든다고 해보자. 처음엔 Express.js 서버를 EC2에 올리는 게 자연스러운 선택이다. 근데 가입 API는 하루에 몇 건 안 들어오는데, 24시간 서버를 켜놓으면 너무 비효율적이다. 이걸 Lambda로 바꾸면 이런 구조가 된다.
// Lambda Function: 회원 가입 처리
const AWS = require('aws-sdk');
const dynamodb = new AWS.DynamoDB.DocumentClient();
exports.handler = async (event) => {
const body = JSON.parse(event.body);
const { email, password, name } = body;
// 1. 이메일 중복 체크
const existingUser = await dynamodb.get({
TableName: 'Users',
Key: { email }
}).promise();
if (existingUser.Item) {
return {
statusCode: 400,
body: JSON.stringify({ error: '이미 존재하는 이메일입니다.' })
};
}
// 2. 비밀번호 해싱 (bcrypt)
const hashedPassword = await bcrypt.hash(password, 10);
// 3. DynamoDB에 저장
await dynamodb.put({
TableName: 'Users',
Item: {
email,
password: hashedPassword,
name,
createdAt: new Date().toISOString()
}
}).promise();
return {
statusCode: 201,
body: JSON.stringify({ message: '회원가입 성공' })
};
};
API Gateway 연결:
POST /signup → Lambda (signup 함수) → DynamoDB
트래픽이 적은 API에서는 이런 전환으로 비용이 크게 줄어든다고 한다. 월 $50 → $2 수준의 절감도 가능하다는 사례가 있다. 트래픽이 불규칙하고 적은 API에는 서버리스가 유리하다.
가장 클래식한 서버리스 예제입니다. 이걸 직접 만들어보면서 "아, 이게 서버리스구나" 하고 체감했습니다.
사용자가 프로필 사진을 올리면 자동으로 작은 썸네일을 만들어야 합니다.
const sharp = require('sharp');
const aws = require('aws-sdk');
const s3 = new aws.S3();
exports.handler = async (event) => {
// 1. 이벤트에서 파일 정보 추출
const bucket = event.Records[0].s3.bucket.name;
const key = event.Records[0].s3.object.key;
// 2. S3에서 원본 다운로드
const image = await s3.getObject({ Bucket: bucket, Key: key }).promise();
// 3. 리사이징 (메모리상에서 처리)
const resized = await sharp(image.Body).resize(200, 200).toBuffer();
// 4. 다시 S3에 저장
await s3.putObject({
Bucket: bucket,
Key: `thumbnails/${key}`,
Body: resized
}).promise();
};
결과:
이 코드를 처음 배포했을 때, 신기했던 게 서버를 띄운 적이 없다는 겁니다. 그냥 코드만 올렸는데, S3에 파일이 올라오면 자동으로 실행됐습니다. "아, 이게 이벤트 기반이구나" 하고 받아들였습니다.
처음엔 AWS Console에서 손으로 Lambda 함수를 만들었습니다. 근데 함수가 10개, 20개로 늘어나니까 지옥이었습니다. 그래서 Infrastructure as Code (IaC)를 도입했습니다.
AWS 공식 도구입니다. CloudFormation의 확장판이라고 생각하면 됩니다.
template.yaml:AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Resources:
ThumbnailFunction:
Type: AWS::Serverless::Function
Properties:
Handler: index.handler
Runtime: nodejs18.x
MemorySize: 512
Timeout: 60
Events:
S3Event:
Type: S3
Properties:
Bucket: !Ref ImageBucket
Events: s3:ObjectCreated:*
Filter:
S3Key:
Rules:
- Name: prefix
Value: uploads/
ImageBucket:
Type: AWS::S3::Bucket
배포:
sam build
sam deploy --guided
SAM을 쓰면서 정리해본 장점은 이겁니다.
sam local start-api로 테스트 가능.더 간결하고 멀티 클라우드를 지원합니다. (AWS, GCP, Azure 모두 가능).
serverless.yml:service: thumbnail-service
provider:
name: aws
runtime: nodejs18.x
region: ap-northeast-2
functions:
thumbnail:
handler: handler.thumbnail
events:
- s3:
bucket: my-image-bucket
event: s3:ObjectCreated:*
rules:
- prefix: uploads/
resources:
Resources:
ImageBucket:
Type: AWS::S3::Bucket
배포:
serverless deploy
저는 처음엔 SAM을 썼다가, 멀티 클라우드 지원이 필요해서 Serverless Framework로 갈아탔습니다. 둘 다 좋습니다. AWS만 쓴다면 SAM, 멀티 클라우드 고려하면 Serverless Framework가 와닿았습니다.
세상에 공짜는 없습니다. 서버리스의 최대 약점은 Cold Start입니다. 이 문제 때문에 한동안 머리를 쥐어뜯었습니다.
함수가 오랫동안 실행되지 않으면, AWS는 리소스 절약을 위해 컨테이너를 꺼버립니다(Freeze). 이 상태에서 요청이 오면:
이 과정이 0.5초 ~ 3초 정도 걸립니다. 사용자는 클릭하고 3초간 멍하니 있어야 합니다.
로그인 API를 Lambda로 만들면 새벽에 첫 로그인을 시도하는 사용자가 3초를 기다려야 한다. "서버 고장 났나?" 하고 새로고침을 누르게 되는 상황이다. Cold Start가 UX에 직접 영향을 준다는 걸 이해하는 순간이다.
"항상 1개는 켜놔"라고 돈 더 내고 설정합니다. EC2보다는 싸지만 0원은 아닙니다.
aws lambda put-provisioned-concurrency-config \
--function-name my-function \
--provisioned-concurrent-executions 1
이렇게 하면 항상 1개의 컨테이너가 warm 상태로 대기합니다. 비용은 시간당 약 $0.015 정도입니다. (월 $10 정도).
5분마다 한 번씩 함수를 찔러주는 봇을 돌려서 안 재우기.
// CloudWatch Events (EventBridge)로 5분마다 실행
exports.handler = async () => {
console.log('Keep-alive ping');
return 'OK';
};
이 방법은 무료지만, 완벽하진 않습니다. 컨테이너가 여러 개 떠 있으면 일부는 여전히 cold 상태일 수 있습니다.
Java는 JVM 로딩 때문에 매우 느립니다. Node.js나 Go, Python이 Cold Start에 유리합니다.
| 언어 | Cold Start 평균 |
|---|---|
| Node.js | ~200ms |
| Python | ~250ms |
| Go | ~150ms |
| Java | ~2000ms |
저는 이 표를 보고 Java를 포기하고 Node.js로 갈아탔습니다. 결국 언어 선택도 비용이구나 하고 이해했습니다.
Lambda 함수는 실행이 끝나면 메모리의 모든 것이 사라집니다. 이걸 모르고 처음에 엄청 삽질했습니다.
Bad (제가 실제로 짠 코드):let count = 0;
exports.handler = async () => {
count++; // 기대: 1, 2, 3... 실제: 매번 1일 수도 있음 (새 컨테이너면 0부터)
return count;
};
이 코드를 배포하고 테스트했을 때, 첫 요청은 1이 나왔고, 두 번째도 1이 나왔습니다. "뭐지?" 하고 당황했는데, 알고 보니 두 번째 요청은 새 컨테이너에서 실행된 거였습니다. Lambda는 매번 같은 컨테이너를 쓰지 않습니다.
Good (DynamoDB에 저장):const AWS = require('aws-sdk');
const dynamodb = new AWS.DynamoDB.DocumentClient();
exports.handler = async () => {
// 1. 현재 count 읽기
const result = await dynamodb.get({
TableName: 'Counter',
Key: { id: 'global' }
}).promise();
const currentCount = result.Item ? result.Item.count : 0;
// 2. +1 증가
await dynamodb.put({
TableName: 'Counter',
Item: { id: 'global', count: currentCount + 1 }
}).promise();
return currentCount + 1;
};
이제 매번 올바른 숫자가 나옵니다. 상태는 반드시 외부 저장소(DynamoDB, Redis, S3)에 저장해야 한다는 걸 뼈저리게 받아들였습니다.
함수 하나하나는 간단하지만, 여러 함수를 연결하려면 복잡해집니다. 예를 들어 "주문 처리" 워크플로우를 생각해봤다.
이걸 어떻게 연결할까요? 처음엔 Lambda 1에서 Lambda 2를 호출하고, Lambda 2에서 Lambda 3를 호출하고... 이런 식으로 짰습니다. 근데 이러면 에러 핸들링이 지옥입니다. Lambda 2에서 에러가 나면 어떻게 롤백할까요?
그래서 AWS Step Functions를 도입했습니다.
state machine 정의:{
"Comment": "주문 처리 워크플로우",
"StartAt": "ValidatePayment",
"States": {
"ValidatePayment": {
"Type": "Task",
"Resource": "arn:aws:lambda:ap-northeast-2:123456789012:function:validate-payment",
"Next": "CheckInventory",
"Catch": [{
"ErrorEquals": ["PaymentError"],
"Next": "PaymentFailed"
}]
},
"CheckInventory": {
"Type": "Task",
"Resource": "arn:aws:lambda:ap-northeast-2:123456789012:function:check-inventory",
"Next": "RequestShipping",
"Catch": [{
"ErrorEquals": ["OutOfStock"],
"Next": "RefundPayment"
}]
},
"RequestShipping": {
"Type": "Task",
"Resource": "arn:aws:lambda:ap-northeast-2:123456789012:function:request-shipping",
"Next": "SendEmail"
},
"SendEmail": {
"Type": "Task",
"Resource": "arn:aws:lambda:ap-northeast-2:123456789012:function:send-email",
"End": true
},
"PaymentFailed": {
"Type": "Fail",
"Error": "PaymentError",
"Cause": "결제 검증 실패"
},
"RefundPayment": {
"Type": "Task",
"Resource": "arn:aws:lambda:ap-northeast-2:123456789012:function:refund",
"Next": "OrderFailed"
},
"OrderFailed": {
"Type": "Fail",
"Error": "OrderError",
"Cause": "주문 처리 실패"
}
}
}
Step Functions를 쓰면 시각적으로 워크플로우를 볼 수 있고, 에러 발생 시 자동으로 롤백 로직을 태울 수 있습니다. 이걸 쓰면서 "아, 서버리스도 복잡한 로직을 다룰 수 있구나" 하고 정리해본 게 이겁니다.
서버리스는 "쓴 만큼 낸다"가 장점이지만, "쓰는 대로 다 낸다"가 단점이기도 합니다. 이 문제로 실제로 청구서 폭탄을 맞을 뻔했습니다.
실제로 이런 사례가 알려져 있다. 봇이 API를 초당 1,000번씩 호출하자, Lambda는 충실히 1,000개의 함수를 띄웠고 하루 만에 $500이 청구됐다. (평소 월 $10 정도 나오던 서비스였다고 한다.)
EC2였다면 서버가 뻗고 끝났을 텐데, Lambda는 "요청이 있으니 처리해야지!" 하고 무한정 확장했다. 이게 서버리스의 또 다른 얼굴이다.
API Gateway에서 속도 제한(Throttling)을 반드시 걸어야 합니다.
# serverless.yml
provider:
apiGateway:
throttle:
rateLimit: 100 # 초당 최대 100 요청
burstLimit: 200 # 버스트 최대 200 요청
이렇게 설정하고 나니 봇이 아무리 호출해도 초당 100개까지만 처리되고 나머지는 429 Too Many Requests를 받았습니다. 청구서도 정상으로 돌아왔습니다.
결국 이거였습니다. 서버리스는 무한 확장이 가능하지만, 그만큼 무한 과금도 가능하다. 반드시 제한을 걸어야 합니다.
"이미지 리사이징 API"를 EC2와 Lambda로 각각 운영했을 때 비용을 비교하면 이렇다.
이런 트래픽 패턴이라면 Lambda가 압도적으로 유리하다. 트래픽이 불규칙하고 피크가 몰리는 서비스일수록 서버리스 전환의 효과가 크다.
마지막으로 소개하고 싶은 게 Lambda@Edge입니다. 이건 CloudFront(CDN) 엣지 로케이션에서 Lambda를 실행하는 겁니다.
사용자가 웹사이트에 접속할 때, 50%는 A 버전, 50%는 B 버전을 보여주고 싶었습니다.
Lambda@Edge 코드:exports.handler = async (event) => {
const request = event.Records[0].cf.request;
const headers = request.headers;
// 쿠키에 variant가 없으면 랜덤 할당
if (!headers.cookie || !headers.cookie[0].value.includes('variant=')) {
const variant = Math.random() < 0.5 ? 'A' : 'B';
request.headers['x-variant'] = [{ key: 'X-Variant', value: variant }];
}
return request;
};
이 코드를 CloudFront에 연결하면, 오리진 서버에 도달하기 전에 엣지에서 처리됩니다. 레이턴시가 거의 없습니다.
와닿았던 건, "Lambda가 중앙 리전에만 있는 게 아니라 전 세계에 뿌려질 수 있구나" 하는 겁니다.
서버리스를 공부하고 직접 코드를 짜보면서 정리해본 결론은 이겁니다.
서버리스가 유리한 경우:하이브리드 접근이 현실적이다. API는 Lambda, WebSocket 채팅은 EC2, 데이터 처리는 Lambda, ML 학습은 EC2. 이렇게 섞어 쓰는 구조가 비용과 성능 양쪽에서 유리하다고 알려져 있다.
결국 이거였습니다. "모든 걸 서버리스로" 할 필요는 없다. 도구는 적재적소에 써야 한다.