[TS] BaseRequest 연구일지

Shimmylog·2025년 6월 11일

ISSUES

목록 보기
5/6
post-thumbnail

⚠️ 본 글은 BaseRequest를 도입하며 겪은 트러블슈팅 일기입니다.
결국, 도입에 실패했으므로 순수재미로 봐주세요.

BaseResponse는 익숙해

프로젝트를 하다보면 서버에서 dto를 한겹 더 감싸서 보내는 경우가 종종 있다. 아니 거의 그렇다.
이는 서버에서 커스텀한 message 나 errorCode 를 보내기 위함인데,

서버에서 다음과 같은 데이터가 온다면,

// AxiosResponse
{
  status: 200,
  data: {
    message: "조회 성공",
    data: { productId: 1, name: "맥북 프로" },
    errors: null
  },
  // 그 외 headers, config, request 등...
}

AxiosResponse의 data 부분을 이런 식으로 정의해 사용한다.

export type BaseResponse<T> = {
	data: T;
	message: string;
	errorCode: string;
}

// 사용할 때는 이런 식으로
export type GetProductResponse = BaseResponse<Product>;

// axios 함수
export const getProductInfo = async (
	productId: number;
): Promise<GetProductResponse> => {
  const response = await axios.get(PRODUCT_URL.INFO + `/${productId}`);
  return response.data;
};

익숙해~

BaseRequest는 왜 없을까?

HTTP Request에 담을 데이터는 크게 3가지로 나뉜다.

Path params: 요청할 리소스 경로
Query params: 쿼리스트링 파라미터
Request Body: 데이터 본문

가독성을 위해 API의 Request Type을 다음과 같이 정의했다.

export type PutProductRequest = {
  path: { productId: number };
  body: ProductFormdata;
};

// axios 함수
export const putProduct = async ({
  path,
  body,
}: PutProductRequest): Promise<PutProductResponse> => {
  const { productId } = path;
  const response = await axios.put(
    PRODUCT_URL.INFO + `/${productId}`,
    body,
  );
  return response.data;
};

// mutate 사용시..
const { mutate: editProductMutate } = useMutation(/* 생략... */);
editProductMutate({
  path: {productId},
  body: {formData},
});

그러다가 문득 든 생각,

path, query, body를 제네릭으로 받는 BaseRequest 인터페이스를 만들면 어떨까?

export interface BaseRequest<P = void, Q = void, B = void> {
  path: P;
  query: Q;
  body: B;
}

// API Request Type
export type PutProductRequest = BaseRequest<
  { productId: number },
  void,
  ProductFormdata
>;

// axios 함수는 변함 없다
export const putProduct = async ({
  path,
  body,
}: PutProductRequest): Promise<PutProductResponse> => {
  const { productId } = path;
  const response = await axios.put(
    PRODUCT_URL.INFO + `/${productId}`,
    body,
  );
  return response.data;
};

고난과 역경

뜻밖의 Type 에러

그러나 빨간줄을 마주했다.

BaseRequest 는 path, body, query를 모두 요하지만,
path, body만 있고 query가 없어서… 라는 듯.

다음의 해결책들로 헤쳐나가 보았다.

이게 맞나 싶은 해결책 1-a: 옵셔널

BaseRequest의 각 필드를 옵셔널로 지정해주어, undefined를 허용해본다.

export interface BaseRequest<P = void, Q = void, B = void> {
  path?: P extends void ? never : P;
  query?: Q extends void ? never : Q;
  body?: B extends void ? never : B;
}

여기는 해결됐다.
근데, 옵셔널로 지정하면 이제 axios 함수에서 path를 지정할 때 말썽이다.

논리상으로는 path가 undefined가 될 가능성이 없지만, 타입스크립트는

“이거 옵셔널이라 undefined일지도 몰라!! 제발 고쳐!!! 이대로 돌리면 타입에러가 날거야!! 꽥!!!”

라고 소리친다. 아유 호들갑!
따라서 타입 가드를 맥여주면 되긴 하는데….

이게 맞나 싶은 해결책 1-b: 타입 가드

export const putProduct = async ({
  path,
  body,
}: PutProductRequest): Promise<PutProductResponse> => {
  const productId = path?.productId ?? 1;
  const response = await axios.put(`${PRODUCT_URL.INFO}/${productId}`, body);
  return response.data;
};

말도 안되는 타입가드를 맥여줘야 한다.

설령 정말 path나 productId가 undefined가 된다면,
id=1을 보낸다는 게 완전 말도 안되는 일이다.

또한, 지금 타입스크립트가 path만 잡아내고, query나 body가 undefined인 경우는 잡아내지 못하는데,
claude 교수님에 의하면 다음의 위협이 존재한다.

  • query가 undefined? → axios에서 params가 undefined여도 정상 처리됨
  • body가 undefined? → undefined를 허용함으로써, postProduct({}) 와 같이 빈 body를 보내도 POST 요청 수행 → 런타임 혹은 서버에서 에러가 날 수도..

이게 맞나 싶은 해결책 2: undefined 명시

제네릭의 디폴트 type을 void에서 unknown으로 바꿔본다.

export interface BaseRequest<P = unknown, Q = unknown, B = unknown> {
  path: P;
  query: Q;
  body: B;
}

editProductMutate({
  path: { productId },
  query: undefined, // query가 없음을 명시하기
  body,
});

이제 query가 undefined임을 명시함으로써 해결됐다. 얏호~
근데 애초에 코드를 편하게 쓰려고 도입한건데 이게 대체 뭐하는건지.

왜 안쓰는지 알겠다.

결국, 2주간 실제 프로젝트에 도입해왔던 BaseRequest는 포기했다.
BaseRequest 도입의 단점을 정리하자면,

  • 타입 안정성을 강화하려다가 오히려 불안정해짐
  • 코드가 간결해질 줄 알았는데 더 복잡해짐 (근데 제네릭 이쁘긴했음..)
  • 옵셔널 필드와 undefined 명시의 딜레마
  • 잘못된 타입 가드로 인한 위험한 디폴트값(unknown) 설정
  • 런타임 에러 가능성을 줄이려다 오히려 늘어난 상황

쓰고나니 단점 투성이다.

하지만, 시도 자체는 TypeScript에 대한 이해가 약간이나마 오른 점에서 꽤 유익했다.
스타트업을 다니며 내가 하고싶은 기술을 연구하고, 실험하고, 도입하는 건 꽤 재미있다.

혹시 이런 구조를 도입해보신 분이 있다면,
어떻게 해결하셨는지 궁금합니다. 알려주세요...

profile
그래요 나 지금 거렁뱅이에요

3개의 댓글

comment-user-thumbnail
2025년 6월 11일

좌충우돌 개발일기

답글 달기
comment-user-thumbnail
2025년 6월 11일

하지만 시도해볼 법 했네요 🤔

1개의 답글