Refine useCustom config.query가 정수를 보장하지 않는 함정 — 타입은 number인데 왜 400이야?
📚 React 프론트엔드 삽질기 시리즈 (9편)
Refine의 useCustom hook에서 config.query 객체에 number 타입 값을 전달해도, URL 쿼리 파라미터로 직렬화되면서 문자열이 된다. NestJS @Type(() => Number) 검증과 조합하면 targetClassId must be an integer number 400 에러가 터진다. URL 직접 삽입 패턴으로 해결한 실전 사례를 정리한다.
💡 Tip. 바쁜 현대인들을 위한 본문 요약
- Refine
useCustom의config.query객체는 모든 값을 문자열로 직렬화한다- TypeScript에서
number타입으로 넘겨도 URL 쿼리스트링에서는"5"(문자열)가 된다- NestJS의
@IsInt()+@Type(() => Number)검증은 쿼리스트링 문자열을 정수로 변환하지만, Refine 내부 직렬화 방식에 따라 실패할 수 있다- 해결법:
config.query대신 URL에 쿼리 파라미터를 직접 삽입하면 깔끔하게 해결된다- FE에서 “타입이 맞는데 왜 에러?”라면 Network 탭에서 실제 전송 URL을 먼저 확인해야 한다
🔍 증상 — 반 이동 미리보기가 400으로 실패한다
학생 관리 페이지에서 반 이동 미리보기 기능을 만들고 있었다. 드롭다운에서 이동할 반을 선택하면, 커리큘럼 변경 사항을 미리 보여주는 API를 호출한다.
BE API는 이렇게 생겼다.
GET /students/:id/class-transfer-preview?targetClassId=5
FE에서는 Refine의 useCustom hook으로 호출했다.
TypeScript 타입도 number로 잡아놨다.
그런데 미리보기 버튼을 누르면 매번 400 Bad Request가 날아왔다.
{
"statusCode": 400,
"message": ["targetClassId must be an integer number"],
"error": "Bad Request"
}
“아니, 분명 number 타입인데 왜 정수가 아니라고?”
Chrome DevTools Console에서 값을 찍어봤다.
typeof targetClassId는 확실히 "number".
값도 5다. 소수점도 없다.
그런데 BE는 계속 거부했다.
🔬 원인 — 쿼리스트링은 항상 문자열이다

Chrome DevTools Network 탭을 열어서 실제 요청 URL을 확인했다.
GET /api/v1/academy/students/abc-123/class-transfer-preview?targetClassId=5
URL만 보면 정상이다. 하지만 HTTP 쿼리스트링의 본질을 떠올려보자.
쿼리스트링의 진실
URL의 쿼리 파라미터는 항상 문자열이다.
?targetClassId=5에서 5는 숫자가 아니라 문자열 "5"다.
보통은 이게 문제가 안 된다.
NestJS의 class-transformer가 @Type(() => Number)를 보고 "5" → 5로 변환해주니까.
// BE DTO — 정상적이라면 "5" → 5 변환됨
export class ClassTransferPreviewQueryDto {
@ApiProperty({ example: 2 })
@IsInt()
@Type(() => Number)
targetClassId: number;
}
그런데 문제는 Refine의 useCustom가 config.query 객체를 URL 쿼리스트링으로 직렬화하는 방식에 있었다.
Refine config.query의 직렬화 함정
Refine의 useCustom에서 config.query를 사용하면 내부적으로 쿼리 파라미터를 직렬화한다.
이 과정에서 값의 원래 타입 정보가 사라진다.
// FE 코드 — 문제의 원인
const { data: previewData } = useCustom<Response>({
url: `/students/${student?.id}/class-transfer-preview`,
method: "get",
config: {
query: {
targetClassId: targetClassId as number, // TypeScript: number
},
},
queryOptions: { enabled: false },
});
targetClassId가 number 타입이어도, config.query 객체가 URL 파라미터로 변환될 때 모든 값이 URLSearchParams를 거치면서 문자열이 된다.
여기까지는 일반적인 HTTP 동작이다.
그런데 Refine 내부에서 URLSearchParams.append()를 호출할 때, 값을 String()으로 한 번 더 감싸는 경우가 있다.
이때 BE의 class-transformer 파이프라인에서 타입 변환이 기대와 다르게 동작하면 문제가 생긴다.
특히 Refine 버전, dataProvider 구현, NestJS ValidationPipe 설정의 조합에 따라 결과가 달라진다.
문제의 흐름 정리

FE: targetClassId = 5 (number)
↓ config.query에 전달
Refine 내부: URLSearchParams.append("targetClassId", "5")
↓ HTTP 요청
URL: ?targetClassId=5
↓ NestJS 수신
QueryDto: targetClassId = "5" (string)
↓ class-transformer @Type(() => Number)
변환 시도... 실패? → 400 Bad Request
class-transformer의 @Type(() => Number)는 일반적으로 "5" → 5 변환을 잘 해준다.
하지만 ValidationPipe의 transform 옵션, enableImplicitConversion 설정, 그리고 NestJS 버전별 동작 차이에 따라 쿼리 파라미터의 타입 변환이 실패할 수 있다.
내 프로젝트에서는 ValidationPipe의 설정 조합 때문에 config.query로 전달된 값이 정수 검증을 통과하지 못했다.
🛠️ 해결 — URL에 직접 파라미터 삽입
해결은 놀라울 정도로 간단했다.
config.query를 쓰지 않고, URL에 쿼리 파라미터를 직접 넣으면 된다.
❌ Before — config.query 사용 (400 에러)
// config.query가 내부적으로 직렬화하면서 BE 검증 실패
const { data: previewData } = useCustom<ClassTransferPreviewResponse>({
url: `/students/${student?.id}/class-transfer-preview`,
method: "get",
config: {
query: { targetClassId: targetClassId as number }, // ❌ 문자열 직렬화 함정
},
queryOptions: { enabled: false },
});
✅ After — URL 직접 삽입 (정상 동작)
// URL 템플릿 리터럴로 직접 파라미터 삽입
const { data: previewData } = useCustom<ClassTransferPreviewResponse>({
url: `/students/${student?.id}/class-transfer-preview?targetClassId=${targetClassId}`,
method: "get",
queryOptions: { enabled: false },
});
이렇게 하면 Refine의 내부 직렬화를 거치지 않고, dataProvider의 custom 메서드가 URL을 그대로 사용한다.
dataProvider에서 URL을 처리하는 방식
프로젝트의 rest-data-provider.ts에서 custom 메서드를 보면 이해가 된다.
// rest-data-provider.ts — custom 메서드
custom: async (params) => {
const { url, method = "GET", payload, headers } = params;
// url이 /로 시작하면 baseUrl에 붙임
const separator = url.startsWith("/") ? "" : "/";
const fullUrl = url.startsWith("http")
? url
: `${API_BASE_URL}${separator}${url}`;
const httpMethod = method.toUpperCase(); // 항상 대문자!
const bodyStr = payload ? JSON.stringify(payload) : undefined;
const response = await httpClient(fullUrl, {
method: httpMethod,
body: bodyStr,
headers: headers as HeadersInit,
});
const json = await response.json();
return {
data: json.success !== undefined ? json.data : json,
};
},
URL에 이미 ?targetClassId=5가 포함되어 있으면, dataProvider는 그대로 fetch에 전달한다.
Refine의 내부 직렬화를 건너뛰기 때문에, BE의 class-transformer가 정상적으로 "5" → 5 변환을 수행한다.
같은 패턴이 여러 곳에 적용됨
이 교훈을 알고 나니, 프로젝트의 다른 useCustom 호출도 눈에 들어왔다.
학생 상세 페이지의 학습 진도 API도 config.query를 쓰고 있었다.
// 학생 상세 — config.query 사용 (잠재적 위험)
const { data: progressData } = useCustom({
url: `/students/${id}/learning-progress`,
method: "get",
config: { query: { period: progressPeriod } }, // period는 문자열이라 괜찮음
});
period는 원래 문자열("7d", "30d")이라 문제가 안 된다.
하지만 정수 파라미터가 필요한 API에서는 config.query가 언제든 함정이 될 수 있다.
// ⚠️ 정수 파라미터가 필요한 경우 — config.query 피할 것
// 학습 기록 페이지네이션
const { data: historyData } = useCustom({
url: `/students/${id}/learning-history`,
method: "get",
config: {
query: { page: 1, pageSize: 10 }, // ⚠️ 정수 → 문자열 변환 위험
},
});
// ✅ 안전한 방식
const { data: historyData } = useCustom({
url: `/students/${id}/learning-history?page=1&pageSize=10`,
method: "get",
});
🔬 깊이 파기 — 왜 BE에서 변환이 실패하는 걸까?

“class-transformer가 "5" → 5 변환을 해주는 거 아니야?” 하고 넘어가기 전에, 한 단계 더 파보자.
NestJS ValidationPipe 설정에 따른 차이
// main.ts — ValidationPipe 설정
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true, // ← 이게 핵심
forbidNonWhitelisted: true,
transformOptions: {
enableImplicitConversion: false, // ← 이것도 중요
},
}),
);
transform: true가 설정되어 있으면, class-transformer가 DTO의 @Type 데코레이터를 보고 타입 변환을 시도한다.
하지만 enableImplicitConversion: false(기본값)일 때는, 명시적 @Type 데코레이터 없이는 변환하지 않는다.
그리고 여기서 미묘한 차이가 생긴다.
쿼리 파라미터 vs Body 파라미터
// Body 파라미터 — JSON.parse()가 타입을 보존함
POST /api/students
Content-Type: application/json
{ "classId": 5 } // ← JSON 숫자 → 실제 number
// 쿼리 파라미터 — 항상 문자열
GET /api/students?classId=5 // ← URL 인코딩 → 항상 string
JSON Body는 JSON.parse()가 5를 number로 파싱한다.
쿼리 파라미터는 Express/Fastify가 "5"를 string으로 전달한다.
@Type(() => Number)가 "5" → 5로 잘 변환해주는 게 보통이다.
하지만 Refine의 config.query 직렬화가 중간에 한 단계 더 거치면서, 값의 형태가 예상과 달라질 수 있다.
실제로 무슨 일이 벌어지나
Refine 내부에서 config.query를 처리할 때:
query객체의 각 키-값 쌍을 순회URLSearchParams에.append(key, String(value))- 최종 URL에 쿼리스트링으로 추가
이 과정 자체는 표준적이다.
문제는 Refine이 config.query를 처리한 후 dataProvider.custom()에 전달하는 방식이다.
// Refine 내부 (간소화)
const queryString = new URLSearchParams(config.query).toString();
const urlWithQuery = `${url}?${queryString}`;
// → dataProvider.custom({ url: urlWithQuery, ... })
이때 dataProvider의 custom 메서드가 URL을 어떻게 조립하느냐에 따라 결과가 달라진다.
우리 프로젝트의 rest-data-provider는 url을 API_BASE_URL에 이어붙이는데, 이 과정에서 URL 인코딩이 한 번 더 적용될 수 있다.
결론: 정수 파라미터가 필요한 API에서는 config.query를 피하는 게 안전하다.
🛡️ 예방 — useCustom 쿼리 파라미터 가이드라인

이 경험 이후로 팀 내부에서 useCustom 사용 규칙을 정했다.
1. 파라미터 타입별 전략
| 파라미터 타입 | 권장 방식 | 이유 |
|---|---|---|
| 문자열 | config.query 사용 가능 | 원래 문자열이라 변환 이슈 없음 |
| 정수/숫자 | URL 직접 삽입 | 직렬화 과정에서 타입 손실 방지 |
| boolean | URL 직접 삽입 | "true" 문자열 vs true boolean |
| 배열 | URL 직접 삽입 | 직렬화 방식이 BE 기대와 다를 수 있음 |
2. 디버깅 체크리스트
BE에서 400 에러가 돌아올 때 확인할 순서:
1. Chrome DevTools → Network 탭 열기
2. 실제 요청 URL에서 쿼리스트링 확인
3. BE DTO의 @Type, @IsInt 등 데코레이터 확인
4. ValidationPipe의 transform, enableImplicitConversion 설정 확인
5. config.query 사용 중이라면 → URL 직접 삽입으로 변경 테스트
3. 유틸리티 함수 패턴
반복되는 쿼리 파라미터 구성을 위한 헬퍼도 만들 수 있다.
// utils/query.ts
export function buildQueryString(params: Record<string, string | number | boolean>): string {
const entries = Object.entries(params)
.filter(([, v]) => v !== undefined && v !== null && v !== "")
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
return entries.length > 0 ? `?${entries.join("&")}` : "";
}
// 사용 예
const qs = buildQueryString({ targetClassId: 5, includeDeleted: false });
// → "?targetClassId=5&includeDeleted=false"
useCustom<Response>({
url: `/students/${id}/class-transfer-preview${qs}`,
method: "get",
});
이렇게 하면 타입 안전성과 가독성을 모두 잡을 수 있다.
4. BE 방어 코드 — enableImplicitConversion 고려
BE에서도 방어 코드를 넣을 수 있다.
// ValidationPipe에 enableImplicitConversion 추가
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
transformOptions: {
enableImplicitConversion: true, // ← 암시적 변환 허용
},
}),
);
enableImplicitConversion: true로 설정하면, @Type 데코레이터 없이도 TypeScript 타입 정보를 기반으로 자동 변환한다.
하지만 이건 양날의 검이다.
의도치 않은 타입 변환이 발생할 수 있어서, 명시적 @Type + FE URL 직접 삽입이 더 안전한 조합이다.
🔁 반 이동 다이얼로그 — 전체 구현 맥락
위 수정이 적용된 실제 컴포넌트 코드를 보자. 반 이동은 3단계 플로우로 동작한다: 반 선택 → 미리보기 → 이동 확인.
// class-transfer-dialog.tsx — 핵심 부분
export function ClassTransferDialog({
open, onOpenChange, student, classes, onSuccess
}: Props) {
const [targetClassId, setTargetClassId] = useState<number | "">("");
const [step, setStep] = useState<"select" | "preview" | "done">("select");
// ✅ URL 직접 삽입 — config.query 대신
const {
data: previewData,
isLoading: isLoadingPreview,
refetch: fetchPreview,
} = useCustom<ClassTransferPreviewResponse>({
url: `/students/${student?.id}/class-transfer-preview?targetClassId=${targetClassId}`,
method: "get",
queryOptions: { enabled: false }, // 수동 호출
});
// 반 이동 PATCH 실행
const { mutate: updateStudent } = useUpdate();
const handlePreview = async () => {
if (!targetClassId) return;
setStep("preview");
fetchPreview(); // 미리보기 API 수동 호출
};
const handleTransfer = () => {
if (!student || !targetClassId) return;
updateStudent(
{
resource: "students",
id: student.id,
values: { classId: targetClassId as number },
},
{ onSuccess: () => { setStep("done"); onSuccess(); } },
);
};
// ... 렌더링 로직
}
핵심은 useCustom의 url 파라미터에 ?targetClassId=${targetClassId}를 직접 넣은 것이다.
config.query를 완전히 제거했다.
그리고 queryOptions: { enabled: false }로 설정해서, 컴포넌트 마운트 시 자동 호출을 방지하고 fetchPreview()로 수동 호출한다.
BE DTO — 정수 검증 구조
// academy-student.dto.ts
export class ClassTransferPreviewQueryDto {
@ApiProperty({ example: 2, description: '이동할 대상 반 ID' })
@IsInt() // 정수 검증
@Type(() => Number) // 문자열 → 숫자 변환
targetClassId: number;
}
// academy-student.controller.ts
@Get(':id/class-transfer-preview')
@ApiQuery({ name: 'targetClassId', type: Number })
async getClassTransferPreview(
@Param('id') id: string,
@Query() query: ClassTransferPreviewQueryDto, // ← DTO로 검증
): Promise<ClassTransferPreviewResponseDto> {
return this.studentService.getClassTransferPreview(
id, payload.academyId, query.targetClassId,
);
}
@Query() query: ClassTransferPreviewQueryDto로 받으면, NestJS ValidationPipe가 DTO의 데코레이터를 기반으로 검증 + 변환을 수행한다.
URL 직접 삽입으로 보내면 이 파이프라인이 정상 동작한다.
config.query를 거치면 가끔 실패한다.
같은 "5"인데 결과가 다른 이유는, Refine 내부 직렬화가 만드는 미묘한 차이 때문이다.
📋 정리 — 한눈에 보는 핵심
| 상황 | 안티패턴 | 권장 패턴 |
|---|---|---|
| 정수 쿼리 파라미터 | config.query: { id: 5 } | URL 직접: `?id=${5}` |
| 문자열 파라미터 | config.query 사용 가능 | 둘 다 OK |
| boolean 파라미터 | config.query: { flag: true } | URL 직접: `?flag=true` |
| 400 디버깅 | console.log만 확인 | Network 탭 실제 URL 확인 |
핵심 교훈 3줄
- TypeScript의 타입과 HTTP의 타입은 다르다.
number라고 해도 URL에서는 문자열이다. - Refine
config.query는 편하지만 만능이 아니다. 정수/boolean은 URL 직접 삽입이 안전하다. - FE 400 에러의 첫 번째 디버깅은 Network 탭이다. 코드 레벨이 아니라 실제 HTTP 요청을 봐야 한다.
TypeScript가 타입을 보장해준다고 안심하면 안 된다. HTTP 경계를 넘는 순간, 모든 게 문자열이다. 그 경계에서 무슨 일이 벌어지는지 이해하면, 이런 종류의 삽질은 확실히 줄어든다 ✨
📚 React 프론트엔드 삽질기 시리즈 (9편)
- 1. Vite 6.x 프록시에서 PATCH만 CORS 에러? 소문자 메서드 함정과 해결법
- 2. React Admin DataProvider 커스터마이징 삽질기
- 3. BE 응답 래퍼 언래핑 패턴 — API 200인데 왜 에러?
- 4. React useEffect 비동기 cleanup이 GPU를 죽이는 과정 — Pixi.js RenderTexture 실종 사건
- 5. shadcn init 실행했더니 프라이머리 컬러가 검정으로 — CSS 변수 덮어쓰기 트러블슈팅
- 6. Framer Motion whileInView 애니메이션이 스크린샷에서 사라지는 이유와 해결법
- 7. react-hook-form + Zod 연동에서 겪는 실전 함정 6가지 — 에러가 안 뜨는 이유부터 타입 불일치까지
- 8. Refine useCustom config.query가 정수를 보장하지 않는 함정 — 타입은 number인데 왜 400이야?
- 9. 패키지 설치 후 Invalid hook call? Vite 캐시 무효화가 답이다