클린 아키텍쳐 이슈 트래커 (1) 아키텍쳐

2024. 3. 14. 22:26카테고리 없음

어떤 관심사들이 있을까?

프로그램에는 다음과 같은 관심사들이 보통 존재한다.

 

1. 뷰를 그리기 위한 렌더링 로직

2. 비즈니스 로직

3. 네트워크 요청

 

이런 관심사에 따라 크게 세가지 레이어로 나누었다.

 

1. Domain layer: Entity, Repository Interface, Use Case

2. Data layer: Repository Impl, Data Source

3. Presentation layer: 리액트 코드

 

Domain layer

도메인 레이어에는 엔티티가 존재했다. 추구했던 방향은 Class로 entity를 나타내고 해당 entity에서 수행해야 하는 비즈니스 로직을 메서드로 표현하고 싶었다.

 

하지만 2가지 문제점이 있었다.

 

1. 앱 자체가 어찌보면 단순한 CRUD 앱이었기 때문에 이렇다할 도메인 비즈니스 로직이 없었다. 

2. 엔티티에 멤버 변수가 필요한가? 라는 의문이 들었다. 엔티티 클래스 멤버 변수에 상태 값을 저장하고 해당 값을 리액트에 전달해주기 위해서는 어차피 setState를 통해 클래스의 멤버 변수 값을 읽어와 상태에 다시 저장해주어야 했다. 이런 식으로 접근하면 하나의 상태 값이 클래스와 리액트 두 군데 저장되게 된다. 

 

따라서 도메인 레이어에서 엔티티를 다음과 같이 인터페이스로만 선언하게 되었다.

 

export interface Issue {
  id: IssueId;
  title: IssueTitle;
  contents: IssueContents;
  isOpen: IssueIsOpen;
  createdAt: IssueCreatedAt;
}

 

 

여기서 또 한가지 고민했던 부분은,  Issue는 User에 대한 id를 참조한다. 하지만 인터페이스에 최종적으로 포함시키지는 않았다. 그 이유는 엔티티는 가장 순수하게, 어디에도 종속성을 가지지 않길 원했다. User id를 참조하게 되면 Issue 파일에서 User 엔티티를 import 해와야 하기 때문에 이런 관계들은 엔티티 속에 두지 않았다.

 


 

도메인 레이어에는 Repository의 인터페이스도 존재했다. Repository의 구현 부분은 data 레이어지만 인터페이스를 도메인 레이어에 둔 이유는 1) useCase에서 repository 메서드에 접근해야 한다. 2) 하지만 클린 아키텍처에선 의존성 방향이 오직 내부만을 향해야 한다는 규칙이 있었다. 따라서 이 규칙을 지키기 위해 Repository의 인터페이스를 도메인 레이어에 두고 useCase는 Repository의 구현부가 아닌 인터페이스에 의존하게 하여 이 규칙을 지켰다. 

 

 

export interface IssueRepository {
  getIssue(getIssuePayload: GetIssuePayload): Promise<IssueResponse>;
  getIssues(issuesFilterPayload: IssuesFilterPayload): Promise<IssuesResponse>;
  openIssues(openIssuesPayload: OpenIssuesPayload): Promise<void>;
  closeIssues(closeIssuesPayload: CloseIssuesPayload): Promise<void>;
  createIssue(createIssuePayload: CreateIssuePayload): Promise<void>;
  deleteIssue(deleteIssuePayload: DeleteIssuePayload): Promise<void>;
  editIssue(editIssuePayload: EditIssuePayload): Promise<void>;
}

 


 

마지막으로 도메인 레이어에는 useCase도 존재했다. useCase는 repository, service 등의 구체적인 로직을 담고있는 클래스들에 대한 파사드로서 활용하려 했지만.. 이 역시도 단순 CRUD이다 보니 현재는 거의 repository에 대한 wrapper로서만 보이게 된다. 

 

import { inject, injectable } from 'inversify';
import type { IssueRepository } from '../../repository/issue-repository';
import { TYPES } from '../../../di/types';
import { IssueResponse } from '../../model/issue/response';
import { GetIssuePayload } from '../../model/issue/payload';

export interface GetIssueUseCase {
  invoke: (getIssuePayload: GetIssuePayload) => Promise<IssueResponse>;
}

@injectable()
export class GetIssue implements GetIssueUseCase {
  private _issueRepo: IssueRepository;

  constructor(@inject(TYPES.IssueRepository) issueRepo: IssueRepository) {
    this._issueRepo = issueRepo;
  }

  async invoke(getIssuePayload: GetIssuePayload) {
    return this._issueRepo.getIssue(getIssuePayload);
  }
}

 

불필요한 레이어라고 생각될 수도 있지만 좀 더 큰 앱일 경우 다음과 같이 복잡한 비즈니스 로직에 대해 단순화를 시켜줄 수 있는 레이어가 될 수 있을거라고 생각한다.

 

export class someUseCase implements SomeUseCase {
  private _payService:BService;
  private _aEntity: AEntity;
  private _aRepo: ARepository;
  
  // 생성자 생략..

  async invoke(getIssuePayload: GetIssuePayload) {
    await this._payService.tryPay(..);
    this._aEntity.doSomething(..);    
    this._aRepo.save(...)
  }
}

 

 

가장 마음에 안드는 부분이 Domain Layer였던듯 싶다. 그리고 도메인 로직의 경우 굳이 클래스로 작성해야 할 필요가 있을까? 라는 의문이 들었다. 외부 라이브러리를 사용하지 않는 한 (또는 자체 제작한 상태관리 라이브러리로 리액트 외부에 상태를 두지 않는한) 상태는 리액트 내부에 있다. 그 말은 로직을 가지고 있는 클래스에 멤버 변수가 필요하지 않아진다는 것을 의미하고, 클래스에 멤버 변수가 존재하지 않으면 굳이 클래스로 작성하기 보단 도메인 로직은 순수 함수로서 작성해야 하지 않나라는 생각이 들었다.

 

사실 redux나 mobX를 쓰면 아키텍쳐가 좀 더 깔끔해지지 않았을까 하는 생각이 든다. 내가 하는 고민들도 많이 해소해줄 수 있는 툴이었다고 생각한다. mobX는 서버 상태에 대한 이렇다할 솔루션이 없는것 같아 보여서, Redux + RTK Query를 useCase 레이어에 사용했으면 어땠을까 하는 생각이 든다. 아니면 내 앱은 서버 상태는 react query, 클라 상태는 리액트 built-in 훅(useState)을 사용하여 관리하므로 굳이 라이브러리를 사용하지 않아도 되는 상황이었기에 커스텀 훅을 useCase 레이어로 사용하는 것도 괜찮았다고 생각한다. 도메인 레이어에 리액트 관련 코드를 넣고 싶지 않았고 실제 프로덕션이 아닌 학습용 프로젝트였기에 지금은 useCase가 저렇게 따로 분리되어있지만 아마 계속 유지 보수를 해야 하는 프로젝트였다면 리액트에 종속적인 커스텀 훅을 useCase에 뒀을 것이다.

 

Data layer

 

데이터 레이어는 외부 API와의 통신을 담당하는 레이어다. Repository구현부와 Datasource가 존재한다. 굳이 Datasource를 추가적으로 둔 이유는 Repository의 구현부에서 외부에서 가져온 데이터를 매핑하는 역할을 맡았기 때문이다. Datasource에서도 쿼리를 생성하는 등의 private 메서드를이 있었기 때문에, 매핑과 쿼리 관련 로직을 한 클래스에서 모두 책임지면 안된다고 생각했다.

 

Datasource 에서는 다음과 같이 구현되어있다. 

@injectable()
export default class IssueDataSourceImpl implements IssueDataSource {
  async getIssue(getIssuePayload: GetIssuePayload): Promise<IssueEntity> {
    const { issueId } = getIssuePayload;
    const dataQuery = this.buildGetIssueQuery(issueId);
    const { data, error } = await dataQuery;

    if (error) throw new Error(error.message);

    return {
      data,
    } 
  }
  
   // 다른 메서드들.. 
  
  private buildGetIssueQuery(id: Issue['id']) {
    const query = supabase
      .from('issues')
      .select(
        'id, title, contents, is_open, created_at, comments(id,contents,created_at, author:author_id(*)), labels(id,title, text_color, background_color), milestones(id,title), author:author_id(*), assignee:assignee_id(*)'
      )
      .eq('id', id)
      .maybeSingle();

    return query;
  }
}

 

 

 


 

Repository는 다음과 같이 구현되었다.

 

@injectable()
export class LabelRepositoryImpl implements LabelRepository {
  private _datasource: LabelDataSource;

  constructor(@inject(TYPES.LabelDataSource) dataSource: LabelDataSource) {
    this._datasource = dataSource;
  }

  async getLabels(): Promise<LabelsResponse> {
    const data = await this._datasource.getLabels();

    return this.mapEntityToModel(data);
  }

  private mapEntityToModel(entity: LabelAPIEntity): LabelsResponse {
    const { data } = entity;

    return {
      data: data.map(
        ({
          id,
          title,
          description,
          text_color,
          background_color,
          created_at,
        }) => {
          return {
            id: id as Label['id'],
            title: title as Label['title'],
            description: description as Label['description'],
            textColor: text_color as Label['textColor'],
            backgroundColor: background_color as Label['backgroundColor'],
            createdAt: created_at as Label['createdAt'],
          };
        }
      ),
    };
  }
}

 

as를 통한 타입 assertion을 사용한 이유는 브랜딩 타입을 사용했기 때문인데, 이는 추후 타입스크립트 글에서 좀 더 자세히 다뤄볼 예정이다.

 

Repository에서는 내가 정한 entity모델에 맞게 외부에서 받아온 데이터를 맵핑한다. 지금은 나 혼자 프론트엔드 코드도 작성하고, 디비도 직접 설계했기 때문에 단순히 스네이크 케이스를 카멜 케이스로 분리하는데 그친다.

 

하지만 만약 백엔드 개발자와 협업한다고 했을 때, 레이어가 분리돼있음으로 인해 다음과 같은 장점이 있다.

1) View가 API 구조에서 자유로워진다. 프론트엔드 개발을 진행하면서 API 구조에 신경쓸 필요가 적어지고, 병렬 개발이 빠르게 진행 될 수 있다. 

2) 만약 API 구조가 바뀌더라도 Presetation에 있는 코드와 (리액트 코드), Domain에 있는 코드 (비지니스 로직)는 변경될 필요가 없다.

3) 그럴 일은 적겠지만 언더페칭이 일어나는 경우 Repository에서 여러 Datasource를 주입받아 원하는 형태로 데이터를 받을 수 있다.

4) 반대로 오버페칭이 일어나는 경우 Presentation에서 필요하지 않은 데이터를 생략할 수 있다. 물론 현재는 REST API이므로 네트워크 벤더 자체를 줄이진 못한다. 좀 찾아본 바로는 graphQL을 사용하면 벤더 자체도 줄일 수 있는 듯 하다. 공부해보지 않아 이 내용은 정확하진 않다.

 

 

사실 내가 가장 해결하고 싶었던, 뷰가 DB 또는 API 구조에 종속되는 문제를 해결해준 부분이긴 하다. 다만 근거는 없지만 가장 세련된 방법은 아니라고 느꼈다. 좀 더 우아한 방법이 없을까? 도메인 엔티티 다음으로 마음에 안드는 부분이다. (혹시 글을 읽고 계신분이 있다면, 더 좋은 아이디어를 댓글로 남겨주시면 감사하겠습니다!)

 

 

 

Presentation layer

 

Presentation layer는 전형적인 리액트 코드다. 컴포넌트를 어떻게 설계했는 지는 다른 글에서 좀 더 자세히 풀어보려고 한다. 여기서는 리액트에서 useCase에 접근하는 방식에 대해서만 얘기해보려고 한다.

 

각 엔드포인트에 따라 커스텀 훅으로 분리하여 관리했는데 다음과 같은 모습이다. 

export default function useIssue(getIssuePayload: GetIssuePayload) {
  const getIssueUseCase = container.get<GetIssueUseCase>(TYPES.GetIssueUseCase);

  const {
    isLoading,
    data: { data: issue } = {},
    error,
  } = useQuery({
    queryKey: ['issues', getIssuePayload.issueId],
    queryFn: () => getIssueUseCase.invoke(getIssuePayload),
  });

  return {
    isLoading,
    issue,
    error,
  };
}

 

 

 

여기서 container는 inversify를 사용했다. 사실 현재 프로젝트 규모는 DI를 직접 손으로 다음과 같이 해도 충분히 핸들링 할 수 있는 수준이다. 

 

export default function useIssue(getIssuePayload: GetIssuePayload) {
  const issueDataSource = new IssueDataSourceImpl();
  const issueRepoistory = new IssueRepositoryImpl(issueDataSource);
  const getIssueUseCase = new GetIssue(issueRepoistory);
  
  // ...
  
 }

 

그럼에도 불구하고 IoC 컨테이너를 사용한 이유는 다음과 같다.

 

1) 매번 useCase를 사용해야 할 때마다 작성해야 할 코드가 많다.

2) 만약 저런 일련의 DI가 여러 곳에 동일하게 작성되어있다면 다른 구현체를 inject 해줘야 할 때 모든 파일을 찾아 일일히 수정해주어야 한다. 하나의 파일을 수정하는 이유는 반드시 단 하나여야 한다는 원칙과 마찬가지로 하나의 수정사항에 대해 최소한의 (이상적으론 하나) 파일을 수정하고 싶었다. 만약 A Datasource (supabase)대신 B Datasource (mongo DB)를 주입해야 할때 A Datasource에서 이전 데이터를 기반으로 응답은 잘 준다면, 프로그래밍 적으로 오류가 발생하진 않지만 어떤 컴포넌트에서는 supabase에 있는 데이터를 받아 화면에 보여줄 것이다. 지금 프로젝트 규모에서는 그럴 가능성이 낮다. 하지만 다른 프론트엔드 개발자와 협업중이라면? 프로젝트 규모가 훨씬 커진다면? 분명 이러한 오류가 발생할 것이라 생각했다.

 

awilx, typeDI, inversify 등의 IoC 라이브러리가 후보군에 있었다.

typeDI같은 경우 마지막 release가 21년이기도 했고, 이슈 탭에 프로젝트가 더 이상 관리되지 않냐는 질문도 있고.. 아직도 major 버전이 1이 아니어서 제외됐다.

awailix는 활발히 유지보수 중이기도 하고, inversify에 비해 번들 사이즈도 작았다. (bundle phobia 기준) 다만 처음 IoC 개념을 접한 나로서 개인 개발자가 만든 라이브러리다 보니 공식문서가 너무 적고 레퍼런스도 적어서 사용하지 않았다.

inversify는 유지보수도 활발히 되는 듯 했고 공식문서가 정말정말 잘 돼있어서 최종적으로 inversify를 택했다.

 

 

하지만 다시 프로젝트를 진행한다면 inversify가 아닌 awilix를 사용할 듯하다. 그 이유는 decorator 때문인데, decorator는 현재 표준스펙이 아니다. 바벨 설정에서 이 때문에 꽤나 애먹기도 했다. decorator가 추후엔 표준 스펙이 될 수도 있어보이는데 ( ECMA TC39의 표준화 절차), 표준이 됐을때 inversify가 사용 가능할까? 라는 불안함이 있다. 위에서 언급한 mobX문서에서도 비슷한 이유로 decorator 사용은 지양하고 있는 듯하다.

 

아무튼 다음에 IoC가 필요하다면 (프론트엔드에서 필요할진 모르겠지만..) decorator 문제가 해결되지 않는 한, awilix를 사용할 듯 싶다.

 

 

결론

클린 아키텍쳐를 시도해보면서 많은 점을 배웠다. 어떻게 백엔드와의 종속성을 끊어낼 것인지에 대한 아이디어, 왜 layered 아키텍쳐가 필요한지 (실무에서도 종종 레이어를 분리하는 듯 하다.), 어떤 커다란 관심사들이 있는지, (깊게 이해하진 못했지만) 객체지향의 SOLID 원칙 , 인터페이스를 사용하는 이유 등 다소 생소했던 개념들을 많이 접했다.

 

사실 혹시 이 글을 읽는 사람이 있다면 확실히 말하고 싶지만, 성공한 아키텍쳐는 아니다. 프로젝트 규모에 비해 굉장히 아키텍쳐가 오히려 과했고, 아직 해결하지 못한 고민들도 많다. 하지만 개인적으로 이런 아키텍쳐를 시도하면서 객체지향에 대한 발담굼을 할 수 있었다. 또한, 오히려 custom hook이 왜 필요한지 더 잘 느낄 수 있던 기회였다.