본문으로 건너뛰기

· 약 2분
최민석

서론

  • 마이크로프론트엔드는 웹 프론트엔드를 여러 개의 작은 프론트엔드로 나누어 개발하는 방법론입니다.
  • 마이크로프론트엔드는 모놀리식(monolithic)과 마이크로서비스(microservice) 사이에 위치한 개념으로, 모놀리식과 마이크로서비스의 장점을 결합한 방법론입니다.

Monolithic vs Microservice vs Microfrontend

모놀리식(Monolithic)

마이크로서비스(Microservice)

마이크로프론트엔드(Microfrontend)

마이크로프론트엔드의 장점

  • 개발자의 독립성 확보

마이크로프론트엔드의 단점

마이크로프론트엔드의 구현 방법

iframe을 이용한 마이크로프론트엔드 구현

웹팩 모듈 페더레이션을 이용한 마이크로프론트엔드 구현

모노레포를 이용한 마이크로프론트엔드 구현

마이크로프론트엔드 적용 사례 및 분석

마이크로프론트엔드의 향후 전망

Reference

· 약 11분
최민석

서론

  • 리액트는 현재 웹 애플리케이션 개발에 널리 사용되는 프론트엔드 라이브러리입니다. 그 특징 중 하나는 상태 관리의 유연성이며, 이를 위해 자주 사용되는 도구 중 하나가 ContextAPI입니다. 그러나 많은 개발자들이 오해하는 것이, ContextAPI는 사실상 상태 관리 도구가 아니라 DI, 즉 의존성 주입(Dependency Injection) 도구라는 사실입니다.

  • 그렇다면 이러한 의존성 주입을 리액트에서는 어떻게 활용할 수 있을까요? 이를 위해 우리는 props와 ContextAPI를 통한 의존성 주입 방법에 대해 살펴볼 것입니다. 이를 통해 리액트에서 의존성 주입이 왜 필요하며, 어떻게 사용하는지에 대한 이해를 높일 수 있을 것입니다.

의존성 주입(Dependency Injection)

의존성 주입이란, 객체 지향 프로그래밍에서 흔히 볼 수 있는 디자인 패턴 중 하나로, 클래스 간의 결합도를 낮추고 코드의 재사용성과 테스트 용이성을 높이는 방법입니다. 이는 특정 객체가 다른 객체에 의존하는 경우, 해당 객체를 직접 생성하는 것이 아니라 외부에서 생성된 객체를 주입받아 사용하는 것을 의미합니다.

의존성 주입을 알았으니 React에서는 어떻게 의존성 주입 하는 지와 알아보겠습니다.

1. props를 통한 의존성 주입

  • React에서는 props를 통해 의존성 주입을 할 수 있습니다.
  • 아래 코드는 App 컴포넌트에서 Counter 컴포넌트로 count 상태와 setCount 함수를 props로 전달하여 의존성 주입을 하고 있습니다.
  • Counter 컴포넌트에서는 props로 전달받은 count 상태와 setCount 함수를 사용하여 의존성을 주입받아 사용하고 있습니다.
//App.tsx
const App = () => {
const [count, setCount] = useState(0);
return (
<div>
<Counter count={count} setCount={setCount} />
</div>
);
};

//Counter.tsx
const Counter = ({ count, setCount }) => {
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
);
};

2. ContextAPI를 통한 의존성 주입

  • 다른 방법으로는 ContextAPI를 통해 의존성 주입을 할 수 있습니다.
  • 아래 코드는 App 컴포넌트에서 Counter 컴포넌트로 count 상태와 setCount 함수를 ContextAPI를 통해 의존성 주입을 하고 있습니다.
  • Counter 컴포넌트에서는 useContext hook을 통해서 의존성을 주입받아 사용하고 있습니다.
//App.tsx
const CountContext = createContext({ count: 0, setCount: () => {} });
const App = () => {
const [count, setCount] = useState(0);
return (
<CountContext.Provider value={{ count, setCount }}>
<Counter />
</CountContext.Provider>
);
};

//Counter.tsx
const Counter = () => {
const { count, setCount } = useContext(CountContext);
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
);
};

리액트에서 의존성 주입을 하는 이유

  • 기본적으로 의존성 주입은 확장 가능한지, 재사용 가능한지에 대해 고민하면서 작업하게 되면서 내 코드가 변경에 유연한지를 고민하게 됩니다.

즉, 리액트에서는 의존성 주입을 통해 UI 컴포넌트 변경이 유연한지 고민하고 재사용성이 있는지에 대한 고민을 해결할 수 있는 과정입니다.

다만 의존성을 잘못 사용할 때의 두 객체 사이의 결합도가 높아지는 문제가 발생하면서 변경에 유연하지 않도록 되기 때문에 잘 사용해야 합니다.

이를 만족할 수 있는 사항들을 알아보겠습니다.

1. 의존성은 교체 가능해야 한다.

  • 의존성 주입을 사용하면 의존하는 객체를 교체할 수 있습니다. 따라서, 코드의 특정 부분을 수정하지 않고도 다른 객체로 교체할 수 있어야 합니다. 이는 유지보수성을 높이고 기능 확장이 용이하게 만들어줍니다.

2. 테스트에 용이해야 한다.

  • 의존성 주입을 통해 의존하는 객체를 모의(mock) 객체로 대체할 수 있습니다. 이를 통해 단위 테스트를 수행할 때 의존하는 객체의 동작을 제어하고 검증할 수 있습니다. 따라서, 테스트 용이성이 높아집니다.

3. 컴포넌트를 통합할 수 있어야 한다.

  • 의존성 주입을 사용하면 여러 컴포넌트 간에 의존 관계를 쉽게 통합할 수 있습니다. 이를 통해 컴포넌트 간의 결합도를 낮출 수 있고, 재사용 가능한 컴포넌트를 만들 수 있습니다.

위와 같은 사항들을 만족할 수 있도록 의존성 주입을 사용해야 합니다.

만약 의존성 주입을 사용하지 않는다면?

의존성 주입을 사용하지 않으면 어떤 문제가 발생할까요? 의존성 주입을 사용하지 않으면 어떤 문제가 발생할 수 있는지 알아보겠습니다.

결합도 증가

  • 컴포넌트가 직접 의존하는 외부 리소스를 생성하거나 관리하는 경우, 컴포넌트와 의존성 사이에 강한 결합이 형성됩니다. 이는 코드의 유연성을 저하시키고 재사용성을 감소시킵니다. 예를 들어, 특정 컴포넌트가 API 호출을 직접 처리한다면, 해당 컴포넌트는 다른 API로의 교체가 어렵고, 테스트하기도 어려워집니다.

테스트의 어려움

  • 의존성 주입이 없으면 특정 컴포넌트의 의존성을 모의(mock) 객체로 대체하여 테스트하기가 어렵습니다. 외부 리소스에 직접 의존하는 경우, 해당 리소스를 테스트 환경에서 재현하기가 어려우며, 테스트 결과의 일관성과 신뢰성이 떨어질 수 있습니다.

코드 중복

  • 의존성 주입을 사용하지 않으면 여러 컴포넌트에서 동일한 의존성을 생성하거나 관리해야 할 수 있습니다. 이는 코드 중복을 증가시키고 유지보수를 어렵게 만듭니다. 의존성 주입을 통해 의존성을 중앙에서 관리하면 중복을 피하고 의존성 관리를 효율적으로 할 수 있습니다.

확장의 어려움

  • 의존성 주입이 없으면 애플리케이션의 확장이 어려워집니다. 새로운 기능이나 외부 리소스에 의존하는 컴포넌트를 추가할 때, 기존 컴포넌트들이 직접 수정되어야 할 수 있습니다. 의존성 주입을 사용하면 새로운 컴포넌트를 추가하거나 기존 컴포넌트를 변경하지 않고도 의존성을 주입함으로써 애플리케이션의 확장성을 향상시킬 수 있습니다.
const UserGreeting = () => {
const user = {
name: "John",
};

return <Greeting name={user.name} />;
};

const GuestGreeting = () => {
return <Greeting name="Guest" />;
};

const Greeting = ({ name }) => {
return <h1>Hello, {name}</h1>;
};

결론

리액트에서 의존성 주입은 코드의 재사용성과 유지보수성을 향상시키기 위해 사용되는 중요한 개념입니다.

의존성 주입을 통해 컴포넌트 간의 결합도를 낮추고 코드를 더 유연하게 만들 수 있습니다. 의존성 주입은 컴포넌트의 의존성을 명확하게 정의하고, 필요한 의존성을 주입함으로써 더욱 확장 가능하고 테스트 용이한 애플리케이션을 개발하는 데 도움을 줍니다.

이를 통해 좀 더 변화에 유연한 코드를 작성할 수 있습니다.

Refs.

· 약 12분
최민석

서론

  • Google Map 기반의 프로젝트를 진행하면서 Google에서 제공해주는 JavaScript Map API는 React에서 바로 사용하기에 어려운 환경을 가지고 있습니다.
  • DOM을 생성하는 시점에 Map을 생성하고 Map을 사용하는 시점에 DOM이 생성되어 있어야 하는데, React에서는 DOM을 생성하는 시점과 Map을 사용하는 시점이 다르기 때문에 문제가 발생합니다.
  • 이러한 점을 고려해 React 라이프사이클 환경에서 Map을 사용하는 방법을 알아보고자 합니다.

Google Map JavaScript API

React 환경에서 Map을 구현하기 전에 Google에서 제공해주는 JavaScript API를 살펴보겠습니다.
Google Map Document를 보면 Map을 생성하는 방법은 아래와 같습니다.

let map: google.maps.Map;
async function initMap(): Promise<void> {
//@ts-ignore
const { Map } = await google.maps.importLibrary("maps");
map = new Map(document.getElementById("map") as HTMLElement, {
center: { lat: -34.397, lng: 150.644 },
zoom: 8,
});
}

initMap();
export {};

위 코드를 통해 Map을 생성함을 알 수 있고 생성된 Map을 사용하기 위해서는 map 변수를 통해 접근할 수 있습니다.
이 코드를 React 환경에서 쓰기 위해서는 어떤 방법으로 접근해야 할 지 알아보겠습니다.

React에서 Map을 사용하기 위한 방법

리액트의 라이프사이클은 크게 mount, update, unmount로 나눌 수 있습니다.
위에서 살펴본 Google Map API를 사용하기 위해서는 mount 시점에 Map을 생성하며 update 시점에서의 최적화를 고려해야 합니다.

또한, Map API를 호출하는 시점 이전에는 로딩 처리를 하며 Map API를 호출하는 시점 이후에는 Map의 기능들을 사용할 수 있도록 구현해야 합니다.

Map API 호출 이전 시점

리액트 DOM이 생성되고 난 이후에 Map을 생성해야 합니다.

제공되는 map의 생성 방식은 DOM에 의존적이여서 DOM이 생성되기 전에 Map API를 호출하여 생성하면 에러가 발생하게 되고, 이에 대한 로딩 처리를 해주어야 합니다.

// Map.tsx
const [map, setMap] = useState<google.maps.Map | null>(null);
const [loading, setLoading] = useState<boolean>(true);
useEffect(() => {
async function initMap(): Promise<void> {
//@ts-ignore
const { Map } = await google.maps.importLibrary("maps");
setMap(
new Map(document.getElementById("map") as HTMLElement, {
center: { lat: -34.397, lng: 150.644 },
zoom: 8,
})
);
setLoading(false);
}
initMap();
}, []);

if (loading) return <div>loading...</div>;
return <div id="map" />;

위 코드를 통해 Map을 생성하고, Map이 생성되기 전에는 로딩을 표시하도록 구현할 수 있습니다. 다만 이렇게 했을 때 단순하게 Map을 생성만 하게 됩니다. 이를 위해 Map이 생성된 이후에 기능들을 사용할 수 있도록 구현해야 합니다.

Map API 호출이후 시점

Map이 생성된 이후에는 Map의 기능들을 사용할 수 있습니다. 이를 위해 Map이 생성된 이후에 기능들을 사용할 수 있도록 구현해야 합니다.

여러 방법이 있겠지만 여러 Map 라이브러리를 살펴보면 Wrapper를 만들고 Map API 호출이후로 다른 기능들을 render를 하는 방식으로 구현되어 있습니다.

저는 google maps에서 제공해주는 @googlemaps/js-api-loader를 활용하여 JavaScript Map API를 호출하여 검증하는 Wrapper를 만들어보았습니다.

해당 라이브러리를 사용한 이유는 싱글톤 패턴으로 구현되어 있어 여러번 호출해도 한번만 호출되고, Map API 호출 이후 다른 기능들을 render할 수 있도록 구현되어 있어 효율적으로 사용할 수 있기 때문입니다.

// GoogleMapWrapper.tsx
const GoogleMapWrapper = ({ children }) => {
const [status, setStatus] = useState<google.maps.Status>("SUCCESS");
useEffect(() => {
const googleMapResponse = new Loader(options);
const setStatusAndExecuteCallback = (status: Status) => {
if (callback) callback(status, googleMapResponse);
setStatus(status);
};
setStatusAndExecuteCallback(Status.LOADING);
googleMapResponse.load().then(
() => setStatusAndExecuteCallback(Status.SUCCESS),
() => setStatusAndExecuteCallback(Status.FAILURE)
);
}, []);

if (status === Status.SUCCESS && children) return <>{children}</>;
return <></>;
};

// MapComponent.tsx
const MapComponent = () => {
const ref = useRef(); // Map을 생성할 DOM을 참조하기 위한 ref
useEffect(() => {
new window.google.maps.Map(ref.current, {
center,
zoom,
});
});
return <div ref={ref} id="map" />;
};

// Map.tsx
const Map = () => {
return (
<GoogleMapWrapper>
<MapComponent />
</GoogleMapWrapper>
);
};

위 코드의 흐름을 살펴보면 아래와 같습니다.

  1. GoogleMapWrapper에서 Google Map API를 호출하게 된다.
  2. Google Map API 호출이 성공하면 MapComponent를 render한다.
  3. MapComponent에서 Map을 생성한다.

이를 통해 Google Map API 호출 이후 시점에 Map을 생성하여 사용할 수 있게 되었습니다.
더 나아가서 Google Map에서 제공하고 있는 Marker, InfoWindow 등 기능들을 사용할 수 있도록 구현하고 최적화를 고려해보겠습니다.

MapComponent의 기능 확장

MapComponent에서 Map을 생성하고, Marker, InfoWindow 등의 기능을 사용할 수 있도록 추상화하여 구현해보겠습니다.

// MapComponent.tsx
export const MapComponent = ({ children, ...options }) => {
const map = useState<google.maps.Map | null>(null);
const mapRef = useRef();

useEffect(() => {
if (!mapRef.current) return;
const initialMap = new window.google.maps.Map(mapRef.current);
setMap(initialMap);
}, [map]);

return (
<>
<div ref={mapRef} style={style} />
{Children.map(children, (child) => {
// children으로 받은 Map 기능들을 cloneElement를 통해 map을 전달하여 사용할 수 있도록 구현.
if (isValidElement(child)) {
return cloneElement(child as ReactElement<google.maps.MapOptions>, {
map,
});
}
return null;
})}
</>
);
};

// Map.tsx
const Map = () => {
return (
<GoogleMapWrapper>
<MapComponent>
<Clusters />
{/* 조건문을 통해 분기도 가능하다. */}
{isVisibleMarker && <VehicleMarkers />}
<VehicleInfoWindows />
<PolyLines />
{/* 맵 기능들을 활용할 수 있다. */}
</MapComponent>
</GoogleMapWrapper>
);
};

위와 같이 MapComponent에서 Map을 생성하고, Children으로 받은 Map 기능들을 cloneElement를 통해 map을 전달하여 사용할 수 있도록 구현하였습니다. 더 나아가서 실시간으로 업데이트되는 Marker 기능을 구현하기 위해 onIdle 함수를 통해 Map의 상태를 변경하는 방법을 살펴보겠습니다.

onIdle 함수를 통한 Map 상태 변경

google.maps.Map에서 제공하는 onIdle 함수를 통해 Map의 상태를 변경할 수 있습니다.
이를 리액트 환경에서 사용하기 위해 useEffect를 통해 onIdle 함수를 등록하고, onIdle 함수에서 Map의 상태를 변경하는 함수를 호출하도록 구현하였습니다.

// useControlledStateMap.ts
const [map, setMap] = useState<google.maps.Map | null>(null);
const [mapCenter, setMapCenter] = useState<LatLngLiteral | null>(null);
const [mapZoom, setMapZoom] = useState<number | null>(null);
const [mapBounds, setMapBounds] = useState<LatLngBoundsLiteral | null>(null);
const onIdle = () => {
// Map의 상태를 변경하는 함수를 호출한다.
const _zoom = map.getZoom();
const _center = map.getCenter();
const _bounds = map.getBounds();
if (_center) {
const { lat, lng } = _center.toJSON();
setMapCenter({
lat,
lng,
});
}
if (_zoom) {
setMapZoom(_zoom);
}
if (_bounds) {
const { east, north, south, west } = _bounds.toJSON();
setMapBounds({
east,
north,
south,
west,
});
}
};

useEffect(() => {
if (!map) return;
// onIdle 함수를 Map Event Listener에 등록한다.
map.addListener("idle", onIdle);
}, [map]);

이 외로 Marker 컴포넌트와 같이 맵의 구성 요소가 바뀌면서 onIdle 함수가 계속 실행되는 사이클을 가지게 되는데 이를 해결하기 위해 useDeepCompareEffect 개념을 활용하여 최적화를 고려해보겠습니다.

useDeepCompareEffect 개념을 활용한 최적화

useDeepCompareEffect란?

useEffect와 동일한 기능을 가지고 있지만, useEffectdeps에 전달된 값이 변경되면 useEffect가 실행되지만, useDeepCompareEffect는 깊은 비교를 통해 값이 변경되었는지 확인하고 변경되었을 때에만 useEffect가 실행되도록 구현되어 있습니다.

  • 실제로 발생했던 이슈 중 하나로, 맵에서 제공하는 Center값과 Zoom값이 변경되면서 onIdle 함수가 계속 실행되는 사이클이 발생했고, onIdle에서 실행되는 Map 상태 변경 함수가 계속 실행되어 성능에 문제가 발생했습니다.
  • 이를 해결하기 위해 useDeepCompareEffect를 활용하여 맵의 zoom, center, bounds가 변경되어도 onIdle 함수가 실행되지 않도록 구현하였습니다.
// useDeepCompareEffect.ts
import { createCustomEqual } from "fast-equals";
const deepCompareEqualsForMaps = createCustomEqual((deepEqual) => (a, b) => {
if (
isLatLngLiteral(a) ||
a instanceof google.maps.LatLng ||
isLatLngLiteral(b) ||
b instanceof google.maps.LatLng
) {
return new google.maps.LatLng(a).equals(new google.maps.LatLng(b));
}
return deepEqual(a, b);
});

const useDeepCompareMemoize = (value) => {
const ref = React.useRef();
// LatLngLiteral과 같은 객체를 비교하기 위해 deepCompareEqualsForMaps를 사용합니다.
if (!deepCompareEqualsForMaps(value, ref.current)) {
ref.current = value;
}

return ref.current;
};

export const useDeepCompareEffectForMaps = (
callback: React.EffectCallback,
dependencies: any[]
) => {
React.useEffect(callback, dependencies.map(useDeepCompareMemoize));
};

// useControlledStateMap.ts
useDeepCompareEffectForMaps(() => {
if (!map) return;
map.setOptions({
theme: mapTheme,
// ... 기타 옵션들
});
}, [map]);

결과적으로 Map의 Lat, Lng가 변경되어도(Center, Bounds 등) onIdle 함수가 실행되지 않도록 구현하였고 효율적인 렌더링을 할 수 있게 되었습니다.

결론

  • 대부분의 제공하는 라이브러리 형태는 Vanila JavaScript 환경에서 사용할 수 있도록 예시가 구현되어 있습니다.
  • 사용할 수 있는 JavaScript Map API가 제공되어도 이를 React 환경에서 사용하기 위해서는 어떤 방식으로 접근해야 할 지 고민해야 합니다.
  • 시중에 제공되는 라이브러리를 살펴보면서 어떤 방식으로 접근해야 할 지 고민해보고, 이를 어떻게 추상화하여 사용할 수 있을 지 고민해보는 것이 중요합니다.

reference

· 약 10분
최민석

서론

  • 프로젝트를 진행하면서 Google Map API를 활용해 위도, 경도를 주소로 변환하는 로직을 필요로 했습니다.
  • 제공하는 API는 매 건당 6.6원의 비용이 발생했었는데 서비스 사용자가 많을 수록 굉장히 많은 호출이 이루어지다보니 비용적인 부담이 있었습니다.
  • 이러한 비용을 최적화하기 위해서는 최대한 효율적으로 API 호출을 해야 했고 해당 과정을 포스팅하게 되었습니다. 이번 포스팅에서는 API를 비용을 최적화하기 위한 방법을 알아보겠습니다.

image

문제 상황

  • 서비스에서 비용이 드는 API를 호출하는 경우가 있습니다. 사용자가 많은 서비스의 경우는 비용이 기하급수적으로 늘어날 수 있을 뿐만 아니라 페이지 첫 접속때 비용이 드는 데이터 패칭이 존재하게 될 경우 비용 남비가 더 심할 수 있는 문제가 발생합니다. 단순히 새로고침을 통해 한 명의 사용자가 여러 번 API를 호출하는 경우도 있고 악의(?)를 갖고 무한 새로고침을 하게 된다면 100번 이상의 API 호출을 할 수 있습니다. 즉, 비용이 많이 드는 API를 호출하는 경우에는 최대한 적은 API 호출을 하도록 최적화를 해야 합니다. 현재 문제 상황을 정리하면 다음과 같습니다.

    • 비용이 많이 드는 API를 호출하는 경우
    • 사용자가 많은 서비스의 경우 비용이 기하급수적으로 늘어날 수 있음.
    • 페이지 첫 접속때 비용이 드는 데이터 패칭이 존재하게 될 경우 비용 남비가 더 심할 수 있음.
    • 새로고침을 통해 한 명의 사용자가 여러 번 API를 호출하는 경우 (악의를 가지고 무한 새로고침을 하게 된다면 짧은 시간에 100번 이상의 API 호출을 할 수 있음)
    • 비용이 많이 드는 API를 호출하는 경우에는 최대한 적은 API 호출을 하도록 최적화를 해야 함.

문제 해결을 위해 API 비용을 최소화하기 위한 방법을 고민했고 API 호출 최소화를 위한 데이터를 캐싱으로 방향성을 잡고 작업했습니다.

queryKey를 통한 데이터 캐싱 방법

  • React 환경에서 데이터 패칭 도구로 react-query를 활용했습니다. useQuery 메소드에 지원하는 queryKey를 활용하여 데이터 캐싱을 유용하게 관리할 수 있는 기능을 활용하려고 했습니다.
    useQuery(['minsgy', { status, page }], ...)
useQuery(['minsgy', { page, status }], ...)
useQuery(['minsgy', { page, status, other: undefined }], ...)

위와 같은 방식을 통해 페이지 접속 시 API 호출은 최소화할 수 있었고 불필요한 API 호출은 막을 수 있었습니다.

그러나 queryKey로만 원하는 방식의 데이터 캐싱 관리는 불가능했습니다. 새로고침을 통하면 클라이언트 상태가 초기화되어 새로운 API 호출이 이루어지는 문제가 발생했습니다.

Storage를 통한 데이터 캐싱 방법

클라이언트 상태를 유지해야 했고 Fetching한 데이터를 저장하는 방법을 고민해 추가 방안은 다음과 같았습니다.

  • Refresh를 통해 클라이언트 상태를 유지하여 추가적인 API 호출을 막는다.
  • Fetching한 유효한 데이터를 저장하고 추가적인 API 호출을 막는다.
  • +) 5분 이상 지난 데이터는 삭제 후, 새로운 API 호출을 한다.

페이지 단에서 Refresh가 발생해도 데이터를 유지할 수 있는 방법으로 web storage와 indexed DB를 고민했지만 100줄 이하의 작은 데이터를 저장하는 것이 목적이여서 WebStorage를 활용했습니다.

queryKey + timestamp + fetching response data

유효한 데이터인지 확인하기 위한 timestamp, react-query와 함께 사용하기 위한 queryKey, 데이터 패칭한 response를 저장하기 위한 data로 묶어서 관리하는 방식으로 구현했습니다.

  • 아래와 같은 useCustomCacheQuery 커스텀 훅을 만들어서 timestamp를 통해 유효한 데이터인지 확인하고 유효한 데이터가 아닌 경우 API 호출을 하도록 구현했습니다.
  • 추가적으로 initialData react-query option를 통해 캐싱된 데이터가 있으면 캐싱된 데이터를 사용하도록 구현했습니다.
 // useCustomCacheQuery.ts
const storedData = JSON.parse(sessionStorage.getItem(queryKey));
const timestamp = storedData ? storedData.timestamp : Date.now();
const queryKey = storedData ? storedData.queryKey : customQueryKey;

// 저장된 데이터가 있고, timestamp가 5분 이내인 경우
if (storedData && timestamp && Date.now() - timestamp < 5 * 60 * 1000) {
...
}

// 저장된 데이터가 있지만, timestamp가 5분 이상인 경우
if (storedData && timestamp && Date.now() - timestamp >= 5 * 60 * 1000) {
...
}

// 저장된 데이터가 없거나, timestamp가 5분 이상인 경우
if (!storedData || !timestamp || Date.now() - timestamp >= 5 * 60 * 1000) {
...
}

return useQuery(queryKey, fetchFunction(), {
initialData: storedData ? storedData.data : undefined, // 캐싱된 데이터가 있으면 캐싱된 데이터를 사용
...
})

나올 수 있는 문제점과 고민 해야 할 점

이러한 캐싱 데이터를 사용할 때 나올 수 있는 문제점은 다음과 같았습니다.

  • 사용자가 확인하고 있는 데이터가 신뢰할 수 있는 데이터인지 확인하기 어렵다.
  • 캐싱된 데이터가 실제 데이터와 다를 수 있다.

해당 로직을 사용하기 위해서는 서비스 특징을 고민하고 적절한 방법을 선택해야 합니다.

  • 캐싱 된 데이터를 제공할 때 문제가 될 수 있는가?
  • 데이터 정합성이 중요한가? (데이터가 실시간으로 변경되는 경우)
  • 데이터 정합성이 중요하지 않은가? (데이터가 실시간으로 변경되지 않는 경우)

에 대한 서비스 특징을 고려하여 적절한 방법을 선택해야 한다고 생각합니다.

결론

API 비용을 최소화하기 위해 데이터 캐싱을 사용하는 방법에 대해 고민해보았습니다.

  • queryKey를 통한 데이터 캐싱 방법
  • Storage를 통해 유효한 데이터 캐싱을 위한 방법

위의 두 가지 방법을 통해 API 비용을 최소화할 수 있었습니다.

하지만 queryKey로만 원하는 방식의 데이터 캐싱 관리는 불가능했고 Storage를 통해 유효한 데이터 캐싱을 위한 방법을 사용했습니다.

또한, 캐싱된 데이터가 실제 데이터와 다를 수 있다. 라는 문제점이 있었습니다.

react-query에서 제공하는 persistance 기능

  • react-query에서는 persistQueryClient 기능을 제공합니다.
  • 직접 useCustomCacheQuery 커스텀 훅을 만들지 않고도 캐싱된 데이터를 사용할 수 있습니다.
  • 아직 react-query v4에서는 persistQueryClient 기능이 stable 버전이 아니기 때문에 사용에 주의가 필요합니다.

· 약 9분
최민석

서론

프로그래밍 패러다임 중 하나인 함수형 프로그래밍은 방대한 데이터들을 효율적으로 사용하기 위해 도입한 하나의 방법입니다. 변경가능한 상태를 최대한 제거하려고 노력한 프로그래밍 언어라고 볼 수 있습니다. 이 글을 통해 함수형 프로그래밍에 대해 이해하고 JavaScript에서 활용할 수 있는 함수형 프로그래밍 방법이 무엇인 지 알아 여러 스레드에서 문제 없이 동작하는 프로그램을 쉽게 작성해볼 수 있습니다.

함수형 프로그래밍과 객체지향 프로그래밍

image

함수형 프로그래밍과 객체지향 프로그래밍은 자주 비교되어 나오는 패러다임 개념입니다.

두 가지 패러다임 다 모두 유효한 패러다임이고 많이 사용하고 있는 프로그래밍 방법입니다. 이 두가지를 비교하는 것보다는 "어떠한 상황에서 많이 사용해야 할 지"에 대한 고민을 통해 개발자는 고민하고 정해서 정할 수 있습니다.

함수형 프로그래밍의 장/단점

image

함수형 프로그래밍의 장점으로 효율성과 지연 평가(Lazy Evaluation), 중첩 함수(고차 함수), 순수 함수를 통한 버그 없는 코드, 병렬 프로그래밍**과 같은 다양한 이점을 제공합니다.

해당하는 함수들은 언제나 쉽게 호출하고 재사용할 수 있다는 장점이 있고 명령형 방식의 반복 작성을 필요로 하지 않습니다. 그리고 코드 자체가 무엇을(What) 에 초점을 맞춰 개발하기 때문에 과정에 대한 이해가 필요 없다는 장점이 있습니다.

image

위와 같이 좋은 장점들이 있지만 가장 큰 문제인 허들이 높다는 점입니다. (함수의 함수의.....?!)

기능적인 관점에서 코드를 접근하기 위해서 모듈화를 진행하게 되는데 이를 객체 지향 용어로 이해하기 쉽지만, 함수형 프로그래밍의 정의는 "데이터 조작" 에 초점이 맞춰져 있습니다. 재사용성과 순수 함수로서의 역할을 구성하기는 쉽지 않고 재귀(Recursion), 모나드(Monad), 펑터(Functor), 커링(curry) 많은 함수형 개념을 이해해야 합니다. 그래서 종합적으로 경험과 학습이 많이 필요한 패러다임입니다.

두 가지를 비교해야 할까?

image

사실 두 패러다임을 비교하기에는 고유한 문제들이 있습니다.

함수형 프로그래밍의 경우, 복잡한 로직을 구상하게 되면 너무 많은 종속성 주입 된 코드들이 존재하며, OOP에는 너무 많은 레이어를 통한 코드가 나오게 됩니다.

결국 각 장단점을 고려해서 주입해야 하는 개발자의 몫이라고도 생각이 됩니다.

함수형 방식의 JavaScript

사실 JavaScript를 통한 완벽한 함수형 프로그래밍은 어려울 수 있지만 전반적인 개념들을 정의하여 사용할 수 있습니다.

// map, reduce, filter는 함수형에 속한다.
// 기존 변수에 대한 사이드 이펙트로 없도록 구현하는 것이 원칙.

// map
const arr = [1, 2, 3, 4, 5];
const map = arr.map(function (x) {
return x * 2;
}); // [2,4,6,8,10]

// filter
const arr = [4, 15, 377, 395, 400, 1024, 3000];
const arr2 = arr.filter((v) => v % 5 === 0);
console.log(arr2); // [15, 395, 400, 3000]

// reduce
let arr = [9, 2, 8, 5, 7];
let sum = arr.reduce((pre, val) => pre + val);
console.log(sum); // 31

JavaScript에서 함수형 개념들을 어떻게 다루는 지 정의 해봅니다.

평가

평가는 코드가 계산(Evalution) 되어 값을 만드는 것을 말합니다.

1 + 2 = 3
(1 + 2) + 4 = 7
[1, 2+3] = [1, 5]
[1, 2, [3, 4]] = [1, 2, Array(2)]

일급 객체(일급)

값, 변수, 함수 프로퍼티, 함수 결과를 사용할 수 있음을 말합니다. 함수가 값으로도 다루어질 수 있으며 조합성과 추상화 도구로서 기본적인 함수형 프로그래밍의 틀이라 할 수 있습니다.

// 일급
const value = 10;
const add_value_10 = (a) => a + 10;
const result = add_value_10(value);
console.log(result); // 20;

// 일급 객체
const add_5 = (a) => a + 5;
console.log(add_5); // a => a + 5;
console.log(add_5(5)); // 10

const func_1 = () => () => 1;
console.log(func_1()); // () => 1

const func_2 = func_1();
console.log(func_2); // () => 1
console.log(func_2()); // 1

고차 함수와 합성 함수

함수를 값으로 다루는 함수, 함수의 반환 값으로 함수를 사용(Closer)하는 걸 말할 수 있습니다. React에서 사용하는 고차 컴포넌트(HOC)가 이에 해당 됩니다.

// 함수 로직을 보내서 개조
const higher-order = func => func(1);
const add_2 = a => a + 2;
console.log(higher-order(add_2)); // 3
console.log(higher-order(a => a - 1)); // 0

// Closer + 합성 함수
const add_func = a => b => a + b;
const add_10 = add_func(10);
console.log(add_10(5)); // 15
console.log(add_10(10)); // 20
console.log(add_function(10)(5)); // 15

그 외로도 함수형 프로그래밍의 개념들을 적용한 사례를 살펴보겠습니다.

불변성

함수형 프로그래밍에서는 함수 외부의 데이터를 변경하지 않도록 합니다.

만약 데이터 변경이 필수적일 경우, 원본 데이터를 변경하지 않고 그 데이터의 복사본을 만들어 변경 작업을 진행할 수 있습니다.

const red = { name:'red' };

// 원본 데이터를 변경하는 방법
funcion changeColor(color,name){
color.name = name;
return color;
}
console.log(changeColor(red,'yellow')); //{name:'yellow'}
console.log(red); //{name:'yellow'}

// 불변성을 지키는 방법.
function changeColor(color,name){
return Object.assign({},color,{name}); // 복사해서 사용하는 slice도 가능합니다.
}

console.log(changeColor(red,'yellow')); //{name: 'yellow'}
console.log(red.name); //{name: 'red'}

그 외로도 JavaScript에서 지원하는 불변성을 기반해 데이터 복사본을 제공하는 메소드를 활용할 수 있습니다.

// map
const arr = ["foo", "hello", "diamond", "A"];
const arr2 = arr.map((v) => v.length);
console.log(arr2); // [3, 5, 6, 1]

// filter
const arr = [4, 15, 377, 395, 400, 1024, 3000];
const arr2 = arr.filter((v) => v % 5 === 0);
console.log(arr2); // [15, 395, 400, 3000]

// reduce
let arr = [9, 2, 8, 5, 7];
let sum = arr.reduce((pre, val) => pre + val);
console.log(sum); // 31

Reference

· 약 12분
최민석

서론

저는 프론트엔드 주니어 개발자로 활동하며 JavaScript에 대한 중요성을 매번 느꼈습니다.

JavaScript에서 데이터를 관리하는 방법, 객체 지향을 고려할 수 있는 방법 등 기본적인 원리가 뒷받침되어 설계를 해야 여러 이슈 사항에도 대응할 수 있었습니다.

또한, 웹이라는 분야에서 빠르게 변화하는 상황에서 React와 같이 어떠한 라이브러리/프레임워크가 유행할 지는 모르기 때문에 기초가 되는 JavaScript에 대한 이해가 필요하다 생각해 런타임까지 분석해보았습니다.

이 포스트를 통해서 배경 지식이 궁금한 JavaScript 사용해오신 프론트엔드 개발자분들께 도움 되기를 바랍니다.

JavaScript

웹 페이지 (HTML/CSS)의 보조 기능을 수행하기 위해 사용 된 언어입니다. 지속된 발전으로 브라우저 이외의 환경에서도 동작시킬 수 있는 환경이 생기게 되었습니다.

JavaScript 등장과 표준화

처음 JavaScript는 동적인 페이지 렌더링도 아니라 단순히 서버로부터 HTML, CSS를 렌더링하는 수준으로만 한정적으로 사용했습니다.

이후 JavaScript의 핵심 원리인 비동기(Asynchronous) 개념을 담은 **Ajax(Asynchronous JavaScript and XML)**가 등장하게 되면서 우리가 알고 있는 기능을 활용하게 되었습니다.

기존 동기적 방식으로 HTML 전체를 다운받아 화면 전환을 했지만 바뀌지 않는 데이터를 포함해 불필요한 데이터가 포함되는 경우가 생겼고 통신 효율과 UX 면에서 옳지 않은 문제가 발생했습니다.

이러한 문제는 비동기를 통해 동적인 웹 페이지를 구성하면서 변화 된 데이터만 받아와 순간적으로 깜빡이는 현상이 없어져 UX로도 좋은 결과를 낼 수 있었습니다. 이러한 결과로 웹 브라우저에서도 데스크탑 애플리케이션과 유사한 퍼포먼스를 내며 주목받기 시작했습니다.

그러나 표준화되지 않은 JavaScript가 브라우저에 적용되면서 크로스 브라우징 이슈가 발생하기 시작했고 모든 브라우저에서 정상 작동하는 웹 페이지 구성이 어려웠습니다.

이를 보완하기 위해 ECMAScript 명으로 JavaScript 표준화가 진행되어 현재 많이 사용하는 ES6, ES5와 같은 문법들이 사용되고 있습니다.

V8, Node.js의 등장

Ajax를 통한 프로그래밍 언어로서 가능성을 확인 한 구글(Google)은 JavaScript로 웹 애플리케이션을 빠르고 정확하게 동작하는 엔진의 필요성을 느꼈고 그렇게 완성 된 오픈소스 엔진인 V8이 등장하게 되었습니다.

이를 통해 JavaScript는 데스크탑 애플리케이션과 비슷한 성능과 퍼포먼스를 보여주었고 웹 애플리케이션 프로그래밍 언어로 정착되게 된 계기가 됩니다.

이후 넓은 확장성에 대한 가능성을 넓혀준 V8 런타임 라이브러리인 Node.js의 등장으로 브라우저 이외의 환경에서도 동작할 수 있도록 독립시킨 역할을 해주었습니다.

기본적으로 비동기 I/O를 지원하고 싱글 스레드(Single Thread) 이벤트 루프를 기반하여 동작하고 실시간 데이터 처리에는 효과적이지만 CPU 사용률이 높은 애플리케이션에는 권장하지 않는 방식입니다. 그리고 Node.js의 개발 시점이 JavaScript 모듈의 표준이 CommonJS로 이루어져 ES Module이 새로운 표준이 된 현재로서는 부족한 부분입니다. 그리고 TypeScript가 필수불가결로 사용하는 상황에 지원하지 않는 단점도 존재합니다.

이러한 Node.js의 단점을 보완하고 발전 된 런타임(Runtime) 라이브러리인 Deno를 소개합니다.

Deno

image

Deno가 등장하게 된 건 2020년 5월이며, Node.js의 단점을 보완하고자 TypeScript 지원, ES Module 지원, 브라우저와 Node.js의 호환성, 보안 문제, 빠른 실행 속도 개선과 명시적인 패키지 관리를 개선하며 등장하게 되었습니다.

Promise

Node.js의 내장된 여러 비동기 로직은 Promise API를 활용하지 않고 예전 방식의 Callback 패턴을 작성하고 있습니다. 이로 인해 node async api가 노후화되어 아쉬운 퍼포먼스를 보여주게 됩니다.

그렇지만 Deno의 경우 Promise API를 기본으로 사용하고 있습니다. 이로 인해 더욱 빠른 퍼포먼스를 보여주고 있습니다.

// deno - async 구문이 자동으로 적용됩니다.
const data = await fetch("URL");
const result = await data.json();
console.log(result);

Packages, Npm

License, repository, description 등 다양한 정보를 담고 있는 package.json은 불필요한 정보가 많습니다. 실제로는 파일과 URL만 사용하면 종속성을 나열할 필요가 없어집니다.

Deno는 이러한 package.json을 제거하고 URL만으로 종속성을 나열할 수 있도록 하였습니다.

또한 npm(closed source) 를 사용하지 않아 독립성을 높일 수 있다는 장점이 있습니다. 이를 통해 Github, GitLab, Bitbucket 등 다양한 플랫폼에서 가져와 사용할 수 있습니다.

// node.js
const moment = require("moment");
console.log(moment.version);

// deno
import { moment } from "https://deno.land/x/deno_moment@1.0.0/moment.ts";
console.log(moment.version);

Deno가 node.js를 대체할 수 있을까?

image

현재 2022년 9월 기준으로 Deno 서비스는 v1.25.2 릴리즈 중입니다.

최근까지도 업데이트를 하는 만큼 차세대 JavaScript 런타임으로 발돋움 할 수 있도록 꾸준한 개발에 있습니다. 과거 잔재인 CommonJS 모듈을 변경하고 사용성이 많은 TypeScript를 제공하는 만큼 앞으로의 기대가 있는 런타임 라이브러리라고 기대할 수 있습니다.

그러나 현재 Deno에 상응하는 TypeScript package 지원이 아쉬운만큼 사용성에 있어서 node.js가 압도적인 시장을 차지하고 있습니다. 점점 TypeScript 프로젝트 시장이 넓어진다면 충분히 사용할만한 매력적인 라이브러리라고 생각합니다.

이후 더 빠른 속도를 위해 탄생한 런타임 라이브러리를 소개합니다.

Bun의 등장

image

새롭게 JavaScript 런타임 및 패키지 관리자인 Bun이 등장했습니다.

기존 V8 엔진을 활용하던 Node, Deno와 비교되는 JavaScriptCore 기반으로 구축되어 빠르게 동작합니다. 저수준 프로그래밍 언어인 Zig로 작성되어서 가능한 성능입니다.

image

특징으로는 node.js 모듈에서 사용하는 node_modules를 그대로 사용하며 npm과 호환할 수 있는 패키지 매니저를 포함하게 됩니다. 그래서 node.js의 사용법과 유사합니다. 또한 commonJS와 ESM 둘 다 지원하며 JavaScript/TypeScript를 지원하여 개발 환경에 대한 확장성이 넓습니다.

이렇게 본다면 Demo와 Node.js가 가진 문제점을 보완하고 있지만, 아직 v1도 나오지 않은 beta version인 상태입니다. 현재 런타임 속도만 바라본다면 괜찮지만 보안 및 다른 이슈에 대해 어떻게 대응할 지 production 환경에서 적용하기에는 시간이 필요해보입니다.

Question List

  1. 자바스크립트는 무슨 언어인가? (언어의 특징, 본인이 생각하는 자바스크립트의 특징도 좋음)
  2. 인터프리터 언어와 컴파일 언어에 대해 대답하고 차이점은 무엇인가요?
  3. Optional Chaining과 Non-null assertion operator에 대해 아는 대로 설명해주세요.
  4. !!, ??, && 연산자에 대해 설명해주세요.
  5. 메모리 할당과 메모리 참조, 가비지 콜렉터에 대해 아는 대로 설명해주세요. 원시타입과 객체타입을 가지고 설명해주세요.
  6. var, let, const에 대해 아는 대로 설명해주세요.
  7. 호이스팅이 무엇이고 일어나는 과정에 대해 아는 대로 설명해주세요.
  8. if (!data)if (data === undefined)의 차이점은 무엇인가요?

Reference

deno bun
deno vs node.js 비교하기
여기어때 Tech Blog

· 약 14분
최민석

서론

JavaScript에서 지원하지 않는 상속 개념인 객체지향 프로그래밍을 구현하기 위해 Prototype을 사용해야 합니다. Prototype을 통해서 넓은 확장성을 가지는 객체지향 프로그래밍을 구현할 수 있습니다. 이번 포스팅에서는 Prototype에 대해 알아보겠습니다.

Property Attribute

JavaScript에서는 객체의 프로퍼티에 대한 속성을 설정할 수 있습니다. 아래와 같은 내부 슬롯과 내부 메소드를 가지게 됩니다.

[[Configurable]]: 프로퍼티의 삭제, 프로퍼티 어트리뷰트의 변경을 허용할지 여부를 나타냅니다.

[[Enumerable]]: 프로퍼티가 열거 가능한지 여부를 나타냅니다.

[[Writable]]: 프로퍼티의 값을 변경할 수 있는지 여부를 나타냅니다. 값이 false인 경우, 읽기 전용 프로퍼티가 됩니다.

[[Value]]: 프로퍼티의 값입니다.

[[Prototype]]: 프로토타입 객체입니다.

[[Extensible]]: 프로토타입 객체에 프로퍼티를 추가할 수 있는지 여부를 나타냅니다.

[[Class]]: 프로퍼티의 클래스입니다.

[[PrimitiveValue]]: 프로퍼티의 원시 값입니다.

[[Call]]: 프로퍼티가 함수인 경우 호출할 수 있는지 여부를 나타냅니다.

[[Construct]]: 프로퍼티가 생성자 함수인 경우 호출할 수 있는지 여부를 나타냅니다.

[[HasInstance]]: 프로퍼티가 생성자 함수인 경우 인스턴스를 생성할 수 있는지 여부를 나타냅니다.

내부 슬롯(프로퍼티 어트리뷰트) 다루기

기본적으로 내부 슬롯은 JavaScript 엔진 내부에 존재하기 때문에 접근하거나 호출할 수 있는 방법이 존재하지 않습니다. 그렇지만 일부 내부 슬롯과 내부 메소드에 한해서 참조 방법으로 접근할 수 있습니다.

const object = {}

object.[[Prototype]] // "error" 내부 슬롯은 JS 엔진 내부 로직으로 접근 불가
object.__proto__ // Object.prototype 참조할 수 있는 내부 메소드 제공

위와 같은 방법 외에도 JavaScript 엔진 특징으로 프로퍼티가 생성 될 때 이러한 프로퍼티를 상태 관리할 수 있는 프로퍼티 어트리뷰트가 기본 값으로 자동 정의되게 됩니다. value, writable, enumerable, configurable와 같은 상태를 확인할 수 있습니다. 즉, 해당되는 내부 슬롯이 생성되게 된다는 뜻입니다.

이러한 프로퍼티 어트리뷰트 값 상태를 확인하는 메소드는 getOwnPropertyDescript 가 있습니다.

const minsgy = {
age: 24,
};
console.log(Object.getOwnPropertyDescriptor(minsgy, "age"));
// { value: 24, writable: true, emumerable: true, configurable: true }

이와 같이 내부 슬롯에서 존재하는 [[Prototype]]을 통해 JavaScript 동작 원리를 알아보겠습니다.

프로토타입 Prototype

JavaScript에서는 클래스 기반을 사용하고 있는 다른 언어(Java, C++)와 달리 프로토타입 기반의 프로그래밍 언어입니다. 클래스에서 사용하는 체 지향의 상속 개념을 프로토타입으로 사용할 수 있습니다.

prototype chain

const student = {
name: "Lee",
score: 90,
};

console.log(student.hasOwnProperty("name")); // true
console.log(student.__proto__ === Object.prototype);
console.dir(student);

image

student 객체의 경우 hasOwnProperty 프로퍼티가 존재하지 않습니다. 그렇지만 사용할 수 있는 이유는 __proto__ 프로퍼티에 의해서 사용할 수 있게 됩니다.

student 객체는 __proto__ 프로퍼티로 부모 객체(prototype 객체)인 Object.prototype를 가리키고 있기 때문에 가능합니다. 이를 통해 클래스 객체 지향의 상속을 활용할 수 있게 됩니다.

같은 이유로 Array.prototype을 상속받는 함수나 객체들은 다음과 같은 메소드를 상속받아 사용할 수 있습니다.

이러한 개념을 prototype chain이라고 부릅니다.

image

이처럼 모든 객체는 본인의 프로토타입 객체를 가리키는 [[Prototype]] 내부 슬롯을 갖으면서 상속을 사용할 수 있습니다. 그러나 함수 객체만의 prototype 프로퍼티가 따로 존재합니다.

prototype property

function Person(name) {
this.name = name;
}

const foo = new Person("Lee");

console.dir(Person); // prototype 프로퍼티가 있다.
console.dir(foo); // prototype 프로퍼티가 없다.

prototype 프로퍼티는 함수 객체만 가지고 있으며, 함수 객체가 생성자로 사용될 때 이 함수를 통해 생성 될 객체의 부모 역할을 하는 객체를 가리키게 됩니다.

constructor property

프로토타입 객체는 constructor 프로퍼티를 갖게됩니다. 이를 통해 자신을 생성한 객체를 가리킬 수 있습니다.

function Person(name) {
this.name = name;
}

const foo = new Person("Lee");

// Person() 생성자 함수에 의해 생성된 객체를 생성한 객체는 Person() 생성자 함수이다.
console.log(Person.prototype.constructor === Person);

// foo 객체를 생성한 객체는 Person() 생성자 함수이다.
console.log(foo.constructor === Person);

// Person() 생성자 함수를 생성한 객체는 Function() 생성자 함수이다.
console.log(Person.constructor === Function);

Class 의 개념이 되는 객체를 가리키게 됩니다. 예를 들어 표현하자면 붕어빵 생성 된 인스턴스라면 붕어빵을 만든 틀을 가리킬 수 있습니다.

함수 리터럴 방식을 통한 선언

함수를 정의하는 방법은 3가지로 함수 선언식, 함수 표현식, new 연산자를 통한 생성자 함수가 존재합니다.

// 함수 표현식 (함수 리터럴 방식)
const square = function (number) {
return number * number;
};

// 함수 선언식 (기명 함수 표현식으로 변환 + 함수 리터럴 방식)
const square2 = function square(number) {
return number * number;
};

두 가지 방법 다 함수 리터럴 방식을 사용하고 이러한 방식은 생성자 함수(Function)로 생성하는 것을 단순화한거로 볼 수 있습니다. 즉, 3가지 방법 다 Function() 생성자 함수를 통해 함수 객체를 생성합니다.

new 연산자를 통해 생성 된 함수를 살펴보면 이와 같이 나타낼 수 있습니다.

function Person(name, gender) {
// 함수이기 때문에 prototype을 가지고있음.
this.name = name;
this.gender = gender;
this.sayHello = function () {
console.log("Hi! my name is " + this.name);
};
}

const foo = new Person("Lee", "male"); // 함수가 아니기때문에 내부 슬롯만

console.dir(Person);
console.dir(foo);

console.log(foo.__proto__ === Person.prototype); // ① true
console.log(Person.prototype.__proto__ === Object.prototype); // ② true
console.log(Person.prototype.constructor === Person); // ③ true
console.log(Person.__proto__ === Function.prototype); // ④ true
console.log(Function.prototype.__proto__ === Object.prototype); // ⑤ true

image (참고. poieweb - prototype)

위와 같이 foo의 경우 함수 객체가 아니기때문에 [[Prototype]] 만 존재하게 되고, Person 함수의 경우 함수 객체이기 때문에 prototype이 존재하게 됩니다. 이를 통해 어떤 방식으로 함수를 선언해도 Object.prototype 객체를 상속받아 사용하게 됩니다.

프로토타입 객체의 변경

프로토타입의 특징으로 객체를 생성할 때 프로토타입은 결정되게 됩니다. 결정된 프로토타입의 객체는 다른 임의 객체로 변경할 수 있는데 이것은 부모 객체인 프로토타입을 동적으로 변경할 수 있는 것을 의미하게 됩니다. 즉, 객체의 상속을 구현할 수 있습니다.

주의 할 점으로는 프로토타입 객체를 변경하게 됐을 시 객체 변경 시점에 따른 [[Prototype]] 바인딩 시점이 달라지게 됩니다.

function Person(name) {
this.name = name;
}

var foo = new Person("Lee");

// 프로토타입 객체의 변경
Person.prototype = { gender: "male" };

var bar = new Person("Kim");

console.log(foo.gender); // undefined
console.log(bar.gender); // 'male'

console.log(foo.constructor); // ① Person(name)
console.log(bar.constructor); // ② Object()

image (참고. poieweb - prototype)

  1. constructor 프로퍼티는 Person() 생성자 함수를 가리키고 있습니다.
  2. 프로토타입 객체 변경 이후에는 Person() 생성자 함수의 Prototype 프로퍼티가 가리키는 프로토타입 객체를 일반 객체로 변경하게 되면서 prototype.constructor 프로퍼티가 사라지게 됩니다. 결국 bar.constructor 값은 Object.prototype.constructor인 Object() 생성자 함수가 됩니다.

Person.prototypePerson 생성자 함수를 가리키지 않고 Object.prototype을 가리키게 되는 문제가 발생하게 됩니다. 의도하지 않은 프로토타입 체인이 이루어져 상속해 사용할 수 있는 메소드에 대한 오류가 발생할 수 있다는 점을 고려해야합니다.

변경되는 시점에 따라 바인딩되는데 이를 통해 만들 수 있는 코드 디자인 패턴을 소개합니다.

이를 활용한 Prototype 디자인 패턴

프로토타입의 특징인 원형이 되는 인스턴스를 사용해 새롭게 생성 할 객체의 종류를 명시해서 새로운 객체가 생성 될 시점에 인스턴스 타입을 결정하도록 하는 디자인 패턴입니다.

image (참고. poieweb - prototype)

이렇게 클라이언트는 Prototype 인터페이스를 따르는 모든 객체를 복사해서 인스턴스를 생성할 수 있습니다. 구현 클래스에 의존하지 않고, 써드 파티에 종속되지 않는 경우 사용할 수 있습니다.

예제


const Component = function (x, y, c) {
getPosX()
getPosY()
getColor()
getRadius()
... // 좌표 계산 등
}

// Component 객체 상속하기
const Circle = function (x, y, c) {
Component.call(this, x, y, c)
}

Circle.prototype = Object.create(Component.prototype)
Circle.prototype.constructor = Circle


// 추가로 필요한 변수 및 함수를 선언
Circle.prototype.radius = 0;
Circle.prototype.getRadius = function(){...}
Circle.prototype.setRadius = function(r){...}


// ---- main ---

const circle = new Circle()

circle.getPosX();
circle.getPosY();
circle.getColor();
circle.getRadius();

장점

  • 구현 클래스에 직접 연결하지 않고 객체를 복사할 수 있습니다. (컴포넌트)
  • 프로토타입 상속 되어 있어서 복잡한 오브젝트를 쉽게 구성할 수 있고 중복 코드를 제거할 수 있습니다.
  • 그 외로 객체 단위를 컴포넌트처럼 활용하여 확장성도 고려할 수 있다는 점이 매력입니다.

단점

가장 문제가 될 수 있는 단점으로 서로 의존되어 있는 관계일 시, 상속되는 프로토타입 패턴을 적용하기가 어렵습니다. 순환 참조가 발생하여 복잡한 객체를 불러 올 시 의존성을 갖고 있는 프로토타입을 활용한다면 문제가 발생할 수 있습니다.

Reference

· 약 14분
최민석

서론

JavaScript의 메모리 관리는 어려운 주제입니다. 예를 들어 JavaScript는 GC(Garbage Collector)를 사용하고 있지만, GC가 언제 어떤 메모리를 해제할지는 알 수 없습니다. JavaScript에서 메모리 관리를 잘 하려면 어떤 것들을 알아야 할 지 알아보기 위해 작성했습니다.

JavaScript 데이터와 메모리

JavaScript에서 데이터는 크게 2가지로 나눌 수 있습니다.

변경 불가능한 값(immutable value)인 원시 타입변경 가능한(mutable value) 객체 타입으로 구분할 수 있으며 정적 데이터(Static Data)를 저장하는 데이터 구조인 스택(Stack)과 동적 데이터 할당하는 힙(Heap)이 존재합니다.

이를 좀 더 자세하게 알아보겠습니다.

메모리 생명주기 과정

image 간단하게 표현하여 JavaScript에서 변수나 함수를 사용하고 필요가 없어지면 메모리에서 해제합니다.

생성한 객체(Object)에 필요한 메모리를 할당하고, 작성한 코드를 통해 변수를 읽거나 사용하는 과정으로 메모리를 사용합니다. 이후 JS 엔진을 통해 할당 된 메모리가 해제되어 메모리를 사용가능한 상태로 만들게 됩니다.

원시 타입 (Primitive Type)

image

원시 타입의 종류로 string, number, boolean, undefined, null, symbol, 참조

여기서 말하는 원시 값의 변경이 불가능하다는 건, "메모리 상 할당 된 변수 값" 을 변경할 수 없다는 의미입니다.

이러한 특징으로 원시 타입은 불변성 특징을 갖게 되고, 값을 복사한다면 새로운 메모리 주소를 할당하여 call by value 방식으로 복사되어 사용하게 됩니다.

위와 같은 의미로 아래와 같이 코드를 사용할 수 있습니다.

// 기존 저장 된 메모리에 값이 저장되는 것이 아닌 새로운 메모리 주소를 할당합니다.
let example = 3;
example = 5;
console.log(example); // 5

// 같은 값을 가지고 있지만, 서로 다른 메모리 주소를 갖고 있는 call by value
let a = 1;
let b = a;

console.log(a === b); // true

이어서 객체(object)와 함수(function)는 힙(heap)에 저장되지만, 참조(Reference)는 스택(Stack)에 저장하여 활용하게 됩니다.

객체(참조) 타입 (Object Type)

객체는 변경 가능한 값(mutable value)입니다.

앞서 사용하는 const 키워드는 상수를 만드는 걸 목적으로 합니다. 선언한 변수 재할당이 불가능하지만 객체에 할당한 변수(property) 는 변경 가능합니다.

const a = {};
a = 3; // @ERROR 상수로 선언되어 재할당이 불가능합니다.
a.name = "최민석"; // 객체를 할당한 변수에 재할당하는 것이 아니기때문에 가능합니다.
console.log(a); // { name: "최민석" }

객체를 생성하고 관리하는 방법은 비용이 많이 들게 됩니다. 그래서 객체를 변경 가능한 값으로 설계했습니다. JavaScript에서는 이를 최소화하여 메모리 효율적 소비를 높이고 성능을 개선합니다.

그러나 여러 개의 식별자가 하나의 객체를 공유하여 영향을 끼치는 문제가 발생합니다.

참조값으로 인한 문제

image

그림과 같이 getDeveloper() 함수는 developer 객체의 name property를 가리키고 있습니다. 이를 통해 발생하는 문제점을 확인해봅니다.

// 서로 다른 값이 영향을 받는 문제가 발생합니다.
// 이를 통해 코드 복잡성이 늘어나게 됩니다.
const developer = {
name: "minsgy",
age: "26",
};

function getDeveloperName() {
return developer.name;
}
console.log(getDeveloperName()); // "minsgy"
developer.name = "umin";
console.log(getDeveloperName()); // "umin"

call by reference를 기반하여 다중 식별자에 대한 문제가 발생합니다.

하나의 로직에 의존성(dependency) 을 가지고 코드 복잡성이 높아지는 문제가 발생하고 의도하지 않은 값 변경이 일어난다면 디버깅에도 어려움을 가져 유지보수에 있어서도 좋지 않은 결과를 보여줍니다.

메모리 누수가 발생하는 경우

JavaScript는 할당된 메모리를 사용하지 않는 경우 GC(Garbage collector)에 의해 메모리 할당을 추적하고 자동으로 메모리를 반환시키는 역할을 하게 됩니다. 그렇지만 이러한 과정에 있어서 메모리가 사용되는 지 추정하기 때문에 때때로 결정불가능(undecidable) 상태가 발생하게 됩니다.

대부분의 GC는 모든 변수가 스코프(Scope)를 벗어났을 때 더 이상 접근 불가능한 메모리를 수집하지만 스코프가 유지되는 경우가 생긴다면 메모리를 반환하지 않는 문제가 발생하게 됩니다.

결국 GC가 의존하는 알고리즘은 여러 객체와의 참조(Reference) 를 통한 개념입니다. 이로 인해 발생할 수 있는 문제는 다음과 같습니다.

순환참조 (해결)

image

아래 예제에서 두 객체가 생성되게 되면서 서로를 참조하고 순환참조가 생성되게 됩니다.

사실상 함수 호출 이후, 스코프(Scope)를 벗어나게 되면서 사용하지 않게 되지만 두 객체 다 한 번은 참조한 걸로 간주되어 GC(Gabage collector)가 적용되지 않는 문제가 발생합니다.

function f() {
var o1 = {};
var o2 = {};
o1.p = o2; // o1은 o2를 참조함
o2.p = o1; // o2는 o1을 참조함. 이를 통해 순환 참조가 만들어짐.
}

f();

2012년 기준으로 현대 브라우저는 해당 순환참조를 해결할 수 있는 Mark-Sweep 알고리즘이 적용되면서 순환참조 문제가 해결되었습니다. 그렇지만 React와 같이 컴포넌트 간 순환 참조가 일어날 경우 이슈가 발생할 수 있기 때문에 여전히 지양해야 하는 부분입니다.

전역 변수

선언되지 않는 변수를 참조하게 된다면 전역 객체에 새로운 변수를 생성합니다. window 객체를 참조하여 GC를 통한 메모리가 정리되지 않아 규모가 크다면 조심해야 합니다.

function foo(arg) {
name = "minsgy"; // window.name
}

setInterval, setTimeout, callback

// 1번째. Observer Time API
// 참조한 Node나 데이터가 더 필요로 하지않는 timer를 사용한 결과를 보여줍니다.
let serverData;

setInterval(function () {
let renderer = document.getElementById("renderer");
if (renderer) {
renderer.innerHTML = JSON.stringify(serverData);
}
}, 5000); // 매 5초 마다 실행

위 코드를 통해 renderer 객체는 어느 시점에 다른 것으로 대체되거나 제거할 수 있으며 Interval로 쌓인 코드는 필요가 없게 됩니다. 그러나 Interval은 활성화된 상태로 GC를 통한 메모리가 정리되지 않게 됩니다. 추가적으로 serverData 데이터도 반환되지 않는 문제가 발생합니다.

이를 해결하기 위해서 clearInterval를 통해 데이터를 반환해야 합니다.

// 2번째. Observer Handler 제거하기
const element = ...
const onClick = () => {...}
element.addEventListener('click', onClick) // event 등록
element.removeEventListener('click', onClick) // event 제거

현대 브라우저에서는 removeEvent를 호출하지 않아도 순환참조를 탐지하여 GC에서 자동 처리하지만 구형 브라우저에서 동작할 때도 메모리 누수가 없도록 신경써줘야 합니다..

이 외에도...

클로져로 인한 메모리 누수(중요), Internal Node로 인한 DOM 참조에 대한 문제가 있습니다. (추후 업데이트 예정)

React 순환 참조 문제

React에서 여러 컴포넌트들을 모듈화하면서 발생할 수 있는 모듈 의존성 문제입니다.

코드를 파일로 분리하여 이것을 다른 파일이 불러와 사용하기 위해서 이런 식으로 ES6의 모듈 시스템을 활용해 컴포넌트 단위 개발을 하게 됩니다.

// A -> B -> C -> A 순환 참조 발생
// Uncaught ReferenceError: Cannot access 'A' before initialization
// A.js
import B from './B.js'
export const A = {
B()
...
}

// B.js
import C from './A.js'
export const B = {
C()
...
}

// C.js
import A from './B.js'
export const C = {
A()
...
}

문제는 모듈 간의 서로 참조하는 경우 초기화 순서에 의해 순환 참조가 발생하게 됩니다. Webpack에서 모듈을 처리하는 방식은 의존성 맨 마지막 순서에 있는 모듈부터 초기화하게 되어 이러한 순환 참조 문제가 발생하게 됩니다.

즉, B.js 파일의 코드가 맨 먼저 실행되면서 초기화가 되지도 않은 A.js를 참조하면서 발생하는 문제입니다. 결국에는 순환참조라는 문제를 해결하기 위해서 고민해야 합니다.

해결 방법

가장 간단한 건 원인인 순환참조를 제거하기 위한 방법으로 React가 Top-down 방식의 컴포넌트 흐름을 가진다면 해결과 동시에 예방까지 할 수 있습니다.

전반적인 데이터의 흐름을 Atomic Component 방식으로 구성하여 제공하던지 UI 컴포넌트와 Service 컴포넌트를 구분하여 API 호출을 하는 방식도 있는 만큼 여러 존재합니다.

프로젝트를 진행하면서 순환참조가 문제를 꼭 일으키지 않을 수 있지만 추후 프로젝트가 커지면서 발생할 수 있는 이슈들을 예방하기 위한 방향으로 인지하는 게 유지보수에 있어서도 좋아보입니다.

Question List

  1. React에서 순환 참조 문제가 발생하는 이유는 무엇인가요?
  2. 싱글 스레드인 자바스크립트가 동시에 여러 개의 작업을 처리할 수 있는 방법은 무엇인가요? (비동기를 처리하는 방법은?)

Reference

JavaScript 엔진에 대하여
JavaScript는 어떻게 작동하는가?
불변 객체

· 약 13분
최민석

서론

JavaScript 생명주기인 스코프(Scope)가 적용되는 이유를 통해 우리가 어떤 코드를 작성할 수 있는지 고민할 수 있습니다. 전역적으로 사용 할 수 있는 이유와 어떤 생명주기까지 고려를 하고 만들어야 우리가 유지보수가 좋은 코드를 짤 수 있는지 알아보기 위해 작성했습니다.

JavaScript 생명주기

JavaScript에서 사용되는 변수나 함수 등 생성되고 소멸되는 일정한 사이클이 존재합니다. 이렇게 JavaScript에서 생성 된 변수나 함수를 사용할 수 있는 범위를 스코프(Scope) 라고 칭합니다.

var x = "global";

function foo() {
var x = "function scope";
console.log(x);
}

foo(); // ?
console.log(x); // ?

위와 같은 예제처럼 x가 2번 선언되는 경우, JavaScript에서는 어떻게 구분할 수 있을까요?

바로 스코프(Scope) 를 통해 foo() 함수 내에 선언 된 x는 내부에서만 참조할 수 있으며 최상단 선언 된 변수 x의 경우 어디에든 참조해서 사용할 수 있습니다. 이러한 스코프 규칙을 통해서 JavaScript 생명 주기를 관리할 수 있고 전역 스코프(Global Scope)지역 스코프(Local Scope) 로 나눌 수 있습니다.

전역 스코프(Global Scope)와 지역 스코프(Local Scope)

전역에 변수를 선언하면 이 변수는 어디서든지 참조할 수 있는 전역 스코프를 갖는 전역 변수가 됩니다. var 키워드로 선언한 전역 변수는 전역 객체(Global Object) window의 프로퍼티입니다.

var global = "minsgy";
function foo() {
var local = "msg";
console.log(global);
console.log(local);
}
foo(); // minsgy msg

console.log(global); // minsgy
console.log(local); // Uncaught ReferenceError: local is not defined

변수 global은 함수 영역 밖에서 선언되어 사용할 수 있지만 변수 local은 스코프가 종료되어 정의가 구현되지 않습니다. 다음 예시를 통해 함수 내에 존재하는 내부 함수를 살펴보겠습니다.

var x = "global";
function foo() {
var x = "local";
console.log(x); // local
function bar() {
// 내부함수
console.log(x); // local
}
bar();
}

foo();
console.log(x); // global

내부 함수는 자신을 포함하고 있는 외부함수의 변수에 접근할 수 있다는 특징이 있습니다. 이는 매우 유용하게 사용할 수 있습니다. 클로저에서와 같이 내부함수가 더 오래 생존하는 경우 다른 언어와는 다르게 작동하게 됩니다.

결과적으로 함수 bar에서 참조하는 x는 함수 foo에서 선언 된 지역변수입니다. 이는 스코프 체인에 의해 참조 순위가 전역 변수 x가 뒤로 밀린 상태이기 때문에 재할당되어 local 값이 적용되게 됩니다.

그리고 선언 된 위치에 의해 따라 생명 주기가 달라집니다. 이를 렉시컬 스코프(Lexical Scope) 라고 합니다.

렉시컬 스코프(Lexical Scope)

var x = 1;

function foo() {
var x = 10;
bar();
}

function bar() {
console.log(x);
}

foo(); // 1
bar(); // 1

위 코드를 통해 두 가지 경우를 유추할 수 있습니다.

함수를 어디서 호출하였는 지? 동적 스코프(Dynamic scope) 함수를 어디서 선언하였는 지? 렉시컬 스코프(Lexical Scope)

여기서 JavaScript는 기본적으로 렉시컬 스코프를 따르므로 함수가 선언 된 시점에 상위 Scope가 결정되게 됩니다.

결과적으로 함수를 어디에서 호출하였는지 스코프 결정에는 아무런 의미도 주지 않으며 예제와 같이 함수 bar는 전역에 선언되어서 전역 변수 값 x = 1을 2번 출력하게 됩니다.

위 함수와 같이 함수를 호출하는 것이 아니라 반환하며 **클로져(Closure)**를 예시를 들어보겠습니다.

클로져(Closure)

function outerFunc() {
var x = 10;
var innerFunc = function () {
console.log(x);
};
return innerFunc;
}
/**
* 함수 outerFunc를 호출하면 내부 함수 innerFunc가 반환된다.
* 그리고 함수 outerFunc의 실행 컨텍스트는 소멸한다.
* */

var inner = outerFunc();
inner(); // 10

외부 함수 outerFunc내부 함수 innerFunc를 반환하고 생명주기를 잃게 됩니다. 그렇지만 outerFunc 지역변수 x를 접근할 수 있는 모습을 보입니다. 이렇게 참조되는 외부함수(outerFunc) 변수를 자유변수(Free variable) 이라고 부릅니다.

이렇게 참조가 가능한 이유는 내부함수가 유효한 상태에서 외부함수가 종료해 반환되어도 외부 함수내의 활성 객체(Activation object: 변수, 함수 선언 정보를 가진)는 내부 함수에 의해 참조되는 한 유효하며 스코프 체인을 통해 참조할 수 있게 됩니다.

image

이와 같이 반환 된 내부 함수가 자신이 선언됐을 때의 환경인 스코프를 기억하여, 이전 환경 밖에서 호출되어도 그 환경에 접근할 수 있는 함수클로져(Closure) 라고 합니다.

클로져는 어디에 사용하나요?

코드의 복잡성을 줄일 수도 있지만 자칫하면 참조되는 상황이 발생해 GC(Garbage Collector) 가 발생하지 않는 문제가 나타날 수 있습니다. 이러한 점들을 고려하면서 클로저를 활용한 사례를 확인해보겠습니다.

1. 전역 변수 줄이기

// 기존
const likeButton = document.querySelector("button");
likeButton.addEventListener("click", handleClick);

let count = 0; // 전역 변수 선언으로 인한 문제들이 많음
function handleClick() {
count++;
return count;
}

// 클로져 사용
const likeButton = document.querySelector("button");
likeButton.addEventListener("click", handleClick());

function handleClick() {
let count = 0;
// 렉시컬 환경을 참조하는 함수 likeButton의 callback 함수를 활용하여 전역 변수 없이 구현
return function () {
count++;
return count;
};
}

클로져를 사용하여 아래 로직과 같이 모듈화한 함수에서만 사용하는 종속적인 변수를 선언합니다. 이를 통해 메소드(Method) 역할이 구분되어 사용할 수도 있을 뿐만 아니라 기본적인 전역 스코프에 대한 문제가 해결되어 인터페이스(Interface) 복잡성이 낮아지게 됩니다.

2. 함수형 방식 모듈화

클로저는 함수형 프로그래밍의 일급 객체(first-class) 개념을 인용하여 스코프(Scope)에 묶인 변수를 바인딩하기 위한 일종의 기술입니다. 이러한 일급 객체 개념을 활용하여 함수를 저장한 레코드(Record) 역할로 활용할 수 있습니다.

function makeSizer(size) {
return function () {
document.body.style.fontSize = size + "px";
};
}

var size12 = makeSizer(12);
var size14 = makeSizer(14);
var size16 = makeSizer(16);

위와 같은 함수형 개념을 통해 외부 함수의 파라미터를 갖고 다른 요소에 의해 넓은 확장성을 가질 뿐만 아니라 내부에 들어가는 로직을 감출 수 있다는 Private한 특징을 가지게 됩니다. JavaScript 태생에 존재하지 않는 메소드를 구현하여 제한적인 접근만 허용**할 수 있게 만듭니다.

3. React Hook

import React, { useEffect, useState, useCallback } from "react";

// Timer를 나타내는 컴포넌트
const TimerComponent = () => {
const [count, setCount] = useState(1);

const incrementCount = useCallback(() => {
setCount(count + 1);
}, []); // empty dependancy

useEffect(() => {
const timer = setTimeout(() => {
incrementCount();
}, 1000);

return () => {
clearTimeout(timer);
};
}, []); // empty dependancy

// 결과는 2에서 멈춘다.
return <div>{`Timer started: ${count}`}</div>;
};

해당 로직은 매 초마다 값이 1씩 증가하는 타이머 컴포넌트입니다. 그렇지만 의도한대로 작동하지 않고 2에서 타이머가 멈추게 됩니다. 의존성 배열에 의해, 한번의 렌더링만 일어나게 되어 함수가 기억하는 값은 여전히 1이기 때문에 타이머가 변하지 않게 됩니다.

즉, useEffect hook 실행 시점에 incrementCount 함수는 count가 1인 환경을 기억하고 있습니다. 이후 count가 업데이트 되어 함수가 새로 변경되어도 useEffect에서 처음 1을 기억하는 함수를 실행하게 됩니다. 이로 인해 count 값이 증가하지 않게 되거나 useEffect가 실행되지 않아 Timer 값이 변화가 없게 되는 결과를 보입니다.

useEffect 뿐만 아니라 useCallback에서도 클로저 개념을 활용하여 렉시컬 환경을 관리하게 됩니다. 이를 통해 Hook을 사용 할 시 의존성 배열 관리를 통해 렉시컬 환경을 변화시킬 수 있다는 점에 있어서 의도한 결과를 내도록 주의해야 합니다. useState의 업데이트 방식도 클로저를 활용합니다.

bonus. useState의 업데이트 구현 방식

let _value;

export useState(initialValue){
if (_value === 'undefined') {
_value = initialValue;
}
const setValue = newValue => {
_value = newValue;
}

return [_value, setValue];
}

useState 밖에 선언된 변수 _value가 있습니다. useState에서는 초기값(initialValue)를 받아 만약 기존 _value 값이 없으면 초기값으로 세팅하게 됩니다. setValue 함수는 받아오는 값으로 전역 _value를 업데이트하게 되면서  _value와 setValue 함수를 배열 형태로 반환한다. useState가 어디에서 실행되었건, 클로저를 통해 _value 값에 접근할 수 있는 구조를 가지게 됩니다.

결과적으로 useState를 통해 생성한 상태를 접근하고 유지하기 위해서 useState 바깥쪽에 state를 저장하여 선언 된 컴포넌트를 구별할 수 있는 key로 접근하게 되고 배열 형식으로 저장되게 됩니다. useState 안에서 선언되는 상태들은 _value 배열에 순서대로 저장되는 원리입니다. (예시는 임의 값이긴 하다..)

Reference

useState와 클로저
클로저 MDN
클로저에 대하여