(꼼꼼 프로젝트) toss slash 분석, zustand와 연결된 커스텀 overlay 훅 구현

Updated:

배경

이번 프로젝트에는 정말 많은 종류의 모달이 존재한다.

심지어 디자인 시안이 아직 다 완성되지 않은 상황이었기에 더 추가될지도 모르는 상황이었다.

구현

상황 분석

모달들이 전부 조금씩 다르게 생겼으나 공통적으로 존재하는 것들을 생각해보았다.

  1. padding과 border-radius
  2. backdrop의 존재
  3. background-color
  4. 주제, 설명의 폰트 같은 겹치는 UI 요소들

1,2,3 의 경우 “모달” 이라는 컴포넌트를 만들어서 조립할 수 있게 만들어주고

4 의 경우 자주 쓰이는 경우 Compound Pattern으로 묶어서 컴포넌트 간의 결합성을 높히게 되긴 하지만 조립하는 쪽에서 쉽게 할 수 있도록 구현하는게 좋다고 생각하였다.

컴포넌트 자체의 구현 방식은 해결되었고 이제 렌더링은 어떻게 시킬지 생각해야 했다.

렌더링 방법

전역 상태 관리

부끄럽지만 기존에 모달을 구현할 때는 마감 기한에 밀려서 좋은 코드를 생각하지 못했던 것 같다…

아래는 원래 사용하던 방식이다. context api를 활용하여 모달이 열리고 닫히고를 구현해놓은 모달이다.

// 일종의 팩토리 패턴이다.
export default function Modal({ children, button }: ModalProps) {
  const [isModalOpen, setIsModalOpen] = useState(false);
  const modalRef = useClickOutside<HTMLDivElement>(() => setIsModalOpen(false));

  const handleOpen = () => setIsModalOpen(true);
  const handleClose = () => setIsModalOpen(false);
  return (
    <ModalContext.Provider value=>
      <section>
        <div onClick={handleOpen}>{button}</div>
      </section>
      {isModalOpen && (
        <div className="fixed inset-0 z-40 flex items-end bg-black bg-opacity-50 md:items-center md:justify-center">
          <div
            ref={modalRef}
            className="z-50 w-full rounded-t-xl bg-background-secondary md:w-[384px] md:rounded-b-xl"
          >
            {children}
          </div>
        </div>
      )}
    </ModalContext.Provider>
  );
}

이렇게 context api를 사용한 모달은

모달을 열었을 때 모달을 여는 버튼도 리렌더링된다.

이는 어쩔수가 없는게 context가 결국 상위 컴포넌트에 있는 상태를 바꾸기 때문

개발자가 편해지긴 하지만 상태를 바꿔서 그 사이의 컴포넌트들이 리렌더링 되는 것을 막을 순 없음 ( 그래서 context api는 전역 상태 관리라기보단 전역 상태 주입이라고도 한다. )

이번 프로젝트에서 전역 상태 관리 라이브러리로 zustand을 세팅했으니 우선 이를 활용해서 만들어 보자 하고 고민을 시작했다.

toss slash

그러던 중 toss의 오픈 소스 라이브러리에 overlay를 다루는 훅이 있는 것을 발견했다.

overlay란 위에 까는 것을 의미하며 여기서는 화면 위에 화면을 올리는 것을 말한다.

즉, 모달 뿐만 아니라 사이드바, 토스트 등 존재하는 화면 위에 있는 컴포넌트도 overlay라고 한다.

useOverlay 상세 분석

https://github.com/toss/slash/blob/main/packages/react/use-overlay/src/useOverlay.ko.md

OverlayProvider
// OverlayProvider.tsx
/** @tossdocs-ignore */
import React, { createContext, PropsWithChildren, ReactNode, useCallback, useMemo, useState } from 'react';

// context api로 저장
export const OverlayContext = createContext<{
// 마운트 되는지 안되는지 확인하는 context
  mount(id: string, element: ReactNode): void;
  unmount(id: string): void;
} | null>(null);
// 배포 단계에서는
if (process.env.NODE_ENV !== 'production') {
  // displayName을 설정
  OverlayContext.displayName = 'OverlayContext';
}

// children을 받는 Provider 생성
export function OverlayProvider({ children }: PropsWithChildren) {
  // id와 컴포넌트를 저장하는 state 생성
  const [overlayById, setOverlayById] = useState<Map<string, ReactNode>>(new Map());

  // mount 함수 생성
  const mount = useCallback((id: string, element: ReactNode) => {
    // mount를 계속 호출하지 않고 처음 마운트 될때만 호출 (useCallback)
    setOverlayById(overlayById => {
      const cloned = new Map(overlayById);
      cloned.set(id, element);
      return cloned;
    });
  }, []);

  // unmount 함수 생성
  const unmount = useCallback((id: string) => {
    // unmount를 계속 호출하지 않고 처음 언마운트 될때만 호출 (useCallback)
    setOverlayById(overlayById => {
      const cloned = new Map(overlayById);
      cloned.delete(id);
      return cloned;
    });
  }, []);

  // context에 mount, unmount를 저장
  const context = useMemo(() => ({ mount, unmount }), [mount, unmount]);

  // context를 Context API를 활용해서 아래로 내리고 id와 element를 사용해서 컴포넌트를 렌더링
  return (
    <OverlayContext.Provider value={context}>
      {children}
      {[...overlayById.entries()].map(([id, element]) => (
        <React.Fragment key={id}>{element}</React.Fragment>
      ))}
    </OverlayContext.Provider>
  );
}

OverlayProvider의 역할

  1. 컴포넌트를 저장하는 상태를 갖고 context api로 바꿀 수 있게 전역으로 내려준다.
  2. 상태를 이용해서 컴포넌트를 열어놓는다. (비어져 있을 때는 열려있어도 아무것도 보이지 않는다.)
useOverlay
import { useContext, useEffect, useMemo, useRef, useState } from 'react';
import { OverlayContext } from './OverlayProvider';
/** @tossdocs-ignore */
import { OverlayController, OverlayControlRef } from './OverlayController';
import { CreateOverlayElement } from './types';

let elementId = 1;

interface Options {
  exitOnUnmount?: boolean;
}

export function useOverlay({ exitOnUnmount = true }: Options = {}) {
  // overlayContext를 가져옴 (mount, unmount가 있음)
  const context = useContext(OverlayContext);

  // context가 없으면 에러 발생 (Provider 내부에서만 사용 가능)
  if (context == null) {
    throw new Error('useOverlay is only available within OverlayProvider.');
  }

  // 비구조 할당으로 mount, unmount를 분리
  const { mount, unmount } = context;
  // id를 저장하는 state 생성
  const [id] = useState(() => String(elementId++));

  // overlayRef를 생성, useImperativeHandle을 사용하여 close 함수를 제공
  const overlayRef = useRef<OverlayControlRef | null>(null);

  useEffect(() => {
    //  exitOnUnmount가 true일 때, 컴포넌트가 언마운트 될 때 unmount 실행
    //  exitOnUnmount가 false일 때, 컴포넌트가 언마운트 될 때 unmount 실행하지 않음, 직접 exit(unmount)를 호출해야 함
    return () => {
      if (exitOnUnmount) {
        unmount(id);
      }
    };
  }, [exitOnUnmount, id, unmount]);

  // useMemo를 사용하여 객체를 캐싱
  return useMemo(
    () => ({
      // open, close, exit 함수를 반환
      // open: overlayElement를 받아서 mount 실행
      // close: overlayRef.current?.close() 실행
      // exit: unmount 실행
      open: (overlayElement: CreateOverlayElement) => {
        mount(
          id,
          <OverlayController
            // NOTE: state should be reset every time we open an overlay
            key={Date.now()}
            ref={overlayRef}
            overlayElement={overlayElement}
            onExit={() => {
              unmount(id);
            }}
          />
        );
      },
      close: () => {
        overlayRef.current?.close();
      },
      exit: () => {
        unmount(id);
      },
    }),
    [id, mount, unmount]
  );
}

useOverlay의 역할

  1. context를 읽고 mount, unmount할 수 있는 함수 제공 (여기서 mount와 unmount는 메모리에 담는 것(전역 상태))
  2. context 내부적으로 사용할 id 생성, 주입
OverlayController
/** @tossdocs-ignore */
import { forwardRef, Ref, useCallback, useEffect, useImperativeHandle, useState } from 'react';

import { CreateOverlayElement } from './types';

interface Props {
  // 결과물
  overlayElement: CreateOverlayElement;
  onExit: () => void;
}

export interface OverlayControlRef {
  close: () => void;
}


export const OverlayController = forwardRef(function OverlayController(
  { overlayElement:OverlayElement, onExit }: Props,
  ref: Ref<OverlayControlRef>
) {
  // 메모리가 아닌 실제 DOM에 렌더링되는지 여부
  const [isOpenOverlay, setIsOpenOverlay] = useState(false);

  // DOM에서 Overlay를 닫는 함수 
  const handleOverlayClose = useCallback(() => setIsOpenOverlay(false), []);

  // ref로 전달된 객체에 close 함수를 추가
  useImperativeHandle(
    ref,
    () => {
      return { close: handleOverlayClose };
    },
    [handleOverlayClose]
  );

  // 컴포넌트가 마운트되면 Overlay를 열기
  useEffect(() => {
    // NOTE: requestAnimationFrame이 없으면 가끔 Open 애니메이션이 실행되지 않는다.
    // 브라우저에 다음 리페인트 전에 콜백 함수 호출을 가능하게 하는 메소드
    requestAnimationFrame(() => {
      setIsOpenOverlay(true);
    });
  }, []);

  // OverlayElement를 렌더링
  return <OverlayElement isOpen={isOpenOverlay} close={handleOverlayClose} exit={onExit} />;
});

OverlayController의 역할

  1. 실제 DOM에 Element를 렌더링
  2. 리페인트 전에 함수를 호출해서 애니메이션 처리 (requestAnimationFrame은 마이크로 큐처럼 본인의 큐를 갖고 실행되기 때문에 리페인트 전에 함수를 실행시킬 수 있다.)
  3. useOverlay의 open으로 모달을 끄는 handler 올리기
  4. Element 내리기
전체 구조도

종합적으로 구조도를 구려보면 다음과 같다.

이를 통해 해결되는 점은

  1. z-index가 쌓임맥락으로 묻혀지지 않도록 영향이 가지않는 main 아래에 모달이 정의된다.
  2. 컴포넌트에서 모달 컴포넌트를 직접 주입할 수 있다.
  3. 같은 페이지에서 여러 모달이 나올 수 있고 동시에도 있을 수 있다.

쌓임 맥락(stacking context)은 가상의 Z축을 사용한 HTML 요소의 3차원 개념화입니다. Z축은 사용자 기준이며, 사용자는 뷰포트 혹은 웹페이지를 바라보고 있을 것으로 가정합니다. 각각의 HTML 요소는 자신의 속성에 따른 우선순위를 사용해 3차원 공간을 차지합니다. 어렵게 되어 있지만 쉽게 말하자면 z-index는 부모의 z-index를 이길 수 없다는 것이다. 자신이 z-index: 6이더라도 부모가 4라면 부모 단에 있는 4보다 높은 요소들을 z-index로 이길 수 없다. (DIV #4는 z-index:6이지만 z-index:5인 DIV #1보다 뒤에 있다.)

프로젝트에 맞게 개선

분석이 끝난 후 관련된 공부를 다 한 나는 강해진 느낌이 들었다. 하핳..

프로젝트를 위해서 useOverlay에서 개선할 점을 생각해보니 다음과 같았다.

  1. 전역 상태 주입에서 전역 상태 관리로 교체하는 것이 리렌더링 문제에서 벗어날 수 있다. 이번 프로젝트에서 전역 상태는 zustand를 사용하고 있기에 zustand로 깎아내는 작업이 필요하다.
  2. slash의 라이브러리는 이전에 구현된 라이브러리 같았다. (React를 import 받고 있었음.) 현재 기준으로 어느정도 리팩토링을 하게 되면 가독성을 늘릴 수 있을 것이다. 간단하게는 컴포넌트의 id를 받는 부분에서 전역 변수를 지정해서 id를 넣어주고 있었는데 이는 react에서 id를 만들 때 쓰라고 만든 훅인 useId를 사용하여 리팩토링할 수 있는 식이다.
  3. OverlayController 단은 마운트, 언마운트 관련해서 애니메이션 처리 (Framer-Motion의 AnimatePresense 같은)를 위해 exit, close로 따로 분리되어 있는데 이는 가독성을 해친다고 생각했다. 훅에 넣지 말고 모달, 사이드바 같은 컴포넌트를 만드는 부분에서 애니메이션 처리를 할 때 상태를 하나 만들어서 처리해도 된다고 생각했다. 기존 라이브러리에서도 이 부분 때문에 주석이 길게 되어 있는 것을 확인할 수 있었는데 개인적으로는 주석 없이도 잘 읽히는 코드가 더 좋은 것 같다. 우리 프로젝트에서는 마운트, 언마운트 시의 애니메이션에 큰 비중을 두지 않았기 때문에 들어내기로 했다.

useCustomOverlay 구조도

OverlayStore
import { ReactNode } from "react";
import { devtools } from "zustand/middleware";
import { createStore } from "zustand/vanilla";

export type OverlayState = {
  ElementsInMemory: Map<string, ReactNode>;
};

export type OverlayActions = {
  mount: (id: string, element: ReactNode) => void;
  unmount: (id: string) => void;
};

export type OverlayStore = OverlayState & OverlayActions;

export const initOverlayStore = (): OverlayState => {
  return { ElementsInMemory: new Map() };
};

export const defaultInitState: OverlayState = {
  ElementsInMemory: new Map(),
};

export const createOverlayStore = (
  initState: OverlayState = defaultInitState,
) => {
  return createStore<OverlayStore>()(
    devtools((set) => ({
      ...initState,
      mount: (id, element) => {
        set(
          (state) => {
            state.ElementsInMemory.set(id, element);
            return { ElementsInMemory: new Map(state.ElementsInMemory) };
          },
          undefined,
          "mount",
        );
      },
      unmount: (id) => {
        set(
          (state) => {
            state.ElementsInMemory.delete(id);
            return { ElementsInMemory: new Map(state.ElementsInMemory) };
          },
          undefined,
          "unmount",
        );
      },
    })),
  );
};

zustand store 방식으로 함으로써 mount, unmount 관련된 로직을 store에 숨길수 있게 되었다. (로직 분리 굳)

트러블 슈팅

짧은 트러블 슈팅으로는 zustand에서는 shallow equal을 사용하여 상태의 변화를 감지하는데 delete만 지워서 리렌더링이 막 일어나게 되었었다.

데이터 변경시 return으로 새로운 Map을 넣어줘야 리렌더링이 정상적으로 일어난다.

OverlayStoreProvider
"use client";

import {
  OverlayStore,
  createOverlayStore,
  initOverlayStore,
} from "@/stores/modal-store";
import {
  Fragment,
  PropsWithChildren,
  createContext,
  useContext,
  useRef,
} from "react";
import { useStoreWithEqualityFn } from "zustand/traditional";

export type OverlayStoreApi = ReturnType<typeof createOverlayStore>;

export const OverlayStoreContext = createContext<OverlayStoreApi | undefined>(
  undefined,
);

export const OverlayStoreProvider = ({ children }: PropsWithChildren) => {
  const storeRef = useRef<OverlayStoreApi>();
  if (!storeRef.current) {
    storeRef.current = createOverlayStore(initOverlayStore());
  }
  return (
    <OverlayStoreContext.Provider value={storeRef.current}>
      {children}
    </OverlayStoreContext.Provider>
  );
};

export const OverlayProvider = () => {
  const ElementsInMemory = useOverlayStore((store) => store.ElementsInMemory);
  return (
    <>
      {[...ElementsInMemory.entries()].map(([id, element]) => (
        <Fragment key={id}>{element}</Fragment>
      ))}
    </>
  );
};

export const useOverlayStore = <T,>(
  selector: (store: OverlayStore) => T,
): T => {
  const overlayStoreContext = useContext(OverlayStoreContext);

  if (!overlayStoreContext) {
    throw new Error(`useModalStore must be used within ModalStoreProvider`);
  }

  return useStoreWithEqualityFn(overlayStoreContext, selector);
};

OverlayStore와 연결된 컴포넌트이다. 쌓임 맥락을 해결하기 위해서 main 아래에 놓아야한다.

Map의 entries로 Map을 전개해서 안에 있는 컴포넌트들을 꺼내주는데 순차적으로 렌더링되기 때문에 뒤에 구현되는 컴포넌트가 쌓임 맥락에서 이기게 되어 덮어써지게 된다.

useCustomOverlay
"use client";

import { useOverlayStore } from "@/providers/modal-store-provider";
import { useId, useMemo } from "react";

import { CreateOverlayElement } from "./types";

/**
 * Zustand와 결합하여 루트 아래에 컴포넌트를 렌더링하는 훅입니다.
 * close를 prop으로 받는 컴포넌트를 렌더링합니다.
 * Modal 컴포넌트와 같이 close 함수를 prop으로 받는 컴포넌트를 만들어서 넣어주시면 됩니다.
 * 해당 컴포넌트는 루트 아래로 띄워주기만 합니다. 
 * 
 * 모달 안에 다른 CustomOverlay를 만들어서 모달을 하나 더 띄우셔도 됩니다. 
 * 단,useOutSideClick은 따로 처리해주셔야 합니다.
 * 
 * @author 이승현
 * @param overlayElement
 * @returns 루트 아래에 해당 컴포넌트가 렌더링됩니다.
 *  const overlay1 = useCustomOverlay(({ close }) => (
    <Modal close={close}>
      <Modal.HeaderWithClose />
      <Modal.Title>제목</Modal.Title>
      <Modal.Description>난 예시다.</Modal.Description>
      <Modal.Description>해냈다.</Modal.Description>
    </Modal>
  ));
  return (
    <button onClick={overlay1.open}>overlay1에 넣은 컴포넌트를 여는 버튼/button>
 */
export function useCustomOverlay(overlayElement: CreateOverlayElement) {
  // useId로 고유한 id를 생성
  const id = useId();
  const mount = useOverlayStore((store) => store.mount);
  const unmount = useOverlayStore((store) => store.unmount);

  // useMemo를 사용하여 객체를 캐싱
  return useMemo(
    () => ({
      open: () => {
        mount(id, overlayElement({ close: () => unmount(id) }));
      },
      close: () => {
        unmount(id);
      },
    }),
    [id, mount, unmount, overlayElement],
  );
}

구현하면서 제일 맘에 들었던 useCustomOverlay이다.

useId를 이용해서 id가 고유값을 갖도록 처리했으며 호출할 때 컴포넌트를 들고 호출하게 해서 사용하는 단에서는

const modalSendEmailOverlay = useCustomOverlay(({ close }) => (
    <ModalSendEmail close={close} />
  ));
 //...
onClick={modalSendEmailOverlay.open}     

open이라는 메소드만 사용하면 렌더링시킬 수 있도록 하였다.

이제 동료 개발자는 세가지 과정만 거치면 오버레이를 관리할 수 있게 된 것이다.

  1. 화면을 띄울 컴포넌트 조립 (오버레이를 닫는 close라는 프롭을 가져야 한다.)
  2. useCustomOverlay에 담음
  3. 인스턴스.open

이로써 모달뿐만 아니라 사이드바, 토스트에도 넣을 수 있는 만능 훅을 구현했다!!!!!

Compound Pattern + useCustomOverlay 예제 (공통 모달 컴포넌트)

interface ModalContextType {
  close: () => void;
}

const ModalContext = createContext<ModalContextType | null>(null);

interface ModalProps {
  closeOnFocusOut: boolean;
  close: () => void;
  children: ReactNode;
  className?: string;
}

/**
 * 모달 컴포넌트
 *
 * @description 컴파운드 패턴으로 만들어졌으며 useCustomOverlay의 인수로써 함께 사용하시면 됩니다.
 * 각 요소들은 margin이 정해져 있지 않습니다. className으로 지정해서 조합해서 사용하시면 됩니다.
 *
 * @author 이승현
 * @param close 모달을 끄는 함수
 * @param children 모달 내부
 * @param closeOnFocusOut 화면 밖 클릭시 모달을 끄는 여부
 * @example
 * ```tsx
  export function ModalWarning({ handleConfirm, close }: ModalWarningProps) {
    const handleClick = async () => {
      await handleConfirm();
      close();
    };
    return (
      <Modal close={close} closeOnFocusOut>
        <header className="flex justify-center pb-4 pt-6">
          <Alert width={24} height={24} />
        </header>
        <div className="mx-12 text-center md:mx-9">
          <Modal.Title className="mb-2 text-slate-50 md:text-text-primary">
            회원 탈퇴를 진행하시겠어요?
          </Modal.Title>
          <Modal.Description className="mb-4">
            그룹장에 있는 모든 그룹은 삭제되고, 모든 그룹에서 나가집니다.
          </Modal.Description>
          <Modal.TwoButtonSection
            closeBtnStyle="outlined_secondary"
            confirmBtnStyle="danger"
            buttonDescription="회원 탈퇴"
            onClick={handleClick}
            close={close}
          />
        </div>
      </Modal>
    );
  }
 */
export default function Modal({
  children,
  close,
  closeOnFocusOut,
  className,
}: ModalProps) {
  const modalRef = useClickOutside(() => {
    close();
  });

  return (
    // eslint-disable-next-line react/jsx-no-constructed-context-values
    <ModalContext.Provider value=>
      <section className={cn(overlayVariants())}>
        <motion.div
          animate=
          initial=
          transition=
          className={cn(modalVariants({ className }))}
          ref={
            closeOnFocusOut
              ? (modalRef as LegacyRef<HTMLDivElement> | undefined)
              : null
          }
        >
          {children}
        </motion.div>
      </section>
    </ModalContext.Provider>
  );
}
//...
export function useModal() {
  const context = useContext(ModalContext);

  if (!context) {
    throw new Error("useModal must be used within a ModalProvider");
  }

  return context;
}
//...
function HeaderWithClose({ className }: HeaderWithCloseProps) {
  const { close } = useModal();
  return (
    <header className={headerWithCloseVariants({ className })}>
      <button type="button" onClick={close} aria-label="닫기">
        <CloseButton
          className="rounded-xl duration-100 hover:scale-105"
          width={24}
          height={24}
        />
      </button>
    </header>
  );
}
HeaderWithClose.displayName = "Modal.HeaderWithClose";

useCustomOverlay 분석에서 이야기 했듯이 마운트, 언마운트 관련 애니메이션 처리는 컴포넌트 내부에 구현되어져 있는 것을 확인할 수 있다.

이번 프로젝트에서는 비슷한 내부 요소를 가진 모달이 다양하게 있기 때문에 compound pattern으로 구성 요소들도 구현하였다.

compound pattern이란 프론트엔드 환경에서 사용되는 디자인 패턴 중에 하나로, context api를 이용해서 내부 상태가 구현된 하나의 컴포넌트만 가져와서 프로퍼티 안에 있는 컴포넌트를 활용, 마치 조립하듯이 다양한 컴포넌트를 구현할 수 있는 패턴입니다. 단, 트리 쉐이킹이 불가능하다는 단점도 갖고 있어 사용하는데 주의가 필요합니다.

export default function HandleArticleModal({
  close,
  onSubmit,
  defaultContent,
  defaultImage,
  defaultTitle,
}: HandleArticleModalProps) {
  const { handleNext, handlePrev, isNext } = useNextPage();
  const [imageFile, setImageFile] = useState<File | null>(null);

  const handleImageFile = useCallback((selectedImageFile: File | null) => {
    setImageFile(selectedImageFile);
  }, []);

  usePreventScroll();

  return (
    <Modal close={close} closeOnFocusOut={false}>
      <Modal.HeaderWithClose className="fixed right-7 top-7" />
      <section className="relative">
        {isNext ? (
          <ArticleForm
            file={imageFile}
            handlePrev={handlePrev}
            handlePost={onSubmit}
            defaultContent={defaultContent}
            defaultTitle={defaultTitle}
            defaultImage={defaultImage}
            close={close}
          />
        ) : (
          <FileDragDown
            file={imageFile}
            onSelect={handleImageFile}
            defaultPreview={defaultImage}
            handleNext={handleNext}
          />
        )}
      </section>
    </Modal>
  );
}

이를 활용한 모달 조립 예시이다. 조립하는 쪽에서는 모달을 구현하는 내부 로직, 스타일을 알 필요 없이 Modal안에 컴포넌트와 필요한 로직을 넣기만 하면 구현되는 것을 확인할 수 있다.

후기

좋은 문제 상황을 만나서 이번에 toss slash의 훅을 분석하는 시간을 가질 수 있게 되었다.

requestAnimateFrame이나 useImperative 훅 등 분석하지 않았다면 몰랐을 지식들을 많이 알게된 경험이었다.

이번 useOverlay는 다른 오픈 소스 라이브러리에 비하면 작은 크기이지만

추후에 좋은 문제를 만나면 이제 두려움 없이 커다란 오픈 소스라도 분석할 수 있을 것 같다.

더 성장해보자

https://github.com/772-company/kkom-kkom/pull/80

꼼꼼 프로젝트 진행 중에 겪은 경험들입니다. 오늘도 많이 성장한 것 같아요!

읽어주셔서 감사합니다! 지켜봐주세요!

감사합니다.

Categories:

Updated:

Leave a comment