포스트

프론트와 백엔드를 동시에 만들며 부딪힌 것들

프론트와 백엔드를 동시에 만들며 부딪힌 것들

IR 자료 생성 서비스를 만들면서 Claude Code 세션 두 개를 동시에 돌렸다. 한쪽에는 화면과 디자인을, 다른 쪽에는 인증과 LLM 호출부를 맡겼다. 같은 저장소, 같은 시간이었다.

코드는 두 세션이 썼고, 무엇을 만들지와 어느 방향으로 갈지는 내가 정했다. 이 글은 그렇게 병렬로 굴렸을 때 실제로 무엇이 부딪혔는지에 대한 기록이다.

빠르긴 했다. 하루에 커밋 10개가 나왔다. 그런데 대가가 분명히 있었고, 그게 오늘 배운 것의 대부분이다.

항목결과
커밋10개
연결을 끊은 작업1건 (API 초안 + 목 서버, 1,916줄)
값이 왕복한 횟수2번 (타임아웃), 3번 (대기 문구)
한쪽 변경이 다른 쪽을 깨뜨린 일2건
서로가 상대의 결함을 발견2건

1. 같은 것을 두 번 만들었다

가장 크게 낭비한 부분이다.

프론트 세션이 PRD를 검토한 뒤 “백엔드 API 계약이 아직 확정되지 않았으니 목 서버로 먼저 개발하자”고 제안했다. 그럴듯해서 그대로 진행시켰고, 세션은 OpenAPI 스펙을 쓰고 타입을 생성하고 MSW 핸들러를 깔았다.

1
2
3
4
5
openapi/ir-generator.yaml     749줄   17경로 22엔드포인트
src/mocks/handlers.ts         566줄   핸들러 22개 + 실패 시나리오 4개
src/mocks/fixtures.ts         548줄   목 데이터
src/api/types.ts               53줄
src/api/schema.d.ts         1,230줄   (스펙에서 자동 생성)

스펙에서 기계적으로 만들어진 타입(1,230줄)을 빼도 1,916줄이다.

같은 시간에 백엔드 세션은 엔드포인트 하나로 만들고 있었다.

 프론트가 만든 초안백엔드가 만든 실제
엔드포인트22개1개 (POST /api/generate)
입력파일 20개 × 50MB텍스트 또는 PDF 10MB
흐름프로젝트 → 자료 → 검토 → 폴링 → Revision단일 동기 요청
검증OpenAPI 스키마zod

둘 다 틀리지 않았다. 프론트 세션은 PRD 문서를 그대로 옮겼고, 백엔드 세션은 “가장 작게 동작하는 것”을 먼저 세웠다. 각자 받은 맥락이 달랐을 뿐이다.

문제는 두 세션이 서로를 볼 수 없다는 것이었다. 각자 자기 맥락에서만 합리적이었고, 그 차이를 알아차릴 수 있는 건 둘을 다 보고 있는 나뿐이었는데 내가 늦었다. 목을 다 만들고 화면을 붙이려는 시점에 드러났다.

실제 구현을 정본으로 정했다. 만든 목은 지우지 않고 연결만 끊게 했다. 왜 끊었는지와 되살리는 조건을 README에 적었다. 프로젝트 관리 기능으로 확장할 때 참고가 되니까.

병렬 작업에서 제일 먼저 합의해야 하는 건 코드가 아니라 계약이다. 엔드포인트가 22개냐 1개냐는 30초 대화로 정해질 일이었는데, 그 30초를 안 써서 1,916줄이 쓰이지 못했다. 합의를 시키는 건 세션이 할 수 없는 일이었다. 내 몫이었다.

2. 값이 왕복했다

타임아웃 하나가 두 번 오갔다.

1
2
3
4
12:24  백엔드 세션   CODEX_TIMEOUT_MS = 300초
12:27  프론트 세션   240초로 낮춤 (실측 근거 주석 첨부)
그 후   백엔드 세션   다시 300초
15:19  프론트 세션   다시 240초

왜 240초여야 하는지에는 근거가 있었다. 프론트 세션에 실제로 재보게 시켰기 때문이다.

1
2
3
입력 약 400자   → 111초
입력 9,803자    → 139초   (24배인데 25%만 늘었다)
상한 30,000자   → 약 200초 (외삽)

그리고 라우트 전체 제한이 300초다. 둘을 같게 두면 LLM이 시간을 다 썼을 때 렌더와 업로드에 남는 시간이 0초가 되어, “시간 초과” 안내가 뜨기 전에 라우트가 먼저 끊긴다. 사용자는 원인 모를 502를 본다.

문구도 세 번 오갔다. 최대 3분 → 최대 5분 → 보통 2~3분.

왕복이 멈춘 건 근거를 코드에 적은 뒤였다. 처음엔 값만 바꿨더니 되돌아왔고, 실측 수치와 “왜 maxDuration보다 작아야 하는지”를 주석으로 남긴 뒤에는 그대로 유지됐다.

1
2
3
4
5
6
7
/**
 * 라우트의 maxDuration(300초)보다 반드시 작아야 한다. 같으면 Codex가 시간을 다 썼을 때
 * 렌더·업로드에 남는 시간이 없어, CODEX_TIMEOUT 안내 대신 라우트가 먼저 죽는다.
 *
 * 실측: 약 400자 입력이 111초, 9,803자가 139초...
 */
const CODEX_TIMEOUT_MS = 240_000;

지금은 세 층으로 정착했다. 안쪽이 먼저 포기하고 바깥이 나중에 포기하는 순서다.

1
2
3
codex 호출      240초
백엔드로 fetch   260초
라우트 전체      300초

이건 병렬의 좋은 결과다. 프론트 세션이 240초의 근거를 남기고, 백엔드 세션이 그 위에 260초를 얹었다. 왕복이 아니라 층이 쌓였다.

3. 한쪽 변경이 다른 쪽을 조용히 깨뜨렸다

PDF를 올렸는데 아무 일도 안 일어났다. 화면에는 아무 안내도 없었다.

로그를 보니 이랬다.

1
[generate] CODEX_FAILED: 백엔드에 연결하지 못했습니다: TypeError: fetch failed

그 사이 백엔드 쪽에서 LLM 호출을 별도 서버로 분리해 놓았다. 구조가 이렇게 바뀐 것이다.

1
브라우저 → Next(:3000) → fetch → 로컬 백엔드(:8787) → codex

즉 이제 서버를 두 개 띄워야 한다. 프론트 세션은 그 변경을 모르고 하나만 띄운 채 “PDF가 안 된다”고 진단하고 있었다. PDF 문제가 아니었고 텍스트도 똑같이 실패했을 것이다.

또 한 번은 .env.local이 초기화됐다. 값 셋이 전부 비고, 이미 지웠던 항목이 되살아나 있었다. 구버전 예시 파일을 복사한 흔적이었다. 그 결과 인증 클라이언트 생성이 실패해서 모든 요청이 500이 됐다.

두 경우 다 한쪽의 정당한 변경이 다른 쪽에서 정체불명의 고장으로 나타났다. 커밋 이력을 보고 나서야 원인을 알았다.

4. 같은 함정을 각자 밟았다

프론트 세션이 검증 스크립트를 만들 때 npx tsx로 실행했다. 그 자리에서는 됐다. 그런데 package.json에는 tsx를 직접 부르게 적었고, tsx는 devDependency에 없었다.

1
2
$ npm run check:codex
sh: tsx: command not found

백엔드 세션도 나중에 같은 방식으로 check:env를 추가했고, 똑같이 실패했다. 한쪽이 먼저 밟은 함정을 다른 쪽이 그대로 다시 밟았다. 세션끼리는 그 실수를 공유할 방법이 없다.

npx는 없으면 임시로 받아오지만 npm run은 그렇지 않다. 한 번 설치하니 둘 다 해결됐다.

5. 서로가 상대의 결함을 발견했다

이건 병렬의 좋은 면이었다.

프론트 세션이 발견한 것 — 백엔드 인증이 작동하지 않았다.

1
if (SECRET && req.headers["x-api-key"] !== SECRET) { /* 401 */ }

SECRET이 undefined면 조건 자체가 거짓이 되어 검사가 통째로 건너뛰어진다. 왜 undefined였냐면, tsx는 .env.local을 자동으로 읽지 않는다. Next.js가 읽는 것이고 순수 Node 스크립트는 안 읽는다.

게다가 서버가 모든 인터페이스에 열려 있어서, 같은 공유기의 누구나 이 계정의 LLM 사용량을 쓸 수 있었다. 세 가지를 고쳤다.

1
2
3
tsx --env-file=.env.local          환경변수를 읽게
server.listen(PORT, "127.0.0.1")   루프백만
SECRET 없으면 기동 중단             경고만으론 지나친다

마지막이 중요하다. 원래는 경고 로그만 찍고 그냥 떴다. 경고는 스크롤에 묻힌다. 안전하지 않은 상태로는 아예 뜨지 않게 하는 편이 낫다.

백엔드 세션이 채운 것 — 프론트가 미룬 미리보기. 프론트 세션이 “PPTX를 이미지로 변환해야 하니 나중에”라고 미뤄둔 슬라이드 미리보기를, 백엔드 세션이 다른 방법으로 붙였다. 슬라이드 데이터(JSON)가 이미 있으니 웹에서 다시 그리면 변환이 필요 없다는 접근이었다.

1
2
SlidePlan (JSON) ┬→ pptxgenjs → PPTX 파일
                 └→ React     → 웹 미리보기

한 세션만 돌렸으면 “변환 파이프라인을 붙일까”에서 더 오래 멈춰 있었을 것이다.

6. 오진 한 번 — 상대가 어디까지 했는지 몰라서

익명 키로 API를 찔러보니 남의 자료가 전부 조회됐다. PPTX도 그대로 다운로드됐다.

프론트 세션이 “마이그레이션이 절반만 적용됐다”고 단정하고 보고서를 쓰고 복구 SQL까지 만들었다. 그런데 실제 상태를 조회해보니 이랬다.

1
2
3
RLS      활성
정책     6개 전부 존재
버킷     비공개

전부 정상이었다. 원인은 환경변수에 공개용 키 대신 비밀 키가 들어간 것이었고, 그 키는 설계상 RLS를 우회한다. 만든 복구 SQL은 쓸 데가 없어서 지웠다.

병렬이 이 오진을 키웠다. DB 스키마를 누가 언제 적용했는지 세션이 알 수 없었기 때문에 “아직 안 됐겠지”가 그럴듯한 가설이 됐다. 확인에 1분도 안 걸리는 걸 안 하고 30분치 작업이 나갔다.

병렬로 하면서 지킨 것들

문제를 겪으면서 두 세션에 공통으로 요구하게 된 것들이다.

작업 전에 최근 변경을 본다.

1
find src -type f -newermt "-30 minutes"

전체 덮어쓰기 대신 정밀 치환을 쓴다. 앵커 문자열을 찾아 그 부분만 바꾸면, 상대가 이미 고쳐놨을 때 앵커가 안 맞아서 실패한다. 조용히 덮어쓰는 것보다 실패하는 게 낫다.

값에는 근거를 같이 적는다. 숫자만 바꾸면 되돌아온다. 실측 수치와 이유를 주석으로 남긴 뒤에야 왕복이 멈췄다.

공유 지식을 파일로 남긴다. 겪은 함정을 전부 AGENTS.md에 적었다. 다음에 이 저장소를 여는 쪽이 처음부터 다시 밟지 않도록.

소유 경계를 정한다. 나중에는 프론트가 화면·디자인·오류 안내를, 백엔드가 인증·LLM·DB를 맡는 식으로 갈렸다. 커밋할 때도 상대가 작업 중인 파일은 스테이징에서 빼고, 짝이 되는 것만 함께 넣었다(스크립트를 등록하면 그 스크립트 파일도 같이).

정리하며

병렬은 확실히 빨랐다. 하루에 화면 4개, 인증, LLM 분리, DB 정책, 미리보기까지 나왔다. 세션 하나로 순서대로 갔으면 이틀은 걸렸을 것이다.

대신 조율 비용이 있었다. 쓰이지 못한 1,916줄과 왕복한 값들이 그 비용이었다. 원인은 하나로 모인다 — 두 세션은 서로를 볼 수 없고, 둘을 다 보는 건 나뿐인데 내가 늦었다.

그래서 다음에는 세션을 띄우기 전에 셋을 먼저 정하려 한다. 셋 중 하나만 있었어도 대부분 안 생겼을 문제였다.

  1. 계약 먼저 — 엔드포인트가 몇 개인지, 입력 상한이 얼마인지. 30초 대화면 됐다
  2. 경계 먼저 — 누가 어느 파일을 소유하는지
  3. 근거 먼저 — 값에 이유를 붙이게 하면 다른 세션이 함부로 되돌리지 않는다

그리고 좋았던 것도 분명하다. 두 세션이 서로의 결함을 잡았다. 인증이 사실상 열려 있던 것도, 타임아웃 두 개가 같아서 오류 안내가 죽는 것도 자기가 쓴 코드에서는 못 봤을 것이다. 한 세션으로는 안 생기는 문제가 있고, 한 세션으로는 못 잡는 문제도 있다.

조율은 내 몫이고, 그걸 대신해 줄 세션은 없었다.


코드는 GitHub에 있고, 배포는 ir-generator-five.vercel.app에서 볼 수 있다. 같은 날 쓴 코드 안쪽 기록도 함께 남겼다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.