AWS 고급 #7 Step Functions 입문

8 분 소요

#3 Lambda, #4 API Gateway, #5 EventBridge / SQS / SNS, #6 Secrets Manager까지 한 축은 함수 / 메시지 / 시크릿이었습니다. 한 단계 더 올라가면 여러 단계의 함수 호출 / 분기 / 병렬을 묶어 하나의 워크플로우로 만드는 방식이 남았습니다.

전통적으로 이런 코드는 한 Lambda 안에 try / except와 if로 짰습니다. 그런데 단계가 5개를 넘어가면 가시성도 디버깅도 힘들어집니다. Step Functions가 그 일을 대신합니다.

이 글이 AWS 고급 시리즈의 마지막입니다. 끝나면 실전 6편으로, 진짜 백엔드를 ECS Fargate에 운영합니다.

Step Functions가 하는 일 #

AWS Step Functions는 매니지드 워크플로우 (state machine) 엔진입니다. 여러 단계를 JSON으로 정의하면, AWS가 단계 진행 / 재시도 / 실패 처리 / 시각화 를 책임집니다.

한 Lambda가 모든 걸 하던 방식 #

모놀리식 Lambda : 5 단계 처리
def handler(event, context):
    user = fetch_user(event["userId"])
    if user.plan == "pro":
        send_pro_email(user)
    else:
        send_basic_email(user)

    try:
        run_billing(user)
    except RateLimitError:
        time.sleep(60)
        run_billing(user)

    notify_slack(user)
    update_dashboard(user)

문제:

  • 한 Lambda의 15분 한도 위에 운영됨
  • 한 단계가 실패하면 어디서 실패했는지 로그 헤매기
  • 단계별 retry 정책이 코드 안에 포함되어 있음
  • 같은 흐름의 다른 변형을 추가하면 if 폭증
  • 운영자가 “지금 어디까지 됐지?” 알기 어려움

Step Functions가 푸는 문제 #

Step Functions의 그림
입력
┌──────────────────┐
│ FetchUser        │  Lambda 호출
└──────┬───────────┘
       ├ "pro" → SendProEmail
       └ "basic" → SendBasicEmail
┌──────────────────┐
│ RunBilling       │  retry: 3회, backoff 60s
└──────┬───────────┘
┌─ Parallel ───────────────┐
│ NotifySlack │ UpdateDash │
└─────────────┴────────────┘
완료

각 단계가 시각화되고, 실행마다 콘솔에서 어디서 멈췄는지 한눈에 파악할 수 있습니다. retry는 선언적으로, 분기는 데이터 기반으로 작동합니다.

Standard vs Express #

Step Functions에는 두 가지 모드가 있습니다.

StandardExpress
실행 시간최대 1년최대 5분
가격 모델단계 전환 (state transition)당호출 + 메모리 + 시간 (Lambda와 비슷)
실행 이력90일 보관, 시각화짧음, CloudWatch Logs만
처리량~25,000 / 초~100,000 / 초
At-least-once vs Exactly-onceExactly-onceAt-least-once
적합한 경우인간이 추적해야 할 비즈니스 워크플로우 (주문, 환불)짧은 / 고처리량 (이벤트 처리, 데이터 변환)

처음에는 Standard. 짧은 처리 + 빈번한 호출이 명확해지면 Express.

Amazon States Language (ASL) #

워크플로우는 JSON으로 정의. ASL이라 부릅니다.

hello.asl.json
{
  "Comment": "첫 state machine",
  "StartAt": "SayHello",
  "States": {
    "SayHello": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:ap-northeast-2:123456789012:function:hello-fn",
      "End": true
    }
  }
}

StartAt으로 시작하고, States 안의 각 상태 / 단계 정의. 각 상태는 Type과 다음 상태 (Next 또는 End: true)를 가짐.

만들기 + 실행 #

state machine 생성
SM_ARN=$(aws stepfunctions create-state-machine \
  --name hello-flow \
  --definition file://hello.asl.json \
  --role-arn arn:aws:iam::123456789012:role/stepfn-role \
  --type STANDARD \
  --query stateMachineArn --output text)

# 실행
aws stepfunctions start-execution \
  --state-machine-arn $SM_ARN \
  --input '{"name":"world"}'

콘솔의 시각화. 노드들이 그래프로 그려지고, 실행마다 노드가 색이 입혀집니다.

4가지 핵심 상태 #

1) Task: 실제 일 #

가장 자주 쓰는 상태. Lambda / ECS Task / SDK / SNS / SQS 등 외부 자원을 호출.

Task : Lambda 호출
{
  "Type": "Task",
  "Resource": "arn:aws:lambda:ap-northeast-2:123456789012:function:fetch-user",
  "InputPath": "$",
  "OutputPath": "$",
  "ResultPath": "$.user",
  "Next": "BranchByPlan"
}
  • InputPath: 들어오는 데이터에서 어느 부분을 함수에 보낼지
  • OutputPath: 다음 상태로 넘길 부분
  • ResultPath: 함수의 결과를 입력 데이터의 어느 위치에 합칠지

Service Integration: 직접 통합 #

Lambda 외에도 AWS SDK를 직접 호출. Lambda 안에서 boto3 호출하던 일을 ASL로 바로:

DynamoDB에 직접 put
{
  "Type": "Task",
  "Resource": "arn:aws:states:::aws-sdk:dynamodb:putItem",
  "Parameters": {
    "TableName": "users",
    "Item": {
      "id": {"S.$": "$.user.id"},
      "name": {"S.$": "$.user.name"}
    }
  },
  "Next": "Done"
}

Lambda 한 개가 줄어듭니다. 코드 / 배포 / 콜드 스타트가 사라집니다.

Optimized Integration #

자주 쓰이는 패턴은 단축 ARN. .sync가 끝의 결과까지 기다림:

ECS Task 동기 실행
{
  "Type": "Task",
  "Resource": "arn:aws:states:::ecs:runTask.sync",
  "Parameters": {
    "Cluster": "prod-cluster",
    "TaskDefinition": "myapp:42",
    "LaunchType": "FARGATE",
    ...
  },
  "Next": "AfterEcs"
}

ECS Task가 끝날 때까지 (혹은 실패할 때까지) 대기. Step Functions는 폴링을 자동.

2) Choice: 분기 #

데이터 값을 보고 다음 상태 결정.

Choice
{
  "Type": "Choice",
  "Choices": [
    {
      "Variable": "$.user.plan",
      "StringEquals": "pro",
      "Next": "SendProEmail"
    },
    {
      "Variable": "$.user.plan",
      "StringEquals": "basic",
      "Next": "SendBasicEmail"
    }
  ],
  "Default": "SendDefaultEmail"
}

3) Parallel: 병렬 가지 #

여러 분기를 동시에 실행하고 결과 모두 받아서 합침.

Parallel
{
  "Type": "Parallel",
  "Branches": [
    {
      "StartAt": "NotifySlack",
      "States": {
        "NotifySlack": { "Type": "Task", "Resource": "...", "End": true }
      }
    },
    {
      "StartAt": "UpdateDashboard",
      "States": {
        "UpdateDashboard": { "Type": "Task", "Resource": "...", "End": true }
      }
    }
  ],
  "Next": "Done"
}

각 가지가 독립으로 실행 + retry. 한 가지 실패하면 (catch 안 하면) 전체 실패.

4) Map: 컬렉션 처리 #

배열의 각 아이템에 대해 같은 흐름을 반복. for-each의 분산 버전.

Map
{
  "Type": "Map",
  "ItemsPath": "$.orders",
  "MaxConcurrency": 10,
  "ItemProcessor": {
    "ProcessorConfig": { "Mode": "INLINE" },
    "StartAt": "ProcessOrder",
    "States": {
      "ProcessOrder": { "Type": "Task", "Resource": "...", "End": true }
    }
  },
  "End": true
}

100개 주문을 동시 10개씩 처리, 모두 끝나면 종료. Distributed Map 모드는 1만 ~ 100만 아이템 규모도 가능 (S3 객체 일괄 처리, ETL 등).

보조 상태들 #

  • Pass. 데이터 가공만, 외부 호출 없음
  • Wait. 일정 시간 / 특정 시각까지 대기
  • Succeed / Fail. 명시적 종료

에러 처리: Retry / Catch #

워크플로우의 가치 중 큰 부분입니다. 단계마다 선언적으로.

Retry #

Retry : 3회까지 backoff로 재시도
{
  "Type": "Task",
  "Resource": "arn:aws:lambda:...:run-billing",
  "Retry": [
    {
      "ErrorEquals": ["States.TaskFailed", "BillingRateLimitError"],
      "IntervalSeconds": 5,
      "MaxAttempts": 3,
      "BackoffRate": 2.0
    },
    {
      "ErrorEquals": ["States.Timeout"],
      "IntervalSeconds": 30,
      "MaxAttempts": 1
    }
  ],
  "Next": "AfterBilling"
}

States.TaskFailed는 일반 실패, States.Timeout은 타임아웃, 또는 Lambda가 throw 한 사용자 정의 에러 이름. 5초 → 10초 → 20초 으로 backoff.

Catch #

Catch : 실패 시 별도 흐름
{
  "Type": "Task",
  "Resource": "...",
  "Catch": [
    {
      "ErrorEquals": ["States.ALL"],
      "ResultPath": "$.error",
      "Next": "HandleFailure"
    }
  ],
  "Next": "AfterTask"
}

retry까지 다 실패하면 HandleFailure로. 보상 트랜잭션 (롤백) / 알림 / 사람 개입 큐로.

자주 쓰는 패턴 #

1) Saga: 보상 트랜잭션 #

분산 시스템에서 트랜잭션이 안 통할 때. 각 단계의 정방향 + 실패 시 보상.

Saga
주문생성 → 결제 → 재고차감 → 배송예약
   ↓        ↓        ↓        ↓ (실패!)
   주문취소  결제환불  재고원복   X

각 Task에 Catch를 두고, 실패 시 보상 단계들을 역순으로 실행.

2) Human-in-the-loop #

대기하다가 사람이 승인 / 거부하면 진행:

Wait for callback
{
  "Type": "Task",
  "Resource": "arn:aws:states:::sns:publish.waitForTaskToken",
  "Parameters": {
    "TopicArn": "...",
    "Message": {
      "TaskToken.$": "$$.Task.Token",
      "OrderId.$": "$.orderId"
    }
  },
  "Next": "AfterApproval"
}

waitForTaskToken. 토큰을 외부 (이메일, 슬랙 봇 등)로 보내고, 누가 SendTaskSuccess / SendTaskFailure API를 호출할 때까지 대기. 최대 1년.

3) Polling 패턴 #

긴 외부 작업의 완료 대기:

StartJob → WaitState(30s) → CheckJob → Choice
                         (계속) WaitState로 회귀
                         (완료) Next

4) Express 워크플로우: 이벤트 처리 #

EventBridge / SQS가 트리거 → 짧은 처리 (Lambda 1~3개) → 결과를 DynamoDB / S3.

Express의 빠른 처리량과 짧은 시간 한도가 자연스럽게 맞음.

Lambda와 비교: 언제 무엇 #

Lambda 한 개로 충분한 경우 #

  • 단계 1~2개의 짧은 처리
  • 시각화 / 사람 추적이 필요 없음
  • 매우 자주 호출 + 매우 짧음 (Step Functions의 단계 전환 비용이 부담)

Step Functions가 어울리는 경우 #

  • 단계 3개 이상 + 분기 / 재시도 / 병렬
  • 실패 / 진행 상황을 사람이 봐야 함
  • 외부 시스템과의 긴 상호작용 (사람 승인, 외부 API)
  • 워크플로우 자체가 비즈니스 자산 (수정 이력 추적)

같이 쓰는 방식 #

대부분은 둘이 함께 씁니다. Step Functions가 흐름을 통제하고, 각 단계는 Lambda / ECS / SDK 호출을 맡습니다.

자주 만나는 함정 #

1) JSONPath 오타 #

"Variable": "$.user.plan"의 점 / $ 한 자만 틀리면 매칭 0. 콘솔의 input/output 검사기로 한 단계씩 확인.

2) Lambda의 출력이 너무 큼 #

Step Functions의 한 상태 입력 / 출력 페이로드 한도 256KB. 큰 데이터는 S3에 두고 키만 전달.

좋은 패턴
{
  "s3Bucket": "myapp-pipeline",
  "s3Key": "jobs/abc123/input.json"
}

3) 매 단계 전환마다 비용 #

Standard 모드 단계 전환 1,000회당 $0.025. 단계가 많은 워크플로우의 전체 비용은 단계 수 × 호출 수. 짧은 단계 (Pass)를 너무 많이 두면 의외로 큼.

4) Lambda의 cold start가 단계마다 #

각 Task가 별도 Lambda면 Cold Start가 단계마다. Express + Provisioned Concurrency, 또는 한 Lambda에 여러 단계 합치기.

5) Retry의 BackoffRate 폭주 #

MaxAttempts: 10, BackoffRate: 3.0이면 1 → 3 → 9 → 27 → 81초… 사용자가 기다리지 못하는 시간. 합계가 합리적인지 계산.

6) Catch가 한 번만 실행 #

retry가 다 끝난 후 catch가 한 번. catch 안에서 또 실패하면 워크플로우 실패. catch 안의 task도 retry 옵션 검토.

7) 시각화에 안 보이는 외부 호출 #

Lambda 안에서 boto3로 호출한 부분은 시각화 / 추적에 안 잡힘. 가능하면 Service Integration으로 ASL의 Task 상태로 끌어내기.

정리 #

이번 글에서 잡은 것:

  • Step Functions의 역할. 여러 단계의 함수 / SDK 호출을 JSON 워크플로우로. 시각화 / retry / 분기 / 병렬이 선언적
  • Standard vs Express. Standard는 길고 비싼 비즈니스 / Express는 짧고 고처리량 이벤트
  • ASL. JSON으로 정의. StartAt + States + 각 상태의 Type / Next
  • 4 핵심 상태. Task (일) / Choice (분기) / Parallel (병렬) / Map (컬렉션)
  • Service Integration. Lambda 없이 SDK 직접. ECS .sync, DynamoDB, SNS 등
  • Retry / Catch. 단계마다 선언적. backoff와 catch 흐름
  • 자주 쓰는 패턴. Saga (보상 트랜잭션), Human-in-the-loop (waitForTaskToken), Polling, Express 이벤트 처리
  • Lambda vs Step Functions. 단계 1~2개 → Lambda 한 개. 3개 이상 + 분기 / 재시도 / 시각화 필요 → Step Functions
  • 함정. JSONPath 오타, 256KB payload (S3 우회), 단계 전환 비용, Lambda 다단계 cold start, BackoffRate 폭주, Catch 한 번, Service Integration 안 쓴 외부 호출

시리즈를 마무리하며 #

#1 ECS / Fargate 부터 7편, 컨테이너 (ECS / ECR), 서버리스 (Lambda / API Gateway), 메시지 (EventBridge / SQS / SNS), 시크릿 (Secrets Manager / Parameter Store), 워크플로우 (Step Functions) 까지 AWS 운영의 도구상자가 모였습니다.

기초 7편의 IAM / 비용 / 보안과 중급 7편의 EC2 / VPC / S3 / RDS / ALB / CloudFront 위에 이 7개를 얹으면, **백엔드를 AWS에 올려 운영하기 위해 필요한 것의 90%**가 갖춰집니다.

다음 시리즈: AWS 실전 #

이론은 다 나왔습니다. 이제 진짜 백엔드를 한 프로젝트로 만들 차례입니다.

AWS 실전 #1: ECS Fargate에 백엔드 배포에서는 모던 파이썬 실전 (FastAPI) / 장고 실전 (DRF)에서 만든 API를 ECS Fargate 위에 운영 가능 형태로 올립니다. RDS, ALB, ACM, Route 53, Secrets Manager가 한곳에 모이는 6편짜리 트랙의 시작입니다.

X