DOGFOOD 2026을 위한 Forgeboard 구축: 신뢰도를 꾸미는 것을 거부하는 해커톤 심사 플랫폼
요약
본 글은 DOGFOOD 2026 해커톤을 위해 자체 호스팅 가능한 플랫폼인 Forgeboard를 구축한 과정을 설명합니다. 이 플랫폼은 이벤트 설정, 팀 제출물 관리, 가중치 루브릭 기반 심사위원 점수 부여 등 복잡한 기능을 제공하며, 단일 Next.js 앱과 SQLite 파일로 구성되어 높은 신뢰성을 자랑합니다.
핵심 포인트
- Forgeboard는 자체 호스팅 가능한 해커톤 플랫폼입니다.
- 이벤트 설정부터 결과 공개까지 모든 과정을 지원합니다.
- Next.js와 SQLite를 사용하여 간결하고 안정적으로 구현되었습니다.

심사 스프레드시트를 상상해 보세요. 한 열이 절대 변하지 않는 열입니다. 심사위원이 양식을 열어 첫 번째 프로젝트에 3점을 주고, 그 이후의 모든 프로젝트에도 3점을 주었습니다.
DOGFOOD 2026은 의도적으로 데이터에 그러한 심사위원을 배치했습니다. 모든 팀에게 동일한 것, 즉 해커톤 제출 및 심사 플랫폼을 구축하도록 요청했고, 주최 측은 어색한 사례들(중복된 제출물, 완료하지 않은 리뷰 배치를 가진 경우, 모든 프로젝트에 동일한 점수를 준 심사위원)이 포함된 공유 fixtures.json 파일을 제공했습니다.
저희는 Forgeboard를 커밋 첫 순간부터 최종 판결까지 구축했습니다. 이 글은 그 뒤에 숨겨진 결정들에 관한 것이며, 놀랍게도 그 결정들 중 상당수는 바로 그 심사위원에게서 비롯되었습니다.
요약 (TL;DR)
- Forgeboard는 자체 호스팅이 가능한 해커톤 플랫폼입니다: 이벤트, 팀, 제출물, 공개 갤러리, 심사위원 배정, 가중치 루브릭(weighted rubrics), 교차 심사위원 정규화(cross-judge normalization) 및 게시된 결과 기능을 제공합니다.
- 하나의 Next.js 앱, 하나의 SQLite 파일, 하나의 컨테이너로 구성됩니다.
docker compose up을 실행하면 시드된 포털이 제공되며, 네트워크가 꺼져 있어도 계속 작동합니다. - 공식 DOGFOOD 검사기는 7개 중 7개를 통과합니다. T1 및 T2에 대한 검사만 있으므로, T3와 T4는 자체 증거로 커버됩니다: 131개의 단위(unit) 및 통합 테스트(integration tests), 실행 중인 앱에 대한 10개의 라이브 체크, 그리고 실제 Chrome을 구동하는 브라우저 스위트입니다.
- 코드 (MIT): https://github.com/Avi36005/DogFood-2026
우리가 구축한 것
해커톤 플랫폼은 네 종류의 사용자가 사용하며, 각 사용자에게는 다른 제품이 제공됩니다:
- **주최자(Organizers)**는 트랙, 상품, 맞춤형 질문 및 마감일을 설정하고 서버가 이를 강제하며, 콘솔에서 심사를 진행합니다.
- **팀(Teams)**은 초대 링크로 참여하고, 초안을 저장하며, 마감일 전에 제출합니다.
- **심사위원(Judges)**은 가중치 부여된 루브릭에 따라 점수를 매기고 자신의 과제만 볼 수 있습니다.
- **대중(The public)**은 갤러리를 둘러보고, 투표하고, 댓글을 달고, 공개된 결과를 읽습니다.
| Tier | 포함 내용 |
|---|---|
| T1 · Core | 로컬 비밀번호 복구 기능이 있는 계정, 이벤트별 역할, 이벤트 설정(날짜, 시간대, 트랙, 상품, 다섯 가지 종류의 맞춤형 질문), 초대 링크 팀, 초안 작성 및 편집 제출, 서버 강제 마감일, 검색 가능한 공개 갤러리 |
| ... | |
| 주최자의 작업 공간은 차트의 벽에 있는 것이 아니라 그들에게 남겨진 것에 열립니다: 할당되지 않은 프로젝트, 아직 시작하지 않은 심사위원, 검토할 준비가 된 결과. |
규칙 1: 낯선 사람, 노트북, 그리고 네트워크 없음
이 명세서의 첫 번째 핵심 요구 사항은 docker compose up을 실행했을 때 네트워크가 꺼진 상태로 작동하는 시드(seeded) 포털이 올라와야 한다는 것입니다. 우리는 이것을 배포 세부 사항이라기보다는 디자인 제약 조건으로 간주했습니다. 우리의 아키텍처 문서는 이를 명확히 밝힙니다: 최적화하는 것은 낯선 사람이 그것을 실행할 수 있는지 여부이며, 추가되는 모든 움직이는 요소는 다른 사람의 노트북에서 실패하게 만드는 방식입니다.
따라서 전체 시스템은 다음과 같습니다:
browser ──HTTP──▶ Next.js (App Router, React 19)
pages and route handlers ← 여기에 규칙은 없다
capabilityFor() ← 유일한 문```
- **두 번째 프로세스 없음.** 데이터베이스 컨테이너, 큐(queue), 캐시(cache), 사이드카(sidecar)가 없습니다.
- **`node:sqlite`를 통한 SQLite 사용:** Node 24에 포함되어 있어 네이티브 빌드 단계나 설치할 드라이버가 필요 없고, 시작할 것도 없습니다.
- **ORM 없음.** 스키마는 검토자가 한 번의 통과로 읽을 수 있는 단일 파일의 일반 SQL입니다.
- **아무것도 집으로 전송하지 않음 (Nothing phones home).** 폰트는 `geist` 패키지에서 가져오고, 분석 기능은 제거되었으며, 사용자가 직접 구성한 웹훅(webhook) 외에는 앱이 런타임에 외부 요청을 보내지 않습니다.
- **메일 서버 없음.** 팀 초대, 심사위원 초대, 투표 토큰 및 비밀번호 복구는 모두 주최자나 관리자가 전달하는 일회용 링크입니다.
이를 통해 우리는 `--network none`으로 시작하면서도 모든 공개 경로(public route)를 서비스하고 데이터를 유지하며 다시 시딩(re-seed)하지 않는 컨테이너에서 1.2초에서 1.6초 사이에 통과하는 건강 검진(healthcheck)의 콜드 스타트(cold start) 시간을 확보할 수 있었습니다.
피처(fixture) 임포트는 같은 본능을 따릅니다: 지루하고, 변경하는 모든 것에 대해 명확하게 알립니다.
[forgeboard] fixtures.json에서 evt_01을 가져옴: 40개 프로젝트, 40개 팀, 30명 심사위원, 121개 점수
[forgeboard] 중복 제출: prj_07 (prj_41로 대체); 검토 5건이 임포트되지 않음
[forgeboard] 시딩 완료. 테스트 로그인 (published in .dogfood.toml; 데모 전용):
...
저희 스키마는 고유 인덱스(unique index)를 통해 팀당 하나의 프로젝트만 허용하므로, 임포터는 재제출을 유지하고 무엇이 누락되었는지 소리 내어 말합니다. 데이터를 조용히
DOGFOOD 사양은 한 줄의 체커 실행이 실패하는 팀의 이야기를 전합니다: `판정관이 피어 점수를 볼 수 없음... 200을 받았으나 401 또는 403을 원함`. 그들의 템플릿은 다른 판정관의 점수를 숨겼고, 그들의 API는 로그인한 모든 사람에게 그것들을 반환했습니다. 사양의 평가는 다음과 같습니다: _"버튼을 숨기는 것과 요청 자체를 거부하는 것 사이에는 차이가 있다."_
우리는 이 문장을 중심으로 제품을 구축했습니다.
**규칙은 데이터 계층에 살아 있습니다.** 모든 요청에서 Forgeboard는 `event_roles` 테이블로부터 하나의 _역량(capability)_을 구축합니다. 이는 한 행위자가 하나의 이벤트 내에서 할 수 있는 것을 의미하며, 요청 간에는 절대 캐시되지 않고 브라우저로 전송되지도 않습니다. 도메인 함수들은 올바른 역량이 없으면 실행을 거부합니다:
export function judgeProgress(cap: Capability): JudgeProgress[] {
requireOrganizer(cap); // AccessDenied 발생
// ...
...
**격리성은 필터가 아니라 쿼리 형태입니다.** 판정관의 대기열은 "모든 할당 건을 내 것만 걸러낸 것"이 아닙니다. 이는 소유권에 의해 선택되므로, 다른 사람의 것을 반환할 수 있는 코드 경로는 없습니다:
SELECT ... FROM assignments a
WHERE a.event_id = ? AND a.judge_user_id = ? -- 행위자 자신의 ID
단일 리뷰를 여는 것은 판정관이 대기열에서 왔다고 신뢰하는 대신 모든 검사를 재실행합니다: 해당 할당 건은 이 이벤트와 이 판정관에게 속하며, 프로젝트는 판정관의 트랙 권한 안에 놓여 있고, 판정관은 프로젝트 팀에 속하지 않으며, 결코 속했던 적도 없습니다. 우리는 팀 멤버십 기록을 유지하므로, 할당 후 팀을 떠난다고 해서 충돌이 해소되지 않습니다. 리뷰를 저장하는 것은 먼저 열기 경로를 호출하므로, 쓰기는 읽기의 검사를 건너뛸 수 없습니다. 그리고 API 라우트는 페이지와 동일한 도메인 함수들을 호출하므로, `curl`로 접근해도 인터페이스가 거부되는 방식과 정확히 똑같이 거부됩니다.
[](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwb4x9sgy2vydin0g2x0j.png)
[](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3svb5ha2j2du8l44tw28.png)
_심사위원 Sam이 다른 심사위원의 리뷰 URL을 붙여넣자 일반적인 404 오류가 발생한다. 주최자의 감사 추적(audit trail)에는 이미 이 기록이 남겨져 있다: `review.open`, 거부됨, "지정된 심사위원이 아님"._
왜 403이 아니라 404일까? 심사위원이 주최자 URL을 건드려도 아무것도 알 수 없다. 페이지가 존재하는지조차 모른다. 기계에 노출되는 엔드포인트(endpoints), CSV 내보내기, 그리고 API는 대신 403 응답을 한다. 왜냐하면 정중한 허구보다 명확한 거절이 더 유용하기 때문이다.
> **우리가 설계상의 버그로 만든 부분.** Next.js App Router에서 라우트 레벨의 `loading.tsx`는 페이지를 Suspense 경계(Suspense boundary)로 감싸고 셸을 즉시 플러시한다. 바이트가 와이어에 올라가는 순간, `notFound()`와 `redirect()`로는 더 이상 상태 코드(status code)를 설정할 수 없으므로, 권한이 없는 페이지는 **200**으로 응답하며 클라이언트 측 리디렉트(client-side redirect)를 내부적으로 포함하게 된다. 상태 코드는 우리의 권한 부여 스토리의 일부이므로, Forgeboard에는 라우트 레벨 로딩 파일이 없다. 스켈레톤(skeleton)이 실제로 도움이 되는 곳, 예를 들어 갤러리 그리드 같은 경우에는 Suspense 경계가 접근 결정이 내려진 후에 페이지 내부에 위치한다.
우리가 좋아하는 또 다른 세부 사항은 다음과 같다: 인스턴스 관리자(instance admin)는 모든 이벤트를 열람할 수 있다. 왜냐하면 그것이 관리자의 의미이기 때문이다. 다만 조용하지 않을 뿐이다. 첫 번째 접근 시 해당 이벤트 자체의 감사 추적에 `admin.access` 행이 기록되며, 주최자들이 이를 볼 수 있게 된다.
## 규칙 3: 심사위원이 부여하지 않은 의견은 절대 지어내지 않기
단일 리뷰를 채점하는 것은 쉬운 부분이다. 주최자가 가중치(weight)와 정수 척도(integer scale)를 가진 기준을 정의하고, 리뷰의 원점수(raw score)는 가중 평균이 된다:
raw = Σ(wᵢ · sᵢ) / Σ(wᵢ)
이는 제출 시점에 계산되어 저장되므로, 나중에 루브릭(rubric)을 수정해도 이미 부여된 점수를 소급하여 변경할 수 없습니다. 저희 데모 루브릭은 DOGFOOD 자체의 채점 가중치를 차용했습니다: 완성도(Completeness) 40, 무결성(Integrity) 25, 운용 가능성(Operability) 20, 기술적 숙련도(Craft) 15.
[](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fscquklqwmq1wrw9ttx9y.png)
_한 심사위원이 Beacon에 대해 4, 5, 3, 4점을 부여하고 가중치 40/25/20/15를 적용합니다. 누적 합계는 4.05가 되며, 이는 저희 유닛 테스트(unit tests)에서 고정하는 값(100점 만점에 76.25점)과 동일합니다._
어려운 점은 원시 점수(raw scores)가 심사위원마다 비교 가능하지 않다는 것입니다. 저희의 시드 이벤트(seeded event)에서 보듯이:
| 심사위원 | 리뷰 수 | 원시 평균 | 패널 대비 편향도 |
| :--- | :--- | :--- | :--- |
| Mo Nakamura | 5 | 2.06 | −1.03σ |
| ... |
Mo가 그린 프로젝트와 Jai가 그린 프로젝트는 동일한 측정 도구로 측정되고 있지 않습니다. 원시 점수를 평균 내면 패널 로터리(panel lottery)가 순위 결정의 일부를 담당합니다.
따라서 Forgeboard는 각 심사위원 내에서 표준화합니다:
z_jp = (raw_jp − mean_j) / sd_j # 모집단 표준편차, 심사위원별
score_p = mean of z over the project's usable reviews
display = clamp(panel_mean + score_p · panel_sd, scale_min, scale_max)
그러면 모든 항목에 3점을 준 심사위원을 만나게 됩니다. 이 사람의 표준편차는 0이므로 공식은 0으로 나누게 됩니다. 교과서적인 회피 방법으로는 패널의 분산(variance)을 차용하거나 심사위원을 그쪽으로 수축시키는 것입니다. 둘 다 존재하지 않는 투표에서 인위적인 구분을 만들어냅니다. 모든 프로젝트에 3점을 준 심사위원은 어떤 프로젝트가 더 좋은지에 대해 아무것도 알려주지 않았으며, 정직한 답변은 그들의 입장을 대신하여 의견을 꾸며내기보다는 그렇게 말하는 것입니다.
Forgeboard는 최소 세 개의 완료된 리뷰와 0을 초과하는 분산(spread)을 가진 심사위원만 표준화합니다. 그 외의 사람은 **이름과 이유를 명시하여** 제외되며, 해당 사람들의 원점수(raw scores)는 화면에 그대로 표시됩니다. 사용 가능한 리뷰가 두 개 미만인 프로젝트는 순위가 전혀 매겨지지 않으며, 확신을 주는 위치를 부여받기보다는 _비교 가능한 리뷰 부족_으로 표시됩니다. 만약 모든 심사위원이 제외된다면, API, 콘솔, 스냅샷 모두 `raw_fallback`이라고 표시할 것입니다. 이 방식은 절대 조용히 전환되지 않습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기