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 (명함 웹에 라우터로 마운트)