Clean Architecture 적용기

현재 기능이 Post 도메인 하나밖에 없지만 해당 도메인에 기반으로 클린 아키텍처를 적용해보았다.

클린 아키텍처(Clean Architecture)란?

Clean Architecture

클린 아키텍처는 수많은 컴퓨터 과학자가 생각한 아키텍처 규칙들을 종합해서 만든 다이어그램이다. 관심사 분리가 핵심이며 관심사에 따라 소프트웨어 계층을 분리하는 것이다. 보통 다이어그램의 안쪽으로 갈 수록 고수준의 소프트웨어가 되며 의존성은 반드시 안쪽으로 향해야 한다. 내부의 속한 원은 바깥원의 그 어떤 것도 알 수 없어야 한다.

Clean Architecture를 적용하게 된 계기

함수의 의존관계가 명확하지 않아서 수정이 필요할 때마다 libraries/post.ts 파일을 정독하다시피 했다. 이 상황이 계속 반복되다보니 아키텍처 설계를 다시 해봐야겠다는 생각이 들어 clean architecture를 적용하게 되었다.

설계할 때 가장 먼저 할 일

Clean Architecture를 설계할 때 가장 먼저 해야할 일은 도메인의 엔티티 모델을 추상화하는 것이라고 생각한다.

/domain/entities/Post.ts
1export interface IPostEntity {
2  id: string;
3  metadata: PostMetadata;
4  content: Content;
5}

post별로 id를 갖고 metadata타입과 content타입을 갖는 키를 만들었다.

  • metadata는 title, description, category와 같은 post를 설명하는 데이터다.
  • content는 내용인데 타입을 따로 만든 이유는 md와 mdx에 따라 UI에 표현하는 방식이 다르기 때문이다.

기존 소스코드 - [Legacy] Post.getFileInfo

[Legacy] Post.getFileInfo
1
2const getFileInfo = (id: string, category: string[]): FileInfo => {
3  const fullMdPath = path.join(postsDirectory, ...category, `${id}.md`);
4  const mdExist = fs.existsSync(fullMdPath);
5
6  const existFilePath = mdExist
7    ? fullMdPath
8    : path.join(postsDirectory, ...category, `${id}.mdx`);
9  const fileContents = fs.readFileSync(existFilePath, "utf8");
10
11  const matterResult = matter(fileContents);
12
13  return {
14    isMdFile: mdExist,
15    matterResult,
16  };
17};
18

Post.getFileInfo의 책임

  • 6~8 - md파일인지 mdx파일인지 확인
  • 11 - front matter를 object로 변환

문제점

  • SRP를 위반함으로써 1번의 책임만 필요할 때, 2번의 책임만 필요할 때에도 이 함수를 호출하게 된다.
  • 또한 유지보수할 떄 이 함수에 의존하는 함수들에 어떤 영향을 미칠지 모른다.

내가 해결한 방법

  • infrastructure 안에 FileHandler를 만들어서 파일의 확장명을 알려주는 함수를 만든다.
  • front matter를 object로 변환하는 건 파일을 분석해 메타데이터를 꺼내주는 것이므로 이것도 FileHandler안에 만든다.
/adapters/infrastructures/FileHandler.ts
1export class FileHandler {
2  getFileInfo(rootPath: string): IFileInfo {
3    const fileInfo: IFileInfo = {
4      extension: "",
5      rootPath: "",
6    };
7    const pathArray = rootPath.split("/");
8    fileInfo.extension = pathArray[pathArray.length - 1].split(".")[1];
9    fileInfo.rootPath = rootPath;
10    return fileInfo;
11  }
12
13  convertFrontMatterToObject(data: string): matter.GrayMatterFile<string> {
14    const matterResult = matter(data);
15    return {
16      ...matterResult,
17    };
18  }
19}
  • 2~11 - getFileInfo 함수가 확장명과 파일의 절대경로를 알려주는 책임으로서 파일 정보를 알려주는 책임을 맞는다.
  • 13~18 - convertFrontMatterToObject 함수가 front matter를 object로 변환해주는 책임을 맞는다. 이렇게 하면 위의 함수의 두 가지 책임을 분리할 수 있다.

기존 소스코드 - [Legacy] Post.getPostData

[Legacy] Post.getPostData
1export const getPostData = async (
2  id: string,
3  category: string[]
4): Promise<PostData> => {
5  const { matterResult } = getFileInfo(id, category);
6
7  const processedContent = await unified()
8    .use(remarkParse)
9    .use(remarkGfm)
10    .use(remarkRehype)
11    .use(rehypePrettyCode, {
12      grid: true,
13      defaultLang: "js",
14      theme: "dark-plus",
15    })
16    .use(rehypeStringify)
17    .process(matterResult.content);
18
19  const contentHtml = processedContent.toString();
20  return {
21    id,
22    contentHtml,
23    ...matterResult.data,
24  };
25};

Post.getPostData의 책임

  • 5 - md, mdx파일의 front-matter를 object로 변환 후 metadata 반환
  • 7~19 - unified, remark, rehype을 통해 md을 html로 변환 후 반환

문제점

  • metadata만 필요할 때, html 소스만 필요할 때 이 함수를 호출해야함.
  • 현재 파일을 읽어오는 일이기 때문에 id, category로 파일을 불러오기 위해 fileinfo를 호출해야함.

내가 해결한 방법

  • front-matter로 변환하는 건 파일을 변환하는 행위이기에 FileHandler에서 한다.
  • html 소스와 metadata는 post 도메인 데이터의 고유 값을 상징한다 생각해서 repository 패턴을 사용해 postRepository에서 post에서 만들어준다
  • html과 metadata를 모두 가져오는 useCase를 만든다.
/adapters/repositories/Post.ts
1export class PostRepository implements IPostRepository {
2
3  constructor(
4    private readonly directory: string,
5    readonly fileHandler: IFileHandler
6  ) {}
7
8  async getPostMetadata(rootPath: string): Promise<PostMetadata> {
9    const postData = await this.fileHandler.readFile(rootPath, {
10      encoding: "utf-8",
11    });
12    const matterResult = matter(postData);
13    const { data: postMetadata } = matterResult;
14    return {
15      ...(postMetadata as PostMetadata),
16    };
17  }
18
19  async getPostContent(rootPath: string): Promise<Content> {
20    const fileData = await this.fileHandler.readFile(rootPath, {
21      encoding: "utf-8",
22    });
23    const { content } = this.fileHandler.convertFrontMatterToObject(
24      fileData as string
25    );
26    const fileInfo = this.fileHandler.getFileInfo(rootPath);
27    if(fileInfo.extension === "md") {
28      const processedContent = await unified()
29      .use(remarkParse)
30      .use(remarkGfm)
31      .use(remarkRehype)
32      .use(rehypePrettyCode, {
33        grid: true,
34        defaultLang: "js",
35        theme: "dark-plus",
36      })
37      .use(rehypeStringify)
38      .process(content);
39
40      const contentHtml = processedContent.toString();
41      return {
42        htmlContent: contentHtml,
43      }
44    }else {
45      return {
46        mdxSource: content,
47      }
48    }
49  }
50}
  • 3~6 - 우선 post가 담겨있는 directory를 부여받고 fileHandler도 의존성 주입을 해준다.
  • 8~17 - getPostMetadata 함수가 metadata를 반환해준다.
  • 19~49 - getContent 함수가 md, mdx인지에 따라 content 데이터를 반환해준다.
/domain/entities/useCases/Post.ts
1export class PostUseCase {
2  constructor(private readonly postRepository: IPostRepository) {}
3
4  async getPostDataByIdAndCategory(
5    id: string,
6    category: string[]
7  ): Promise<IPostEntity> {
8    const {
9      path: { rootPath },
10    } = this.postRepository.getPostFileInfo(id, category);
11
12    const promiseMetadata = this.postRepository.getPostMetadata(rootPath);
13    const promiseContent = this.postRepository.getPostContent(rootPath);
14    const [metadata, content] = await Promise.all([
15      promiseMetadata,
16      promiseContent,
17    ]);
18
19    return {
20      id,
21      metadata,
22      content,
23    };
24  }
25}
  • useCase에서는 repository에 의존하며 getPostDataByIdAndCategory라는 함수를 만들어 Post entity를 최종적으로 만들어준다.

이렇게 repository 패턴을 사용해 데이터의 고유 값을 가져오는 계층을 만들고 두 가지 책임을 분리해 method를 만들어 함수의 문제점을 해결한다.

기존 소스코드 - [Legacy] posts/[...path]/page.tsx

[Legacy] posts/[...path]/page.tsx
1const parsePath = (path: string[]) => {
2	let id: string = "";
3	const category: string[] = [];
4	if (path.length <= 1) {
5		id = path[0];
6	} else {
7		id = path[path.length - 1];
8		category.push(...path.slice(0, path.length - 1));
9	}
10  return {
11    id, category
12  }
13}
14export default async function Post({ params }: { params: { path: string[] }}) {
15  const { path } = params;
16  const { id, category } = parsePath(path);
17  const postData = await getPostData(id, category);
18  return (
19    <>
20      <div className="post-header">
21        <h1 className="title">{postData.title as string}</h1>
22        <DateView dateString={postData.date as string} />
23      </div>
24      <div className="content" dangerouslySetInnerHTML={{ __html: postData.contentHtml }} />
25    </>
26  )
27}

문제점

  • 1~13 - parsePath라는 함수가 UI 계층에 같이 있는게 보기 안 좋다.
  • 14~26 - UI 계층에서 path를 파라미터로 받은 걸 그대로 데이터 가져오는데 사용하지 않고 parse를 한 번 하고 데이터를 가져오는게 불편했다.

내가 해결한 방법

  • presenter를 하나 만들어서 path를 파라미터로 받고 useCase를 의존하고 getPostDataByIdAndCategory 함수를 호출한다.
/adapters/presenters/post.ts
1export class PostPresenter {
2  constructor(private readonly postUseCase: PostUseCase){}
3
4  async getPostDataByRoutingPath(routingPath: string[]): Promise<IPostEntity> {
5    let id: string = "";
6    const category: string[] = [];
7
8    if (routingPath.length <= 1) {
9      id = routingPath[0];
10    } else {
11      id = routingPath[routingPath.length - 1];
12      category.push(...routingPath.slice(0, routingPath.length - 1));
13    }
14
15    return await this.postUseCase.getPostDataByIdAndCategory(id, category);
16  }
17}
  • 2 - poseUseCase를 의존한다.
  • 5~15 - parsePath의 로직을 그대로 넣고 usecase에 id와 category를 파라미터로 넣는다.

의존성 주입

지금까지 만든 infrastructure, repository, useCase, presenter들을 한 곳에서 의존성 주입을 한다. 처음에 말했던 것처럼 바깥쪽에서 안쪽으로 주입해야 한다.

1. InfraStructure

가장 바깥쪽인 infrastructure부터 만들어준다

/di/infrastructures.ts
1import { FileHandler } from "@/adapters/infrastructures/FileHandler";
2
3export interface IInfrastructures {
4  fileHandler: FileHandler;
5}
6
7export default (): IInfrastructures => ({
8  fileHandler: new FileHandler(),
9});

2. Repository

instrastructure를 의존하고 post directory를 정의해 repository를 만들어준다.

/di/repositories.ts
1import { PostRepository } from "@/adapters/repositories/Post";
2import path from "path";
3import { IInfrastructures } from "./infrastructures";
4
5const postDirectory = path.join(process.cwd(), "src/contents/posts");
6
7export interface IRepositories {
8  postRepository: PostRepository;
9}
10
11export default (infrastructures: IInfrastructures): IRepositories => ({
12  postRepository: new PostRepository(
13    postDirectory,
14    infrastructures.fileHandler
15  ),
16});

3. UseCase

repository를 의존하는 usecase를 만들어준다.

/di/useCases.ts
1import { PostUseCase } from "@/domain/useCases/Post";
2import { IRepositories } from "./repositories";
3
4export interface IUseCases {
5  postUseCase: PostUseCase;
6}
7
8export default (repositories: IRepositories): IUseCases => ({
9  postUseCase: new PostUseCase(repositories.postRepository),
10});

4. Presenter

useCase를 의존하는 presenter를 만들어준다.

/di/presenters.ts
1import { PostPresenter } from "@/adapters/presenters/Post";
2import { IUseCases } from "./useCases";
3
4export interface IPresenters {
5  postPresenter: PostPresenter;
6}
7
8export default (useCases: IUseCases): IPresenters => ({
9  postPresenter: new PostPresenter(useCases.postUseCase),
10});

5. DI

이제 모든 레이어에 의존성 주입을 해줌으로써 하나의 도메인을 완성한다.

/di/index.ts
1import infrastructures from "./infrastructures";
2import presenters from "./presenters";
3import repositories from "./repositories";
4import useCases from "./useCases";
5
6const createdInfrastructures = infrastructures();
7const createdRepositories = repositories(createdInfrastructures);
8const createdUseCases = useCases(createdRepositories);
9const createdPresenters = presenters(createdUseCases);
10
11export default {
12  post: createdPresenters.postPresenter,
13};

마무리

클린 아키텍처가 왜 클린 아키텍처인지 적용하면서 알 수 있었다. 직접 구현하기 전에는 추상적이라 와닿지 않았었다. 사실 처음에 적용할 때도 '이게 맞는건가?' 하는 생각도 많이 들었다. 그런데 김영한(전 배민 기술이사)님께서 "추상화와 구체화의 반복이다" 라는 말이 와닿아서 일단 해보자는 생각으로 해봤다. 물론 앞으로도 추상화와 구체화의 반복이겠지만 새로운 기능이나 유지보수를 할 때 어느 계층에 넣어야할 지 정리가 된 것 같아서 상당히 만족스러웠다.

또한 이전보다 많이 나아졌지만 지금이 최고라고 생각하지는 않는다. 누군가 이렇게 바꾼 걸 보고 다르게 생각할 여지도 있다고 생각한다. 나 또한 아직 만족스럽지 못 한(md, mdx content가 repository에서 받아오는게 맞는지) 부분이 있다. 고민해보고 또 수정할 예정이다.

참조