go to post list

지난한 리팩토링 기록 (4/17+)

2024. 03. 19 • 페이지 처음부터 다시 설계하기

마지막 프로젝트에 대한 글도 세 번째입니다. 이전에 썼던 목 말라 Continuous 줘, 인증은…힘들다가 제 세 번째 프로젝트에 대한 글이었는데요, 이번에는 이 프로젝트를 리팩토링한 기록에 대해 써 보려고 합니다.

프로젝트 코드를 시간이 지난 이후에 다시 쓰는 일은 처음 해 보는 것이었는데, 리팩토링은 새로 짜는 것보다 더 어려운 것이었습니다. 잘 고쳐야 한다는 심적 부담감 같은 것이 더해져서 그런지 몰라도 지난한 시간이었습니다. 그래도 시간에 쫓겨 설계도 제대로 하지 못했던 부분을 오래 곱씹으면서 생각하고, 납득할 수 있을 때까지 다시 설계해 보고 하면서 고칠 수 있었던 건 그나마 다행이라고 생각합니다. 리팩토링이라기엔 해당 모듈들 자체의 구조를 다 바꿔가면서 새로 짠 수준이지만요…

1. 게시글 작성/수정 페이지 리팩토링

게시글 작성/수정 페이지는 Render Props 패턴으로 작성되어 있었습니다. 로직과 UI 부분을 분리하기 위해 무작정 사용했는데요, 30개가 넘는 함수와 메서드, 상태를 컴포넌트를 전달받아 렌더링하도록 되어 있습니다. 리팩토링하기 전의 코드는 아래와 같습니다. 정말 부끄럽지만… 고해성사를 하는 기분으로 (어차피 깃허브에 다 있으니) 올려 보겠습니다.

Typescript

export function ScheduleEdit() {
  return (
    <ScheduleEditData>
      {({ states, setStates, fn }) => (
      <div className={BASE}>
            <Spacing size="2" />
            <Form ...
            {/* 시작 날짜  시간 */}
            <Input
                  id="title"
                  type="text"
                  name="title"
                  value={states.scheduleName}
                  onChange={e => setStates.setScheduleName(e.target.value)}
                  placeholder={CREATE.PLACEHOLDERS.TITLE}
                />
            ...
            
            {/* 매우 장황한 폼 컴포넌트 */}
            
            ... />
     </ScheduleEditData>
  );
}

전체 코드

Typescript

export function ScheduleEditData(props: { children: (args: ScheduleEditDataProps) => ReactNode }) {
  const [startYear, setStartYear] = useState<number | undefined>();
  const [startMonth, setStartMonth] = useState<number | undefined>();
  
  ...
  {/* 정말 많은 상태와 함수들 */}
  ...
  
  //states는 상태, setStates는 setter, fn은 비동기 함수나 다른 모든 함수들을 묶어서 저장함
  return props.children({ states, setStates, fn });
}

전체 코드

아래 interface는 백엔드 파트와 협의해 정의한 API Request 사양입니다. 위의 Input 컴포넌트의 name은 title이라고 되어 있는데요, 아래의 interface에는 title이라는 필드가 없습니다. 그러니 주석이 없으면 저 Input 컴포넌트가 아래 리퀘스트의 어떤 부분을 담당하는지 알 수 없는 경우도 발생하고, 코드를 작성하면서 불필요한 변환 함수를 만들게 되거나, 비효율적인 코드를 더 작성하게 됩니다. 아무리 나중에 고친다고 해도 그렇지 이건 좀 심하다는 생각이 듭니다.

Typescript

export interface ScheduleCreateRequest {
  scheduleContent: string;
  isTimeSelected: boolean;
  isDateSelected: boolean;
  isAllday: boolean;
  isAuthorizedAll: boolean;
  scheduleStartAt: string;
  scheduleEndAt: string;
  participants: Array<UserReadResponse>;
  alarmTime: string;
  scheduleName: string;
  scheduleMemo: string;
}

리팩토링 목표

1. 소통 언어를 하나로 맞춥니다. 유지보수 피로도를 줄입니다.

  1. 가능한 한 API Requestform name을 컴포넌트 단위로 맞춰 유지보수 단위를 최적화합니다.
  2. Input 하나당 하나의 상태를 생성해 사용하던 것을 Form 하나당 하나의 상태를 생성하는 것으로 변경합니다.

2. 조금이라도 어플리케이션의 성능을 최적화합니다.

  1. 실시간으로 피드백이 필요없는 Input의 경우 리렌더를 방지할 수 있는 장치를 마련합니다.
  2. 수정 페이지의 경우 초기 데이터 fetching을 SSR로 변경해 불필요한 통신과 렌더링 횟수를 줄입니다.

리팩토링 과정 및 결과

1. useForm Hook 작성

useForm이라는 custom Hook을 작성했습니다. form에서 쓰이는 값들과 함수를 자동으로 처리해 리턴합니다. 필수로 파라미터에 넣어야 하는 값은 inputName:initialValue 쌍의 객체, onSubmit입니다. 그러면 넣은 객체와 동일한 타입의 values, input에 값을 바인딩할 수 있는 handleChange, submit등 필요한 여러 값과 함수를 받을 수 있습니다. useForm은 Form 내부의 몇몇 Input만 비제어 Input을 사용하는 경우나 상태 관리 라이브러리를 사용하는 경우도 지원합니다.

04/17 - useForm은 최적화 문제로 외부 라이브러리 연동 대신 React Context 연동을 매끄럽게 지원하려고 노력했습니다. 여기에서 설명하려면 분량이 너무 길어질 것 같아 일단은 링크로 대체합니다. 이 훅에 대한 자세한 정보와 한글 API는 이 글에서, 전체 구현 코드는 여기에서 보실 수 있습니다.

2. zustand 연동 및 컴포넌트 분리

작성한 custom Hook과 zustand를 연동하고,API Request의 필드 단위로 하위 컴포넌트를 분리했습니다. 컴포넌트의 이름도 xxxxxForm 의 형태로 변경해 주었습니다.

1차 리팩토링 코드

Typescript

export function ScheduleEditForm({ scheduleDetail }: ScheduleEditFormProps) {

  const [values, refValues, submit, setUseFormValues, setUseFormData] = useScheduleEditStore(
    useShallow(state => [state.values, state.refValues, state.submit, state.setUseFormValues, state.setUseFormData])
  );
  useForm<ScheduleEditFormData>({
    initialValues: scheduleDetail || initFormValues,
    onSubmit: scheduleSubmit,
    validator: scheduleFormValidator,
    refInputNames: refInputNames,
    setExternalStoreValues: setUseFormValues,
    setExternalStoreData: setUseFormData,
  }); //외부 상태관리 라이브러리를 사용할 때는 상태관리 라이브러리의 값을 사용하고, 이 훅의 리턴값을 사용하지 않습니다

  return (
    <>
      <Form
        formName="scheduleEdit"
        onSubmit={() => {
          submit({ ...values, ...refValues });
        }}
      >
        <Flex flexDirection="column" justifyContents="start" alignItems="start">
          <H2>{CREATE.ADD_SCHEDULE}</H2>
          <Spacing dir="v" size="3" />
          <IsAuthorizedAll />
				    ...
          <Category />
          <ScheduleMemo />
          <Spacing dir="v" size="3" />
          <Btn type="submit" variant="key">
            {CREATE.ADD_SCHEDULE}
          </Btn>
        </Flex>
      </Form>
      <Spacing dir="v" size="5" />
    </>
  );
}

4/17 2차 리팩토링

상태 관리 라이브러리를 걷어냈습니다. useForm과 내부에서 지원하는 useFormContext로 마이그레이션했습니다. useFormContextcreateUseFormContextContext API를 그대로 리턴하지만, useForm의 복잡한 리턴 타입을 쉽게 사용할 수 있도록 지원합니다.

Typescript


export const ScheduleEditContext = createUseFormContext<ScheduleEditFormData>();

export function ScheduleEditForm({ scheduleDetail }: ScheduleEditFormProps) {
  const scheduleSubmit = async() => {...}

  const data = useForm<ScheduleEditFormData>({
    initialValues: scheduleDetail || initFormValues,
    onSubmit: scheduleSubmit,
    refInputNames: refInputNames,
  });

  return (
    <>
      <ScheduleEditContext.Provider value={data}>
        <Form
          formName="scheduleEdit"
          onSubmit={data.submit}
        >
          <Flex flexDirection="column" justifyContents="start" alignItems="start">
            <H2>{CREATE.ADD_SCHEDULE}</H2>
            <Spacing dir="v" size="3" />
            <IsAuthorizedAll />
            <Spacing dir="v" size="2" />
            <ScheduleName />
						{...}
            <Category />
            <ScheduleMemo />
            <Spacing dir="v" size="3" />
            <Btn type="submit" variant="key">
              {CREATE.ADD_SCHEDULE}
            </Btn>
          </Flex>
        </Form>
        <Spacing dir="v" size="5" />
      </ScheduleEditContext.Provider>
    </>
  );
}

각각의 input 컴포넌트들은 아래의 예시처럼 분리되어 있습니다.

Typescript

export function ScheduleName() {
  const [values, handleChange] = useScheduleEditStore(useShallow(state => [state.values, state.handleChange]));

  return (
    <Label htmlFor="scheduleName">
      <Input id="scheduleName" type="text" name="scheduleName" value={values.scheduleName} onChange={handleChange} placeholder={CREATE.PLACEHOLDERS.TITLE} />
    </Label>
  );
}

3. Server-side data fetching

수정 페이지의 초기 데이터 fetch를 페이지가 렌더링된 후 클라이언트 사이드에서 하던 것을 서버 사이드에서 하도록 변경해 브라우저와 서버 사이의 불필요한 HTTP 통신 횟수와 DOM 렌더링 횟수를 줄였습니다.

기존에 브라우저서버 사이에서

  • 페이지 /getHTML resassets/js req, res초기 페이지 hydration 완료
  • API Call: Next.js req(...Spring res → Next.js res)
  • 컴포넌트 리렌더 완료

순으로 초기 페이지 요청과 응답, API Call, 컴포넌트 리렌더링까지 이루어지던 것을

  • 페이지 /get(...Spring res → HTML res)assets/js req, res초기 페이지 hydration 완료

1단계로 간소화할 수 있습니다.

기존에 Next.js의 장점을 살리지 못하고 있던 것을 변경한 것으로, 사용자에게 페이지가 조금이라도 더 빠르게 전달될 수 있도록 했습니다. 클라이언트 입장에서는 HTTP 통신 횟수가 줄어들기 때문에 인터넷 연결이 불안정하고 속도가 느릴수록, 디바이스의 성능이 좋지 않을수록, Spring 서버와 Next.js 서버가 물리적으로 가까이 있을수록 더 체감될 것이라고 생각합니다.

리팩토링 성과

PR1 / PR2

가장 큰 성과는 유지보수 피로도의 저하라고 생각합니다. 잘 읽히는 코드라는 말 자체가 유지보수 피로도가 낮아졌다는 뜻이라고 생각합니다. 이전 코드에 대한 부채 청산이었다고 생각하고, 추후에 다른 Input 필드가 필요해지거나 디자인 변경이 필요해질 때 훨씬 더 간단하게 유지보수를 할 수 있게 되었습니다.

2. 친구 도메인 전체 재작성

친구 도메인은 끔찍한 조건부 렌더링이 특징이었습니다. 페이지 라우팅 없이 독자적인 컴포넌트로서 CRUD 기능을 모두 가지고 있어야 했는데요, 당초 기획할 때 사이드바 내에서 검색, 검색 후 친구 추가, 삭제가 모두 가능하도록 기획되었기 때문입니다. 거기에 친구들을 그룹화해서 모아둘 수 있는 그룹이라는 기능도 기획되어서 그룹 편집도 그 안에서 가능해야 했습니다. 우선 이전 코드를 살펴보겠습니다.

Typescript

export const MODE_CONTEXT = createContext<
  [
    number | 'ADD_GROUP' | 'SEARCH' | 'NOT_EDITING',
    Dispatch<SetStateAction<number | 'ADD_GROUP' | 'SEARCH' | 'NOT_EDITING'>>,
  ]
>(['NOT_EDITING', () => {}]); 

export function FriendFrame({ children }: FriendsProps) {
  const [MODE, SET_MODE] = useState<
    number | 'ADD_GROUP' | 'SEARCH' | 'NOT_EDITING'
  >('NOT_EDITING');
  
  return (
    <MODE_CONTEXT.Provider value={[MODE, SET_MODE]}>
      <div className={FRAME}>
        <Flex flexDirection="column" justifyContents="start" alignItems="start">
          <Flex width="fill" justifyContents="spaceBetween">
            <H3>친구</H3>
            <Flex justifyContents="start">
            
              {MODE === 'ADD_GROUP' ? (
                <BtnRound ...
                ...
     >
   )
 }

전체 코드

React Context로 최상위에서 라우팅 규칙을 만든 뒤, 이 라우팅 규칙으로 조건부 렌더링을 하고 있는 것이 보입니다. 조건부 렌더링을 하는 게 문제가 아니라, 조건부 렌더링을 유지보수가 불가능하도록 쓴 것이 큰 문제입니다. 주석이 없으면 알아볼 수 없고, 컴포넌트 분리도 되어 있지 않고요. (고해성사를 하자면 두 코드 모두 산출물 발표 6시간 전에 아웃풋을 내야 해서 휘갈긴 코드입니다.)

리팩토링 과정

리팩토링보다 컴포넌트를 아예 다시 작성하는 게 빠를 것이라고 생각해서, 깔끔하게 걷어내고 다시 구현했습니다.

1. 라우팅 재분류

우선 라우팅을 직관적으로 바꿨습니다. 라우트 자체가 도메인 종속적인 데이터라고 판단했기 때문에 React Context를 zustand로 마이그레이션하고, 라우트를 네 개로 다시 구분했습니다. (제가 이번 프로젝트에서 어떤 상태 관리 도구를 사용해야 하는지 판단한 기준은 이 글에 자세히 적어 놓았습니다.)

Typescript

export type ROUTES = 'READ' | 'SEARCH' | 'UPDATE' | 'CREATE';

export interface Page {
  PATH: ROUTES;
  updating: number | null;
  setUpdating: (to: number | null) => void;
  PushToFriend: (to: ROUTES) => void;
}
export const pageSlice: StateCreator<Page, [], [], Page> = set => ({
  PATH: 'READ',
  updating: null,
  setUpdating: to => set(state => ({ updating: to })),
  PushToFriend: to => set(state => ({ PATH: to })),
});

스케줄 페이지를 리팩토링하면서 의사 소통의 단위를 API Request/Response로 정했었는데요, 친구 도메인의 API 관련 Interface를 살펴 보면 아래와 같습니다.

Typescript

export interface User {
  userSeq: number;
  userName: string;
  userId: string;
  imageUrl: string;
}

export interface Group {
  groupSeq: number;
  groupName: string;
  groupMember: Array<User>;
}

export type FriendReadResponse = Array<Group>;

export interface GroupCreateRequest {
  groupName: string;
  userSeqs: Array<number>;
}
export interface GroupUpdateRequest {
  groupName: string;
  userSeqs: Array<number>;
}

Request, Response의 기본 단위가 Group 으로 설계되어 있음을 알 수 있습니다. 따라서 페이지 컴포넌트의 구조도 Group으로 맞추어 재설계합니다. 그 전에 먼저 프레임과 라우터로 Group을 감싸 주었습니다. 프레임은 각각 HeaderBody로 구분되어 있습니다.

Typescript

export function FriendFrameLayout() {
  return (
    <div className={FRAME}>
      <FriendFrameHeader />
      <FriendFrameBody>
        <FriendRouter />
      </FriendFrameBody>
    </div>
  );
}

Typescript

export function FriendRouter() {
  const [PATH, PushToFriend] = useFriendRouter(state => [state.PATH, state.PushToFriend]);

  switch (PATH) {
    case 'ADD':
      return <FriendGroupCreate />;
    case 'READ':
      return <FriendGroupRead />;
    case 'SEARCH':
      return <FriendGroupSearch />;
    case 'UPDATE':
      return <FriendGroupUpdate />;
    default:
      PushToFriend('READ');
      return <FriendGroupRead />;
  }
}

SearchRead의 API를 그대로 사용하지만 아예 다른 UI를 사용하는 상황이므로 페이지를 분리합니다. 내부는 그대로 불러온 API에 따라 Friend 컴포넌트를 렌더링하고 있는 간단한 구조입니다. 당연히 내부 컴포넌트들도 모두 새로 작성했습니다.

2. 꼬여 있는 상태 풀기

Group Update의 경우는 조금 더 살펴볼 필요가 있습니다. SWR에서 가져온 서버 상태를 편집할 수 없어 편집할 수 있도록 zustand의 초기값으로 set하게 되는 부분 때문인데요, 서버 상태를 그대로 복사해 출처를 두 개로 만드는 것처럼 보이기 때문입니다. 처음에는 저도 이게 맞나 싶어서 헷갈렸는데, input의 초기값 출처가 서버 상태일 뿐이지 바인딩된 상태 자체는 당연히 클라이언트 상태이기 때문에 side effect로 인한 값의 변화를 추적할 수 있도록 useEffect로 SWR과 zustand를 연결했습니다.

처음에는 이 부분을 리팩토링하면서 zustand 내부에서 비동기 처리를 해서 클라이언트 상태로 간주하는 것으로 생각했었는데, Update를 하는 컴포넌트에서만 따로 처리를 하게 되면 구현이 훨씬 복잡해지는 문제가 있어 SWR을 연동시키는 패턴으로 처리했습니다.

덧붙여서 이 패턴이 첫 번째 리팩토링인 게시글 수정에서는 나오지 않았었는데요, 게시글 수정 페이지의 경우 초기 데이터를 SSR로 처리하기 때문입니다.

Typescript


//이전 리팩토링 코드
export function FriendGroupUpdate() {
  const [pushToFriend, updatingGroupSeq, setUpdatingGroupSeq] = useFriendRouter(...);
  //페이지 이동 함수

 const [values, setGroup, submit, handleChange, addUser, deleteUser, setFormData] = useGroupRequestFormStore(
    useShallow(state => [...])
  ); //상태 관리 라이브러리 zustand에 연동된 useForm의 값들

  const { data, mutate } = useFriend();
  //친구 리스트 data, 친구 리스트를 재검증하는 함수 mutate (SWR hook)
  const { ALL_FRIENDS, UPDATING_GROUP } = data?.reduce(...)
	//API 설계상 groupName === null인 그룹이 "전체 친구 목록"입니다.

    useForm<GroupRequestForm>({
    initialValues: values,
    onSubmit: () => updateGroup(updatingGroupSeq, values),
  }); //이 시점에서 zustand에 useForm의 초기값이 들어갑니다.
  	
  useEffect(() => {
    UPDATING_GROUP && setGroup(UPDATING_GROUP);
  }, [UPDATING_GROUP]);
  //현재 편집중인 그룹 정보를 정상적으로 받아왔다면 존재한다면, 폼의 초기값을 세팅합니다.
	//SWR이 가져온 서버 상태를 편집 가능한 클라이언트 상태의 초기값으로 설정하는 부분입니다.

  return (
    <>
      <Form
        formName="FriendGroupCreate"
        onSubmit={async e => {
          e.preventDefault();
          submit(values);
          await mutate();
          pushToFriend('READ');
        }}
      >
      ...
      
     </>
  )
}

저의 경우는 이 데이터를 useForm custom hook으로 넣어줘야 하고, 해당 setState function은 zustand 안에 들어가 있는 상태여야 하기 때문에 초기값으로 swr의 data(UPDATING_GROUP)를 바로 넣어줄 수가 없습니다. 즉 useForm의 초기화가 보장된 이후에 값을 세팅할 수 있게 되기 때문에 저런 순서로 훅을 호출하게 됩니다.

4/17 2차 리팩토링

SWR과 Zustand가 useForm을 통해 연결되도록 했습니다. SWR에서 불러온 초기값이 useEffect를 통해 useForm의 초기값으로 들어가고, input의 복잡한 입력값이 가공을 통해 zustand에 User 객체를 추가하거나 뺍니다. 최종적으로 API Call은 Zustand에 있는 가공된 객체를 보내게 됩니다.

Typescript


export function FriendGroupUpdate() {
  const [pushToFriend, updatingGroupSeq, setUpdatingGroupSeq] = useFriendRouter(state => [
    state.PushToFriend,
    state.updatingGroupSeq,
    state.setUpdatingGroupSeq,
  ]);

	//Zustand Store
  const { groupName, groupMembers, setGroupName, addUser, deleteUser } = useGroupRequestFormStore(useShallow(state => state));

	//SWR HOOK, 사실상 최초 1회만 데이터를 불러오도록 설정
  const { UPDATING_GROUP, ALL_FRIENDS, isLoading, mutate } = useFriend(updatingGroupSeq);

	//useForm
  const { values, setValues, submit, handleChange } = useForm<GroupRequestForm>({
    initialValues: {
      groupName: '',
      groupMembers: [],
    },
    onSubmit: () => updateGroup(updatingGroupSeq, { groupName, groupMembers }),
  });
  
  //SWR 데이터를 useForm 초기값으로 설정
  useEffect(() => {
    if (UPDATING_GROUP) {
      setValues({ groupMembers: UPDATING_GROUP.groupMember, groupName: UPDATING_GROUP.groupName });
    }
  }, [UPDATING_GROUP]);

  if (isLoading || !UPDATING_GROUP) return <FriendLoading />;
  else
    return (
      <>
        {
          <Form
            formName="FriendGroupEdit"
            onSubmit={async e => {
              e.preventDefault();
              //폼 데이터를 Zustand 스토어로 옮겨 가공하고 전송한 후, 데이터 재검증
              setGroupName(values.groupName);
              addUser(values.groupMembers);
              submit({});
              await mutate();
              setUpdatingGroupSeq(null);
              pushToFriend('READ');
            }}
          >
            <GroupHeader>
						{...}
            </GroupHeader>
            <Spacing size="0.5" />
	          {...}
          </Form>
        }
      </>
    );
}

리팩토링 후기

PR LINK

친구 도메인 컴포넌트가 꽤 복잡한 컴포넌트라고 생각했는데, 막상 이렇게 풀리고 보니 복잡한 컴포넌트가 아니었습니다. 파일 구조도 비교적 정리된 모습이고요.

마무리 및 여담

‘시간이 충분히 주어졌더라면’ 이라고 생각하기 전에 그냥 다음부터는 시간이 촉박하더라도 제대로 짤 수 있게 제가 더 연습해야겠다는 생각을 했습니다. 데드라인은 진짜 죽음의 시간이기 때문에 결국 거기에 맞춰서 잘 짜는 게 능력 아닌가 싶습니다.

컴포넌트를 다시 쓰는 작업을 하면서 얻은 건 컴포넌트 설계나 상태 설계같은 기술적인 측면도 크지만 좋아하는 일을 찾은 것도 큰 수확인 것 같습니다. 첫 번째 프로젝트에서는 돈 올라가는 애니메이션을 구현하면서 UX/UI 만드는 일을 훨씬 더 좋아하는 줄 알았는데, 알고 보니 이런 부채 청산 작업이나 기술 스택 마이그레이션 작업 같은 일이나 개발자의 문제를 해결하는 일, 개발자를 서포트하는 일도 못지않게 신나는 일이었습니다. 이런 유틸리티 hook을 구현하는 작업이 그렇게 재미있을 수가… 생각해 보면 프로젝트를 세 번 하면서 항상 생각하고 있던 건 “옆에 있는 동료의 문제를 어떻게 해결해줄 수 있고 어떻게 하면 더 편하게 개발하게 할 수 있을까” 였던 것 같습니다. 지금 보면 미숙하게 구현한 것들 투성이지만요. 그래도 동료가 제가 만든 함수나 컴포넌트를 잘 사용해 주면 기분이 참 짜릿한 것 같습니다. 어떤 개발자가 되고 싶냐는 질문을 많이 받는데 아무래도 저는 이런 쪽이 잘 어울리는 것 같다는 생각을 요즘 하곤 합니다.

사실 리팩토링을 하면서나 프로젝트를 진행하면서 계속 하고 있는 고민이 있는데, 컴포넌트를 어느 선에서 묶고 풀어야 하는지, 추상화 레벨은 어느 선에서 맞춰야 하는지 같은 것입니다. 이건 상황에 따라 잘 합의해야 하는 문제인데 지나고 보면 잘못 판단한 것 같다는 생각이 들 때가 많아서 정말 어려운 문제인 것 같습니다. 이 문제는 또 나중에 다룰 기회가 있으면 글로 써 보도록 하겠습니다.

4/17 2차 리팩토링 후기

useForm을 계속 다시 쓰다 보니 2차 리팩토링을 하게 됐습니다. 생각보다 길게 끌게 됐는데 그래도 이제는 왜 이렇게 했는지에 대해 이전보다 스스로 납득할 수 있게 되었고, 다른 사람에게 좀더 설명할 수 있게 된 것 같아서 좀더 성장하지 않았나 싶습니다.

다른 글