AWS를 연습하려면 늘 같은 벽에 부딪힌다. 계정을 열고, 카드를 등록하고, 뭔가를 잘못 켜두면 청구서가 날아온다. aws CLI 명령 하나, Terraform apply 한 번이 실제 리소스를 만들고 실제 돈이 된다. 마음 편히 이것저것 눌러보기 어렵다.
mincloud는 그 반대를 목표로 한다. AWS API와 호환되는 서버를 Go로 밑바닥부터 짜서, 로컬에서 AWS CLI·SDK·Terraform을 청구서 걱정 없이 돌리는 것. 이미 LocalStack이 그 자리를 채우고 있지만, 여기서 하려는 건 완성품을 갖다 쓰는 게 아니라 그 안에서 무슨 일이 벌어지는지 한 겹씩 직접 구현하며 이해하는 것이다.
“호환된다"는 말은 생각보다 단순하다. AWS CLI나 SDK가 진짜 AWS에 보내는 것과 똑같은 요청을 받아, 똑같은 형식의 응답을 돌려주면 클라이언트 입장에선 상대가 진짜 AWS인지 아닌지 구분할 수 없다. 그러니 서버를 짜기 전에 먼저 “진짜 AWS에 보내던 요청이 정확히 어떻게 생겼는지"를 알아야 한다. 그게 이 편의 전부다.
첫 목표는 아주 작게 잡았다. aws sts get-caller-identity 한 줄이 “너는 이 계정의 이 사용자다"라는 신원을 리턴하게 만드는 것. 실제 AWS에서도 자격증명이 살아있는지 확인할 때 가장 먼저 때려보는 명령이다. 이게 되면 요청을 받고, 인증하고, 응답을 돌려주는 최소 왕복이 한 바퀴 완성된 셈이다.
그런데 이 한 줄을 받아내려면 먼저 알아야 할 게 있다. CLI가 대체 무엇을 보내는가.
구현하기 전에, 일단 들여다본다
문서로 SigV4를 공부할 수도 있다. 하지만 더 빠른 길이 있다. 요청을 그냥 받아서 그대로 찍어보는 것이다. 서명을 검증하지도, 응답을 제대로 만들지도 않는다. 딱 두 가지만 한다 — 들어온 요청을 로그로 덤프하고, 아무 말이나 돌려준다.
| |
핵심은 Go 표준 라이브러리의 httputil.DumpRequest다. 요청 한 개를 헤더부터 본문까지 통째로 문자열로 만들어준다. 서버는 그걸 로그로 찍고, 본문에는 not implemented만 적어 돌려준다.
이제 CLI에게 진짜 AWS 대신 이 서버를 보라고 알려준다.
AWS_ACCESS_KEY_ID=MINCLOUDTESTKEY0000A \
AWS_SECRET_ACCESS_KEY=mincloud-test-secret-not-real \
aws sts get-caller-identity \
--endpoint-url http://localhost:9900 \
--region ap-northeast-2
--endpoint-url은 CLI가 요청을 보낼 주소를 바꾼다. 자격증명은 환경변수로 넘긴다. 여기 나오는 키와 시크릿은 전부 테스트용 가짜 값이다 — 실제 어디에도 통하지 않는다.
CLI는 이렇게 답한다.
Unable to parse response (syntax error: line 1, column 0), invalid XML received.
b'not implemented\n'
당연하다. 우리는 XML이 아니라 not implemented라는 평문을 돌려줬으니까. 이 에러는 마지막에 다시 볼 것이다. 지금 중요한 건 CLI가 응답을 받기 전에 보낸 것, 즉 서버 로그에 찍힌 요청이다.
캡처된 요청
로그에 찍힌 요청은 이렇다. (읽기 좋게 Authorization 한 줄만 여러 줄로 접었다.)
POST / HTTP/1.1
Host: localhost:9900
Accept-Encoding: identity
Authorization: AWS4-HMAC-SHA256
Credential=MINCLOUDTESTKEY0000A/20260705/ap-northeast-2/sts/aws4_request,
SignedHeaders=content-type;host;x-amz-date,
Signature=285b60204ba0de2785782461e04d6aa0c962ec010b8196276f4a1112306dab85
Content-Length: 43
Content-Type: application/x-www-form-urlencoded; charset=utf-8
X-Amz-Date: 20260705T112547Z
Action=GetCallerIdentity&Version=2011-06-15
몇 가지가 눈에 띈다.
먼저 맨 아래 본문. Action=GetCallerIdentity&Version=2011-06-15, 이 한 줄이 곧 API 호출이다. get-caller-identity라는 명령이 Action=GetCallerIdentity라는 폼 데이터로 번역돼 본문에 실렸다. STS를 비롯한 오래된 AWS 서비스는 이렇게 Query protocol을 쓴다 — HTTP 본문에 폼 인코딩된 Action=...이 그대로 호출 이름이 된다. JSON도, REST 경로도 아니다. POST / 하나에, 본문으로 “무엇을 할지"를 담는다.
굳이 폼 인코딩과 Action=이라니 낯설지만, STS·EC2 같은 초기 서비스가 이 방식으로 설계된 뒤 하위 호환을 위해 그대로 유지된 것이다. 우리 입장에선 오히려 다루기 쉽다. 본문을 폼으로 파싱해 Action 값만 꺼내면 무슨 작업을 원하는지 바로 알 수 있다.
그리고 X-Amz-Date. 요청을 만든 시각이다. 뒤에서 서명이 이 시각에 묶이기 때문에, 오래된 요청을 그대로 복사해 재사용하지 못하게 하는 장치가 된다.
하지만 이 요청의 핵심은 Authorization 헤더다.
Authorization 헤더의 네 조각
접었던 줄을 다시 한 줄로 펴 보면 이렇다.
Authorization: AWS4-HMAC-SHA256 Credential=MINCLOUDTESTKEY0000A/20260705/ap-northeast-2/sts/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=285b60204ba0de...
맨 앞 AWS4-HMAC-SHA256은 서명 방식의 이름이다. 그 뒤로 콤마로 구분된 세 덩어리가 온다.
- Credential =
MINCLOUDTESTKEY0000A/20260705/ap-northeast-2/sts/aws4_request. 슬래시로 나뉜 첫 조각MINCLOUDTESTKEY0000A가 액세스키 ID다. 나머지20260705/ap-northeast-2/sts/aws4_request는 이 서명이 유효한 범위 — 날짜, 리전, 서비스, 그리고 고정 문자열aws4_request. - SignedHeaders =
content-type;host;x-amz-date. 이 요청의 어떤 헤더들이 서명 계산에 들어갔는지 적은 목록이다. - Signature =
285b60204ba0de.... 실제 서명값. 64자리 16진수, 즉 SHA-256 계열 HMAC의 결과다.
여기서 한 가지가 눈에 들어와야 한다. 어디에도 시크릿 키가 없다.
키는 두 개, 역할이 다르다
AWS 자격증명은 늘 쌍으로 온다. 액세스키 ID와 시크릿 액세스키. 이름이 비슷해서 둘 다 비밀 같지만, 역할은 아이디와 비밀번호만큼 다르다.
- 액세스키 ID (
MINCLOUDTESTKEY0000A)는 아이디다. “누가 보냈나"를 밝힌다. 비밀이 아니기 때문에, 위에서 봤듯 요청에 평문으로 실려 온다. 로그에 찍혀도, 도청당해도 그 자체로는 문제가 아니다. - 시크릿 액세스키 (
mincloud-test-secret-not-real)는 비밀번호다. “정말 본인인가"를 증명한다. 그리고 절대 네트워크로 나가지 않는다. 위 요청 어디에도 없다.
그럼 시크릿을 보내지도 않는데 서버는 어떻게 본인을 확인하나. 이게 SigV4의 핵심 아이디어다. 클라이언트는 시크릿으로 요청 내용을 버무려 서명이라는 결과 하나를 만들어 보낸다. 서버는 액세스키 ID로 자기 저장소에서 같은 시크릿을 찾아, 똑같은 계산을 다시 해본다. 두 서명이 일치하면 “이 사람은 시크릿을 알고 있다 = 본인이다"라고 판정한다. 비밀번호를 주고받는 대신, 비밀번호를 아는 사람만 낼 수 있는 답을 맞춰보는 것이다.
즉 서버가 할 일이 벌써 정해졌다. 액세스키 ID로 시크릿을 찾고, 클라이언트가 한 것과 똑같은 서명 계산을 재현하는 것. 그 계산이 정확히 무엇인지는 다음 편에서 판다.
SignedHeaders는 누가 정하나
SignedHeaders=content-type;host;x-amz-date를 다시 보자. 요청에는 이것 말고도 헤더가 더 있다. Accept-Encoding도, Content-Length도 버젓이 있는데 서명 목록엔 빠졌다. 누가 이 셋만 골랐나.
두 주체가 관여한다. 먼저 호출하는 작업이 자기가 중요하게 여기는 헤더를 정한다. Content-Type은 본문을 어떻게 읽을지, Host는 어느 서버로 가는지, X-Amz-Date는 언제 만든 요청인지 — 바뀌면 요청의 의미가 달라지는 것들이다. 이런 건 서명에 넣어 잠근다.
반대로 서명에서 일부러 빼는 헤더도 있다. Accept-Encoding, Content-Length, User-Agent 같은 것들이다. 이들은 클라이언트와 서버 사이의 프록시·게이트웨이가 중간에 바꿔버릴 수 있다. 만약 이런 헤더까지 서명에 넣었다가 중간 장비가 값을 손대면, 내용은 멀쩡한데 서명만 깨져서 정상 요청이 거부된다. 그래서 서명기(SDK/CLI)는 이런 “중간에 변하는 헤더"를 블랙리스트로 두고 서명 대상에서 뺀다.
정리하면 규칙은 단순하다. 작업이 의미를 두는 헤더는 서명하고, 중간에서 변할 수 있는 헤더는 뺀다. 그 결과가 content-type;host;x-amz-date라는 목록이고, 서버는 이 목록에 적힌 헤더만, 적힌 순서대로 서명 계산에 넣어야 클라이언트와 같은 답이 나온다.
다음 편: 이 서명을 검증한다
우리는 아직 한 줄의 검증 코드도 짜지 않았다. 그런데 요청 하나를 들여다본 것만으로 서버가 할 일의 윤곽이 다 드러났다.
- 본문의
Action=을 읽어 무슨 작업인지 안다. Credential의 첫 조각으로 액세스키 ID를 얻고, 저장소에서 시크릿을 찾는다.SignedHeaders에 적힌 헤더들과 본문으로, 클라이언트가 한 것과 같은 서명을 계산한다.- 그 결과가
Signature와 같은지 본다.
그리고 아까 그 에러 — invalid XML received — 가 마지막 할 일을 알려준다. 서명이 맞았다면, 이번엔 CLI가 알아들을 수 있는 XML로 신원을 돌려줘야 한다. 평문 not implemented로는 안 된다.
다음 편에서는 3번, 서명 계산을 실제로 재현한다. AWS4-HMAC-SHA256이라는 이름 뒤에 어떤 계산이 숨어 있는지, 왜 시크릿을 곧바로 쓰지 않고 여러 단계를 거치는지, 그리고 Go 스무 줄 남짓이 실제 AWS CLI가 만든 서명과 바이트 단위로 똑같은 값을 뱉게 만드는 과정을 따라간다.