로버트 마틴의 클린 아키텍처를 읽고 쓴 글입니다.
서론
이전에 유연한 컴포넌트라는 글을 썼었는데,
이전에 제가 쓴 "유연한 컴포넌트" 글에서는 리액트 컴포넌트를 UI, 비즈니스 로직, 그리고 Data Fetching으로 분리했었습니다. 이렇게 분리함으로써 코드의 재사용성과 유지보수성을 높이고자 했죠.
하지만 최근 들어 사용하는 프레임워크 & 라이브러리의 deprecated나 Next.js의 급격한 변화(Next.js 13에서 14, 15로 이어지는)를 보며..; 피로감과 불안감을 느끼게 되었습니다.
특히 Next.js처럼 프레임워크 & 라이브러리의 큰 변화는 기존 코드 구조에 큰 영향을 미치고, 때로는 기존에 작성한 로직을 재작성해야 하는 상황을 만들기도 합니다. 이러한 변화는 개발자로서 큰 부담으로 다가올 수밖에 없는데, 이를 어떻게 대처할 수 있을지 고민이 많아졌습니다.
그래서 클린 아키텍처를 읽게 되었는데요.
보통 백엔트 아키텍처에서 많이 사용하고, 객체 지향을 사용해서 프론트엔드 분야에서는 좀 낯선 분야였지만 읽고 많은 영감을 얻게 되었습니다.
문제를 해결하기 위한 WHAT 과 HOW
요약하자면, 어떤 비즈니스의 문제를 해결할때 추상화를 통하여
"WHAT"(문제를 어떻게 해결할지)만 생각해야지 HOW(어떤 기술을 쓰는지)는 중요하지 않고, 분리해서 생각해야한다는 것이였습니다.
이를 클린 아키텍처에서는 layered architecture와 DIP(의존성 역전)으로 해결합니다.
예를 들어, 블로그에서 자신이 쓴 비밀 글을 조회하는 상황을 생각해봅시다.
티스토리에서는 비밀 글을 자신만 조회하거나 비밀번호를 알아야 조회할 수 있습니다. (아마도..? 제가 아는 한은 그렇습니다)
그럼 가장 높은 추상화 단계로 생각하자면
유저가 로그인을 했고, 정당한 권한을 가지고 있다. (WHAT)만 판별하면 되는 문제입니다.
여기서 로그인 판별 방식을 어떻게 할지는(HOW), 세션 인증을 사용하는지, 아님 JWT를 사용하는지는 부차적인 문제고 나중에 언제든지 바뀔 수 있습니다.
그리고 권한을 판별할때도, 지금은 비밀글을 자신밖에 못본다 하더라도 나중 업데이트를 통하여
네이버 블로그처럼 서로 이웃인 친구는 비밀글을 볼 수 있도록 나중에 바뀔 수 있겠죠. 자세한 디테일은 언제든지 갈아끼우면 됩니다.
이처럼 기술과, 비즈니스 로직을 부품화해서 언제든지 갈아끼울 수 있도록 구현하는 방식을 클린 아키텍처는 소개시켜 줍니다.
클린 아키텍처 구조
클린 아키텍처하면 가장 유명한 그림인데요
이 책을 읽기 전에는 이해가 잘 안갔는데 읽고 난 후에는 SOLID의 DIP를 응용한 아키텍처라는것을 알게 되었습니다.
각 레이어를 to-do 리스트를 만드는 코드 예시와 간단하게 훑어봅시다.
샘플 코드는 아래 깃허브 주소에 있습니다.
https://github.com/lodado/chatgpt-cleanarchitecture-example
엔티티 레이어(Entity Layer):
class Task {
id: string;
title: string;
isCompleted: boolean;
constructor(params: { id: string; title: string; isCompleted?: boolean }) {
if (!params.id || !params.title) {
throw new EntityError({ message: "Task must have an id and a title." });
}
this.id = params.id;
this.title = params.title;
this.isCompleted = params.isCompleted ?? false;
}
/**
* Mark the task as completed
*/
toggleMark(): void {
this.isCompleted = !this.isCompleted;
}
/**
* Change the title of the task
*/
changeTitle(newTitle: string): void {
if (!newTitle) {
throw new EntityError({ message: "New title cannot be empty." });
}
this.title = newTitle;
}
}
이 레이어는 애플리케이션의 핵심이 되는 비즈니스 엔티티를 포함하고 있습니다. 이러한 엔티티는 비즈니스 규칙을 캡슐화하며 애플리케이션 레이어와는 독립적입니다.
집합 개념으로 치면 "연산이 닫혀 있다.(Closure under an operation)" 라는 개념이 생각나는데요.
닫혀있다의 사전적 의미는 특정 연산에 대해 어떤 집합이 닫혀 있다고 말할 때, 이는 그 연산을 집합의 원소들 사이에서 수행한 결과가 항상 동일한 집합의 원소로 남아 있는 성질을 의미합니다.
예를 들어, 엔티티 레이어는 애플리케이션의 다른 레이어와 독립적으로 존재하며, 엔티티 간의 상호작용이나 연산이 일어날 때 그 결과가 항상 엔티티 레이어 내에서 관리되고 유지됩니다. 즉, 엔티티 레이어 내의 비즈니스 규칙이 적용된 연산 결과는 여전히 같은 엔티티 레이어 내에서 처리되며, 외부 레이어로부터의 영향을 받지 않고 독립성을 유지합니다.
이는 수학적 집합에서 특정 연산을 수행한 결과가 항상 그 집합 내에 남아 있는 것과 유사합니다.
유스 케이스 레이어(Use Case Layer):
AddTodoListUseCase {
constructor(private taskRepository: TodoListRepositoryImpl) {}
async execute(params: { id: string; title: string }): Promise<void> {
const task = new Task({ id: params.id, title: params.title });
try {
return await this.taskRepository.addTask({ task });
} catch (error) {
throw mapRepositoryErrorToUseCaseError(error as Error);
}
}
}
이 레이어는 애플리케이션에 특화된 비즈니스 규칙을 포함하고 있습니다. 이 레이어는 엔티티로부터 데이터를 주고받는 흐름을 조정하고, 필요한 리포지토리를 호출하며 각 유스 케이스에 대한 로직을 관리합니다.
비즈니스 로직을 정의하고 있는데요. addTask라는 명령을 수행하는데 해당 코드는 하나의 list를 저장소에 추가하라는 명령을 내리고 있습니다.
그런데 데이터를 "어떻게", "어디에" 저장할지는 주입받은 repository layer에서 정의합니다.
예를 들어 임베디드 시스템에서 저장 명령을 내릴때 "메모리에 저장해"라는 명령어를 내리고 펌웨어에서 "어디"에 저장할지 구체적으로 실행합니다.
리포지토리 레이어(Repository Layer):
import { Task } from "../../../../entities";
import {
RepositoryError,
mapEntityErrorToRepositoryError,
} from "../../../../shared";
import { TodoListRepositoryImpl } from "./interface";
export default class InMemoryTodoListRepository
implements TodoListRepositoryImpl
{
private tasks: Map<string, Task> = new Map();
async getAllTasks() {
return new Map(this.tasks);
}
async addTask(params: { task: Task }): Promise<void> {
try {
if (this.tasks.has(params.task.id)) {
throw new RepositoryError({
message: "Task with this ID already exists.",
});
}
this.tasks.set(params.task.id, params.task);
} catch (error) {
throw mapEntityErrorToRepositoryError(error as Error);
}
}
async getTaskById(params: { id: string }): Promise<Task | null> {
try {
return this.tasks.get(params.id) ?? null;
} catch (error) {
throw mapEntityErrorToRepositoryError(error as Error);
}
}
async deleteTask(params: { id: string }): Promise<void> {
try {
if (!this.tasks.delete(params.id)) {
throw new RepositoryError({ message: "Task not found for deletion." });
}
} catch (error) {
throw mapEntityErrorToRepositoryError(error as Error);
}
}
async toggleMark(params: { id: string }): Promise<void> {
try {
const task = await this.getTaskById({ id: params.id });
if (!task) {
throw new RepositoryError({
message: "Task not found to mark as completed.",
});
}
task.toggleMark();
} catch (error) {
throw mapEntityErrorToRepositoryError(error as Error);
}
}
}
이 레이어는 데이터 접근 로직에 대한 추상화를 제공합니다. 데이터베이스나 웹 서비스와 같은 외부 시스템과 상호작용하며, 외부 포맷과 애플리케이션 엔티티 간의 데이터를 변환하는 역할을 담당합니다.
구체적으로 "어디에" 저장할건지를 수행합니다. DB일수도, 메모리일수도, 아니면 네트워크 요청일수도 있죠. 레이어 위에서 아래로 클래스를 주입하면서 의존성 역전을 통하여 핵심적인 로직만 전달합니다.
어댑터 레이어(Adapter Layer):
이 레이어는 애플리케이션과 외부 세계(예: 사용자 인터페이스, 외부 API, 데이터베이스 등) 사이의 인터페이스 역할을 합니다.
백엔드면 URL 요청을 받는 controller, 프론트엔드면 hook 부분일수 있습니다.
객체지향을 쓰지 않더라도 이책에서 말하는 핵심적인 가치, 추상화는 유용할것 같습니다. 이책을 읽고 많이 배운것 같네요.
'Front-end' 카테고리의 다른 글
쓸만해보이는 태그, 속성들 (메모용) (0) | 2022.07.11 |
---|---|
jest가 느린이유 (0) | 2022.02.18 |