• logo

      SeolMyeongTang

  • 싱글벙글 서버리스 이전 삽질기(w. Lambda)

    2023년 5월 02일

    2023년 1월, Notion(이하 노션)에서만 끄적끄적 글을 썼었는데 이걸 개인 블로그로 운영하면 재미있을거 같다는 생각으로 설명탕을 만들었습니다. 설명탕을 만든 이유 중 하나는 개발부터 배포까지 제가 가지고 있는 모든 지식을 총동원하여 서비스 형태로 풀어보고 싶었기 때문입니다. 그중 인프라는 전통적인 방식이라 할 수 있는, 24시간 돌아가는 서버 위에 도커를 띄워서 프론트엔드, 백엔드 서비스를 돌렸습니다. 자동 배포 시스템도 구축하고 자기소개 페이지도 만들고 개인 블로그를 운영하기에 더할 나위 없이 좋았습니다. 하지만 아쉬운 점이 있다면 서버 고정 비용과 노션 api 응답이 느렸다는 점입니다.

    개인 블로그 수준에서는 트래픽이 많지 않아 24시간 돌아가는 서버는 사치입니다. 설명탕 홈페이지 같은 경우는 AWS의 EC2 t3.micro 사양을 사용하고, 예약 인스턴스 1년 형태로 구매하여 한 달에 1만원 정도의 요금을 내고 있습니다. 서버 비용으로 한 달에 1만원이면 크지 않지만 줄일 수만 있다면 줄이고 싶은 비용입니다.

    1만원도 돈이야 돈!
    1만원도 돈이야 돈!

    글 내용 정보 같은 경우는 먼저 노션에 글 초안과 퇴고를 거쳐 글을 완성합니다. 그리고 백엔드에서 Notion API를 통해 글 데이터를 가져오고 프론트엔드에 필요한 내용만 가공해서 던져주는 방식으로 구성했습니다. 이 방식은 Notion API에 의존적입니다. 안타깝게도 Notion API을 통해 글 30개 정도를 불러오는 응답 속도가 평균적으로 2초 정도로 느립니다. react-query에서 initialData 옵션으로 글을 빠르게 불러올 수 있지만 근본적으로 Notion API의 응답 속도를 컨트롤 할 수 없었습니다.

    TSX
    const Body = ({ database }: PropTypes) => {
    	const { data } = useQuery('notion', getDatabase, {
    		initialData: database,
    	});
    	...
    };
    
    export default Body;
    
    로컬 환경에서 Notion API 속도 측정
    로컬 환경에서 Notion API 속도 측정

    이러한 문제를 해결하기 위해 서버리스 형태를 고민했습니다. 서버리스는 EC2와 달리, 서버를 대여하여 사용한 시간만큼 비용을 지불하는 형식이 아니라 사용자가 서버리스를 호출하는 만큼의 비용만 지불하면 되는 형태입니다. 서버리스를 구축하기 위해 AWS의 Lambda, API Gateway, Cloudfront, S3, DynamoDB 제품을 사용했습니다. 제가 계산해본 비용은 달에 몇 천원대로 나오는데 이 제품들의 정확한 비용을 알고 싶으면 AWS 계산기를 활용하면 좋을거 같습니다. 또한 Notion API 대신 DynamoDB에 데이터를 저장하고 Lambda를 통해 데이터를 가져오는 형식으로 Notion API를 대체하는 것으로 결정했습니다.

    서버리스 이전 구조도와 서비리스 구조도
    서버리스 이전 구조도와 서비리스 구조도

    개인 블로그를 운영하는데 전혀 문제 없었던 서버이지만 예전부터 서버리스로 서비스를 구성해보고 싶었고 이참에 비용도 줄일 겸 기존 EC2 대신 서버리스로 옮기는 작업을 진행했습니다. 실전 프로덕션도 아닌 개인 블로그인데 해보고 싶은걸 적용하고 삽질하는게 낭만 아닐까요 😎

    이전부터 개인 블로그에 서버리스를 도입하고 싶었지만, 서버리스에 대한 의문점들이 많았습니다. 서버가 없다면 인증 관리는 어떻게 하는 거지? 자동배포는 어떻게 하는 거지? Cold start 때문에 응답 속도가 느리다는데 성능이 괜찮을까? 복잡한 로직을 서버리스에 올릴 수 있을까? 하는 서버리스에 대한 회의적인 시선이 있어 쉽게 선택하기 어려웠습니다. 설명탕은 글 내용 조회만 하는 단순한 구조라서 인증 관리나 복잡한 DB 쿼리를 날리거나 하지는 않아 서버리스를 구축하면서 서버리스에 궁금했던 모든 질문에 답을 내리지 못했습니다. 생각의 흐름 순서로 작성하여 다소 글이 깔끔하지 못하고 장황할 수 있지만 서버리스를 구축하면서 어떤 점이 좋았고 어떤 점이 불편했는지 저의 경험을 공유하고자 글을 정리하게 되었습니다.

    서버리스로 옮기기 위해 가장 처음으로 한 일은 Notion API 제거였습니다. Notion API를 제거함으로써 프론트엔드, 백엔드, 인프라 모든 부분이 바뀌어졌습니다. 노션에 쓰인 글들은 마크다운 형식으로 가져올 수 있습니다. Notion API를 통해 글 데이터를 가져오지 말고, 노션 글을 마크다운으로 가져오고 이걸 어딘가에 저장하고 서버를 통해 다시 가져오는 방법으로 생각했습니다. 데이터를 저장해야 하니 DB가 필요했습니다. 평소 같으면 RDS는 비용 문제로 사용하지 않고 EC2에 DB 컨테이너를 띄웠을 겁니다. 하지만 서버리스와 통신해야 하고 예상되는 DB 구조가 복잡하지 않아 NoSQL 종류인 DynamoDB를 선택했습니다.

    그 다음으로 DynamoDB를 설계해야했는데 이 부분이 RDBMS이랑 방법이 달라서 헤맸습니다. DB 테이블의 키 이름 컨벤션은 PascalCase로 통일했습니다. DynamoDB에서 PK를 테이블 같은 느낌으로 SK를 id 같은 느낌으로 생각하였습니다. 그리고 글 제목, 글 내용, 글 생성 날짜를 담을 Title, Content, CreatedAt 키를 추가했습니다.

    PKSKTitleContentCreatedAt
    postmdn_autocompletionMDN의 자동완성 검색 이야기(md 형식)2021-08-12
    postqemu_kvmQEMU, KVM, QEMU-KVM가 뭐야?(md 형식)2021-08-18
    postwebassembly웹과 시스템의 만남, WebAssembly(md 형식)2021-10-28

    Notion API를 사용했을 때, 프론트엔드에서는 글 내용을 API 통신으로 json으로 가져오고 렌더링하면 됐습니다. 하지만 지금은 md 형식을 해석하고 렌더링해야 하므로 이 부분을 수정하였습니다. md 렌더링을 위해 react-markdown 모듈을 사용했습니다.

    TSX
    <ReactMarkdown
      className="prose dark:prose-invert max-w-[800px]"
      children={data.Content}
      rehypePlugins={[rehypeRaw]}
      remarkPlugins={[remarkGfm]}
      components={{
        code({ children, className, inline }) {
          return inline ? (
            <code className="p-1 rounded bg-[#f2f2f2] font-mono dark:bg-[#0f081c]">
              {children}
            </code>
          ) : (
            <Code code={children[0]?.toString()} language={className} />
          );
        },
      }}
    />
    

    DB를 만들고 프론트엔드 부분도 처리했으니 이제 서버 쪽을 보겠습니다. DynamoDB에 저장되어 있는 데이터를 가져오기 위해 Lambda를 이용합니다. Lambda는 AWS에서 제공하는 서버리스 컴퓨팅 서비스입니다. 일반적인 서버를 구축하기 위해서는 언어 런타임 프로그램, Apache, 서버 프레임워크 같은 도구들이 필요합니다. 하지만 Lambda를 이용하면 위와 같은 서버 설정 없이 애플리케이션 코드만 있으면 서버 응답을 받을 수 있습니다. 마치 로컬 환경에서 함수를 실행하듯이 말이죠. Lambda는 여러 언어를 지원하는데 저는 Go 언어를 선택했습니다.

    Lambda를 통해 구현하고 싶은 것은 전체 글 조회와 특정 글 조회 기능입니다. 설명탕 글 데이터는 DynamoDB에 저장되어있으니 Lambda와 DynamoDB 간의 통신이 필요합니다. 이를 위해 aws-sdk-go-v2 패키지를 이용했으며 URL query에 따라 글 데이터를 가져오도록 구현했습니다. 전체적인 코드 구현은 GitHub에서 찾아볼 수 있습니다.

    GO
    func getPostsFromDB(ctx context.Context) ([]postWithoutContent, error) {
    	res, err := db.Query(ctx, &dynamodb.QueryInput{
    		TableName:              aws.String(tableName),
    		KeyConditionExpression: aws.String("#PK = :PK"),
    		ExpressionAttributeNames: map[string]string{
    			"#PK": "PK",
    		},
    		ExpressionAttributeValues: map[string]types.AttributeValue{
    			":PK": &types.AttributeValueMemberS{Value: pk},
    		},
    	})
    	errCheck("Couldn't query", err)
    
    	var posts []postWithoutContent
    	err = attributevalue.UnmarshalListOfMaps(res.Items, &posts)
    	errCheck("Couldn't unmarshal query response", err)
    
    	return posts, nil
    }
    

    작성한 Go 코드를 Lambda에서 실행하기 위해서 main()lambda.start(HANDLER_FUNC) 함수를 추가했습니다. 그 후 Lambda에 Go 빌드물을 올리면 됩니다.

    GO
    package main
    
    import (
    	"github.com/aws/aws-lambda-go/lambda"
    )
    
    func main() {
    	lambda.Start(router)
    }
    

    Lambda에 Go 빌드물을 올리는 방법은 다양합니다. Go 빌드 후 나온 빌드물을 zip 압축하여 Lambda에 올리는 방법도 있고 S3를 통해서 올려도 되고 AWS CLI를 이용해도 되고 스크립트를 이용해도 됩니다. 이상적인 방법은 CICD에 스크립트를 활용하여 자동배포를 구축하는 것이 좋겠으나 잦은 서버 배포가 필요 없을 거라 생각되어 빌드물을 압축 후 Lambda에 올리는 방법을 선택했습니다.

    작성한 Lambda는 API Gateway와 연동할거라 query, params와 같은 사용자의 request를 받기 위해 APIGatewayProxyRequest 인자를 추가합니다. 또한 통신하려는 URL에 대하여 cors 허용을 하기 위해 header에 cors 관련 내용도 적어줘야합니다.

    GO
    func getPosts(ctx context.Context) (events.APIGatewayProxyResponse, error) {
    	posts, err := getPostsFromDB(ctx)
    	errCheck("Couldn't get posts from db", err)
    
    	body, err := json.Marshal(posts)
    	errCheck("Couldn't marshal", err)
    
    	return events.APIGatewayProxyResponse{
    		StatusCode: 200,
    		Headers: map[string]string{
    			"Access-Control-Allow-Origin":  "redundant4u.com",
    			"Access-Control-Allow-Methods": "OPTIONS,GET",
    			"Access-Control-Allow-Headers": "Content-Type",
    			"Content-Type":                 "application/json",
    		},
    		Body: string(body),
    	}, nil
    }
    

    코드를 모두 작성했다면 Go를 빌드 후 zip으로 압축하고 이를 Lambda에 올립니다. 주의할 점은 Lambda에 올릴 때 ‘런타임 설정’에서 핸들러 이름을 main으로 바꿔줘야 합니다. 빌드물을 올리면 Lambda에서 배포가 진행됩니다.

    BASH
    GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o main .
    zip function.zip main
    
    Lambda 핸들러 이름 설정
    Lambda 핸들러 이름 설정

    다음으로는 API Gateway를 살펴보겠습니다. Lambda를 통해 서버 코드를 작성했지만 사용자가 직접적으로 Lambda와 통신하지 않습니다. 물론 Lambda와 직접적으로 통신하도록 구성할 수 있지만, API Gateway를 통해 RESTful API 생성, 인증 및 배포 관리, 다른 AWS 서비스와의 연동 등의 기능을 수행 할 수 있습니다. Lambda와 API Gateway를 연동하여 서비스를 보다 견고하게 만들어 보겠습니다.

    API Gateway 이름에서 알 수 있듯이 Nginx와 같이 서버 앞 단의 gateway 역할을 합니다. API Gateway는 외부로부터 들어온 요청을 EC2와 통신할지 Lambda와 통신할지 혹은 다른 서버와 통신할지 선택할 수 있습니다. 구체적인 API Gateway 구축 과정은 생략하겠습니다. 다만 API Gateway와 Lambda를 설정할 때 주의할 점은 ‘통합 요청’ 항목에서 ‘Lambda 프록시 통합 사용’을 활성화해줘야 Lambda 까지 요청이 넘어가게됩니다. 또한 통신하려는 URL에 대한 cors 허용을 해줘야합니다. Lambda와 API Gateway 두 서비스 모두 cors 관련 설정을 해줘야 클라이언트에 정상적으로 데이터를 보낼 수 있으니 주의해야합니다.

    Lambda 프록시 통합 사용 활성화
    Lambda 프록시 통합 사용 활성화
    API Gateway cors 활성화
    API Gateway cors 활성화

    API Gateway 배포를 하게 되면 ap-northeast-2.amazonaws.com/[STAGE_NAME]으로 끝나는 URL을 받게 됩니다. 이 URL을 통해 API Gateway와 통신을 해도 되지만, ‘사용자 지정 도메인 이름’을 통해 설명탕 서브 도메인을 부여했습니다. 참고로 네임서버는 namecheap을 이용했고 도메인과 서브도메인에 대한 SSL 인증은 기존의 letsencrypt에서 AWS의 Certificate Manager로 관리하도록 했습니다.

    이상으로, Notion API를 제거하고 md 형식을 렌더링하기 위해 DynamoDB 설계와 이에 따른 프론트엔드 코드를 수정하였습니다. 또한 Lambda와 API Gateway를 통해 DynamoDB와 통신하는 백엔드를 배포하였습니다. 이제 마지막으로 남은 부분은 프론트엔드 배포입니다. 다음 글을 통해 S3와 CloudFront를 통해 프론트 배포를 진행하도록 하겠습니다.