본문 바로가기
Development

[23.07.29] 프론트엔드에서 Route를 확장성있게 관리하기

by igy95 2024. 1. 9.

리팩터링 배경

앱 내의 페이지가 많아지고 기획이 복잡해질수록 각 페이지의 주소를 나타내는 route 또한 심도 있게 관리될 필요가 있다. route의 경로뿐만 아니라 해당 경로로 안내된 페이지가 public 한 지, 해당 route의 하위 route는 무엇인지 등 여러 가지 상태를 표현할 수 있어야 하기 때문이다.

현재 관리되고 있는 route 객체는 이러한 요구사항을 충족시키기에 다소 부족하다는 생각이 들었고 이를 보완하기 위해 확장성을 높이는 방향으로 리팩터링을 시도해보았다.

기존 구조가 가지고 있던 문제점

const Route = {
  Public: {
    SignIn: '/signin',
  },
  Private: {
    Main: '/',
    Billings: {
      Orders: '/billings/orders',
      Coupons: '/billings/coupons',
    },
    ...,
    NotFound: '/404',
  },
} as const

 

  • route의 특정 상태를 객체의 키로 나타내었기 때문에, route의 상태를 추가해야 할 경우 쉽게 확장시킬 수 없다.
  • 2-depth 이상인 route도 리터럴로 선언되어 상위 컨텍스트에 있는 route의 중복 선언이 발생하고 있으며, 추후 구조 변경 시(e.g string → object) 사용처의 변경 또한 불가피하다.

요구사항 도출

  • 각 route 내의 상태를 쉽게 추가, 삭제할 수 있어야 한다.
  • 여러 depth를 가진 route도 변경에 용이해야 한다.
  • (문제점은 아니었지만) route와 관련된 util도 응집성 있게 관리하면 좋겠다.

구현 방향

사용자가 route 구성에 필요한 최소한의 정보를 입력하여 인자로 넘겨주면, 해당 정보를 토대로 새로운 route 객체를 만들고 관련 메서드와 함께 반환하는 모듈을 만든다.

구현 과정

인자로 받을 RouteSchema의 타입을 정의한다. 작업 중인 프로젝트 성격에 맞추어 메타 정보를 선언했고 context는 depth의 확장성을 고려해 재귀적으로 구성하였다.

 

type Pathname = `/${string}`

type RouteSchema = {
  [key: string]: {
    /* route 경로 */
    pathname: Pathname
    /* route의 이름 (page title, navigation menu 용도) */
    name?: string
    /* route가 list page일 때 detail page가 있는지 여부 */
    detail?: boolean
    /* route 공개 여부 */
    isPublic?: boolean
    /* 하위 route */
    context?: RouteSchema
  }
}

type RouteInfo = RouteSchema[string]

 

그다음, 인자로 받은 RouteSchema를 가공하여 반환할 RouteSchemaResult의 타입을 정의하였다. 이때 신경 쓴 부분은 반환된 객체의 pathname은 인자와 달리 컨텍스트에 따라 병합된 리터럴 타입을 제공해야한다는 점이다. 예를 들어 RouteSchema 안에서 /route1에 대한 객체가 있고 해당 컨텍스트 내에서 /route2를 선언했을 때 RouteSchemaResult에서 /route2/route1/route2로 추론되어야 한다.

 

이를 위해 구글링을 통해 여러 방법을 찾아본 뒤, template literal type과 제너릭을 활용한 재귀를 통하여 타입을 선언할 수 있었다.

 

type RouteSchemaResult<T extends RouteSchema, P extends string = ''> = Readonly<{
  [K in keyof T]: Omit<T[K], 'pathname' | 'context' | 'detail'> & {
    pathname: `${P}${T[K]['pathname']}`
    detail: T[K]['detail'] extends true
      ? (id: string | number) => Pick<T[K], 'name'> & { pathname: `${P}${T[K]['pathname']}/${string | number}` }
      : never
    context: T[K]['context'] extends RouteSchema ? RouteSchemaResult<T[K]['context'], `${P}${T[K]['pathname']}`> : never
  }
}>

 

이렇게 인풋과 아웃풋에 대한 타입을 작성한 뒤, 실질적으로 구현해야 할 모듈의 인터페이스를 작성하였다. (대부분의 구현은 재귀 함수로 구현되었으며, 구현부를 포함하면 코드 라인이 너무 길어져 인터페이스만 첨부하였다.)

 

interface RouteManager<T extends RouteSchema> {
	/* route 객체 */
	route: RouteSchemaResult<T>
	/* 주어진 pathname이 public한지 확인하는 메서드 */
	isPublicRoute: (pathname: string) => boolean
	/* 주어진 pathname이 유효한 route인지 확인하는 메서드 */
	isValidRoute: (pathname: string) => boolean
}

class RouteManagerImpl<T extends RouteSchema> implements RouteManager<T> {
	private schema: RouteSchemaResult<T>
	
	private routeMap = new Map<string, RouteInfo>()

	constructor(schema: T) {
		this.assignToRouteMap(schema)
		this.schema = this.convertToShemaResult(schema)
	}

	get route(): RouteSchemaResult<T>

	private convertToSchemaResult(schema: RouteSchema, pathname?: Pathname): RouteSchemaResult<T>

	private assignToRouteMap(schema: RouteSchema): void

	private findByPathname(pathname: string): RouteInfo | undefined

	isPublicRoute(pathname: string) => boolean

	isValidRoute(pathname: string) => boolean
}

 

주석이 작성된 필드 외에 나머지를 간단히 설명해 보자면 다음과 같다.

  • convertToShemaResult - 외부에서 받아온 RouteSchemaRouteSchemaResult로 변환하는 메서드
  • routeMap - 특정 pathnameRouteSchema 내의 RouteInfo와 비교하기 위해 매번 객체 전체를 탐색하는 것은 비효율적이기 때문에 { pathname: RouteInfo } 구조로 flat 하게 관리하여 RouteInfo에 빠르게 접근할 목적으로 초기화한 자료구조
  • assignToRouteMap, findByPathname - routeMap에 할당 및 접근을 수행하는 메서드

Use Case

const routeManager = new RouteManager({
	signIn: {
		pathname: '/signin',
		isPublic: true
	},
	billings: {
		name: 'Billings',
		pathname: '/billings',
		context: {
			orders: {
				name: 'Orders',
				pathname: '/orders',
				detail: true,
			}
		}
	}
})

const Route = routeManager.route

Route.billings.context.orders.pathname // '/billings/orders'
Route.billings.context.orders.detail(1).pathname // 'billings/orders/1'
Route.billings.context.orders.detail(1).name // 'Billings 1'

routeManager.isPublicRoute('/signin') // true
routeManager.isValidRoute('/invalid-route') // false

아쉬운 점

RouteSchemaResult내의 모든 pathname으로 이루어진 union type을 만들어 Route와 관련된 함수, 컴포넌트에서 타입을 좁히고 싶었으나 현재의 구조에서 마땅한 방법을 찾지 못했다. 추후에 시간이 되면 한번 더 시도해 봐야겠다.