Refine useCustom config.query가 정수를 보장하지 않는 함정 — 타입은 number인데 왜 400이야?

Refine의 useCustom hook에서 config.query 객체에 number 타입 값을 전달해도, URL 쿼리 파라미터로 직렬화되면서 문자열이 된다. NestJS @Type(() => Number) 검증과 조합하면 targetClassId must be an integer number 400 에러가 터진다. URL 직접 삽입 패턴으로 해결한 실전 사례를 정리한다.


💡 Tip. 바쁜 현대인들을 위한 본문 요약

  • Refine useCustomconfig.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는 계속 거부했다.


🔬 원인 — 쿼리스트링은 항상 문자열이다

console.log를 또 추가하며 🔬 원인 — 쿼리스트링은 항상 문자열이다 추적 중
console.log를 또 추가하며 🔬 원인 — 쿼리스트링은 항상 문자열이다 추적 중

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의 useCustomconfig.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 },
});

targetClassIdnumber 타입이어도, config.query 객체가 URL 파라미터로 변환될 때 모든 값이 URLSearchParams를 거치면서 문자열이 된다.

여기까지는 일반적인 HTTP 동작이다. 그런데 Refine 내부에서 URLSearchParams.append()를 호출할 때, 값을 String()으로 한 번 더 감싸는 경우가 있다.

이때 BE의 class-transformer 파이프라인에서 타입 변환이 기대와 다르게 동작하면 문제가 생긴다.

특히 Refine 버전, dataProvider 구현, NestJS ValidationPipe 설정의 조합에 따라 결과가 달라진다.

문제의 흐름 정리

직접 정리한 config.query 직렬화와 URL 직접 삽입 비교 흐름도
직접 정리한 config.query 직렬화와 URL 직접 삽입 비교 흐름도

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 변환을 잘 해준다. 하지만 ValidationPipetransform 옵션, 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의 내부 직렬화를 거치지 않고, dataProvidercustom 메서드가 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에서 변환이 실패하는 걸까?

🔬 깊이 파기 — 왜 BE에서 변환이 실패하는 걸까?에서 허탈한 실수를 발견한 순간
🔬 깊이 파기 — 왜 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()5number로 파싱한다. 쿼리 파라미터는 Express/Fastify가 "5"string으로 전달한다.

@Type(() => Number)"5"5로 잘 변환해주는 게 보통이다. 하지만 Refine의 config.query 직렬화가 중간에 한 단계 더 거치면서, 값의 형태가 예상과 달라질 수 있다.

실제로 무슨 일이 벌어지나

Refine 내부에서 config.query를 처리할 때:

  1. query 객체의 각 키-값 쌍을 순회
  2. URLSearchParams.append(key, String(value))
  3. 최종 URL에 쿼리스트링으로 추가

이 과정 자체는 표준적이다. 문제는 Refine이 config.query를 처리한 후 dataProvider.custom()에 전달하는 방식이다.

// Refine 내부 (간소화)
const queryString = new URLSearchParams(config.query).toString();
const urlWithQuery = `${url}?${queryString}`;
// → dataProvider.custom({ url: urlWithQuery, ... })

이때 dataProvidercustom 메서드가 URL을 어떻게 조립하느냐에 따라 결과가 달라진다. 우리 프로젝트의 rest-data-providerurlAPI_BASE_URL에 이어붙이는데, 이 과정에서 URL 인코딩이 한 번 더 적용될 수 있다.

결론: 정수 파라미터가 필요한 API에서는 config.query를 피하는 게 안전하다.


🛡️ 예방 — useCustom 쿼리 파라미터 가이드라인

다시는 🛡️ 예방 — useCustom 쿼리 파라미터 가이드라인 실수를 반복하지 않겠다는 다짐
다시는 🛡️ 예방 — useCustom 쿼리 파라미터 가이드라인 실수를 반복하지 않겠다는 다짐

이 경험 이후로 팀 내부에서 useCustom 사용 규칙을 정했다.

1. 파라미터 타입별 전략

파라미터 타입권장 방식이유
문자열config.query 사용 가능원래 문자열이라 변환 이슈 없음
정수/숫자URL 직접 삽입직렬화 과정에서 타입 손실 방지
booleanURL 직접 삽입"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(); } },
    );
  };
  
  // ... 렌더링 로직
}

핵심은 useCustomurl 파라미터에 ?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줄

  1. TypeScript의 타입과 HTTP의 타입은 다르다. number라고 해도 URL에서는 문자열이다.
  2. Refine config.query는 편하지만 만능이 아니다. 정수/boolean은 URL 직접 삽입이 안전하다.
  3. FE 400 에러의 첫 번째 디버깅은 Network 탭이다. 코드 레벨이 아니라 실제 HTTP 요청을 봐야 한다.

TypeScript가 타입을 보장해준다고 안심하면 안 된다. HTTP 경계를 넘는 순간, 모든 게 문자열이다. 그 경계에서 무슨 일이 벌어지는지 이해하면, 이런 종류의 삽질은 확실히 줄어든다 ✨