본문으로 건너뛰기
강홍재/ James
← Work
EMBA2026 · Solo Builder· Started(First Commit date)

EMBA 중앙선거관리시스템

구글폼으로는 1인 1표도, 무기명도 보장할 수 없어서 하루 만에 만든 기수장 선거 시스템 - 학번과 휴대폰번호 해시로 본인확인, 파일 배치로 비밀투표, 개표 전 수 맞춤 검증. 투표 시작 35분 전 적대적 감사로 결함 4건을 고쳤고, 37명 중 35명이 투표했다.

  • Node.js
  • Express
  • No DB
  • Election
  • EMBA
  • QA
스크린샷
  • 투표지 - 학번·휴대폰번호만으로 들어온 뒤 후보 1명을 고른다. 화면은 가짜 명부 5명의 테스트 인스턴스이고 후보명은 예시다

  • 제출 전 확인 - 제출하면 변경하거나 취소할 수 없다는 것을 한 번 더 보여준다

  • 투표 완료 - 10자리 확인코드. 선택과는 따로 보관되는 무작위 코드라 누구에게 투표했는지는 알 수 없고, 공표 뒤 목록에 있으면 내 표가 집계된 것이다

  • 결과 페이지 - 당선·투표 현황·개표 결과와 확인코드 조회. 공표 뒤에는 투표함 파일을 고쳐도 이 숫자가 바뀌지 않는다

  • 관리 화면 - 선거인·투표·투표율·미투표, 과반·3분의 2·전원 단계, 15초 자동 갱신. 표가 들어오면 후보 설정은 잠긴다

  • 개표 - 판정(과반 당선 확정), 득표 차트, 그리고 명부 수 = 표 수 = 확인코드 수 검증. 하나라도 어긋나면 결과를 내주지 않는다

Setup

Problem

기수장 선거는 그동안 구글폼이었다. 공지문에 적은 문제는 셋이다. 구글 계정으로는 1인 1표를 보장할 수 없다. 관리자가 응답자와 응답을 같이 볼 수 있어 무기명이 아니다. "38명 참여 완료 시 마감"이라는 조건은 자퇴자 때문에 영원히 충족되지 않는다. 선거는 결과보다 과정이 의심받으면 끝이다. 그래서 요구는 기능이 아니라 보장이었다. 한 사람이 한 번만, 누가 누구를 찍었는지는 서버를 다 볼 수 있는 사람도 모르게, 그리고 공표된 숫자를 나중에 바꿀 수 없게.

Context

120기 기수장 선거(투표 2026-09-17 14:00 ~ 09-18 23:59)를 위해 만들었다. 첫 커밋이 17일 10:55, 투표 시작이 같은 날 14:00이었다. 그 사이 커밋 24개 중 13:25의 하나가 적대적 감사 결과다. 여섯 렌즈로 훑어 확증된 결함 7건 중 4건을 고치고 3건은 반증해 남겼다. 투표 중에도 화면 커밋은 이어졌다. 원우수첩(EMBA 앱) 회원 DB를 선거인명부의 원천으로 쓰되 투표 데이터는 그 DB에 넣지 않는다. 명함 웹(Express, pm2)에 라우터로 얹어 새 포트도 ALB 규칙도 없이 vote 서브도메인에서 돌렸다. 의존성은 Express 하나, DB 없이 JSON 파일 넷, 프론트는 순수 HTML 네 장. 관리자는 셋(개발총괄, 66대 총학생회 회장·부회장)이고 셋 다 118·119기라 선거권이 없다. 선거 뒤 9월 30일에 다중 선거·아카이브 구조로 코어를 분리했고(PR 2건), 같은 틀로 120기 슬로건 선정 투표 3라운드를 명함 웹에서 돌렸다. 커밋 31개, 단독 작업.

Users

선거인은 120기 원우 37명(재적 39에서 테스트 계정 1, 자퇴 1 제외). 폰으로 QR이나 링크를 열고 학번과 휴대폰번호만 넣는다. 비밀번호는 없다. 120기의 앱 평균 로그인이 1.2회라 비밀번호를 기억할 리 없다는 데이터가 근거다. 관리자 셋은 현황·개표·공표를 맡고, 선거 설정·명부·활성화·폐기는 개발총괄만 한다.

Hypothesis

학번·휴대폰 해시로 본인확인을 하고, 1인 1표를 서버가 강제하고, 비밀투표를 파일 배치로 보장하면 구글폼보다 참여가 높고 결과에 이의가 없다. 공지문에 적은 약속이 이것이었고, 결과는 투표율 94.6%와 기록된 이의 0건이다.

Build

What I did
  • 본인확인 - sha256(salt + 학번 + 휴대폰)을 명부 해시와 대조. 명부는 운영 DB에서 스크립트로 뽑고 salt는 별도 파일, 둘 다 커밋 금지와 사유를 .gitignore에 적음
  • 1인 1표 - 투표 완료면 409, 투표 직후 세션 쿠키 삭제(공용 PC 대비). 저장 순서는 ledger → ballots → receipts로 고정
  • 비밀투표 - 명부(학번별 완료 여부만), 투표함({"c":"c1"}만), 확인코드 세 파일로 쪼개고, 명부는 학번순 재작성, 투표함과 확인코드는 매 표마다 암호학적 셔플 뒤 원자적 교체. 서버와 DB를 다 보는 사람도 재구성 못 하게
  • 개표·공표 - 마감 뒤에만 열리고 명부 수 = 표 수 = 확인코드 수가 안 맞으면 409. 공표는 1회, 재요청은 첫 스냅샷. 공표된 result.json에 명부 해시를 박아 사후 변경 탐지
  • 확인코드 - 10자리로 본인 표가 집계에 포함됐는지 결과 페이지에서 확인. "영수증"이라는 말은 투표 당일 "확인코드"로 바꿨다
  • 관리 화면 - 15초 갱신 현황, 미투표자(투표 여부만), 독려 문안 복사, 표가 들어오면 후보 잠김, 개표·공표·아카이브·폐기
  • 시도 제한 - 학번별 8회/10분(엄격), IP별 60회/10분(강의실 와이파이·통신사 NAT 고려), 틀렸을 때만 카운트
  • /test 연습 인스턴스 - 가짜 5명 명부, 별도 데이터 디렉터리, 빨간 경고 띠. 초기화 라우트는 연습 인스턴스에만 존재
  • 테스트 159개(node --test) - 무기명 보장, 권한 분리, 선거 간 격리, 생성→명부→활성화→아카이브→폐기 생명주기, 운영 공표 스냅샷을 마이그레이션 회귀 기준값으로
Product decisions
  • 비밀번호 없음 - 평균 로그인 1.2회인 사용자에게 비밀번호를 요구하면 선거 당일 문의가 선거를 덮는다. 학번과 휴대폰이면 본인만 아는 조합으로 충분하다고 봤다
  • 전원 투표 시 즉시 마감 - 구글폼의 "38명 완료 시"가 불가능했던 이유(자퇴자)를 명부 자체에서 제외하는 것으로 풀었다
  • 주최 기구 표기 없음 - 실제로 그런 기구가 없기 때문. 권한의 근거는 레포 config의 admins 목록이고 원우수첩 등급이 아니며, 화면에서 만든 설정의 admins는 코어가 무시한다
  • 투표 요청은 어디에도 로깅하지 않는다 - 감사 로그는 선관위 행위만(로그인·개표 조회·공표·설정 변경). 운영 로그와 진행 로그도 분리해 "몇 번째 투표자인가"가 새지 않게
  • 투표가 열린 동안 백업·배포·서버 접속 금지 - 두 시점의 명부와 투표함 차분이 개인 선택을 복원하기 때문. /api/maintenance가 safeToBackup을 돌려준다
  • 소규모 선거 게이트 - 선거인 5명 미만은 활성화 불가, 유효표 2 이하나 만장일치는 공표 전 재확인. 작은 선거일수록 무기명이 깨지기 쉽다
  • 아카이브 뒤 폐기 - 명부·salt·지원서·운영 로그는 지우고 투표함·확인코드·결과·감사 로그는 남긴다. 폐기는 비가역이고 개발총괄만
QA considerations
  • 투표 시작 35분 전 적대적 감사 - 확증 7건 중 고친 4건: 관리자 토큰을 유권자 쿠키로 넣으면 표가 들어감, 선거권 제외 뒤 남은 세션으로 투표 가능, 학번만 알면 남을 10분 잠글 수 있음(시도 제한을 대조보다 먼저 봄), 후보 순서 바꾸면 사진·지원서가 다른 사람에게 붙음. 반증한 3건도 이유와 함께 커밋에 남겼다
  • 표가 빠지는 것과 부풀려지는 것 중 무엇이 덜 나쁜가 - 저장 중간에 죽으면 "표 누락"이 되게 순서를 정했다. 누락은 명부 수 > 표 수로 즉시 드러나고 복구할 수 있지만, 부풀림은 어느 표를 뺄지 모른다
  • 무기명이 서버 관리자에게도 성립하는가 - 파일 셋으로 쪼개고 셔플하고, 명부에 행을 추가하지 않고 통째로 재작성해 순서 정보를 없앴다. 테스트로 고정
  • 공표 뒤 숫자가 바뀌지 않는가 - 투표함 파일을 손으로 고쳐도 공개 숫자가 불변인지 테스트로 고정. 마감 전 득표를 계산하지 않는 것도 테스트
  • 남은 구멍을 README에 적었다 - 서버에 붙어 파일 변경을 실시간으로 보는 경우, 관리 화면 15초 폴링이 주는 사람별 투표 시각, 아카이브가 영구 보존하는 투표함. 닫지 못했고 알고 있다
  • 리허설 인스턴스는 있지만 실제 선거 전에 드라이런을 했다는 기록은 없다. CI도 없다. 테스트 159개는 로컬에서 돈다
  • 투표 중 로그인 실패 건수·문의 건수·조기 마감 여부는 기록이 없다. 데이터 디렉터리가 레포 밖이라서다

Outcome

Metrics
  • 선거인 37, 투표 35, 투표율 94.6%, 기권 0, 공개 확인코드 35개. 공표 2026-09-18(KST 09-19 00:48), 공표자 66대 총학생회 회장
  • 커밋 31개(9월 17일 24개, 30일 7개), 단독 저자. 첫 커밋 10:55, 투표 시작 14:00
  • 테스트 159개 통과(2026-10-10 로컬 실행), 9월 30일 하루 100→147개. CI 없음
  • src 3,871줄, test 4,334줄, public 4,331줄, README 1,039줄. 테스트가 소스보다 길다
  • 운영 중 사고 기록 0건, TODO·FIXME 0건. 로그인 실패·문의 건수는 기록 없음
Result / Learning

선거는 예정대로 열리고 닫혔고 공표됐다. 투표율 94.6%, 기록된 이의 없음, 기록된 사고 없음. 구글폼이 못 하던 세 가지(1인 1표, 무기명, 마감 조건)가 전부 해결됐다는 게 공지문과 결과 페이지로 남아 있다. 이 프로젝트가 남긴 건 숫자보다 문서다. 1,039줄 README에 저장 순서를 그렇게 정한 이유, 투표 중 백업을 금지한 이유, 닫지 못한 구멍 세 개가 적혀 있다. 9월 30일 다중 선거 구조로 떼어낸 코어는 같은 달 슬로건 투표 3라운드에 재사용됐다.

Retrospective
  • 적대적 감사를 투표 시작 35분 전에 했다. 결함 4건이 거기서 나왔으니 하루 전에 했어야 했다. 하루짜리 프로젝트에서 "하루 전"이 없었다는 게 문제의 본질이다.
  • 투표 중에도 화면 커밋을 이어갔다. 로직은 안 건드렸지만, "투표 중 배포 금지"를 README에 적은 사람이 할 일은 아니었다.
  • 연습 인스턴스를 만들고 실제로 드라이런을 했다는 기록을 남기지 않았다. 했는지 안 했는지 지금은 알 수 없고, 그게 기록을 남기지 않은 비용이다.
Tech stack
  • Node.js 18+
  • Express (단일 의존성)
  • JSON 파일 저장 (tmp + rename 원자적 쓰기)
  • 순수 HTML 4장
  • node --test
  • pm2 (명함 웹에 라우터로 마운트)