백엔드와 프론트엔드가 같은 언어를 쓰면 참 좋겠지만, 현실은 그렇지 않은 경우가 많다. 백엔드는 Python을 사용하고 snake_case를 선호한다. 프론트엔드는 TypeScript를 사용하고 camelCase를 선호한다. 둘 다 각자의 생태계에서는 자연스러운 선택이지만 API로 요청과 응답을 주고 받을 때는 case와 관련한 문제가 생긴다.
처음에는 크게 문제라고 생각하지 않았다. 요청을 보낼 때는 camelCase를 snake_case로 바꾸고, 응답을 받을 때는 snake_case를 camelCase로 바꾸면 됐다. 하지만 비슷한 변환 코드가 React Query 훅마다 하나씩 생기면서 중복 코드를 제거하기 위한 방법을 생각해보게 됐다.
기존 방식: 훅마다 직접 변환하기
처음에는 각 API 훅 내부에서 요청 전후로 case 변환을 처리했다.
예시 코드
import ky from 'ky';
import { useMutation, useQuery } from '@tanstack/react-query';
import { camelToSnake, snakeToCamel } from './case';
type CreateUserInput = {
userName: string;
phoneNumber: string;
};
type UserResponse = {
userId: number;
userName: string;
phoneNumber: string;
};
export function useCreateUser() {
return useMutation({
mutationFn: async (input: CreateUserInput) => {
const response = await ky
.post('/api/users', {
json: camelToSnake(input),
})
.json();
return snakeToCamel(response) as UserResponse;
},
});
}
export function useUser(userId: number) {
return useQuery({
queryKey: ['user', userId],
queryFn: async () => {
const response = await ky.get(`/api/users/${userId}`).json();
return snakeToCamel(response) as UserResponse;
},
});
}
TypeScript
복사
처음에는 이 방식도 나쁘지 않아 보인다. 변환 위치가 명확하고, 훅 하나만 보면 데이터가 어떻게 오고 가는지 바로 보인다. 문제는 API가 늘어날수록 같은 코드가 계속 반복된다는 점이다.
그리고 또 다른 문제는 변환 로직이 비즈니스 로직을 오염시킨다는 것이었다.
문제: 변환 로직이 비즈니스 로직을 오염시킨다
React Query 훅은 본래 서버 상태를 가져오고 캐싱하고 동기화하는 역할에 집중해야 한다. 그런데 case 변환 로직이 훅마다 들어가면 훅의 관심사가 흐려진다.
export function useUpdateProfile() {
return useMutation({
mutationFn: async (input: UpdateProfileInput) => {
const body = camelToSnake(input);
const response = await ky
.put('/api/profile', {
json: body,
})
.json();
const result = snakeToCamel(response) as Profile;
return result;
},
});
}
TypeScript
복사
이 코드는 기능적으로는 문제가 없다. 하지만 훅의 핵심은 “프로필을 수정한다”인데, 코드의 상당 부분은 “케이스를 변환한다”에 쓰이고 있다. API가 많아질수록 이 반복은 더 커진다.
더 큰 문제는 일관성이다. 어떤 훅은 요청만 변환한다. 어떤 훅은 응답만 변환한다. 어떤 훅은 둘 다 변환한다. 이렇게 되면 개발자 본인이 case change를 까먹으면 코드 내에서 타입 에러나 런타임 에러가 발생할 수 있다.
그래서 비즈니스 로직에서 변환 로직을 신경쓸 필요가 없도록 코드를 수정하려 시도해봤다.
1차 개선: ky instance에서 변환하기
먼저 떠올릴 수 있는 방식은 ky instance를 확장하는 것이었다. ky는 hooks를 제공하므로 요청 전에 body를 변환하거나, 응답 후 데이터를 변환하는 처리를 공통화할 수 있다.
다만 ky의 afterResponse에서 응답 JSON을 직접 바꿔 반환하는 방식은 조심해야 한다. Response 객체는 한 번 body를 읽으면 다시 읽을 수 없기 때문이다. 따라서 변환된 JSON을 다시 Response로 감싸서 반환하는 식으로 처리할 수 있다.
import ky from 'ky';
export const api = ky.create({
prefixUrl: '/api',
hooks: {
beforeRequest: [
async (request) => {
if (request.method === 'GET') {
return;
}
const contentType = request.headers.get('content-type');
if (!contentType?.includes('application/json')) {
return;
}
const body = await request.clone().json();
const convertedBody = camelToSnake(body);
return new Request(request, {
body: JSON.stringify(convertedBody),
});
},
],
afterResponse: [
async (_request, _options, response) => {
const contentType = response.headers.get('content-type');
if (!contentType?.includes('application/json')) {
return response;
}
const data = await response.clone().json();
const convertedData = snakeToCamel(data);
return new Response(JSON.stringify(convertedData), {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
},
],
},
});
TypeScript
복사
이제 훅에서는 변환 로직을 제거할 수 있다.
export function useUser(userId: number) {
return useQuery({
queryKey: ['user', userId],
queryFn: () => api.get(`users/${userId}`).json<UserResponse>(),
});
}
export function useCreateUser() {
return useMutation({
mutationFn: (input: CreateUserInput) =>
api.post('users', { json: input }).json<UserResponse>(),
});
}
TypeScript
복사
훨씬 낫다. 훅은 다시 본래의 역할로 돌아왔다. 요청 데이터는 평소처럼 camelCase로 작성하면 되고, 응답도 camelCase로 받는다고 생각하면 된다.
하지만 여기에도 아쉬운 점이 있다. ky의 hooks는 모든 요청에 대해 동작하므로 예외 처리가 필요하다. FormData, 파일 업로드, 외부 API, 이미 snake_case로 보내야 하는 특수 요청 등은 공통 변환에서 제외해야 한다. 또한 ky.get, ky.post, ky.put 등을 사용할 때마다 .json<T>()를 붙이는 형태는 그대로 남아 있다.
그래서 다른 방법을 생각해봤다.
개선된 방식: Proxy로 API 클라이언트 간편화하기
이전 단계에서 벗어나 Proxy를 사용하는 방법을 생각해봤다. Proxy를 통해 기존 인터페이스나 로직을 변경하지 않고 횡단 관심사를 추가할 수 있다는 걸 떠올렸다. 기존 코드의 하위호환성을 지키거나 라이브러리를 변경할 때 적용하면 좋을 것 같아 기억해두고 있었는데, 이 케이스에 맞는 것 같아 적용해 보았다.
내가 원하는 방식
api.get<UserResponse>('users/1');
api.post<UserResponse>('users', {
userName: 'Roseline',
phoneNumber: '010-0000-0000',
});
TypeScript
복사
이렇게 호출하면 내부에서 자동으로 요청은 snake_case로 바꾸고, 응답은 camelCase로 바꾼다. 사용하는 쪽에서는 case 변환을 신경 쓰지 않는다.
먼저 타입을 정의해보자.
import type { Options } from 'ky';
type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete';
type ApiClient = {
[K in HttpMethod]: <TResponse>(
url: string,
dataOrOptions?: unknown,
options?: Options,
) => Promise<TResponse>;
};
TypeScript
복사
그리고 ky instance를 만든다.
import ky from 'ky';
const kyInstance = ky.create({
prefixUrl: '/api',
timeout: 30_000,
});
TypeScript
복사
이제 Proxy를 사용해 메서드 호출을 가로챈다.
export const api = new Proxy({} as ApiClient, {
get(_target, method: HttpMethod) {
return async <TResponse>(
url: string,
dataOrOptions?: unknown,
options?: Options,
): Promise<TResponse> => {
const isGetLikeMethod = method === 'get' || method === 'delete';
const requestOptions: Options = isGetLikeMethod
? {
...((dataOrOptions as Options) ?? {}),
}
: {
...options,
json: camelToSnake(dataOrOptions),
};
const response = await kyInstance[method](url, requestOptions).json();
return snakeToCamel(response) as TResponse;
};
},
});
TypeScript
복사
get 외에도 put, post, delete 등의 다른 메서드에도 적용하면 된다.
이렇게 하면 React Query 훅에서는 더 이상 case change에 대해서는 생각하지 않아도 된다.
type User = {
userId: number;
userName: string;
phoneNumber: string;
};
type CreateUserInput = {
userName: string;
phoneNumber: string;
};
export function useUser(userId: number) {
return useQuery({
queryKey: ['user', userId],
queryFn: () => api.get<User>(`users/${userId}`),
});
}
export function useCreateUser() {
return useMutation({
mutationFn: (input: CreateUserInput) =>
api.post<User>('users', input),
});
}
TypeScript
복사
훅에서 사라진 것은 단순히 camelToSnake, snakeToCamel 호출 몇 줄이 아니다. “이 API는 어떤 case를 쓰는가?”라는 고민 자체가 사라지게 됐다. 프론트엔드 코드는 계속 프론트엔드답게 camelCase를 사용한다. 백엔드와 통신하는 경계에서만 snake_case로 바뀐다.
이전 방식 vs 개선된 방식
이전 방식에서는 각 훅이 API 요청, 응답 처리, case 변환까지 모두 책임졌다. 코드가 명시적이라는 장점은 있었지만, API가 늘어날수록 반복이 커졌고 누락 가능성도 함께 커졌다. 특히 변환 로직이 훅 내부에 섞이면서 비즈니스 로직의 가독성이 떨어졌다.
개선된 방식에서는 case 변환을 API 클라이언트의 책임으로 이동시켰다. React Query 훅은 “무엇을 요청하고 무엇을 반환받는가”에만 집중한다. 요청 데이터는 자동으로 snake_case로 변환되고, 응답 데이터는 자동으로 camelCase로 변환된다. 덕분에 훅의 코드가 짧아지고, 변환 누락 가능성도 줄어든다.
// 이전 방식
const response = await ky
.post('/api/users', {
json: camelToSnake(input),
})
.json();
return snakeToCamel(response) as User;
TypeScript
복사
// 개선된 방식
return api.post<User>('users', input);
TypeScript
복사
코드가 짧아졌다는 것은 단순한 미관 문제가 아니다. 반복이 줄어들면 실수할 가능성이 줄어들게 된다.
또 하나의 장점: 타입 안전성은 어떻게 유지할까
런타임 변환과 타입 변환은 다르다. snakeToCamel(response) as User는 실제 데이터를 변환하지만, TypeScript가 그 변환 과정을 완벽하게 검증해주는 것은 아니다. 결국 응답 타입은 API 스펙과 맞춰 관리해야 한다.
이제는 가장 단순한 방식으로, 프론트엔드에서는 변환 이후의 타입만 정의하면 된다.
type User = {
userId: number;
userName: string;
phoneNumber: string;
};
TypeScript
복사
백엔드 응답은 실제로 다음과 같이 내려올 수 있다.
{
"user_id": 1,
"user_name": "현지",
"phone_number": "010-0000-0000"
}
TypeScript
복사
하지만 API 클라이언트를 통과한 뒤 프론트엔드 코드에서는 다음 형태로 다룬다.
const user = await api.get<User>('users/1');
console.log(user.userName);
TypeScript
복사
즉, Proxy로 개선하면서 프론트엔드 전체에서 하나의 컨벤션만 유지할 수 있게 됐다. 컴포넌트, 훅, store, form 모두 camelCase만 사용한다. snake_case는 API boundary 바깥으로 새어 나오지 않는다.
조금 더 엄격하게 가고 싶다면 Zod 같은 런타임 스키마 검증을 함께 사용할 수 있다.
import { z } from 'zod';
const userSchema = z.object({
userId: z.number(),
userName: z.string(),
phoneNumber: z.string(),
});
export async function getUser(userId: number) {
const data = await api.get<unknown>(`users/${userId}`);
return userSchema.parse(data);
}
TypeScript
복사
이렇게 하면 case 변환 이후 데이터가 실제로 기대한 형태인지 런타임에서 검증할 수 있다. API 스펙이 자주 바뀌거나, 백엔드 응답의 신뢰도가 낮은 환경에서는 꽤 유용하다. 물론 모든 API에 스키마를 붙이면 코드량이 늘어난다. 안전성과 생산성 사이에서 팀 상황에 맞게 선택해야 한다.
주의할 점: 모든 요청을 무조건 변환하면 안 된다
자동화는 편하지만, 자동화가 너무 열심히 일하면 사고가 난다. 특히 다음 경우는 case 변환에서 제외하는 것이 좋다.
FormData를 사용하는 파일 업로드는 JSON 변환 대상이 아니다.
const formData = new FormData();
formData.append('file', file);
await kyInstance.post('files', {
body: formData,
});
TypeScript
복사
이런 요청에 camelToSnake를 적용하면 FormData 구조가 깨질 수 있다. 따라서 wrapper에서 FormData, Blob, ArrayBuffer 같은 타입은 그대로 통과시켜야 한다.
function shouldTransformBody(value: unknown) {
if (!value) return false;
if (value instanceof FormData) return false;
if (value instanceof Blob) return false;
if (value instanceof ArrayBuffer) return false;
return typeof value === 'object';
}
TypeScript
복사
그리고 wrapper에 반영한다.
const requestOptions: Options = isGetLikeMethod
? {
...((dataOrOptions as Options) ?? {}),
}
: {
...options,
json: shouldTransformBody(dataOrOptions)
? camelToSnake(dataOrOptions)
: dataOrOptions,
};
TypeScript
복사
또한 외부 API를 호출하는 경우에도 자동 변환이 오히려 문제가 될 수 있다. 외부 API가 camelCase를 요구하거나, 특정 필드명을 그대로 유지해야 한다면 별도의 ky instance를 사용하는 편이 안전하다.
마무리
처음에는 훅 안에서 직접 camelToSnake, snakeToCamel을 호출하는 방식으로 충분해 보였다. 하지만 API가 늘어나면서 변환 로직은 반복되고, 누락 가능성은 커지고, 훅의 가독성은 떨어졌다.
이 문제를 해결하기 위해 case 변환을 API boundary로 옮겼다. ky instance와 Proxy wrapper를 사용하면 React Query 훅은 서버 상태 관리에만 집중할 수 있다. 프론트엔드는 계속 camelCase를 사용하고, 백엔드와 통신하는 순간에만 snake_case로 변환한다.
결국 핵심은 “변환을 어디서 할 것인가”다. 각 훅에서 변환하면 모든 개발자가 매번 신경 써야 한다. API 클라이언트에서 변환하면 한 번만 신경 쓰면 된다. 개발에서 좋은 추상화란 마법이 아니라, 반복되는 귀찮음을 한 곳에 몰아넣는 일에 가깝다. 그리고 귀찮음은 한 곳에 모아두면 관리 대상이 되지만, 여기저기 흩어지면 버그가 된다.
_(1).jpeg&blockId=0e552736-74f0-4f5a-89e1-328d4931ca7c)