본문 바로가기
카테고리 없음

Rosterush 캐릭터 타이핑 퀴즈를 AWS 서버리스 아키텍처로 구축한 방법: CloudFront, Lambda, DynamoDB 실전 설계

by junah201 2026. 11. 6.

Rosterush는 오버워치, 원신, 붕괴: 스타레일 등 다양한 게임에 등장하는 캐릭터의 이름을 제한 시간 안에 입력하는 타이핑 퀴즈 서비스입니다. 사용자는 주어진 게임과 난이도에 따라 캐릭터 이름을 입력하고, 모든 정답을 맞히거나 시간이 종료되면 자신의 플레이 기록을 확인할 수 있습니다.

단순한 웹 기반 게임처럼 보이지만 실제 서비스에는 다양한 기술적 요소가 필요합니다. 사용자의 게임 기록을 저장하는 기능부터 전체 이용자와 비교한 백분위 통계, 결과 공유 링크, SNS 미리보기 이미지 생성, 댓글 시스템까지 여러 기능이 유기적으로 연결되어야 합니다.

이러한 기능을 구현하면서 가장 중요하게 고려한 부분은 인프라 운영 비용과 유지보수 부담을 최소화하는 것이었습니다. 사용자가 몰리는 시간대와 그렇지 않은 시간대의 차이가 큰 서비스인 만큼, 상시 실행되는 서버를 운영하기보다는 AWS의 관리형 서비스를 조합하는 방향을 선택했습니다.

이 글에서는 Rosterush를 구축하면서 적용한 AWS CloudFront, Lambda, DynamoDB, S3 중심의 서버리스 아키텍처와 각 기능의 구현 방식, 운영 과정에서 고려한 기술적인 선택을 살펴보겠습니다.

Rosterush에 서버리스 아키텍처를 적용한 이유

서버리스는 서버를 직접 관리하지 않으면서 실제 요청이 발생한 만큼 컴퓨팅 자원을 사용하는 방식입니다. 특히 트래픽 변동이 불규칙한 개인 프로젝트나 소규모 웹 서비스에서 비용 효율적인 선택이 될 수 있습니다.

트래픽 변동에 유연하게 대응하는 사용량 기반 인프라

게임형 웹 서비스는 일반적인 업무용 서비스와 다른 트래픽 특성을 보입니다. 평소에는 방문자가 많지 않더라도 커뮤니티나 SNS에 링크가 공유되면 짧은 시간 동안 접속자가 급격하게 증가할 수 있습니다.

이러한 환경에서 EC2와 같은 가상 서버를 사용하면 사용자가 접속하지 않는 시간에도 인스턴스 운영 비용이 발생합니다. 갑작스러운 트래픽 증가에 대응하려면 추가 서버를 준비하거나 오토 스케일링을 구성하는 작업도 필요합니다.

반면 AWS Lambda는 요청이 발생했을 때 함수를 실행하는 방식이며, DynamoDB의 온디맨드 모드인 PAY_PER_REQUEST 역시 실제 읽기 및 쓰기 요청량을 기준으로 비용을 산정합니다.

따라서 평소 요청이 거의 없는 시간에는 불필요한 컴퓨팅 자원 사용을 줄일 수 있습니다. 트래픽이 증가하더라도 AWS가 제공하는 자동 확장 기능을 활용할 수 있어 운영자가 직접 서버 대수를 조절할 필요가 없습니다.

Rosterush에서는 이러한 특성을 활용해 정적 콘텐츠는 CDN으로 제공하고, 실제 데이터 처리가 필요한 기능에만 Lambda를 사용하는 구조를 채택했습니다.

역할에 따라 분리한 pnpm 모노레포 구조

프로젝트의 코드 관리 방식으로는 pnpm 워크스페이스 기반의 모노레포를 선택했습니다. 하나의 저장소에서 여러 패키지를 관리하되, 각 패키지가 담당하는 역할은 명확하게 구분하는 방식입니다.

프로젝트는 크게 네 가지 패키지로 구성됩니다.

패키지주요 역할사용 기술
web 사용자 인터페이스 및 정적 사이트 생성 Next.js
api API와 AWS 인프라 관리 Serverless Framework v4
core 공통 비즈니스 로직 및 타입 정의 TypeScript
games 게임별 캐릭터 데이터 관리 JSON

web 패키지는 Next.js를 사용하지만 일반적인 서버 렌더링 방식으로 운영하지 않습니다. 정적 내보내기(Static Export)를 사용해 빌드 시점에 HTML, CSS, JavaScript 파일을 생성하고, 이를 S3에 업로드해 서비스합니다.

api 패키지는 Serverless Framework v4를 통해 Lambda 함수와 AWS 리소스를 정의합니다. API 요청 처리뿐 아니라 DynamoDB, S3, CloudFront 등의 인프라 구성도 코드로 관리합니다.

core 패키지에는 정답 판정, 문자열 정규화, 백분위 계산, 통계 집계, API 스키마 등 여러 영역에서 공통으로 사용하는 로직을 배치했습니다.

마지막으로 games 패키지는 게임별 캐릭터 목록을 JSON 파일로 관리하며, 데이터 형식과 캐릭터 목록의 유효성을 검사하는 스크립트도 제공합니다.

이러한 분리 방식의 가장 큰 장점은 동일한 비즈니스 로직을 브라우저와 Lambda에서 재사용할 수 있다는 점입니다. 예를 들어 캐릭터 이름의 정답 판정과 문자열 정규화 로직을 양쪽에서 별도로 구현하면 미묘한 동작 차이가 발생할 수 있습니다. 공통 패키지를 활용하면 이러한 불일치를 줄이고 유지보수도 간편하게 진행할 수 있습니다.

CloudFront를 중심으로 구성한 단일 도메인 아키텍처

Rosterush에서는 웹사이트와 API를 별도의 도메인으로 분리하지 않고 하나의 CloudFront 배포를 통해 제공합니다. 요청 경로에 따라 서로 다른 오리진으로 전달하는 방식으로 구성했습니다.

CloudFront 캐시 동작을 활용한 경로별 오리진 분리

CloudFront는 요청 URL의 경로 패턴에 따라 서로 다른 오리진으로 요청을 전달할 수 있습니다. 이 기능을 활용하면 하나의 도메인에서 정적 웹사이트, API, 이미지, 통계 데이터를 모두 제공할 수 있습니다.

Rosterush의 주요 경로는 다음과 같이 구성했습니다.

경로연결 대상캐시 정책
/api/* API Gateway HTTP API 캐시 비활성화
/api/shares/* API Gateway HTTP API 장기 캐시
/og/* API Gateway HTTP API 장기 캐시
/s/* API Gateway HTTP API 장기 캐시
/stats/* 통계 데이터 S3 버킷 오리진 정책에 따름
/img/* 캐릭터 이미지 S3 버킷 오리진 정책에 따름
그 외 모든 경로 Next.js 정적 사이트 S3 버킷 정적 콘텐츠 캐시

일반적인 API 요청은 사용자별 데이터나 최신 상태를 반환해야 하므로 캐시하지 않습니다. 반면 공유 정보, OG 이미지, 공유 페이지처럼 생성 이후 내용이 변하지 않는 리소스는 긴 캐시 수명을 적용합니다.

이때 중요한 점은 CloudFront의 캐시 동작이 설정된 순서대로 평가된다는 것입니다. /api/*가 /api/shares/*보다 먼저 등록되어 있으면 공유 API 역시 일반 API 정책에 포함될 수 있습니다.

따라서 구체적인 경로 패턴을 먼저 배치하고 포괄적인 패턴을 나중에 배치하는 것이 올바른 설정의 핵심입니다.

또한 웹사이트와 API가 동일한 도메인을 사용하므로 브라우저에서는 동일 출처(Same Origin) 요청으로 처리됩니다. 별도의 CORS 허용 도메인이나 복잡한 교차 출처 정책을 관리하지 않아도 된다는 장점이 있습니다.

서비스 이용자가 한국과 일본에 집중되어 있다는 점도 고려했습니다. 이에 따라 두 지역의 엣지 로케이션을 포함하는 PriceClass_200을 선택해 CDN 비용과 사용자 응답 속도 사이의 균형을 맞췄습니다.

CloudFront Functions를 이용한 언어 선택과 URL 정규화

정적 export 방식으로 생성한 Next.js 사이트는 실제 파일 경로에 맞게 요청 URL을 변환해야 합니다. 예를 들어 /ko/genshin/이라는 주소로 접근하면 S3에 저장된 /ko/genshin/index.html 파일을 읽을 수 있어야 합니다.

이를 위해 CloudFront Functions를 사용했습니다.

CloudFront Functions는 CloudFront의 뷰어 요청 단계에서 실행되는 경량 JavaScript 함수입니다. Lambda@Edge보다 실행 구조가 단순하고 비용 부담도 낮기 때문에 URL 변환이나 간단한 리다이렉트 처리에 적합합니다.

Rosterush에서는 다음과 같은 작업을 수행합니다.

  • 디렉터리 형태의 요청을 실제 index.html 파일 경로로 변환합니다.

  • 루트 경로(/) 접근 시 사용자의 언어 설정을 확인합니다.

  • 언어 쿠키와 Accept-Language 헤더를 참고해 한국어, 일본어, 영어 중 적절한 페이지로 이동시킵니다.

  • 지원하지 않는 언어는 영어를 기본값으로 지정합니다.

  • 마지막에 슬래시가 없는 URL은 슬래시가 포함된 주소로 301 리다이렉트합니다.

특히 마지막 URL 정규화 과정은 SEO 측면에서도 의미가 있습니다. /ko/genshin과 /ko/genshin/이 서로 다른 주소로 취급되는 상황을 방지해 동일한 콘텐츠가 여러 URL로 노출될 가능성을 줄일 수 있습니다.

Origin Access Control을 통한 S3 및 API 접근 보호

정적 콘텐츠를 S3에 저장했다고 해서 버킷을 공개적으로 열어 둘 필요는 없습니다. Rosterush에서는 모든 S3 버킷의 퍼블릭 액세스를 차단하고 CloudFront의 Origin Access Control(OAC)을 통해서만 객체를 읽을 수 있도록 구성했습니다.

OAC는 CloudFront가 오리진에 요청할 때 서명된 요청을 전달하도록 하는 기능입니다. 이를 통해 S3 버킷에 대한 직접적인 공개 접근을 차단하면서 CDN을 통한 콘텐츠 제공은 정상적으로 유지할 수 있습니다.

통계 데이터 버킷에는 추가적인 접근 제어도 적용했습니다. 공개 통계 파일이 저장되는 stats/ 경로만 CloudFront를 통해 읽을 수 있도록 하고, 집계 과정에서 사용하는 중간 데이터가 포함된 internal/ 경로는 외부에 노출되지 않도록 분리했습니다.

API Gateway 역시 CloudFront를 통하지 않는 직접 요청을 제한합니다. CloudFront가 오리진 요청에 X-Origin-Verify라는 사용자 지정 헤더를 추가하고, Lambda에서 해당 값이 올바른지 확인하는 방식입니다.

이렇게 하면 API Gateway 엔드포인트가 외부에 알려지더라도 비밀 헤더가 없는 요청은 정상적으로 처리하지 않도록 만들 수 있습니다.

여기서 한 가지 주의해야 할 AWS 동작이 있습니다. OAC로 보호된 S3 버킷은 존재하지 않는 객체를 요청했을 때 일반적인 404 대신 403을 반환할 수 있습니다.

이를 처리하기 위해 CloudFront의 사용자 지정 오류 응답을 이용해 403을 404 페이지로 변환했습니다. 다만 이 설정은 배포 전체에 영향을 줄 수 있으므로 API에서 실제 권한 오류를 403으로 반환하지 않도록 응답 상태 코드 체계도 함께 고려해야 합니다.

AWS Lambda와 DynamoDB를 활용한 게임 기록 API

게임이 종료되면 브라우저에서 생성된 결과 데이터가 API로 전달됩니다. Lambda는 요청을 검증한 후 DynamoDB에 기록을 저장하며, 비정상적인 요청이나 자동화된 플레이를 판별하는 작업도 함께 수행합니다.

Node.js 24와 ARM64 기반 Lambda 실행 환경

Lambda의 실행 환경은 Node.js 24와 arm64 아키텍처로 구성했습니다. ARM64는 AWS Graviton 프로세서를 사용하는 실행 환경으로, x86 기반 환경과 비교했을 때 비용 효율적인 선택지가 될 수 있습니다.

기본적인 함수 설정은 다음과 같습니다.

항목설정
런타임 Node.js 24
아키텍처 arm64
기본 메모리 512MB
기본 제한 시간 10초
이미지 렌더링 및 집계 1024MB

모든 함수에 동일한 메모리를 할당하지 않고 작업 특성에 따라 자원을 조정했습니다.

일반적인 API 요청은 512MB로 처리하지만 이미지 렌더링이나 데이터 집계처럼 상대적으로 무거운 작업에는 1024MB를 할당합니다.

배포 과정에서는 esbuild를 이용해 코드를 번들링합니다. 여러 모듈을 하나의 배포 파일로 묶으면 패키지 크기를 줄일 수 있고, Lambda가 초기 실행 과정에서 불러와야 하는 파일 수도 줄어듭니다. 이는 콜드 스타트 시간을 줄이는 데에도 도움이 됩니다.

Zod를 활용한 입력 검증과 비정상 기록 필터링

사용자가 제출하는 게임 기록은 그대로 데이터베이스에 저장하지 않습니다. 먼저 요청 데이터가 올바른 형식인지 검증하고, 게임 규칙에 어긋나는 값이 포함되어 있는지 확인합니다.

기록 저장 API의 검증 과정은 다음과 같습니다.

  1. 요청 본문의 크기가 16KB를 초과하는지 확인합니다.

  2. Zod 스키마를 이용해 데이터 구조와 필드의 유효성을 검사합니다.

  3. 게임에 실제로 존재하지 않는 캐릭터가 포함되어 있는지 확인합니다.

  4. 개별 캐릭터의 정답 시간이 전체 플레이 시간을 초과하는지 검사합니다.

  5. 모든 캐릭터를 맞힌 결과라면 실제로 전체 캐릭터가 정답 목록에 포함되어 있는지 검증합니다.

이러한 검증은 단순히 잘못된 데이터가 저장되는 것을 방지하는 것뿐 아니라, 클라이언트에서 임의로 조작한 기록이 통계에 영향을 미치는 상황을 줄이는 역할도 합니다.

추가로 비정상적으로 빠른 입력 기록을 구분하기 위한 기준도 마련했습니다. 캐릭터 하나당 평균 입력 시간이 300밀리초보다 짧은 기록은 일반적인 사람이 직접 입력했다고 보기 어려울 수 있으므로 flagged 상태로 저장합니다.

중요한 점은 이러한 기록을 무조건 삭제하거나 저장 단계에서 거부하지 않는다는 것입니다. 데이터 자체는 보관하되 백분위 통계와 공유 기능에서는 제외합니다.

이 방식은 정상적인 사용자의 이용 흐름을 불필요하게 차단하지 않으면서도 통계의 신뢰성을 유지할 수 있다는 장점이 있습니다.

날짜 기반 파티션 키와 DynamoDB TTL 설계

DynamoDB는 데이터를 어떤 방식으로 조회할지 먼저 고려한 뒤 파티션 키와 정렬 키를 설계해야 합니다.

Rosterush의 게임 기록 테이블은 다음과 같은 키 구조를 사용합니다.

  • 파티션 키: 게임#언어#난이도#날짜

  • 정렬 키: 시간순 정렬이 가능한 ULID

예를 들어 원신의 한국어 일반 난이도 기록을 특정 날짜 기준으로 조회하려면 해당 조합에 해당하는 파티션 키를 사용하면 됩니다.

정렬 키에는 ULID를 활용했습니다. ULID는 시간 정보를 포함하는 식별자로, 생성 순서에 따른 정렬이 가능하다는 특징이 있습니다.

이러한 키 구조 덕분에 특정 게임과 모드의 특정 날짜 기록을 조회할 때 테이블 전체를 스캔하지 않고 필요한 파티션에서 데이터를 가져올 수 있습니다.

오래된 데이터의 관리에는 DynamoDB TTL(Time to Live)을 사용합니다. 각 기록에 expiresAt 속성을 추가하고, 기록 생성 시점으로부터 30일 뒤의 만료 시각을 저장합니다.

TTL이 활성화되면 만료된 항목은 DynamoDB가 백그라운드에서 자동으로 정리합니다. 별도의 주기적인 삭제 Lambda를 만들거나 운영자가 직접 오래된 데이터를 관리할 필요가 없습니다.

DynamoDB 원자적 카운터를 이용한 IP별 Rate Limit

API 남용을 방지하기 위해 별도의 Redis 서버를 추가하는 대신 DynamoDB의 원자적 업데이트 기능을 활용했습니다.

Rate Limit 테이블의 키는 다음과 같은 형태로 구성합니다.

이름#IP해시#구간길이#구간번호

요청이 들어오면 UpdateItem의 ADD 연산을 사용해 해당 구간의 요청 횟수를 1 증가시킵니다.

DynamoDB의 원자적 업데이트를 이용하기 때문에 여러 요청이 동시에 들어와도 카운터가 서로 덮어쓰이는 문제를 방지할 수 있습니다.

카운터 값이 설정된 한도를 초과하면 HTTP 429 응답과 함께 Retry-After 헤더를 반환합니다. 요청 제한 구간이 종료되면 해당 항목은 TTL을 통해 자동으로 삭제됩니다.

서비스에서는 기능별로 서로 다른 제한을 적용할 수 있습니다.

기능요청 제한
게임 기록 저장 10분당 20회
댓글 작성 1분당 5회
댓글 작성 추가 제한 1시간당 30회

이처럼 여러 시간 단위의 제한을 동시에 적용하면 짧은 시간 동안 반복적으로 요청하는 행위뿐 아니라 장시간에 걸친 과도한 요청도 제어할 수 있습니다.

개인정보 보호를 위한 IP 처리 방식도 중요합니다. 사용자 IP 주소를 원문 그대로 데이터베이스에 저장하지 않고, CloudFront가 전달하는 CloudFront-Viewer-Address 헤더에서 IP를 추출한 뒤 HMAC-SHA256 방식으로 변환합니다.

단순 SHA-256 해시만 사용하면 IPv4 주소 공간을 대입해 원래 IP를 추측할 수 있습니다. 반면 비밀 키를 사용하는 HMAC은 키가 없는 외부인이 동일한 방식으로 해시를 계산하기 어렵습니다.

따라서 비밀 키가 설정되지 않은 환경에서는 Rate Limit 기능이 실행되지 않도록 구성해 보안 설정 누락으로 인한 위험도 방지했습니다.

백분위 통계를 실시간 계산하지 않고 정적 JSON으로 제공하는 방법

Rosterush에서는 게임 결과를 제출한 사용자에게 자신의 기록이 전체 이용자 중 어느 정도 수준인지 알려 줍니다. 이를 위해 전체 기록을 기반으로 백분위 통계를 생성하지만, 사용자 요청이 들어올 때마다 계산하지는 않습니다.

스케줄 기반 Lambda를 이용한 주기적인 증분 집계

백분위 통계는 여러 기록을 비교하고 정렬해야 하는 작업이므로 사용자 요청이 발생할 때마다 실행하면 불필요한 비용과 응답 지연이 발생할 수 있습니다.

이 문제를 해결하기 위해 통계 집계 작업을 일반 API 요청 경로에서 분리했습니다.

집계 Lambda는 Serverless Framework의 schedule 이벤트를 통해 자동 실행되도록 설정했습니다. 실행 시각은 한국 시간 기준 매주 월요일과 목요일 오전 4시입니다.

집계 대상은 게임, 언어, 난이도 조합별로 구분합니다. 각 조합에 대해 S3에 저장된 이전 집계 상태를 불러온 다음, 마지막으로 처리한 ULID 이후에 추가된 기록만 조회합니다.

이러한 방식을 **증분 집계(Incremental Aggregation)**라고 합니다.

매번 모든 기록을 다시 조회하고 계산하는 대신 새롭게 추가된 데이터만 처리하기 때문에 기록이 누적되더라도 불필요한 연산을 크게 줄일 수 있습니다.

ULID 생성 시점과 데이터 저장 시점의 차이를 고려한 안전 구간

증분 집계에서는 데이터 누락을 방지하는 것이 중요합니다. 특히 ULID가 생성된 시점과 실제 DynamoDB에 저장되는 시점 사이에는 시간 차이가 발생할 수 있습니다.

예를 들어 Lambda가 요청을 받은 시점에 ULID를 생성했지만, 데이터베이스에 저장하기 전에 집계 작업이 시작될 수 있습니다.

이때 집계 함수가 해당 ULID까지 처리한 것으로 커서를 이동시키면 문제가 발생합니다. 아직 저장되지 않은 기록은 다음 집계에서 커서 이전 데이터로 취급되기 때문에 영구적으로 누락될 수 있습니다.

이를 방지하기 위해 현재 시각을 기준으로 5분의 안전 구간을 설정했습니다.

집계 작업은 현재 시각보다 5분 이전까지 생성된 기록만 처리합니다. 최근 5분 이내에 생성된 기록은 다음 집계 실행으로 넘겨 저장 지연에 따른 누락 가능성을 줄입니다.

데이터 저장 순서도 중요합니다. 집계 상태를 공개 JSON 파일보다 먼저 저장하도록 구성했습니다.

만약 공개 JSON을 생성하거나 업로드하는 과정에서 오류가 발생하더라도 집계 상태가 먼저 저장되어 있으면 다음 실행에서 동일한 상태를 기반으로 결과를 다시 생성할 수 있습니다.

CloudFront 캐시와 최소 표본 수를 활용한 통계 제공

집계 결과는 S3에 JSON 파일로 저장하고 CloudFront를 통해 제공합니다. 브라우저는 게임이 종료된 후 필요한 통계 JSON을 내려받아 자신의 기록이 어느 백분위에 해당하는지 계산합니다.

즉, 사용자가 통계를 확인하기 위해 별도의 Lambda API를 호출할 필요가 없습니다.

통계 파일은 크게 두 가지 형태로 관리합니다.

파일용도캐시 기간
latest.json 가장 최근에 생성된 통계 1시간
날짜별 스냅샷 특정 시점의 통계 보관 1년

최신 통계 파일은 주기적으로 갱신되므로 1시간 캐시를 적용합니다. 반면 날짜별 스냅샷은 한 번 생성되면 내용이 변경되지 않으므로 1년 동안 캐시할 수 있습니다.

추가로 백분위를 공개하기 위한 최소 표본 수도 설정했습니다. 기록이 100개 미만인 경우에는 백분위 정보를 제공하지 않습니다.

표본이 지나치게 적으면 몇 개의 기록만으로 순위가 크게 달라질 수 있습니다. 따라서 일정 수준 이상의 데이터가 확보된 경우에만 통계를 공개해 사용자에게 보다 신뢰할 수 있는 정보를 제공하도록 했습니다.

Lambda에서 동적으로 생성하는 SNS 공유 미리보기 이미지

게임 결과를 다른 사람과 공유하는 기능은 퀴즈 서비스의 중요한 요소입니다. 단순한 URL만 제공하는 것보다 플레이 결과가 포함된 미리보기 카드를 함께 표시하면 공유 콘텐츠의 전달력을 높일 수 있습니다.

Rosterush에서는 OG(Open Graph) 이미지를 Lambda에서 직접 생성합니다.

Satori와 resvg-wasm을 이용한 PNG 이미지 렌더링

일반적으로 동적인 이미지 생성을 구현할 때는 Chromium과 같은 헤드리스 브라우저를 실행하는 방식을 고려할 수 있습니다. 하지만 Lambda 환경에 브라우저를 포함하면 배포 패키지 크기가 커지고 실행 환경도 복잡해집니다.

Rosterush에서는 이러한 부담을 줄이기 위해 satori와 resvg-wasm을 조합했습니다.

이미지 생성 과정은 다음과 같습니다.

  1. /og/{공유ID}.png 경로로 요청이 들어옵니다.

  2. Lambda가 공유 ID를 이용해 게임 결과 데이터를 조회합니다.

  3. Satori를 사용해 JSX 기반 레이아웃을 SVG로 변환합니다.

  4. resvg-wasm으로 SVG를 PNG 이미지로 렌더링합니다.

  5. 생성된 이미지를 HTTP 응답으로 반환합니다.

이미지 크기는 SNS와 메신저의 공유 미리보기에 적합한 1200×630 픽셀로 설정했습니다.

Satori는 React와 유사한 JSX 문법으로 레이아웃을 작성할 수 있어 이미지 구성 요소를 관리하기 편리합니다. 또한 resvg-wasm은 WebAssembly 기반 렌더링을 제공하므로 별도의 브라우저 프로세스 없이 PNG를 생성할 수 있습니다.

WebAssembly 파일은 esbuild의 바이너리 로더를 이용해 번들에 포함했습니다. 따라서 Lambda 실행 중 별도의 파일 시스템 경로에서 바이너리를 읽어오는 과정이 필요하지 않습니다.

한글과 일본어 폰트 용량을 줄이는 서브셋 적용

이미지 생성 과정에서 고려해야 할 또 다른 문제는 글꼴 파일의 크기입니다.

한글과 일본어는 표현할 수 있는 문자의 수가 많기 때문에 일반적인 폰트 파일의 크기가 상당히 큽니다. 이러한 파일을 Lambda 배포 패키지에 그대로 포함하면 패키지 용량이 증가하고 초기 로딩에도 부담이 발생할 수 있습니다.

이를 해결하기 위해 Google Fonts CSS API의 text 파라미터를 활용했습니다.

이 파라미터를 사용하면 필요한 문자를 지정해 해당 문자만 포함된 글꼴 파일을 요청할 수 있습니다.

예를 들어 공유 이미지에 표시해야 하는 캐릭터 이름과 게임 제목, 결과 텍스트에 해당하는 글자만 요청하면 전체 글꼴 대신 훨씬 작은 TTF 파일을 사용할 수 있습니다.

여기서 한 가지 기술적인 주의점이 있습니다. Satori는 WOFF2 형식을 직접 읽지 못하므로 브라우저가 아닌 User-Agent를 사용해 요청하여 TTF 형식의 파일을 받아야 합니다.

다운로드한 글꼴은 Lambda 실행 환경의 메모리에 캐시합니다. 동일한 실행 환경이 재사용되는 웜 스타트 상황에서는 이미 가져온 글꼴을 다시 다운로드하지 않아도 됩니다.

1년 캐시 정책과 Open Graph 메타 태그 주입

공유 미리보기 이미지는 한 번 생성되면 내용이 변경되지 않는 특성이 있습니다. 따라서 이미지 응답에는 다음과 같은 캐시 정책을 적용했습니다.

Cache-Control: public, max-age=31536000, immutable

이 설정은 브라우저와 CDN이 이미지를 최대 1년 동안 캐시할 수 있도록 합니다. 처음 이미지를 요청한 이후에는 CloudFront 엣지에서 직접 응답하므로 동일한 이미지에 대한 Lambda 실행을 줄일 수 있습니다.

공유 링크는 /s/{공유ID} 형태로 제공합니다.

해당 경로에 요청이 들어오면 API가 정적 웹사이트의 HTML을 가져온 뒤, 공유 ID에 해당하는 이미지와 정보를 이용해 Open Graph 메타 태그를 삽입합니다.

이 과정에서 og:title, og:description, og:image 등의 값을 동적으로 구성합니다.

결과적으로 실제 화면은 정적 사이트를 기반으로 유지하면서도, 공유 링크마다 서로 다른 미리보기 정보를 제공할 수 있습니다.

Rosterush 운영 과정에서 얻은 서버리스 아키텍처 설계 원칙

Rosterush를 구축하면서 확인한 핵심은 모든 기능을 서버에서 직접 처리할 필요가 없다는 점입니다. 사용자 요청에 필요한 작업만 Lambda에서 실행하고, 나머지는 정적 파일과 CDN 캐시로 처리하는 것이 전체 구조를 단순하게 만드는 데 효과적이었습니다.

읽기 작업은 CDN에 맡기고 데이터 변경만 Lambda에서 처리하기

가장 중요한 설계 원칙은 읽기와 쓰기의 처리 방식을 구분하는 것입니다.

게임 기록 저장이나 댓글 작성처럼 실제 데이터 변경이 필요한 기능은 Lambda와 DynamoDB를 이용합니다.

반면 통계 조회, 공유 정보, 미리보기 이미지처럼 반복적으로 요청되는 데이터는 가능한 한 CloudFront 캐시를 통해 제공합니다.

이렇게 하면 API 서버가 처리해야 하는 요청 수를 줄이고, 사용자는 가까운 엣지 로케이션에서 콘텐츠를 빠르게 전달받을 수 있습니다.

비용이 큰 연산을 사용자 요청 경로에서 분리하기

백분위 계산과 같은 집계 작업은 사용자 요청이 발생할 때마다 실행할 필요가 없습니다.

정해진 시간에 스케줄 Lambda를 실행해 미리 결과를 생성하면 사용자에게는 완성된 JSON 파일만 전달하면 됩니다.

이러한 구조는 응답 속도를 개선할 뿐 아니라, 갑작스러운 접속 증가가 발생했을 때도 집계 작업이 사용자 요청에 영향을 주지 않도록 분리하는 효과가 있습니다.

AWS 관리형 기능을 활용해 별도 서버 운영 줄이기

DynamoDB TTL, 원자적 카운터, CloudFront OAC 등은 별도의 관리 서버를 구축하지 않고도 다양한 운영 문제를 해결할 수 있도록 지원합니다.

  • DynamoDB TTL: 오래된 기록과 요청 제한 데이터를 자동 정리합니다.

  • 원자적 카운터: 별도의 캐시 서버 없이 요청 횟수를 안전하게 관리합니다.

  • CloudFront OAC: S3 버킷에 대한 직접 접근을 제한합니다.

  • CloudFront Functions: 간단한 URL 변환과 리다이렉트를 처리합니다.

  • Lambda 스케줄 이벤트: 주기적인 통계 집계를 자동 실행합니다.

이처럼 AWS에서 제공하는 기능을 적절하게 조합하면 작은 규모의 서비스에서도 보안과 확장성을 고려한 인프라를 구축할 수 있습니다.

Serverless Framework를 활용한 인프라 코드 관리

마지막으로 인프라 구성은 Serverless Framework v4를 중심으로 코드화했습니다.

Lambda 함수뿐 아니라 DynamoDB 테이블, S3 버킷, CloudFront 배포 등 주요 AWS 리소스를 serverless.yml을 통해 관리합니다.

이렇게 구성하면 개발 환경과 운영 환경에서 동일한 인프라 정의를 활용할 수 있습니다. 콘솔에서 직접 리소스를 생성하거나 설정을 변경하는 작업을 최소화하고, 코드 변경 내역을 Git으로 추적할 수 있다는 점도 장점입니다.

결국 Rosterush의 서버리스 아키텍처는 단순히 EC2를 사용하지 않는 구성에 그치지 않습니다. 각 요청의 성격에 따라 정적 콘텐츠, CDN 캐시, Lambda, DynamoDB의 역할을 명확하게 분리한 설계입니다.

개인 프로젝트나 트래픽 변동이 큰 소규모 웹 서비스를 구축하고 있다면, 처음부터 상시 실행 서버를 운영하기보다는 정적 사이트와 CloudFront를 중심으로 구성하고 실제 데이터 처리가 필요한 부분에만 Lambda를 도입하는 방법을 검토해 볼 만합니다.

서버를 직접 관리하는 시간을 줄이고 서비스의 핵심 기능에 집중할 수 있다는 점에서 서버리스 아키텍처는 충분히 실용적인 선택지가 될 수 있습니다.