나만의 포트폴리오를 만들며 겪었던 상황들에 대한 정리입니다.
지금까지 내 포트폴리오는 노션에 잘 정리되어 있었다. 아주 예쁘게. 나만 볼 수 있게.
문제는 여기서 시작된다.
회사에 지원하거나 누군가 "그래서 지금까지 뭐 하셨어요?"라고 물어보면... 보여줄 게 없다.
"어 그... 노션에 있는데... 링크 드릴까요...?" (상대방 표정: 🤨)
그리고 결정적으로, 나는 프론트엔드 개발자다.
근데 나를 소개하는 웹페이지가 없다.
프론트 개발자가 본인 웹사이트 하나 없는 건, 요리사가 집에서 배달 시켜 먹는 것과 비슷한 느낌이랄까. 틀린 말은 아닌데 뭔가 좀... 그렇잖아요.
트러블슈팅은 또 어떻고. 에러 만나면 그 순간엔 셜록 홈즈처럼 물고 늘어져서 해결한다. 그리고 3일 뒤 똑같은 에러를 만나면? "어? 이거 어디서 봤는데...?" 기억이 안 난다. 분명히 고쳤는데. 나는 대체 뭘 고친 걸까. 미제 사건만 늘어간다.
시도해보고 싶었던 기술들도 마찬가지다. "이거 나중에 꼭 써봐야지" 하고 북마크만 쌓여간다. 그 '나중에'는 대체 언제 오는 걸까? (스포일러: 안 옴) 그렇게 나의 성장 그래프는 완만한 우상향... 아니 그냥 평지를 유지하고 있었다.
그래서 반성했다. 그리고 결심했다. 포트폴리오를 직접 만들기로. 미뤄뒀던 기술들을 이참에 다 끌어와서, 이 프로젝트 하나에 그동안의 나를 몽땅 정리해보려 한다.
이번 포트폴리오에 쓴 스택은 이렇게 정리됐다.
| 분류 | 기술 |
|---|---|
| 프레임워크 | Astro 6, TypeScript |
| 스타일링 | Tailwind CSS v4 |
| 콘텐츠 | MDX, Astro Content Collections + Zod |
| 배포 | GitHub Actions → AWS S3 + CloudFront |
Astro — 이유는 단순했다. 포트폴리오 사이트는 데이터가 실시간으로 바뀌지 않는다. 내 소개, 프로젝트 목록, 블로그 포스트 — 전부 빌드 타임에 확정되는 정적 콘텐츠다. 방문자가 뭘 누른다고 내 경력이 실시간으로 늘어나는 것도 아니고(늘어나면 좋겠다), 여기에 서버가 돌아갈 이유가 딱히 없었다. 이 사이트는 블로그까지 겸할 예정이었다. 마크다운으로 글 쓰고, 빌드하면 정적 페이지로 뽑히는 구조 — 딱 Astro가 제일 잘하는 일이다. 그래서 Astro가 낙점됐다. 기본이 SSG라, MDX를 빌드 타임에 HTML로 다 구워버리고 JavaScript는 정말 필요한 곳에만 콕 집어 끼워 넣는다. 그리고 사실 GeekNews 보다가 써보고 싶어져서 써봤다.
Tailwind CSS v4 — Styled Component 와 뭘 쓸지 고민을 좀 했다. 다만, 해당 프로젝트에서는 많은 컴포넌트가 나오지 않을 것이고 딱히 css 모듈화가 필요할 것 같지 않아, 한눈에 디자인과 화면 구성을 파악하기 쉽게 컴포넌트 파일 하나만 열면 마크업이랑 스타일이 다 보이는 Tailwind를 선택했다.
MDX + Content Collections + Zod — 블로그 글은 MDX로 쓴다. 마크다운인데 필요하면 컴포넌트도 꽂을 수 있으니, 글 안에 인터랙티브한 요소를 넣고 싶을 때 편했다. 그리고 Astro의 Content Collections에 Zod로 스키마를 걸어놨다. 글마다 제목, 날짜, 태그 같은 frontmatter를 적는데, 오타로 date를 data라고 쓰거나 날짜 형식을 틀리면 빌드 단계에서 바로 걸린다. 런타임에 "어? 왜 안 나오지?" 하고 헤매는 대신, 빌드가 대신 잔소리를 해주는 셈이다. 타입 안전한 콘텐츠, 생각보다 마음이 편하다.
GitHub Actions → S3 + CloudFront — CI/CD는 직접 구성했다. main 브랜치에 push 하면 자동으로 S3에 배포되는 파이프라인을, 이번엔 직접 만들어보고 싶었다. AWS 인증 붙이는 것부터 캐시 무효화(invalidation) 날리는 것까지 전부 손으로 짜보는 것 — 그게 하고 싶었던 일이다. 편한 버튼 누르고 넘어가면 또 "됐으니까 됐지 뭐" 하고 아무것도 안 남을 게 뻔했으니까.
스택을 정했으니, 이제 화면을 어떻게 쌓을지 차례다. 전체 구조를 먼저 트리로 펼쳐보면 이렇다.
BaseLayout (Nav + Footer + 스크롤 등장 애니메이션)
├── / (index.astro)
│ ├── Hero — Matter.js 물리 시뮬레이션 + 헤드라인 사이클링
│ ├── About — 소개 + 경력/학력 타임라인
│ ├── Skills — 기술 스택 그룹 목록
│ ├── Projects — 사이드 프로젝트 카드 목록
│ └── Contact — 연락처
│
├── /blog (blog/index.astro)
│ └── 카테고리 필터 + 키워드 검색 + 페이지네이션
│
├── /blog/[slug] (BlogLayout)
│ └── MDX 블로그 포스트
│
└── /projects/[slug] (ProjectLayout)
└── MDX 프로젝트 세부 페이지
크게 보면 메인 한 장(원 페이지) + 블로그 + 프로젝트 상세, 이렇게 세 갈래다. 메인은 Hero부터 Contact까지 쭉 내려가며 보는 랜딩이고, 블로그랑 프로젝트는 MDX로 쓴 글이 [slug]별로 페이지가 되는 구조다.
껍데기를 매번 새로 짜기 싫어서, 레이아웃을 상속 구조로 잡았다. 위에서 아래로 물려받는다.
BaseLayout — 공통 껍데기
모든 페이지가 뒤집어쓰는 최상위 레이아웃이다. <head> 메타 태그, OG 태그, Pretendard 폰트, Nav, Footer가 전부 여기 들어있다. 그리고 IntersectionObserver로 .animate-in 요소들의 스크롤 등장 애니메이션도 여기서 한 번에 처리한다. 요소가 화면에 들어오는 순간 스르륵 나타나는 그거. 페이지마다 따로 붙일 필요 없이, 클래스만 달아두면 알아서 등장한다.
BlogLayout — 블로그 포스트용
BaseLayout을 상속받고, 여기에 글 읽기용 타이포그래피를 얹었다. 본문 폭, 줄 간격, 제목 위계 — 긴 글을 읽어도 눈이 안 피곤하게. 껍데기는 BaseLayout이 알아서 해주니, 얘는 글 읽는 경험에만 집중하면 됐다.
ProjectLayout — 프로젝트 상세용
마찬가지로 BaseLayout 상속. 대신 위쪽에 프로젝트 헤더(스택 / 기간 / 링크)를 붙이고, 아래엔 트러블슈팅 아코디언을 넣었다. 이 블로그를 시작한 이유 중 하나가 "트러블슈팅 해결하고 그냥 넘어가서 기억 못 하는 것"이었으니까, 프로젝트마다 삽질 기록을 접었다 폈다 할 수 있게 아예 레이아웃에 박아뒀다.
정리하면 BaseLayout이 공통 뼈대를 다 지고, 블로그와 프로젝트는 각자 필요한 옷만 덧입는 구조다. 덕분에 Nav 하나 고치려고 파일 세 개 뒤질 일은 없어졌다.
구조를 잡았으니, 이제 각 화면에 어떤 생각을 담았는지 차례로 풀어본다.

화면에 딱 들어온 순간, 사용자의 시선을 잡아야 한다고 생각했다. 3초 안에 흥미를 못 끌면 그냥 뒤로가기니까.
그래서 화면을 좌우로 갈랐다. 왼쪽엔 내가 무엇을 추구하는지를 큼직하게 박아두고, 오른쪽엔 개발자로서 내가 믿는 가치와 지금까지 걸어온 과정을 담기로 했다.
문제는 오른쪽이었다. "지금까지 해온 것들"을 어떻게 보여줄까 고민하다가, Matter.js가 떠올랐다. 키워드 필(pill)이 하늘에서 뚝뚝 떨어져 중력에 따라 아래로 쌓이는 그림. 키워드 하나하나가 떨어져 차곡차곡 쌓여가는 모습이, 내가 성장해온 과정을 은유적으로 보여줄 것 같았다. 경력이란 결국 그렇게 하나씩 쌓이는 거니까.
여기서 한 발 더 갔다. 왼쪽과 오른쪽을 하나로 엮은 것.
그러니까 사용자는 첫 화면에서 손가락 몇 번으로 내 전부를 훑을 수 있다. 스크롤을 강요하지 않아도, 궁금한 필만 누르면 원하는 곳으로 데려다주는 구조. 화면 하나에 "이게 접니다"를 다 담고 싶었다.
거기에 헤드라인 3줄이 10초 간격으로 슬라이드 업 되며 바뀐다. 가만히 있어도 화면이 조금씩 살아 움직인다.

Hero가 시선을 끄는 자리라면, About은 내가 어떤 개발자인지 솔직하게 말하는 자리다. 화려한 수식어 대신, 내가 실제로 어떤 사람인지를 담고 싶었다.
나는 "이 정도는 원래 참고 하는 거야"라는 말을 잘 못 견딘다. 명단을 이틀씩 손으로 옮기는 것도, 납품마다 같은 화면을 처음부터 다시 만드는 것도, 인스타툰 새 화가 나왔는지 매번 확인하러 들어가는 것도 — 반복되는 불편을 마주하면 손이 근질거려 구조나 자동화로 바꿔버린다. 회사 일이든 내 개인적인 귀찮음이든 똑같다.
고등학생 때 첫 회사에서 실제 사용자에게 나가는 제품을 만들어왔다. 8만 명이 쓰는 플랫폼의 데이터 불일치를 잡고, 불공정하게 설계된 추첨 로직을 다시 짜고, 수작업으로 굴러가던 운영을 자동화하면서 배운 건 하나다 — 잘 짠 코드보다, 실제로 문제를 해결하고 누군가 진짜로 쓰는 것이 먼저라는 것.
그 아래엔 경력과 학력을 타임라인으로 뒀다. 말로 풀어낸 가치가 실제로 어떤 시간을 거쳐 쌓였는지, 한 줄기로 내려가며 볼 수 있게. 결국 Hero에서 필이 쌓이던 그 은유를, About에선 시간순으로 펼쳐 보이는 셈이다.

Skills는 욕심을 버린 섹션이다. 숙련도를 별점이나 퍼센트 바로 표현하는 방식도 생각했지만, 관뒀다. "React 90%" 같은 숫자가 대체 무슨 의미인지 나도 설명 못 하겠더라. 보는 사람 입장에서도 신뢰가 안 가고.
그래서 그동안 다뤄온 기술을 그룹별로 묶어 한눈에 보이게 하는 데만 집중했다. 프레임워크는 프레임워크끼리, 스타일링은 스타일링끼리 — 카테고리로 정리하니 "이 사람이 어느 영역을 다루는구나"가 스크롤 한 번에 들어온다. 과장 없이, 있는 그대로.

Projects에서 제일 신경 쓴 건, 예쁜 결과 스크린샷이 아니라 "무슨 문제를 어떻게 풀었나"다.
About에서 말한 그대로다. 잘 짠 코드보다 실제로 해결한 문제가 먼저라면, 프로젝트도 그 기준으로 보여줘야 맞다. 그래서 카드에는 "이거 만들었어요"가 아니라 어떤 문제가 있었고, 어떻게 접근했는지가 드러나게 했다.
카드를 클릭하면 세부 페이지로 넘어가고, 거기엔 트러블슈팅 기록이 아코디언으로 접혀 있다. 이 블로그를 시작한 이유가 "문제 해결하고 그냥 넘어가서 기억 못 하는 것"이었으니까, 프로젝트마다 삽질의 과정을 아코디언으로 남겨뒀다. 결과만 자랑하는 포트폴리오는 많지만, 나는 과정을 보여주는 쪽이 더 나답다고 생각했다.
여기까지가 메인 페이지 한 장에 담긴 것들이다. 표로 정리하면 이렇다.
| 섹션 | 기능 |
|---|---|
| Hero | Matter.js 물리 엔진 — 키워드 필이 중력에 따라 화면 아래로 쌓임 |
| Hero | 필터 버튼으로 카테고리별 필만 남기고 나머지는 페이드 아웃 |
| Hero | 필 호버 → 좌측 설명 텍스트 전환 / 필 클릭 → 해당 섹션으로 이동 |
| Hero | 헤드라인 3줄이 10초 간격으로 슬라이드 업 전환 (Web Animations API) |
| About | 자기소개 + 경력/학력 타임라인 |
| Skills | 기술 스택 그룹별 분류 |
| Projects | MDX Content Collections 기반 프로젝트 카드, 클릭 시 세부 페이지로 |
| Contact | 연락처 링크 |
| 공통 | IntersectionObserver 스크롤 등장 애니메이션 (.animate-in) |

블로그는 "글이 쌓였을 때 원하는 걸 빨리 찾는 것"에 초점을 뒀다.
draft 포스트는 목록에서 자동으로 빠지고, featured 포스트는 위로 끌어올려 정렬한다.| 페이지 | 기능 |
|---|---|
| 목록 | 카테고리 탭 필터 (전체 / Frontend / Backend / DevOps / AI / 생산성 / 기타) |
| 목록 | 키워드 검색 (제목 기준 실시간 필터) |
| 목록 | 페이지당 5개, 고정 높이 컨테이너로 페이지네이션 버튼 위치 고정 |
| 목록 | draft 자동 제외, featured 우선 정렬 |
포스트 (/blog/[slug]) | MDX 파일명 → URL slug, BlogLayout 타이포그래피 적용 |
프로젝트 (/projects/[slug]) | 프로젝트 헤더 (스택 / 기간 / 역할 / 링크) |
프로젝트 (/projects/[slug]) | 트러블슈팅 섹션 아코디언 (기본 접힘, 클릭 시 확장) |
main 브랜치에 push 하면, GitHub Actions가 빌드부터 배포까지 알아서 끝낸다.
name: Deploy Website
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ap-northeast-2
- run: npm ci
- run: npm run build
- run: aws s3 sync --delete ./dist/ s3://${{ secrets.BUCKET_ID }}
- run: aws cloudfront create-invalidation --distribution-id ${{ secrets.DISTRIBUTION_ID }} --paths "/*"
흐름은 단순하다.
main 브랜치에 push
↓
GitHub Actions 실행
↓
① 코드 체크아웃
② AWS 인증 (Secrets에서 키 로드)
③ npm ci → 패키지 설치
④ npm run build → dist/ 정적 파일 생성
⑤ S3 sync → dist/ 를 버킷에 동기화 (--delete 로 삭제된 파일도 반영)
⑥ CloudFront invalidation → CDN 캐시 갱신
커밋 하나 밀어넣으면 잠시 후 사이트가 갱신돼 있다.
배포는 push 딱 한 동작이면 끝이다.
https://www.choiyunsung.dev/ 실제 배포된 포트폴리오 사이트입니다.
이렇게 노션 속에만 잠들어 있던 포트폴리오가, 드디어 링크 하나로 보여줄 수 있는 웹사이트가 됐다. 이제 누가 "그래서 지금까지 뭐 하셨어요?"라고 물으면, 당당하게 주소를 던질 수 있다. (상대방 표정이 🤨에서 🙂로 바뀌길 바라며)
만들면서 미뤄두기만 했던 Astro도 써봤고, styled-components에서 Tailwind로 갈아탔고, CI/CD도 손으로 직접 짜봤다. 북마크만 쌓여가던 "나중에 해봐야지" 목록을, 이 프로젝트 하나에 몰아서 해치운 셈이다. 평지였던 성장 그래프가 아주 조금은 우상향으로 꺾였길.
그리고 무엇보다, 이제 트러블슈팅을 그냥 흘려보내지 않을 자리가 생겼다. 앞으로 만날 미제 사건들은 이 블로그에 차곡차곡 기록될 예정이다. 셜록 홈즈처럼 해결하고 3일 뒤에 까먹는 삶이여, 이제는 안녕.
물론 이 다짐이 얼마나 갈지는 나도 모른다. 하지만 적어도 지금은, 빈 페이지가 아니라 채워나갈 페이지가 생겼다는 것만으로 충분하다.
다음 글은... 이 블로그의 첫 트러블슈팅이 되지 않을까? (부디 그러길)