코드를 쓰기 전에 남의 코드를 먼저 읽었습니다
구글폼 비슷한 서비스를 만들어 보기로 했습니다. 폼을 만들어 링크로 뿌리고 응답과 파일을 받는, 딱 그만큼입니다. 여섯 시간짜리 실습 과정에서 완성하는 것이 목표라 기능을 크게 줄여야 했습니다.
보통 이런 걸 만들 때는 바로 프로젝트를 만들고 화면부터 그립니다. 이번에는 그러지 않았습니다. 같은 문제를 이미 오래 풀어 온 오픈소스 두 개를 먼저 읽었습니다.
문제
만들다 보면 결정할 것이 계속 나옵니다. 질문 목록을 테이블로 나눌까 JSON 한 덩어리로 담을까. 폼이 마감됐는지를 컬럼에 저장할까 조회할 때 계산할까. 파일을 언제 검사할까.
이런 걸 하나씩 감으로 정하면 나중에 왜 그렇게 했는지 설명하지 못합니다. 그리고 설명하지 못하는 결정은 고칠 때도 근거가 없습니다.
이미 사용자를 받고 있는 제품은 그 결정을 다 내려 봤습니다. 되돌리기 어려운 결정은 코드에 흔적을 남깁니다. 그걸 읽으면 남이 지불한 수업료를 빌릴 수 있습니다.
무엇을 읽었나
두 개를 골랐습니다. 둘 다 폼 빌더이고 소스가 공개되어 있으며 지금도 커밋이 올라옵니다.
| OpnForm | HeyForm | |
|---|---|---|
| 백엔드 | PHP, Laravel, REST | Node.js, NestJS, GraphQL |
| 프론트엔드 | Nuxt 한 앱 | React 관리자 앱 + 별도 렌더러 패키지 |
| 데이터베이스 | 관계형, 마이그레이션 112개 | MongoDB, Mongoose 스키마 26개 |
| 저장소 구성 | api와 client 2분할 | pnpm 워크스페이스 7패키지 |
언어도 다르고 데이터베이스도 다르고 API 방식도 다릅니다. 그래서 비교할 가치가 있었습니다. 둘이 서로 다른 선택을 한 지점은 취향이나 상황의 문제이고, 둘이 같은 선택을 한 지점은 그 문제의 답에 가깝습니다.
어떻게 읽었나
열두 개 관점을 먼저 적어 두고, 한 관점을 두 저장소에서 연달아 봤습니다. 작성자 흐름, 응답자 흐름, 편집기 구조, 렌더러 구조, 질문 타입 정의, 데이터 모델, 상태 관리, 제출 검증, 파일 업로드, 인증과 권한, 테스트 순입니다.
저장소별로 통째 훑고 나중에 맞추는 방식은 쓰지 않았습니다. 그렇게 하면 비교 기준이 읽는 동안 흔들립니다.
정반대로 간 지점
가장 인상 깊었던 차이는 편집기와 응답 화면의 관계였습니다.
OpnForm은 렌더러 하나를 모드만 바꿔 재사용합니다. 모드가 여덟 개 있고, 모드마다 검증할지, 관리자 컨트롤을 보일지, 실제로 제출할지를 객체로 돌려줍니다. 편집기 미리보기는 공개 폼과 똑같은 컴포넌트를 미리보기 모드로 띄웁니다.
HeyForm은 반대입니다. 빌더 캔버스에 질문 타입별 컴포넌트가 25개 있고, 응답자용 렌더러에 28개가 따로 있습니다. 타입을 컴포넌트에 연결하는 분기문도 두 곳에 각각 존재합니다. 입력을 흉내만 내는 FakeRadio, FakeSelect 같은 파일이 그 증거입니다.
처음에는 중복으로 보였는데 이유가 있었습니다. 렌더러를 npm 패키지로 따로 배포합니다. 다른 사이트에 폼을 임베드하려면 관리자 앱과 결합을 끊어야 합니다. 배포 독립성을 얻고 유지 비용을 냈습니다.
여섯 시간짜리 MVP에는 임베드가 필요 없습니다. 그래서 OpnForm 쪽을 택했습니다.
이런 갈림길이 여섯 개 나왔습니다. 폼 상태를 컬럼 하나에 둘지 설정 객체에 흩을지, 초안과 발행본을 나눌지, 파일을 언제 검증할지, 파일을 응답에 묶을지 폼에 묶을지 같은 것들입니다.
둘이 같은 답을 낸 지점
여기가 진짜 수확이었습니다.
질문 목록을 정규화하지 않습니다. OpnForm은 JSON 컬럼에, HeyForm은 객체 배열에 담습니다. 관계형과 문서형이라는 정반대 데이터베이스를 쓰면서 같은 선택을 했습니다. 질문 순서를 바꾸고 타입을 바꾸는 일이 잦은데, 정규화하면 그때마다 여러 행을 손대야 합니다.
서버는 요청이 보낸 질문 정의를 믿지 않습니다. 응답을 받을 때 클라이언트가 “이 질문은 필수가 아니고 최대 길이는 10만 자”라고 주장하는 정의를 함께 보낼 수 있습니다. 두 저장소 모두 그걸 무시하고 폼 id로 데이터베이스를 다시 조회해 검증 기준으로 삼습니다.
값의 의미는 저장된 정의가 정합니다. HeyForm의 결제 처리에서 봤습니다. 응답자가 보낸 금액과 통화를 쓰지 않고 발행된 폼 설정에서 다시 계산합니다. 코드에 주석까지 달아 뒀습니다.
마감 여부는 저장하지 않고 요청할 때 판정합니다. 상태가 닫힘인지, 마감 시각이 지났는지, 응답 수가 상한에 닿았는지를 조회 시점에 계산합니다.
파일은 확장자가 아니라 내용으로 확인합니다. HeyForm은 MIME 타입마다 허용 확장자와 매직 바이트 검사를 짝지어 둡니다. OpnForm은 파일 앞부분을 읽어 실제 형식을 판독하고, SVG는 막는 대신 스크립트를 걷어 냅니다.
쓸모 있었던 한 줄
OpnForm의 권한 정책 클래스에 이런 메서드가 있었습니다.
응답을 받을 수 있는가: 마감되지 않았고, 제출 상한에 닿지 않았고, 공개 상태일 때.
사용자를 널 허용으로 받습니다. 익명 응답자를 다루는 방식이 이 한 줄에 들어 있습니다. 그리고 이건 Supabase의 행 수준 보안 정책으로 거의 그대로 옮겨집니다. “폼이 발행 상태일 때만 응답 삽입을 허용한다”는 SQL 조건 하나가 됩니다.
애플리케이션 코드에 권한 검사를 흩어 두면 새 경로를 추가하면서 빠뜨립니다. 데이터베이스에 걸어 두면 어느 경로로 들어와도 막힙니다.
만든 문서를 검증했더니
읽은 것을 문서로 만들었습니다. 관점별 분석 두 편, 대조표, 채택 결정 문서입니다. 결정 문서 마지막에는 어떤 원본 파일을 읽고 어떤 결정을 내렸는지 잇는 표를 붙였습니다. 나중에 “왜 이렇게 했지”를 물었을 때 되짚을 자리가 있어야 합니다.
그다음 이 결정들을 근거로 제품 요구사항 문서를 썼습니다. 그리고 쓴 쪽과 검증하는 쪽을 나눴습니다. 검증하는 쪽은 원본 브리프와 결정 문서만 보고 요구사항 문서를 훑습니다.
차단 결함 네 개가 나왔습니다. 그중 둘이 특히 뼈아팠습니다.
기술 제약 네 개가 통째로 빠져 있었습니다. 브리프에 적어 둔 Next.js App Router와 Turborepo가 문서 본문에 한 번도 나오지 않았습니다. 모바일 반응형도 마찬가지였습니다. 페르소나 설명에 “스마트폰으로 접속하는 경우가 많다”는 문장은 있었지만 그건 요구사항이 아닙니다.
보안 보증이 스스로 무너지는 구조였습니다. 한 절에서는 “응답 삽입은 데이터베이스의 행 수준 보안으로 강제한다”고 못박아 놓고, 다른 절의 API 요약에서는 그 경로가 서비스 롤 키를 쓴다고 전제했습니다. 서비스 롤 키는 행 수준 보안을 통째로 건너뜁니다. 그대로 구현했으면 정책은 있으나 마나가 됩니다.
수용 기준에는 이렇게 적혀 있었습니다. “행 수준 보안을 우회한 직접 삽입 시도도 같은 이유로 차단되는지 확인한다.” 우회했는데 그걸로 막힌다는 문장입니다. 문장 자체가 모순인데 쓸 때는 보이지 않았습니다.
두 번째 라운드에서 나온 것
지적을 반영하고 같은 검증자에게 다시 보냈습니다. 이번에는 검증자가 자기가 라운드 1에서 놓쳤던 것을 찾았습니다.
파일은 응답 제출보다 먼저 올라가고, 제출 요청이 그 저장 경로를 함께 보냅니다. 그런데 이 경로도 클라이언트가 조작할 수 있는 값입니다. 서버가 그대로 받으면 다른 폼에 올라간 파일을 자기 응답에 붙일 수 있습니다.
“요청이 보낸 값을 믿지 않는다”는 원칙을 문서 여기저기에 적어 두고도, 정작 그 원칙을 적용할 자리를 하나 빠뜨린 셈입니다. 원칙을 아는 것과 빠짐없이 적용하는 것은 다른 일이었습니다.
배운 것
세 가지가 남았습니다.
첫째, 서로 다른 기술로 만든 두 제품이 같은 선택을 한 지점은 신뢰할 만합니다. 하나만 읽었으면 그게 그 제품의 취향인지 그 문제의 답인지 구분하지 못했을 겁니다.
둘째, 문서는 쓴 사람이 읽으면 안 됩니다. 쓴 사람은 자기가 이해한 대로 읽습니다. 문서에 안 적혀 있어도 머릿속에 있으면 있는 것처럼 읽힙니다. 기술 제약 네 개가 빠진 것을 저 스스로는 못 찾았습니다.
셋째, 모르는 것을 모른다고 적어 두는 편이 낫습니다. 이번 문서에는 아직 정하지 않은 항목이 열두 개 있고 각각 왜 아직 정하지 않았는지가 붙어 있습니다. 임의로 정해 버리면 그게 결정이었는지 추측이었는지 나중에 구분되지 않습니다.
특히 저장소 구성 도구 하나는 브리프와 선행 분석이 서로 다른 답을 주고 있었습니다. 어느 쪽을 따를 근거가 문서 안에 없어서 임의로 고르지 않고 사람에게 물었습니다.
다음
이제 구조 설계로 넘어갑니다. 먼저 풀어야 할 건 보안 경계 네 가지입니다. 공개 폼을 어떤 권한으로 읽을지, 응답 제출에 어떤 키를 쓸지, 파일 경로를 어떻게 검증할지, 서명 링크를 얼마나 살려 둘지.
읽는 데 쓴 시간이 아깝지 않았습니다. 감으로 정했으면 몰랐을 것들이 꽤 있었습니다.
이번 분석과 문서 작업은 AI 에이전트를 붙여 진행했습니다. 저장소를 읽고 정리하는 일, 그리고 만든 문서를 다른 관점으로 검증하는 일에 특히 도움이 됐습니다. 참고한 두 저장소는 읽기만 했고 수정하지 않았습니다.