이 장이 끝나면: 온티켓의 실제 화면 — 이벤트 목록, 상세, 예매, 완료(QR) — 이 web/ 안에 구현되어 API 서버와 통신한다. 도중에 CORS라는 유명한 벽에 한 번 부딪히고, 그 벽이 왜 있는지 이해한 채로 통과한다. 그리고 "무엇을 화면이 결정하고 무엇을 서버가 결정하는가"라는 경계 감각을 얻는다.
프론트엔드는 API 명세와 돌아가는 백엔드만 있으면 무엇으로든 만들 수 있다. 웹이든
휴대폰 앱이든 키오스크든, 명세대로 요청을 보내고 응답을 받아 그리면 된다. 그리고 지금
그 둘이 다 있다 — docs/API-Spec.md, 그리고 Supabase DB에 연결되어 명세대로 답하는 api
서버.
순서를 뒤집어 백엔드 없이 화면부터 만들면 어떻게 되나. 화면은 보여줄 데이터가 필요하니 가짜 데이터를 지어내 그리게 된다. 그러다 진짜 API가 붙는 날, 필드 이름이 다르고 없는 값이 있고 실패 응답은 생각도 안 해 둔 것이 드러나, 가짜로 그린 화면을 다시 맞춰야 한다. 진짜 응답이 있는 상태에서 시작하면 그 재작업이 없다.
프론트엔드도 백엔드와 똑같은 명세를 기준 자료로 삼는다. 둘이 같은 계약을 보기 때문에,
따로 만들어도 서로 어긋나지 않는다. 데이터의 기준 자료가 명세라면, 모양의 기준 자료는
디자인팀이 남긴 셋이다 — design/tokens.css(색·크기), design/금지목록.md(하지 말 것),
docs/화면기획서.md(화면별 요소와 상태). 프론트엔드는 이 넷을 받아 동작하는 화면으로 옮긴다.
프론트엔드 코드에는 다른 층과 결정적으로 다른 점이 하나 있다. 사용자의 컴퓨터에서 실행된다는 것이다. 우리가 배포한 화면 코드는 고객의 브라우저로 내려가서 돈다. 그리고 브라우저의 주인은 고객이다 — 개발자 도구를 열면 누구나 화면 코드를 읽고, 바꾸고, 요청을 조작할 수 있다.
이 말을 조금 더 구체적으로 그려 보자. 백엔드 코드는 우리 서버 안에서만 돈다. 고객은 그 코드를 볼 수도, 만질 수도 없다 — 우리 집 안방과 같다. 그런데 화면 코드는 다르다. 고객이 온티켓에 접속하는 순간, 우리가 만든 HTML·CSS·JavaScript가 통째로 고객의 브라우저로 복사되어 내려간다. 그 순간부터 그 코드는 고객의 컴퓨터에 있다. 고객은 F12(개발자 도구)를 눌러 우리 코드를 한 줄씩 읽을 수 있고, 값을 바꿔 볼 수 있고, "이 버튼을 누르면 어디로 요청이 가는지"까지 전부 들여다볼 수 있다. 심지어 우리 화면을 거치지 않고, 우리 API로 자기가 직접 요청을 쏴 볼 수도 있다. 이것은 우리가 허술해서가 아니라 웹이라는 기술의 본질이다. 브라우저에 내려간 코드는 더 이상 우리 것이 아니다.
그래서 프론트엔드에서는 이렇게 기억하면 좋다 — 화면 코드에 적힌 것은 전부 공개된 것이다. 비밀번호, 관리자 열쇠, "이 값이면 통과" 같은 판정 기준을 화면 코드에 적어 두면, 그것은 고객에게 소리 내어 읽어 준 것과 같다.
이 사실에서 프론트엔드의 제1원칙이 나온다. 화면은 아무것도 판정하지 않는다. 잔여석이 있는지, 금액이 얼마인지를 화면 코드가 '결정'하게 만들면, 그 결정은 사용자 손안에 있는 셈이다. 우리는 이미 서버에서 이를 확인했다 — 금액 조작 요청을 서버가 무시하는 것을. 화면은 그 판정 결과를 보여줄 뿐이다.
한 문장으로 못 박아 두자. 화면은 물어보고, 서버가 판정한다. 화면은 "이 사람이 리버사이드 재즈 1일권 2장을 5만 원에 사려 합니다, 괜찮나요?"라고 서버에 물을 뿐이다. 잔여가 충분한지, 금액이 정말 5만 원이 맞는지는 서버가 자기 손안의 데이터로 다시 계산해 판정한다. 화면이 보낸 "5만 원"이라는 숫자는 서버 입장에서 참고조차 되지 않는다 — 고객이 조작했을 수 있는 값이기 때문이다. 이 원칙은 온티켓뿐 아니라 돈이 오가는 모든 서비스의 뼈대다.
[짚고 가기] '브라우저는 사용자의 영토'는 이 장 전체에서 가장 중요한 개념이다. 직접 해 보면 확실히 와닿는다. 아무 웹사이트나 열어 F12를 누르고, Elements(요소) 탭에서 글자 하나를 즉석에서 바꿔 보자. 방금 그 사이트의 제목을 바꿨지만, 이건 내 브라우저 안에서만 바뀐 것이고 서버는 아무것도 모른다. 이 한 번의 실험이 '브라우저는 사용자의 영토'를 몸으로 이해시킨다. 그렇다면 화면에 적힌 가격을 서버가 믿어 버리면 어떻게 될까 — 이 물음이 자연스럽게 다음으로 이어진다.
있다. 수량 입력칸에서 5를 못 고르게 막고, 이메일 형식이 틀리면 빨간 줄을 긋는다. 이것은 판정이 아니라 친절이다 — 고객이 서버까지 갔다가 퇴짜맞기 전에 미리 알려주는 것. 같은 검사가 두 곳에 있는 이유가 이제 분명해진다.
두 검사는 목적이 완전히 다르다. 화면의 검사는 고객을 돕기 위한 것이고, 서버의 검사는 회사를 지키기 위한 것이다. 예를 들어 이메일 형식 검사를 생각해 보자. 화면이 "@가 없네요"라고 즉시 빨간 줄을 그어 주면, 고객은 예매하기 버튼을 누르고 한참 기다렸다가 "잘못된 이메일입니다"를 받는 답답함을 겪지 않는다. 이것이 친절이다. 하지만 고객이 개발자 도구로 그 빨간 줄 검사를 무력화하고 엉터리 이메일을 그대로 보낼 수도 있으므로, 서버도 똑같이 이메일을 검사한다. 이것이 방어다. 친절은 건너뛰어도 고객만 불편하지만, 방어를 건너뛰면 회사가 다친다. 그래서 방어는 절대 생략하지 않는다.
| 화면의 검사 | 서버의 검사 | |
|---|---|---|
| 목적 | 사용자 편의 (빠른 피드백) | 방어 (최종 판정) |
| 우회 가능? | 가능 (사용자의 영토니까) | 불가능 |
| 없으면? | 불편해진다 | 사고가 난다 |
여기서 초심자가 자주 하는 오해 하나를 풀어 두자. "그럼 화면 검사는 어차피 우회되니까 안 만들어도 되는 것 아닌가?"라는 물음이다. 아니다. 화면 검사가 없으면 정직한 고객 대부분이 불편해진다. 초과 수량을 실수로 입력한 고객이, 버튼을 누르고 서버까지 다녀와서야 "안 됩니다"를 받으면 짜증이 난다. 화면 검사는 그 실수를 즉시 잡아 준다. 반대로 서버 검사가 없으면 악의를 가진 소수가 회사를 무너뜨린다. 둘 다 필요하되, 하나만 남긴다면 반드시 서버다. 화면 검사는 있으면 좋은 편의, 서버 검사는 없으면 안 되는 방어다.
그래서 프론트엔드 개발자의 일은 화면을 만드는 것 절반, 경계를 정하는 것 절반이다. 이 값은 누가 결정하는가? 온티켓으로 예를 들면:
| 결정 | 담당 | 이유 |
|---|---|---|
| 잔여석이 충분한가 | 서버 | 돈과 재고의 판정 |
| 최종 결제 금액 | 서버 | 조작되면 사고 |
| 매진 배지를 무슨 색으로 보여줄까 | 화면 | 표현의 문제 |
| 목록을 날짜순으로 보여줄까 | 화면(기본), 서버(정렬 옵션 제공) | 협의 지점 |
| 로딩 중에 무엇을 보여줄까 | 화면 | 전적으로 화면의 일 |
이 표를 읽는 법을 한 줄로 정리하면 이렇다. 값 자체를 정하는 일은 서버, 그 값을 어떻게 보여줄지 정하는 일은 화면. 잔여석 수(값)는 서버가 정하지만, 그 수가 3 이하일 때 "마감 임박"이라는 주황색 배지를 붙일지(표현)는 화면이 정한다. 금액(값)은 서버가 계산하지만, 그 금액을 세 자리마다 콤마를 찍어 "50,000원"으로 보여줄지(표현)는 화면이 정한다. 표현을 아무리 바꿔도 회사가 손해 볼 일은 없다 — 그래서 표현은 화면에 맡겨도 안전하다.
이 표를 채우는 감각은 실무 어디서나 쓰인다 — 쇼핑몰의 할인율 계산(서버), 할인 배지 애니메이션(화면), 게임의 점수 계산(서버), 점수 연출(화면). 헷갈리면 한 가지만 묻자. "사용자가 이 값을 조작하면 누가 손해 보나?" 회사가 손해 보면 서버의 일이다.
경계 감각이 실무에서 어떻게 쓰이는지, 도메인 몇 개로 더 넓혀 보자.
공통점이 보이는가. 돈·재고·권한처럼 조작되면 회사가 다치는 값은 예외 없이 서버, 색·배치·애니메이션처럼 조작돼도 고객 눈에만 다르게 보이는 것은 화면이다.
[짚고 가기] "그럼 서버가 다 하면 화면은 왜 검사하지?"라는 의문이 들 수 있다. 은행 앱에 비유하면 분명해진다. 이체 화면에서 '금액을 입력하세요'라는 빨간 안내가 뜨는 건 친절이다. 그런데 진짜로 잔액이 되는지는 은행 서버가 판단한다. 화면 안내를 지워도 은행은 안 망하지만, 서버 검사를 지우면 없는 돈도 이체된다. 이 대비를 붙잡아 두면 "친절과 방어"의 차이가 오래 남는다.
이 장에서 처음 만나는 용어들이다. 화면과 서버가 어떻게 대화하는지, 그 사이를 막아서는 벽은 무엇인지에 관한 말들이다. 완벽히 외울 필요는 없다 — 실습에서 한 번씩 부딪히며 손에 붙는다.
화면 코드에서 API를 호출하는 표준 함수가 fetch다. "이 주소로 요청을 보내고 응답을 받아 와라." 우리 화면의 모든 데이터가 이 통로로 들어온다.
const res = await fetch(`${API_URL}/events`) // API 서버에 요청
const events = await res.json() // JSON 응답을 꺼낸다
이름 그대로 풀면 fetch는 '가져오다·물어오다'라는 뜻이다. 강아지에게 공을 던지면 물어 오듯, 화면이 API 주소로 "이 데이터 좀 가져와"라고 던지면 응답을 물어 온다. 이 코드에서 눈여겨볼 것이 두 가지 있다.
await: fetch는 인터넷을 건너 서버에 다녀오는 일이라 시간이 걸린다.
응답이 도착할 때까지 "기다렸다가" 다음 줄로 넘어가라는 표시가 await다.
이 기다림이 있기 때문에, 뒤에서 볼 로딩 상태가 필요해진다 — 고객을 빈
화면 앞에서 마냥 기다리게 둘 수 없으니 "불러오는 중"을 보여주는 것이다..json(): 서버가 돌려준 응답은 그대로는 못 쓰는 원자재 상태다. 그것을
화면 코드가 다룰 수 있는 데이터(목록·객체)로 풀어 주는 것이 .json()이다.
JSON은 서버와 화면이 데이터를 주고받을 때 쓰는 공용 표기법이라고 알아 두면 된다.fetch가 반드시 성공하는 것은 아니다. 서버가 꺼져 있거나, 인터넷이 끊겼거나, 뒤에 나올 CORS 벽에 막히면 fetch는 실패한다. 그래서 잘 짠 화면 코드는 fetch를 항상 "성공했을 때"와 "실패했을 때"로 나눠 처리한다. 이것이 3종 세트로 이어진다.
현대 프론트엔드(React/Next.js)는 화면을 컴포넌트라는 부품 단위로 조립한다. 이벤트 카드 하나가 컴포넌트고, 그것을 12번 반복하면 목록이 된다. 부품화의 이점은 재사용이다 — 같은 카드를 홈에서도, 검색 결과에서도 쓴다.
컴포넌트(component)는 원래 '구성 요소·부품'이라는 뜻이다. 레고 블록을 떠올리면 쉽다. 작은 블록(버튼·배지·카드) 여러 개를 조립해 큰 블록(이벤트 카드)을 만들고, 그 큰 블록을 또 여러 개 늘어놓아 화면 한 페이지(목록)를 만든다. 부품화가 주는 이점은 재사용만이 아니다.
화면은 데이터가 오기 전·후·실패를 모두 표현해야 한다. 이를 상태라고 하며, 모든 데이터 화면에는 3종 세트가 필요하다.
'상태(state)'는 화면이 지금 어떤 처지에 놓여 있는지를 가리키는 말이다. 앞의 fetch를 떠올려 보자. 화면이 서버에 데이터를 요청하면, 화면은 시간의 흐름에 따라 서로 다른 처지를 지난다. 처음엔 기다리는 중(로딩), 그다음 응답이 도착하면 성공 또는 실패(에러)로 갈린다. 성공했더라도 목록이 비어 있으면 또 다른 처지(빈 상태)다. 이 네 갈래를 각각 다른 화면으로 그려 주는 것이 데이터 화면의 기본기다. (성공은 당연히 그리므로, 놓치기 쉬운 나머지 셋을 묶어 '3종 세트'라 부른다.)
세 가지가 왜 각각 필요한지 온티켓으로 그려 보자.
AI에게 화면을 시키면 성공 케이스만 만들고 끝내는 경우가 많다 — 3종 세트를 지시에 명시하는 것이 요령이다. (참고로 이 3종 세트 개념은 어디에나 있다. 엑셀 대시보드의 "데이터 없음" 표시, 키오스크의 "준비 중" 화면도 같은 것이다.)
[짚고 가기] 3종 세트를 제대로 이해하려면 실제로 실패를 만들어 보는 것이 최고다. 목록이 잘 뜨는 상태에서 api 서버를 꺼 버리고 새로고침하면 화면이 어떻게 되는지 확인할 수 있다. 3종 세트를 안 만든 화면은 흰 화면이나 콘솔 에러로 끝나지만, 만든 화면은 "목록을 불러오지 못했습니다"를 얌전히 보여 준다. 성공만 만들면 데모는 되지만 서비스는 안 된다 — 이 차이가 여기서 드러난다.
CORS(교차 출처 리소스 공유) 는 브라우저의 보안 장치다. 원리만 이해하자.
이 절은 초심자가 이 장에서 가장 자주 막히는 곳이므로, 서두르지 말고 차근차근 쌓아 올리자. CORS는 Cross-Origin Resource Sharing의 약자로, 그대로 옮기면 '교차 출처 자원 공유'다. 이름을 뜯으면 이미 절반은 설명된다 — '출처가 서로 엇갈리는(cross-origin)' 상황에서 '자원(데이터)을 공유(sharing)'해도 되는지를 다루는 규칙이라는 뜻이다.
먼저 출처(origin) 부터 정확히 잡자. 출처는 웹 주소에서 세 부분이 모여 만드는 '신분증'이다.
http 인가 https 인가localhost, onticket.com 같은 주소 이름:3000, :8787 처럼 주소 뒤에 붙는 번호이 셋 중 하나라도 다르면 브라우저는 "다른 출처"로 본다. 사람 눈에는
localhost:3000과 localhost:8787이 둘 다 "내 컴퓨터"라 같아 보이지만,
브라우저에게 이 둘은 포트가 다른 남남이다. 여기서 초심자가 가장 많이
놀란다 — "같은 localhost인데 왜 막혀요?" 답은 "포트가 다르니까"다.
'포트(port)'가 뭔지도 짚어 두자. 포트는 한 컴퓨터 안에서 프로그램들이 각자 쓰는 문 번호다. 한 건물(내 컴퓨터, localhost)에 여러 사무실(프로그램)이 있고, 각 사무실마다 다른 호수(포트 번호)가 붙은 셈이다. 온티켓 실습에서는 화면(web/)이 3000호, API(api/)가 8787호에 입주해 있다. 같은 건물이지만 호수가 다르니, 브라우저는 이 둘을 별개의 출처로 취급한다.
이제 본론이다. 브라우저는 localhost:3000(우리 화면)에서 실행되는 코드가
localhost:8787(우리 API)로 요청을 보내면 긴장한다 — 포트가 다르면
다른 출처(origin) 로 취급하기 때문이다. "3000의 코드가 왜 8787의 데이터를
가져가려 하지? 8787이 허락했나?" 허락(응답 헤더)이 없으면 브라우저가 응답을
차단한다.
이 대목에서 오해를 하나 풀어야 한다. CORS에 막혔을 때 실제로 일어나는 일의 순서는 이렇다. 요청은 8787까지 가서 응답도 받아 온다. 그런데 브라우저가 그 응답을 화면에 넘겨주기 직전에 가로막는다. 즉 CORS는 '요청을 못 나가게' 막는 것이 아니라, '남의 출처가 준 응답을 우리 화면 코드가 읽지 못하게' 막는 장치다. 그래서 CORS 에러는 서버가 뻗어서 나는 에러가 아니다 — 서버는 멀쩡히 답했는데, 브라우저가 그 답을 고객 화면에 전달하기를 거부한 것이다. 이 차이를 알면, 뒤에서 "고칠 위치가 왜 api인지"가 자연스럽게 이해된다.
왜 이런 장치가 있나 — 악성 사이트가 방문자의 브라우저를 이용해, 방문자가 로그인해 둔 은행 API를 몰래 호출하는 것을 막기 위해서다. 벽 자체는 선하다. 우리처럼 정당한 사이는 서버 쪽에서 "3000은 허락한다"고 선언하면 된다 (허락은 언제나 서버의 권한이다 — 화면이 스스로 허락할 수 있다면 보안이 아니다).
이 시나리오를 조금 더 실감 나게 그려 보자. 고객이 아침에 은행 사이트에 로그인해 두었다. 그 상태로 오후에 낯선 사이트(evil.com)를 무심코 열었다. CORS가 없다면, evil.com의 화면 코드가 고객의 브라우저를 시켜 은행 API로 "이 사람 계좌에서 100만 원 이체해줘"를 몰래 쏠 수 있다. 고객은 이미 로그인 상태라 요청에 인증이 붙어 나간다. CORS는 바로 이걸 막는다 — 은행 서버는 "나는 은행 사이트(bank.com)만 허락했다"고 선언해 두었으므로, evil.com에서 온 요청의 응답을 브라우저가 evil.com 화면에 넘겨주지 않는다. 출처를 신분증처럼 확인해, 허락받지 않은 출처에는 남의 데이터를 넘기지 않는 것 — 이것이 CORS의 존재 이유다. 그러니 이 벽은 우리를 괴롭히려는 것이 아니라, 우리 고객을 지키는 편이다.
그리고 여기서 왜 허락이 서버의 권한인지가 핵심이다. 만약 화면 코드가 "나는 나를 허락한다"고 스스로 선언할 수 있다면, evil.com도 똑같이 "나도 나를 허락한다"고 쓰면 그만이다. 그러면 벽은 아무 의미가 없다. 그래서 허락은 반드시 데이터를 가진 쪽(서버) 이 한다. 온티켓에서 데이터를 가진 쪽은 api(8787)다. 따라서 "3000을 허락한다"는 선언은 web(3000)이 아니라 api(8787)에서 해야 한다. 화면은 아무리 애써도 자기 스스로 이 벽을 뚫을 수 없다. 이것이 CORS 에러를 만났을 때 "고칠 위치는 api"라는 결론의 뿌리다.
허락을 선언할 때 지켜야 할 원칙이 하나 있다. 허용 출처는 좁게, 딱 필요한
것만. 온티켓 api는 "http://localhost:3000 만 허락한다"고 콕 집어 선언한다.
"전부 허락한다(*, 와일드카드)"로 열어 버리면 편하긴 하지만, 그것은 문을
여는 김에 담장을 통째로 허무는 격이라 금지한다. 정당한 우리 화면 하나만
들여보내면 충분한데, 세상 모든 출처에 문을 열 이유가 없다.
실습에서 이 벽에 일부러 부딪힌다. 실무에서 프론트-서버 분리 개발을 하면 누구나 첫날 만나는 에러라서, 미리 정체를 알고 만나는 것과 모르고 만나는 것의 차이가 크다.
[짚고 가기] CORS는 "내 코드가 틀렸나?" 하고 자책하며 web/ 안을 헤매게 만드는 대표적 함정이다. 처음부터 이렇게 기억해 두면 좋다 — 이 에러는 화면 코드의 문법 실수가 아니라, 서버가 아직 '너를 허락한다'고 말하지 않은 것뿐이다. 그리고 "같은 localhost인데 왜 막히지?"라는 의문은 포트(문 번호) 비유로 풀린다.
*로 뚫고 싶은 유혹이 들기 쉬운데, 왜 안 되는지(evil.com도 똑같이 뚫린다)를 이해하면 보안 감각이 함께 자란다.
화면 코드가 부를 API 주소는 지금은 localhost:8787이지만 배포하면 실제
주소로 바뀐다. 주소를 코드 여기저기에 박아 두면 배포 때 전부 찾아 바꿔야 한다.
그래서 환경변수 — 실행 환경마다 다르게 주입되는 값 — 로 뺀다.
web/의 .env.local 파일에 NEXT_PUBLIC_API_URL=http://localhost:8787 처럼
적어 두고 코드는 그 이름만 참조한다. 배포 환경에서는 같은 이름에 실제 주소를
넣으면 코드는 한 글자도 안 바뀐다.
'환경변수(environment variable)'라는 말을 풀어 보자. '환경'은 코드가 실행되는 곳 — 내 노트북일 수도, 배포된 서버일 수도 있다. '변수'는 바뀌는 값이다. 즉 실행되는 환경에 따라 달라지는 값을 코드 바깥에 빼 둔 것이 환경변수다. 비유하자면, 편지 봉투에 받는 사람 주소를 직접 인쇄해 버리면 주소가 바뀔 때마다 봉투를 새로 찍어야 하지만, 주소를 스티커로 붙이게 해 두면 스티커만 갈아 붙이면 된다. 코드가 봉투, 환경변수가 스티커다.
두 가지 세부를 초심자 눈높이로 짚어 두자.
.env.local 파일: web/ 폴더에 두는, 환경변수를 적는 파일이다. 파일 이름이
점(.)으로 시작해 평소엔 숨겨져 있다. 여기에 NEXT_PUBLIC_API_URL=...처럼
이름=값 형식으로 한 줄씩 적는다. 중요한 점 하나 — 이 파일은 보통 git에
올리지 않는다. 사람마다·환경마다 값이 다르고, 실제 서비스 키 같은 민감한
값이 섞이기 때문이다. (그래서 프로젝트에는 값을 비워 둔 견본 파일을 대신
올려 두곤 한다.)NEXT_PUBLIC_ 접두사: Next.js에는 규칙이 하나 있다. 환경변수 이름 앞에
NEXT_PUBLIC_을 붙이면 그 값은 브라우저(화면)에서도 읽을 수 있게 공개된다.
안 붙이면 서버 쪽에서만 읽힌다. API 주소는 화면의 fetch가 써야 하므로
NEXT_PUBLIC_을 붙인다. 뒤집어 말하면, NEXT_PUBLIC_이 붙은 값은 고객
브라우저로 내려가 누구나 볼 수 있다는 뜻이다(앞서 본 '브라우저는 사용자의
영토'가 여기서도 통한다). 그러니 비밀번호나 관리자 열쇠 같은 비밀은 절대
NEXT_PUBLIC_으로 내보내면 안 된다. API 주소는 어차피 공개돼도 되는 값이라
괜찮다.| 칸 | 내용 |
|---|---|
| 판단 기준 | 데이터의 기준은 API 명세, 화면의 기준은 디자인 토큰·금지목록과 화면기획서(화면별 요소와 상태). 화면은 표시하고, 판정은 서버에 묻는다 |
| 금지 목록 | 금액·재고 판정 로직 구현 금지 / api/ 코드 수정 금지 / 토큰에 없는 색·크기 발명 금지 / 성공 케이스만 만들고 끝내기 금지(3종 세트 필수) |
| 프로세스 | 명세·화면기획서·토큰 확인 → 계획 → 구현 → 세 가지 폭(1440·768·375) 자가 확인 → 보고 |
| 도구 권한 | web/ 안에서만 작업 |
이 네 칸이 왜 이렇게 짜였는지 한 줄씩 뜯어 보면, 앞 절의 개념이 그대로 규칙으로 굳은 것임을 알 수 있다.
docs/화면기획서.md가 판단한다. 어느 쪽도 프론트엔드가 즉흥으로 정하지 않는다 — 각자 기준이 되는
자료(single source of truth)가 따로 있다.디자이너 스킬과의 관계를 정리해 두자. 디자이너는 모양의 결정(시안·토큰)을 담당했고, 프론트엔드는 그 결정을 동작하는 화면으로 구현한다. 실무에서도 이 둘은 다른 직군이다 — 우리 팀에서는 산출물(토큰·화면기획서·시안)이 두 역할을 잇는다.
프론트엔드 지시의 핵심은 두 가지를 문장에 반드시 넣는 것이다. 어디서 데이터를 가져오는가(fetch 대상과 API 주소), 그리고 이 화면이 판정하면 안 되는 것은 무엇인가(경계). 여기에 3종 세트까지 얹으면, AI가 성공 케이스만 만들고 끝낼 여지가 사라진다.
/frontend @docs/API-Spec.md 온티켓 홈(이벤트 목록) 화면을 web/에 구현해줘.
- 데이터는 API GET /events 에서 fetch로 가져온다. API 주소는 환경변수로
- 모양은 design/tokens.css 의 토큰과 design/home.html 시안을 따른다
- 화면에 들어갈 요소와 상태는 @docs/화면기획서.md 의 홈 화면 항목을 따른다
- 로딩·에러·빈 상태 3종을 모두 만든다
- 경계: 이 화면은 표시만 한다. 잔여·금액을 화면에서 계산하는 코드가 있으면 실패다
이 지시가 왜 강한지 한 줄씩 보자. 첫 줄의 @docs/API-Spec.md는 데이터의
법전을 직접 물려준 것이다 — AI가 응답 모양을 지어내지 않고 명세대로 읽게
한다. 둘째 줄은 fetch 대상과 "주소는 환경변수로"를 못 박아, localhost:8787을
코드에 직접 박는 실수를 막는다. 셋째 줄은 모양의 법(토큰·시안)을, 넷째 줄은
화면에 무엇이 있어야 하는지의 법(화면기획서)을 지정하고, 다섯째 줄은 3종 세트를
강제한다. 마지막 줄이 이 지시의 백미다 — "이런 코드가
있으면 실패다"라고 실패 조건을 못 박으면, AI가 편의상 화면에 금액 계산을
슬쩍 넣는 일을 원천 차단한다.
약한 지시를 강한 지시로 바꾸는 감각을, 프론트엔드 상황에 맞춰 표로 정리한다.
| 흔한(약한) 지시 | 고친(강한) 지시 | 무엇을 바꿨나 |
|---|---|---|
| 이벤트 목록 화면 만들어줘 | @docs/API-Spec.md GET /events로 목록을 fetch해 web/에 구현. 로딩·에러·빈 상태 3종 포함, 판정은 서버에 맡기고 표시만 | 데이터 출처·3종 세트·경계를 명시 |
| API 주소 넣어줘 | API 주소는 .env.local의 NEXT_PUBLIC_API_URL로 빼고 코드는 그 이름만 참조 | 주소 하드코딩을 환경변수로 |
| 목록 예쁘게 정렬해줘 | design/tokens.css 토큰과 home.html 시안을 따르고, 토큰에 없는 색·크기는 쓰지 마 | 모양의 법을 토큰으로 고정 |
| 데이터 안 뜨면 처리해줘 | fetch 실패 시 "목록을 불러오지 못했습니다. 다시 시도" 버튼을, 빈 배열이면 "진행 중인 공연이 없습니다"를 표시 | 에러·빈 상태의 문구까지 지정 |
| 매진 표시 넣어줘 | 서버가 준 잔여 값이 0이면 매진 배지를 붙여줘. 잔여 판단은 서버 값을 그대로 읽고 화면에서 계산하지 마 | 표시는 화면, 판정은 서버로 분리 |
CORS에 막히면 브라우저 개발자 도구(F12)의 콘솔에 빨간 에러가 뜬다. 해석하려 애쓰지 말고 원문을 복사해 그대로 붙여넣는 것이 가장 빠르다.
홈 화면에서 목록이 안 뜨고 콘솔에 이 에러가 나:
Access to fetch at 'http://localhost:8787/events' from origin
'http://localhost:3000' has been blocked by CORS policy: ...
원인을 설명해주고, 고칠 위치가 web인지 api인지부터 말해줘.
왜 원문을 그대로 주는 것이 최선인지 알아 두자. 고객이 에러를 자기 말로 요약해 전하면("데이터가 안 떠요") 정보가 깎여 나간다. 반면 에러 원문에는 어느 출처가 (from origin) 어느 주소로(fetch at) 요청했다가 무엇에 막혔는지(blocked by CORS policy)가 전부 들어 있다. AI는 이 원문 한 덩어리에서 정확히 진단한다. 그러니 에러는 번역하지 말고 통째로 넘긴다 — 이건 CORS뿐 아니라 모든 에러에 통하는 습관이다.
마지막 줄이 배움 포인트다 — 답은 api다(허락은 서버의 권한). 고치는 지시는
백엔드 역할에게 간다: /backend api에 CORS 설정을 추가해줘. 허용 출처는
localhost:3000 만. ("전부 허용(*)"으로 뚫는 것은 금지 — 문을 여는 김에
담장을 없애는 격이다.)
이 두 줄짜리 지시에 담긴 판단이 사실 이 장의 핵심이다. "고칠 위치가 web인지
api인지부터 말해줘"라고 먼저 위치를 묻는 습관은, 에러를 만났을 때 무작정
자기 코드부터 헤집는 초심자의 함정을 피하게 한다. CORS는 화면 코드를 아무리
고쳐도 안 풀린다 — 허락은 데이터를 가진 api의 권한이기 때문이다. 그리고
백엔드 지시에서 "허용 출처는 localhost:3000 만"이라고 좁게 못 박은 것에
주목하자. 여기서 *로 열면 편하지만, 그 순간 evil.com 같은 낯선 출처에도
문이 열린다. 딱 우리 화면 하나만 들여보내는 것이 원칙이다.
여러 화면을 시킬 때는 기술 용어보다 사용자 동선으로 말하는 것이 명확하다.
/frontend 예매 흐름을 만들어줘. 동선:
목록에서 카드 클릭 → 상세(등급별 가격·잔여 표시) → 등급 선택, 수량 1~4 선택
→ 이메일 입력 → 예매하기 → POST /orders → 성공하면 완료 화면(주문번호와 QR 표시),
409면 "남은 수량이 부족합니다"를 화면에 표시.
화면별 요소와 상태는 @docs/화면기획서.md 를 따른다.
경계 동일: 잔여·금액 판단은 전부 API 응답을 그대로 쓴다.
동선으로 말하는 것이 왜 유리한지 짚어 두자. "상세 페이지, 예매 페이지, 완료 페이지를 만들어줘"처럼 화면을 낱개로 나열하면, AI는 각 화면을 따로 만들 뿐 그 사이를 어떻게 오가는지를 놓친다. 반대로 "카드 클릭 → 상세 → … → 완료"처럼 고객이 밟는 길로 말하면, 화면과 화면을 잇는 이동·전달까지 함께 구현된다. 그리고 이 지시에는 성공 경로(완료 화면)와 실패 경로(409 → 안내 문구)가 둘 다 들어 있다. 초심자가 자주 빠뜨리는 것이 실패 경로다 — "잘되면 완료 화면"만 말하고, "안 되면?"을 안 적는다. 여기서는 그것까지 못 박았다.
409라는 숫자를 한 번 풀어 두자. 서버가 응답에 붙여 보내는 상태 코드 중 하나로,
"요청은 알아들었는데 지금 상태와 충돌해서 못 해준다"는 뜻이다. 온티켓에서는
"남은 수량보다 많이 사려 한다"가 그 충돌이다. 화면의 일은 이 무뚝뚝한 숫자
409를 고객의 언어인 "남은 수량이 부족합니다"로 번역해 보여주는 것이다.
판정(부족한지 아닌지)은 서버가 하고, 번역(어떻게 알릴지)은 화면이 한다 —
여기서도 경계가 그대로다.
[짚고 가기] 동선 지시는 화살표(→)로 이어 쓰는 것만으로도 결과가 확 달라진다. "화면 세 개 만들어줘"라고 하면 서로 연결 안 된 화면 셋이 나오고, "A 클릭하면 B로, B에서 하면 C로"라고 하면 실제로 이동하는 흐름이 나온다. AI에게 화면이 아니라 '길'을 알려준다 — 이 감각을 기억해 두면 좋다.
.claude/skills/frontend/SKILL.md 로 프론트엔드 개발자 스킬을 만들어줘.
판단 기준·금지·프로세스는 이렇게:
- 데이터의 법은 docs/API-Spec.md, 모양의 법은 design/tokens.css 와
design/금지목록.md, 화면별 요소와 상태의 법은 docs/화면기획서.md
- 화면은 표시만. 금액·재고 판정 코드 금지, api/ 수정 금지
- 모든 데이터 화면에 로딩·에러·빈 상태 3종 필수
- 구현 후 1440·768·375 자가 확인
위 프롬프트를 그대로 복사해 입력하면 된다. 다른 내용으로 바꿔서 해 보고 싶다면 자유롭게 바꿔도 좋다.
이 스킬 문서가 위 역할 정의의 네 칸을 그대로 옮긴 것임을 확인하자. 스킬을 한 번 잘
만들어 두면, 이후 /frontend로 부를 때마다 이 규칙이 자동으로 함께 적용된다 —
매번 "판정은 서버에 맡기고"를 손으로 적을 필요가 없어진다.
아래 지시(위 '프롬프트 작성법'에서 본 홈 화면 지시)를 실행한다.
/frontend @docs/API-Spec.md 온티켓 홈(이벤트 목록) 화면을 web/에 구현해줘.
- 데이터는 API GET /events 에서 fetch로 가져온다. API 주소는 환경변수로
- 모양은 design/tokens.css 의 토큰과 design/home.html 시안을 따른다
- 화면에 들어갈 요소와 상태는 @docs/화면기획서.md 의 홈 화면 항목을 따른다
- 로딩·에러·빈 상태 3종을 모두 만든다
- 경계: 이 화면은 표시만 한다. 잔여·금액을 화면에서 계산하는 코드가 있으면 실패다
위 프롬프트를 그대로 복사해 입력하면 된다. 다른 내용으로 바꿔서 해 보고 싶다면 자유롭게 바꿔도 좋다.
서버 두 개(3000·8787)가 떠 있는 상태에서 localhost:3000을 연다. 두 가지 중
하나가 일어난다.
/backend에게 허용을 지시한다CORS 에러를 만났을 때 실습에서 밟을 순서를 명확히 해 두자.
/backend에게 "허용 출처는 localhost:3000 만"으로
CORS 설정을 지시한다. (* 금지)이 다섯 걸음을 한 번 밟아 보는 것이, 백 번 읽는 것보다 CORS를 오래 기억하게 한다.
목록이 뜨면 데이터의 여정을 되짚자 — Supabase DB → api(8787) → fetch → 화면(3000). DBA가 부어 둔 12행이 백엔드를 거쳐 화면의 카드 12장이 되었다. 이것이 화면 → API → DB가 한 줄로 도는 첫 순간이다. 어제까지 정적 시안이던 것이 지금은 살아 있는 데이터다.
[짚고 가기] '바로 뜨는' 경우를 그냥 넘기지 않는 것이 이 실습의 숨은 핵심이다. AI가 뼈대에서 CORS를 미리 허용해 두면 벽을 만나지 못하고, 그러면 이 장의 가장 중요한 학습이 통째로 빠진다. 이럴 땐 오히려 잘된 기회다 — 이미 뚫려 있는 설정을 열어, 여기 3000만 적혀 있는데 이걸
*로 바꾸면 왜 위험한지를 거꾸로 짚어 보면 된다. 벽에 부딪히든 안 부딪히든, CORS 설정 파일을 한 번은 자기 눈으로 보고 넘어가는 것이 중요하다.
아래 지시(위 '프롬프트 작성법'에서 본 동선 지시)를 실행한다.
/frontend 예매 흐름을 만들어줘. 동선:
목록에서 카드 클릭 → 상세(등급별 가격·잔여 표시) → 등급 선택, 수량 1~4 선택
→ 이메일 입력 → 예매하기 → POST /orders → 성공하면 완료 화면(주문번호와 QR 표시),
409면 "남은 수량이 부족합니다"를 화면에 표시.
화면별 요소와 상태는 @docs/화면기획서.md 를 따른다.
경계 동일: 잔여·금액 판단은 전부 API 응답을 그대로 쓴다.
위 프롬프트를 그대로 복사해 입력하면 된다. 다른 내용으로 바꿔서 해 보고 싶다면 자유롭게 바꿔도 좋다.
실행한 뒤 브라우저에서 직접 예매를 한 바퀴 돈다. 리버사이드 재즈 1일권 2장 → 이메일 입력 → 예매하기 → 완료 화면에 주문번호와 QR이 뜨는가.
그리고 Supabase 대시보드를 연다 — 화면에서 예매한 주문이 orders에 행으로 남는가.
완료 화면의 주문번호와 같은 행이 있고, 입력한 이메일과 서버가 계산한 금액이 적혀 있으면
된다. 백엔드를 만들 때는 curl로 넣던 주문을, 이번에는 고객이 쓸 화면으로 넣은 것이다.
이어서 실패 동선도 확인한다 — 백엔드를 시험할 때 잔여를 3장으로 줄여 둔 등급 (스탠드업 코미디 나이트 · 일반)에 4장을 시도해 "남은 수량이 부족합니다"가 화면에 뜨는지. 서버의 409가 사용자의 언어로 번역되는 지점이다.
이 실패 동선을 꼭 손으로 밟아 보게 하자. 승승장구는 예전에 '골목 라이브'에서 초과 판매 사고를 겪은 회사라, 온티켓에서는 "남은 만큼만 판다"가 제대로 지켜지는지가 중요하다. 화면에서 초과 수량을 넣고 예매하기를 누르면, 화면이 아니라 서버가 잔여를 확인해 409로 거절하고, 화면은 그 409를 "남은 수량이 부족합니다"로 번역해 보여준다. 만약 화면 코드가 자기 멋대로 잔여를 계산해 미리 막았다면, 그것은 경계 위반이다 — 판정은 어디까지나 서버의 몫이다.
화면 코드에 판정 로직이 스며들지 않았는지 확인한다.
web/ 코드 전체에서 금액을 계산하거나 잔여 수량을 판정하는 코드가 있는지
찾아줘. 있다면 위치만 보고하고 고치지는 마 — 처리는 내가 정한다.
위 프롬프트를 그대로 복사해 입력하면 된다. 다른 내용으로 바꿔서 해 보고 싶다면 자유롭게 바꿔도 좋다.
이 지시에서 "위치만 보고하고 고치지는 마"가 왜 중요한지 짚어 두자. 검증과 수정을 한 번에 시키면, AI가 발견 즉시 자기 판단으로 고쳐 버려서 무엇이 왜 잘못됐는지를 사람이 볼 기회가 사라진다. 먼저 위치만 받아 사람이 확인하고, 어떻게 고칠지(제거할지, 서버 값으로 바꿀지)를 사람이 정한 다음 지시하는 것이 경계 감각을 기르는 순서다. '결정은 사람의 일'이라는 원칙을 실습으로 옮긴 것이다.
발견되면 프론트엔드 역할에게 제거를 지시한다("표시는 API 응답 값을 그대로").
무엇이 걸려 나오는지 예를 들어 두면 학생이 감을 잡는다.
수량 × 단가로 총액을 직접 계산해 보여준다 → 서버가
준 금액을 그대로 표시하도록 바꾼다. (화면 계산과 서버 계산이 어긋나면 사고다.)잔여 < 수량이면 버튼 비활성화를 화면이 최종 판정으로 쓴다 →
이건 미묘하다. 버튼을 미리 흐리게 하는 것은 '친절'이라 괜찮지만, 그것만 믿고
서버 검증 없이 통과시키면 위반이다. 최종 판정은 반드시 서버가 다시 해야 한다.375·768·1440 폭으로 화면을 확인하고, 스크린샷을 찍어 디자인 리뷰어에게
리뷰를 맡긴다 — 시안이 아니라 진짜 화면의 첫 리뷰다. Blocker가 나오면
/frontend에게 반영시킨다. (리뷰어→만드는 사람이 수정 사항을 반영하는 흐름은 시안 때와 동일하다.
같은 절차가 대상만 바뀌어 반복된다는 것, 그것이 프로세스가 있다는 뜻이다.)
세 폭을 확인하는 실용적 방법도 일러 두자. 브라우저 개발자 도구(F12)에는 화면 크기를 휴대폰·태블릿으로 바꿔 보는 '반응형 모드'가 있다. 여기서 폭을 375(휴대폰)·768(태블릿)·1440(데스크톱)으로 바꿔 가며, 글자가 넘치거나 카드가 깨지거나 버튼이 화면 밖으로 밀려나지 않는지 본다. 한 폭에서만 예쁜 화면은 아직 절반만 된 것이다.
/frontend 스킬이 등록되어 있다orders에 행으로 남아 있다orders에 행으로 남으면, 화면 → API → DB가 한 줄로 돈 것이다.NEXT_PUBLIC_이 붙은 값은 고객 브라우저에 공개된다는 것도 함께 기억하라.