11장. 백엔드 개발자 — API 서버를 맡긴다

이 장이 끝나면: 백엔드 개발자 역할이 팀에 합류하고, api/ 안에 온티켓의 핵심 엔드포인트 — 이벤트 조회(/events), 주문 생성(/orders), 입장 확인(/checkins) — 가 API 명세 그대로, 그리고 처음부터 Supabase DB에 연결된 채로 구현되어 있다. 브라우저와 curl로 직접 API에 요청을 보내 응답을 확인하고, 잔여석 초과 주문이 409로 거절되는 것, 주문이 DB에 행으로 남는 것까지 본다.


11.1 개념 — 백엔드 개발자의 직업윤리는 "명세 그대로"

백엔드가 도대체 무엇인가 — 화면 뒤의 일

'백엔드(backend)'라는 말부터 풀고 시작하자. 우리가 앞에서 만든 것은 사람이 눈으로 보고 손으로 누르는 화면 — 프론트엔드(frontend), 즉 '앞단'이다. 백엔드는 그 화면 뒤에서 데이터를 보관하고, 계산하고, 규칙을 지키는 쪽 — '뒷단'이다. 식당에 비유하면 프론트엔드는 고객이 앉는 홀과 메뉴판이고, 백엔드는 주방이다. 고객은 주방을 직접 보지 못하지만, 실제로 요리가 만들어지고 재고가 관리되는 곳은 주방이다.

온티켓으로 옮기면 이렇다. 관객이 보는 예매 화면은 프론트엔드다. 그런데 "이 공연에 좌석이 몇 자리 남았나", "이 주문의 총액은 얼마인가", "이 QR 코드가 진짜 발급된 티켓인가" 같은 판단은 화면이 하면 안 된다. 화면은 관객의 컴퓨터에서 도는, 관객이 마음대로 뜯어고칠 수 있는 영역이기 때문이다. 이런 판단은 우리가 통제하는 서버 — 백엔드 — 에서 해야 한다. 이 장은 바로 그 주방을 짓는 장이다.

API란 무엇이며, 왜 '명세'가 필요한가

백엔드와 프론트엔드는 서로 다른 곳에서 돌아간다(관객 컴퓨터 vs 우리 서버). 둘은 어떻게 대화할까? API(Application Programming Interface) 를 통해서다. API는 '프로그램끼리 주고받는 약속된 창구'를 말한다. 사람이 식당에서 "9번 테이블, 파스타 둘"이라고 정해진 양식으로 주문하듯, 화면은 서버에게 "EV009 공연, TT020 등급 티켓 2장 주문"이라고 정해진 양식으로 요청을 보낸다. 그 양식을 문서로 못 박아 둔 것이 API 명세(spec) 다.

명세에는 이런 것이 적혀 있다 — 어떤 주소(경로)로, 어떤 방식(메서드)으로 요청하면, 어떤 데이터를 실어 보내야 하고, 성공하면 어떤 모양의 응답이 오고, 실패하면 어떤 코드가 오는지. 말하자면 주방과 홀이 함께 합의한 주문서 양식이다. 홀은 이 양식대로 주문을 넣고, 주방은 이 양식대로 요리를 내보낸다. 양식만 지키면, 홀과 주방은 서로의 내부 사정을 몰라도 완벽히 협업한다.

땅은 이미 다져져 있다 — 백엔드는 명세와 DB를 잇는다

백엔드가 서버 코드를 짜려면, 그 코드가 붙을 돌아가는 DB와 테이블과 데이터가 먼저 있어야 한다. "공연 목록을 돌려준다"는 코드는 공연이 담긴 테이블이 있어야 쓸 수 있고, "주문을 저장한다"는 코드는 주문을 받아 줄 테이블이 있어야 돈다. 먼저 땅을 다지고, 그 위에 집을 짓는다. 그래서 DBA가 먼저였다 — docs/ERD.md로 테이블과 컬럼과 제약을 정했고, Supabase에 실제 테이블 5개를 만들어 CSV 데이터(공연 12·티켓 등급 26·회원 50·주문 300·입장 15행)를 부어 두었다. 그리고 아키텍트는 그 ERD를 입력으로 docs/API-Spec.md를 썼다. 명세에 적힌 값은 전부 DB가 실제로 가진 값이거나, DB 값으로 계산할 수 있는 값이다.

이 장에 들어오는 지금, 손에 쥔 것을 세어 보자.

이미 있는 것 누가 만들었나 백엔드에게 무엇인가
docs/API-Spec.md 아키텍트 무엇을 주고받을지의 계약
docs/ERD.md, api/의 Drizzle 스키마·마이그레이션 DBA 실제로 가진 값의 설계도
Supabase의 테이블 5개와 데이터 DBA 코드가 붙을 진짜 DB
api/.envDATABASE_URL DBA 그 DB로 가는 접속 주소
/health만 있는 api/ 골격 뼈대를 세울 때 코드를 채울 빈 집터

백엔드의 일은 이 둘 — 명세와 DB — 을 이어 붙이는 것이다. 명세가 "GET /events는 이런 모양으로 답한다"고 적어 두었으면, DB의 events 테이블에서 값을 읽어 그 모양으로 내보내는 코드를 쓴다. 그래서 이 장에서는 코드 안에 표본 데이터를 심어 놓고 흉내 내는 단계가 없다. 첫 엔드포인트부터 진짜 DB를 읽고, 첫 주문부터 진짜 DB에 쓴다.

기준 자료가 두 개인 역할

백엔드 개발자 역할의 기준 자료는 이다.

기준 자료 무엇을 말해 주나 주인
API 명세(docs/API-Spec.md) 무엇을 주고받을지 — 경로·요청 필드·응답 필드·오류 코드 아키텍트
ERD·DB 스키마(docs/ERD.md, api/의 Drizzle 스키마) 실제로 가진 값 — 테이블·컬럼·제약 DBA

'기준 자료(source of truth)'라는 말을 한 번 짚자. 어떤 사실을 두고 여러 문서·코드가 서로 다르게 말할 때, "이것이 맞다"고 최종 판정하는 출처를 기준 자료라 한다. 주고받는 모양의 최종 판정자는 코드가 아니라 명세이고, 저장된 값의 최종 판정자는 코드가 아니라 스키마다. 코드가 이 둘과 다르면 틀린 것은 코드다 — 이 우선순위를 몸에 새기는 것이 이 장의 핵심 태도다.

먼저 명세 쪽. 백엔드가 명세와 다르게 만들면 무슨 일이 생기나 — 명세를 보고 만든 화면이 통째로 어긋난다. 필드 이름 하나(price vs amount)만 달라도 화면에는 빈칸이 뜬다. 화면 담당자도, 나중에 합류할 QA도, 전부 같은 명세를 보고 각자 자기 일을 한다. 모두가 같은 악보를 보기 때문에 따로 연습해도 합주가 되는 것이다.

다음은 스키마 쪽. 스키마는 DBA의 것이다 — 백엔드는 스키마를 고치지 않는다. 구현하다 보면 "컬럼 하나만 더 있으면 편하겠는데" 싶은 순간이 온다. 그때 백엔드가 테이블을 슬쩍 고치면, ERD와 실제 DB가 어긋나고, ERD를 근거로 쓴 명세까지 근거를 잃는다. 반대로 컬럼이 없다고 코드 안에 값을 지어내 끼워 넣어도 안 된다. 컬럼이 더 필요하면 코드로 때우지 말고 사람에게 보고한다. 사람이 받아들이면 DBA가 ERD와 스키마를 고치고, 아키텍트가 명세를 맞추고, 그다음에야 백엔드가 코드를 쓴다. 문서가 흘러온 순서 그대로 다시 거치는 것이다.

한 가지 미리 알아 둘 것이 있다. 명세의 필드 이름과 DB 컬럼 이름이 글자까지 같지는 않다. 어느 컬럼이 어느 필드가 되는지는 docs/ERD.md의 대응표에 적혀 있고, 백엔드는 그 표대로 옮긴다. 그리고 잔여(remaining)는 DB에 저장된 값이 아니라 총수량 − 판매수량으로 서버가 계산해 내보내는 값이다. 이런 '옮기기와 계산'이 곧 명세와 DB를 잇는 일의 실체다.

그래서 백엔드의 직업윤리는 한 문장이다.

명세에 있는 대로 만들고, 명세에 없는 것은 만들지 말고 질문한다.

"명세에 없지만 있으면 좋을 것 같아서" 필드를 추가하는 것은 친절이 아니라 사고다. 명세에 구멍이 보이면 코드로 때우지 말고 명세를 고치자고 제안하는 것 — 그것이 올바른 작업 순서다. (사람 개발팀에서도 똑같다. 명세 안 고치고 코드만 고치는 팀은 반드시 명세와 현실이 어긋난 채로 굳는다.)

왜 "있으면 좋을 것 같아서"가 사고가 되는지 조금 더 풀어 보자. 백엔드가 명세에 없는 discount_rate(할인율) 필드를 선의로 하나 추가했다고 하자. 화면 담당자는 그 필드가 있는 줄 모르니 안 쓴다. 그러면 그 필드는 아무도 안 읽는 채로 응답에 실려 다니며 용량만 잡아먹는다. 반대로 화면이 어쩌다 그 필드를 발견해 쓰기 시작하면, 이번엔 명세에 없는 동작에 의존하는 코드가 생긴다. 나중에 백엔드가 그 필드를 지우는 순간 화면이 깨진다. 어느 쪽이든 "명세에 없는데 실제로는 있는" 상태가 시한폭탄이 된다. 그래서 규율은 단호하다 — 없으면 만들지 말고, 필요하면 먼저 명세에 적자고 말한다.

[짚고 가기] 이 "없으면 질문한다"는 규율은 AI 시대에 특히 중요하다. AI는 유능하고 부지런해서, 시키지 않아도 "이런 것도 있으면 편하겠네" 하며 필드나 엔드포인트를 덤으로 만들어 주려는 경향이 있다. 사람 신입이라면 눈치를 보며 안 할 일을, AI는 자신 있게 해 버린다. 그래서 백엔드 역할에게는 "친절한 초과 구현 금지"를 명시적으로 못 박아 둘 필요가 있다. 뒤에서 백엔드 역할의 금지 목록에 이 규율을 새기는 이유가 여기 있다.

"서버는 클라이언트를 믿지 않는다"는 직업윤리

명세 준수와 짝을 이루는 백엔드의 두 번째 계율이 있다. 서버는 클라이언트를 믿지 않는다. 여기서 '클라이언트(client)'는 서버에 요청을 보내는 쪽 — 우리 경우 관객의 브라우저 화면을 말한다. 왜 안 믿는가? 그 화면은 관객의 컴퓨터에서 돌기 때문이다. 마음만 먹으면 관객은 브라우저 개발자 도구로 화면 코드를 뜯어고쳐, 서버에 아무 요청이나 보낼 수 있다.

구체적으로 상상해 보자. 화면에서는 "수량은 최대 4장"이라고 막아 두었다. 하지만 악의적 사용자는 그 제한을 무시하고 서버에 직접 "수량 400장" 요청을 쏠 수 있다. 화면이 "총액 99,000원"이라고 계산해 보여줬어도, 사용자는 "총액 900원"이라고 조작한 요청을 보낼 수 있다. 화면에서 이미 검사했으니 서버는 믿고 넘어가자 — 이것이 바로 사고의 지름길이다.

그래서 서버는 화면의 검사를 편의로만 취급하고, 자기 손으로 다시 검사한다. 수량이 범위 안인지, 잔여석이 충분한지, 금액이 얼마인지를 서버가 처음부터 다시 판정한다. 화면의 검사는 사용자에게 빠른 피드백을 주는 친절이고, 서버의 검사가 진짜 방어선이다. 이 한 문장이 이 장 실습의 하이라이트(금액 조작 실험)로 이어진다.


11.2 용어 정리

이 절의 용어들은 이 장 내내, 그리고 온티켓의 뒷단 전체에서 계속 쓰인다. 코드를 직접 칠 일은 없지만, AI가 만든 코드와 curl 결과를 읽고 판단하려면 이 말들이 손에 익어야 한다. 완벽히 외우려 하지 말고 "이런 게 있구나" 정도로 눈에 익혀 두면 실습하며 붙는다.

Hono — API 서버의 뼈대 도구

Hono는 API 서버를 만드는 작고 빠른 프레임워크다. "어떤 주소로 어떤 요청이 오면, 이 함수를 실행해서 응답해라"를 선언하는 도구라고 보면 된다. Cloudflare Workers 위에서 도는 API의 표준적 선택이고, 코드가 단순해서 AI가 특히 실수 없이 다룬다.

용어를 조금 더 풀자. '프레임워크(framework)'는 '뼈대·틀'이라는 뜻으로, 자주 하는 일을 미리 갖춰 둔 반제품이다. API 서버를 맨바닥부터 만들면 "요청을 받는 부분", "주소를 구분하는 부분", "응답을 포장하는 부분"을 전부 손으로 짜야 하는데, 프레임워크는 그 틀을 제공해서 우리는 알맹이(핵심 로직)만 채우면 되게 해 준다. 집을 지을 때 기둥과 골조가 이미 서 있는 상태에서 방을 꾸미는 것과 같다.

'Hono(ホノ)'는 일본어로 '불꽃'이라는 뜻이다. 이름값처럼 아주 빠르고 가벼운 것이 특징이다. 비슷한 도구로 오래된 Express(익스프레스)가 있는데, Hono는 그보다 훨씬 가볍고, 무엇보다 Cloudflare Workers 같은 최신 실행 환경에서 잘 돈다. 우리가 온티켓 API를 Cloudflare Workers에 올릴 계획이라 Hono가 자연스러운 짝이다.

도구 성격 온티켓에서
Hono 가볍고 빠름, 최신 환경(Workers 등)에 최적 우리가 쓰는 것
Express 가장 오래되고 자료 많음, 전통적 서버에 강함 안 씀(무겁고 Workers와 궁합이 덜함)
Fastify 성능 지향, 기능 풍부 안 씀(이번 규모엔 과함)

지금 단계에서 이 비교를 외울 필요는 없다. "API 서버 뼈대는 여러 선택지가 있고, 우리는 가볍고 AI가 실수 없이 다루는 Hono를 골랐다" 정도면 충분하다.

라우트와 핸들러

라우트(route) 는 "메서드 + 경로"의 조합 하나다. GET /events, POST /orders 각각이 라우트다. 핸들러(handler) 는 그 라우트에 요청이 왔을 때 실행되는 함수다. Hono 코드에서 이 둘은 이렇게 생겼다.

// api/src/index.ts (발췌 예시)
app.get('/events/:id', (c) => {          // ← 라우트: GET /events/:id
  const id = c.req.param('id')            // ← 경로에서 :id 부분을 꺼낸다
  const event = findEvent(id)             //    (예: EV009)
  if (!event) return c.json({ error: 'NOT_FOUND' }, 404)
  return c.json(event)                    // ← JSON으로 응답
})                                        //    이 함수 전체가 핸들러

코드를 전부 이해할 필요는 없다 — 어차피 작성은 AI가 한다. 다만 구조를 읽을 수 있으면 리뷰가 가능해진다: "GET /events/:id 에 (요청이 오면) → id를 꺼내 → 찾아보고 → 없으면 404, 있으면 JSON." 이 정도로 읽히면 충분하다.

용어의 뜻을 비유로 마저 잡자. '라우트(route)'는 원래 '길·경로'라는 뜻이다. 우체국이 주소를 보고 편지를 알맞은 동네로 보내듯, 서버도 들어온 요청의 '메서드+경로'를 보고 알맞은 처리 담당에게 보낸다. 이 길 배정을 '라우팅(routing)'이라 하고, 배정된 목적지 하나하나가 라우트다. '핸들러(handler)'는 '처리하는 사람'이라는 뜻으로, 그 목적지에서 실제로 일을 하는 담당 직원이다. 정리하면 — 라우트는 주소, 핸들러는 그 주소의 담당자다.

'메서드(method)'라는 말도 여기서 짚자. 같은 주소라도 "무엇을 하러 왔는가"에 따라 요청의 종류가 다르다. 이 종류를 HTTP 메서드라 부른다. 우리 장에서 쓰는 것은 둘뿐이다.

메서드 온티켓 예
GET 조회한다(가져오기만, 아무것도 바꾸지 않음) GET /events 이벤트 목록 보기
POST 생성한다·처리한다(서버 상태를 바꿈) POST /orders 주문 만들기

GET은 "보여만 주세요"라 몇 번을 눌러도 안전하고, POST는 "이걸 만들어 주세요"라 누를 때마다 새 주문이 생긴다. 그래서 뒤에서 보듯 GET은 브라우저 주소창으로도 시험할 수 있지만, POST는 curl 같은 도구로 본문을 실어 보내야 한다.

요청에 데이터가 실려 오는 세 가지 길

요청은 그냥 주소만 오는 게 아니라, 대개 데이터를 함께 싣고 온다. 데이터가 실려 오는 길은 세 가지다. 이 셋을 구분하는 것이 API를 읽는 기본기다.

생김새 쓰임
경로 파라미터 /events/EV009EV009 "어느 것"을 지목할 때
쿼리 /events?category=콘서트 필터·검색 조건
본문(body) POST 요청에 실리는 JSON 만들 데이터 (주문 내용 등)

세 길을 우체국 비유로 이어 보자. 경로 파라미터는 주소 자체에 박혀 있는 번지수다 — /events/EV009는 "EV009라는 그 공연"을 콕 집는다. 하나를 지목할 때 쓴다. 쿼리(query) 는 주소 뒤에 ?로 붙는 조건표다 — ?category=콘서트는 "콘서트만 골라 줘"라는 검색·필터 조건이다. '쿼리'는 '질의·물음'이라는 뜻이라, "이런 조건으로 물어본다"고 기억하면 된다. 본문(body) 은 봉투 안에 넣어 보내는 내용물이다 — 주소만으로 표현하기엔 큰 데이터(주문 내역 같은)를 JSON 형태로 실어 보낸다.

'JSON(제이슨)'도 여기서 처음 나왔으니 한 줄 풀자. JSON은 프로그램끼리 데이터를 주고받을 때 쓰는 표준 표기법으로, {"quantity": 2, "buyer_email": "me@test.com"}처럼 '이름:값' 쌍을 중괄호로 묶은 모양이다. 사람도 읽을 수 있을 만큼 단순해서, 오늘날 대부분의 API가 JSON으로 요청·응답을 주고받는다. 우리 curl 실습에서 계속 보게 될 모양이다.

상태 코드 — 응답에 붙는 세 자리 숫자

API 응답에는 본문(JSON)만 오는 게 아니라, "결과가 어땠는가"를 알리는 세 자리 숫자 — HTTP 상태 코드(status code) — 가 함께 온다. 이 장에서 우리가 일부러 만들어 낼 코드들이라 미리 정리해 둔다.

코드 온티켓에서 언제
200 OK 성공(조회) GET /events가 목록을 잘 돌려줄 때
201 Created 성공(생성됨) POST /orders로 주문이 새로 만들어질 때
400 Bad Request 요청이 잘못됨(사용자 탓) 수량이 1~4 범위 밖일 때
404 Not Found 그런 것 없음 없는 이벤트 ID(EV999)를 찾을 때
409 Conflict 충돌(상태가 안 맞음) 잔여석보다 많이 주문할 때

숫자의 앞자리로 큰 뜻을 가른다 — 2xx는 성공, 4xx는 요청자(클라이언트)의 잘못, 5xx는 서버의 잘못이다. 특히 이 장의 주인공은 409다. '409 Conflict'는 "요청 자체는 형식이 맞지만, 지금 서버의 상태와 부딪혀서(충돌해서) 들어줄 수 없다"는 뜻이다. 남은 좌석이 2석인데 4석을 달라는 주문이 딱 이 경우다 — 요청 형식은 멀쩡한데 재고와 충돌한다. 그래서 초과 주문의 표준 응답이 409다.

'오류를 200으로 포장하지 말라'는 규율(뒤에서 볼 백엔드 역할의 금지 목록)이 왜 중요한지 여기서 이어진다. 실패했는데도 200(성공)을 돌려주고 본문에만 "실패"라고 적으면, 화면과 다른 시스템은 겉의 200만 보고 "성공했구나" 오해한다. 실패는 반드시 4xx/5xx 상태 코드로 정확하게 전달해야 한다.

검증 (validation)

들어온 요청이 약속(명세)대로인지 검사하는 것. 수량이 1~4 사이인가, 이메일 형식이 맞는가, 잔여석이 충분한가. 서버는 클라이언트를 믿지 않는다가 원칙이다 — 화면에서 이미 검사했더라도 서버는 다시 검사한다. 화면 검사는 사용자 편의고, 서버 검사가 진짜 방어선이다.

'검증(validation)'은 '유효한지(valid) 확인한다'는 뜻이다. 검증에는 결이 다른 두 층이 있다는 걸 알아 두면 좋다. 하나는 형식 검증 — 수량이 숫자인가, 이메일에 @가 있는가 처럼 요청의 생김새를 보는 것. 다른 하나는 규칙 검증 — 잔여석이 충분한가, 이 등급이 이 공연에 실제로 있는가처럼 우리 데이터·비즈니스 규칙과 대조하는 것. 형식 검증은 문지기가 신분증 양식을 보는 것이고, 규칙 검증은 명단과 실제로 대조하는 것이다. 둘 다 통과해야 요청을 받아들인다.

개념 절에서 말한 "서버는 클라이언트를 믿지 않는다"가 여기서 실체를 얻는다. 화면이 아무리 꼼꼼히 검사해도, 그 검사는 관객 컴퓨터에서 도는 코드라 우회당할 수 있다. 그래서 서버는 같은 검사를 한 번 더, 자기 손으로 한다. 중복처럼 보여도 이 중복이 방어선이다.

curl — 터미널에서 API에 요청을 보내 보는 도구

화면 없이 API만 시험하고 싶을 때 쓰는 명령줄 도구다(Git Bash와 macOS에 기본 포함). 브라우저 주소창은 GET밖에 못 보내지만 curl은 POST도 보낼 수 있다.

터미널
# GET — 이벤트 목록
$ curl http://localhost:8787/events

# POST — 주문 생성 (JSON 본문을 실어 보낸다)
$ curl -X POST http://localhost:8787/orders \
  -H "Content-Type: application/json" \
  -d '{"event_id":"EV009","ticket_type_id":"TT020","quantity":2,"buyer_email":"test@example.com"}'

개발 현장에서 curl은 만능 검진 도구다 — "API가 죽었나?" 싶을 때, 배포 직후 확인할 때, 남의 API를 처음 써 볼 때 가장 먼저 꺼내는 것이 curl이다.

이 장에서 curl을 계속 쓰게 되니, 위 명령의 옵션들을 한 번 뜯어 두면 두려움이 사라진다. Claude Code를 설치할 때 설치 명령 curl -fsSL ...을 뜯어 봤던 것과 같은 방식이다.

GET과 POST를 curl로 어떻게 다르게 쓰는지 나란히 보면 이렇다.

브라우저 주소창 curl
GET(조회) 된다 (localhost:8787/events 입력) curl localhost:8787/events
POST(생성) 안 된다 (본문을 실을 수 없음) -X POST -H ... -d '{...}' 로 본문을 실어 보냄

브라우저 주소창은 결국 GET 요청만 보낼 수 있는 창구라, 주문 생성 같은 POST는 시험할 수 없다. 그래서 이 장의 뒷단 실습은 curl이 주력 도구가 된다. 화면이 아직 없어도, 화면이 언젠가 보낼 요청을 우리가 curl로 흉내 내어 미리 서버를 시험하는 것이다.

한 가지 실전 팁 — 응답 JSON이 한 줄로 뭉쳐 읽기 힘들면, 뒤에 파이프로 정리 도구를 붙일 수 있다. 다만 이 과정에서는 그런 명령을 외울 필요가 없다. "curl 응답을 보기 좋게 정리해 줘"라고 AI에게 시키면 되기 때문이다. 도구 사용법조차 AI에게 맡기는 것이 이 과정의 방식이다.

[짚고 가기] curl이라는 단어가 낯설다면, 이 순서로 직접 해 보면 감이 쉽게 잡힌다. (1) 먼저 브라우저 주소창에 localhost:8787/events를 쳐서 GET이 되는 것을 본다. (2) 그다음 "그럼 주문은?" 하고 주소창에 주문을 넣어 보면 안 된다는 것을 확인한다(주소창은 데이터를 못 싣는다). (3) 그래서 curl이 필요하고, POST를 curl로 날려 본다. 이렇게 해 보면 "브라우저가 못 하는 걸 curl이 한다"는 필요성이 몸으로 이해된다. 참고로 localhost는 '내 컴퓨터 자신'을 가리키는 주소이고, :8787은 그 안에서 우리 api 서버가 듣고 있는 문(포트) 번호다.


11.3 백엔드 역할 정의 — 네 칸과 스킬

앞선 장들에서 역할을 '판단 기준·금지 목록·프로세스·도구 권한'의 네 칸으로 설계했다. 백엔드 역할도 같은 틀로 정의한다. 이 네 칸이 곧 이 역할의 직업윤리를 코드처럼 못 박은 것이다.

네 칸으로 설계하면 이렇다.

내용
판단 기준 주고받는 모양은 API 명세가 법, 가진 값은 ERD·DB 스키마가 법이다. 명세에 없으면 만들지 않고 질문한다. 컬럼이 모자라면 코드로 때우지 않고 보고한다. 모든 판정(재고·금액)은 서버가 한다
금지 목록 명세에 없는 필드·엔드포인트 추가 금지 / 명세(docs/API-Spec.md) 직접 수정 금지 — 계약을 바꿔야 하면 사람에게 보고하고 /architect를 거친다 / DB 스키마·마이그레이션 수정 금지(DBA의 것) / 코드 안에 임시 데이터 심기 금지 / 금액·잔여 계산을 클라이언트에 위임 금지 / 화면(web/) 코드 수정 금지 / 오류를 200으로 포장 금지
프로세스 명세·ERD 확인 → 계획 → 구현 → 명세와 대조하는 자기 검증 → 보고
도구 권한 api/ 폴더 안에서만 작업하되, 그 안의 Drizzle 스키마·마이그레이션 파일은 읽기만 한다. DB에는 데이터 읽기·쓰기만 하고 테이블 구조는 바꾸지 않는다 (본 대화에서 역할 지시로 운용하되, 경계는 CLAUDE.md 규칙으로)

각 칸이 왜 그렇게 적혔는지 한 줄씩 풀어 두면, 학생이 나중에 자기 역할을 설계할 때 응용할 수 있다.

이것을 스킬로 만들어 두면 /backend 한마디로 이 규율이 적용된다.

'스킬(skill)'이라는 말을 다시 짚자. 스킬은 이 네 칸짜리 역할 정의를 파일 하나 (.claude/skills/backend/SKILL.md)에 담아 둔 것이다. 매번 "명세가 법이고, 없으면 질문하고, 판정은 서버가 하고…"를 다시 설명하는 대신, /backend라고 부르기만 하면 이 규율 전체가 한꺼번에 걸린다. 회사로 치면 '백엔드 개발자 직무기술서'를 한 장으로 정리해 두고, 새 일을 줄 때마다 그 직무기술서를 첨부하는 것과 같다.

명세 기반으로 일하는 방식은 실무 어디에나 있다 — 이 역할 정의는 온티켓 전용이 아니다.

이 사례들을 조금 더 구체화하면 '명세가 법'이라는 감각이 온티켓 밖으로도 확장된다.

즉 '명세대로, 없으면 질문'은 온티켓 백엔드의 특수 규율이 아니라, 시스템과 시스템이 협업하는 모든 자리의 공통 직업윤리다. 이 장에서 그 감각을 몸에 익히면, 나중에 어떤 연동을 맡아도 첫 질문이 "명세는 어디 있나요?"가 된다.

[짚고 가기] 이 절은 자칫 딱딱한 규칙 나열로 읽히기 쉽다. 실무 사례(특히 결제·배송 연동에서 명세를 어겨 사고 난 이야기)를 함께 떠올리면 규율의 무게가 살아난다. "명세에 없는 필드를 선의로 더 보냈다가 상대 시스템이 거절해 결제가 다 실패한" 류의 사고는 업계에 흔하다. 규칙을 외우기보다 "왜 그래야 하는지"를 사례와 함께 붙잡아 두면 오래 남는다.


11.4 프롬프트 작성법 — 명세를 무기로 지시한다

이 절의 프롬프트들이 이 장의 실전 핵심이다. 공통된 발상은 하나다 — 명세를 첨부하고, 울타리를 치고, 확인을 강제한다. 백엔드에게는 "잘 만들어 줘"가 아니라 "명세대로 만들고, 명세와 대조해 보고해 줘"라고 시킨다. 세 가지 상황별로 나눠 본다.

구현 지시 — 명세를 첨부하고, 울타리를 친다

프롬프트
/backend @docs/API-Spec.md 명세의 GET /events 와 GET /events/:id 를
api/ 에 구현해줘.
- 명세 그대로. 명세에 없는 필드를 추가하면 실패다
- 데이터는 Supabase DB에서 읽는다(api/.env 의 DATABASE_URL, DBA가 만든
  Drizzle 스키마를 그대로 사용). 코드 안에 임시 데이터를 심지 마
- 스키마·마이그레이션은 고치지 마. 컬럼이 모자라면 구현을 멈추고 나에게 보고해
- 명세 필드와 DB 컬럼의 대응은 @docs/ERD.md 의 대응표를 따른다
- 구현 후 명세와 응답을 대조한 자기 검증 결과를 표로 보고해

이 지시가 왜 좋은지 뜯어 보자. @docs/API-Spec.md명세를 손에 쥐여 준다(@는 파일을 대화에 첨부하는 표시다). "명세 그대로, 없는 필드 추가하면 실패"로 울타리를 친다. "Supabase DB에서 읽는다, 코드 안에 임시 데이터를 심지 마"로 데이터 출처를 못 박는다 — 이 줄이 없으면 AI는 DB 연결이 번거로울 때 코드 안에 공연 몇 개를 적어 넣고 "됩니다"라고 보고할 수 있다. 응답은 똑같이 생겼으니 눈으로는 구분되지 않는다. "스키마는 고치지 마, 모자라면 보고해"는 DBA의 영역을 지키는 선이다. 마지막으로 "자기 검증 결과를 표로"라는 완료 기준을 박는다. 첫 지시를 배울 때 익힌 '목표 + 완료 기준 + 제약'의 기본형이, 백엔드 버전으로 확장된 모습이다.

판정 로직 지시 — 사고 나는 지점을 명시한다

주문 생성은 온티켓의 심장이다. 지시에 판정 규칙을 명시적으로 싣는다.

프롬프트
/backend 명세의 POST /orders 를 구현해줘. 반드시:
- 금액은 서버가 계산한다. 요청에 금액이 실려 와도 무시한다
- 잔여 수량(총수량 − 판매수량)을 DB에서 확인하고, 부족하면 명세대로 409를 돌려준다
- 수량은 1~4 범위 밖이면 400
- 성공하면 orders 테이블에 주문 행을 저장하고, ticket_types 의 판매수량을
  주문 수량만큼 늘린다. 둘은 함께 성공하거나 함께 실패해야 한다
- 성공 시 주문번호와 QR 코드 문자열을 만들어 201로 돌려준다

"요청에 금액이 실려 와도 무시한다"에 밑줄을 긋자. 악의적 사용자가 브라우저에서 금액을 99000→900으로 조작해 보내는 일은 실제로 일어난다. 서버가 스스로 계산하는 것만이 방어다.

이 지시의 요령은 "사고가 날 수 있는 지점을 미리 문장으로 못 박는다" 는 것이다. 주문 생성에는 사고가 날 자리가 최소 셋이다 — 금액(조작), 재고(초과), 수량(범위). 지시에 이 셋의 처리 규칙을 명시적으로 실으면, AI가 알아서 잘하겠지에 기대지 않고 우리가 원하는 판정을 못 박을 수 있다. 특히 첫 줄(금액은 서버가 계산)은 개념 절의 '서버는 클라이언트를 믿지 않는다'를 코드로 옮기는 결정적 한 줄이다.

"함께 성공하거나 함께 실패해야 한다"는 줄도 짚자. 주문 행은 저장됐는데 판매수량은 안 늘었다면, 장부상 좌석이 남아 있는 것처럼 보여 그 좌석이 또 팔린다. 여러 번의 DB 쓰기를 하나로 묶어 '전부 되거나 전부 안 되게' 만드는 장치를 트랜잭션(transaction) 이라 한다. 구현은 AI가 하지만, 이 요구를 지시에 싣는 것은 사람의 몫이다.

확인 지시 — 구현했다는 말을 믿지 않는다

프롬프트
방금 구현한 세 엔드포인트를 명세와 대조해줘.
경로·메서드·요청 필드·응답 필드·오류 코드가 전부 일치하는지 표로.
불일치가 있으면 코드가 아니라 나에게 먼저 보고해 — 명세를 고칠지
코드를 고칠지는 내가 정한다.

마지막 줄이 중요하다. 불일치의 해결 방향(명세 수정 vs 코드 수정)은 결정이고, 결정은 사람 몫이다.

왜 굳이 "표로" 대조를 시키는가? "다 맞게 구현했습니다"라는 한 줄은 검증이 아니라 주장이다. 경로·메서드·요청 필드·응답 필드·오류 코드를 명세와 코드 두 열로 나란히 놓고 한 칸씩 대조한 표는, AI 스스로도 빠진 항목을 발견하게 만들고, 사람도 눈으로 확인할 수 있는 증거가 된다. '자기 검증'을 말이 아니라 표라는 형태로 강제하는 것이 이 지시의 핵심이다.

약한 지시 → 강한 지시 (백엔드판)

같은 목표라도 지시를 어떻게 적느냐에 따라 결과가 갈린다. 첫 지시를 배울 때 본 약한 지시 → 강한 지시 변환표를 백엔드 상황으로 옮겨 본다.

약한 지시 강한 지시 무엇을 바꿨나
주문 API 만들어줘 명세의 POST /orders 를 구현해줘. 금액은 서버가 계산(요청 금액 무시), 잔여 부족 시 409, 수량 범위 밖 400, 성공 시 201 판정 규칙을 지시에 명시
이벤트 목록 나오게 해줘 명세의 GET /events 를 명세 그대로 구현해줘. 명세에 없는 필드 추가 금지, 데이터는 Supabase DB에서 읽고 코드 안에 임시 데이터를 심지 마 명세 준수와 데이터 출처를 못 박음
잘 됐는지 봐줘 세 엔드포인트를 명세와 대조해 표로 보고. 불일치는 코드 말고 나에게 먼저 검증을 표로 강제, 결정권은 사람에게
없는 이벤트도 처리해줘 없는 ID 요청 시 명세대로 404와 에러 본문을 돌려줘 '처리'를 구체적 코드·응답으로
보안 신경 써줘 요청에 amount가 실려 와도 무시하고 서버가 계산한 금액만 신뢰해 막연한 '보안'을 구체 규칙으로

핵심 감각은 그때와 같다 — 막연한 형용사('잘', '보안 신경 써서')를 눈으로 확인 가능한 규칙과 완료 기준으로 바꾼다. 백엔드에서는 그 규칙이 대개 "어떤 상황에 어떤 상태 코드를 돌려준다"의 형태를 띤다.

수정 지시의 예 — 불일치가 나왔을 때

명세 대조에서 불일치가 나오면, 고치는 지시도 방향을 명확히 실어 준다. 결정은 사람이 하고, 실행만 AI에게 넘기는 리듬이다.

프롬프트
(코드 쪽을 고치기로 결정한 경우)
아까 대조에서 나온 불일치 중 '응답 필드 remaining 이 명세엔 있는데 코드엔 빠짐'을
코드 쪽을 고쳐서 맞춰줘. 명세는 그대로 두고.
프롬프트
(명세 쪽을 고치기로 결정한 경우)
대조에서 나온 '응답에 sold_out 필드가 코드엔 있는데 명세엔 없음'은
코드가 맞다고 판단했어. 명세(docs/API-Spec.md)에 sold_out 필드를 추가해줘.
왜 필요한지 한 줄 설명도 명세에 남겨줘.

두 지시의 공통점은 "무엇을 기준으로 삼을지(명세 vs 코드)를 사람이 먼저 정하고, AI에게는 그 방향으로 맞추는 실행만 시킨다"는 것이다. 이것이 백엔드 역할 프로세스의 '보고 → (사람의 결정) → 수정'이 실제로 도는 모습이다. 불일치가 명세와 코드 사이가 아니라 명세와 DB 사이에서 나왔다면(명세에는 있는데 DB에는 없는 값) 백엔드 선에서 풀 일이 아니다. 사람이 DBA와 아키텍트를 다시 불러 ERD와 명세부터 맞춘다.

[짚고 가기] 이 절은 프롬프트를 '읽는' 것으로 끝내지 말고, 하나라도 직접 넣어 보면 좋다. 특히 판정 로직 지시(POST /orders)를 강한 버전으로 넣었을 때와, "주문 API 만들어줘"라는 약한 버전으로 넣었을 때 결과가 어떻게 다른지 비교해 보면 '지시에 규칙을 싣는다'는 감각이 산다. 약한 지시로도 AI가 곧잘 만들긴 하지만, 금액 조작을 막는지·409를 정확히 쓰는지는 강한 지시에서 훨씬 안정적이다.


11.5 실습 — 온티켓 API를 세운다

이제 실제로 온티켓의 뒷단을 세운다. 목표는 명확하다 — 화면 없이도, curl과 브라우저만으로 "이벤트가 조회되고, 주문이 만들어지고, 초과 주문은 거절되고, 금액 조작은 무시되는" API를 눈으로 확인하는 것이다. 그리고 매번 Supabase 대시보드를 함께 열어, 그 데이터가 정말 DB에서 나오고 DB로 들어가는지를 대조한다.

[잠깐] 시작 전에 확인한다. docs/ERD.md, docs/API-Spec.md, api/.envDATABASE_URL, 그리고 Supabase 대시보드의 테이블 5개(데이터 포함)가 있어야 한다. 하나라도 없으면 이 장의 지시는 기댈 땅이 없다 — DBA와 아키텍트의 작업을 먼저 끝낸다.

따라하기 1 — 백엔드 스킬 생성

프롬프트
.claude/skills/backend/SKILL.md 로 백엔드 개발자 스킬을 만들어줘.
- 기준 자료는 둘: 주고받는 모양은 docs/API-Spec.md, 실제로 가진 값은
  docs/ERD.md 와 api/ 의 Drizzle 스키마
- 판단 기준: 명세에 없으면 만들지 말고 질문. 컬럼이 모자라면 코드로 때우지 말고
  나에게 보고. 모든 판정(재고·금액)은 서버가 한다
- 프로세스: 명세·ERD 확인 → 계획 → 구현 → 명세 대조 자기 검증 → 보고
- 금지: 명세에 없는 필드·엔드포인트 추가, docs/API-Spec.md 직접 수정(계약 변경은
  나에게 보고 — /architect 를 거친다), DB 스키마·마이그레이션 수정,
  코드 안에 임시 데이터 심기, 금액·재고 판정의 클라이언트 위임,
  web/ 코드 수정, 오류의 200 포장
- 도구 권한: api/ 안에서만 작업. 스키마·마이그레이션 파일은 읽기만

위 프롬프트를 그대로 복사해 입력하면 된다. 다른 내용으로 바꿔서 해 보고 싶다면 자유롭게 바꿔도 좋다.

이 지시가 만드는 것은 위 역할 정의의 네 칸을 담은 스킬 파일 하나다. 만든 뒤 /backend라고 불러 보면 이 규율이 걸린 상태로 대화가 시작된다. 스킬을 먼저 만드는 이유는, 이후 모든 구현 지시가 이 규율 위에서 돌게 하기 위해서다 — 매번 규칙을 되풀이하지 않아도 되는 것이다.

따라하기 2 — 조회 API 구현

아래 지시(위 '프롬프트 작성법'에서 본 구현 지시 — GET /events, GET /events/:id)를 실행한다.

프롬프트
/backend @docs/API-Spec.md 명세의 GET /events 와 GET /events/:id 를
api/ 에 구현해줘.
- 명세 그대로. 명세에 없는 필드를 추가하면 실패다
- 데이터는 Supabase DB에서 읽는다(api/.env 의 DATABASE_URL, DBA가 만든
  Drizzle 스키마를 그대로 사용). 코드 안에 임시 데이터를 심지 마
- 스키마·마이그레이션은 고치지 마. 컬럼이 모자라면 구현을 멈추고 나에게 보고해
- 명세 필드와 DB 컬럼의 대응은 @docs/ERD.md 의 대응표를 따른다
- 구현 후 명세와 응답을 대조한 자기 검증 결과를 표로 보고해

위 프롬프트를 그대로 복사해 입력하면 된다. 다른 내용으로 바꿔서 해 보고 싶다면 자유롭게 바꿔도 좋다.

AI가 계획을 내놓으면 실행 전에 한 가지를 본다 — 데이터를 DB에서 읽는가. 계획에 "공연 목록을 코드 안 배열로 둔다" 같은 말이 보이면 돌려보낸다. 스키마 파일을 고치겠다는 계획도 마찬가지다.

끝나면 api 서버가 떠 있는 상태에서 브라우저로 확인한다.

네 번째가 DB 연결의 첫 증명이다. 더 확실히 하고 싶으면 대시보드에서 공연 하나의 제목 끝에 글자 하나를 붙여 저장하고 브라우저를 새로고침해 보자. 응답의 제목이 따라 바뀌면, 이 API는 코드 안의 사본이 아니라 진짜 DB를 읽고 있는 것이다(확인한 뒤 제목은 원래대로 돌려놓는다).

JSON이 한 덩어리로 읽기 힘들면 브라우저 확장 없이도 방법이 있다 — "응답을 보기 좋게 정리해 보여줘"라고 AI에게 시키면 된다.

세 번째 확인(EV999)을 그냥 지나치지 말자. 없는 것을 달라고 했을 때 404가 정확히 나오는지를 보는 것은, "정상일 때만이 아니라 비정상일 때도 명세대로 반응하는가"를 확인하는 것이다. 초심자는 "잘 되면 됐지"라며 성공 경로만 보는데, 실무의 안정성은 오히려 실패 경로를 얼마나 제대로 처리하느냐에서 갈린다. 없는 ID에 500(서버 오류)이나 빈 화면이 뜨면 그건 버그다 — 명세는 404를 요구하기 때문이다.

'localhost:8787'이 낯설면 한 줄로 정리하자. localhost는 '내 컴퓨터 자신'을 가리키는 주소이고, :8787은 그 안에서 우리 api 서버가 귀를 열고 기다리는 문 번호(포트)다. 즉 이 주소는 "지금 이 컴퓨터 안에서 도는 우리 api 서버에게 물어봐"라는 뜻이다. 아직 인터넷에 올린 게 아니라, 내 컴퓨터 안에서만 도는 상태다.

[짚고 가기] 조회 API가 뜨는 순간이 이 장 첫 성취감 포인트다. 브라우저에 JSON이 좌르륵 뜨는 걸 보면 "화면 없이도 데이터가 나온다"는 게 체감된다. 그것도 DBA가 부어 둔 바로 그 데이터다 — DB → api → 브라우저로 이어지는 줄이 처음 이어진 것이다. 이 JSON을 프론트엔드가 받아 예매 페이지를 그린다고 생각하면, 백엔드가 왜 화면보다 먼저 서는지가 자연스럽게 이해된다. JSON이 잘 안 읽히면 "정리해서 보여줘"를 AI에게 시키면 된다 — 도구 사용법조차 AI에게 맡기는 것이 이 과정의 방식이다.

따라하기 3 — 주문 API 구현

아래 지시(위 '프롬프트 작성법'에서 본 주문 API 지시)를 실행한다.

프롬프트
/backend 명세의 POST /orders 를 구현해줘. 반드시:
- 금액은 서버가 계산한다. 요청에 금액이 실려 와도 무시한다
- 잔여 수량(총수량 − 판매수량)을 DB에서 확인하고, 부족하면 명세대로 409를 돌려준다
- 수량은 1~4 범위 밖이면 400
- 성공하면 orders 테이블에 주문 행을 저장하고, ticket_types 의 판매수량을
  주문 수량만큼 늘린다. 둘은 함께 성공하거나 함께 실패해야 한다
- 성공 시 주문번호와 QR 코드 문자열을 만들어 201로 돌려준다

위 프롬프트를 그대로 복사해 입력하면 된다. 다른 내용으로 바꿔서 해 보고 싶다면 자유롭게 바꿔도 좋다.

POST는 브라우저 주소창으로 못 보내니 curl로 확인한다. (터미널을 하나 더 열어서)

[잠깐] 초과 주문을 시험하려면 잔여가 적은 등급이 먼저 있어야 한다. 이제 api는 진짜 DB를 본다. 그런데 초기 데이터에는 잔여가 4장보다 적은 등급이 하나도 없어서, 한 번에 최대 4장인 주문으로는 "잔여 부족"을 만들 수 없다. 그래서 시험용으로 하나 만든다. Supabase 대시보드 Table Editor → ticket_types 에서 TT017 (스탠드업 코미디 나이트 · 일반, 총 120장) 행의 sold_quantity117로 고친다. 잔여가 3장이 된다. 코드를 건드린 것이 아니라 데이터를 바꾼 것이고, api는 다음 요청부터 이 값을 읽는다 — 데이터가 코드 안이 아니라 DB에 있다는 것을 다시 확인하는 셈이다.

터미널
# 정상 주문 — 201과 주문번호·QR이 와야 한다
$ curl -X POST http://localhost:8787/orders \
  -H "Content-Type: application/json" \
  -d '{"event_id":"EV009","ticket_type_id":"TT020","quantity":2,"buyer_email":"me@test.com"}'

# 초과 주문 — 409가 와야 한다 (잔여 3장인 등급에 4장을 일부러)
$ curl -X POST http://localhost:8787/orders \
  -H "Content-Type: application/json" \
  -d '{"event_id":"EV007","ticket_type_id":"TT017","quantity":4,"buyer_email":"me@test.com"}'

# 금액 조작 시도 — 요청에 amount를 실어 보내도 서버 계산이 이겨야 한다
$ curl -X POST http://localhost:8787/orders \
  -H "Content-Type: application/json" \
  -d '{"event_id":"EV009","ticket_type_id":"TT020","quantity":1,"buyer_email":"me@test.com","amount":900}'

세 번째가 이 장의 백미다. 응답의 금액이 900이 아니라 서버가 계산한 99,000 인지 확인하라. 아니라면 — 방금 심각한 보안 결함을 발견한 것이다. 백엔드 역할에게 지적하고 고치게 하라.

세 curl을 "무엇을 시험하는가"로 다시 정리하면 이렇다. 하나하나가 서로 다른 방어선을 찌르는 것이다.

시험 보내는 것 기대 응답 무엇을 확인하나
정상 주문 잔여 안쪽 수량 201 + 주문번호·QR 성공 경로가 도는가, 주문이 DB에 남는가
초과 주문 잔여보다 큰 수량 409 재고 방어선(서버가 재고를 판정하는가)
금액 조작 amount:900 을 몰래 실음 201 + 서버 계산 99,000 금액 방어선(서버가 요청 금액을 무시하는가)

정상 주문은 201을 받은 것으로 끝내지 말고, DB에 남았는지까지 확인한다.

세 번째가 중요한 이유 — 주문을 코드 안 변수에만 담아 두는 서버도 201은 똑같이 돌려준다. 그런 서버는 재시작하는 순간 모든 주문을 잊는다. 고객은 돈을 냈는데 티켓이 사라지는 것이다. 응답만 봐서는 이 둘이 구분되지 않아서, 껐다 켜 보는 것이다.

초과 주문의 409에는 방어가 한 겹 더 있다. 다층 방어다 — 서버 코드가 잔여를 확인해 409로 막고, 설령 그 코드가 뚫려도 DBA가 새겨 둔 CHECK 제약(판매수량 <= 총수량)이 마지막에 저장을 거부한다. 409는 고객에게 이유를 알려 주는 첫 번째 문이고, CHECK는 어떤 코드 실수도 넘지 못하는 마지막 문이다.

금액 조작 시험을 한 번 더 강조하는 이유가 있다. amount:900은 명세에 없는 필드다. 악의적 사용자가 "혹시 서버가 이걸 그대로 믿지 않을까" 하고 몰래 끼워 넣은 것이다. 올바른 서버라면 이 필드를 아예 쳐다보지도 않고, 자기가 (등급 단가 × 수량)으로 계산한 99,000원을 응답에 담고, orders의 새 행에도 99,000원을 적는다. 만약 응답에 900이 찍혀 나온다면, 서버가 클라이언트를 믿어 버린 것 — 즉 '서버는 클라이언트를 믿지 않는다'는 계율을 어긴 것이고, 이는 실제 서비스라면 매출이 새는 심각한 보안 결함이다. curl 한 줄로 이 결함의 유무를 직접 확인하는 것이 이 장의 하이라이트다.

한 가지 실전 팁 — curl은 기본적으로 응답 본문(JSON)만 보여주고 상태 코드(409 등)는 숨긴다. 상태 코드까지 눈으로 보고 싶으면 "응답의 HTTP 상태 코드도 같이 보이게 curl 명령을 알려줘" 라고 AI에게 시키면 된다(-i-w 옵션을 붙여 준다). 여기서도 옵션을 외우지 않고 AI에게 맡기는 방식은 그대로다.

[짚고 가기] 금액 조작 시험은 이 장에서 가장 '와닿는' 순간이다. 누군가 개발자도구로 결제 금액을 900원으로 바꿔 티켓을 900원에 사 간다면 어떻게 될까 — 이 상황을 curl로 직접 흉내 내 보고, 서버가 99,000원으로 되받아치는 것을 확인하면 그 방어가 실감 난다. 반대로 만약 900이 찍힌다면, 그 자리에서 방금 취약점을 잡은 것이니 백엔드 역할에게 고치게 하면 된다 — 실습이 곧 보안 리뷰가 되는 셈이다. 초과 주문 409도 마찬가지다. 앞서 나온 '골목 라이브 초과 판매 사고'에 그때 이 409가 있었다면 막혔을 것이라고 이으면, 세계관과 개념이 함께 붙는다.

따라하기 4 — 입장 확인 API 구현

프롬프트
/backend 명세의 POST /checkins 를 구현해줘. QR 코드를 받아 해당 주문을
찾고, 입장 처리 결과를 돌려준다. 구현 후 명세 대조 보고까지.

위 프롬프트를 그대로 복사해 입력하면 된다. 다른 내용으로 바꿔서 해 보고 싶다면 자유롭게 바꿔도 좋다.

curl로 확인한다 — 방금 주문에서 받은 QR 코드로:

터미널
$ curl -X POST http://localhost:8787/checkins \
  -H "Content-Type: application/json" \
  -d '{"qr_code":"OT-XXXX-XXXX"}'

여기서 OT-XXXX-XXXX 자리에는 방금 정상 주문 응답에서 받은 진짜 QR 코드 문자열을 넣는다. 이 실습은 두 엔드포인트가 데이터를 주고받는 첫 순간이라는 점에서 의미가 있다 — 주문(POST /orders)이 orders에 저장한 QR을, 입장 확인(POST /checkins)이 DB에서 찾아낸다. 두 엔드포인트를 잇는 것은 서버의 기억이 아니라 DB다. 확인이 끝나면 대시보드의 checkins에 입장 기록이 한 행 늘었는지도 본다.

여유가 있으면 '이미 입장한 티켓을 또 확인하면?'도 시험해 볼 만하다. 같은 QR로 checkins를 두 번 보내 보는 것이다. 명세가 "재입장은 거절" 또는 "이미 입장함 표시"를 요구한다면 두 번째 응답이 그렇게 나와야 한다. 이것도 조회 API에서 본 404처럼 실패·중복 경로가 명세대로 도는가를 보는 확인이다. 명세에 그 규칙이 없다면? 그때가 바로 "명세에 없으니 코드로 때우지 말고 질문한다"의 실전이다 — 사람에게 "재입장은 어떻게 처리할지 명세에 없는데 정해 달라"고 되물어야 한다.

따라하기 5 — 명세 대조 최종 검증

아래 지시(위 '프롬프트 작성법'에서 본 확인 지시 — 전체 엔드포인트 명세 대조)를 실행한다.

프롬프트
방금 구현한 세 엔드포인트를 명세와 대조해줘.
경로·메서드·요청 필드·응답 필드·오류 코드가 전부 일치하는지 표로.
불일치가 있으면 코드가 아니라 나에게 먼저 보고해 — 명세를 고칠지
코드를 고칠지는 내가 정한다.

위 프롬프트를 그대로 복사해 입력하면 된다. 다른 내용으로 바꿔서 해 보고 싶다면 자유롭게 바꿔도 좋다.

불일치가 보고되면 명세를 고칠지 코드를 고칠지 내가 정해서 지시한다.

이 마지막 단계가 백엔드 역할의 프로세스를 완성하는 지점이다. 구현이 끝났다고 끝이 아니라, 세 엔드포인트 전부를 명세와 한 표에 놓고 대조한 뒤에야 "됐다"고 말한다. 표에서 초록불(일치)만 나오면 이상적이지만, 실전에서는 한두 칸 불일치가 나오는 것이 오히려 정상이다. 그때 당황하지 말고, 앞서 본 수정 지시처럼 어느 쪽을 기준으로 삼을지 먼저 정하고 그 방향으로 고치게 하면 된다. 이 '결정은 사람, 실행은 AI'의 리듬을 마지막으로 한 번 더 밟는 것이 이 실습의 마무리다.

완료 기준 체크리스트

이 체크리스트의 각 줄은 앞선 따라하기와 하나씩 짝을 이룬다. 특히 굵게 표시된 '금액 조작' 항목은 눈으로 반드시 확인해야 하는 줄이다 — 나머지가 다 통과해도 이 한 줄이 안 되면, 그 서버는 "명세대로 도는 것처럼 보이지만 실은 위험한" 서버다. 체크리스트를 형식적으로 넘기지 말고, 특히 이 줄에서는 curl 응답의 금액 숫자를 직접 눈으로 확인하고 넘어가자.


11.6 정리

오명운 · macro@prag-ai.com