use-funnel
https://www.youtube.com/watch?v=NwLWX2RNVcw
우연히 toss slash의 퍼널에 대한 유튜브 영상을 보게되었는데
사실 저는 퍼널이라는 용어 자체를 저 영상에서 처음 접했습니다.
용어가 생소할 뿐이지 개념 자체는 너무 익숙한 개념이었습니다.
익숙하다못해 제가 다니는 회사의 핵심 기능 역시도 퍼널이라고 볼 수 있었습니다.
이번에 회사코드 전체에 대한 기술 부채를 해소하는 마이그레이션을 진행하면서
가장 골칫거리로 미뤄두고 있던 기능 역시도 저 퍼널이었습니다.
40여개에 달하는 설문조사들을 한페이지에 한개의 질문씩만을 보여주면서
해결하기 위해서는 정말 많은 페이지가 필요하고 그만큼 구현도 복잡해지기 쉬웠는데요
어떻게 해결하는게 좋을까라는 고민을 하다가 우연히 보게되어서
토스팀이 구현해둔 use-funnel이라는 훅의 내부구현이 궁금해졌습니다.
다행히 toss/slash라는 라이브러리에 코드가 전부 공개되어있었고
어렵지않게 찾아볼 수 있었는데요 내부 코드도... 다른 라이브러리에 비하면 적은 편이라
가벼운 마음으로 감사하게 볼 수 있었습니다.
긴말할 거 없이 바로 훅을 살펴보겠습니다.
Funnel
/** @tossdocs-ignore */
import { assert } from '@toss/assert';
import { Children, isValidElement, ReactElement, ReactNode, useEffect } from 'react';
import { NonEmptyArray } from './models';
export interface FunnelProps<Steps extends NonEmptyArray<string>> {
steps: Steps;
step: Steps[number];
children: Array<ReactElement<StepProps<Steps>>> | ReactElement<StepProps<Steps>>;
}
export const Funnel = <Steps extends NonEmptyArray<string>>({ steps, step, children }: FunnelProps<Steps>) => {
const validChildren = Children.toArray(children)
.filter(isValidElement)
.filter(i => steps.includes((i.props as Partial<StepProps<Steps>>).name ?? '')) as Array<
ReactElement<StepProps<Steps>>
>;
const targetStep = validChildren.find(child => child.props.name === step);
assert(targetStep != null, `${step} 스텝 컴포넌트를 찾지 못했습니다.`);
return <>{targetStep}</>;
};
export interface StepProps<Steps extends NonEmptyArray<string>> {
name: Steps[number];
onEnter?: () => void;
children: ReactNode;
}
export const Step = <T extends NonEmptyArray<string>>({ onEnter, children }: StepProps<T>) => {
useEffect(() => {
onEnter?.();
}, [onEnter]);
return <>{children}</>;
};
타입스크립트와 친하지 않은 분들은 조금 당황스러울 수 있을 것 같습니다.
차근차근 뜯어보면 어렵지 않게 해석할 수 있으니 먼저 맨위의 interface부터 살펴보겠습니다.
export type NonEmptyArray<T> = readonly [T, ...T[]];
./models NonEmptyArray<T> = readonly [T, ...T[]]
제네릭을 받으며 그 제네릭과 맞는 타입으로 구성됨과 동시에
readonly 인 array여야 한다는 타입을 가집니다.
이러한 타입의 경우에는 배열에 as const 키워드를 이용해주면 흔히 생성되는 타입입니다.
import { NonEmptyArray } from './models';
export interface FunnelProps<Steps extends NonEmptyArray<string>> {
steps: Steps;
step: Steps[number];
children: Array<ReactElement<StepProps<Steps>>> | ReactElement<StepProps<Steps>>;
}
이제 Funnel의 Props에 대한 인터페이스 정의를 살펴봅시다.
이 역시도 제네릭을 이용하는데요 Steps라는 제네릭은 방금 살펴보면
NonEmptyArray를 확장합니다. 그런데 제네릭에는 string을 전달합니다.
즉 FunnelProps는 모두 문자열로 구성된 readonly 배열을 인수로 받아야한다는 것을 명시합니다.
steps는 readonly 배열이 들어갈것이고
step은 steps의 요소가 들어갈것이면서
children은 항상 ReactElement이면서 StepProps여야하는군요
잠시 멈추고 StepProps 타입의 정의를 살펴봐야겠습니다.
export interface StepProps<Steps extends NonEmptyArray<string>> {
name: Steps[number];
onEnter?: () => void;
children: ReactNode;
}
Stepprops는 역시 아까 살펴본 NonEmptyArray 타입을 확장합니다.
name에는 Steps의 요소중 하나가 들어가야하며
onEnter는 필요한 곳에서만 사용할 수 있게 옵셔널인 모습입니다.
children의 경우에는 향후 자식을 받아야하니 필요한 모양이네요
이제 StepProps를 이해했으니 다시 코드로 돌아갑시다.
import { NonEmptyArray } from './models';
export interface FunnelProps<Steps extends NonEmptyArray<string>> {
steps: Steps;
step: Steps[number];
children: Array<ReactElement<StepProps<Steps>>> | ReactElement<StepProps<Steps>>;
}
이제 좀 더 명확하게 children 부분을 해석할 수 있어졌습니다.
children은 항상 Steps의 요소 중 하나를 name으로 갖는 리액트엘리먼트여야만합니다.
이제 FunnelProps가 뭔지 대충 감이 옵니다. Funnel 컴포넌트의 구현을 봅시다.
export const Funnel = <Steps extends NonEmptyArray<string>>({ steps, step, children }: FunnelProps<Steps>) => {
const validChildren = Children.toArray(children)
.filter(isValidElement)
.filter(i => steps.includes((i.props as Partial<StepProps<Steps>>).name ?? '')) as Array<
ReactElement<StepProps<Steps>>
>;
const targetStep = validChildren.find(child => child.props.name === step);
assert(targetStep != null, `${step} 스텝 컴포넌트를 찾지 못했습니다.`);
return <>{targetStep}</>;
};
Funnel 컴포넌트 역시 제네릭을 받는데 제네릭의 세부내용은 위에서 다룬 내용과 같으니
생략하도록 하겠습니다.
간단한 유효성검증 함수와 현재 step인 칠드런을 찾는 find 문으로 구성되어있는데요
리액트가 제공하는 Children API를 이용하면 children을 마치 배열처럼 다룰 수 있습니다.
validChildren 변수를 살펴보면
Children.toArray를 통해 칠드런을 배열로 바꾸고 배열메서드 filter를 도는것을 확인할 수 있습니다.
isValidElement라는 함수는 저도 처음보는 함수인데요
https://react.dev/reference/react/isValidElement
리액트에서 제공하는 api 로 인자에 들어간 값이 리액트 엘리먼트인지를 판별하는 함수입니다.
즉 먼저 리액트 요소인지 판별을 한다음
요소의 prop중 name 프랍이 우리의 퍼널인 녀석들로만 남을 수 있게 만들어주는 작업입니다.
여기서 재밌는건 Partiel이라는 타입스크립트가 제공하는 유틸리티타입을 활용한 부분인데요
Partiel 유틸리티 타입은 제네릭으로 들어간 타입들의 프로퍼티를 모두 옵셔널로 만들어줍니다.
const targetStep = validChildren.find(child => child.props.name === step);
이제 targetStep 변수를 보겠습니다.
이름에서도 알 수 있듯이 퍼널의 여러가지 스텝 중 현재 타겟이 되는 스텝을 찾는 로직입니다.
find 문을 통해 name prop이 현재 step과 같은 요소를 찾아내는 간단한 로직입니다.
assert(targetStep != null, `${step} 스텝 컴포넌트를 찾지 못했습니다.`);
assert는 빌트인 함수는 아니고 toss/slash 라이브러리의 함수입니다.
https://slash.page/ko/libraries/common/assert/src/assert.i18n
첫번째 인수에 들어가는 표현식이 트루시하다면 컨디션을 단언하고
만약 falsy하다면 두번째 인수에 전달한 내용으로 에러를 쓰로우하는 함수인걸로 보입니다.
return <>{targetStep}</>;
그리고 마지막으로 찾아낸 targetStep 엘리먼트를 렌더링하면서
Funnel 컴포넌트는 끝이납니다.
생각보다 간단하네요
그럼 이제 useFunnel 훅을 살펴보겠습니다.
훅 살펴보기
/** @tossdocs-ignore */
import { assert } from '@toss/assert';
import { safeSessionStorage } from '@toss/storage';
import { useQueryParam } from '@toss/use-query-param';
import { QS } from '@toss/utils';
import deepEqual from 'fast-deep-equal';
import { useRouter } from 'next/router.js';
import { SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from 'react-query';
import { Funnel, FunnelProps, Step, StepProps } from './Funnel';
import { NonEmptyArray } from './models';
interface SetStepOptions {
stepChangeType?: 'push' | 'replace';
preserveQuery?: boolean;
query?: Record<string, any>;
}
type RouteFunnelProps<Steps extends NonEmptyArray<string>> = Omit<FunnelProps<Steps>, 'steps' | 'step'>;
type FunnelComponent<Steps extends NonEmptyArray<string>> = ((props: RouteFunnelProps<Steps>) => JSX.Element) & {
Step: (props: StepProps<Steps>) => JSX.Element;
};
const DEFAULT_STEP_QUERY_KEY = 'funnel-step';
export const useFunnel = <Steps extends NonEmptyArray<string>>(
steps: Steps,
options?: {
/**
* 이 query key는 현재 스텝을 query string에 저장하기 위해 사용됩니다.
* @default 'funnel-step'
*/
stepQueryKey?: string;
initialStep?: Steps[number];
onStepChange?: (name: Steps[number]) => void;
}
): readonly [FunnelComponent<Steps>, (step: Steps[number], options?: SetStepOptions) => void] & {
withState: <StateExcludeStep extends Record<string, unknown> & { step?: never }>(
initialState: StateExcludeStep
) => [
FunnelComponent<Steps>,
StateExcludeStep,
(
next:
| Partial<StateExcludeStep & { step: Steps[number] }>
| ((next: Partial<StateExcludeStep & { step: Steps[number] }>) => StateExcludeStep & { step: Steps[number] })
) => void
];
} => {
const router = useRouter();
const stepQueryKey = options?.stepQueryKey ?? DEFAULT_STEP_QUERY_KEY;
assert(steps.length > 0, 'steps가 비어있습니다.');
const FunnelComponent = useMemo(
() =>
Object.assign(
function RouteFunnel(props: RouteFunnelProps<Steps>) {
// eslint-disable-next-line react-hooks/rules-of-hooks
const step = useQueryParam<Steps[number]>(stepQueryKey) ?? options?.initialStep;
assert(
step != null,
`표시할 스텝을 ${stepQueryKey} 쿼리 파라미터에 지정해주세요. 쿼리 파라미터가 없을 때 초기 스텝을 렌더하려면 useFunnel의 두 번째 파라미터 options에 initialStep을 지정해주세요.`
);
return <Funnel<Steps> steps={steps} step={step} {...props} />;
},
{
Step,
}
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
const setStep = useCallback(
(step: Steps[number], setStepOptions?: SetStepOptions) => {
const { preserveQuery = true, query = {} } = setStepOptions ?? {};
const url = `${QS.create({
...(preserveQuery ? router.query : undefined),
...query,
[stepQueryKey]: step,
})}`;
options?.onStepChange?.(step);
switch (setStepOptions?.stepChangeType) {
case 'replace':
router.replace(url, undefined, {
shallow: true,
});
return;
case 'push':
default:
router.push(url, undefined, {
shallow: true,
});
return;
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[options, router]
);
/**
* 아래부터 withState() 구현입니다.
* 외부 함수로 분리하기 어려워 당장은 inline 해둡니다.
* FIXME: @junyong-lee withState() 구현을 외부 함수로 분리하기
*/
type S = Record<string, unknown>;
const [state, _setState] = useFunnelState<S>({});
type Step = Steps[number];
type NextState = S & { step?: Step };
const nextPendingStepRef = useRef<Step | null>(null);
const nextStateRef = useRef<Partial<S> | null>(null);
const setState = useCallback(
(next: Partial<NextState> | ((next: Partial<NextState>) => NextState)) => {
let nextStepValue: Partial<NextState>;
if (typeof next === 'function') {
nextStepValue = next(state);
} else {
nextStepValue = next;
}
if (nextStepValue.step != null) {
nextPendingStepRef.current = nextStepValue.step;
}
nextStateRef.current = nextStepValue;
_setState(next);
},
[_setState, state]
);
useEffect(() => {
if (nextPendingStepRef.current == null) {
return;
}
if (deepEqual(nextStateRef.current, state)) {
setStep(nextPendingStepRef.current);
nextPendingStepRef.current = null;
}
}, [setStep, state]);
const initializedRef = useRef(false);
function withState<State extends Record<string, unknown>>(initialState: State) {
if (!initializedRef.current) {
setState(initialState);
initializedRef.current = true;
}
return [FunnelComponent, state, setState] as const;
}
return Object.assign([FunnelComponent, setStep] as const, { withState }) as unknown as readonly [
FunnelComponent<Steps>,
(step: Steps[number], options?: SetStepOptions) => Promise<void>
] & {
withState: <StateExcludeStep extends Record<string, unknown> & { step?: never }>(
initialState: StateExcludeStep
) => [
FunnelComponent<Steps>,
StateExcludeStep,
(
next:
| Partial<StateExcludeStep & { step: Steps[number] }>
| ((next: Partial<StateExcludeStep & { step: Steps[number] }>) => StateExcludeStep & { step: Steps[number] })
) => void
];
};
};
type FunnelStateId = `use-funnel-state__${string}`;
function createFunnelStateId(id: string): FunnelStateId {
return `use-funnel-state__${id}`;
}
/**
* NOTE: 이후 Secure Storage 등 다른 스토리지를 사용하도록 스펙이 변경될 수 있으므로, Asynchronous 함수로 만듭니다.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function createFunnelStorage<T>(funnelStateId: FunnelStateId, storageType = 'sessionStorage'): FunnelStorage<T> {
switch (storageType) {
case 'sessionStorage':
return {
get: async () => {
const d = safeSessionStorage.get(funnelStateId);
if (d == null) {
return null;
}
return JSON.parse(d) as Partial<T>;
},
set: async (value: Partial<T>) => {
safeSessionStorage.set(funnelStateId, JSON.stringify(value));
},
clear: async () => {
safeSessionStorage.remove(funnelStateId);
},
};
default:
throw new Error('정확한 스토리지 타입을 명시해주세요.');
}
}
interface FunnelStorage<T> {
get: () => Promise<Partial<T> | null>;
set: (value: Partial<T>) => Promise<void>;
clear: () => Promise<void>;
}
function useFunnelState<T extends Record<string, any>>(
defaultValue: Partial<T>,
options?: { storage?: FunnelStorage<T> }
) {
const { pathname, basePath } = useRouter();
const storage = options?.storage ?? createFunnelStorage<T>(createFunnelStateId(`${basePath}${pathname}`));
const persistentStorage = useRef(storage).current;
const initialState = useQuery({
queryFn: () => {
return persistentStorage.get();
},
suspense: true,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
}).data;
const [_state, _setState] = useState<Partial<T>>(initialState ?? defaultValue);
const setState = useCallback(
(state: SetStateAction<Partial<T>>) => {
_setState(prev => {
/**
* React Batch Update 그리고 Local State와 Persistent Storage의 State의 일관성을 위해서 이렇게 작성했습니다.
*/
if (typeof state === 'function') {
const newState = state(prev);
persistentStorage.set(newState);
return newState;
} else {
persistentStorage.set(state);
return state;
}
});
},
[persistentStorage]
);
const clearState = useCallback(() => {
_setState({});
persistentStorage.clear();
}, [persistentStorage]);
return [_state, setState, clearState] as const;
}
대략 260 줄 가량의 코드가 담겨있어서 조금 부담스럽지만..
이것도 쪼개서 하나씩 바라보면 그렇게 부담스럽지 않을 것입니다.
위에서부터 차근차근 읽어봅시다.
interface SetStepOptions {
stepChangeType?: 'push' | 'replace';
preserveQuery?: boolean;
query?: Record<string, any>;
}
먼저 SetStepOptions 인터페이스를 확인해봅시다.
대충 예상할수 있는 바로는 stepChangeType이 push/ replace인걸로 미루어보아
라우터와 관련된 로직일 확률이 높아보이네요
query라는 프로퍼티가 무슨 일을 하는 녀석인지는 아직 감이 안오니 더 읽어봅시다.
type RouteFunnelProps<Steps extends NonEmptyArray<string>> = Omit<FunnelProps<Steps>, 'steps' | 'step'>;
RouteFunnel의 props가 될 타입인듯합니다.
타입정의 자체는 지금까지 주구장창 봐왔던 형식의 타입정의인데요
Omit이라는 유틸리티타입이 등장합니다.
Omit은 앞서 소개드린 Partiel과 마찬가지로 타입스크립트에서 제공하는 유틸리티 타입인데요
두번째 인수에 넣어준 프로퍼티를 제거한 타입을 생성해주는 유틸리티 타입입니다.
즉 위 코드는 FunnelProps<Steps> 타입에서 'steps'랑 'step'은 사용하고싶지않다는 코드가 되는것입니다.
import { NonEmptyArray } from './models';
export interface FunnelProps<Steps extends NonEmptyArray<string>> {
steps: Steps;
step: Steps[number];
children: Array<ReactElement<StepProps<Steps>>> | ReactElement<StepProps<Steps>>;
}
아까 FunnelProps는 3개의 프로퍼티가 있었는데 steps와 step은 숨기고
children만 남겨주려는 의도인걸로 보이네요!
type FunnelComponent<Steps extends NonEmptyArray<string>> = ((props: RouteFunnelProps<Steps>) => JSX.Element) & {
Step: (props: StepProps<Steps>) => JSX.Element;
};
다음 타입입니다. 제네릭은 주구장창 보던 녀석이고
&는 타입스크립트의 문법으로 intersection 타입 연산자입니다.
말그대로 교집합을 의미한다고 생각할 수 있는데요
인터페이스 문법에서 extends를 통해 객체를 상속받는것과 비슷하게
타입 문법을 이용하면 intersection 연산자를 통해 서로 다른 타입을 합쳐줄 수 있습니다.
즉 prop으로 RouteFunnelProps를 받고 JSX.Element를 반환하는 함수와
StepProps<Steps>를 받고 JSX.Element를 반환하는 Step 프로퍼티가 있는 객체
두가지 조건을 만족하는 컴포넌트여야한다는 의미가 됩니다.
다시 쉽게 설명하면
Step이라는 프로퍼티를 갖고있는 컴포넌트여야한다는 타입정의에요
자바스크립트에서는 함수 역시 객체이기 때문에 함수도 프로퍼티를 가질 수 있다는 사실을 기억하세요!
const DEFAULT_STEP_QUERY_KEY = 'funnel-step';
다음은 상수선언입니다.
어디에 쓰는 키인지는 몰라도 추후 상수로 활용할 것은 명확하니 기억만 해두고 넘어갑시다.
이제 드디어 우리는 useFunnel 훅의 내부를 들여다볼 준비를 마쳤습니다!
먼저 useFunnel의 매개변수와 반환타입부터 봐봅시다.
export const useFunnel = <Steps extends NonEmptyArray<string>>(
steps: Steps,
options?: {
/**
* 이 query key는 현재 스텝을 query string에 저장하기 위해 사용됩니다.
* @default 'funnel-step'
*/
stepQueryKey?: string;
initialStep?: Steps[number];
onStepChange?: (name: Steps[number]) => void;
}
): readonly [FunnelComponent<Steps>, (step: Steps[number], options?: SetStepOptions) => void] & {
withState: <StateExcludeStep extends Record<string, unknown> & { step?: never }>(
initialState: StateExcludeStep
) => [
FunnelComponent<Steps>,
StateExcludeStep,
(
next:
| Partial<StateExcludeStep & { step: Steps[number] }>
| ((next: Partial<StateExcludeStep & { step: Steps[number] }>) => StateExcludeStep & { step: Steps[number] })
) => void
];
} => {
친절한 주석도 존재하네요
useFunnel 훅은 반드시 최소한 하나의 인수를 요구한다는 것을 눈치챌수있습니다.
바로 NonEmptyArray<string> 타입을 만족하는 배열을 첫번째 인수로 요구하는데요
그리고 두번째 옵션으로 세개의 옵션을 전달해줄 수 있네요
stepQueryKey는 주석으로 미루어보았을때 queryString에 사용하기 위해 쓰는 프로퍼티같고
initialStep은 처음 시작이 되는 Steps를 지정하는 것 같아보이네요
onStepChange는 이름으로 미루어보아 스텝이 바뀌는 이벤트가 발생할때 실행될 함수같아보입니다.
그리고 그 뒤에 readonly로 이어지는 부분들이 바로 반환타입에 대한 정의인데요
언뜻보면 코드를 파악하기 어려우니 반환 타입 정의 부분만 추출해서 살펴봅시다.
readonly [FunnelComponent<Steps>, (step: Steps[number], options?: SetStepOptions) => void] & {
withState: <StateExcludeStep extends Record<string, unknown> & { step?: never }>(
initialState: StateExcludeStep
) => [
FunnelComponent<Steps>,
StateExcludeStep,
(
next:
| Partial<StateExcludeStep & { step: Steps[number] }>
| ((next: Partial<StateExcludeStep & { step: Steps[number] }>) => StateExcludeStep & { step: Steps[number] })
) => void
];
네 이부분이 반환타입에 대한 정의부분입니다.
readonly한 튜플을 반환한다는 것을 알 수 있는데요
첫번째에는 아까 우리가 살펴본 FunnelComponent가 담겨있다는 걸 알수있고
두번째에는 함수가 반환된다는 것을 볼 수 있습니다.
변경할 스텝에 대한 내용과 옵션으로 아까 위에서 뭔진모르겠지만 쓸거라고 예상할 수 있었던
SetStepOptions 타입을 가진 옵션객체를 받고 있는걸 확인할 수 있습니다.
이부분이 상태를 변경하는 setState 함수가 튜플의 두번째 요소로 반환될것이란것을 예측할 수 있어요
여기까지가 첫번째 리턴타입입니다.
반환타입은 크게 두개로 나눠서 살펴봐야하는데요
readonly [FunnelComponent<Steps>, (step: Steps[number], options?: SetStepOptions) => void]
우리는 여기까지의 타입을 살펴봤고 이제 아래에 이어지는 타입을 살펴봅시다
{
withState: <StateExcludeStep extends Record<string, unknown> & { step?: never }>(
initialState: StateExcludeStep
) => [
FunnelComponent<Steps>,
StateExcludeStep,
(
next:
| Partial<StateExcludeStep & { step: Steps[number] }>
| ((next: Partial<StateExcludeStep & { step: Steps[number] }>) => StateExcludeStep & { step: Steps[number] })
) => void
];
교집합으로 반환되는것은 객체입니다.
이 객체는 withState라는 프로퍼티만 하나 가지고 있는 객체인데요
이 withState 프로퍼티는 역시 함수입니다.
그리고 이 코드에서 우리는 정말 유용한 타입 정의를 배울 수 있습니다.
<StateExcludeStep extends Record<string, unknown> & { step?: never }>
바로 이 타입정의 부분인데요
Record 역시 타입스크립트에서 제공하는 유틸리티 타입으로
첫번째 인수에는 허용되는 객체의 키값이 들어가고
두번째 인수에는 허용되는 객체의 밸류값이 들어갑니다.
즉 Record<string,unknown>은 모든 키값과 밸류를 허용하는 객체 타입을 의미하게됩니다.
하지만 그 뒤에 인터섹션타입을 통해 step?:never 를 추가합니다.
이것의 의미는 모든 키값을 허용하지만 step이라는 키값은 사용할 수 없게 제한한다라는 의미가 됩니다.
이미 내부적으로 step 을 이용하고있기 때문에 제한하는 것으로 보이네요
(혹시 아니라면 알려주세요 ㅎㅎ;;)
-> 이후 구현도 살펴보고싶었지만..
당장 회사업무에 적용할 퍼널 기능 개발이 필요해서
핵심적인 구현은 파악했으니 직접 구현해보겠습니다.
글이 너무 길어져서 직접 구현 파트는 다음 글에서 이어집니다.
https://xionwcfm.tistory.com/423
'best' 카테고리의 다른 글
husky와 commitlint로 jira 이슈번호 자동화 시키기 (2) | 2023.11.27 |
---|---|
toss/slash의 use-funnel 훅 내부 구현 탐구하고 직접 구현하기(2) (0) | 2023.11.03 |
TypeScript , JavaScript의 접근 제한자 '#' Deep Dive (1) | 2023.08.29 |
자바스크립트의 호이스팅에 Deep Dive 해보자 (3) | 2023.04.11 |
클로저가 뭐냐?라는 일상적인 질문에 잘 아는 것처럼 행동하는 방법 (6) | 2023.02.23 |