URL information
URL information?
이 의미는 URL 하나의 정보로 페이지를 탐색할 수 있다.
즉, URL 하나로 search 기능을 한다.
history의 Location의 타입을 보면 대표적으로 pathname, search, state, hash를 볼 수 있다.history는 페이지 stack 관리로 SPA가 가능하게 끔 해주는 유용한 인터페이스다.
export interface Location<S = LocationState> {
pathname: Pathname;
search: Search;
state: S;
hash: Hash;
key?: LocationKey | undefined;
}
// /id/2#ss?sort=L&page=3
{
pathname: '/id/2',
search: '?sort=L&page=3',
hash: '#ss',
state: { a: '1', b: '2', c: 'true', d: date, e: 'id', f: ['1', '23'] },
},
우리는 이것을 리액트 같은 SPA 라이브러리에서 많이 사용하고 있다. 대표적인 라이브러리는 react-router
가 있다. 이 라이브러리를 통해 페이지 라우터를 정의를 해주고, 페이지에서 다른 페이지 이동시 <Link to="/b">처럼 URL 링크를 입력하게 되면, 서버에 다시 html 요청하지 않고 원하는 부분만 렌더링이 되는 것을 볼 수 있었다.
Problem
1. 하드코딩 스타일
<Link to="/b"> 컴포넌트의 to
props 정의에서 하드코딩 스타일
을 쉽게 마주 할 수 있다.
<Link to="/issue">이슈 페이지 이동</Link>
<Link to="/issue?page=1">이슈 페이지 이동</Link>
<Link to="/issue?page=2">이슈 페이지 이동</Link>
페이지가 많지 않을 때는 상관이 없지만, 페이지가 늘어남에 따라 유지보수성은 떨어진다고 판단이 된다. path 정보가 바뀌거나, query가 늘어나거나, query의 key 명이 바뀔 때, 에디터 혹은 IDE검색을 통해서 바꾼다는 것은 위험할 수 있기 때문이다.
특히, 새로운 유저가 프로젝트에 합류하게 되었을 때, query, param, state 같은 정보 명세가 없으면 함부로 다루기가 어려워진다고 판단이 된다. (실제로 경험한 부분)
처음에 이 부분을 해결하고자, 페이지 링크를 변수로 만들고 사용해 보았다.
url.ts
const ISSUE = '/issue';
Apage.tsx
<Link to={ISSUE}>이슈 페이지 이동</Link>
<Link to={`${ISSUE}?page=1`}>이슈 페이지 이동</Link>
<Link to={`${ISSUE}?page=2`}>이슈 페이지 이동</Link>
여기서 페이지마다 URL을 리턴해주는 함수를 만들어 보았다.
url.ts
export interface IssueParam {
id: string;
}
export interface IssueQuery {
page: string;
}
const ISSUE_READ = {
path: '/issue/:id',
url: ({ id }: IssueParam, { page }: IssueQuery) => {
return `${urlParamReplace(ISSUE.path, {
id,
})}?page=${page}`;
},
}
Apage.tsx
<Link to={ISSUE_READ.url({ id : '1' }, { page: '1' })}>이슈 페이지 이동</Link>
2. 타입 지정이 늘어남
여기서 단점은 페이지가 늘어남에 따라 ~Param, ~Query 타입들을 작성해줘야 하는 불편함이 생기게 된다.
또한, react-router에서 제공하는 useParams 같이 파싱 훅스를 사용하여 param 혹은 query 값을 가져올 때도 타입을 지정해 줘야 타입 힌트를 얻을 수 있다.
만약, 타입을 지정하지 않게 되면, url.ts에서 수정을 하더라도, 타입 컴파일 오류가 일어나지 않게 되어 페이지 관리가 힘들어질 수가 있다.
Apage.tsx
const { id } = useParams<IssueParam>();
const { id } = useParams<{ id: string }>();
const { id } = useParams();
이런 식으로 코드 스타일이 다양하기 때문에 코드 리뷰를 하지 않는 이상 파악하기 힘든 부분이다.
3. 타입 파싱
파싱 또한 문제가 발생된다. param, query를 추출하면 무조건 string 값으로 리턴이 되어 사용자는 항상 원하는 타입을 위해 새로 파싱 하게 된다.
Apage.tsx
const { id } = useParams<IssueParam>();
const info = useReadIssue({ id: id ? parseInt(id, 10) : 0 });
이러한 문제들로 컴포넌트 페이지 폴더당 pageFilter.ts를 만들어 페이지 관리를 하게 되었다.
import qs, { ParsedUrlQuery } from 'querystring';
import cloneDeep from 'lodash/cloneDeep';
import Router from 'next/router';
import { Page } from '@typings/type';
const PATH_NAME = '/';
export interface Query {
page: number;
pageSize: number;
hashtag: string;
mode: ViewMode;
}
export default class PageFilter implements Page {
pathname: string;
query: Query;
static defaultOption = {
DEFAULT_CUR_PAGE: 1,
DEFAULT_PER_PAGE: 10,
DEFAULT_MODE: 'infinite',
};
static parseQuery(query?: ParsedUrlQuery | Query) {
const mode = (query?.mode as ViewMode) || PageFilter.defaultOption.DEFAULT_MODE;
const page =
Number(query?.page) && mode !== 'infinite' ? Number(query?.page) : PageFilter.defaultOption.DEFAULT_CUR_PAGE;
const pageSize = Number(query?.pageSize) || PageFilter.defaultOption.DEFAULT_PER_PAGE;
const hashtag = query?.hashtag ? decodeURIComponent(query.hashtag as string) : '';
return {
mode,
page,
pageSize,
hashtag,
};
}
constructor(query?: ParsedUrlQuery | Query) {
this.pathname = PATH_NAME;
this.query = PageFilter.parseQuery(query);
}
get queryString() {
const { page, pageSize, hashtag, mode } = this.query;
return `?${qs.stringify({ page, pageSize, hashtag: encodeURIComponent(hashtag), mode })}`;
}
get url() {
return `${this.pathname}${this.queryString}`;
}
private replaceQuery({ page, pageSize, hashtag, mode }: Partial<Query>) {
const query = cloneDeep(this.query);
if (page) query.page = page;
if (pageSize) query.pageSize = pageSize;
if (hashtag !== undefined) query.hashtag = hashtag;
if (mode) query.mode = mode;
return query;
}
search(query?: Partial<Query>) {
const searchQuery = query ? this.replaceQuery(query) : this.query;
Router.push({
pathname: this.pathname,
query: {
page: searchQuery.page,
pageSize: searchQuery.pageSize,
hashtag: searchQuery.hashtag,
mode: searchQuery.mode,
},
});
}
}
해당 코드에서 패턴을 찾고 싶었지만, 패턴 경우의 수가 많았다.
하나의 페이지에서 param 정보가 있을 수 있고, query 정보가 있을 수 있고, state 정보가 있을 수 있으므로, 페이지 컴포넌트당 pageFilter 파일을 만들어야 한다는 결론을 내렸다. (프로젝트 참고)
문제는 해결되었지만, 페이지 만들 때마다 번거로움이 생기게 되어 우아하지 않았다.
Solve, route-type-safe
이러한 문제와 요구사항을 해결하고자 라이브러리를 만들게 되었다.
- 하나의 파일에서 페이지 정보를 관리
- 페이지 링크 이동시 타입에 대한 힌트를 얻을 수 있다.
- param, query 추출 후, 타입 파싱
컨셉은 react-router-typesafe-routes에서 영감을 받았다. url을 만들기 위해 build를 사용하고, 파싱을 위해 parse함수를 사용한다.
해당 라이브러리의 아쉬운 점은 param의 타입 힌트가 없으며, 모든 값이 optional로 타입이 지정이 된다. 또한, 해당 라이브러리의 param, query, state 함수를 호출해야만 타입이 지정되는 불편함이 있었다.
build
import { route,typeParser } from 'route-type-safe';
export const routes = {
PRODUCT: route({ path: '/id' }),
PRODUCTID: route({
path: 'id/:id',
typeParam: {
id: typeParser.number.required,
},
typeQuery: {
sort: typeParser.oneOf("L", "R").optional,
page: typeParser.number.required,
},
}),
};
<Link
to={routes.PRODUCTID.build({
param: { id : 1 },
query: { page: 1 },
})
>
이슈 페이지 이동
</Link>
해당 라이브러리에서 typeParser
로 타입 (string | number | boolean | date | array | oneOf )을 선택한 뒤, required , optional 함수를 넘겨주면 route 함수에서 타입 지정 build
함수가 생성된다.
타입의 힌트를 얻을 수 있으며, 페이지 컴포넌트는 route에서 리턴된 변수(product)를 사용하면 나중에 path, param, query, hash, state 정보가 바뀌더라도, 컴파일 에러 나는 부분만 고치기만 하면 되기 때문에, 에디터 찾는 방법보다 효율적이면서 안정성이 확보가 된다.
parse
const {
param,
query,
hash,
state,
} = routes.PRODUCTID.parse(useParams(), useLocation());
const { id } = routes.PRODUCTID.parseParam(useParams());
const { page } = routes.PRODUCTID.parseQuery(usePathQuery());
const psHash = routes.PRODUCTID.parseHash(useLocation());
const psState = routes.PRODUCTID.parseState(useLocation());
- 모든 string 값을 typeParser 함수로 인해, 원하는 타입으로 변환해주는 parse기능을 볼 수 있다.
- 타입 힌트
- param: typeParser에서 리턴된 required , optional에 따라 타입 힌트를 받을 수 있다.
- query: 외부에서 url 기입 시, required위반이 될 수 있으므로 query의 parse부분은 모두 undefined(optional)로 올 수 있게끔 타입을 설정해 주었다.
- hash: route 함수에서 아무런 값을 주지 않았을 때, never의 타입 힌트를 받으면 리턴되는 값은 '' 빈 문자열이다.
- state: param과 동일한 효과를 받는다.
export const encode = (v: string, isEncode = false) => {
if (isEncode) {
// '*' escape except that same to return URLSearchParams func.
return encodeURIComponent(v).replace(/[!'()]/g, (x) => `%${x.charCodeAt(0).toString(16).toUpperCase()}`);
}
return v;
};
export const decode = (v: string, isDecode = false) => {
if (isDecode) {
return decodeURIComponent(v);
}
return v;
};
- encode, decode
- param과 query가 외부에서 URL로 접속 시 encode가 필요한 경우가 발생된다.
- 그래서 항상 param과 query는 build시에는 encode를 한 상태로 리턴이 되고, parse시에는 decode로 값을 다시 재 설정한다.
const product = route({
path: '/id/:id',
typeParam: {
id: typeParser.number.required,
},
typeQuery: {
sort: typeParser.oneOf('L', 'R').optional,
page: typeParser.number.required,
},
});
expect(() => product.parseParam({ id: 'apple' })).toThrow();
expect(product.parseParam({ productId: '2' })).toEqual({});
expect(product.parseQuery({ isSort: 'true', isPage: 'false' })).toEqual({});
expect(() => product.parseQuery({ sort: '2', page: '3' })).toThrow();
- 만약 외부에서 URL로 접속 시 route에서 설정한 type 키 값이 아니라면, 제외 대상이 된다.
결론적으로,
- 하드코딩 스타일에서 라이브러리 사용 규약으로 교체가 되었다.
- 타입 지정이 늘어나지 않고, route함수에서 변수를 사용하기만 하면 된다.
- 타입 파싱 문제로, 파일을 여러 개로 만들어야 하는 상황을 만들지 않고, route 함수에서 리턴된 parse에서 해결이 가능해졌다.
더 자세한 사용법은 테스트 코드를 보는 것을 추천한다.