개인 포트폴리오 사이트 구축
포트폴리오를 PDF로 보내달라는 요청을 받을 때마다 문서를 새로 손봐야 했는데, 요청하는 쪽과 보내는 쪽 모두의 편의를 위해 '웹으로 바로 열람하고 PDF도 자동 생성되는 사이트'를 직접 만들기로 했습니다. 만드는 김에 프론트부터 인프라·CI/CD까지 전부 혼자 해보자는 목표로 시작했습니다.
담당 역할: Next.js 프론트엔드, Puppeteer PDF 생성, 피드백 알림 시스템, Terraform IaC, GitHub Actions CI/CD까지 전 과정 단독 담당. '배포 버튼 한 번이면 끝'인 구조를 만드는 것이 개인적인 기준이었습니다.

정적 페이지는 CloudFront(OAC) → S3로 서빙하고, PDF 생성과 피드백 제출은 API Gateway → Lambda로 처리합니다. 피드백은 EventBridge로 이메일 발송 Lambda와 느슨하게 연결했고, WAF·보안 헤더·X-Ray·CloudWatch 알람으로 보안과 관측을 확보했습니다.
설계 근거
EC2에 Next.js 서버를 올리는 게 가장 간단했지만, 포트폴리오 사이트는 트래픽이 거의 없어서 상시 서버 비용이 아까웠습니다. 정적 페이지는 S3+CloudFront로 비용을 거의 0에 가깝게 만들고, PDF 생성처럼 간헐적이지만 무거운 작업만 Lambda로 분리하는 구조를 택했습니다. 피드백 시스템은 처음에 수신 Lambda가 직접 이메일을 보내게 했는데, 나중에 Slack이나 DB 저장을 추가하려면 그때마다 Lambda를 다시 손대야 하는 구조라 EventBridge로 분리했습니다. Rule만 추가하면 새 소비자를 붙일 수 있는 구조입니다.
S3를 퍼블릭으로 여는 건 보안상 꺼려져서, OAC로 CloudFront를 통해서만 접근하도록 제한했습니다. 포트폴리오에 글로벌 CDN까지 필요할까 싶지만, CloudFront의 캐싱과 HTTPS 처리가 편해서 함께 적용했습니다.
브라우저 인쇄(window.print())로도 PDF는 나오지만, 결과물이 기기마다 달라집니다. 여백·페이지 나눔·배경 그래픽 포함 여부를 사용자가 인쇄 대화상자에서 직접 맞춰야 하고, 특히 폰에서는 브라우저마다 결과가 제각각이라 통제가 안 됩니다. 이력서 성격의 문서라 '누가 어디서 받아도 같은 PDF'가 필요했고, 그러려면 렌더링 주체를 클라이언트에서 서버로 옮겨야 했습니다. 고정된 Chromium 버전과 폰트, 고정된 인쇄 옵션으로 서버가 생성하면 결과가 한 가지로 수렴합니다. 구현에서는 Puppeteer의 Chromium 바이너리가 250MB 이상이라 ZIP 패키징 Lambda의 크기 제한(50MB)을 넘겼고, 컨테이너 이미지 Lambda로 전환해 해결했습니다. 요청이 하루 몇 건 수준이라 상시 서버보다 비용이 훨씬 낮았습니다.
Lambda를 직접 URL로 노출할 수도 있지만, 나중에 인증이나 요청 제한을 추가할 때 API Gateway가 있어야 유연합니다. X-Ray 추적도 여기서 켜면 바로 되는 점이 좋았습니다.
피드백 수신 Lambda가 직접 이메일을 보내면 간단하지만, Slack 알림이나 DB 저장을 나중에 추가하려면 그때마다 Lambda 코드를 수정해야 합니다. EventBridge로 이벤트를 발행하면 소비자를 Rule로만 추가할 수 있어 확장이 자유로워집니다.
CloudFront·S3·WAF·Lambda 등 리소스가 10개가 넘으니 콘솔에서 수동으로 관리하면 '이거 왜 이렇게 설정돼 있지?'가 반복될 게 뻔했습니다. 코드로 정의하면 설정 의도가 히스토리에 남고, 환경을 통째로 재현할 수 있습니다.
매번 빌드 → S3 업로드 → 캐시 무효화를 수동으로 하다 보면 빠트리는 단계가 생겼습니다. push 한 번이면 전체가 돌아가도록 자동화하니 배포 실수가 완전히 사라졌습니다.
- 이슈
- Lambda에서 Puppeteer로 PDF를 생성했더니 한글이 전부 □□□로 깨져 나왔습니다.
- 분석
- Chromium은 시스템에 설치된 폰트를 쓰는데, Lambda 컨테이너 환경에는 한글 폰트가 아예 없었습니다. 로컬에서는 잘 되다가 Lambda에서만 깨지니 처음엔 인코딩 문제인 줄 알았지만, 폰트 부재가 원인이었습니다.
- 해결
- 컨테이너 이미지에 Noto Sans KR을 번들링하고, print 페이지에서 document.fonts.ready를 기다린 뒤에 PDF를 캡처하도록 waitForFunction을 추가했습니다. 외부 CDN에 의존하면 네트워크 상태에 따라 또 깨질 수 있어서 이미지 내에 포함시키는 걸 선택했습니다.
- 결과
- 한글이 일관되게 렌더링되고, 외부 네트워크 의존 없이 어떤 환경에서든 같은 결과를 보장합니다.
- 이슈
- 배포 후 프로젝트 상세 페이지에서 뒤로가기나 홈 버튼을 누르면 홈이 아니라 프로젝트 페이지로 다시 돌아갔습니다.
- 분석
- 모든 정적 파일에 immutable 캐시를 건 게 문제였습니다. Next.js의 RSC 페이로드(.rsc)까지 장기 캐시가 적용돼서, 브라우저가 오래된 라우팅 데이터를 계속 참조하고 있었습니다. '정적 파일은 다 오래 캐시해도 된다'는 단순한 생각이 원인이었습니다.
- 해결
- S3 업로드 단계에서 파일 유형별로 Cache-Control을 분리했습니다. HTML과 RSC 페이로드는 no-cache로, 콘텐츠 해시가 붙은 JS·CSS만 immutable로 적용했습니다.
- 결과
- 라우팅 문제가 해결됐고, 해시가 붙은 에셋의 캐시 효율은 그대로 유지됩니다.
가장 크게 남은 건 '동작하는 것'과 '운영할 수 있는 것'이 완전히 다른 일이라는 감각이었습니다. 정적 사이트를 S3에 올려서 화면이 뜨게 만드는 것까지는 금방 끝났는데, 그 뒤에 붙인 WAF·X-Ray·CloudWatch 알람이 훨씬 오래 걸렸습니다. 기능이 늘어난 것도 아닌데 왜 이걸 붙들고 있나 싶다가도, 알람 덕분에 배포가 깨진 걸 사용자보다 먼저 알아챈 순간부터 생각이 바뀌었습니다. 관측과 방어는 '있으면 좋은 것'이 아니라 운영을 시작하는 조건에 가깝다는 걸 그때 알았습니다.
Terraform도 처음에는 파일 하나에 전부 몰아넣었습니다. 리소스가 늘면서 하나를 고치면 무엇이 같이 바뀌는지 추적이 안 되기 시작했고, 그제서야 acm·s3·cloudfront·lambda·waf처럼 역할별 모듈로 쪼갰습니다. 처음부터 이렇게 짰으면 좋았을까 생각해보면 그건 아닌 것 같습니다. 한 파일이 감당이 안 되는 상태를 직접 겪고 나서야 모듈을 나누는 기준이 무엇인지 스스로 설명할 수 있게 됐습니다.
PDF를 굳이 서버에서 만든 이유도 결국 같은 맥락이었습니다. 브라우저 인쇄로도 PDF는 나오지만, 그건 사용자가 인쇄 대화상자에서 여백과 배경 옵션을 직접 맞춰야 나오는 결과입니다. 갑자기 폰으로 하나 보내야 할 때가 있었는데 그때 나온 PDF가 PC에서 뽑은 것과 달라서, 이력서 성격의 문서라면 '누가 어디서 받아도 같은 파일'이 되어야 한다고 생각했습니다. 그러려면 렌더링 주체를 클라이언트에서 서버로 옮기는 수밖에 없었고, 그게 Lambda와 Puppeteer를 붙인 이유입니다. 기능을 늘리려던 게 아니라 결과물을 하나로 고정하려던 선택이었습니다.
대신 그 선택의 대가로 Cold Start를 안고 가게 됐습니다. 첫 요청이 10초 가까이 걸리고, Provisioned Concurrency를 붙이면 해결된다는 것도 압니다. 그런데 트래픽이 거의 없는 사이트에 상시 비용을 낼 것인가, 아니면 가끔 오는 첫 사용자가 10초를 기다리게 둘 것인가 — 이 둘 사이에서 아직 스스로 답을 정하지 못했습니다. 비용과 사용자 경험이 정면으로 부딪히면 정답이 딱 떨어지지 않는다는 걸, 규모가 가장 작은 이 프로젝트에서 처음 겪어봤습니다.