API Gateway + AWS Lambda에서 바이너리를 반환하면 왜 CORS 에러가 발생하죠?

지난 주에는, Handlerbar 템플릿을 PDF 바이너리로 변환하여 내려주는 간단한 서버 API를 만들었다. 운영 비용을 줄일 생각으로 Serverless 프레임워크(이하 Serverless)로 개발을 해서 AWS Lambda 환경에 배포를 했다. Serverless는 여러 서버리스 인프라 프로바이더를 하나의 인터페이스로 추상화 한 API를 제공하는 프레임워크다. 몇 가지 간단한 설정을 하고 구현 로직을 만들어서 제공하면 빌드, 배포를 모두 알아서 해준다.

오래 전에 동료가 Serverless를 이용해서 구현한 다른 기능이 잘 돌아가고 있고 빨리 문제를 해결하고 싶었던터라 크게 고민하지 않고 기존 코드를 참고해서 구현을 했다. 레거시랑 Getting Started 정도만 읽어보고 금방 코드를 만들어서 배포할 수 있었다.

하지만 이런 도구는 내부 원리를 잘 모르면 사용 중에 문제가 생겼을 때 문제를 해결하기가 쉽지 않다. 특히나 AWS 의 클라우드 인프라는 컴포넌트를 다양하게 조합할 수 있다보니, 각 인프라의 동작 방식을 잘 모르면 문제가 생겼을때 미로에 빠지기 쉽다. 이번에 겪은 문제도 그랬다.

빠르게 만들어서 API를 배포했지만, 클라이언트가 API를 호출하면 브라우저에서 CORS 에러가 발생했다. CORS 설정 가이드를 다시 한 번 확인하고, 설정을 하고, 테스트 하기를 수차례. 해결이 되지 않았다.

잘 안 풀릴 때는 침착하게 공식 문서를 읽어보는 게 문제를 더 빨리 해결하는 길이라는 걸 경험으로 터득했다. 결국 가이드 문서를 정독해서 내부 구현 원리를 이해하고 나서야 원인을 특정할 수 있었다. 이번에는 경험치가 빨리 동작해서 다행이었다.

이 글은 Serverless를 이용해 구현한 바이너리 반환 API를 AWS의 API Gateway + AWS Lambda 환경에 배포한 후에 발생한 CORS 에러의 원인을 추적하고 해결한 과정을 기록한다.

😠 현상은 이렇습니다.

Lambda에 배포한 코드는 대충 이런 모습이다. 이 함수는 컨트롤러의 역할을 하며, 모든 로직은 toPdfFromHtml 안에 들어있다.

const { data, template } = JSON.parse(event.body);
const pdf = await toPdfFromHtml(template, {
  ...data,
  stampImageHref: data ? data.stampImageHref : '',
});

const response: APIGatewayProxyResult = {
  statusCode: 200,
  headers: {
    'Access-Control-Allow-Origin': '*',
    'Content-Type': 'application/pdf; charset=utf-8',
    'Content-Length': Buffer.byteLength(pdf),
    'Content-Disposition': `attachment; filename="${encodeURI(template)}.pdf"`,
  },
  // NOTE:
  // https://stackoverflow.com/questions/45348580/aws-lambda-fails-to-return-pdf-file
  body: pdf,
};

return response;

Serverless는 이 코드를 빌드하여 Lambda에 배포하고 API Gateway에 연결된 이런 엔드포인트를 만들어 준다.

https://<rest_api_id>-<vpc_endpoint_id>.execute-api.<aws_region>.amazonaws.com

이제 클라이언트에서 이 API를 호출하면 끝! … 이라고 생각을 했지만, 테스트 과정에서 CORS 에러가 발생했다.

CORS 에러가 발생하고 있음을 보여주는 네트워크 탭

누구든 CORS 에러를 만나면 가장 먼저 CORS 설정을 살펴보지 않을까.

Your CORS and API Gateway survival guide

나 역시 CORS 설정을 잘못 했나 싶어서 위의 가이드를 다시 보면서 코드를 수정하고 테스트를 했지만 해결을 할 수 없었다. 그렇게 쉽게 문제를 풀었다면 이 글을 쓰지 않았겠지. 삽질이 기운이 스멀스멀 피어오른다.

🤔 CloudFront는 왜…?

해결이 안 되자 조급해지는 마음을 잠시 가다듬고 네트워크 탭을 뚫어져라 쳐다보며 단서를 수집했다.

Lambda 함수에서 'Access-Control-Allow-Origin': '*'를 응답 헤더에 반환했는데 CORS 에러 로그를 보면 이 헤더가 없음을 보여주는 네트워크 탭 상세보기 화면

어라, Lambda 함수에서 ‘Access-Control-Allow-Origin’: ‘*‘를 응답 헤더에 반환했는데 CORS 에러 로그를 보면 이 헤더가 없다. 어디로 간걸까? 그러고보니 에러 메시지의 출처가 CloudFront네!?

x-amzn-errortype: InternalServerErrorException
x-cache: Error from cloudfront

내가 배포한 Serverless 구성이 “API Gateway → Lambda”라고 생각했는데, CloudFront는 어디에서 나타난걸까. Serverless가 만들어 준 인프라 구조를 내가 잘 모르고 있다는 걸 깨달았다. 대충 알아서 해주겠거니…하는 생각에 코드만 보고 문서를 안 읽었다. AWS 문서를 하나씩 읽어보기 시작했다. 머지 않아 힌트를 찾았다.

AWS는 API Gateway로 3가지 유형의 Endpoint를 제공하는데 이 중에 Edge-optimized API Endpoints가 Serverless의 기본 값이었다.

Edge-optimized API Endpoints는 URL을 CloudFront에 연결한다. 이름에서 최적화 삘이 난다. Serverless의 설정 타입을 보면 endpointType 프로퍼티의 유니온 타입을 이렇게 정의하고 있다.

endpointType?: 'regional' | 'edge' | 'private' | undefined

딱딱 맞는다. Serverless 가이드 문서 역시, 기본 값은 edge라고 명시한다.

By default, the Serverless Framework deploys your REST API using the EDGE endpoint configuration. If you would like to use the REGIONAL or PRIVATE configuration, set the endpointType parameter in your provider block.

https://www.serverless.com/framework/docs/providers/aws/events/apigateway#configuring-endpoint-types

내가 착각을 했다. “API Gateway → Lambda”가 아니라 “CloudFront → API Gateway → Lambda” 구조였다.

가설을 세웠다. CloudFront가 요청을 릴레이 하는 과정에서 API Gateway에서 에러가 발생하자 자신이 대신 에러 응답을 반환했다. 이 과정에서 Lambda가 반환한 cors 헤더를 유실했고, 브라우저는 cors 헤더가 없으니 에러를 발생시켰다.

그렇다면 CloudFront 뒤에 숨어 음모를 꾸미는 녀석은 누굴까?

진짜 범인은…!

Lambda 함수에서 출력하는 로그는 CloudWatch에 남는다. 로그를 뒤졌다. 로그는 Lambda가 무죄라는 걸 증명했다.

CloutWatch에 남은 람다 로그

이제 남은 건 API Gateway. 심증은 가나 물증이 없다. 원인을 특정할 수 없어서 Lambda가 반환하는 응답 개체의 프로퍼티를 하나씩 제거하면서 변화가 생기는지 관찰했다. 어라, body에 빈 문자열을 주면 CORS 에러가 발생하지 않는다. 그렇지.

const { data, template } = JSON.parse(event.body);
const pdf = await toPdfFromHtml(template, {
  ...data,
  stampImageHref: data ? data.stampImageHref : '',
});

const response: APIGatewayProxyResult = {
  statusCode: 200,
  headers: {
    'Access-Control-Allow-Origin': '*',
    'Content-Type': 'application/pdf; charset=utf-8',
    'Content-Length': Buffer.byteLength(pdf),
    'Content-Disposition': `attachment; filename="${encodeURI(template)}.pdf"`,
  },
  body: '',
};

return response;

Lambda와 API Gateway 사이에서 범인의 냄새를 맡았다.

위의 코드에서 pdf는 바이너리 버퍼 타입이다. 바이너리를 반환하는 게 문제일까? Lambda에서 바이너리를 반환할 수 없는 건가? 구글링을 하다가 공식 문서를 보고서야 바이너리 타입은 base64로 인코딩을 해야한다는 걸 깨달았다. 아뿔싸!

REST API에 대한 이진 미디어 유형 작업

API Gateway에서 API 요청과 응답은 텍스트 또는 이진 페이로드를 포함합니다. 텍스트 페이로드는 UTF-8 인코딩 형식의 JSON 문자열입니다. 이진 페이로드는 텍스트 페이로드를 제외한 페이로드입니다. 예를 들어 JPEG 파일, GZip 파일, XML 파일 등이 이진 페이로드가 될 수 있습니다. 이진 미디어를 지원하는 데 필요한 API 구성은 API가 프록시 통합을 사용하는지 아니면 비 프록시 통합을 사용하는지에 따라 달라집니다.

https://docs.aws.amazon.com/ko_kr/apigateway/latest/developerguide/api-gateway-payload-encodings.html

바이너리를 base64로 인코딩 해야 하는 이유는 API Gateway의 콘텐츠 유형 변환 규칙 때문이다. 이 내용 역시, 아래의 가이드 문서에 잘 나와 있다.

API Gateway의 콘텐츠 유형 변환

문서를 보면 변환 규칙을 이런 식으로 테이블로 정의해서 안내하고 있다.

콘텐츠 유형 변환 테이블 스크린샷

API Gateway는 HTTP Payload(Body), Content-type, binaryMediaTypes, contentHandling 설정을 참고해서 런타임에 HTTP Body의 인코딩 타입을 결정한다.

그래서 해결은?

앞에서 언급한 내용을 종합하면, API Gateway로 들어오고 나가는 HTTP Body는 텍스트나 바이너리를 가질 수 있는데, 이 때 개발자는 개발을 할 때 다음 세 가지 제약을 따라야 한다.

모두 세 군데를 수정하는 걸로 문제를 해결할 수 있었다.

1) 응답에 base64로 인코딩한 body를 추가하고, isBase64Encoded 속성을 true로 설정해서 API Gateway에게 이 사실을 알려준다.

const response: APIGatewayProxyResult = {
	statusCode: 200,
	headers: {
      'Access-Control-Allow-Origin': '*',
      'Content-Type': 'application/pdf; charset=utf-8',
      'Content-Length': encodedPdf.length,
      'Content-Disposition': `attachment; filename="${encodeURI(template)}.pdf"`,
	},
	// NOTE:
	// https://stackoverflow.com/questions/45348580/aws-lambda-fails-to-return-pdf-file
	body: encodedPdf,
    isBase64Encoded : true,
};

2) serverless.ts의 apiGateway 설정에 binaryMediaTypes 설정을 추가한다.

provider: {
  apiGateway: {
    binaryMediaTypes: ['application/pdf']
  },
},

3) HTTP Request를 요청할 때 Content-type 헤더를 바이너리 타입으로 설정(나의 경우 application/json)한다.

headers: { 'content-type': 'application/json', },

그러면 응답 콘텐츠 유형 변환 테이블에서 아래에 셀렉트 하여 표시한 조건이 동작하고,

콘텐츠 유형 변환 테이블에서 이진 데이터, 이진수 형식, 일치하는 미디어 유형이 있는 세트, 정의되지 않음, 이진 데이터 항목이 하이라이팅 되어 있음

네트워크 요청에 성공해서 200 응답을 받고,

요청이 성공해서 200 응답을 받았음을 보여주는 네트워크 상세보기 화면

API Gateway가 보낸 예쁜(?) PDF 바이너리를 받을 수 있다.

API Gateway의 응답으로 받은 바이너리

이때 Request의 Accept를 지정하지 않으면 아래 조건에 걸려서 Base64로 인코딩한 문자열을 반환해버리는데 이 또한 정해진 콘텐츠 유형 변환 규칙을 적용받기 때문이다.

콘텐츠 유형 반환 테이블에서 Base64로 인코딩된 문자열을 반환하는 조건 하이라이팅

API Gateway가 기대한 결과를 돌려주지 않는다면 이 테이블을 참고해서 설정을 잘 했는지 확인할 수 있다.