원티드 프리온보딩 챌린지 1주차 과제 2/2

HR.lee·2022년 8월 13일
1

원티드

목록 보기
3/9

리팩토링 기간 : 2022-08-13 ~ 2022-08-16

1. 1주차 typescript 강의를 바탕으로 코드를 2차 개선하기

1. common 폴더에 있는 것들 index화 해서 한번에 관리하기

before

  • 앨리먼트를 분리한건 좋았지만 한개 페이지를 꾸미기 위해서 import줄이 불필요하게 너무 길어졌다.
import { Input } from "../../common/Input";
import { Button } from "../../common/Button";
import Label from "../../common/Label";
import Spinner from "../../common/Spinner";
import { Box } from "../../common/Box";
import { Text } from "../../common/Text";
  • 재료만 이만큼이다.

after

  • common폴더에 index.ts를 만들고
import { Box } from "./Box";
import { Button } from "./Button";
import { Input, TextArea } from "./Input";
import Label from "./Label";
import Spinner from "./Spinner";
import { Text } from "./Text";
export { Box, Button, Label, Spinner, Text, Input, TextArea };
  • 컴포넌트에는 import * as el from "./common" 이런식으로
  • 그리고 불러올때 el. <-- 이렇게 점을 찍고 불러올 수 있다.
import * as el from "./common"
...

 {loading && <el.Spinner />}
      <el.Box>
        <div className="">
          {isSignInPage ? (
            <el.Text variant="title">Sign up</el.Text>
          ) : (
            <el.Text variant="title">Login</el.Text>
          )}
  • 이렇게 하면 import 갯수도 줄고 얘가 inline styled-component인지 element인지도 쉽게 구분할수 있다.

2. login과 signin 컴포넌트 분리 및 공통함수

before

  • useForm과 useFormValidation 두개의 훅은 로그인과 회원가입을 처리하기 위해 만들어진 함수였지만, 딱 그 2개만 처리할 수 있는 훅이었다.

  • 필터와 모든 value값은 value가 email, password, passwordConfirm일때만 사용 가능했다.

  • typescript와 객체리터럴의 콜라보...

  • 겨우 2가지 경우인데도 호환이 안되고 재사용이 1도 불가능한 코드였다.

  • 그치만 수업에서 몇가지 중요한 개념들을 배워서 함수를 공통으로 쓸수 있게 바꾸어 보았다.

after

1) callback : 함수를 인자로 전달하면 내부로직을 뜯어고칠 필요 없이 변화가 필요한 특정 값만 교체해도 된다.

  • 내 경우 콜백함수는 이미 사용하고 있었지만 안에 있는 value와 error타입도 동적으로 교체해줄 필요가 있었다.
  const { values, errors, handleChange, handleSubmit, isError } = useForm(
    login,
    validate,
    init <--- 추가
  );
  • 간단하게 init을 추가해서 validation할 객체를 같이 전달해 주었다.
  • init은 constant 폴더에 따로 분리해두었다.
export const UserLoginInit = { email: "", password: "" };
export const NewUserInit = { email: "", password: "", passwordConfirm: "" };

2) typescript의 타입지정 중엔 객체 프로퍼티의 유무에 따라 분기를 태우는 방법도 있다.

  • if ("phoneNumber" in friend) 이렇게 사용할 수 있다.

   <form onSubmit={handleSubmit} noValidate>
            {isLoginPage && <LoginForm {...{ handleChange, values, errors }} />}
 			{isSignInPage &&
              "passwordConfirm" in values &&
              "passwordConfirm" in errors && (
                <SignInForm {...{ handleChange, values, errors }} />
              )}
  • 이렇게 실제 submit을 담당하는 Form.tsx 안에 LoginForm과 SignInForm을 따로 빼서 조건에 따라 소환했다.

3) useFormValidations의 경우 객체리터럴을 사용했기 때문에 init값을 주면 객체 = 참조에 의한 복사만 가능해서ㅜㅜ 에러값이 자꾸 서버에 보내야할 밸류값들을 덮어쓰는 문제가 있었다.

export default function validate(
  values: UserProps | NewUser,
  init: UserProps | NewUser
) {
const errors = Object.assign({}, init);
  • 이렇게 받아온 init 객체 자체를 복사하는 방식으로 해결했다.

4) 유효성검사기들을 유틸로 분리하여 validate 폴더에 넣기

  • 이렇게 하나하나 분리해서 폴더에 담으면 객체 키가 이메일일 경우에 어디서든 사용가능한 유틸이 만들어진다.
import React from "react";

type emailType = { email: string };

export const email = (value: emailType, error: emailType) => {
  if (value.email === "") {
    error.email = "이메일을 입력해주세요";
  } else if (!/\S+@\S+\.\S+/.test(value.email)) {
    error.email = "이메일 주소의 형태로 입력해주세요";
  }
  return error.email;
};

...

import React from "react";

type passwordType = { password: string };

export const password = (value: passwordType, error: passwordType) => {
  if (!value.password) {
    error.password = "비밀번호를 입력해주세요";
  } else if (value.password.length < 8) {
    error.password = "비밀번호는 최소 8자입니다";
  }
  return error.password;
};

  • 얘를 useFormValidation에 import 해주면 이렇게 코드가 짧아지고 간결해진다. 그리고 확장성도 증가한다.

  • values <- 실제 값, init <- 메시지 전송을 위한 키만 들어있는 빈 객체

import React from "react";
import { UserProps, NewUser } from "../types/user";
import * as validate from "./validations";

export default function useFormValidations(
  values: UserProps | NewUser,
  init: UserProps | NewUser
) {
  const errors = Object.assign({}, init);

  errors.email = validate.email(values, errors);
  errors.password = validate.password(values, errors);
  if ("passwordConfirm" in values && "passwordConfirm" in errors) {
    errors.passwordConfirm = validate.passwordConfirm(values, errors);
  }

  return errors;
  • 들어오는 타입과 객체리터럴 부분을 고치고 유효성검사를 추가하고 싶으면 vaildate 폴더에서 키별로 추가할 수도 있다!

5) useForm 안의 에러핸들러 부분을 범용적으로 바꾸기

before

useEffect(() => {
    if (
      pathname === "/" &&
      Object.values(errors)[0] === "" &&
      Object.values(errors)[1] === ""
    ) {
      setIsError(false);
    } else if (
      Object.values(errors)[0] === "" &&
      Object.values(errors)[1] === "" &&
      Object.values(errors)[2] === ""
    ) {
      setIsError(false);
    } else {
      setIsError(true);
    }
  }, [debouncedKeyword, errors]);
  • 하나하나 단순비교 해주었기에 무척 길고 게다가 하드코딩이다!!

after

  const errorList = Object.entries(errors).filter(
    ([key, value]) => value === ""
  );
    useEffect(() => {
    if (errorList.length === Object.entries(values).length) {
      setIsError(false);
    } else {
      setIsError(true);
    }
  }, [debouncedKeyword, errors]);
  • 값이 뭐가 들어오더라도 에러리포트에 빈값이 다채워짐 + values의 길이와 같아지면 에러스테이트를 false로 돌려주도록 했다.
  • 이렇게 하자 value의 길이나 안에 든 애들에 상관없이 실행할 수 있는 함수가 되었다.

결론!

  • 무척 보람있는 작업이었다!
  • 이제 1달 뒤에 다시 이 코드를 보더라도 혹은 다른 프로젝트에서 갖다쓸때도 편안하게 가져다 쓸 수 있을 것 같다.

3. 스토리지 추상화하기

커밋3
커밋4

  • 스토리지는 로컬스토리지일수도 있고 세션스토리지일수도 있는데 이걸 함수로 분리해서 사용하면 나중에 뭐가 바뀌더라도 api폴더에 있는 애만 교체해주면 편하게 사용할 수 있다고 한다.

  • 멘토님이 강의자료에 남겨주신 코드를 토대로 빌드해보았다.

axios.interceptors.response.use(
	(res) => {
		if (res.data.token) {
			Storage.set({ key: "authToken", persist: false })
		}
	  return res;
	},
	(error) => {
	  return Promise.reject(error);
	}
);
  • 이런식으로 사용하는건데 여기서 스토리지를 정의해주면 된다.
  • get함수와 set함수를 정의해주었다.
  • 타입스크립트에 맞추는게 참 힘들었다.
export type storageProps = {
  key: string;
  persist: boolean;
  value?: string | "";
};

export type storageInnerProps = {
  set: ({ key, persist, value }: storageProps) => void;
  get: ({ key, persist }: storageProps) => string | undefined | null;
};

export const Storage: storageInnerProps = {
  set: ({ key, persist, value }: storageProps) => {
    if (persist === true) {
      sessionStorage.setItem(key, value || "");
    }
    if (persist === false) {
      localStorage.setItem(key, value || "");
    }
  },
  get: ({ key, persist }: storageProps) => {
    if (persist === true) {
      return sessionStorage.getItem(key);
    }
    if (persist === false) {
      return localStorage.getItem(key);
    }
  },
};
  • set을 구현하기 위해 value값도 넣어보았다.

  • set은 스토리지에 토큰을 집어넣는거니까 void가 되고 get은 토큰을 가져와야하니까 string type을 리턴한다.

  • persist 라는 boolean값을 true로 하면 세션 스토리지, false로 하면 로컬 스토리지에 들어가게 하고 기존엔 리퀘스트에만 인터셉터를 적용했었는데 리스폰스도 if문으로 해서 토큰을 들어가게 하고 바깥 로직에서는 깔끔하게 지워주었다.

  • 실제 사용

instance.interceptors.request.use(
  function (config) {
    const token = Storage.get({ key: "token", persist: false });
    if (token !== "")
      instance.defaults.headers.common.Authorization = token || "";
    return config;
  },
  function (error) {
    return Promise.reject(error);
  }
);

instance.interceptors.response.use(
  (response) => {
    if (response.data.token) {
      Storage.set({ key: "token", persist: false, value: response.data.token });
    }
    return response;
  },
  (error) => {
    return Promise.reject(error);
  }
);
  • 세션스토리지나 다른 DB를 사용하게 되어도 만들어둔 함수를 기반으로 한 부분만 고치면 되어 좋다.

라우팅, 로그아웃 지점에서의 토큰 유무 확인을 위한 함수

  • 프로젝트에서 localstorage들을 제거하려고 보니 토큰이 있는지 확인하는 함수도 필요하다는 것을 알았다.
  • 얼른 추가해주었다.
export type storageInnerProps = {
  set: ({ key, persist, value }: storageProps) => void;
  get: ({ key, persist }: storageProps) => string | undefined | null;
  has: ({ key, persist }: storageProps) => boolean | undefined;
};

export const Storage: storageInnerProps = {
.....
  has: ({ key, persist }: storageProps) => {
    if (persist === true) {
      return !!sessionStorage.getItem(key)?.valueOf();
    }
    if (persist === false) {
      return !!localStorage.getItem(key)?.valueOf();
    }
  },
};
  • 이렇게 하면 뷰단에서 코드 한줄로 토큰의 존재유무를 알 수 있다!
  const token = Storage.has({ key: "token", persist: false });
  1. Darkmode와 모바일 웹 개선

before

  • 상단에 계속 사이드바를 띄워서 한번에 볼수 있게 한건 좋았지만 문제는 모바일이었다. 모바일창에 가면 사이드바가 화면의 절반 이상을 차지해서 다른 컨텐츠를 보기가 힘들었다.

  • 게다가 오버플로우가 적용되어 있지 않아 컨텐츠 내용이 길 경우 what to do가 계속 늘어났다.

after

  • 첫번째로는 화살표를 만들어서 스크롤 중 사이드바를 닫고 열수 있게 했다. 하지만 스크롤 위치가 페이지의 상단이라면 사이드바는 원상복구되고 화살표는 작동하지 않는다. 배너역할을 떼니까 랜딩페이지가 예쁘지 않았기 때문에 스크롤에 따라 적용시켰다.

  • 줄이면 이렇게 깔끔하게 들어간다. 물론 모바일에서도 잘 작동한다.
  • 디테일페이지에는 오버플로우 y를 적용해서 전체크기가 모바일에서도 화면의 절반을 넘지 않게 했다.

  • 그리고 다크모드도 구현해보았다.

  • 모바일에서도 잘 나온다.

기타 꿀팁 정리

  1. 유니온타입을 사용하자. 타입 한정은 타입 자동완성에 매우 좋다.

    • 예를 들어, 회원가입과 로그인 2개의 기능만 하는 함수를 string값으로 분리한다면, 유니온으로 그 값 2개만 들어가게 해주면 좋다. 적어도 개발자의 오타로 인한 버그 발생은 생기지 않는다.
    • 리듀서 사용할 때 action을 대문자로 짓는 규칙 + 상수로 선언해두는 규칙과 비슷한 맥락인 것 같다.
  2. 제네럴 타입을 이용하면 인자타입과 리턴타입이 일치하지 않아도 된다! 인자가 달라질 수 있는 경우에도 유용하다. 제네릭은 최대한 단순하게 설명하면 “인자로 타입을 받는 함수”

    • 문자를 받아서 숫자로 변환해줘야 한다거나,
    • 리듀스를 사용할 때 acc에는 리턴값이 없고, 또 배열은 never[] <== 비어있는 거에서 시작하는 등 타입이 다른거 여러개를 한번에 쓰는데다 연산결과를 반환하기 때문에 객체를 넣으면 any파티가 된다. 이럴때 쓰면 좋다.
  3. api 받아올때 error의 타입을 error(axios라면 axios error)로 한정해주기 위해 if문을 사용해야 한다. 이런걸 타입가드라고 한다.

    • if문을 쓴다고 무조건 타입추론이 되는건 아니지만, 많은 경우 가드를 해주면 타입스크립트가 알아듣는다. typeof나 instanceof가 있고 객체 내부의 값을 비교하고 싶을땐 ~ in Object 를 쓸수도 있다.
  4. 타입스크립트의 흐름에 몸을 맡기고 타입스크립트가 타입추론을 할 수 있게끔 잘 이끌어주면서 코딩하면 머리가 덜 아프다.

    • 일단 any로 작성하고 -> 얘 왜 안돼 하는 것보다, 각 단계에서 타입지정이 되는 것을 확인하고 넘겨주면 도미노처럼 알아서 잘 넘어간다.
  5. 에러는 숨기는것보다 노출하는 것이 디버깅을 위해 바람직하다.

    • lint가 띄워주는 메시지는 사실 개발자를 위한거니까...
  6. 같은 기능을 하더라도 사람에게 읽히기 쉬운 코드를 짜보자.

    • 같은 최적화 정도에 같은 기능이라면 유지보수하기 쉬운 코드가 더 좋다. 내가 지금 짠 코드가 1달 뒤에 기억이 안난다는 점을 감안해볼때, 생면부지의 사람이 봐도 1분 안에 이해할 수 있는 코드를 짜려고 노력해야겠다.
  7. 같은 기능을 하더라도 디버깅에 공수가 덜 드는 코드를 짜보자.

    • 이건 약간 6번과 상충하는 듯 느껴질 수 있는데, 코드 자체는 여러 곳을 봐야되어서 눈이 조금 복잡하지만, 얘를 고치려고 할때는 로직이 한군데에 집중되어 있어서 결합부만 뜯으면 되는 코드를 얘기한다.

    • 만화에서 종종 실로 뭘 조종하는 빌런이 나오는데 본체나 본체에 연결된 메인 실 하나만 끊으면 공략할 수 있게 만드는 것과 비슷한 이치다.

    • 뷰단에서 이러기는 어렵고 주로 custom hook이나 api 관련 컴포넌트에서 의존성을 제거하고 인위적으로 교체가능한 옵션을 만들어서 <- 이 옵션 컴포넌트만 수정하면 되도록 하는 작업을 말한다.

2. React Query 적용해서 API 호출하기

  • 각자 React Query 공부해서 적용해보기 ✅
  • 이미 React Query를 사용하고 있다면 로직을 개선해보기 ✅
  • 과제 제출할 때 수업에서 배운 내용을 근거로 들어 개선사항 만들어보기 ✅
  • 공식 문서 & tkdodo의 블로그 글을 기반으로 UI / Server State 분리하는 이유에 대해 생각해보기 ✅
  • Cache, stale, stale-while-revalidate 개념을 공부하기 ✅
  • 적용 이전 대비 어떤 효용이 있는지 Before / After 작성해보기 ✅

1. useMutation의 isLoading을 쓰고 mutaion 내에서 분기함으로써 함수를 좀더 간단하게.

before

 function login() {
    if (isLoginPage && "email" in values) {
      UserAPI.loginTodo(values)
        .then((res) => {
          alert("로그인 성공!");
        })
        .catch((error) => {
          console.log(error);
          alert("아이디와 비밀번호를 확인해주세요");
        });
    }
    if (isSignInPage && "passwordConfirm" in values) {
      UserAPI.singUpTodo(values).then((res) => {
        alert("계정 생성 완료, 자동 로그인 되었습니다!");
      });
    }
    setLoading(true);
    setTimeout(() => {
      navigate("/todo");
    }, 300);
  }
  • 이런 느낌으로 유즈스테이트를 이용해서 로딩처리를 해주고 있었다.
  • 서버 실패시에 자동으로 꺼지게 하기 위해 settimeout까지 이용하는 등 복잡했다.

after

  const { mutateAsync, isLoading } = useMutation(
    (values: UserProps | NewUser) =>
      isLoginPage ? UserAPI.loginTodo(values) : UserAPI.singUpTodo(values),
    {
      onSuccess: () => navigate("/todo"),
      onError: (error: AxiosError) => {
        if (error !== undefined && error instanceof AxiosError) {
          console.log(Object.values(error?.response?.data)[0]);
        }
      },
    }
  );

  function login() {
    mutateAsync(values);
    isLoginPage
      ? alert("로그인 성공!")
      : alert("계정이 생성되었습니다, 자동으로 로그인합니다!");
  }
  • 뮤테이션을 구조분해할당해서 submit함수랑 isLoading state를 간소화하고 함수 자체도 간결해졌다.

  • 또한 onError를 써서 오류시 백엔드 서버에서 보내주는 메시지를 송출하게 했다.

  • 코드는 같지만 전역상태관리 없이 리액트 쿼리에서 받아오고 있어 훨씬 간단하다.

 {isLoading && <el.Spinner />}

2. 다른 뮤테이션들도 구조분해할당

before

  • 함수를 그냥 이렇게만 가져다가 쓰고 있었는데...
  const detailToDo = getToDoById();

after

const { data, isLoading, isError, error } = getToDos();
  • 제대로 쓰는 방법은 이거였다. 괜히 이름 하나씩 더 열심히 짓고 있었다.
  • 뮤테이션의 경우
  const { mutateAsync, isLoading, isError, error } = createTodo();
  • 공식문서를 잘 보자는 교훈을 얻었다.
  • 여기저기 펭귄 스피너를 달았더니 기분이 좋다.

3. 서스펜스 적용하기

  • 리스트는 특별히 isLoading대신 서스펜스를 적용해보았다.
import { QueryErrorResetBoundary } from "@tanstack/react-query";
const List = React.lazy(() => import("../components/toDo/List"));

 		<QueryErrorResetBoundary>
          <React.Suspense fallback={<el.Spinner />}>
            <div className="flex p-10">{data?.data && <List {...data} />}</div>
          </React.Suspense>
        </QueryErrorResetBoundary>
  • 이렇게하면 겟리스트를 다시 받아올때 다른 화면은 그대로 떠있고 리스트 부분만 로딩이 돌아간다.

4. 적용했을때 어떤 이점이 있었는가.

  • 일단 hook형태로 되어있어서 로직이 간단해졌고, 블링킹현상이 많이 줄어들었다.
  • 데이터 패칭을 마치 로컬에서 가져오듯이 편안하게 할수 있었다.
  • 복잡한 설정 없이도 캐싱을 할수 있어서 좋았다.
  • 무엇보다 loading 관련 state를 리덕스나 리코일 등으로 따로 관리해주지 않아도 되고 + 신경쓸 필요도 없어서 좋았다.

5. 공식 문서 & tkdodo의 블로그 글을 기반으로 UI / Server State 분리하는 이유에 대해 생각해보기

  • 공식문서 최적화가 date-fns페이지보다 훨씬 잘되어 있다는 느낌을 받았다. 검색할때 렉이 안걸린다.
  • tkdodo님의 블로그는 거의 교과서다.

UI / Server State 분리하는 이유

  • 리액트는 가상돔을 이용하는 SPA다. 데이터 변화에 따른 UI의 변경을 위해서는 데이터변경이 아닌 리액트가 지정한 state가 변경되어야 한다.

  • 그런데 리액트의 기본 state는 성능최적화를 위해 Batching을 해버리기 때문에 요청을 여러가지를 한번에 많이 할 경우 여러개 보내지거나 빼먹고 보내질 가능성이 있다는 점이다...!

  • 원래 해야되는거 + 서버데이터랑 동기화하기 위해 해야하는거 하면 state가 너무 많다...

  • 게다가 client State(내가 받아와서 굴리는 데이터)는 로컬이라서 터지지 않지만, Server State(서버에 요청해야만 하는 데이터)는 서버의 상태 = 외부의 상태에 따라서 클라이언트단에 아무 문제가 없더라도 터질 수가 있다.

  • 그래서 두개가 긴밀하게 결합되어 있으면 여러가지 분기처리를 해주어야 해서 코드가 복잡해지고 사이트에 화이트스크린이 자주 뜨는걸 볼수 있다.

  • 하지만 잘 분리 해놓으면 적어도 받아와서 쓰고 있는 데이터는 그대로 쓸수가 있어서 유저가 10000글자 정도 작성하던 블로그 글을 날리는 일이 줄어든다. 클라이언트단에서 로컬/세션스토리지에다가 임시저장 기능을 만들면 더 줄어든다.

  • 그리고 ui = view 컴포넌트가 잘 분리되어 있으면 새 프로젝트를 시작할때 고민거리가 줄어든다. 내 경우에는 404페이지, 헤더, 레이아웃 등 기본적인 애들은 거의 뜯어와서 시작하는 편이다.

6. Cache, stale, stale-while-revalidate

  • Cache : 캐시 = 리액트쿼리가 가지고 있는 자료구조이다. 여기에 서버 데이터를 담아둔다.

  • Caching : 캐싱, React-Query의 캐싱은 stale과 cachetime의 조합으로 이루어진다.

stale

  • 데이터의 신선도. 전달받은 데이터는 리엑트 쿼리의 자료구조 내용 중 캐시에 저장이 되는데, 이때 이 캐시데이터의 "신선한 상태" 가 언제까지 될지를 말해주는 옵션이다. default는 0으로, 받아오는 즉시 stale하다고 판단하며 캐싱 데이터와 무관하게 계속해서 feching을 진행한다.

  • 클라이언트 데이터는 서버데이터의 어느 한 시간을 스냅샷 찍어서 오는 데이터인데 신선함의 기준을 잡아서 오래되었다고 판단하는게 스테일이다.

cachetime

  • 캐시에 저장된 데이터는 메모리상에 존재하게 된다. 이 때, 메모리에 저장되어 있는 캐시 데이터가 언제까지 유지될지를 말해주는 옵션이다. 캐싱된 쿼리의 결과값은 지우지 않아도 시간이 지나면 메모리에서 사라진다.

쿼리 키

  • useQuery를 통해 호출할 당시 첫번째 인자로 전달한 값. [] 이렇게 배열안에 들어있는 값이다.
    이 값과 동일한 키를 가진 캐싱값이 존재할 경우, fetching을 진행하지 않고 이 캐싱값을 그대로 다시 사용한다.

staletime

  • 언제까지 신선할거라고 보장해주는 유통기간 같은 것, useQuery를 호출할 당시에 옵션으로 staletime을 따로 지정해주지 않으면 캐싱되어 있는 데이터가 계속 신선하지 않다고 여기기 때문에 서버에 계속적인 refetching 요청을 하게된다.

  • 결론 : 캐싱을 잘하려면 옵션을 잘 지정하고 쿼리키를 분리하기!

stale-while-revalidate

  • swr은 캐싱된 낡은 컨텐츠에 대한 확장된 지시를 표현한다.
    - 요청을 짧은시간 안에 반복적으로 여러번 보내면 캐싱된 애를 돌려주고
    - 정해진 시간 내에 보내면 우선은 캐싱된 애를 돌려준 후 서버에 검토요청을 보내 새로운 데이터를 받아와야할지 평가하고
    - 그리고 정해진 시간은 넘겨서 보내면 평범하게 서버에 요청을 해준다.

  • react-query, swr은 낡은 캐시로부터 빠르게 컨텐츠를 반환하고, 백그라운드에서 요청을 통해 캐싱된 컨텐츠의 재검증을 진행하여 캐싱 레이어에서 최신화된 데이터를 보장할 수 있도록 swr 캐싱 전략을 취하고 있다.

  • 프론트엔드 개발자가 중복요청이나 잘못된 요청, 빈번한 요청 등으로 발생하는 오류와 씨름하는 것을 줄여주는 좋은 애들이다.

profile
It's an adventure time!

0개의 댓글

관련 채용 정보