Web

Good Bye! BOJ! 백준 메모리얼 웹 배포 후기

Aidengoldkr 2026. 4. 16. 11:55

BOJ 서비스 종료 공지를 보고 뭔가를 남겨야겠다는 생각이 들었다.

메모리얼 방명록 사이트를 만들었다. goodbye-boj.com이다.

만든 이유

16년을 버텨온 플랫폼이 공지 하나로 마감됐다. 공지를 읽고 바로 개발을 시작했다.

BOJ와 함께 성장한 사람들이 한마디씩 남길 공간이 있으면 좋겠다고 생각했다. 그리고 4월 28일 종료일까지 카운트다운이 흘러가는 사이트가 어울릴 것 같았다.

주요 기능

기능은 단순하게 잡았다.

  • 카운트다운 타이머 — 2026-04-28 종료일까지 실시간 카운트다운
  • 방명록 — Google 로그인 후 140자 이내 메시지 작성, 하루 1회 제한
  • 좋아요 반응 — 다른 사람 글에 좋아요(❤️) 반응 전송
  • 플로팅 배경 — 방명록 메시지들이 화면을 가로질러 흐르는 인터랙티브 배경
  • 프로필 — BOJ 닉네임, 티어(Bronze ~ Master), 풀어본 문제 수, 주력 언어 설정

기술 스택

분류 기술
프레임워크 Next.js 14 (App Router) + TypeScript
인증 NextAuth v5 (Google OAuth)
데이터베이스 Supabase (PostgreSQL)
스타일 Tailwind CSS v4 + CSS Modules
애니메이션 Framer Motion
클라이언트 데이터 TanStack Query v5

새로운 기술을 억지로 끼워 넣기보다, 이미 익숙한 조합을 빠르게 썼다.

구현하면서 신경 쓴 것들

1. Server / Client 역할 분리

Next.js App Router의 기본 원칙을 최대한 따랐다.

Server Component에서는 데이터를 읽는다. Client Component에서는 상호작용을 처리한다. 모든 쓰기 작업(createGuestbookEntry, addFlower, 프로필 저장)은 src/app/actions/Server Action으로 분리했다.

덕분에 API Route를 따로 만들 필요가 없었다.

2. Supabase 클라이언트를 읽기/쓰기로 나눈 이유

src/lib/supabase/server.ts에 클라이언트를 두 개 만들었다.

// 읽기 — Server Component에서 사용, anon key
export const createClient = () => createServerClient(url, anonKey, ...)

// 쓰기 — Server Action에서 사용, service role key (RLS 우회)
export const createServiceClient = () => createServerClient(url, serviceRoleKey, ...)

방명록 작성과 좋아요 반응은 인증된 사용자만 가능하다. RLS(Row Level Security)를 설정하기보다 Server Action에서 service role key로 직접 접근하는 방식을 선택했다. 뮤테이션이 서버에서만 일어나므로 키 노출 위험도 없다.

3. 플로팅 배경 — 20개 레인 설계

해당 컴포넌트는 동아리 선배가 제작해주었다.

FloatingMessages 컴포넌트가 메인 페이지 배경을 담당한다.

방명록 메시지를 20개 레인으로 나눠 setTimeout 틱 기반으로 하나씩 화면에 띄운다. CSS 애니메이션으로 오른쪽에서 왼쪽으로 흐르게 했다.

처음에는 마퀴(marquee) 방식도 시도했다. MessageMarquee라는 컴포넌트가 아직 코드에 남아 있는데, 최종적으로는 FloatingMessages로 교체했다. 메시지가 동시에 우르르 흐르는 것보다 드문드문 등장하는 게 분위기에 맞았다.

4. 좋아요 — localStorage 낙관적 업데이트

꽃 반응은 GuestbookCard에서 처리한다.

클릭 즉시 UI를 업데이트(낙관적 업데이트)하고, 서버에는 addFlower / removeFlower Server Action을 보낸다. 실패하면 롤백한다.

좋아요 상태는 localStorageboj_liked_entries 키에 저장한다. 배열에 entry ID를 넣고 뺀다. 간단한 방식인데 재방문해도 상태가 유지된다.

5. XSS 방어

방명록 내용을 저장하기 전에 sanitizeHtml로 HTML 이스케이프를 처리한다.

사용자 입력이 그대로 데이터베이스에 들어가는 건 위험하다. 출력 시에만 이스케이프하는 방법도 있지만, 저장 시점에 한번 처리하는 게 더 명확하다.

6. KST 기준 하루 1회 제한

createGuestbookEntry에서 당일 작성 횟수를 체크한다.

이슈: UTC 자정이 기준이면 한국 시간대에서 오전 9시에 카운트가 리셋된다.

해결책: KST 자정(UTC+9)을 기준으로 오늘 작성 수를 집계해서 user_limits 테이블의 daily_limit과 비교한다.

7. 티어 배지

티어는 짧은 코드("g4", "u", "m")로 저장한다. TierBadge 컴포넌트가 /public/tier/<code>.svg를 렌더링한다.

기존에 "Gold" 같은 긴 형태로 저장된 값이 있을 수 있어서 normalizeTier()로 정규화한다. "Gold""g3" 같은 식이다.

전체 방명록 페이지

/guestbookTanStack Query v5로 클라이언트 사이드 페이지네이션을 구현했다.

20개씩 로드하며 최신순 / 좋아요순 정렬을 지원한다. 정렬 기준을 바꿔도 페이지 전체가 새로고침되지 않는다. GuestbookListClient에서 쿼리 파라미터만 바꾸면 TanStack Query가 알아서 데이터를 가져온다.

배포

Vercel로 배포했다. Next.js 프로젝트라 설정이 거의 없었다.

환경 변수 6개를 Vercel 대시보드에 등록했다.

AUTH_SECRET
AUTH_GOOGLE_ID
AUTH_GOOGLE_SECRET
NEXT_PUBLIC_SUPABASE_URL
NEXT_PUBLIC_SUPABASE_ANON_KEY
SUPABASE_SERVICE_ROLE_KEY

Google OAuth 콘솔에서 redirect URI를 프로덕션 도메인으로 추가하는 걸 빠뜨려서 로그인이 한 번 터졌다. 개발 URI(localhost:3000)만 등록해뒀었다. 금방 발견해서 수정했다.

마무리

기획부터 배포까지 짧게 만든 사이트다.

기술적으로 새로운 시도가 많지는 않았다. 대신 빠르게 동작하는 걸 목표로 했고, 그 결과 실제로 사람들이 방명록을 남기고 있다.

BOJ 종료일인 4월 28일 이후에도 운영할지는 미지수다. 

 

배운 것: 빠르게 만들어야 의미가 있는 것들이 있다. 메모리얼 사이트가 그랬다.