디자이너가 개발자 노트북 없이 화면을 보게 만들기
기획이 끝나고 디자인이 나오면 구현이 시작돼요. 그리고 디자이너가 그 화면을 처음 보는 시점은 보통 QA 단계예요. 거기서 "이 여백 8px 아니었는데요"가 나오면 이미 다 만들어진 뒤죠.
김주성
프론트엔드 · · 읽는 데 약 12분
기획이 끝나고 디자인이 나오면 구현이 시작돼요. 그리고 디자이너가 그 화면을 처음 보는 시점은 보통 QA 단계예요. 거기서 "이 여백 8px 아니었는데요"가 나오면 이미 다 만들어진 뒤죠.
그런데 왜 하필 그때일까요. 화면이 돌아가려면 API가 붙어야 하고, API 구현은 퍼블리싱보다 늦게 끝나기 때문이에요. 마크업과 스타일이 다 끝나 있어도 내려올 데이터가 없으면 열어서 보여줄 수가 없어요. 그래서 디자인 확인이 자동으로 백엔드 일정 뒤로 밀려요.
앞당기는 방법 자체는 명확해요. 만드는 도중에 보여주면 되니까요. 문제는 보여줄 방법이 없었다는 것이었어요.
도구는 있었는데, 여는 법이 없었어요
저희 팀에는 이미 시나리오 프리뷰 도구가 있었어요. 사내에서 만들어 쓰는 도구인데, MSW(Mock Service Worker)로 브라우저의 네트워크 요청을 가로채는 방식이에요. 실제 화면 위에 작은 패널을 띄워 두고, 그 화면이 부르는 API를 이름 붙인 케이스로 바꿔 끼우면서 화면을 확인해요. 잔액 부족, 연체, 주문 생성 실패 코드처럼 실서버로는 만들기 어려운 상태를 클릭 한 번으로 재현할 수 있죠.
도구의 README에는 목적이 이렇게 적혀 있었어요. "백엔드 스펙이 나오기 전에 PM·PD·디자이너가 케이스별 UI를 직접 눌러보며 1차 디자인 QA를 할 수 있다."
대상 독자가 처음부터 디자이너였던 거예요. 그런데 실행 방법은 개발 서버를 띄우는 명령 하나뿐이었어요. 개발자 노트북 앞에 앉아야 보이는 도구여서, 정작 대상 독자는 못 쓰고 있었죠. 그 사이 시나리오는 55건까지 쌓였고 전부 로컬 전용이었어요.
목표는 하나로 정리됐어요. 링크를 보내면 열리게 하기.
링크가 핵심이에요
패널에서 고른 케이스는 ?scenario=<key> 로 URL에 남아요. 이게 생각보다 중요해요. "잔액 부족 화면 확인해주세요"가 재현 상태를 통째로 담은 주소 하나로 바뀐다는 뜻이거든요. 적립을 절반쯤 채운 상태, 연체 상태, 팝업이 뜨는 순간 — 전부 링크가 돼요.
그래서 호스팅 자체는 큰 일이 아니었어요. 목업 데이터만 쓰니까 백엔드도 DB도 필요 없고, 기존 프로세스 옆에 프로세스를 하나 더 띄우고 리버스 프록시 설정을 한 벌 얹으면 되니까요.
이렇게 띄운 프리뷰용 한 벌을 저희는 슬롯이라고 불러요. 앱마다 하나씩 두고, 가장 마지막에 배포한 브랜치가 그 자리를 차지하는 구조예요.
까다로운 건 그 다음이었어요.
첫 번째 벽 — 링크만으로는 안 되는 것들
MSW는 브라우저 네트워크 계층만 가로채요. 이 문장이 실제로 무엇을 의미하는지는 생각보다 넓어요.
상태가 네트워크 응답에만 있으면 전부 잡혀요. 하지만 로그인 여부는 쿠키에 있고, 웹뷰인지 여부도 쿠키나 요청 헤더에 있어요. 서버 사이드 렌더링이 부르는 fetch는 워커를 아예 타지 않고요. 상태가 클라이언트 스토어에만 있는 것(선택된 쿠폰, 입력한 금액)도 잡을 수 없어요.
시나리오 목록에는 이런 설명이 달려 있었어요. "이 시나리오만으로는 전환되지 않는다 — 브라우저에서 쿠키를 지우고 확인할 것."
개발자에겐 안내문이지만 디자이너에겐 불가능한 요구예요. 링크를 던지는 게 목표인데, 링크를 열어도 아무 일도 생기지 않는 케이스가 섞여 있다는 뜻이니까요.
그래서 프록시에 스위치를 달았어요
디자이너에게 "쿠키를 지우세요"는 못 시켜도 주소는 보낼 수 있어요. 그래서 주소에 ?as= 라는 약속된 쿼리를 두기로 했어요. 리버스 프록시가 그 값을 읽어서 응답에 Set-Cookie 를 붙여 내려보내고, 브라우저가 그 쿠키를 갖게 되면 그때부터 서버가 그 사람을 로그인한 사용자로, 또는 앱 안에서 들어온 사용자로 판정해요.
주소에 붙이는 값프록시가 하는 일?as=member로그인 상태를 흉내 내는 토큰 쿠키를 심어요?as=guest토큰 쿠키를 지워서 비로그인으로 되돌려요?as=webview웹뷰 판정에 쓰이는 쿠키를 심어요
이건 MSW로는 못 하는 일이에요. MSW는 이미 나간 요청의 응답을 바꾸는 도구인데, 로그인 여부는 요청에 실려 가서 서버가 판정하는 값이거든요. 방향이 반대라 손이 닿지 않아요. 반면 프록시는 요청과 응답 사이에 앉아 있으니 여기서만 할 수 있는 일이고요. 덤으로 제품 코드는 한 줄도 안 대도 되고요.
아름다운 계획이었고, 바로 막혔어요.
두 번째 벽 — 로그인을 흉내 냈는데 화면이 통째로 동작하지 않았어요
더미 토큰을 심고 여니 화면마다 재로그인 안내가 떴어요. 시나리오를 켜도 마찬가지였고요. 각 화면에 맞는 시나리오를 물려서 네 가지를 돌렸는데 네 가지 모두 같은 에러 화면이었어요.
직관적으로는 401이 떨어지고 있을 것 같았어요. 아니었죠.
실 API는 HTTP 200을 주면서 응답 본문의 에러 코드로 거절하고 있었어요. 드물지 않은 규약이에요. 다만 통신 계층에서는 정상 응답이라, 네트워크 탭만 보면 아무 이상이 없어 보여요. 그 에러 코드를 공용 fetch 래퍼가 throw하면, 상위 에러 바운더리가 화면 전체를 재로그인 안내로 갈아끼우고요.
더 곤란한 건 빠져나오는 것도 안 됐다는 거예요. 그 에러 화면은 자가복구를 시도하면서 토큰 쿠키를 지우는데, 지우려는 쿠키와 심은 쿠키의 스코프가 달랐어요. 프록시는 호스트 전용 쿠키로 심었고 앱은 상위 도메인 스코프로 지우고 있었거든요. 같은 이름의 쿠키 둘이 공존하면서 서로 닿지 않으니, 보는 사람은 에러 화면에 갇혀요.
여기서 얻은 교훈은 이거예요. "로그인한 상태처럼 보이기"는 토큰 하나로 되지 않아요. 그 토큰으로 실제 서버에 물어보는 지점이 하나만 남아 있어도 거기서 전부 무너지거든요. 그래서 "상태를 흉내 낸다"가 아니라 "로그인 모드에서는 실 API로 나가는 요청이 0"을 계약으로 못 박는 방향으로 갔어요. 앱 전역 인증 엔드포인트에 픽스처를 깔고, 맨 뒤에 캐치올 핸들러를 달아서 덮지 못한 경로는 콘솔에 단서로 남기게 했고요.
세 번째 벽 — 심은 쿠키가 사라져요
웹뷰 스위치는 또 다른 방식으로 샜어요. 분명히 Set-Cookie 를 내려보내는데 브라우저에 남지 않았거든요.
원인은 리버스 프록시 설정의 기본값 하나였어요. 설정한 헤더는 HTML 응답에만 붙는 게 아니라 모든 응답에 붙어요. 자바스크립트 번들, CSS, 이미지 전부요. 그런데 그 요청들엔 ?as= 가 없으니 기본값이 적용되죠.
기본값을 "쿠키 삭제"로 둔 게 바로 그 자충수였어요.
sequenceDiagram
participant B as 브라우저
participant P as 리버스 프록시
B->>P: GET /page?as=webview
P-->>B: 200 + Set-Cookie(isWebview=true)
Note over B: 쿠키 생김 ✅
B->>P: GET /assets/main.js (?as= 없음)
P-->>B: 200 + Set-Cookie(isWebview=삭제)
Note over B: 방금 심은 쿠키가 지워짐 ❌HTML이 심은 쿠키를 뒤따르는 자산 요청 수십 건이 지우고 있었어요. 고치는 방법은 단순했어요. 기본값을 삭제가 아니라 헤더를 아예 붙이지 않는 것으로 바꿨죠. 그러면 쿠키는 ?as= 를 명시할 때만 바뀌어요.
이 부류에는 비슷한 함정이 하나 더 있었어요. 스위치를 태운 요청 자체는 아직 바뀌기 전 쿠키를 들고 서버에 도착해요. 링크를 열었는데 아무 변화가 없고, 한 번 더 이동해야 적용되는 거죠. ?as= 를 뗀 주소로 302를 되돌려서 해결했어요.
네 번째 벽 — 같은 패턴을 복사하면 조용히 망가져요
두 번째 앱에 붙이는 건 쉬울 것 같았어요. 어차피 같은 구조니까 설정을 복사해서 포트만 바꾸면 될 일로 보였고, 그렇게 했죠.
배포하자 새 슬롯의 API가 전부 500이 됐어요. 기존 서버는 멀쩡했고요.
로그에 답이 있었어요. invalid connection header. 첫 번째 앱은 프레임워크 서버가 API 호출을 직접 처리하는데, 두 번째 앱은 자기가 받은 요청 헤더를 그대로 모아 API로 전달하는 프록시 계층을 가지고 있었어요. 거기에 웹소켓용으로 복사해 온 Connection: upgrade 가 실렸고, HTTP 클라이언트가 그걸 금지 헤더로 보고 요청을 전부 거부한 거예요.
기존 서버가 멀쩡했던 이유는 따로 있었어요. 그쪽 설정은 API 요청을 별도 경로로 분리해서 직접 프록시하고 있어서 앱 서버를 거치지 않았거든요. 복사할 때 그 부분을 같이 가져오지 않은 게 프록시 계층을 새로 드러나게 만든 셈이에요.
그래서 도달한 결론은 이거예요. 두 앱의 스위치는 방향이 반대여야 해요. 첫 번째 앱은 프록시가 쿠키를 심으니까 302로 되돌려야 한 번의 방문으로 적용돼요. 두 번째 앱은 요청 헤더를 주입하고 앱이 자기 규칙대로 쿠키를 심는 구조라, 302로 되돌리면 헤더가 사라져서 아무 일도 일어나지 않아요. 이건 배포 전에는 몰랐어요.
마지막 벽 — 안내문은 해결이 아니에요
프록시 스위치까지 붙이고 나니 링크로 로그인 상태를 만들 수 있게 됐어요. 그런데 실제로 주소를 붙여넣어 보면 여전히 깨졌어요. 결제 화면 케이스의 링크를 그냥 열면 클라이언트 에러 화면이 떴거든요. ?as=member 를 먼저 태워야 열렸어요.
즉 "쿠키를 지우고 확인할 것"을 "?as=member 를 먼저 붙일 것"으로 바꿔 쓴 것에 지나지 않았던 거예요. 요구사항의 문장만 바뀌었을 뿐, 보는 사람이 셋업을 해야 한다는 사실은 그대로였죠. 링크 하나로 화면을 보여주는 게 목적인 도구에서 이건 실패예요.
그래서 축을 뒤집었어요. 사람이 링크에 조건을 붙이는 대신, 시나리오가 자기 진입 경로에 필요한 로그인 상태를 선언하고 프리뷰가 부팅할 때 그 상태로 맞춘 뒤 화면을 다시 그리게 했어요. 선언이 없는 시나리오는 건드리지 않고, 실제 로그인 세션이 살아 있으면 덮어쓰지 않고요.
앞서 만든 ?as= 스위치를 없앤 건 아니에요. 그대로 남아 있고, 다만 자리가 바뀌었어요. 기본 경로가 아니라 어떤 상태를 일부러 보고 싶을 때 쓰는 수동 스위치가 된 거죠. 사람이 써야만 돌아가는 장치와, 써도 되는 장치는 같은 코드여도 완전히 다른 물건이에요.
그제서야 붙여넣은 링크가 아무 설명 없이 열렸어요. 시나리오 설명에서 "쿠키를 지우고 확인할 것" 같은 문장도 전부 지웠고요. 그 문장을 지울 수 있게 된 게 이 작업의 실제 완료 신호였다고 생각해요.
무엇이 달라졌나요
가장 크게 달라진 건 순서예요. API 구현을 기다리지 않고 디자인 QA를 할 수 있어요.
gantt
title 디자인 확인이 언제 일어나는가 (기간이 아니라 순서를 보는 그림이에요)
dateFormat YYYY-MM-DD
axisFormat %m/%d
section 공통
기획·디자인 :done, c1, 2026-01-05, 4d
API 설계 :done, c2, after c1, 3d
설계 공유 :milestone, m1, after c2, 0d
section 예전
퍼블리싱 :p1, after m1, 3d
API 구현 :p2, after m1, 7d
연동 :p3, after p2, 2d
디자인 확인·수정 :crit, p4, after p3, 3d
section 지금
퍼블리싱 :n1, after m1, 3d
디자인 확인·수정 :crit, n2, after n1, 3d
API 구현 :n3, after m1, 7d
연동 :n4, after n3, 2d퍼블리싱은 API 설계가 공유되는 시점에 시작돼요. 여기가 중요한데, 그 시점에 이미 목업을 만들 재료가 손에 있다는 뜻이거든요. 응답 스펙을 알면 그대로 픽스처를 쓸 수 있으니까, 서버가 아직 없어도 화면은 돌아가요. 퍼블리싱이 끝나는 순간 링크가 생기고, 디자이너는 백엔드 일정과 상관없이 화면을 봐요.
예전이라면 API 구현과 연동이 다 끝나고 QA 단계에 가서야 나왔을 "이 여백 8px 아니었는데요"가, 마크업을 고치는 게 제일 싼 시점에 나오는 거예요.
예외 케이스를 먼저 적게 돼요
목업을 만들려면 "이 화면에 어떤 상태들이 있나"를 먼저 적어야 해요. 기획 명세와 API 설계에 흩어져 있던 실패 코드, 경계 조건, 빈 상태를 케이스 하나하나로 옮기는 일이거든요. 화면을 만드는 동안 예외 목록이 같이 만들어지는 셈이에요.
순서가 뒤집힌 게 핵심이에요. 예전에는 연동하다가, 혹은 QA에서 하나씩 발견되던 것들이었어요. 지금은 명세를 읽으면서 미리 적고, 적은 걸 그대로 눌러 보면서 확인해요. 그리고 한번 적어 두면 코드로 남아서, 나중에 그 화면을 고칠 때 케이스 목록을 다시 세울 필요가 없어요.
연동은 "목업을 떼는 일"이 돼요
MSW는 네트워크 계층에서 가로채기 때문에 제품 코드에는 목업 분기가 하나도 없어요. 화면은 처음부터 실 API를 부르는 코드로 쓰여 있고, 프리뷰 빌드에서만 그 요청이 가로채이는 구조예요.
그래서 API 구현이 끝나면 "연동 작업"이라고 부를 게 별로 없어요. 목업을 끄면 같은 코드가 그대로 실 서버에 붙거든요. 스펙대로 왔으면 그걸로 끝이고, 안 맞으면 어긋난 지점이 바로 드러나요. 퍼블리싱과 API 구현이 같은 설계 문서에서 동시에 출발해 놓고도 예전에는 한쪽이 끝나야 다른 쪽을 볼 수 있었는데, 이제는 만나는 지점이 스위치 하나가 된 거죠.
이번 건이 실제로 그러었어요. 퍼블리싱과 API 구현이 병렬로 굴러가고, API 구현이 끝난 뒤에는 하루 정도 목업을 걷어내는 작업으로 연동이 마무리됐어요. 그 시점에 화면은 이미 완성돼 있었고, 디자인 확인도 진작 끝나 있었고요.
그 밖에 패널에서 케이스를 고르면 그 케이스가 보이는 화면으로 자동으로 이동하고, 그 주소를 그대로 다시 공유할 수 있어요. 실서비스 배포로 새지 않도록 파이프라인에 가드를 넣었고, 프리뷰 태그로는 어느 실환경에도 배포가 나가지 않는 걸 파이프라인 잡 목록으로 확인했어요.
돌아보면 새로 알게 된 건 설계보다 마감에 있었어요.
배운 것왜목업은 제품 코드 밖에 둬야 해요코드 안에 분기를 넣으면 지우는 것도 일이 돼요. 네트워크 계층에서 가로채면 연동이 "떼는 일"로 끝나요"상태를 흉내 낸다"는 절반짜리예요흉내 내기 시작하면 끝까지 해야 해요. 진짜 서버에 물어보는 지점이 하나만 남아도, 흉내 내지 않았을 때보다 나쁜 상태가 되거든요안내문은 해결이 아니에요"쿠키를 지우세요"를 "쿼리를 붙이세요"로 바꾸는 건 같은 부담을 다른 문장으로 옮긴 것뿐이에요. 사람이 해야 할 일이 남아 있으면 아직 안 끝난 거죠에러는 상태 코드에만 있지 않아요200을 주면서 본문에서 거절하는 API는 많아요. 네트워크 탭만 보면 원인을 오판하게 돼요프록시 헤더는 모든 응답에 붙어요HTML 기준으로만 생각하면 뒤따르는 자산 요청 수십 건이 그걸 되돌려요설정 복사는 전제까지 복사되지 않아요원본이 부작용 없이 돌던 이유는 다른 곳에 있었어요. 가져오지 않은 그 조건이 범인이었죠진단은 로그로 하는 거예요네 번째 벽은 한 줄의 에러 메시지로 끝났어요. 짐작으로 헤맨 시간이 훨씬 길었고요
아직 모르는 것
경계는 여전히 남아 있어요. 서버 사이드 렌더링이 부르는 fetch, 클라이언트 스토어에만 있는 상태, 네이티브 브릿지는 링크로 재현되지 않아요. 이건 도구의 한계이지 결함은 아니에요. 링크만으로 되는 것과 사람이 동석해야 하는 것을 나눠서 목록을 따로 쓰는 편이 오해를 덜 만들었어요.
그리고 더 정직하게 말하면, 전체 기간이 얼마나 줄었는지는 아직 숫자로 말하기 어려워요. 이번이 첫 시도라 견줘 볼 이전 사이클이 마땅치 않고, 디자인 확인이 빨라진 만큼 나중 단계가 가벼워졌는지도 한 번으로는 알 수 없어요. 지금 말할 수 있는 건 구조가 바뀌었다는 것까지예요. 디자인 확인이 API 구현 뒤에 줄 서 있지 않아도 되는 형태가 됐다는 것.
그게 실제로 값을 하는지는 몇 번 더 돌려 봐야 알 것 같아요. QA 단계에서 나오는 디자인 이슈 건수, 연동 이후에 나오는 수정 요청 비율 같은 걸 다음 사이클에 견줘 볼 생각이고요.
확실한 건 하나예요. 화면을 다 만들어 놓고 API를 기다리기만 하는 구간은 없어졌어요. 그 시간이 정말로 디자인을 고치는 시간이 됐는지는, 몇 번 더 해 보고 다시 쓸게요.
도움이 되었다면 눌러주세요
댓글 0
첫 댓글을 남겨보세요.