
⚠️ 본 글은 BaseRequest를 도입하며 겪은 트러블슈팅 일기입니다.
결국, 도입에 실패했으므로 순수재미로 봐주세요.
프로젝트를 하다보면 서버에서 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;
};
익숙해~
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;
};

그러나 빨간줄을 마주했다.
BaseRequest 는 path, body, query를 모두 요하지만,
path, body만 있고 query가 없어서… 라는 듯.
다음의 해결책들로 헤쳐나가 보았다.
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일지도 몰라!! 제발 고쳐!!! 이대로 돌리면 타입에러가 날거야!! 꽥!!!”
라고 소리친다. 아유 호들갑!
따라서 타입 가드를 맥여주면 되긴 하는데….
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 교수님에 의하면 다음의 위협이 존재한다.
undefined? → axios에서 params가 undefined여도 정상 처리됨undefined? → undefined를 허용함으로써, postProduct({}) 와 같이 빈 body를 보내도 POST 요청 수행 → 런타임 혹은 서버에서 에러가 날 수도..제네릭의 디폴트 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에 대한 이해가 약간이나마 오른 점에서 꽤 유익했다.
스타트업을 다니며 내가 하고싶은 기술을 연구하고, 실험하고, 도입하는 건 꽤 재미있다.
혹시 이런 구조를 도입해보신 분이 있다면,
어떻게 해결하셨는지 궁금합니다. 알려주세요...
좌충우돌 개발일기