서버리스 웹 애플리케이션 만들기

서버 운영에서 손을 뗀 지 4년 정도 된 것 같다. 호스팅 업체를 사용하다가 클라우드가 유행하면서 AWS로 옮겨 가상컴퓨팅머신(ec2)이나 이것저것 자동으로 만들어주는 앨라스틱 빈스톡(elastic beanstalk)으로 서버를 구성하고 운영했었다.

크라우드로 옮겼지만, 서버 운영에 대한 부담은 여전했다. 오토 스케일만 믿다가 장애를 겪었다는 다른 회사의 안타까운 사례 발표도 종종 들렸다. 트래픽 증가 속도가 너무 빨라 스케일링 중에 요청을 처리하지 못했다는 것이다. 서비스를 운영할수록 쌓여가는 데이터베이스 관리도 디비 전문가 없이는 과연 잘할 수 있을지 의문이다.

클라우드 서비스가 출시되고 얼마 후 관리형 서버라고 말하는 "서버리스" 서비스가 출시되는 것은 이런 점에서 자연스러워 보인다. AWS 람다가 대표적인 서비스인데 회사와 개발자들의 사례 발표를 보면서 언젠가 기회가 온다면 꼭 사용해 보고 싶었다.

마침 회사에서 신규 서비스 출시 프로젝트에 참여하면서 서버리스를 사용할 기회를 잡을 수 있었다. 여러 가지 시행착오 끝에 어드민과 웹 서비스를 AWS 서버리스 위에 얹어 놓을 수 있었다.

노드(Node.js)와 리액트(React.js)로 구성된 애플리케이션을 어떻게 AWS 플랫폼을 이용해 서버리스로 구성했는지 기록해 두어야겠다.

웹서버가 필요한 이유 🙄

회사에서 프론트엔드 개발자로서 일하다 보면 "웹 서버가 있어야 한다"라는 말을 종종 한다. 이게 듣는 사람별로 당연하게 생각하는 사람도 있는 반면, 왜 필요한지 고개를 갸우뚱하시는 분들도 있다. 설명을 하다 보면 나도 좀 헷갈릴 때가 있는데...... 왜 웹서버가 필요할까?

내가 이해하고 있는 웹 애플리케이션 서버의 역할은 두 가지다.

  • 정적파일 제공
  • API 제공

브라우져가 서버에 요청한 웹 문서와 문서에 포함된 자바스크립트, 스타일시트, 이미지 따위를 웹 서버가 제공해 주어야 한다. 정적 파일을 제공하기 위해 전용 CDN 서버가 있다 하더라도 동적인 웹 문서를 만들려면 웹서버는 필요하다. 검색 엔진이나 소셜네트워크 공유에 사용하려면 이러한 서버에서 문서를 만드는 과정이 필요하다.

웹 문서가 자바스크립트를 로딩하면 추가적인 데이터는 AJAX로 처리하는 것이 일반적이다. 이 요청을 처리할 API 엔드포인트도 필요한데 이것이 웹서버의 두 번째 역할이다. API 전용 서버가 있더라도 브라우져의 CROS 보안 정책을 따르려면 웹 문서를 요청한 웹서버를 통해 API가 제공되어야 한다. 웹서버는 자체 API를 제공하기도 하고 또 다른 API 전용 서버의 호출을 대신해주는 "프록시 서버"의 역할도 한다.

애플리케이션 구조

이러한 역할을 하는 웹 서버를 만드는데 노드와 리액트를 사용했다. 그중 노드 웹 프레임웍인 익프프레스(Express.js)로 서버의 골격을 만들었는데 위에서 설명한 두 가지 역할을 다음과 같이 만들었다.

정적파일 제공

리액트를 사용한 프로젝트라서 웹팩으로 번들링 한다. 번들링 된 정적파일은 특정 폴더에 위치하는데 서버에서는 이것을 정적 파일로 서비스한다.

// 웹팩 결과물을 dist 폴더에 저장하고 이것을 정적 파일로 제공한다.
app.use(express.static("dist"))

운영 환경에서는 빌드한 정적파일을 이런 식으로 제공하면 되지만 개발 환경에서는 조금 다르다. 로컬 환경에 웹서버를 띄워놓더라도 코딩할 때마다 웹팩이 실행되어야 하고, 매번 변경된 결과물을 웹서버가 정적 파일로 서비스해 주어야 하기 때문이다.

이런 용도로 webpack-dev-middleware를 사용했다.

// 실시간으로 빌드되는 웹팩 결과물을 정적 파일로 제공한다.
const middleware = require("webpack-dev-middleware")
const compiler = webpack(require("../webpack.config"))

app.use(middleware(compiler))

API 제공

몇 년 전부터 마이크로서비스가 유행이었는데 넷플릭스의 사례를 보았던 것 같다. 그때는 다소 교과서적이고, 이 정도로 할만 서비스가 얼마나 있을지 의문도 가졌었다. 이 프로젝트에도 도메인별로 API 서버를 단독으로 운영하는 구조인데 이제는 퍽 익숙하다.

여러 API 서버가 있더라도 이것은 각자 내부망에서만 접근할 수 있고 인터넷망에서 접속하려면 포탈 역할을 하는 API 게이트웨이 서버가 있어야 한다. 모바일 운영체제나 브라우저에서 접속하려면 이런 공개된 네트워크에 있는 서버를 통해 각 API 서버의 리소스에 접근할 수 있는 구조다. API 게이트웨이는 입구를 지키는 문지기처럼 인증과 권한 체크를 하고 요청을 통과하거나 제한한다.

반면 사내망에 있는 어드민 서비스는 API 게이트웨이의 지원을 받지 못했다. 웹서버에서 API 게이트웨이의 역할을 대신하도록 했다.

express-http-proxy 는 이런 역할을 하는 익스프레스 미들웨어다.

import proxy from "express-http-proxy"

app.use("/api", proxy())

브라우저는 AJAX 요청을 할 때 "/api" 로 시작되는 엔드포인트를 가지고 웹서버로 호출한다. 웹서버는 이를 받아 등록된 라우팅 규칙을 찾아 설정된 API 서버로 호출을 전달한다. API 서버에서 받은 응답은 그대로 요청한 브라우저에 전달된다. 이러한 요청과 응답을 전달만 하는 구조일 뿐이지만 그런데도 사용하는 이유는 브라우저의 CROS 정책을 준수하기 위함이다. 이 외에서 서버 간의 호출 시 사용되는 인증정보도 외부로 유출하지 않고 사용할 수 있다.

인프라 구조

애플리케이션 구조를 봤으니 이걸 어떻게 인프라에 올렸는지 정리해 보자.

AWS 서비스가 무척 많은데 먼저 두 개 서비스부터 보자.

  • 람다: 웹 서버
  • S3: 파일 서버

프로젝트를 빌드하면 웹서버 코드와 정적파일을 담은 폴더가 생기도록 했다. 전자는 람다에 올리고 후자는 S3의 특정 버킷에 올리도록 했다. 물론 처음엔 젠킨스같은 배포 도구가 없어서 직접 AWS 대시보드에서 파일을 올렸다.

람다

람다는 단순히 코드를 실행시켜주는 서비스다. 이벤트를 받아 이걸 람다 인자로 전달하면 코드를 실행하는 구조다. 그러다 보니 람다에 들어온 이벤트를 노드 코드에서 해석하는 작업이 필요하다. 게다가 익스프레스는 요청을 Reqeust 객체로 받기 때문에 람다 이벤트를 이에 맞게 변환하는 추가 작업도 해야 한다.

이런 람다 서비스의 특성은 개발 환경에서의 적잖게 불편했다. AWS 이벤트를 받는 구조로 애플리케이션 코드를 만들었기 때문에 코드를 테스트할 때도 AWS 이벤트를 전달해야 한다. 람다 대시보드에서는 AWS 이벤트를 만들 수있긴 하다. 이것도 뭐 편한 방법은 아니지만 말이다. 이게 로컬 환경에서는 무척 불편했다. 그래서 SAM이란 툴을 잠깐 사용하기도 했었는데 썩 만족스럽진 않았던 것 같다.

이번에는 aws-serverless-express 라는 도구를 사용했는데 훨씬 편했다. 이전에는 코딩할 때 '여기는 AWS 이벤트야, 그러니깐 이렇게 작성해야지' 혹은 '코드가 너무 AWS에 의존적인데'라는 생각이 들어 염려스러웠다. 하지만 이 도구를 사용하고 나니 AWS와 완전히 격리해서 개발할 수 있었다.

람다 환경에 의존적인 것은 aws-serverless-express에게 맞기고 익프프레스 프레임웍만 보면서 코딩하면 된다. 단 로컬 환경과 람다 환경에서의 진입점 파일만 구별해 주면 되었다.

  • index.local.js: 로컬 환경에서의 진입점
  • index.js: 람다 환경에서의 진입점

람다 환경에서의 진입점도 무척 단순하다. 그냥 라이브러리의 proxy 함수만 불러주면 끝이다.

// index.js
import AWSServerlessExpress from "AWS-serverless-express"
import app from "@/app"

const server = AWSServerlessExpress.createServer(app)

export const handler = (event: any, context: any) => {
  AWSServerlessExpress.proxy(server, event, context)
}

S3

람다에 배포하는 코드는 API 처리와 HTML을 생성하는 코드지만 웹팩으로 빌드한 정적파일은 S3로 업로드 했다. 직접 서버를 만들 때도 정적파일을 엔진엑스나 별도 CDN 서버에 두는 것처럼 람다가 직접 제공하지 않고 파일 서버인 S3가 담당하도록 했다. 이렇게 해서 웹서버로 들어오는 요청을 분산해서 서버 부담을 줄이자는 전략이다.

API Gateway

람다는 코드를 실행시킴으로써 서버의 일부 역할을 하긴 하지만 HTTP 요청을 받을 수는 없다. 개발할 때 테스트한 것처럼 AWS 이벤트를 받아서 동작할 뿐이다.

서버로 사용하려면 람다가 HTTP 요청을 받을 수 있어야 하는데 API 게이트웨이(API Gateway)가 그런 목적의 서비스다. API 게이트웨이는 호출할수 있는 url를 가지고 있는데 람다 함수를 연결하면 이 url로 람다 함수를 호출이 가능하다. 이렇게 API 게이트웨이와 람다를 같이 사용해야만 비로소 브라우져에서 노드 서버로 요청을 보낼 수 있다.

CloudFront

브라우저가 요청하면 노드 서버는 웹 문서 응답할 것이고 이 문서에 지정된 자바스크립트와 스타일시트 파일을 이어서 요청할 것이다. 하지만 람다에는 서버 코드만 있고 이런 정적파일은 S3에 위치해 있다. 아직 S3에 접근할 방법은 없다.

이런 구조를 해결하기 위해서 크라우드 프론트(CloudFront) 서비스를 이용했다. 정적파일 요청이 들어오면 S3를 호출하고 그렇지 않으면 람다와 연결된 API 게이트웨이를 호출하도록 설정한다.

Route53

클라우드 프론트는 접속할 수 있는 URL이 있어서 이 주소로 서비스에 접속할 수 있다. API 게이트웨이의 주소와 다른 점은 정적 파일까지 접근할 수 있다는 점이다.

하지만 서비스를 출시할 때는 미리 구매해둔 도메인을 사용하기 때문에 DNS 등록이 필요하다. AWS의 router 53이 이 역할을 한다. 서버에 사용할 도메인과 클라우드 프론틍 주소를 연결한다.

결론

이렇게 해서 AWS 서비스를 이용해 웹/어드민을 서버리스 아키텍처로 구성했다. 성능 테스트를 해야 할까 싶어서 artillery 같은 부하 테스트 도구를 사용해 봤다. 뭐 잘 버텨 주는 것 같은데 람다 함수를 테스트 하는 것이 의미가 있을까 싶기도 하다. 마치 유닛 테스트할 때 리액트 본연의 기능을 테스트하는 것과 비슷하다. 이미 검증된 라이브러리를 테스트하는 게 무슨 의미일까?

출시가 얼마 남지 않았다. 이대로 잘 동작할지는 지켜봐야 할 일이다.

여기까지 오는데 삽질을 꽤 많이 했는데 그런 내용을 모아서 다시 정리해 보아야겠다.