타입 안전한 API 모킹으로 프론트엔드 생산성 높이기

4 weeks ago 6

프론트엔드 개발에서는 API 응답 모킹이 필수입니다. 그러나 JSON 파일 기반 모킹은 API 변경을 감지하기 어렵고, 렌더링 오류를 유발할 수 있습니다. 주문웹프론트개발팀(배달의민족 장바구니부터 결제 이후 화면까지의 주문 도메인 웹프론트 개발을 담당하는 팀)은 이 문제를 해결하기 위해 JSON 대신 TypeScript로 모킹 파일을 관리하는 방식을 도입했습니다. 이 문서에서는 그 배경, 방법, 개선 효과, 한계를 소개합니다.

1. 기존 JSON 모킹 방식의 한계

주문웹프론트개발팀은 API 응답을 모킹하기 위해 MSW(Mock Service Worker)와 MSG(Mock Service GUI)를 사용했습니다. 각 시나리오별 네트워크 응답은 JSON 파일로 관리했습니다.

MSG란?

MSG(Mock Service GUI)는 우아한형제들 내부에서 사용하는 프론트엔드용 API 모킹 제어 라이브러리입니다. MSW의 내부 클래스를 오버라이드하여, 시나리오별 목 데이터를 관리하고 브라우저 UI에서 실시간으로 적용할 수 있도록 지원합니다.

아래는 JSON 파일로 API 응답을 모킹하고, 이를 MSW 핸들러에 연결하는 예시입니다.

// src/mocks/ordersheet/responses/fetchOrdersheet_200.json export default { "status": "SUCCESS", "message": "성공", "serverDatetime": "2024-05-14 17:19:57", "data": { "orderNumber": "123456", // ... 나머지 데이터 ... } } // src/mocks/ordersheet/handlers.ts import { extendHandlers, http } from 'mock-service-gui'; import { HttpResponse } from 'msw'; import fetchOrdersheet_200 from '@/mocks/ordersheet/responses/fetchOrdersheet_200.json'; const handlers = [ // DOCS: 주문서 조회 http .get('*/ordersheet/:id', () => HttpResponse.json(fetchOrdersheet_200)) .presets( { label: '200 - 주문서 조회 성공', status: 200, response: fetchOrdersheet_200, }, ];

이 방식의 가장 큰 문제는 API 스펙 변경에 대응하기 어렵다는 점입니다. 서버 응답 모델이 변경되면 모킹 파일도 함께 수정해야 하지만, 이를 놓치면 렌더링 오류가 발생하고 디버깅이 복잡해집니다.
개발자는 모킹 데이터를 실제로 적용해 보기 전까지 문제를 미리 알기 어려워, 렌더링 이슈를 뒤늦게 발견하는 경우가 많았습니다.

예를 들어, 주문서 API에서 다음과 같이 필드명이 변경된다고 가정해보겠습니다.

{ "status": "SUCCESS", "message": "성공", "data": { - "orderNumber": "123456", + "orderNo": "123456", // ... 나머지 데이터 ... } }

이 프로젝트에서는 다양한 시나리오별로 10개가 넘는 JSON 모킹 파일을 관리하고 있었습니다.

src/mocks/ordersheet/responses/ ├── fetchOrdersheet_200.json # 기본 성공 케이스 ├── fetchOrdersheet_guest_200.json # 비회원 ├── fetchOrdersheet_membership_200.json # 멤버십 할인 ├── fetchOrdersheet_meetPay_200.json # 만나서 결제 ├── fetchOrdersheet_withAlcohol_200.json # 주류 포함 └── ... 기타 다양한 시나리오 파일들

서버 응답 모델이 변경될 때마다 모든 JSON 파일을 열어 일일이 수정해야 했습니다.
단 하나라도 수정이 누락되면 해당 시나리오에서 렌더링 오류가 발생해, 예상치 못한 디버깅 작업이 뒤따랐습니다.
결국, 이런 반복적인 수작업은 시간을 많이 소모하고 오류 가능성이 높은 작업이었습니다.

2. 타입 안전한(type-safe) TypeScript 모킹으로 개선

2.1 개선 방법: JSON → TypeScript 전환

문제를 해결하기 위해 JSON 파일을 TypeScript 파일로 전환하고, 서버 응답 모델에 as 타입 단언을 적용해 타입 불일치를 명확히 감지할 수 있도록 개선했습니다.

// src/mocks/ordersheet/responses/fetchOrdersheet_200.ts import { DeliveryModel, ServiceType } from 'common/constants/delivery'; import { ServerResponse } from '@/models/ServerResponse'; import { FetchOrdersheetResponse } from '@/models/ordersheet/responses/FetchOrdersheetResponse'; export default { status: 'SUCCESS', message: '성공', serverDatetime: '2024-05-14 17:19:57', data: { orderNumber: '123456', // ... 나머지 데이터 ... }, } as ServerResponse<FetchOrdersheetResponse>;

2.2 as 타입 단언을 선택한 이유

완전한 타입 체크보다 as 타입 단언을 사용한 이유는 개발 생산성과 타입 안전성의 균형을 위해서입니다.

  1. 네트워크 응답을 그대로 복사해서 붙여넣을 수 있는 편의성 유지
  2. 필드 누락이나 추가 같은 구조적 변경은 타입 체크로 검증 가능
  3. enum이나 union 타입 값이 정확하지 않더라도, 런타임에서는 문자열로 정상 동작해 실행 오류 방지

as 타입 단언을 사용하면 TypeScript는 객체의 구조(필드 유무)는 검증하지만, 각 필드의 세부 타입(enum, union 값 등)은 검사하지 않습니다.
이는 TypeScript의 구조적 타입 검사(Structural Typing) 원칙에 따른 것으로, 객체가 요구하는 필드를 모두 갖추면 내부 값 오류는 컴파일러가 무시합니다.

2.3 JSON만으로는 해결할 수 없는 한계

여기서 한 가지 궁금증이 생길 수 있습니다.

"TypeScript가 JSON 파일의 구조를 추론할 수 있는데 굳이 TypeScript 파일로 전환할 필요가 있을까?"

"JSON을 가져와서 as 타입 단언을 적용하면 타입 검증도 가능하지 않을까?"

실제로 TypeScript 설정의 resolveJsonModule 옵션을 사용해 JSON 파일을 import하고, as 타입 단언을 적용해 타입 안정성을 확보하는 것도 가능합니다.

그러나 주문웹프론트개발팀이 JSON 대신 TypeScript 파일로 전환한 이유는 단순한 타입 검증만을 위한 것이 아니었습니다.

핵심 목적은 목 데이터 관리의 복잡도를 줄이는 것이었습니다.

JSON 파일을 계속 추가하면 파일 수가 빠르게 증가하고, API 변경이 발생할 때마다 모든 파일을 수동으로 수정해야 하는 부담이 커집니다.

한편, TypeScript 파일로 관리하면 공통 응답을 베이스 파일로 정의한 뒤 시나리오별로 필요한 부분만 수정하는 방식을 사용할 수 있습니다.

이런 배경에서 주문웹프론트개발팀은 공통 응답을 기반으로 다양한 시나리오를 유연하게 구성할 수 있는 방식에 주목했고, 코드 구조에 반영했습니다.

3. 베이스 파일 + 시나리오별 변형 전략

3.1 베이스 파일 패턴

“매번 JSON을 복사해서 만드는 대신, 공통 구조를 재사용할 수는 없을까?”

이런 고민에서 출발해, 주문웹프론트개발팀은 ‘베이스 파일 + 시나리오’ 패턴을 도입했습니다.

이 방식은 공통 응답 데이터를 하나의 베이스 파일로 정의하고, 시나리오에 따라 필요한 부분만 수정해 재사용하는 구조입니다. TypeScript와 immer를 활용하면 깊은 중첩 객체도 타입 안전성을 유지하면서 효율적으로 수정할 수 있어, API 변경에 대한 대응력을 크게 높일 수 있습니다.

이렇게 하면 중복을 줄이고, 목 데이터를 더 효율적이고 안정적으로 관리할 수 있습니다.

도입 방식은 다음과 같습니다.

  1. 하나의 베이스 TypeScript 모킹 파일을 생성
  2. 다른 시나리오는 베이스 파일을 import해 필요한 부분만 수정

예를 들어, API에서 orderNumber가 orderNo로 변경되었다고 가정해보겠습니다.

❌ 기존 방식 (JSON)

// 기존 방식: 10개 이상의 JSON 파일을 모두 수정해야 함 { "status": "SUCCESS", "data": { "orderNo": "123456",  // orderNumber → orderNo // ... 다른 필드들 ... } }

✅ 개선된 방식 (TypeScript + 베이스 파일)

// 베이스 파일: fetchOrdersheet_200.ts export default { status: 'SUCCESS', message: '성공', data: { orderNo: '123456', // 필드명 변경 적용 // ... 다른 필드들 ... } } as ServerResponse; // 시나리오 파일: fetchOrdersheet_guest_200.ts import baseResponse from './fetchOrdersheet_200'; import { produce } from 'immer'; export default produce(baseResponse, (draft) => { draft.data.isLoggedIn = false; // orderNo는 베이스에서 그대로 사용 });

베이스 파일과 시나리오 파일 구조로 목 데이터를 구성하면 API 스펙이 변경되었을 때 베이스 파일만 수정하면 모든 시나리오에 변경 사항이 적용됩니다.

도입 초기에는 “목 데이터를 이렇게까지 구조화해야 할까?“라는 의견도 있었지만,
실제로 운영해 보니, 단순한 복사/붙여넣기 방식보다 유지보수성과 확장성이 크게 향상되었습니다. 특히 API 스펙 변경 시 이전에는 10개 이상의 파일을 일일이 수정해야 했던 작업이, 이제는 베이스 파일 하나만 수정하면 되어 변경 적용 시간이 대폭 줄었습니다. 또한, 시나리오 추가 작업도 기존 전체 응답을 복사하는 대신 필요한 부분만 오버라이드하면 되므로 작업 속도가 빨라졌습니다.

또한, immer를 활용하면 중첩 객체도 간결하게 수정할 수 있어,
복잡한 구조를 다루는 데서 오는 코드 가독성과 안정성도 함께 확보할 수 있었습니다.

3.2 immer 사용의 장점

주문웹프론트개발팀이 관리하는 목 데이터는 중첩 깊이가 깊은 객체가 많았습니다.
immer를 사용하지 않았다면, 아래처럼 spread 연산자를 여러 번 중첩해 사용해야 했습니다.

// immer 없이 spread 연산자로 처리할 경우 export default { ...fetchOrdersheet_200, data: { ...fetchOrdersheet_200.data, serviceType: ServiceType.BMART, component: { ...fetchOrdersheet_200.data.component, snackbar: { ...fetchOrdersheet_200.data.component.snackbar, qcTrialSnackbar: { target: true, remainingSeconds: 10, limitMinutes: 30, message: '지금부터 고객님은 $B마트 배달팁 무료!$', warningMessage: '$B마트 배달팁 무료$ 시간이 끝나가요!', } } } } };

이런 방식은 가독성이 떨어지고, 실수할 가능성도 높습니다.
중첩 구조가 깊은 객체에서는 immer를 사용해 코드를 간결하게 작성하고, 의도를 더 명확하게 표현할 수 있습니다.

3.3 immer 사용의 단점

immer의 produce 함수 내부에서는 as 타입 단언을 사용할 수 없어, 엄격한 타입 체크가 적용됩니다.
이로 인해 enum이나 union 타입 값을 문자열로 직접 입력할 수 없고, 반드시 enum을 import해 사용해야 합니다.

import { ServiceType } from 'common/constants/delivery'; produce(fetchOrdersheet_200, (draft) => { // 문자열이 아닌 enum을 import해서 사용해야 함 draft.data.serviceType = ServiceType.BMART; });

주문웹프론트개발팀은 추가 코드를 작성하는 번거로움이 있더라도, 깊은 중첩 객체를 직접 수정하는 복잡함을 줄일 수 있다는 점에서 immer를 사용하는 것이 더 나은 선택이라고 판단했습니다.

4. 개선 효과

베이스 파일 + 시나리오 패턴을 도입한 후, 여러 측면에서 개발 경험이 개선되었습니다.

  • 타입 검증 단계에서 API 변경을 감지할 수 있게 되었습니다.
    서버 API가 변경되면 타입 오류로 즉시 인지할 수 있어, 흰 화면 디버깅 시간이 크게 줄었습니다.
  • 새로운 시나리오 모킹 데이터 작성 시간이 단축되었습니다.
    새 시나리오가 필요할 때 베이스 파일을 수정하는 것만으로 대응할 수 있게 되었습니다.
  • 코드 가독성이 향상되었습니다.
    각 시나리오 파일의 변경 사항이 명확히 드러나, 정책과 로직 파악이 쉬워졌습니다.

개인적으로는 마지막 코드 가독성 향상이 특히 인상적이었습니다.
‘어떤 필드를 변경해야 스낵바가 출력되는지’ 같은 의문도 목 데이터 파일 하나로 빠르게 확인할 수 있어, 정책과 코드 이해가 훨씬 수월해졌습니다.

5. 한계와 고려사항

물론 몇 가지 한계도 있습니다.

  • 필요한 데이터를 직접 import해야 합니다.
    다만, WebStorm과 같은 IDE의 자동 import 기능 덕분에 큰 불편은 느끼지 않았습니다.
  • 시나리오별 변경점이 많으면 베이스 파일을 재활용하기 어렵습니다.
    이 경우에는 기존 JSON 방식처럼, 아예 새로운 파일을 생성하는 편이 낫습니다.
  • API 스펙이 대규모로 변경되면 수동 작업이 필요합니다.
    베이스 파일뿐 아니라 이를 참조하는 모든 시나리오 파일도 함께 수정해야 합니다.

결론

JSON 대신 TypeScript를 사용해 API를 모킹해보니, 단순한 도구 변경을 넘어 개발 효율성과 유지보수성 모두에서 큰 차이를 느낄 수 있었습니다.

컴파일러가 타입 검증을 도와주면서 API 변경을 놓치지 않게 되었고, 베이스 파일 + 시나리오 패턴을 통해 코드 품질과 생산성도 자연스럽게 따라올 수 있었습니다.

특히 as 타입 단언을 활용한 느슨한 타입 체크 덕분에, 개발 편의성과 구조적 안정성 사이에서 좋은 균형을 만들 수 있었습니다.

물론, 이 방식이 모든 상황에 꼭 맞는 해결책은 아닐 수 있습니다. 하지만 주문웹프론트개발팀에서는 실제로 개발환경의 ‘흰 화면 이슈’를 크게 줄이는 데 도움이 되었고, 목 데이터 관리 부담도 훨씬 가벼워졌습니다.

목 데이터 관리나 API 변경 대응에 고민이 있다면, 소개한 방법을 한번 시도해 보시길 바랍니다!

Read Entire Article