Skip to content

[9팀 임두현] Chapter 1-3. React, Beyond the Basics#45

Open
ldhldh07 wants to merge 22 commits intohanghae-plus:mainfrom
ldhldh07:main
Open

[9팀 임두현] Chapter 1-3. React, Beyond the Basics#45
ldhldh07 wants to merge 22 commits intohanghae-plus:mainfrom
ldhldh07:main

Conversation

@ldhldh07
Copy link

@ldhldh07 ldhldh07 commented Jul 22, 2025

과제 체크포인트

배포 링크

https://ldhldh07.github.io/front_6th_chapter1-3/

기본과제

equalities

  • shallowEquals 구현 완료
  • deepEquals 구현 완료

hooks

  • useRef 구현 완료
  • useMemo 구현 완료
  • useCallback 구현 완료
  • useDeepMemo 구현 완료
  • useShallowState 구현 완료
  • useAutoCallback 구현 완료

High Order Components

  • memo 구현 완료
  • deepMemo 구현 완료

심화 과제

hooks

  • createObserver를 useSyncExternalStore에 사용하기 적합한 코드로 개선
  • useShallowSelector 구현
  • useStore 구현
  • useRouter 구현
  • useStorage 구현

context

  • ToastContext, ModalContext 개선

과제 셀프회고

기존의 과제들도 마찬가지였지만, 이번 과제도 한번쯤 해보고 싶었던 작업이었습니다.

개인적으로 효과가 좋다고 생각하는 학습방식이 있습니다.
원리를 다 파악하고 실제로 활용하는 것보다는, 일단 원리도 모르는 상태에서 다들 하는대로 적용하고 보는 것입니다.
그렇게 익숙해질 즈음에 다시 이론적인 개념을 파악하면 그 때 이해가 쏙쏙 됩니다.

리액트도 많이 써왔으면서도 그 코어 작동 로직이나, 구체적인 동작에 대해서 모르고 있던 것이 사실입니다.
조금 늦긴 했지만 그래도 적절한 타이밍에 살짝 리액트를 파고들어서 공부할 수 있는 기회였습니다.
실제로 써왔던 것들이라 그 로직들이 이해가 됐고, 앞으로 더 높은 이해도로 리액트를 사용할 수 있겠다 싶었습니다.

기술적 성장

useRef

특히 그 원리를 모르고 그냥 쓰고 있던 대표적인 훅이 이 useRef였습니다.
그냥 DOM 조작할 때 쓰는구나 싶어서 루틴대로 사용하던 훅입니다.

const divRef = useRef(null);
<div ref={divRef}>
// divRef.current에 DOM이 할당됨

그러던중 이런 방식으로 작성된 코드를 봤습니다.

const Provider = ({ children }: PropsWithChildren) => {
  const [isOpen, setIsOpen] = useState(false);
  
  ...

  const contentRef = useRef<ReactNode>(null);
  const bottomSheetOptionsRef = useRef<BottomSheetOptions>({});

  const open = useCallback(({ content, options }: BottomSheetConfigs) => {
    contentRef.current = content;
    bottomSheetOptionsRef.current = options || {};
    setIsOpen(true);
  }, []);
 
  ...

  return (
    <context.Provider value={memoizedValue}>
      {children}
      {isOpen && (
        <BottomSheet
          open={isOpen}
          {...bottomSheetOptionsRef.current} 
          onOpenChange={setIsOpen}
        >
          {contentRef.current} 
        </BottomSheet>
      )}
    </context.Provider>
  );
};

useRef로 값을 저장하는 듯한 이 활용법은 어색하게 다가왔습니다.

이 때 이해못했던 것을 과제에서 useRef를 구현하고 그 동작 목적에 맞게 활용하면서 이해가 됐습니다.

먼저 공식문서에서 useRef에 대해

useRef는 처음에 제공한 초기값으로 설정된 단일 current 프로퍼티가 있는 ref 객체를 반환합니다.
다음 렌더링에서 useRef는 동일한 객체를 반환합니다. 정보를 저장하고 나중에 읽을 수 있도록 current 속성을 변경할 수 있습니다. state가 떠오를 수 있지만, 둘 사이에는 중요한 차이점이 있습니다.
ref를 변경해도 리렌더링을 촉발하지 않습니다. 즉 ref는 컴포넌트의 시각적 출력에 영향을 미치지 않는 정보를 저장하는 데 적합합니다. 예를 들어 interval ID를 저장했다가 나중에 불러와야 하는 경우 ref에 넣을 수 있습니다. ref 내부의 값을 업데이트하려면 current 프로퍼티를 수동으로 변경해야 합니다

라고 설명이 되어있습니다.

여기서 중요한 점이자 useState와의 차이점은 다음과 같습니다.

  • ref 변경이 리렌더링을 촉발하지 않음
  • 단일 current 프로퍼티를 가진 객체 구조
  • 프로퍼티 직접 변경 가능

이 특징들로 인해 "화면에 영향을 주지 않는 데이터"를 리렌더링 없이 저장할 수 있습니다.

이 때 프로퍼티를 수동으로 변경해야 한다는 것은 ref의 프로퍼티의 접근이 가능하다는 것입니다.
useState로 state를 저장할 때 객체로 저장한 후 그 프로퍼티에 접근해서 변경하는 것은 리액트의 정책에 위반되기에, 이 특성이 useRef의 여러 특성들을 가능케 합니다.

  • 참조가 유지된다
  • 그러면서 내부 값 변경은 자유롭다

이걸 그냥 텍스트로만 읽고는 완벽하게 와닿지는 않았습니다.

useState(() => {})

useRef를 처음에 구현하려고 하다가 복잡도가 너무 높아져서 찾아보는 과정에서 useState(() => {})라는 생소한 패턴을 발견했습니다:

생소했지만 조금만 더 생각해보니 크게 새로운 형태가 아닌, 기존 useState()에 함수가 들어간 형태라는 점을 인지했습니다.

그로 인해 몇가지 특성이 유발됩니다.

  • 첫 렌더링에서만 함수 실행 - 성능 최적화
  • 참조 유지 - 같은 객체/함수 반환
//  렌더링마다 계산 실행
const [data] = useState(sum());

// 첫 렌더링에서만 계산 실팽
const [data] = useState(() => sum());

React는 전달받은 값이 함수인지 확인하고, 함수라면 첫 렌더링에서만 호출합니다.
이후 리렌더링에서는 저장된 값만 반환하여 불필요한 재계산을 방지합니다.

이 특성를 이해하면서 useRef의 동작 뿐 아니라 useRef와 별개로 이 형태 자체의 특징과 어떤 상황에 쓸 수 있는지도 알 수 있었습니다.

의존성 없는 메모이제이션으로 사용할 수 있는 패턴으로, 다양한 최적화 시나리오에 적용 가능했습니다

  • input value는 onChange로 set하면서 화면의 리렌더링은 onBlur시 작동하게 하는 방식
    • qna때 코치님이 보여주었던 방식으로 실제로도 유용할 것 같아 인상깊었고 해당 동작에 대한 이해도도 높여주었습니다.
  • toastProvider 최적화시 함수들을 분리해서 메모이제이션할 때 사용
const actions = { show, hide };

const [actions] = useState(() => ({ show, hide }));

구현한 useRef

import { useState } from "react";

export function useRef<T>(initialValue: T): { current: T } {
  const [ref] = useState(() => ({ current: initialValue }));
  return ref;
}

앞의 과정들을 거쳐 이 코드를 다시 쳐다봤습니다.
useRef가 어떻게 설계되었고 그래서 어떻게 활용되는지 좀 더 이해가 됐습니다.

동작 원리 분석

저 코드의 동작을 다시 보면 이렇습니다:

  • lazy initialization을 통해 current 단일 프로퍼티를 가진 객체를 생성
  • 그 객체만을 반환

이로 인해 얻을 수 있는 효과

  1. 참조 안정성 확보
  • 최초 생성된 참조값을 계속 리턴해서 씀으로써 확보되는 참조 안정성
  • 컴포넌트가 리렌더링되어도 항상 같은 객체
  1. 제한적 가변성
  • 단일 프로퍼티인 객체로서 객체는 변하지 않고 속성만 변한다
  • current에 접근해서 수정함으로써 리렌더링 없이 값 수정 가능
  1. 명시적 접근
// 명시적으로 .current를 통해 접근
ref.current = newValue;
const value = ref.current;

용도

이런 특성들이 조합되어서 "상태는 바뀌지만 렌더링은 안 했으면 좋겠다" 싶을 때 쓴다는 점을 이해했습니다.

깊은 비교는 얼마나 깊어야 하는걸까

Object.keys()

객체의 키에 들어갈 수 있는 타입은 3가지가 있습니다.

  • string
  • number
  • symbol

하지만 Object.keys()의 반환 타입은 string[]입니다:

  • string → string
  • number → string 형변환
  • symbol → 제외

이런 방식이기 때문입니다.
얕은 비교일 때는 symbol에 대한 비교가 불필요하다고 생각했습니다.

deepEquals

deepEquals는 중첩된 객체와 배열까지 재귀적으로 비교하여 모든 레벨에서 값이 동일한지 확인하는 것입니다.

그런데 여기서 의문이 생겼습니다. Deep 비교에서도 Symbol 키를 제외해야 할까?

기본 라이브러리의 경우

주요 라이브러리들이 어떻게 처리하는지 찾아봤습니다.
Lodash.isEqual, fast-deep-equal의 경우에서도 symbol은 배제하고 비교했습니다.

배제하는 이유
  1. 성능상의 이유
  • Object.keys()는 enumerable string/number 키만 순회
  • Reflect.ownKeys()는 모든 own properties (symbol 포함) 순회
  • Symbol 처리 로직이 추가적인 성능 오버헤드 발생
  1. 실용성 관점
  • React 애플리케이션에서 Symbol 키 사용 빈도 매우 낮음
  • 대부분의 상태는 JSON 직렬화 가능한 일반 객체
  1. JSON 직렬화와의 일관성
  • 서버와의 데이터 통신, localStorage 저장 등에서 Symbol 손실
  • deepEquals이 JSON 동작과 일치하면 예측 가능한 동작

대부분의 경우 Symbol은 "내부 메타데이터"로 이용되는 것이고 실제 비교할 대상에는 사용되지 않스니다.
그렇기 때문에 깊은 비교에서도 비교할 필요가 없습니다.

학습 효과 분석

과제에서 요구되는 많은 훅들이 useRef기반으로 작성됩니다.

use훅들을 만들고 use훅들을 만들 때 useRef를 통해 이전 ref와 얕은 비교 후 상태를 바꾸는 과정이 반복되는데 이를 통해 리액트 시스템의 이해도가 높아졌습니다.
또한 커스텀 훅을 만드는 것이 리액트의 가독성을 높이고 활용하는 데 중요한 점이라고 생각하는데 이후 개발하는데 커스텀 훅을 보다 적극적으로 활용할 수 있을 듯 합니다.

동작을 이해하자 정확히 어떤 유즈케이스에 어떤 리액트 훅을 써야 할지 명확해졌습니다.

useState - 상태 관리, 변경 시 리렌더링 트리거
useState(() => {}) - 초기 렌더링 생성, 참조는 유지하되 리렌더링 의존성 없음
useRef - 리렌더링 없는 값 저장
useMemo - 의존성 있는 값 메모이제이션
useCallback - 의존성 있는 함수 메모이제이션

학습 갈무리

메모이제이션에 대한 나의 생각을 적어주세요.

우리의 컴퓨터는 아주 빠르기 때문에 대부분의 최적화에서 메모이제이션은 우선순위가 낮다고 생각합니다.
QnA에서 코치님이 해주신 "메모이제이션은 최적화보다는 정합성을 맞추는 데 주로 쓴다"라는 말이 인상깊었습니다.

실제로도 메모이제이션을 사용했던 대부분의 경우가 참조의 불안정성으로 인한 무한 리렌더링을 해결하기 위해서였습니다.

다만 메모이제이션의 과한 사용으로 인한 단점도 경험했습니다.

메모이제이션으로 트러블슈팅을 해결한 경험 이후, 메모이제이션이 만능인 줄 알고 과도하게 사용했습니다. 하지만 하나를 메모이제이션하면 의존성이 있는 다른 요소들도 함께 고려해야 했기 때문에 코드 복잡도가 높아지고 오히려 생산성이 낮아졌습니다.

  1. 참조 안정성으로 올바른 동작 보장
  2. 예외적으로 정말 느릴 때만 최적화

이를 메모이제이션 사용의 기준으로 삼으려고 합니다.

컨텍스트와 상태관리에 대한 나의 생각을 적어주세요.

컨텍스트는 단방향 데이터 흐름을 유지하면서 prop drilling 문제를 해결할 때 유용한 패턴입니다.
하지만 기본적으로는 props를 활용한 상태 관리가 우선되어야 한다고 생각합니다.

props drilling은 안티 패턴으로 간주되지만 개인적으로는 props 통해 상태 전파를 하는 것이 좋았습니다.
처음에는 막연히 부모 컴포넌트 -> 자식 컴포넌트 -> 손자 컴포넌트 -> 증손자 컴포넌트로 가는 과정에서
홀연히 상태가 호출되어 나타나는것보다 실제 흐름대로 명시되는 것이 당연하다 느껴졌고 더 기분이 후련했기 때문에 선호했습니다.

그 막연한 느낌을 글로 표현해주어 인상깊게 읽은 게시글이 있습니다.
https://velog.io/@woohm402/no-global-state-manager

이 글에서 제시한 것과 개인적인 생각을 종합한 props 상태 관리의 장점은 아래와 같습니다.

  • 명시성: 데이터 흐름이 명확하게 보임
  • 추적 가능성: 어디서 어떤 데이터가 오는지 쉽게 파악
  • 테스트 용이성: 컴포넌트 단위 테스트가 쉬움
  • 성능: 불필요한 리렌더링 위험이 적음

@ldhldh07 ldhldh07 force-pushed the main branch 2 times, most recently from 568cf53 to d0fe62b Compare July 23, 2025 00:59
Copy link

@susmisc14 susmisc14 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3주차도 고생 많으셨습니다 두현님! 두현님 코드를 보면 항상 배울게 많다는 생각이 들어요. 앞으로도 좋은 코드 많이 작성해주세요! 응원합니다~

Comment on lines +11 to +15
for (const key of keysOfA) {
const valueA = (objA as Record<string, unknown>)[key];
const valueB = (objB as Record<string, unknown>)[key];
if (!deepEquals(valueA, valueB)) return false;
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 처음에는 두현님처럼 두 타입을 하나의 로직으로 묶어 처리하는 게 간결하다고 생각했습니다. 그런데 ['a']{ '0': 'a', length: 1 }이 같게 처리될 수 있지만, 실제로는 타입도 다르고 .map 같은 배열 메서드도 사용할 수 없다는 점에서 예상치 못한 부작용이 생길 수 있겠다는 걸 깨달았습니다.

혹시 이런 차이점에도 불구하고 코드를 통합해서 얻는 이점이 더 크다고 보시는지, 아니면 분리하는 게 더 낫다고 생각하시는지 그 설계에 대한 두현님의 생각이 궁금합니다!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

말씀 들어보고 생각해보니 나눠서 처리하는게 더 정교하고 부작용도 없겠네요.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저는 depth라는 인자를 추가해서 비교의 깊이를 조절할 수 있게 만들었어요. 이 depth 값을 재귀적으로 1씩 줄여나가다가 0이 되면 === 비교로 마무리하는 방식입니다. 이렇게 하면 deepEquals와 shallowEquals를 하나의 함수로 통합할 수 있는 장점이 있습니다. 예를 들어, shallowEquals(a, b)는 내부적으로 deepEquals(a, b, 1)을 호출하는 거죠. 이런 방식을 고려한다면 중복되는 구조를 깔끔하게 해결할 수 있을 것 같아요!

Copy link
Author

@ldhldh07 ldhldh07 Jul 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋은 아이디어인 것 같습니다!
그럴 경우 얇은 비교는 비교지만 depth는 1인 비교이다라는 의도 또한 전달할 수도 있다는 점도 좋네요.

개인적으로는 두개의 비교를 독립적으로 분리하고 싶어서 코드가 중복되더라도 별개의 로직으로 만드는 쪽이 좀 더 끌리긴 합니다. 코드의 중복을 줄이느냐, 아니면 하나의 코드 묶음에서 진행 과정을 명확히 하느냐의 밸런스에 대해 문제도 개인적으로 고민하던 점이었습니다.

처음에는 코드를 짧게 만들고 파편화 해서 재사용하는 쪽을 좋아했는데, 가면 갈수록 좀 더 명시적인 코드와 관심사를 분산시키지 않고 하나의 파일에 작성하는 것에 좀 더 무게를 두게 되더라구요. 이 맥락에서 deepEqualshallowEqual가 중복되는 부분의 복잡도가 높지도 않아 각각의 파일 내의 코드만 봐도 독립적으로 그 동작 흐름을 알 수 있게 하고 싶은 마음이 살짝 더 큽니다.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 코드 중복과 명시적인 구조 사이에서 고민이 되었어요. 물론 코드 중복을 시도해 보았지만 지수님의 피드백과 같은 방법을 생각해내지 못하여 강제적으로 후자를 택하게 되었습니다,,,ㅎ

Comment on lines +6 to +7
const fnRef = useRef<T>(fn);
fnRef.current = fn;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저는 if 문을 추가해서 이전 callback과 현재 callback을 비교하는 과정을 추가했었는데 지금와서 생각해보면 과한 로직인 것 같아요.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

값을 저장하고 자유롭게 수정하는 MutableRefObject를 완벽하게 재현하고 있는 것 같아요. 그리고 저도 리뷰를 달다가 생각난건데 useRef의 또 다른 주요 용도인 DOM 엘리먼트를 참조하는 경우(useRef<HTMLDivElement>(null))에 대해서는 어떻게 생각하시는지 궁금합니다. 이 경우에는 current가 읽기 전용인 RefObject<T> 타입을 사용하는 것이 일반적인데, 이런 부분까지 고려하여 타입을 확장해보는 것도 재미있을 것 같아요!

Copy link

@jun17183 jun17183 Jul 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

readonly 타입인 RefObject을 만들고 export function useRef<T>(initialValue: null): RefObject<T>;와 같이 작성하면 될까요?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오호이 지수님 좋은 의견이네요

Comment on lines +8 to +11
setState((currentValue) => {
if (shallowEquals(currentValue, newValue)) return currentValue;
else return newValue;
});

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저는 setState 바깥에서 새로운 값을 계산했는데, setState 콜백 안에서 prevValue를 사용해야 여러 번의 상태 업데이트가 연달아 일어날 때도 stale state 문제 없이 항상 최신 상태를 기반으로 값을 계산할 수 있겠네요. 제가 놓쳤던 부분인데 다른 분들은 다 꼼꼼히 챙기셔서 약간 슬퍼졌어요...

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

과제의 요구사항에 맞게 정확히 State와 Command 컨텍스트를 분리하신 것 같아요! 추가로 제가 평소에 즐겨 사용하는 createSafeContext라는 패턴을 소개해 드리고 싶어요.
createContext를 사용할 때마다 초기값을 반드시 설정해야 하거나 Provider 외부에서 컨텍스트를 사용하는 실수를 방지하기 위해 null 체크를 하고 값을 useMemo로 감싸는 등 반복적인 작업을 하나로 묶어줄 수 있어요.

import { createContext, createElement, useContext, useMemo } from "react";

export function createSafeContext<ContextValue extends object | null>(
  rootComponentName: string,
  defaultValue?: ContextValue,
) {
  const Context = createContext<ContextValue | undefined>(defaultValue);

  const Provider = ({ children, ...rest }: ContextValue & { children?: React.ReactNode }) => {
    // eslint-disable-next-line react-hooks/exhaustive-deps
    const value = useMemo(() => rest, Object.values(rest)) as ContextValue;

    return createElement(Context, { value }, children);
  };

  const useSafeContext = (consumerName: string) => {
    const ctx = useContext(Context);
    if (ctx) return ctx;
    if (defaultValue) return defaultValue;

    throw new Error(`\`${consumerName}\` must be used within \`${rootComponentName}\``);
  };

  Provider.displayName = rootComponentName + "Provider";

  return [Provider, useSafeContext] as const;
}

이 헬퍼를 적용하면, 현재 컨텍스트를 생성하는 부분을 아래처럼 바꿀 수 있습니다.

export const [ToastStateProvider, useToastState] =
  createSafeContext<ContextState>("ToastStateProvider");
export const [ToastCommandProvider, useToastCommand] =
  createSafeContext<ContextCommand>("ToastCommandProvider");

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

우라까이하겠습니다

@q1Lim
Copy link

q1Lim commented Jul 25, 2025

전체적으로 코드가 가독성이 좋았고, 세세한 부분까지 신경 써서 구현하신 점이 인상적이었어요, 고생 많으셨습니다~!

Copy link

@tomatopickles404 tomatopickles404 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

꽁꽁 숨겨진 코드천재님 😎
기만은 그만하시고 인정하시죵.
짧은 식견이지만 살포시 의견 달아보려 했는데 여윽시 별로 리뷰 할 게 없네요~!
한 주 고생 많으셨고 남은 시간도 재밌게 해봐요!!

Comment on lines +2 to +9
if (objA === objB) return true;

if (objA == null || objB == null || typeof objA !== "object" || typeof objB !== "object") return false;

const keysOfA = Object.keys(objA);
const keysOfB = Object.keys(objB);

if (keysOfA.length !== keysOfB.length) return false;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요런 중복은 별도의 유틸함수로 빼도 괜찮은 것 같아요!
실제로 제가 적용했던 유틸입니당

export const compareObjectProperties = (a: ObjectType, b: ObjectType, equals: Equals) => {
  const aKeys = Object.keys(a);
  const bKeys = Object.keys(b);

  if (aKeys.length !== bKeys.length) return false;

  return aKeys.every((key) => equals(a[key], b[key]));
};

Comment on lines +7 to +9
export function useRef<T = undefined>(): MutableRefObject<T | undefined>;
export function useRef<T>(initialValue: T): MutableRefObject<T>;
export function useRef<T>(initialValue?: T): MutableRefObject<T | undefined> | MutableRefObject<T> {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오호이 지수님 좋은 의견이네요

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

두현님 멋집니다,, PR에서 useRef의 동작 원리에 대해 깊게 고민하신거 인상 깊게 읽었어요..!

useState로 useRef의 참조 안정성을 구현하신게 재밌었습니다. useState가 컴포넌트 라이프사이클 동안에 참조를 유지해준다는 걸 잘 활용하신 것 같아요. (저는 이런 생각 몬했을듯)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

찾아보니 실제 useRef는 내부적으로 컴포넌트 인스턴스에 값을 저장하는 방식으로 동작한다고 합니다. 리렌더링이 발생해도 해당 컴포넌트 인스턴스는 유지돼서 저장된 ref 객체 또한 동일한 참조를 유지하게 된다고 하는데 현재 useState를 사용하신 구현 방식 외에 다른 방식으로도 useRef의 핵심 기능을 구현해볼 수 있지 않을까 싶어요!

Copy link

@lieblichoi lieblichoi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

많이 배워갑니다 플래티넘 두현님 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants