xction-dev / xction.co.kr

Xction!의 홈페이지를 만들고 있습니다
0 stars 0 forks source link

Architecture - Logic #16

Open designDefined opened 8 months ago

designDefined commented 8 months ago

프론트엔드 작업자를 위한 아키텍쳐 원칙 정리 (로직)

요약

규모가 있는 프로젝트의 클라이언트 작업을 하게 되면, 로직과 UI가 제대로 분리되지 않았을 때 많은 문제가 발생합니다. 그렇다고 각 개발자들이 임의의 방식으로 이를 분리하면 코드베이스의 가독성이 현저히 떨어지죠. 따라서 xction.co.kr은 클린 아키텍쳐 원칙에 의거하여 코드 간의 깔끔한 분리를 지향하며 개발을 진행하고자 합니다. 아래 내용은 각 페이지, 컴포넌트들의 로직을 담당하는 코드를 어떻게 작업할 지에 대한 제안입니다. 아래 글을 가볍게 읽어보시고, 궁금한 부분을 코멘트로 달아주세요! 이해를 위해 아래와 같은 자료를 추가로 참고하셔도 좋습니다.

타입들

아키텍쳐를 설명하기 위해서는 각 용어가 의미하는 바가 무엇인지 명확히 정리해야 합니다. 클린 아키텍쳐에는 몇 가지 핵심 타입들이 사용됩니다. 각 타입은 아키텍쳐의 서로 다른 계층을 나타냅니다. 어디에 어떤 타입을 사용해야하는 지만 이해한다면 아무리 복잡한 아키텍쳐여도 그 의미와 작업 방식을 유추할 수 있을 겁니다. 각 타입들의 의미를 요약하자면 다음과 같습니다.

Entity

엔티티는 가장 핵심적인 업무 개념을 표현하는 타입입니다. 저희가 구축할 서비스를 컴퓨터 프로그램이 아니라 현실 세계에서 구현한다 할 지라도 (비디오 대여방 등), 문서 등으로 정리할 필요가 있는 User(고객), Project(개별 비디오), Comment 등이 이에 해당합니다. 한 번 엔티티가 만들어지고 나면 이를 수정할 일은 거의 없을 것입니다(새로운 피쳐를 추가할 때 확장될 수는 있습니다.) 엔티티는 ~~Entity 접미사를 붙여 표현합니다.

아래는 샘플로 만든 유저 엔티티의 예시입니다.

export type SampleUserEntity = {
  id: number;
  name: string;
  email: string;
  password: string;
};

Usecase

유즈케이스는 개발이 진행되는 가장 기본적인 단위입니다. 유즈케이스를 나누는 기준은 사용자가 우리의 애플리케이션을 사용하는 목적입니다. 예를 들어 계정 정보를 변경하려는 사용자의 유즈케이스는 ManageAcountService, 영상을 감상하려는 사용자의 유즈케이스 WatchProjectService로 표현할 수 있겠습니다. 유즈케이스는 해당 목적을 달성하려는 사용자에게 필요한 모든 정보와 기능을 포함합니다. 로그인을 하려는 사용자의 경우 로그인을 시도하는 기능과, 로그인의 결과로 주어지는 유저 정보가 필요할 것입니다. 이는 적절한 프로퍼티와 메소드로 유즈케이스 객체에 포함되게 됩니다. 유즈케이스에는 ~~Service라는 접미사를 붙입니다.

아래는 로그인하려는 사용자를 가정하고 만든 샘플 로그인 유즈케이스의 예시입니다.

export type SampleUserService<UserData, LoginRequestBody> = {
  tryLogin: (body: LoginRequestBody) => void;
} & (
  | { status: "fetching"; me: UserData | null; error: unknown | null }
  | { status: "fail"; error: unknown; me: null }
  | { status: "success"; me: UserData; error: null }
);

이 코드에서 이용되는 UserData, LoginRequestBody라는 타입은 무엇일까요? 두 타입은 제네릭 타입으로, SampleUserService라는 유즈케이스를 실제로 사용할 때, 마치 함수에 인자를 넣듯 주입시켜 사용할 수 있습니다. 유즈케이스의 일부 타입들(주로 dto)은 개발을 진행하면서 쉽게 변경될 수 있습니다. 이 때마다 유즈케이스를 변경하는 것은 너무 코드에 영향을 미칠 수 있으므로

DTO

dto는 Data Transfer Object의 줄임말로, 서버와 클라이언트 사이에서 데이터를 주고 받을 때 사용하는 객체 타입입니다. 주로 Body를 통해 주고 받는 값을 의미합니다. dto는 기본적으로 유즈케이스와 엔티티에 의존하며, 서버에서 api를 만들 때 정의하여 클라이언트와 공유하게 됩니다. dto를 잘 공유하면 api 문서에 크게 의존하지 않아도 의도에 맞는 api 요청을 보낼 수 있습니다. dto는 ~~Dto라는 접미사가 붙습니다.

아래는 샘플 로그인과 유저 정보 가져오기에 사용되는 dto의 예시입니다.

/**
 * SampleLogin 엔드포인트에 POST를 요청할 때 필요한 Dto입니다.
 */
export type PostSampleLoginRequestDto = {
  email: string;
  password: string;
};

/**
 * SampleMe 엔드포인트에 GET 요청을 하면 응답하는 Dto입니다.
 */
export type GetSampleMeResponseDto = {
  name: string;
};

작업 방식

위에서 추상적인 개념에 해당하는 타입들을 설명했다면, 실제로 어떻게 작업을 하게 되는지 하나씩 살펴봅시다.

  1. 엔티티 & 유즈케이스 타입 정의
  2. 서버 API 및 Dto 생성
  3. 클라이언트 유즈케이스 구현
  4. 컴포넌트 작업

엔티티 & 유즈케이스 타입 정의

이 작업은 기획자의 제안을 받아 회의에서 수행됩니다. 필요 시에는 프로젝트의 리드가 주도적으로 작업하기도 합니다. 정의된 엔티티와 유즈케이스는 서버와 클라이언트 개발자 모두에게 잘 이해되어야 합니다. 특히 유즈케이스를 생성했을 때에는 혹시 이해되지 않은 부분이 있다면 바로 질문을 합시다.

유즈케이스 타입을 정의할 때부터, 각 유즈케이스 별로 feature 브랜치를 생성합니다.

서버 API 및 Dto 생성

유즈케이스를 확인한 서버 개발자는 필요한 기능을 구현하면서 요청을 주고 받는 엔드포인트와 dto의 타입을 정의합니다. 여기에서 자세히 설명하지는 않겠지만, 서버 작업자는 주로 유즈케이스를 클래스나 객체, 함수 등으로 구현하게 됩니다.

서버 작업이 어려운 상황이면 dto만 미리 만들어 둘 수도 있습니다. 이 경우 프론트엔드 개발자는 dto에 맞는 서버 응답이 올 것이라 예상하고 미리 로직 작업을 해 둘 수 있습니다.

클라이언트 유즈케이스 구현

엔티티와 유즈케이스, dto가 정해지면 클라이언트 작업을 본격적으로 시작할 수 있습니다. 제일 기본이 되는 작업은 유즈케이스를 프론트엔드에서 사용할 react hook으로 만드는 것입니다.

type InjectedUsecase = SampleUserService<SampleUserDto, PostSampleLoginDto>;

export const useSampleUserService = (): InjectedUsecase => {
  // query를 관리하는 객체
  const queryClient = useQueryClient();

  // useQuery를 이용한 내 정보 가져오기
  const { data, error, status } = useQuery({
    queryKey: ["user", "me"], // 에러는 react-query 버전 문제인 것 같습니다. 추후에 해결할 예정
    queryFn: getSampleMe, // /api/SapmleUserService 에서 정의한 함수를 가져옵니다
    staleTime: 1000 * 60, // 1분에 한 번씩 로그인 확인
    gcTime: 1000 * 60 * 60, // 페이지 전환해도 1시간동안 로그인 유지
    retry: 0, // 실패시 바로 에러 처리
  });

  // useMutation을 이용한 로그인 시도
  const { mutate } = useMutation({
    mutationFn: postSampleLogin,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["user", "me"] }); // 로그인 성공 시 ["user", "me"] 쿼리를 새로고침해줍니다
    },
  });

  // useQuery의 결과값으로부터 status & me & error 값을 파싱합니다.
  // useMemo를 써 parsedFetchResult의 값에 영향을 주지 않는 리렌더링 시에는 캐싱된 값을 사용합니다.
  const parsedFetchResult = useMemo(() => {
    switch (status) {
      case "pending":
        return {
          status: "fetching", // usecase에서 요구한 대로 status를 바꿔주어야 합니다. 바꾼 결과는 타입
          me: data ?? null,
          error: error ?? null,
        } as const; // 이렇게 하면 타입스크립트에게 status가 string union 타입임을 알려줄 수 있습니다.
      case "error":
        return { status: "fail", me: null, error } as const;
      case "success":
        return { status: "success", me: data, error: null } as const;
    }
  }, [status, data, error]);

  return { ...parsedFetchResult, tryLogin: mutate }; // 유즈케이스의 타입과 동일한 객체를 반환합니다.
};

유즈케이스를 구현할 때는 다음과 같은 방식으로 접근하면 좋습니다.

  1. 먼저 만들어진 유즈케이스를 확인한다.
  2. 해당 유즈케이스에 어떤 타입을 주입해야 하는지 확인한다. 주로 dto가 주입될 것이다.
  3. 이렇게 구체화된 유즈케이스를 리턴하는 리액트 훅의 틀을 만든다.
  4. 유즈케이스에 정의된 속성과 메소드에 맞는 데이터와 함수를 만든다. 이 때 dto를 확인하고 서버와 요청을 주고받게 됩니다.

4번에 해당하는 코드는 여러분이 원하는 방식대로 작성할 수 있습니다. fetch로 단순히 api를 주고받을 수도 있고, 위의 예시와 같이 useQuery, useMutation과 같은 react-query 라이브러리를 사용할 수도 있죠.

컴포넌트 작업

유즈케이스를 커스텀 훅으로 구현하고 나면, 로직 작업은 끝이 납니다. 이제 각 리액트 컴포넌트에서 필요한 유즈케이스 훅을 모두 가져오고, 여기에서 반환된 값과 함수를 이용하여 컴포넌트의 로직을 구현하면 됩니다. 추천하는 방식은 module 또는 page 층위에서 훅을 모두 가져오고, component(공용으로 사용하는 ui 컴포넌트)에 prop으로 필요한 값을 전달하는 겁니다.

주의사항

유즈케이스는 작업과 명명의 기준이다

웹 개발 작업은 결국 사용자의 목적을 충족시킬 수 있는 피쳐를 하나씩 추가하는 과정이라 볼 수 있습니다. 따라서 작업의 최소 단위는 항상 유즈케이스가 되어야 합니다(엔티티가 아님). 따라서 새로운 유즈케이스가 추가될 때 브랜치가 생성되고, dto나 유즈케이스 구현체(리액트 훅), 서버에서 이용하는 db 접근 코드 등은 모두 유즈케이스를 기준으로 분류됩니다. 따라서 디렉토리를 만들 때 고민이 된다면, 유즈케이스로 디렉토리의 이름을 붙이는 방법을 고려해보세요. 유즈케이스를 명명 기준으로 삼지 않는 것은 그보다 고수준에 위치한 엔티티와, UI와 관련된 파일들 뿐입니다. UI의 분류 기준은 페이지인데, 이는 하나의 페이지가 여러 개의 유즈케이스를 동시에 담당하는 경우가 많기 때문입니다.

의존성 흐름을 엄격하게 관리하기

타입스크립트의 의존 관계는 특정 파일에서 외부의 어떤 코드를 import하여 사용하는 지를 보고 판단할 수 있습니다. 엔티티와 같은 고수준(핵심적이고, 잘 변경되지 않음)의 타입을 정의함에 있어서 저수준(작업자 개개인이 쉽게 변경할 수 있음)의 코드를 import하게 되면, 간단한 기능 하나를 수정할 때도 전체 코드를 도미노처럼 바꿔주어야 하는 일이 발생할 수 있습니다. 따라서 의존성의 흐름은 엄격하게 제어되어야 합니다. 의존성 규칙은 다음과 같습니다.

  1. 엔티티는 다른 엔티티를 제외한 무엇에도 의존하지 않습니다.
  2. 유즈케이스 타입은 엔티티에만 의존합니다. 나중에 작업하게 될 dto가 유즈케이스를 정의하는 데 필요하다면, 유즈케이스에서는 일단 제네릭을 통해 추상적으로 명시만 해놓고 실제 구현 시에 dto를 주입하여 사용합시다.
  3. dto는 유즈케이스와 엔티티에 의존합니다. 혹시 엔티티나 유즈케이스가 변경되면 dto에도 변경 사항이 바로 반영되어야 합니다. dto는 특정 라이브러리나 db에 의존하지 않도록 합니다.
  4. 마지막으로, 유즈케이스를 구현한 hook은 위의 유즈케이스와 dto에 의존하게 됩니다. 또한 다른 라이브러리의 코드에도 의존할 수 있습니다. hook은 가장 저수준에 위치하는 UI 컴포넌트와 직결되는 부분이기 때문입니다.

아래는 의존성 흐름을 명확하게 보여주는 다이어그램입니다.

Screenshot 2023-11-02 at 2 36 48 PM

아래로 뻗어나가는 보라색 박스는 프론트엔드 작업, 위로 뻗어나가는 노란색 박스들은 백엔드 작업의 영역입니다, 사이의 파란색과 녹색은 공통의 영역입니다. 의존성의 흐름과 파일들의 명명 규칙에 주목하세요!