문서 읽는 데 81분 · F4

F-4: API 모킹 — 서버 없이 서버를 재현합니다

목차 51
전체 59강 중 42강 · 리액트
난이도 · 중급선수지식HTML·CSS·JS

ℹ️TypeScript · React · Next.js 트랙이에요. html-css-js에서 만든 순수 JS 화면을 React 컴포넌트로 다시 짭니다. 웹 기초를 먼저 익히고 오면 좋아요.

안녕하세요, 홍순구 튜터입니다.

시작하기 전에 실험을 하나 해봤어요. client.ts에서 서버 주소를 적어둔 줄을 찾아서, 있지도 않은 곳으로 통째로 바꿨습니다.

텍스트
  before  http://localhost:8090/api
  after   http://localhost:9999/wrong

이 정도면 앱이 완전히 망가진 거라 어떤 요청도 도착할 수 없는데, 그 상태에서 지난 시간에 쓴 판들을 그대로 돌려봤습니다.

텍스트
  통과   4
  실패   0

아무 일도 없었습니다. 서버 주소가 통째로 틀렸는데 판은 초록불이에요.

지난 시간 마지막에 제가 이렇게 말씀드렸죠. "우리 코드를 한 줄도 안 고치고 서버 응답만 가로채는 방법을 배웁니다" 라고요. 오늘 그 방법의 이름을 알려드릴 텐데, 방금 그 4 대 0이 왜 그 방법을 써야 하는지에 대한 답이기도 합니다.

오늘 배울 도구는 사실 여러분 저장소에 이미 들어와 있는데, 여덟 개 파일이 쓰고 있고 그 이름이 나오는 곳이 백 군데가 넘습니다. 지난 두 시간에 그랬던 것처럼 오늘도 이미 있는 것을 마주하는 데서 출발하지만, 오늘은 조금 다른 이야기를 할 거예요. 그 도구가 무엇을 해주고, 무엇은 안 해주는가입니다.


💡 오늘 수업의 핵심 — "가로채기는 우리가 적어둔 목록만 가로챕니다"


🎯 학습 목표

  • 우리 코드를 한 줄도 안 고치고 네트워크 요청을 가로채는 방법을 익힙니다.
  • 그 가로채기의 경계를 압니다. 목록에 없는 요청은 어디로 가는지 눈으로 확인합니다.
  • 같은 목록 하나를 테스트와 개발 중인 브라우저가 나눠 쓰게 만듭니다.

Step 1: "비어 있는 자리에 첫 핸들러를 놓습니다"

먼저 우리 앱이 서버에게 무엇을 물어보는지부터 세어봤어요. src 안에서 api.get·api.post 같은 줄을 전부 찾으면 아홉 개가 나옵니다.

텍스트
 GET  /posts                        피드
 GET  /tags                         태그 목록
 GET  /posts/{id}                   게시물 하나
 POST /posts/{id}/like              좋아요
 GET  /posts/{id}/comments          댓글 목록
 DEL  /comments/{id}                댓글 삭제
 GET  /conversations/{id}/messages  쪽지 이력
 GET  /conversations                대화 목록       아무도 흉내 내주지 않는 주소
 POST /auth/login                   로그인

여덟 개는 이미 누군가 흉내를 내주고 있는데, GET /conversations 하나만 비어 있어요. 오늘 첫 핸들러를 놓을 곳이 여기입니다.

그리고 이 함수는 지난 시간에 우리가 이미 확인했던 그 함수예요.

TypeScript
// apps/web-spa/src/api/dm.ts
export async function fetchConversations(): Promise<Conversation[]> {
  const response = await api.get<Conversation[]>('/conversations');

  return response.data;
}

지난 시간에는 vi.mock으로 ./client를 통째로 치우고 확인했죠. 오늘은 client를 그대로 둔 채 확인해볼 거예요.

서버가 이렇게 답한다고 적어둡니다

새 파일을 하나 만듭니다.

TypeScript
// apps/web-spa/src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
import type { Conversation } from '../types/dm';

export const MOCK_API_BASE = 'http://localhost:8090/api';

/** 연습용 서버가 씌워 보내는 봉투와 같은 세 칸 */
export function ok<T>(data: T) {
  return { success: true, data, message: null };
}

export const mockConversations: Conversation[] = [
  {
    conversationId: 1,
    otherUsername: 'minji',
    otherProfileImageUrl: 'https://picsum.photos/seed/minji/64/64',
    lastMessage: '반포대교 남단이요! 해 지기 30분 전이 제일 좋아요',
    lastMessageAt: '2026-08-18T09:14:00',
  },
];

export const handlers = [
  http.get(`${MOCK_API_BASE}/conversations`, () => HttpResponse.json(ok(mockConversations))),
];

읽는 법은 그대로예요. http.get(주소, 답하는 함수) — 이 주소로 GET이 오면 이렇게 답해라입니다. HttpResponse.json(...)은 그 답을 JSON으로 만들어주고요.

ok(...)로 한 겹 싸는 것을 눈여겨보세요. 진짜 서버는 { success, data, message } 세 칸짜리 봉투에 담아 보내잖아요. 흉내를 내려면 그 봉투까지 같이 씌워야 합니다.

그 목록을 켭니다

목록만 있으면 아무 일도 안 일어나요. 켜주는 쪽이 따로 필요합니다.

TypeScript
// apps/web-spa/src/mocks/node.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

이름이 server 지만 진짜 서버가 뜨는 게 아니에요. 포트도 안 열립니다. 나가려는 요청을 중간에서 낚아채는 장치일 뿐이에요.

텍스트
  우리 코드                     낚아채는 장치            바깥
  ─────────                     ─────────────            ────

  fetchConversations()
        │
        
  axios (client.ts)
        │  GET :8090/api/conversations
        
   ┌────────────────┐
   │   setupServer  │──── 목록에 있다 ── 우리가 적은 답
   └────────────────┘
                     ──── 목록에 없다 ── 진짜로 나간다

오른쪽 아래 화살표를 기억해두세요. Step 3의 주인공입니다.

판을 씁니다

TypeScript
// apps/web-spa/src/api/f4-dm-msw.test.ts
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import { server } from '../mocks/node';
import { fetchConversations } from './dm';

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

describe('fetchConversations — 서버 없이, 우리 코드는 그대로', () => {
  it('대화 목록이 온다', async () => {
    const conversations = await fetchConversations();

    expect(conversations).toHaveLength(1);
    expect(conversations[0].otherUsername).toBe('minji');
  });

  it('봉투를 벗기는 일은 인터셉터가 진짜로 한다', async () => {
    const conversations = await fetchConversations();

    // success·data·message 세 칸이 아니라, data 칸 안에 있던 것만 손에 들어온다.
    expect(conversations[0]).not.toHaveProperty('success');
    expect(conversations[0].lastMessage).toBe('반포대교 남단이요! 해 지기 30분 전이 제일 좋아요');
  });
});

맨 위 세 줄이 켜고 끄는 부분이에요. listen으로 켜고, 판이 하나 끝날 때마다 resetHandlers로 되돌리고, 다 끝나면 close로 끕니다. 이 세 줄은 앞으로 계속 같은 모양으로 나올 거예요.

두 번째 판을 보세요. not.toHaveProperty('success')라고 썼습니다. 우리가 답할 때는 분명히 봉투에 담아 보냈는데, 손에 들어올 때는 봉투가 벗겨져 있어요. 벗기는 일을 C-6에서 만든 인터셉터가 진짜로 하고 있다는 뜻입니다.

여기가 vi.mock과 갈리는 첫 번째 지점이에요. vi.mock('./client')로 확인하면 인터셉터가 통째로 사라지니까, 봉투를 벗기는 코드가 잘 도는지는 그 판이 원리적으로 모릅니다.

💡 한 줄 정리

http.get(주소, 답)으로 목록을 적고 setupServer로 켜면, 우리 코드는 한 줄도 안 바뀐 채 서버만 흉내로 바뀝니다.

🙋 학생 질문 — "튜터님, 이건 그냥 우리가 답을 정해놓은 거잖아요. 그럼 가짜 아닌가요?"

가짜 맞는데, 어디까지 가짜인지가 중요해요.

vi.mock은 우리가 만든 fetchConversations의 옆 사람을 가짜로 바꿔서, axios도 인터셉터도 주소 조립도 전부 안 돌아요.

MSW는 그보다 훨씬 바깥에서 가짜가 됩니다. axios는 진짜로 돌고, 인터셉터도 진짜로 돌고, 주소도 진짜로 조립돼서 나가요. 그 요청이 랜선을 타기 직전에 낚아채는 겁니다.

텍스트
  [우리 함수]  [axios]  [인터셉터]  [주소 조립]  | 여기서 낚아챔 |  랜선
                                                             
   vi.mock 은 여기를 자름                              MSW 는 여기를 자름

자르는 곳이 오른쪽일수록 진짜에 가까운 것을 확인하게 돼요. 그 차이를 다음 Step에서 숫자로 보겠습니다.


Step 2: "주소를 통째로 망가뜨려 봅니다"

오프닝에서 보여드린 실험을 이제 제대로 해볼게요.

지난 시간에 쓴 판이 이렇게 생겼습니다.

TypeScript
// apps/web-spa/src/api/f2-dm.test.ts
vi.mock('./client', () => ({
  api: { get: vi.fn() },
}));

import { api } from './client';
import { fetchConversations, fetchMessages } from './dm';

'./client'를 통째로 치우고 그 자리에 { api: { get: vi.fn() } }를 놓았어요. 이 판은 이런 것들을 확인했죠.

텍스트
  방 번호가 주소에 들어간다      toHaveBeenCalledWith('/conversations/7/messages')
  한 번만 묻는다                 toHaveBeenCalledTimes(1)
  방마다 다른 주소로 묻는다      mock.calls 대조

좋은 판이에요. 그런데 여기 적힌 주소를 다시 보면 /conversations/7/messages라 앞부분이 없어요.

앞부분을 붙이는 사람이 client.ts인데, 그 파일을 치워버렸으니 이 판은 앞부분에 대해 아무것도 모릅니다.

실험

client.ts의 주소 한 줄을 바꿉니다.

TypeScript
// apps/web-spa/src/api/client.ts
export const API_BASE_URL = 'http://localhost:9999/wrong';

그리고 두 판을 나란히 돌립니다.

확인하는 방법 결과
f2-dm.test.ts vi.mock('./client') 4건 전부 통과
f4-dm-msw.test.ts MSW 가로채기 2건 전부 실패

서버 주소가 통째로 틀렸는데 왼쪽은 아무 일도 없다고 말합니다. 오른쪽은 즉시 무너져요.

이유는 간단해요. MSW 목록에 적힌 주소는 http://localhost:8090/api/conversations인데, 앱이 보내는 주소는 http://localhost:9999/wrong/conversations가 됐으니 안 맞는 거죠.

⚠️ 그래서 핸들러 파일은 주소를 직접 적습니다

여기서 실수하기 쉬운 곳이 하나 있어요. 이렇게 쓰고 싶어집니다.

TypeScript
// 이렇게 쓰면 안 됩니다
import { API_BASE_URL } from '../api/client';

http.get(`${API_BASE_URL}/conversations`, ...)

깔끔해 보이고 주소를 두 번 안 적어도 되지만, 이렇게 하면 방금 그 실험이 통과해버립니다. 우리 코드가 주소를 :9999/wrong으로 바꾸면 핸들러도 같이 :9999/wrong으로 따라가니까요.

핸들러 파일은 "서버는 이렇다"를 적는 곳이고, client.ts는 "우리는 여기로 보낸다"를 적는 곳이에요. 둘은 서로 다른 주장이라서, 서로를 참조하면 어긋남을 잡을 수가 없거든요. 그 이유를 우리 파일 맨 위에 적어뒀어요.

TypeScript
// apps/web-spa/src/mocks/handlers.ts
// ⚠️ 주소를 여기에 그대로 적는 이유
// 이 파일은 "서버 쪽" 을 흉내 내는 자리다. 그래서 client.ts 의 API_BASE_URL 을
// 가져다 쓰지 않고 주소를 직접 적는다. 가져다 쓰면 우리 코드가 주소를 틀리게
// 바꿔도 핸들러가 같이 따라 움직여서 아무 일도 없는 것처럼 보인다.

💡 한 줄 정리

모듈을 갈아 끼우면 그 모듈이 하던 일까지 확인 대상에서 빠집니다. 네트워크를 가로채면 그 일이 전부 진짜로 돌아요.

🙋 학생 질문 — "그럼 vi.mock 은 이제 쓰면 안 되나요?"

아니에요. 지난 시간에 쓴 그 판은 지금도 저장소에 그대로 있고, 지울 이유가 없습니다.

갈라 쓰는 기준은 무엇을 확인하려는가예요.

  • vi.mock이 나은 곳: 네트워크와 상관없는 것을 갈아 끼울 때. 시계, 무작위 값, 브라우저 API 같은 것들이요.
  • MSW가 나은 곳: HTTP 요청이 끼는 모든 곳. 주소·헤더·본문·상태 번호·봉투가 전부 확인 대상이 됩니다.

한 문장으로 줄이면 이렇습니다. 네트워크를 흉내 낼 때는 네트워크 층에서 흉내 냅니다. axios 자체를 가짜로 바꾸는 방법이 권장되지 않는 이유도 같아요. 확인하고 싶은 것을 확인 대상에서 빼버리게 되니까요.


Step 3: "목록에 없는 것은 진짜로 나갑니다"

Step 1 다이어그램의 오른쪽 아래 화살표를 기억하시죠. 이제 그걸 확인할 차례예요.

우리 목록에는 지금 GET /conversations 하나뿐입니다. 그럼 fetchMessages는요?

TypeScript
// apps/web-spa/src/api/dm.ts
export async function fetchMessages(conversationId: number): Promise<DirectMessage[]> {
  const response = await api.get<DirectMessage[]>(`/conversations/${conversationId}/messages`);

  return response.data;
}

주소가 다르죠. /conversations/1/messages는 목록에 없어요. 판을 돌리면 이런 메시지가 뜹니다.

텍스트
[MSW] Error: intercepted a request without a matching request handler:

  • GET http://localhost:8090/api/conversations/1/messages

If you still wish to intercept this unhandled request, please create a request handler for it.
Read more: https://mswjs.io/docs/http/intercepting-requests

"가로채긴 했는데 맞는 핸들러가 없다"예요. 그럼 그 요청은 어떻게 됐을까요?

실험: 네 가지 태도

server.listen()에 넘기는 server.listen()에 넘기는 onUnhandledRequest 값에 따라 답이 갈립니다. 핸들러가 하나도 없는 상태에서 fetchGET /api/tags를 불러봤어요. 연습용 서버는 켜둔 채로입니다.

onUnhandledRequest 알림 그 요청은
안 주면 (기본값) Warning 진짜로 나갑니다 → 200 + 진짜 데이터
'warn' Warning 진짜로 나갑니다 → 200 + 진짜 데이터
'error' Error 막힙니다
'bypass' 없음 진짜로 나갑니다 → 200 + 진짜 데이터

넷 중 셋은 그냥 내보내고 경고만 찍는데, 이번에는 같은 실험을 연습용 서버를 끈 채로 다시 해봤습니다.

텍스트
  기본값 · warn · bypass    TypeError: fetch failed

여기가 오늘 가장 중요한 곳입니다. 같은 코드, 같은 명령, 같은 판인데 내 노트북에 서버가 떠 있느냐로 결과가 갈립니다.

이게 왜 무서우냐면, 보통은 서버를 켜두고 개발하거든요. 그래서 내 노트북에서는 초록불이에요. 그런데 서버를 안 띄우는 곳에서 돌리면 갑자기 빨간불이 됩니다. 반대 방향도 있어요. 서버가 켜져 있어서 통과했는데, 그 판은 흉내 서버가 아니라 진짜 서버를 확인하고 있었던 거죠.

⚠️ 나간 요청이 어디까지 가는지는 또 다른 이야기입니다

위 표는 fetch로 잰 값이에요. 그런데 우리 앱은 fetch가 아니라 Axios를 씁니다. 그래서 같은 조건에서 세 가지를 나란히 재봤어요. 연습용 서버는 켜둔 채, 핸들러는 하나도 없이, 'warn'입니다.

무엇으로 부르나 결과
fetch 200 · 14ms — 진짜 서버까지 닿습니다
XMLHttpRequest onerror
Axios (우리 인스턴스) 던짐 — 서버에 연결할 수 없어요

Axios는 판이 도는 환경에서 XMLHttpRequest를 쓰는데, 그쪽은 다른 주소로 나가는 것을 막아요. 브라우저를 흉내 내는 환경이라 브라우저 규칙을 따르는 거라서, 정확한 서술은 이렇게 둘로 나뉩니다.

  • 내보낼지 말지는 MSW가 정합니다. 목록에 없으면 내보내요.
  • 나간 것이 진짜 서버까지 닿는지는 무엇으로 불렀느냐가 정합니다.

어느 쪽이든 결론은 같아요. 우리가 확인하려던 것을 확인하지 못합니다. 닿으면 진짜 서버를 확인하게 되고, 못 닿으면 엉뚱한 이유로 깨져요. 둘 다 원하는 게 아니죠.

'error'를 쓰면 흔들리지 않습니다

TypeScript
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));

이렇게 두면 목록에 없는 요청이 아예 막힙니다.

텍스트
InternalError: [MSW] Cannot bypass a request when using the "error" strategy
for the "onUnhandledRequest" option

서버가 켜져 있든 꺼져 있든 결과가 같아요. 판이 환경을 안 탑니다.

우리 저장소의 MSW 판을 전부 열어보면 하나같이 'error'가 적혀 있어요. 그동안은 그냥 관례처럼 보였을 텐데, 이유가 이겁니다. 실제로 전체 판을 서버를 켠 채 한 번, 끈 채 한 번 돌려봤는데 숫자가 똑같이 나왔어요.

비어 있던 자리를 채웁니다

이제 fetchMessages의 주소를 목록에 더합니다.

TypeScript
// apps/web-spa/src/mocks/handlers.ts
// 주소 가운데가 바뀌는 자리는 :이름 으로 받는다. 받은 값은 params 에 들어온다.
http.get(`${MOCK_API_BASE}/conversations/:conversationId/messages`, ({ params }) => {
  const id = Number(params.conversationId);

  return HttpResponse.json(ok(mockMessages.filter((message) => message.conversationId === id)));
}),

:conversationId는 C-2에서 라우터에 썼던 그 문법이에요. 주소 가운데가 바뀌는 곳을 이름표로 받고, 받은 값은 params로 들어오는데, 이걸로 확인할 수 있는 게 하나 늘어요.

TypeScript
// apps/web-spa/src/api/f4-dm-msw.test.ts
it('★ 방 번호가 주소에 실제로 실려 나간다 — 서버가 받은 것으로 확인한다', async () => {
  const messages = await fetchMessages(7);

  // 7번 방에는 아무것도 없다. 방 번호를 안 실어 보냈다면 1번 방 것이 왔을 것이다.
  expect(messages).toEqual([]);
});

지난 시간에는 "우리가 함수에 무엇을 넘겼나"를 확인했어요. 지금은 "서버가 무엇을 받았나"를 확인합니다. 확인하는 쪽이 반대편으로 넘어갔죠.

💡 한 줄 정리

가로채기는 우리가 적어둔 목록만 가로챕니다. 나머지는 조용히 밖으로 나가고, onUnhandledRequest: 'error'만 그 문을 닫습니다.

🙋 학생 질문 — "그냥 'error' 가 기본값이면 되지 않나요? 왜 통과시키는 게 기본이죠?"

MSW는 테스트 전용 도구가 아니거든요. Step 8에서 보시겠지만 개발 중인 브라우저에서도 씁니다.

브라우저에서는 우리가 안 적어둔 요청이 잔뜩 나가요. 이미지, 글꼴, 분석 스크립트… 그걸 전부 막으면 화면이 아예 안 뜹니다. 그래서 기본값이 "모르는 건 그냥 내보낸다"인 거예요.

정리하면 이렇습니다.

  • 테스트에서는 'error' — 확인하려는 것만 오가야 하고, 환경에 흔들리면 안 되니까요.
  • 브라우저에서는 'warn' 정도 — 안 적어둔 것들이 정상적으로 오가야 하니까요.

같은 도구인데 쓰는 곳에 따라 태도를 바꿔주는 겁니다.


Step 4: "이 판에서만 다르게 말하게 합니다"

지금까지는 잘 되는 경우만 봤는데, 확인해야 할 것 중 절반은 안 될 때 무슨 일이 나는가입니다.

문제가 하나 있어요. 서버가 실패하는 걸 우리가 어떻게 만들죠? 진짜 서버라면 일부러 고장을 낼 수가 없잖아요.

MSW는 이걸 server.use()로 풉니다. 이 판에서만 목록을 덮어쓰는 방법이에요.

TypeScript
// apps/web-spa/src/api/f4-dm-msw.test.ts
it('서버가 500 이면 서버가 보낸 사유가 손에 들어온다', async () => {
  server.use(
    http.get(`${MOCK_API_BASE}/conversations`, () =>
      HttpResponse.json(failure('대화 목록을 불러오지 못했습니다'), { status: 500 }),
    ),
  );

  await expect(fetchConversations()).rejects.toThrow(ApiError);
  await expect(fetchConversations()).rejects.toThrow('대화 목록을 불러오지 못했습니다');
});

failure(...)ok(...)의 짝이에요. 실패 봉투를 만들어줍니다.

TypeScript
// apps/web-spa/src/mocks/handlers.ts
/** 서버가 "안 됐다" 고 말할 때의 봉투. data 자리는 비고 사유가 들어온다. */
export function failure(message: string) {
  return { success: false, data: null, message };
}

두 번째 줄의 단언을 보세요. 서버가 보낸 한국어 사유가 그대로 손에 들어옵니다. C-6에서 만든 인터셉터가 봉투의 message 칸을 꺼내 ApiError에 담아주기 때문이에요. 이것도 인터셉터가 진짜로 돌기 때문에 확인할 수 있는 것입니다.

연결 자체가 안 되는 경우

서버가 답을 주는 실패 말고, 답을 아예 못 받는 경우도 있죠.

TypeScript
it('연결 자체가 안 되면 우리가 쓴 우리말이 들어온다', async () => {
  server.use(http.get(`${MOCK_API_BASE}/conversations`, () => HttpResponse.error()));

  // Axios 가 주는 말은 영어 'Network Error' 라 화면에 그대로 못 쓴다.
  await expect(fetchConversations()).rejects.toThrow('서버에 연결할 수 없어요');
});

HttpResponse.error()는 "연결이 안 됐다"를 흉내 냅니다. 랜선이 뽑힌 것 같은 상황이에요.

C-6에서 이 갈래를 따로 만들었던 것 기억하시죠. Axios가 주는 말은 영어 Network Error라서 화면에 그대로 내보낼 수 없으니, 우리말로 갈아 끼웠잖아요. 그 갈래가 실제로 도는지를 이제 확인할 수 있게 됐습니다.

덮어쓴 것은 이 판에서만 삽니다

TypeScript
it('★ 덮어쓴 것은 이 판에서만 산다 — 다음 판은 원래 목록으로 돌아온다', async () => {
  const conversations = await fetchConversations();

  expect(conversations).toHaveLength(1);
});

앞 판에서 500을 말하게 해뒀는데, 이 판에서는 다시 정상이에요. 되돌려주는 사람은 맨 위의 이 줄입니다.

TypeScript
afterEach(() => server.resetHandlers());

일부러 이 줄을 지우고 돌려보니, 앞 판의 500이 그대로 흘러들어와서 정확히 위 판 하나가 깨집니다.

텍스트
  resetHandlers 있음   7건 통과
  resetHandlers 없음   6건 통과 · 1건 실패

판 하나가 다음 판에 영향을 주기 시작하면 아주 찾기 어려운 문제가 됩니다. 판을 따로 돌리면 통과하는데 같이 돌리면 깨지거든요. 이 한 줄이 그걸 막아줍니다.

💡 한 줄 정리

잘 되는 경우는 목록에 적어두고, 안 되는 경우는 server.use()로 그 판에서만 덮어씁니다. resetHandlers가 매번 원래대로 되돌려요.

🙋 학생 질문 — "덮어쓸 때 주소를 또 적는 게 번거로운데, 원래 핸들러를 재활용할 수 없나요?"

server.use()는 목록 앞에 새 항목을 끼워 넣는 방식이에요. 같은 주소가 둘이면 앞에 있는 것이 이깁니다. 그래서 주소를 다시 적을 수밖에 없어요.

대신 자주 쓰는 실패 모양을 함수로 만들어두면 반복이 줄어듭니다.

TypeScript
function failingWith(status: number, message: string) {
  return http.get(`${MOCK_API_BASE}/conversations`, () =>
    HttpResponse.json(failure(message), { status }),
  );
}

server.use(failingWith(500, '대화 목록을 불러오지 못했습니다'));

너무 많이 감싸면 판을 읽는 사람이 "이 판에서 서버가 뭐라고 답하지?"를 알려고 다른 파일까지 열어봐야 하니, 판 안에서 한눈에 보이는 게 더 나을 때가 많습니다.


Step 5: "기다리는 화면, 도착한 화면, 실패한 화면"

여기까지는 함수 하나를 두고 확인했어요. 이제 화면으로 갑니다.

지난 시간에 우리가 확인한 화면들은 전부 데이터를 이미 손에 쥔 채로 그려졌어요. likeCount={1240}처럼 직접 넘겨줬으니까요. 그래서 그리자마자 완성된 화면이 나왔습니다.

진짜 피드는 그렇지 않죠. 서버에 물어보고, 기다리고, 도착하면 그때 그려집니다.

TypeScript
// apps/web-spa/src/routes/f4-feed-screen.test.tsx
it('그리자마자 보이는 것은 기다리는 화면이다', () => {
  render(withQuery(withRouter(<HomePage />)));

  expect(screen.getByText('피드를 불러오는 중이에요…')).toBeInTheDocument();
});

it('★ 그 순간에는 카드가 없다 — getBy 로 찾으면 못 찾는다', () => {
  render(withQuery(withRouter(<HomePage />)));

  expect(() => screen.getAllByRole('article')).toThrow();
});

두 번째 판이 재밌어요. getAllByRole('article')던진다는 것 자체를 단언하고 있습니다. 지난 시간에 배운 그대로예요. getBy는 지금 당장 없으면 화를 냅니다.

그래서 findBy입니다

TypeScript
it('★ findBy 는 도착할 때까지 기다렸다가 찾아준다', async () => {
  render(withQuery(withRouter(<HomePage />)));

  expect(await screen.findAllByRole('article')).toHaveLength(allPosts.length);
});

지난 시간에 findBy를 소개하면서 "서버가 끼는 화면은 다음 시간에"라고 미뤄뒀잖아요. 여기가 그 자리입니다. 같은 화면, 같은 조회 방법인데 앞의 것은 못 찾고 뒤의 것은 찾아요. 차이는 기다려주느냐 하나뿐입니다.

텍스트
 render 직후            잠시 후                  응답 도착
 ──────────             ───────                  ─────────
 "불러오는 중이에요…"    "불러오는 중이에요…"      카드 10장
      │                     │                        │
 getAllBy  ✗ 던짐      getAllBy  ✗ 던짐        getAllBy  ✓
 findAllBy ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈  findAllBy ✓

⚠️ 기다림은 delay가 만드는 게 아닙니다

핸들러에 이런 줄을 넣어뒀어요.

TypeScript
// apps/web-spa/src/mocks/handlers.ts
export const FEED_DELAY_MS = 100;

http.get(`${MOCK_API_BASE}/posts`, async ({ request }) => {
  await delay(FEED_DELAY_MS);
  const tag = new URL(request.url).searchParams.get('tag');
  const shown = tag === null ? allPosts : allPosts.filter((post) => post.hashtagNames.includes(tag));

  return HttpResponse.json(ok(shown));
}),

delay(100)이 있으니까 기다리는 화면이 생기는 것처럼 보이는데, 실제로 확인해보면 이 값을 0으로 바꿔도 다섯 판이 전부 그대로 통과해요.

기다리는 화면은 delay가 만드는 게 아니에요. 응답이 아무리 빨라도 요청은 첫 렌더보다 늦습니다. 요청을 보내는 일 자체가 비동기니까, 첫 화면은 언제나 "아직 없음"인데, 그렇다면 delay는 왜 넣었을까요? 그 순간을 눈으로 볼 만큼 늘려주기 위해서예요. Step 8에서 브라우저를 열어 확인할 때 100ms 정도는 있어야 로딩 문구가 스쳐 지나가는 게 보입니다.

실패하는 화면

TypeScript
it('서버가 사유를 보내면 화면이 카드를 안 그린다', async () => {
  server.use(
    http.get(`${MOCK_API_BASE}/posts`, () =>
      HttpResponse.json(failure('피드를 불러오지 못했습니다'), { status: 500 }),
    ),
  );

  render(withQuery(withRouter(<HomePage />)));

  // 실패는 위로 던져진다(C-7). 화면이 스스로 처리하지 않으므로
  // 카드가 영영 안 뜨는 것으로 확인한다.
  await expect(screen.findAllByRole('article', undefined, { timeout: 400 })).rejects.toThrow();
});

C-7에서 throwOnError를 켜면서 실패 처리를 화면 밖으로 내보냈던 탓에, HomePage 안에는 실패를 다루는 갈래가 없어요. 확인하는 방법도 그에 맞춰서 "카드가 끝내 안 뜬다"로 잡았습니다.

💡 한 줄 정리

서버가 끼는 화면은 기다림·도착·실패 세 얼굴을 갖고, 그 셋을 전부 확인할 수 있게 해주는 것이 가로채기입니다.

🙋 학생 질문 — "timeout 400은 어디서 나온 숫자예요? 그냥 아무 값이나 되나요?"

아무 값이나 되면 안 됩니다. 두 가지를 동시에 만족해야 해요.

  • 너무 짧으면: 정상적으로 도착할 화면도 못 기다려서, 멀쩡한데 실패로 나옵니다.
  • 너무 길면: 판 하나가 그만큼 느려집니다. 실패를 확인하는 판은 끝까지 기다려야 끝나니까요.

우리 경우는 delay가 100ms 니까 그보다 넉넉하면서, 판이 눈에 띄게 느려지지 않을 만큼으로 400ms 을 골랐어요.

한 가지 더 있어요. findBy의 기본 기다림은 1초인데, 판 하나에 걸린 전체 제한 시간이 그보다 짧으면 그 1초를 원리적으로 다 못 씁니다. 안쪽 한도를 늘릴 때는 그것을 감싸는 바깥 한도도 같이 봐야 해요.


Step 6: "보내는 요청의 본문을 읽습니다"

지금까지는 전부 가져오는 요청이었어요. 이번엔 보내는 요청입니다.

우리 앱에서 본문을 실어 보내는 곳은 로그인이에요.

TypeScript
// apps/web-spa/src/api/auth.ts
export async function login(username: string): Promise<LoginResponse['user']> {
  const response = await api.post<LoginResponse>('/auth/login', { username });
  ...
}

핸들러 쪽은 http.post를 쓰고, 본문은 request.json()으로 읽습니다.

TypeScript
// apps/web-spa/src/mocks/handlers.ts
// 연습용 서버에 있는 사람들. 이 목록에 없으면 서버가 거절한다.
export const mockUsers = ['jaehoon', 'minji'];

// 서버가 실제로 받은 요청 본문을 여기에 쌓아둔다.
// 돌려받은 값만 봐서는 "우리가 무엇을 보냈는지" 를 알 수 없다.
export const receivedLoginBodies: unknown[] = [];

http.post(`${MOCK_API_BASE}/auth/login`, async ({ request }) => {
  const body = (await request.json()) as { username?: string };
  receivedLoginBodies.push(body);

  if (body.username === undefined || !mockUsers.includes(body.username)) {
    return HttpResponse.json(failure('아이디 또는 비밀번호가 올바르지 않습니다'), { status: 401 });
  }

  return HttpResponse.json(
    ok({
      accessToken: 'access-1',
      refreshToken: 'refresh-1',
      user: {
        id: 1,
        username: body.username,
        profileImageUrl: `https://picsum.photos/seed/${body.username}/64/64`,
      },
    }),
  );
}),

두 가지가 새로워요.

첫째, 본문을 읽습니다. await request.json()이에요. 요청을 받는 쪽이니까 응답이 아니라 요청에서 읽는 겁니다.

둘째, 읽은 것을 보고 답을 바꿉니다. 아는 사람이면 토큰을 주고, 모르는 사람이면 401로 거절해요.

TypeScript
// apps/web-spa/src/api/f4-login-msw.test.ts
it('★ 서버가 받은 본문이 { username } 이다', async () => {
  await login('jaehoon');

  expect(receivedLoginBodies).toEqual([{ username: 'jaehoon' }]);
});

지난 시간의 mock.calls와 비교해보세요. 그때는 "우리가 함수에 무엇을 넘겼나" 였고, 지금은 "네트워크로 무엇이 나갔나"입니다. 사이에 있는 axios가 본문을 JSON으로 만드는 일까지 전부 진짜로 돌았어요.

🌟 그리고 여기서 앱의 결함이 하나 튀어나왔습니다

핸들러가 진짜 서버처럼 거절하기 시작하니까, 지금까지 한 번도 안 돌던 갈래가 돌기 시작했어요. 없는 아이디로 로그인해봤습니다.

텍스트
  서버가 보낸 것     401  "아이디 또는 비밀번호가 올바르지 않습니다"
  앱이 화면에 올린 것      "다시 로그인해주세요"

로그인하려던 사람에게 "다시 로그인해주세요"라고 말합니다. 사용자는 뭘 어떻게 하라는 건지 알 수가 없어요.

원인은 C-6에서 만든 인터셉터에 있었습니다.

TypeScript
// apps/web-spa/src/api/client.ts (고치기 전)
if (error.response.status === 401 && config !== undefined && config.retried !== true) {
  config.retried = true;

  try {
    await startRefresh();
  } catch {
    tokenStore.clear();
    throw new ApiError(LOGIN_AGAIN, 401);
  }
  ...

이 갈래는 "액세스 토큰이 만료됐으니 갱신하고 다시 보내자"를 위한 것이었는데, 로그인 요청이 받은 401까지 여기로 들어옵니다. 갱신할 토큰이 없으니 갱신에 실패하고, 서버가 보낸 진짜 사유는 LOGIN_AGAIN으로 덮여버려요.

왜 지금까지 안 보였을까요? 지금까지 쓰던 흉내 서버의 로그인 핸들러가 본문을 아예 안 읽었기 때문입니다. 어떤 아이디로도 성공을 돌려줬으니 401이 나올 수가 없었어요.

고침은 한 줄짜리예요.

TypeScript
// apps/web-spa/src/api/client.ts
// 토큰을 얻으러 가는 요청. 여기서 온 401 은 "만료" 가 아니라
// "아이디나 비밀번호가 틀렸다" 는 뜻이라 갱신할 것이 없다.
const AUTH_FREE_PATHS = ['/auth/login'];
TypeScript
const goesToAuthFreePath = AUTH_FREE_PATHS.includes(config?.url ?? '');

if (
  error.response.status === 401 &&
  config !== undefined &&
  !goesToAuthFreePath &&
  config.retried !== true
) {

고치고 나서 C-6에서 쓴 인터셉터 판 14건을 다시 돌렸더니 전부 그대로 통과했어요. 갱신은 여전히 잘 돕니다. 로그인만 그 길에서 빠진 거예요.

TypeScript
// apps/web-spa/src/api/f4-login-msw.test.ts
it('★ 서버가 보낸 사유가 그대로 올라온다', async () => {
  await expect(login('nobody-such-user')).rejects.toThrow(
    '아이디 또는 비밀번호가 올바르지 않습니다',
  );
});

💡 한 줄 정리

http.postrequest.json()으로 보낸 것을 확인할 수 있고, 흉내 서버가 진짜처럼 거절하기 시작하면 지금까지 안 돌던 갈래가 돌기 시작합니다.

🙋 학생 질문 — "흉내 서버를 너무 똑똑하게 만들면 그것대로 유지보수할 게 늘지 않나요?"

맞는 걱정이고, 실제로 선을 그어야 합니다.

기준은 그 판단이 화면의 갈래를 만드느냐예요.

  • 아는 아이디와 모르는 아이디를 갈라야 하는 이유는 화면이 그 둘에 다르게 반응해야 하기 때문이라서, 흉내 서버가 갈라줘야 해요.
  • 반면 비밀번호를 실제로 해싱해서 맞춰보는 것 같은 일은 화면에 아무 차이도 안 만듭니다. 그건 흉내 낼 필요가 없어요.

이번에 우리가 더한 것도 딱 그만큼이에요. 목록에 있으면 성공, 없으면 401, 이렇게 세 줄인데 그 세 줄이 앱의 결함 하나를 잡아냈고, 흉내 서버가 진짜와 얼마나 닮아야 하는가는 다음 Step의 주제이기도 합니다.


Step 7: "핸들러는 사실이 아니라 우리의 주장입니다"

Step 6에서 좀 이상한 일이 있었어요. 흉내 서버를 진짜처럼 고쳤더니 결함이 나왔죠. 뒤집어 생각하면, 그 전까지 우리 흉내 서버는 진짜와 다른 말을 하고 있었다는 뜻입니다.

이게 가로채기의 마지막 그늘이에요.

텍스트
  핸들러 목록  =  "서버는 이렇게 답한다" 는 우리의 주장
  진짜 서버    =  실제로 그렇게 답하는지 여부

  둘이 어긋나면    판은 초록불, 앱은 깨짐

판이 초록불인데 앱이 깨지는 게 가장 나쁜 경우예요. 아무도 안 알려주니까요.

어긋남을 드러내는 판

그 어긋남을 드러내려고 판을 하나 더 둡니다. 우리가 적어둔 주소가 진짜 서버에도 있는 주소인지 확인하는 판이에요.

TypeScript
// apps/web-spa/src/mocks/f4-contract.test.ts
// 연습용 서버가 실제로 받아주는 주소 목록을, 손으로 옮겨 적지 않고
// 그 파일에서 그대로 읽어온다. 옮겨 적으면 그 목록이 또 하나의 주장이 된다.
const serverSource = readFileSync(resolve(process.cwd(), '../api-stub/server.mjs'), 'utf-8');

주석에 적힌 이유가 중요해요. 진짜 서버의 주소 목록을 우리가 손으로 옮겨 적으면, 그 목록도 결국 또 하나의 주장이 됩니다. 진짜가 바뀌어도 우리 목록은 그대로일 테니까, 그 파일을 직접 읽습니다.

TypeScript
it.each(
  handlers.map((handler) => ({
    method: String(handler.info.method),
    path: String(handler.info.path),
  })),
)('$method $path — 연습용 서버에도 있는 주소다', ({ method, path }) => {
  const pathname = sampleUrlOf(path);
  const found = realRoutes.some(
    (route) => route.method === method.toUpperCase() && route.accepts(pathname),
  );

  expect(found).toBe(true);
});

handlers.map(...)을 눈여겨보세요. 우리 핸들러 목록을 코드가 직접 읽습니다. 새 핸들러를 더하면 확인할 항목도 저절로 하나 늘어요. 사람이 목록을 따라 적을 필요가 없습니다.

handler.info 안에 그 핸들러의 method와 path가 들어 있어요. 우리가 http.get(주소, ...)라고 쓸 때 넘긴 그 값입니다.

진짜로 잡히는지 확인해봤습니다

이런 판은 만들어놓고 안심하기가 쉬워서, 일부러 어긋난 것을 하나 넣어봤습니다.

지금까지 쓰던 흉내 서버 파일에는 GET /whoami라는 주소가 있어요. C-6에서 헤더가 잘 붙는지 확인하려고 만든 것인데, 연습용 서버에는 그런 주소가 없습니다. 그걸 우리 목록에 넣고 돌렸어요.

텍스트
  GET /whoami                                ✗ 실패
  POST /auth/login                           ✓
  GET /posts                                 ✓
  GET /conversations                         ✓
  GET /conversations/:conversationId/messages ✓

정확히 그 하나만 잡힙니다.

/whoami 같은 주소는 판을 쓰기 편하게 만들어주지만, 진짜 서버에 없는 것을 있는 것처럼 다루기 시작하면 판이 점점 현실에서 멀어져요. 우리 목록에는 안 넣기로 한 이유입니다.

💡 한 줄 정리

핸들러는 사실이 아니라 우리가 적어둔 주장이라서, 진짜와 어긋나면 판만 초록불이 됩니다. 그 어긋남도 확인 대상으로 둬야 해요.

🙋 학생 질문 — "진짜 백엔드는 파일 하나가 아닐 텐데, 그럴 땐 이런 판을 어떻게 만들어요?"

좋은 질문이에요. 우리는 연습용 서버가 파일 하나라서 읽어낼 수 있었지만, 실무에서는 그렇지 않죠.

대신 서버가 자기 규격을 문서로 내보내주는 경우가 많습니다. 백엔드 과목에서 만나실 텐데, 서버가 "나는 이런 주소를 받고 이런 모양으로 답한다"를 기계가 읽을 수 있는 형식으로 뱉어줘요. 그걸 받아서 우리 목록과 대조하면 같은 일을 할 수 있습니다.

규격 문서조차 없다면 방법이 하나 더 있어요. 진짜 서버에 실제로 붙어보는 판을 아주 적게, 따로 둡니다. 평소에는 안 돌리고 하루에 한 번쯤만 돌리는 거죠. 우리 주장이 아직 유효한지 확인하는 용도로요.

어느 쪽이든 핵심은 같아요. 주장은 언젠가 낡는다는 것을 전제로 두는 겁니다.


Step 8: "브라우저에서도 같은 목록을 씁니다"

여기까지 만든 목록은 판에서만 쓰였는데, 이 목록을 개발할 때도 쓸 수 있으면 좋지 않을까요?

지금은 화면을 보려면 연습용 서버를 꼭 띄워야 합니다. 안 띄우면 피드가 영영 안 떠요.

브라우저는 가로채는 방법이 다릅니다

Node에서는 요청 계층을 직접 손볼 수 있었어요. 브라우저에서는 그럴 수 없습니다. 대신 서비스 워커를 씁니다. 브라우저가 페이지와 네트워크 사이에 끼워주는 작은 프로그램이에요.

텍스트
 [테스트]                          [브라우저]

  우리 코드                          우리 코드
      │                                  │
                                        
  Node 요청 계층  여기를 손봄       서비스 워커  브라우저가 끼워준다
      │                                  │
                                        
   바깥                               바깥

  같은 handlers.ts 를 둘이 나눠 쓴다

그 워커 파일은 우리가 짜는 게 아니라 MSW가 갖다 놓습니다.

Bash
npx msw init public/

이 명령 하나가 public/mockServiceWorker.js를 만들어줘요. 9,666 바이트짜리 파일이고, 브라우저가 이 파일을 워커로 등록합니다.

그다음은 Node 쪽과 거의 같아요.

TypeScript
// apps/web-spa/src/mocks/browser.ts
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';

export const worker = setupWorker(...handlers);

setupServersetupWorker로 바뀐 것 하나뿐이에요. handlers는 판이 쓰던 그 목록 그대로입니다.

켜는 자리

TypeScript
// apps/web-spa/src/main.tsx
// 연습용 서버 없이 화면을 보고 싶을 때만 흉내 서버를 켠다.
//
//   VITE_MOCK_API=1 npm run dev
//
// import() 를 여기서 부르는 이유가 둘이다. 하나는 배포본에 안 들어가게 하는 것이고,
// 다른 하나는 워커가 다 켜진 뒤에 화면을 그려야 첫 요청부터 가로채지기 때문이다.
async function startMockingIfAsked(): Promise<void> {
  if (!import.meta.env.DEV || import.meta.env.VITE_MOCK_API !== '1') {
    return;
  }

  const { worker } = await import('./mocks/browser');

  await worker.start({ onUnhandledRequest: 'warn' });
}

onUnhandledRequest가 여기서는 'warn'이에요. Step 3의 🙋 에서 이야기한 그대로입니다. 브라우저에서는 우리가 안 적어둔 요청들이 정상적으로 오가야 하니까요.

await worker.start()가 끝난 다음에 화면을 그리는데, 순서가 바뀌면 첫 요청이 워커를 못 만나고 그냥 나가버려요.

진짜로 되는지 봤습니다

연습용 서버를 완전히 끈 채로 개발 서버를 띄웠어요.

Bash
VITE_MOCK_API=1 npm run dev
플래그 켬 플래그 끔
서비스 워커 등록됨 없음
화면의 카드 10장 0장
로딩 문구 사라짐 피드를 불러오는 중이에요…에 갇힘
개발자 도구에서 :8090 직접 호출 200 · 10개 Failed to fetch

백엔드가 한 줄도 안 도는데 인스타그램이 그려집니다. 개발자 도구 콘솔에서 직접 fetch('http://localhost:8090/api/posts')를 불러도 200이 돌아와요. 진짜 서버는 꺼져 있는데도요.

배포본에는 무엇이 들어갈까요

개발용 도구를 앱에 끼워 넣었으니 확인해봐야죠.

텍스트
  index.js   배선 전  531,689 B
             배선 후  531,727 B      +38 B
  mocks 청크          안 생김

MSW 본체는 배포본에 안 들어갑니다. import('./mocks/browser')import.meta.env.DEV 조건 안에 뒀기 때문이에요. 배포 빌드에서는 그 조건이 거짓으로 굳어서, 빌드 도구가 그 안쪽을 통째로 들어냅니다. C-7에서 개발자 도구 창을 붙일 때 쓴 방법과 같아요.

늘어난 38 바이트는 감싼 함수 껍데기예요.

⚠️ 다만 빠뜨리면 안 되는 게 하나 있습니다.

텍스트
  dist/mockServiceWorker.js   9,666 B

워커 파일은 배포본에 그대로 실립니다. public/에 있는 것은 빌드가 손대지 않고 통째로 복사하거든요. 코드는 0 바이트인데 파일은 들어가는 겁니다. 아무도 그 파일을 요청하지 않으니 사용자가 내려받지는 않지만, 배포본에 개발용 파일이 남아 있는 것은 사실이에요.

💡 한 줄 정리

setupWorker로 같은 목록을 브라우저에서도 쓰면 백엔드 없이 앱이 돌고, 배포본에는 코드가 안 들어갑니다.

🙋 학생 질문 — "워커가 한 번 등록되면 계속 남아서 가로채는 거 아니에요? 나중에 진짜 서버를 붙이고 싶은데요."

안 그렇습니다. 워커 파일을 직접 열어보면 답이 적혀 있어요.

JavaScript
// public/mockServiceWorker.js
if (!activeClientIds.has(client.id)) {
  return passthrough()
}

워커는 "나를 켜라" 고 말한 페이지의 요청만 가로챕니다. worker.start()를 부르면 그 신호가 가고, 안 부르면 그냥 통과시켜요.

조금 더 아래에는 이런 줄도 있어요.

JavaScript
if (remainingClients.length === 0) {
  self.registration.unregister()
}

그 페이지들이 다 닫히면 스스로 등록을 지웁니다.

실제로 확인해봤어요. 플래그를 빼고 개발 서버를 다시 띄운 뒤 열어보니 등록된 워커가 하나도 없었고, 피드는 연습용 서버를 기다리는 원래 상태로 돌아와 있었습니다.


마무리

오늘은 이름 하나를 알려드리는 것으로 시작했어요. 지난 시간에 "우리 코드를 한 줄도 안 고치고 가로채는 방법" 이라고만 해뒀던 그것, MSW인데, 하루를 써서 배운 건 그 도구가 무엇을 해주느냐보다 무엇은 안 해주느냐 쪽이 더 많았어요.

텍스트
  해준다        적어둔 주소의 요청을 낚아채서 우리가 정한 답을 준다
                그 앞의 axios · 인터셉터 · 주소 조립은 전부 진짜로 돌린다

  안 해준다     적어두지 않은 주소는 그냥 내보낸다
                우리가 적은 답이 진짜와 같은지는 봐주지 않는다

왼쪽이 Step 1·2 였고, 오른쪽이 Step 3과 Step 7 이었습니다.

오늘 배운 핵심 세 가지

💡 하나 — 흉내는 네트워크 층에서 냅니다. 모듈을 갈아 끼우면 그 모듈이 하던 일까지 확인 대상에서 빠져요. 서버 주소를 통째로 바꿔도 통과하던 4건이 그 증거입니다.

💡 둘 — 가로채기는 목록에 있는 것만 가로챕니다. 나머지는 조용히 밖으로 나가고, 그때부터 판의 결과가 내 노트북 상태에 따라 달라져요. onUnhandledRequest: 'error'가 그 문을 닫습니다.

💡 셋 — 핸들러는 주장입니다. 진짜와 어긋나면 판만 초록불이 돼요. 흉내 서버를 진짜처럼 고쳤더니 앱의 결함이 하나 나온 것도 같은 이야기입니다.

다음 시간 예고

오늘 Step 6에서 우리가 만진 것이 로그인이었는데, 아이디를 보내고 토큰을 받고 저장하고 다음 요청에 붙이는 그 흐름이 사실 아직 진짜는 아닙니다. 비밀번호를 물어보는 화면도 없고, 아이디만 보내면 서버가 그냥 토큰을 내주죠. 지금까지는 그걸로 충분했어요. 우리가 확인하고 싶었던 것은 토큰이 잘 붙느냐였으니까요.

오늘 한 가지가 더 드러나기도 했습니다. 흉내 서버를 진짜처럼 만들자마자 로그인 실패를 다루는 갈래가 엉뚱하게 동작하고 있었다는 게 나왔잖아요. 그 갈래를 제대로 만들려면 로그인 자체가 제대로 있어야 합니다.

다음 시간에는 진짜 로그인을 붙입니다. 로그인한 사람만 볼 수 있는 화면, 새로고침해도 풀리지 않는 세션, 그리고 만료된 토큰을 조용히 갈아 끼우는 흐름까지요.

무대는 Next.js 쪽으로 돌아갑니다. D-8에서 문지기에게 두 번째 일을 줬던 것 기억하시죠. 그때 그 문지기는 쿠키가 있는지만 봤어요. 그 쿠키가 진짜인지, 누구 것인지는 안 봤습니다. 다음 시간에 그 자리를 채우는데, 테스팅 이야기도 여기서 끝나지 않습니다. 진짜 브라우저를 띄워 한 줄기를 통째로 밟아보는 방법과, 컴포넌트를 카탈로그로 만들어 눈으로 확인하는 방법이 남아 있습니다. 둘 다 오늘 만든 목록을 그대로 가져다 쓰고요. 커리큘럼에서 그 두 시간을 만나실 수 있습니다.


과제

[구현] 댓글 제출을 서버까지 보내고, 그 요청을 확인해보세요

우리 앱의 댓글 입력칸은 지금 서버에 아무것도 안 보냅니다. 입력한 내용이 화면 안에만 쌓여요. 새로고침하면 사라집니다.

연습용 서버에는 댓글을 받아주는 주소가 이미 있어요.

텍스트
  POST /api/posts/{postId}/comments
  본문  { "content": "..." }

다음을 만들어보세요.

  1. src/api/comments.tscreateComment(postId, content)를 더합니다. 같은 파일의 fetchComments를 참고하세요.
  2. src/mocks/handlers.ts에 그 주소의 핸들러를 더합니다. 본문의 content를 읽어서, 빈 문자열이면 400으로 거절하게 하세요. 연습용 서버가 그렇게 합니다.
  3. 판을 써서 세 가지를 확인하세요. 서버가 받은 본문에 내가 쓴 글이 들어 있다 · 빈 내용이면 거절당한다 · 거절당했을 때 서버가 보낸 사유가 손에 들어온다

⚠️ Step 7의 판이 새 핸들러도 자동으로 확인합니다. 주소를 잘못 적으면 그 판이 먼저 알려줄 거예요.

[탐구] 배포본에 남는 워커 파일을 어떻게 할까요

Step 8에서 확인했듯이 public/mockServiceWorker.js는 배포 빌드에도 그대로 실립니다.

  1. 정말 그런지 직접 확인하세요. npm run build를 돌리고 dist/ 안을 보면 됩니다.
  2. 이 파일이 배포본에 남으면 실제로 무슨 문제가 생기는지, 아니면 안 생기는지 따져보세요. 사용자가 이 파일을 내려받게 되나요? 누군가 이 주소를 직접 열면 무슨 일이 나나요?
  3. 빼고 싶다면 어떻게 할 수 있을지 방법을 하나 이상 찾아보고, 그 방법이 개발 중에는 여전히 잘 되는지 확인하세요. 빼는 것보다 이쪽이 중요합니다.

[구현] 좋아요를 목록에 더하고, 다섯 번에 한 번 실패하게 만들어보세요

연습용 서버는 좋아요를 다섯 번에 한 번 실패시킵니다. C-7에서 되돌리기를 배울 때 그 규칙을 썼죠.

  1. POST /posts/:postId/like 핸들러를 handlers.ts에 더하세요. 누를 때마다 눌림 상태가 뒤집히고, 개수도 따라 움직여야 합니다.
  2. 그다음 server.use()이 판에서만 500을 말하게 해서, 화면이 눌린 상태를 되돌리는지 확인하세요.
  3. ⚠️ 실패를 흉내 내는 규칙(몇 번에 한 번 실패할지)을 핸들러 안에 고정해두면 판마다 몇 번째인지를 세야 합니다. 그러지 않으려면 어디에 둬야 할지 생각해보세요.

[탐구] 새어 나간 요청은 정말 진짜 서버까지 갈까요

Step 3의 ⚠️ 에서 fetch와 Axios의 결과가 달랐죠. 직접 재보시면 좋겠습니다.

  1. 핸들러가 하나도 없는 setupServer()를 만들고 onUnhandledRequest: 'warn'으로 켜세요. 그 상태에서 같은 주소를 fetch로 한 번, 우리 api 인스턴스로 한 번 불러 결과를 비교하세요. 연습용 서버는 켜둔 채로 합니다.
  2. 결과가 갈린다면 왜 그런지 찾아보세요. 힌트는 두 가지입니다. 판이 도는 곳은 브라우저가 아니라 브라우저를 흉내 낸 환경이라는 것, 그리고 Axios가 그 환경에서 무엇을 써서 요청을 보내는지입니다.
  3. 이번엔 연습용 서버를 끄고 같은 실험을 반복하세요. 어느 쪽 결과가 바뀌고 어느 쪽이 안 바뀌나요?
  4. 마지막으로, 이 실험 결과가 'error'를 쓰는 이유를 약하게 만드는지 강하게 만드는지 한 문단으로 정리해보세요.

⚠️ 실험이 끝나면 실제 판 파일은 원래대로 되돌리고, 전체가 통과하는지 꼭 확인하세요.


생각해볼 주제

1. 흉내 서버는 얼마나 진짜를 닮아야 할까요

Step 6에서 우리는 흉내 서버에 세 줄을 더했습니다. 아이디를 보고 거절하는 갈래였죠. 그 세 줄이 앱의 결함 하나를 잡아냈어요.

그렇다면 더 닮게 만들면 더 많이 잡을 수 있을까요? 비밀번호도 확인하고, 로그인 시도 횟수도 세고, 토큰 만료 시간도 진짜처럼 흘려보내면요?

어느 지점부터는 흉내 서버를 만드는 일이 서버를 한 벌 더 만드는 일이 됩니다. 그 선을 어디에 그어야 할까요, 또 그 선을 정하는 기준은 무엇일까요.

2. 판이 전부 초록불인데 사용자는 못 쓰는 상황

오늘 우리는 요청을 가로채서 답을 정해줬습니다. 화면은 그 답을 받아 잘 그렸고 판은 초록불이 됐는데, 그 답이 진짜 서버가 주는 것과 한 글자라도 다르면 사용자 화면에서는 아무것도 안 될 수 있습니다. Step 7에서 주소는 대조했지만 답의 모양은 대조하지 않았죠.

이런 어긋남을 미리 잡으려면 무엇이 더 필요할까요, 또 그것을 갖추는 비용과 안 갖췄을 때 치르는 비용 중 어느 쪽이 클지 어떻게 판단하면 좋을까요.

3. 개발용 도구가 앱 코드에 들어오는 것

Step 8에서 우리는 main.tsx에 흉내 서버를 켜는 코드를 넣었습니다. 배포본에는 안 들어가도록 조건을 걸었고 실제로 안 들어가는 것도 확인했는데, 앱을 처음 열어보는 사람 입장에서 보면 앱이 시작하는 파일에 앱과 상관없는 코드가 한 덩어리 있는 셈입니다. 비슷한 것이 이미 하나 더 있기도 하고요.

이렇게 개발용 코드가 앱 코드에 섞이는 것을 어디까지 허용해야 할까요. 아예 안 섞이게 하는 방법이 있다면 그 대가는 무엇일까요.

✅ 예시 답안정답 보기

과제와 생각해볼 주제의 예시답안입니다. 정답이 하나뿐인 문제는 아니에요. 여러분이 쓴 것과 견주면서 읽어보세요.


과제 예시답안

🎯 [과제 1 예시답안] 댓글 제출을 서버까지 보내고, 그 요청을 확인해보세요

채점 포인트

항목 배점 확인
createCommentapi.post로 본문을 실어 보낸다 6 주소에 postId가 들어가고 본문은 { content }
핸들러가 await request.json()으로 본문을 읽는다 6 응답이 아니라 요청에서 읽었는가
빈 내용을 400 으로 거절한다 5 연습용 서버와 같은 상태 번호·같은 사유
서버가 받은 본문을 확인하는 판이 있다 5 돌려받은 값이 아니라 받은 것을 봤는가
거절당했을 때 사유가 손에 들어오는 것을 확인한다 3 인터셉터가 봉투에서 꺼내준다
합계 25

풀이 예시

먼저 보내는 함수입니다. 같은 파일의 fetchComments와 모양이 같아요.

TypeScript
// apps/web-spa/src/api/comments.ts
export async function createComment(postId: number, content: string): Promise<Comment> {
  const response = await api.post<Comment>(`/posts/${postId}/comments`, { content });

  return response.data;
}

두 번째 인자가 본문이에요. 봉투를 벗기는 일은 인터셉터가 하니까 제네릭에는 Comment 하나만 적습니다.

핸들러는 이렇게 씁니다.

TypeScript
// apps/web-spa/src/mocks/handlers.ts
export const receivedCommentBodies: unknown[] = [];

http.post(`${MOCK_API_BASE}/posts/:postId/comments`, async ({ request, params }) => {
  const body = (await request.json()) as { content?: string };
  receivedCommentBodies.push(body);

  const content = typeof body.content === 'string' ? body.content.trim() : '';
  if (content === '') {
    return HttpResponse.json(failure('댓글 내용을 입력해주세요'), { status: 400 });
  }

  const created: Comment = {
    id: 100,
    postId: Number(params.postId),
    username: 'jaehoon',
    content,
    createdAt: '2026-08-20T10:00:00',
  };

  return HttpResponse.json(ok(created), { status: 201 });
}),

requestparams를 함께 받는 것을 눈여겨보세요. 주소에서 postId를 꺼내고 본문에서 content를 꺼냅니다. 두 곳에서 정보가 오는 거예요.

판은 세 가지를 확인합니다.

TypeScript
it('서버가 받은 본문에 내가 쓴 글이 들어 있다', async () => {
  server.use(createCommentHandler());

  await createComment(1, '노을 진짜 예쁘네요');

  expect(receivedCommentBodies).toEqual([{ content: '노을 진짜 예쁘네요' }]);
});

it('빈 내용이면 거절당하고 서버가 보낸 사유가 손에 들어온다', async () => {
  server.use(createCommentHandler());

  await expect(createComment(1, '   ')).rejects.toThrow(ApiError);
  await expect(createComment(1, '   ')).rejects.toThrow('댓글 내용을 입력해주세요');
});

두 번째 판에 공백 세 칸을 넣은 것에 주목하세요. 빈 문자열이 아니라 공백만 있는 경우입니다. 핸들러가 trim()을 하니까 이것도 거절돼야 해요. 연습용 서버도 똑같이 trim을 합니다.

자주 나오는 실수

돌려받은 값만 확인하고 끝냅니다. expect(created.content).toBe('노을 진짜 예쁘네요')는 통과하지만, 그건 우리가 핸들러에서 그렇게 만들어 돌려줬기 때문이에요. 본문이 실제로 실려 나갔는지는 서버가 받은 것을 봐야 알 수 있습니다. 지난 시간의 mock.calls와 같은 이야기예요.

응답에서 본문을 읽으려고 합니다. 핸들러 안에서 읽는 것은 request.json() 이지 response가 아니에요. 우리가 지금 서버 쪽에 서 있으니까요.

await를 빼먹습니다. request.json()은 약속을 돌려줘요. await 없이 쓰면 body.content가 언제나 undefined라 전부 400이 됩니다.

💡 튜터의 한마디

이 과제를 다 하고 나면 화면 쪽이 궁금해질 거예요. 지금 CommentForm은 입력한 내용을 화면 안에만 쌓고 있으니까요. 그걸 서버까지 잇는 일은 C-7에서 배운 useMutation의 몫입니다. 오늘 만든 핸들러가 그때 바로 쓰입니다. 흉내 서버를 먼저 갖춰두면 화면을 만들 때 백엔드를 기다릴 필요가 없어요. 실무에서 프런트와 백엔드가 같은 시기에 일을 시작할 수 있는 이유가 이겁니다.

🎯 [과제 2 예시답안] 배포본에 남는 워커 파일을 어떻게 할까요

채점 포인트

항목 배점 확인
배포본에 실제로 실리는 것을 직접 확인했다 6 npm run builddist/를 봤는가
왜 실리는지 이유를 안다 5 public/은 빌드가 손대지 않고 복사한다
무슨 문제가 생기는지, 안 생기는지 따져봤다 7 사용자가 내려받는가 · 직접 열면 무슨 일인가
빼는 방법을 하나 이상 찾았다 4 실제로 빠지는 것을 확인했는가
그 방법이 개발 중에는 여전히 되는지 확인했다 3 안 하면 도구를 망가뜨린 것
합계 25

풀이 예시

먼저 확인부터 합니다.

Bash
npm run build
ls dist/
텍스트
  assets
  index.html
  mockServiceWorker.js      9,666 B

실립니다. 이유는 간단해요. public/ 안에 있는 것은 빌드 도구가 내용을 보지도 않고 통째로 복사합니다. 코드가 아니라 파일로 취급하니까요.

그래서 이런 어긋남이 생겨요.

텍스트
  MSW 코드     배포본에 0 B      (import() 가 DEV 조건 안이라 통째로 빠짐)
  워커 파일    배포본에 9,666 B  (public/ 이라 그냥 복사됨)

무슨 문제가 생기나요? 정직하게 따져보면 대부분은 안 생깁니다.

  • 사용자가 내려받지 않습니다. 이 파일을 워커로 등록하는 코드가 배포본에 없어요. 아무도 요청하지 않으니 전송량도 0입니다.
  • 직접 주소를 열면 그냥 자바스크립트 소스가 보입니다. 등록되지 않은 파일이라 아무 일도 안 해요.

그럼 왜 신경 쓰나요? 두 가지입니다.

  • 배포본에 개발용 파일이 있다는 사실 자체가 읽는 사람에게 잘못된 신호를 줍니다. "이 앱은 요청을 가로채나?" 하고 의심할 여지를 남겨요.
  • 만약 누군가 실수로 이 워커를 등록하는 코드를 배포본에 넣는다면, 파일이 이미 거기 있으니 곧바로 동작합니다. 파일이 없으면 그 실수가 즉시 드러날 텐데요.

빼는 방법

빌드가 끝난 뒤에 그 파일만 지우는 것이 가장 간단합니다.

TypeScript
// apps/web-spa/vite.config.ts
import { rm } from 'node:fs/promises';

const dropMockWorker = {
  name: 'drop-mock-worker',
  apply: 'build' as const,
  async closeBundle() {
    await rm('dist/mockServiceWorker.js', { force: true });
  },
};

apply: 'build'가 결정적이에요. 이 줄이 있어야 빌드할 때만 돌고 개발 서버는 안 건드립니다.

확인한 결과입니다.

텍스트
  npm run build 후 dist/          assets · index.html   (워커 파일 없음)
  개발 서버에서 /mockServiceWorker.js   200               (그대로 됨)

자주 나오는 실수

apply: 'build'를 빠뜨립니다. 이걸 빠뜨리면 개발 서버를 띄울 때도 그 파일을 지우려 들어서, 흉내 서버가 아예 안 켜집니다. 빼는 데만 신경 쓰다가 도구를 망가뜨리는 거예요. 과제가 3번을 따로 물어본 이유입니다.

public/에서 파일을 통째로 지웁니다. 이렇게 하면 개발 중에도 워커가 없어서 브라우저 흉내 서버가 안 돌아요.

💡 튜터의 한마디

이 과제의 진짜 답은 "어떻게 빼느냐"가 아니라 "뺄 만한 일인지 먼저 따져보는 것"이에요. 실제로 재보면 사용자에게 가는 비용은 0 이거든요. 그런데도 빼기로 한다면 그건 성능 때문이 아니라 다른 이유입니다. 도구를 하나 더 얹으면 그것도 나중에 누군가 읽고 고쳐야 하니까, 얻는 것과 치르는 것을 저울에 올려보는 습관이 중요해요.

🎯 [과제 3 예시답안] 좋아요를 목록에 더하고, 다섯 번에 한 번 실패하게 만들어보세요

채점 포인트

항목 배점 확인
http.post로 좋아요 핸들러를 더했다 5 :postId를 받아 그 게시물을 찾는가
누를 때마다 뒤집히고 개수가 따라 움직인다 6 두 번 눌러 원래대로 돌아오는 것까지
상태를 핸들러 바깥에 두고 판마다 되돌린다 8 안 하면 판 순서에 결과가 매인다
server.use()로 이 판에서만 500 을 만든다 4 목록을 통째로 안 고치고
서버가 보낸 사유가 손에 들어오는 것을 확인한다 2
합계 25

풀이 예시

핵심은 3번이에요. 먼저 잘못된 모양부터 보겠습니다.

TypeScript
// 이렇게 하면 판 순서에 결과가 매입니다
let attempts = 0;

http.post(`${MOCK_API_BASE}/posts/:postId/like`, ({ params }) => {
  attempts += 1;
  if (attempts % 5 === 0) {
    return HttpResponse.json(failure('좋아요를 저장하지 못했습니다'), { status: 500 });
  }
  ...
});

attempts가 파일이 열릴 때 한 번 만들어지고 계속 쌓입니다. 그러면 세 번째 판이 성공을 볼지 실패를 볼지가 앞 판들이 몇 번 눌렀느냐에 달려요. 판 하나를 지우면 다른 판이 깨집니다.

세는 곳을 밖에 두고 판마다 되돌립니다.

TypeScript
// apps/web-spa/scratch/f4-story-answer.ts
export const likeDb = {
  posts: [] as Post[],

  reset(): void {
    this.posts = allPosts.map((post) => ({ ...post }));
  },

  find(id: number): Post | undefined {
    return this.posts.find((post) => post.id === id);
  },
};

export function likeToggleHandler() {
  return http.post(`${MOCK_API_BASE}/posts/:postId/like`, ({ params }) => {
    const found = likeDb.find(Number(params.postId));

    if (found === undefined) {
      return HttpResponse.json(failure('게시물을 찾을 수 없습니다'), { status: 404 });
    }

    found.liked = !found.liked;
    found.likeCount += found.liked ? 1 : -1;

    return HttpResponse.json(ok({ id: found.id, liked: found.liked, likeCount: found.likeCount }));
  });
}
TypeScript
beforeEach(() => {
  likeDb.reset();
});

reset()에서 allPosts.map((post) => ({ ...post }))로 복사본을 만드는 것을 눈여겨보세요. 원본을 그대로 쓰면 판이 원본을 고쳐서 다른 판까지 오염됩니다.

확인한 결과입니다.

텍스트
  첫 번째 누름   liked: true  · likeCount: 1241
  두 번째 누름   liked: false · likeCount: 1240
  다음 판 첫 누름 liked: true  · likeCount: 1241    처음 상태로 돌아왔다

실패는 그 판에서만 만듭니다.

TypeScript
it('이 판에서만 500 을 말하게 하면 서버가 보낸 사유가 온다', async () => {
  server.use(
    http.post(`${MOCK_API_BASE}/posts/:postId/like`, () =>
      HttpResponse.json(failure('좋아요를 저장하지 못했습니다'), { status: 500 }),
    ),
  );

  await expect(likePost(1)).rejects.toThrow('좋아요를 저장하지 못했습니다');
});

"다섯 번에 한 번"이라는 규칙을 흉내 낼 필요조차 없어졌죠. 확인하고 싶은 것은 실패했을 때의 화면이지 실패가 몇 번째에 오느냐가 아니니까요.

자주 나오는 실수

allPosts를 직접 고칩니다. reset() 없이 원본 배열의 게시물을 뒤집으면 그 변화가 다른 판까지 따라갑니다. 좋아요를 안 누른 판이 갑자기 눌린 상태를 보게 돼요.

실패 규칙을 핸들러 안에 고정합니다. 위에서 본 그대로입니다. 판이 서로에게 영향을 주기 시작해요.

💡 튜터의 한마디

연습용 서버가 "다섯 번에 한 번 실패"로 만들어진 이유는 손으로 눌러볼 때 실패를 만나게 하려는 거였어요. 사람이 실패를 보려고 다섯 번 누르는 건 괜찮은데, 판에서는 정반대예요. 원하는 순간에 정확히 실패해야 하고, 원하지 않을 때는 절대 실패하면 안 됩니다. 같은 흉내 서버라도 사람이 쓸 때와 판이 쓸 때 필요한 성질이 다릅니다. 오늘 만든 목록이 두 곳에서 쓰인다는 것을 기억하면, 이 차이를 어디서 흡수할지 고민하게 될 거예요.

🎯 [과제 4 예시답안] 새어 나간 요청은 정말 진짜 서버까지 갈까요

채점 포인트

항목 배점 확인
fetchapi 인스턴스를 같은 조건에서 비교했다 7 서버 켠 채 · 핸들러 없음 · 'warn'
결과가 갈리는 것을 확인했다 5 한쪽은 닿고 한쪽은 못 닿는다
왜 갈리는지 이유를 찾았다 6 판이 도는 환경 · Axios가 쓰는 수단
서버를 껐을 때 무엇이 바뀌고 무엇이 안 바뀌는지 봤다 4
'error'를 쓰는 이유에 대해 결론을 냈다 3
합계 25

풀이 예시

TypeScript
const empty = setupServer();

beforeAll(() => empty.listen({ onUnhandledRequest: 'warn' }));
afterAll(() => empty.close());

핸들러가 하나도 없으니 무엇을 불러도 "목록에 없음"입니다. 그 상태에서 같은 주소를 세 가지 방법으로 불러봤어요.

연습용 서버를 켠 채로:

무엇으로 결과
fetch 200 · 14ms
XMLHttpRequest onerror
api 인스턴스 (Axios) 던짐 — 서버에 연결할 수 없어요

연습용 서버를 끈 채로:

무엇으로 결과
fetch TypeError: fetch failed ← 바뀜
api 인스턴스 서버에 연결할 수 없어요 ← 안 바뀜

왜 갈릴까요

판이 도는 곳은 진짜 브라우저가 아니라 브라우저를 흉내 낸 환경입니다. 그 환경은 XMLHttpRequest를 자기가 직접 구현해뒀는데, 브라우저 규칙을 따르느라 다른 주소로 나가는 요청을 막아요.

Axios는 브라우저처럼 보이는 곳에서는 fetch가 아니라 XMLHttpRequest를 쓰다 보니, 그렇게 부른 요청은 MSW를 지나 밖으로 나가긴 하지만 그 바로 다음 문턱에서 막히는 거예요.

정리하면 이렇게 두 층입니다.

텍스트
  우리 요청
      │
      
  MSW 목록 확인  ──── 없음 ── "내보낸다" 고 결정       여기까지가 MSW 의 몫
      │
      
  실제로 내보내는 수단
      ├── fetch  ────────────── 진짜 서버에 닿는다
      └── XMLHttpRequest ────── 환경이 막는다

'error'를 쓰는 이유는 강해집니다, 약해지지 않습니다

"어차피 Axios는 못 나가니까 'warn' 이어도 괜찮지 않나?"라고 생각할 수 있는데, 반대입니다.

  • 우리 앱이 전부 Axios를 쓰는 것은 지금 그렇다는 것뿐이에요. 누군가 fetch를 한 번 쓰는 순간 그 요청은 밖으로 나갑니다.
  • 못 나가는 쪽도 좋을 게 없어요. 판이 서버에 연결할 수 없어요로 깨지는데, 진짜 원인은 핸들러를 안 적은 것이거든요. 엉뚱한 곳을 들여다보게 됩니다.

'error'는 두 경우를 하나로 만들어줍니다. 원인을 그대로 말해주는 메시지 하나로요.

자주 나오는 실수

한 가지 방법으로만 재고 결론을 냅니다. fetch만 재면 "새어 나간다", Axios만 재면 "안 새어 나간다"가 되는데 둘 다 반쪽이에요.

연습용 서버가 정말 떠 있는지 확인 안 합니다. 껐다고 생각했는데 켜져 있거나 그 반대면 표가 통째로 뒤집힙니다. 실험 전에 그 주소를 한 번 직접 열어보세요.

💡 튜터의 한마디

이 과제에서 가장 중요한 것은 표가 아니라 "내가 잰 값이 어떤 조건에서 나온 것인지"를 적어두는 습관이에요. 같은 실험인데 요청 수단 하나로 결과가 뒤집혔죠. 조건을 안 적어두면 나중에 그 숫자를 본 사람이 정반대로 이해합니다. 성능이든 동작이든, 잰 값에는 늘 조건이 따라붙어야 해요.


생각해볼 주제 예시답안

🤔 [생각해볼 주제 1] 흉내 서버는 얼마나 진짜를 닮아야 할까요

문제 상황 요약

Step 6에서 흉내 서버에 세 줄을 더했더니 앱의 결함이 하나 나왔습니다. 더 닮게 만들면 더 나올까요? 어디까지 닮게 만들어야 할까요.

튜터의 가이드 및 해설

기준을 "그 판단이 화면의 갈래를 만드느냐"로 잡으면 대부분 정리됩니다.

우리가 더한 세 줄이 딱 그랬어요. 아는 아이디와 모르는 아이디는 화면이 다르게 반응해야 하니까 흉내 서버가 갈라줘야 합니다. 반대로 비밀번호를 해싱해서 맞춰보는 일은 화면에 아무 차이도 안 만들어요. 흉내 낼 이유가 없습니다.

이 기준으로 보면 흉내 서버에 넣을 것과 뺄 것이 이렇게 갈립니다.

넣는다 뺀다
상태 번호 (200 · 400 · 401 · 403 · 500) 실제 인증 알고리즘
응답의 모양과 봉투 데이터베이스 조회 방식
화면을 가르는 조건 (있음 · 없음 · 권한 없음) 성능 특성
사람이 읽는 오류 사유 로그 · 감사 기록

이 기준만으로는 부족한 자리가 있어요. 흉내 서버가 진짜와 다르게 말하는 것을 아무도 안 알려준다는 문제입니다.

닮게 만들수록 어긋날 여지도 늘어나요. 세 줄을 넣으면 그 세 줄이 진짜와 같은지도 관리해야 하니, 정직한 결론은 이렇습니다.

닮게 만드는 것과 어긋남을 잡는 것은 함께 가야 합니다. 어긋남을 잡을 방법이 없다면, 닮게 만들수록 오히려 위험해져요. 판은 점점 자신 있게 초록불을 켜는데 그 자신감의 근거가 우리 상상이니까요.

실무에서는 흉내 서버를 크게 만드는 대신 자주 대조하는 쪽으로 갑니다. 서버가 내보내는 규격 문서를 받아서 우리 목록과 맞춰보는 방식이 대표적이에요.

🎯 면접관을 홀리는 핵심 멘트

"모킹의 범위는 '얼마나 진짜 같은가'가 아니라 '화면의 갈래를 만드는가'로 정했습니다. 상태 번호와 응답 모양은 흉내 내고 인증 알고리즘은 흉내 내지 않는 식이죠. 다만 닮게 만들수록 진짜와 어긋날 여지도 함께 늘어나기 때문에, 모킹을 키우는 일과 계약을 대조하는 일을 항상 짝으로 뒀습니다. 대조할 방법이 없는 모킹은 자신감만 키우고 근거는 안 키웁니다."

🤔 [생각해볼 주제 2] 판이 전부 초록불인데 사용자는 못 쓰는 상황

문제 상황 요약

우리는 요청을 가로채 답을 정해줬고 화면은 그 답을 잘 그렸는데, 그 답이 진짜 서버와 한 글자라도 다르면 사용자 화면에서는 아무것도 안 될 수 있어요. Step 7에서 주소는 대조했지만 답의 모양은 대조하지 않았습니다.

튜터의 가이드 및 해설

이 문제를 풀 방법은 크게 셋인데, 비용과 잡아내는 범위가 전부 다릅니다.

하나 — 타입을 공유합니다. 서버가 내보내는 규격에서 타입을 만들어내고, 핸들러가 그 타입을 쓰게 하는 방법이에요. 규격이 바뀌면 우리 핸들러가 컴파일 단계에서 빨개집니다. 비용이 낮고 효과가 커서 가장 먼저 시도할 만한데, 한계도 분명합니다. 타입은 모양만 보고 값은 안 봅니다. 서버가 날짜를 "2026-08-20"으로 주는지 "2026-08-20T10:00:00"으로 주는지는 둘 다 string이라 안 걸려요.

둘 — 진짜 서버에 붙는 판을 아주 적게 둡니다. 로그인하고 피드를 받는 정도의 한 줄기만요. 값까지 진짜로 확인할 수 있는 유일한 방법인데, 서버가 꺼져 있으면 깨지고 느려서 평소 판과 섞지 않고 따로 돌립니다.

셋 — 실제로 오간 요청을 기록해서 핸들러로 만듭니다. 진짜 서버와 한 번 통신한 내용을 그대로 저장해두고 그다음부터 그것을 재생하는 방식이에요. 처음 만들 때는 정확한데, 서버가 바뀌면 다시 기록해야 하고 그걸 잊으면 원래 문제로 돌아옵니다.

여기서 판단 기준을 볼게요. 어긋남이 언제 드러나느냐로 저울질하세요.

텍스트
  어긋남이 배포 후에 드러난다        갖출 값이 있다
  어긋남이 개발 중에 곧 드러난다     안 갖춰도 된다

프런트와 백엔드가 같은 팀에서 매일 이야기한다면 어긋남이 몇 시간 안에 드러나니, 굳이 큰 장치를 만들 이유가 없어요. 반대로 서버가 다른 회사 것이고 배포 주기가 다르다면, 어긋남이 사용자 화면에서 처음 드러납니다. 그때는 셋 중 하나는 반드시 필요해요.

🎯 면접관을 홀리는 핵심 멘트

"모킹의 가장 큰 위험은 판이 실패하는 게 아니라 틀린 채로 성공하는 것이라고 봅니다. 그래서 저는 '이 어긋남이 언제 드러나는가'를 기준으로 투자를 정합니다. 같은 팀이 매일 붙어 있으면 몇 시간 안에 드러나니 타입 공유 정도로 충분하고, 서버가 외부 것이면 어긋남이 사용자 화면에서 처음 드러나니 진짜 서버에 붙는 얇은 판을 따로 둡니다. 모든 프로젝트에 같은 장치를 까는 게 아니라, 드러나는 시점을 앞으로 당기는 데 드는 비용을 비교하는 겁니다."

🤔 [생각해볼 주제 3] 개발용 도구가 앱 코드에 들어오는 것

문제 상황 요약

main.tsx에 흉내 서버를 켜는 코드가 한 덩어리 들어왔습니다. 배포본에는 안 들어가는 것을 확인했지만, 앱이 시작하는 파일에 앱과 상관없는 코드가 있는 것은 사실이에요.

튜터의 가이드 및 해설

먼저 사실 확인부터 하죠. 우리 main.tsx에는 이미 비슷한 것이 하나 더 있습니다. C-7에서 넣은 개발자 도구 창이요. 그것도 import.meta.env.DEV 조건 안에 있어요.

그러니 질문은 "허용할 것인가"가 아니라 "이런 게 몇 개까지 늘어나도 괜찮은가"에 가깝습니다.

섞이는 것을 받아들이는 쪽의 근거는 이렇습니다. 개발 도구는 앱이 시작하는 순간에 켜져야 하는 것이 많아요. 흉내 서버가 특히 그렇습니다. 첫 요청보다 먼저 켜져야 하니 시작 지점 말고는 켤 곳이 없어요. 억지로 떼어내면 오히려 순서가 꼬입니다.

떼어내는 쪽의 방법도 있어요. 시작 파일을 두 개 두는 겁니다. main.tsx는 앱만 켜고, main.dev.tsx가 흉내 서버를 켠 다음 main.tsx를 부르는 식인데, 개발 서버가 볼 파일을 후자로 바꿉니다.

대가는 이렇습니다.

섞어두기 떼어내기
앱 시작 파일 조건문이 늘어난다 깨끗해진다
시작 파일 개수 하나 둘 — 둘이 어긋날 수 있다
개발과 배포의 차이 조건 한 줄 진입점 자체가 다르다

마지막 줄이 핵심이에요. 떼어내면 개발할 때 도는 코드와 배포된 코드가 다른 파일에서 시작해서, "개발에서는 되는데 배포하면 안 돼요"가 생길 여지가 늘어나요. 조건 한 줄로 갈라놓으면 최소한 같은 파일을 지나갑니다.

그래서 저는 이렇게 정리합니다. 개수가 적을 때는 섞어두는 쪽이 낫고, 늘어나기 시작하면 파일 하나로 모읍니다. 시작 파일에 조건이 셋을 넘어가면 startDevTools() 같은 함수 하나로 묶어서 main.tsx에서는 한 줄만 보이게 하는 거죠. 진입점은 그대로 하나로 두고요.

🎯 면접관을 홀리는 핵심 멘트

"개발용 코드를 앱에서 떼어낼 때 가장 조심하는 건 진입점이 갈리는 것입니다. 파일을 나누면 시작 파일은 깨끗해지지만 개발과 배포가 서로 다른 경로로 시작하게 되고, '개발에서는 됐는데'가 생길 여지가 늘어납니다. 그래서 저는 진입점은 하나로 두고 조건으로만 가른 다음, 그 조건이 셋을 넘으면 함수 하나로 모아서 시작 파일에는 한 줄만 남기는 쪽을 택합니다. 깨끗함보다 개발과 배포가 같은 길을 지나는 게 중요하다고 봅니다."

전체 목록 리액트