티스토리 뷰

728x90
SMALL

배경

예전부터 그랬지만 쿼리파람이 상태로 다뤄져야한다는 글들이 점점 많이 보이는 것 같다.(예전에도 관련한 글을 작성한적이 있다.) 최근에는 LLM의 SKILL도 많이 사용하게 되면서 정형화된 패턴을 문서로 작성하는게 필요해졌다. 이런 상황에서 내가 사용하는 패턴을 글로 작성하면 좋을 것 같다는 생각이 들었다.

 

기존 문제점

인 메모리 상태는 유저의 악의적인 상태 변경을 제어할 수 있다. 그런데 URI는 절대로 제어할 수 없다. 여기서 딜레마가 생긴다. 해결 방법은 네가지다.

1. 터트리기

경우에 따라서는 assert를 걸어서 타입을 확실하게 정의하고 그렇지 않다면 터트리는 것도 방법이다. 단, 나는 대부분의 경우에 이 방법을 지양한다. 유저의 실수로 인해 화면이 터지는건 좋은 방법이 아니라고 생각한다. 에러바운더리를 잡고 reset 버튼을 유도하는 것도 마찬가지다.

2. 잘못된 상태가 들어왔을 때 잘못된 쿼리파람 고치기

deprecated된 쿼리파람인 경우는 이 방법을 사용하는 것도 좋아보인다. pushstate가 아니라 replace를 사용해야한다는 것을 주의하자. 하지만 그냥 잘못된 쿼리파람을 고쳐주는건 좋은 방법인지 모르겠다. 가끔 잘못된 URI 공유로 인해 잘못된 쿼리파람을 가지고 들어오는 경우도 있는데 쿼리파람을 고쳐버리면 정확한 로그를 찍을 수 없다.(정확히 말하면 찍을수는 있지만 전 상태가 유실될 가능성이 너무 높다. 이걸 세부적으로 컨트롤하는 것도 굉장히 불편한 일이고) 너무 많이 이상한 쿼리파람이 찍힌다면 광고페이지에서 잘못된 URI를 넘겨준다던가 이런걸 유추할 수 있다.

3. 라우터단에서 쿼리파람을 typesafe하게 관리하기

tanstack router는 typesafe하게 쿼리파람을 관리한다. 내부적으로는 zod같은걸 이용하는 것 같다. 사실 next나 react-router를 사용해도 한번 래핑하면 typesafe하게 관리할 수 있다. 다만 이건 마이그레이션 비용이 좀 있다.

https://tanstack.com/router/v1/docs/guide/search-params#zod

import { zodValidator } from '@tanstack/zod-adapter'
import { z } from 'zod'

const productSearchSchema = z.object({
  page: z.number().default(1),
  filter: z.string().default(''),
  sort: z.enum(['newest', 'oldest', 'price']).default('newest'),
})

export const Route = createFileRoute('/shop/products/')({
  validateSearch: zodValidator(productSearchSchema),
})

 

4. 잘못된 쿼리파람 상태를 default 값으로 대체하기

나는 이 방법을 가장 선호한다. undefined인 경우는 그냥 ||이나 ?? 문법으로 default 값을 넣어주면 된다. 그런데 잘못된 값이 들어왔다면 특정 enum에 속하는지 확인하고 속하지 않는 경우만 default 값을 넣어주는 코드가 필요하다. 어렵지 않지만 모든 쿼리파람 관련 코드에 이런 코드를 넣는다면 너무 코드량이 많아진다. 이건 보일러플레이트에 가깝다. 추상화시킬 수 있다.

 

enum 유틸함수

그 전에..  enum에 대한 유용한 유틸함수부터 살펴보자.

enumIncludes

Array 인스턴스에는 includes라는 메서드가 존재한다. enum에도 이게 있으면 유용하지 않을까? 나는 다음과 같은 유틸함수를 자주 사용한다. 딱히 어려운 코드는 아니다. 그냥 제네릭과 is 용법을 이용해 typesafe하게 boolean을 return해주는 함수다.

/**
 * 주어진 enum 객체에 특정 값이 포함되어 있는지 확인합니다.
 * 타입 가드로 사용되어 item의 타입을 TEnumValue로 좁혀줍니다.
 *
 * @param enumVariable - 검사할 enum 객체
 * @param item - enum 값에 포함되어 있는지 확인할 값
 * @returns item이 enum 값 중 하나인지 여부 (타입 가드)
 *
 * @example
 * enum Color { Red = 'red', Blue = 'blue' }
 * const value = 'red';
 * if (enumIncludes(Color, value)) {
 *   // 이 블록 안에서 value는 Color 타입으로 취급됨
 * }
 */
export function enumIncludes<T extends string, TEnumValue extends string>(
  enumVariable: { [key in T]: TEnumValue },
  item: unknown
): item is TEnumValue {
  return Object.values(enumVariable).includes(item);
}

getValidEnumValue

위에서 만든 enumIncludes를 활용한 함수다. 유효한 enum이 아닐 때 placeholder를 return해준다.

/**
 * 주어진 값이 enum에 포함된 유효한 값인지 확인하고,
 * 유효한 경우 해당 값을 반환하고, 그렇지 않은 경우 기본값을 반환합니다.
 *
 * @param enumVariable - 검사할 enum 객체
 * @param item - 검증할 값
 * @param placeholder - item이 유효하지 않을 경우 반환할 기본값
 * @returns 유효한 enum 값 또는 기본값
 *
 * @example
 * const order = getValidEnumValue(ORDERS_MAP, searchParams.get(SEARCH_PARAMS_ORDER));
 * // order: OrderType | undefined
 * const order = getValidEnumValue(ORDERS_MAP, searchParams.get(SEARCH_PARAMS_ORDER), 'asc');
 * // order: OrderType
 */
export function getValidEnumValue<T extends string, TEnumValue extends string, P = undefined>(
  enumVariable: { [key in T]: TEnumValue },
  item: unknown,
  placeholder: P = undefined as P,
): TEnumValue | P {
  return enumIncludes(enumVariable, item) ? item : placeholder;
}

 

쿼리파람 다루는 방법 패턴화하기

이제 위에서 만든 유틸함수들을 이용해서 패턴화할 수 있다. 어떤 router를 사용하느냐에 따라 조금씩 다르지만 대부분의 라이브러리들은 사용법이 크게 다르지 않아서 react-router를 기준으로 적어보겠다. (애초에 웹에서 URLSearchParams라는 API를 제공하고 그걸 래핑한게 라이브러리들이기 때문에 사용법이 특별하게 다르지 않은게 당연하다.)

 

쿼리파람을 다루는 다양한 케이스가 있는데 tab을 다루는 경우로 생각해봤다.

나는 개인적으로 enum을 사용하지 않아서 as const 용법을 사용했는데 이건 각자 팀 컨벤션, 개인 취향에 맞게 사용해도 좋을 것 같다.

handleTabChange는 본인이 다루는 코드베이스에서 typesafe한 tab을 다룬다면 굳이 신경쓸필요없는 부분이다.

const TabValue = {
  all: 'all',
  foo: 'foo',
} as const;
type TabValue = (typeof TabValue)[keyof typeof TabValue];

const DEFAULT_TAB: TabValue = 'all';
const SEARCH_PARAMS_TAB = 'tab';

// 컴포넌트 내부
const [searchParams, setSearchParams] = useSearchParams();
const currentTab = getValidEnumValue(TabValue, searchParams.get(SEARCH_PARAMS_TAB), DEFAULT_TAB);

// value가 TabValue면 더 좋지만 Tab 컴포넌트의 onChange는 onChange value에 제네릭을 지원하지 않는 경우가 많다.
// 또한 잘못된 value가 들어오더라도 currentTab에서 fallback 처리를 하기 때문에 큰 문제는 없습니다.
const handleTabChange = (value: string) => {
    setSearchParams({ [SEARCH_PARAMS_TAB]: value });
};

 

 

SKILL로 만들기

사실 꼭 스킬이 아니여도 괜찮다. 나는 claude를 주로 사용하는데 CLAUDE.md에 넣어도 된다. 그런데 코드베이스가 커지면 자연스럽게 넣을 프롬프트가 길어진다. 그리고 이는 claude 팀에서도 권장하지 않는다. SKILL 적중율이 claude.md보다 낮다는 문제가 있지만.. 일단은 SKILL로 만들어보자.

 

tab은 하나의 예시일 뿐이라서 조금 더 일반적인 주석으로 변경했다. 그리고 지금은 쿼리파람을 상태로 관리하는 패턴만 가이드했는데 어떤 경우에 쿼리파람을 상태로 사용해야하는지는 적지 않았다. 지금 SKILL과는 성격이 다르다고 생각해서 의도적으로 적지 않은 것이다. 만약 AI가 쿼리파람을 적절한 경우에 사용하지 못한다고 생각한다면 상태관리에 대한 SKILL을 만들고 그 내부에서 지금 만든 SKILL을 호출하는 걸 만들 수도 있다.

 

`SKILL.md`

---
name: query-params
description: URL Search Params로 상태를 관리하는 패턴 가이드
---

# URL Search Params 패턴

URL search params로 상태를 관리할 때 사용하는 패턴입니다.
`getValidEnumValue` 유틸 함수를 활용하여 잘못된 값에 대한 fallback 처리를 합니다.

## Import

```ts
import { getValidEnumValue } from '@/shared/utils/enum';
import { useSearchParams } from 'react-router';
```

## 기본 패턴

### 1. Enum 객체 및 타입 정의

```ts
const TabValue = {
  all: 'all',
  foo: 'foo',
} as const;
type TabValue = (typeof TabValue)[keyof typeof TabValue];

const DEFAULT_TAB: TabValue = 'all';
const SEARCH_PARAMS_TAB = 'tab';
```

### 2. 컴포넌트에서 사용

```tsx
const [searchParams, setSearchParams] = useSearchParams();
const currentTab = getValidEnumValue(TabValue, searchParams.get(SEARCH_PARAMS_TAB), DEFAULT_TAB);

// 기본적으로 value 타입을 맞춰서 사용
const handleTabChange = (value: TabValue) => {
  setSearchParams({ [SEARCH_PARAMS_TAB]: value });
};

// 외부 컴포넌트가 제네릭을 지원하지 않아 string만 받는 경우에만 string 사용
// 읽을 때 getValidEnumValue로 검증하므로 잘못된 값이 들어와도 fallback 처리됨
const handleTabChange = (value: string) => {
  setSearchParams({ [SEARCH_PARAMS_TAB]: value });
};
```

## 핵심 포인트

- `getValidEnumValue`는 유효하지 않은 값이 들어오면 기본값(placeholder)을 반환
- URL에서 읽은 값이 enum에 없는 경우 자동으로 fallback 처리됨
- onChange 핸들러의 value 타입은 기본적으로 enum 타입을 사용하고, 외부 컴포넌트가 제네릭을 지원하지 않는 경우에만 `string`을 허용

 

후기

사실 SKILL을 만드는것자체는 크게 어렵지 않다. 이미 내 방법론에 대한 것들이 정형화 되어있고 문서화되어있다면 그냥 적당히 다듬으면 된다. 진짜 문제는 팀에서 사용하는 정형화된 패턴이 없는 것이다. 어떤 팀에서는 빡빡하게 컨벤션을 가져가고, 어떤 팀에서는 높은 자율성을 기반으로 코드를 작성한다. 정답은 없지만 이런 AI 시대에는 어느정도 컨벤션을 만들고 이걸 AI에게 넘겨줄 컨텍스트를 만들고 이 컨텍스트를 유지보수하는게 필요할지도 모르겠다.

728x90
LIST
댓글
공지사항