들어가며

AWS는 200개가 넘는 서비스를 제공하며, 각 서비스는 고유한 API 프로토콜과 요청/응답 구조를 가지고 있다. 이들을 하나하나 수동으로 구현하는 것은 현실적으로 불가능에 가깝다. DevCloud에서는 AWS의 내부 모델링 언어인 Smithy를 리버스 엔지니어링하여 거의 전체 AWS 서비스의 Go 코드를 자동 생성하는 파이프라인을 구축했다.

이 포스트에서는 Smithy 모델을 파싱하여 Go 코드를 생성하는 전체 흐름과, 자동 생성된 코드를 기반으로 로컬 AWS 에뮬레이터를 구동하는 방법을 살펴본다. 하지만 더 근본적인 질문은 이것이다: 왜 IDL에서 코드를 생성하는가? 단순한 “생산성 자동화” 이상의 의미를 파악하려면, 먼저 이 접근이 해결하려는 근본적인 문제를 이해해야 한다.

근본적인 문제: 수동 API 구현의 한계

클라우드 에뮬레이터를 구현하는 작업은 본질적으로 프로토콜 변환기 구현이다. AWS SDK가 JSON 바디를 보내면 에뮬레이터는 Go 구조체로 변환해야 하고, XML 응답을 반환해야 하며, 에러 코드를 SDK가 기대하는 형식으로 맞춰야 한다.

이 작업을 수동으로 하는 데에는 세 가지 근본적인 한계가 있다.

첫째, 언어(spec)과 구현(impl)의 불일치다. AWS가 S3에 새 오퍼레이션을 추가하면, 문서를 읽고 Go 구조체를 추가하고 직렬화 코드를 수정해야 한다. 이 과정에서 언제든 누락이 발생한다. 두 번째, 5가지 프로토콜의 직렬화 규칙을 모두 외우는 것은 인간의 인지 능력의 한계를 넘는다. REST-XML의 XML 네임스페이스, Query 프로토콜의 ECMAScript 날짜 포맷, JSON 프로토콜의 X-Amz-Target 헤더 형식 — 이 모든 것을 동시에 정확히 구현하는 것은 실제로 시도해 본 사람만이 얼마나 어려운지 안다. 세 번째, 확장성이다. 6개 핵심 서비스를 구현하는 것은 현실적이지만, 그것을 90개 서비스로 확장하는 것은 전혀 다른 문제다.

이 문제를 해결하는 열쇠는 단순하다: AWS가 이미 모델로 정의해놓으니까, 그 모델을 파싱해서 코드를 생성하면 된다. 언어와 구현 사이의 간극을 모델이라는 중간 표현으로 메우는 것이다. 이것이 모델 기반 코드 생성의 본질이다.

Smithy란 무엇인가

Smithy는 AWS가 내부적으로 사용하는 **인터페이스 정의 언어(IDL)**다. Protocol Buffers나 OpenAPI와 같은 역할을 하지만, 몇 가지 결정적인 차이가 있다.

특징OpenAPIProtocol BuffersSmithy
정의 대상REST API메시지 포맷API + 프로토콜 + 에러
프로토콜REST에 내장별도 정의 필요모델에 명시
서버 코드 생성공식 지원 안 함protoc (Go)지원 안 함 (직접 구현)
SDK 생성공식 지원protoc (다양 언어)공식 지원 (다양 언어)
에러 모델RFC 7807별도 정의내장 (httpBinding, retryConfig)

Smithy의 핵심 차이점은 프로토콜이 모델에 명시된다는 점이다. OpenAPI는 REST에 국한되며, Protocol Buffers는 직렬화만 정의한다. 반면 Smithy는 서비스, 오퍼레이션, 구조체, 에러, 프로토콜 바인딩, HTTP 엔드포인트, 재시도 정책, 페이지네이션 등 API의 모든 측면을 하나의 모델에 정의한다.

Smithy 모델은 JSON 형태로 배포되며, AWS SDK Go V2 저장소에서 찾을 수 있다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
{
  "smithy": "2.0",
  "shapes": {
    "com.amazonaws.s3#PutObjectRequest": {
      "type": "structure",
      "members": {
        "Bucket": { "target": "com.amazonaws.s3#BucketName" },
        "Key": { "target": "com.amazonaws.s3#ObjectKey" }
      },
      "traits": {
        "aws.api#http": { "method": "PUT", "uri": "/{Bucket}/{Key+}" },
        "aws.protocols#restXml": {}
      }
    }
  }
}

이 하나의 Shape 정의에서 우리가 필요한 모든 정보를 추출할 수 있다: 구조체 필드(Bucket, Key), HTTP 바인딩(PUT /{Bucket}/{Key+}), 프로토콜(rest-xml).

코드 생성 파이프라인 구조

DevCloud의 코드 생성 파이프라인은 네 단계로 구성된다:

smithy12tisdreb34-..yneeora..mptrsursoS(eeietoeGdmSsrarer_ieie.flirsptltrgaia..rHshvoczlggou/yieeioovb*c.rzi.Jeg.ed,AjSD7ogrecsOeo.rtoNfg.iPn,GogoRoonOs(pAeJXQUWrSMuRSaOLeI(tNrNSi릿(bypomoboa(tin(odptGItD/HdyahomheTyrpyfTaHl,)PmTesTmSGPeho)na)tpe)edD)ef)

이 구조의 핵심 아이디어는 관심사의 분리다. 모델 파싱(1단계)은 AWS의 도메인 지식이 필요하고, 템플릿 렌더링(2단계)은 Go 템플릿과 직렬화 라이브러리에 대한 이해가 필요하다. 하지만 서비스 구현(3단계)은 프로토콜 세부사항을 전혀 몰라도 된다. 직렬화/역직렬화는 코드 생성기가 처리하므로, 개발자는 “PutObject는 파일에 저장한다"라는 비즈니스 로직만 구현하면 된다.

1단계: Smithy JSON 파싱의 핵심

Parser의 임무는 AWS의 형식으로 인코딩된 JSON을 프로그램이 다룰 수 있는 구조체로 변환하는 것이다. 이 과정에서 세 가지 주요한 결정을 내렸다.

원시 JSON을 중간 표현으로

Smithy JSON의 모든 값이 json.RawMessage로 들어있다. 이는 각 Shape의 타입(service, operation, structure 등)에 따라 파싱 방법이 다르기 때문이다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
type rawModel struct {
    Smithy string                     `json:"smithy"`
    Shapes map[string]json.RawMessage `json:"shapes"`
}

type rawShape struct {
    Type       string                     `json:"type"`
    Operations []rawTarget                `json:"operations"`
    Input      *rawTarget                 `json:"input"`
    Output     *rawTarget                 `json:"output"`
    Errors     []rawTarget                `json:"errors"`
    Members    map[string]rawMember       `json:"members"`
    Member     *rawMember                 `json:"member"`
    Traits     map[string]json.RawMessage `json:"traits"`
}

Traits 맵이 핵심이다. 프로토콜 정보(aws.protocols#restXml), HTTP 바인딩(aws.api#http), 문서화(smithy.api#documentation) 등이 모두 Trait으로 표현된다. Parser는 Trait의 키 접두사로 프로토콜을 감지한다:

1
2
3
4
5
6
7
8
9
func detectProtocol(s *rawShape) string {
    traits := s.Traits
    if traits["aws.protocols#restXml"] != nil { return "rest-xml" }
    if traits["aws.protocols#awsJson1_0"] != nil { return "json-1.0" }
    if traits["aws.protocols#awsJson1_1"] != nil { return "json-1.1" }
    if traits["aws.protocols#awsQuery"] != nil { return "query" }
    if traits["aws.protocols#restJson1"] != nil { return "rest-json" }
    return ""
}

오퍼레이션 참조 해석

Smithy의 오퍼레이션은 다른 오퍼레이션이나 구조체를 참조할 때 #로 구분된 식별자를 사용한다: com.amazonaws.s3#PutObjectInput. Parser는 이 식별자에서 짧은 이름(PutObjectInput)만 추출한다:

1
2
3
4
func shortName(ref string) string {
    parts := strings.Split(ref, "#")
    return parts[len(parts)-1]
}

smithy.api#Unit은 입력이 없는 오퍼레이션을 나타내는 Smithy의 빌트인 타입이다. DeleteBucket, ListBuckets 같은 오퍼레이션이 이 타입을 입력으로 사용한다.

HTTP 바인딩 추출

REST 프로토콜 서비스(S3, Route53 등)의 경우, HTTP 메서드와 URI 패턴이 aws.api#http Trait에 포함된다. 이 정보는 라우터 생성에 필수적이다:

1
2
3
4
5
6
"traits": {
    "aws.api#http": {
        "method": "PUT",
        "uri": "/{Bucket}/{Key+}"
    }
}

{Key+}+는 “하나 이상의 경로 세그먼트"를 의미하는 Smithy의 URI 템플릿 문법이다. 라우터는 이 패턴을 정규식으로 변환하여 HTTP 요청을 매칭한다.

2단계: Go 템플릿 렌더링

Generator는 파싱된 중간 표현을 기반으로 Go 템플릿을 렌더링한다. 각 서비스마다 7개의 Go 파일이 생성되며, 각 파일이 해결하는 문제가 다르다.

types.go — 다중 프로토콜 구조체

가장 까다운 템플릿이다. 하나의 구조체가 JSON과 XML 양쪑의 태그를 가져야 하므로, 템플릿은 Smithy Trait에서 양쪀 프로토콜의 직렬화 지시자를 읽어 태그를 생성한다:

1
2
3
4
5
6
7
{{ range .Structures -}}
type {{ .Name }} struct {
{{ range .Members -}}
    {{ .Name }} {{ .GoType }} `json:"{{ .JSONTag }}" xml:"{{ .XMLTag }}"`
{{ end -}}
}
{{ end -}}

json:"bucketName"xml:"BucketName"처럼 필드명이 다를 수 있다는 점에 주의해야 한다. AWS SDK는 XML 요소명을 camelCase로 변환하지만, 일부 구형 서비스는 원본 케이스를 유지한다. 이 미세한 차이가 호환성 실패의 흔한적인 원인이 된다.

interface.go — 오퍼레이션 계약

서비스의 전체 오퍼레이션을 인터페이스로 정의한다:

1
2
3
4
{{ range .Operations }}
    {{ .Name }}(ctx context.Context, input *{{ .InputName }}) (*{{ .OutputName }}, error)
{{- end }}
}

이 인터페이스는 서비스 구현체와 코드 생성기 사이의 계약이다. 인터페이스가 정의되어 있으므로, 구현체가 컴파일 타임에 모든 오퍼레이션을 구현했는지 확인할 수 있다.

router.go — HTTP 기반 라우팅

REST 프로토콜 서비스에서는 HTTP 메서드와 URI 패턴으로 오퍼레이션을 결정한다:

1
2
3
4
5
var OperationRoutes = []OperationRoute{
    {Method: "PUT", Pattern: "/{Bucket}/{Key+}", Operation: "PutObject"},
    {Method: "GET", Pattern: "/{Bucket}", Operation: "ListObjectsV2"},
    {Method: "DELETE", Pattern: "/{Bucket}/{Key+}", Operation: "DeleteObject"},
}

JSON 프로토콜 서비스(DynamoDB, Lambda)에서는 라우터가 필요 없다 — X-Amz-Target 헤더가 오퍼레이션을 지정하므로.

base_provider.go — 구현 강제 메커니즘

모든 오퍼레이션에 대해 NotImplementedError를 반환하는 스텁을 생성한다. 이 스텁은 Go의 **임베딩(embedding)**과 결합하여 강력한 구현 강제 메커니즘을 만든다:

1
2
3
4
5
6
func (p *S3BaseProvider) PutObject(ctx context.Context, req *PutObjectInput) (*PutObjectOutput, error) {
    return nil, &NotImplementedError{
        Service: "Amazon S3",
        Operation: "PutObject",
    }
}

이 패턴의 장점: 구현하지 않은 오퍼레이션은 컴파일 타임에 에러가 아닌 런타임 에러가 된다. 버그가 아니라 미구현이라는 것이 명확하다. 반면, Go의 컴파일러는 구현체가 인터페이스를 만족하는지 검증하므로, 실수 누락을 방지할 수 있다.

3단계: 스텁 임베딩으로 구현하기

자동 생성된 코드를 기반으로 실제 서비스를 구현하는 방법을 S3를 예시로 살펴본다.

임베딩으로 전체 오퍼레이션 차단

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
type S3Provider struct {
    *generated.S3BaseProvider  // 자동 생성된 스텁 — 미구현 오퍼레이션 자동 차단
    fileStore  *FileStore
    metaStore  *MetadataStore
    serverPort int
}

func (p *S3Provider) ServiceID() string     { return "s3" }
func (p *S3Provider) ServiceName() string   { return "Amazon S3" }
func (p *S3Provider) Protocol() plugin.ProtocolType {
    return plugin.ProtocolRESTXML
}

*generated.S3BaseProvider를 임베딩하면, 오버라이드하지 않은 모든 메서드가 자동으로 NotImplementedError를 반환한다. Go의 임베딩이 이 패턴을 가능하게 하는데, 핵심은 명시적 오버라이드가 암시적 오버라이드보다 우선된다는 Go의 메서드 해석 규칙 때문이다.

경로 트래버설 보호

로컬 파일시스템을 사용하므로 보안이 중요하다. FileStore는 모든 경로에 대해 트래버설 검사를 수행한다:

1
2
3
4
5
6
7
8
9
func (fs *FileStore) safePath(parts ...string) (string, error) {
    joined := filepath.Join(append([]string{fs.baseDir}, parts...)...)
    cleaned := filepath.Clean(joined)
    if !strings.HasPrefix(cleaned, fs.baseDir+string(filepath.Separator)) &&
       cleaned != fs.baseDir {
        return "", fmt.Errorf("path traversal detected: %s", cleaned)
    }
    return cleaned, nil
}

filepath.Clean../../etc/passwd 같은 악의적인 경로를 정규화하고, strings.HasPrefix로 정규화된 경로가 여전히 baseDir 아래에 있는지 검증한다. 이 두 단계 검사 없이는 안전하지 않다.

4단계: 주간 자동 동기와 자동화 선순환

AWS는 수시로 새 서비스를 출시하고 기존 서비스에 오퍼레이션을 추가한다. 이를 추적하는 것이 코드 생성 파이프라인의 마지막 단계다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# .github/workflows/smithy-sync.yml
on:
  schedule:
    - cron: "0 0 * * 1"  # 매주 월요일 자정 UTC
  workflow_dispatch:       # 수동 실행도 가능
jobs:
  sync:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Download latest Smithy models
        run: bash scripts/download-smithy-models.sh
      - name: Run code generation
        run: |
          CGO_ENABLED=1 go run ./cmd/codegen \
            -models ./smithy-models \
            -output ./internal/generated \
            -templates ./internal/codegen/templates
      - name: Check for changes
        id: changes
        run: |
          if git diff --quiet internal/generated/; then
            echo "changed=false" >> $GITHUB_OUTPUT
          else
            echo "changed=true" >> $GITHUB_OUTPUT
          fi
      - name: Create Pull Request
        if: steps.changes.outputs.changed == 'true'
        uses: peter-evans/create-pull-request@v8
        with:
          commit-message: "chore: sync Smithy models and regenerate code"
          title: "chore: weekly Smithy model sync"
          body: |
            Automated weekly sync of AWS Smithy models.
            Changes detected in generated code.
          branch: smithy-sync/weekly
          delete-branch: true

이 워크플로우의 핵심은 변경이 있을 때만 PR을 생성한다는 점이다. AWS가 모델을 변경하지 않은 주에는 아무 일도 일어나지 않는다. 변경이 감지되면 PR이 자동으로 생성되어 리뷰 후 병합할 수 있다.

이것이 코드 생성의 진정 가치다: 자동화의 선순환. AWS가 모델을 변경 → 코드가 자동 재생성 → 호환성 테스트가 자동 실행 → 문제가 없으면 병합. 인간 개발자는 비즈니스 로직 구현에만 집중하면 된다.

5가지 프로토콜 자동 감지

런타임임에 들어오는 요청의 헤더만으로 프로토콜을 판별한다. 이 감지 로직은 에뮬레이터의 진입점에서 가장 중요한 코드 중 하나다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
func DetectProtocol(r *http.Request) (protocol string, serviceID string) {
    // 1. JSON 프로토콜: X-Amz-Target 헤더
    if target := r.Header.Get("X-Amz-Target"); target != "" {
        contentType := r.Header.Get("Content-Type")
        proto := jsonProtocolFromContentType(contentType)
        service := serviceFromTarget(target)
        return proto, service
    }

    // 2. Query 프로토콜: form-encoded body에 Action= 파라미터
    if strings.Contains(r.Header.Get("Content-Type"),
        "application/x-www-form-urlencoded") {
        bodyBytes, _ := io.ReadAll(r.Body)
        r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
        if strings.Contains(string(bodyBytes), "Action=") {
            return "query", serviceFromQueryRequest(r, string(bodyBytes))
        }
    }

    // 3. SigV4 서명에서 REST 스타일 서비스 추출
    if svc := serviceFromSigV4(r); svc != "" && svc != "s3" {
        return "rest-json", normalizeServiceID(svc)
    }

    // 4. 기본: REST-XML (S3)
    return "rest-xml", "s3"
}

감지 순서가 중요하다. X-Amz-Target을 먼저 확인해야 JSON 프로토콜 서비스(DynamoDB, Lambda)가 Query 프로토콜로 오인되지 않는다. SQS는 JSON과 Query 프로토콜을 모두 지원하는 특이한 서비스로, X-Amz-Target 헤더 유무로 구분한다.

프로토콜Content-Type오퍼레이션 지정 방식대표 서비스복잡도
REST-XMLapplication/xmlHTTP 메서드 + URIS3, Route53, CloudFront매우 높음
JSON 1.0application/x-amz-json-1.0X-Amz-Target 헤더DynamoDB, SQS중간
JSON 1.1application/x-amz-json-1.1X-Amz-Target 헤더ECS, Lambda, CloudWatch중간
Queryapplication/x-www-form-urlencodedAction= 파라미터IAM, STS, SNS, RDS높음
REST-JSONapplication/jsonHTTP 메서드 + URIACM, API Gateway중간

REST-XML이 가장 복잡한 이유는 HTTP 경로 기반 라우팅, XML 직렬화, 멀티파트 업로드, presigned URL 등 S3 특유의 기능들이 많기 때문이다. 반면 JSON 프로토콜은 오퍼레이션이 헤더에 명시되므로 라우팅이 단순하다.

서비스 구현 현황과 우선순위

전체 서비스는 세 단계로 분류되며, 각 단계의 목표가 다르다:

  • Tier 1 (핵심 6개): S3, SQS, DynamoDB, Lambda, IAM, STS — 124개 이상의 오퍼레이션, 완전 구현. 대부분의 클라우드 네이티브 애플리케이션이 이 서비스들만으로 동작한다.
  • Tier 2 (통합 8개): EventBridge, SNS, CloudWatch, KMS, Secrets Manager, SSM, ECR — 157개 이상의 오팼레이션. 마이크로서비스 아키텍처에서 필수적인 서비스들.
  • Tier 3 (확장 40+개): EC2, EFS, EBS, Route53, ACM, ECS, EKS 등 — 스텁 생성 완료, 점진적 구현 예정.

우선순위를 정하는 기준은 사용 빈도 × 구현 난이도다. S3, DynamoDB, SQS는 거의 모든 클라우드 애플리케이션에서 사용되므로 가장 먼저 완전 구현한다. EC2는 사용 빈도는 높지만 구현 난이도가 높으므로 나중에 스텁 단계에서 시작한다.

핵심 인사이트

이 파이프라인에서 얻은 교훈은 “코드를 자동 생성했다"라는 기술적 사실이 아니다. 더 깊은 통찰이 가능하다.

1. 모델이 소스 of truth가 되면 유지보수가 사라진다

96개 서비스 × 평균 10개 오퍼레이션 = 약 960개의 오퍼레이션. 이 중 AWS가 주간에 변경하는 것은 극소수다. 코드 생성 파이프라인이 이를 자동으로 감지하고 PR을 생성하므로, 인간 개발자는 “어떤 오퍼레이션이 추가되었나?“라는 질문에 답할 필요가 없다. 모델이 바뀐면 코드가 따라간다 — 이게 모델 기반 접근의 진정 가치다.

2. 인터페이스가 개발자와 코드 생성기 사이의 언어가 된다

ServicePlugin 인터페이스는 두 가지 역할을 동시에 수행한다. 개발자에게는 “이 메서드를 구현하면 된다"는 명확한 계약을 제공하고, 코드 생성기에게는 “이 메서드의 직렬화는 내가 처리한다"라고 선언한다. 양쪀이 서로 다른 언어를 사용하더라도, 인터페이스라는 공통 언어가 되어 통신이 가능하다.

3. 스텁은 시작점이지 끝점이 아니다

전체 오퍼레이션의 스텁을 자동 생성하면 “아무것도 구현하지 않은 빈 프로젝트"가 된다. 하지만 이 빈 프로젝트는 컴파일이 되고 테스트가 실행되고, 대시보드에 표시된다. 이것이 시작점이다. 실제 사용 빈도에 따라 점진적으로 구현하면 되고, 구현이 누락되면 에러가 즉시 드러난다.

4. 임베딩이 구성(composition)보다 유리한 경우가 있다

Go에서 인터페이스를 만족시키는 방법은 두 가지다 — 구성(composition)과 임베딩(embedding). 구성은 모든 메서드를 수동으로 위임해야 하지만, 임베딩은 명시적으로 오버라이드한 메서드만 실제 구현이 호출되고 나머지는 임베디드 타입의 기본 동작을 따른다. 수백 개의 오퍼레이션 중 몇 개만 구현할 때 임베딩이 훨씬 실용적이다.

마치며: 이 접근이 의미하는 것

Smithy 모델에서 Go 코드를 자동 생성하는 것은 기술적으로 흥미로운 접근이지만, 그 이면의 진정 가치는 자동화의 선순환에 있다. AWS가 모델을 변경하면 코드가 자동으로 재생성되고, 호환성 테스트가 자동으로 실행된다. 인간 개발자는 비즈니스 로직에만 집중하면 된다.

이 패턴은 AWS에 국한되지 않는다. Azure의 REST API는 OpenAPI로 정의되어 있고, GCP의 gRPC 서비스는 Protocol Buffers로 정의되어 있다. 각 플랫폼의 IDL을 파싱하여 코드를 생성하는 파이프라인은 멀티클라우드 에뮬레이터를 구축하는 범용적인 접근법이 될 수 있다.

전체 소스 코드는 github.com/skyoo2003/devcloud에서 확인할 수 있다.