로버트 C. 마틴 의 저서 클린 아키텍처는 널리 알려진 개발 서적입니다. 저는 2019년 출간된 한국어 버전으로 이 책을 읽었습니다.
클린 아키텍처는 꽤나 방대한 내용을 담고 있기에 블로그 한 편으로 요약한다는 것은 불가능합니다. 하지만 이 책과 사상의 한 열렬한 팬으로서 더 많은 분들이 더 쉽게 클린 아키텍처에 입문했으면 하는 마음에, 많은 내용을 생략하고 요약하여 이 글을 작성합니다.
클린 아키텍처의 목적은, 더 적은 인력으로 더 결함율이 낮고 더 빠르게 개발할 수 있는 코드를 작성하는 것입니다.
설명을 위한 언어는
TypeScript
를 채택했습니다.
열심히 쓰긴 했지만, 이 글보단 원본 책을 읽으시는 걸 강력하게 추천드립니다. 하지만 만약 책이 부담스럽게 느껴지신다면 이 글로 입문해보시는 것도 좋을 것 같습니다 :)
개발자는 돈을 받고 코드를 만드는 사람입니다. 따라서 회사에게 가장 중요한 것은
입니다. 하지만 모든 개발자들이 이렇게 "적은 인력으로 유지보수가 가능하면서도 결함율이 낮은" 꿈만 같은 소프트웨어를 만들어낼 수는 없습니다. 오히려 많은 개발자들은 "많은 인력을 가지고도 유지보수도 못하고 버그는 많이 생기는" 끔찍한 소프트웨어를 만들곤 합니다.
소프트웨어를 만들어내는 것은 누구나 할 수 있는 일이지만, 이렇게 적은 인력으로 유지보수를 쉽게 할 수 있는 좋은 소프트웨어를 만드는 것은 많은 연습과 공부가 필요한 일입니다. 그리고 이런 좋은 소프트웨어가 가지고 있는 규칙을 우리는 좋은 아키텍처
라고 부릅니다.
좋은 아키텍처를 구성하는 규칙은 분명 존재합니다. 1940년에 튜링이 처음으로 머리속에서 코딩을 한 이후로, 하드웨어는 엄청나게 발전했지만 소프트웨어는 변하지 않았습니다.
중요한 부분이라 반복하면, 소프트웨어 규칙은 반세기 동안 변하지 않았습니다.
클린 아키텍처는 이런 몇 가지 (앞으로도 변하지 않을 가능성이 높은) 규칙들을 소개한 다음 - 끝에 가서는 이런 아이디어를 모아 한 가지 그림을 보여줍니다.
많은 코드들은 다른 코드에 의존합니다. 의존이란, 다른 코드베이스를 참조하여 이용하는 것을 말합니다. Java에서는 import
, JavaScript 에서는 require
나 import
, C 에서는 include
를 통해 모듈들이 서로 의존합니다.
모듈이 서로 의존하는 관계는 아래와 같이 나타낼 수 있습니다.
B 모듈이 A 모듈을 import
하고 있는 것입니다. 즉 B 모듈은 A 모듈에 의존하고 있습니다.
A 모듈은 아무것도 import하지 않지만, 두 개의 모듈이 A 모듈을 import하고 있습니다. 이런 상태를 안정적이다
라고 표현합니다. 안정적이라고 해서 좋아 보일 수 있지만, 꼭 좋은 말은 아닙니다. 안정적이란 것은 변경하기 어렵다는 것입니다. 이렇게 이해하는 게 좋습니다.
A 모듈을 건드렸다가 B, C 모듈에 무슨 일이 일어날지 모르니 A 모듈은 건드리지 말자
반대로 C나 D 모듈은 매우 불안정합니다. C 모듈을 잘못 건드려도, C 모듈만 망하지 다른 모듈이 망하진 않을 것입니다. 우리는 C 모듈을 거리낌 없이 변경할 수 있습니다.
우리 코드에는 안정된 모듈과 불안정한 모듈이 모두 존재해야 합니다. 안정된 모듈이 없을 수는 없고, 불안정한 모듈이 분명히 있어서 편안한 마음으로 변경할 수 있어야 합니다.
우리는 항상 불안정한 모듈이 안정된 모듈에 의존하게 해야 합니다.
만약 반대의 상황이 벌어진다면, 안정된 모듈을 건드리지 않기 위해 불안정한 모듈을 건드릴 수 없게 되거나 불안정한 모듈을 건드려 버려서 안정된 모듈을 건드리고 코드가 위험해질 수 있습니다. 아래가 좁고 위가 넓은 물체인 팽이는 언제든 쓰러질 수 있지만, 아래가 넓고 위가 좁은 피라미드는 쓰러지지 않습니다.
어떻게 이를 이룰 수 있을까요?
구현체는 자주 변하지만, 추상화는 자주 변하지 않습니다. 기획단에서 오는 많은 변경사항들은 추상화의 변경이 아닌 구현체의 변경을 요구합니다.
따라서 구현체를 불안정한 모듈로, 추상화를 안정된 모듈로 만들면 더 안정적인 - 피라미드와 같이 아래가 넓고 위가 좁아 넘어지지 않는 시스템을 구축할 수 있습니다.
모든 코드는 구현체가 아닌 추상화에 의존해야 합니다.
// A.ts
type MyAwesomeModule = {
foo: () => number;
}
// createMyAwesomeModule.ts
import { type MyAwesomeModule } from 'A';
const createMyAwesomeModule = (): MyAwesomeModule => ({
foo: () => {/* ... */}
});
// createMyOtherModule.ts
import { type MyAwesomeModule } from 'A';
const createMyOtherModule = (myAwesomeModule: MyAwesomeModule) => {
/* ... */
};
이 코드에는 A
, createMyAwesomeModule
, createMyOtherModule
세 개의 모듈이 있습니다.
A
모듈은 안정되었고, createMyAwesomeModule
과 createMyOtherModule
은 불안정합니다. A
모듈은 추상 인터페이스이기 때문에 변경될 일이 없고, createMyAwesomeModule
과 createMyOtherModule
은 구현체이기 때문에 변경될 일이 잦을 것입니다. 이 시스템은 변경에 용이합니다.
그런데 뭔가 이상한 점이 있습니다. 코드를 저렇게 짜면, createMyAwesomeModule
은 누가 수행하는 것이며 createMyOtherModule
에게 myAwesomeModule
을 전달해주는 역할은 누가 하죠? 추상화에 의존하기만 하는 코드는 아무것도 할 줄 모르는 바보 코드인 것 아닌가요?
나중에 나오는 이야기이지만, 여기서 넘어가면 납득이 안되기에 미리 비밀을 알려드겠습니다. 모든 구현체에 의존하고 시스템을 수행시키며 짬처리를 당하는 역할을 담당하는 게 바로 메인
입니다. 메인은 일반적으로 시스템의 진입점입니다. 우리는 ts-node main.ts
와 같은 형태로 메인을 수행하며, 메인이 모든 모듈들의 구현체를 만든 뒤 의존성을 잘 주입한 다음 구현체에게 제어권을 던져주기를 기대합니다. 메인은 구현체에 직접 의존하기에 위험하지만, 어쩔 수 없습니다. 메인만 더럽고 못생기고 불결하면 나머지 모두가 깨끗할 수 있습니다.
// main.ts
import { createMyAwesomeModule } from 'createMyAwesomeModule';
import { createMyOtherModule } from 'createMyOtherModule';
const myAwesomeModule = createMyAwesomeModule()
const myOtherModule = createMyOtherModule(myAwesomeModule);
myOtherModule.foo();
메인은 전체 시스템에서 유일하게 구현체에 의존합니다.
모든 코드는 정책을 담고 있습니다.
type User = { email: string; } // 유저에게 이메일 정보를 받는다는 정책
<div>안녕하세요</div> // 문구가 "안녕하세요" 라는 정책, 사용자 인터페이스가 웹이라는 정책
const getDiscountedPrice = (price) => price * 0.7; // 할인율이 30%라는 정책
sql.read('select * from user'); // DB가 SQL 형태라는 정책
ReactDOM.createRoot().render(); // 프레임워크는 리액트라는 정책
그런데 이 정책들 중에서도 더 중요한 정책과 덜 중요한 정책이 있습니다. 위에 나열된 정책들을 중요한 순서대로 나열해 볼까요?
어플리케이션의 존재 목적은 기능입니다. 따라서 고객 (또는 기획자) 가 원하는 "기능"이 어플리케이션의 핵심 정책입니다. 이는 어플리케이션의 사명입니다.
반면 웹, DB, 프레임워크 등은 그렇게까지 중요하지는 않습니다. 영속성이 MySQL
로 관리되든 엑셀 시트
로 연동되어 관리되든, 유저 입장에서 기능적인 차이는 없습니다. 인터페이스가 웹
이든 앱
이든 거의 차이 없습니다. 물론 차이가 있긴 한데, 할인율보다 덜 중요하다는 것은 분명합니다.
간혹 우리는 프레임워크, 데이터베이스, 웹을 중요하게 여기곤 합니다. 그래서 웹에 종속된 코드를, 데이터베이스에 종속된 코드를, 프레임워크에 종속된 코드를 구현합니다. 하지만 클린 아키텍처에서는 이는 완전히 잘못되었다고 합니다. 이들은 중요한 정책이 아니며, 우리 코드는 이들을 편하게 갈아탈 수 있어야 합니다.
좋은 아키텍처는 더 중요한 정책을 더 중요한 곳에 위치시켜 보호하고, 이를 통해 결함율을 낮춥니다.
이렇게 봤을 때, 더 중요한 정책과 덜 중요한 정책이 있습니다. 더 중요한 정책을 이 책에서는 업무 규칙
이라고 하고, 덜 중요한 정책을 세부사항
이라고 합니다.
인간은 한번에 모든 결정을 완벽하게 할 수 없습니다. 완벽하게 해냈다 하더라도 외부 요인에 의해 결정이 번복되어야 할 때가 있습니다. 따라서 가능하다면, 결정을 뒤로 미루는 것이 현명합니다. 여기서 뒤로 미룰 만한 결정들은 주로 프로젝트의 목적과 직접적으로 관련되지 않은 세부사항들입니다. 즉 프레임워크, 웹서버, 데이터베이스 등입니다.
데이터베이스를 먼저 결정하고 나서 보니 기능을 구현하는 데에 적합하지 않을 수 있습니다. 프레임워크도 마찬가지입니다. 데이터베이스나 프레임워크는 소프트웨어를 도와주는 도구일 뿐 핵심 개체가 아니기에, 겨우 도구가 핵심 기능을 방해하는 이런 일이 일어나서는 안 됩니다.
더 중요한 것과 덜 중요한 것은 어떻게 결합되어야 할까요? 클린 아키텍처에서는 플러그인 아키텍처
를 제안합니다. 가령 vscode 와 vscode 의 익스텐션 중 하나의 관계와 비슷합니다. 가령 vscode가 업데이트되어서 vscode 익스텐션인 auto import 가 더이상 동작하지 않는다면, auto import 의 제작자는 궁시렁대면서 vscode의 업데이트에 맞춰줘야 합니다. 만약 맞춰주지 않는다면, 유저들은 비슷한 역할을 하는 아무 다른 익스텐션이나 찾아서 떠나버릴 것입니다.
이처럼, 세부사항이 정책에 플러그인처럼 결합해야 합니다. 세부사항은 정책 앞에서 아무것도 못하고 아무 불만도 내지 못합니다. 정책과 세부사항의 관계는 아주 일방적이며, 만약 세부사항이 정책에 적합하지 않다면 우리는 비슷한 역할을 하는 아무 세부사항을 찾아 떠나면 됩니다. 우리의 정책에 맞춰줄 데이터베이스, 프레임워크, GUI를 말이죠.
사실 세부사항이 정책에 적합한지 미리 알기는 어렵기 때문에, 여기서 한 발짝 더 나아가서 엉클 밥은 프레임워크나 데이터베이스 등 세부사항은 나중에 결정하고 유즈케이스 먼저 구현하라고 이야기합니다.
소프트웨어의 존재 이유는 기능, 다시 말해 유즈케이스라고 했습니다. 가정집이 주거를 위해 존재하고 식당이 손님이 식사를 하는 것을 위해 존재하는 것처럼, 소프트웨어는 유즈케이스를 위해 존재합니다.
따라서 좋은 아키텍처라면 유즈케이스를 핵심적인 위치에 둬야 합니다. 시스템의 폴더 구조를 보았을 때 유즈케이스가 무엇인지 한눈에 보이고, 시스템이 어떤 일을 하는지 일일이 찾아볼 필요 없이 유즈케이스 폴더를 열어서 확인할 수 있을 것입니다. 그리고 그 폴더에서 시작해서 GUI로도, 데이터베이스로도 추적해나갈 수 있을 것입니다.
많은 경우 우리가 잘못 만든 아키텍처는 "배달 관리 시스템이야" 가 아닌 "리액트야" 또는 "스프링이야" 와 같이 소리치곤 하는데요, 이렇게 되면 프레임워크가 세부사항이라는 중요한 사실을 위반하는 것입니다. "리액트야" 라는 정보는 개발자가 유즈케이스를 확인하는 데에 아무 도움도 주지 않습니다.
클린 아키텍처는 세상에 없던 새로운 개념이 아닙니다. 육각형 아키텍처, DCI, BCE 등 관심사의 분리를 위한 여러 아키텍처들이 가지고 있는 아이디어들을 하나로 통합해보는 것 뿐입니다. 클린 아키텍처는 단순한 하나의 동심원을 보여줍니다.
클린 아키텍처의 핵심은 이것입니다.
소스 코드 의존성은 반드시 안쪽으로, 고수준의 정책을 향해야 한다.
내부의 원에 속한 요소는 외부의 원에 속한 어떤 것도 알지 못해야 합니다. 원은 꼭 위와 같이 4개가 아니어도 괜찮습니다. 2개일 수도 있고, 10개일 수도 있습니다. 중요한 것은 소스 코드 의존성이 안쪽으로 향하며, 안쪽 원에는 고수준의 정책이 존재한다는 것입니다.
가령 repository 레이어는 API 엔드포인트 등을 포함하는데, 이는 사진의 초록색 원에 속합니다. repository 레이어의 구현체는 파란색 원에 속하는 DB, 웹 등의 것을 단 하나도 알면 안 됩니다.
그럼 이제 각 레이어에 대한 자세한 개념을 확인해 보겠습니다.
어플리케이션의 핵심 중의 핵심이 되는 업무 객체입니다. 엔티티에는 정말 중요한 것들이 들어가는데, "정말 중요한 것"이라고 하면 우리가 어플리케이션 없이 사람 손으로 모든 걸 한다고 해도 존재하는 것입니다. 가령 배달 서비스 플랫폼이라면, "음식점과 주문자가 있다" 등의 정책입니다.
type Restruant = { location: Location; };
type User = { id: number; };
유즈케이스는 어플리케이션의 핵심 업무 객체입니다. 엔티티는 어플리케이션이 없어도 존재해야 하는 것인 반면, 유즈케이스는 어플리케이션이 있기에 존재하는 업무 객체입니다. 가령 "장바구니에 음식을 담을 수 있다", "로그인/로그아웃을 할 수 있다" 등입니다.
type AuthService = {
login: (username: string, password: string) => Promise<void>;
};
유즈케이스가 올바르게 동작하기 위해서는 사용자 인터페이스와 데이터베이스와 소통해야 합니다. 이때 유즈케이스가 GUI나 데이터베이스 사이에서 브릿지 역할을 해 주는 것이 어댑터 레이어입니다. 이 레이어에는 우리가 흔히 말하는 repository
, presenter
, controller
등이 속합니다.
type RestruantRepository = {
listRestruants: () => Promise<Restruant[]>;
};
웹, 데이터베이스, 프레임워크 등입니다. 다시 한 번 강조하지만, 이들은 절대 업무 규칙보다 중요하지 않습니다. 업무 규칙은 세부 사항에 대해 아무것도 몰라야 합니다.
body {
height: 100%;
}
앞서 잠깐 소개했었던, 어플리케이션의 진입점이자 궁극의 세부사항입니다. 지금까지는 어떻게든 추상화에 의존하고 구현체를 바보로 만들어서 관심사를 분리하려 했지만, 메인은 반대입니다. 모든 구현체들이 바보가 된 것은 메인의 희생 덕분입니다. 메인은 어플리케이션의 모든 세부사항을 다 알고 있으며, 환경변수를 읽고, 서비스 구현체 인스턴스를 생성하고, 업무 객체에게 제어권을 넘깁니다.
모든 세부사항보다도 바깥쪽에 있는 친구입니다. 하지만 분명히 아키텍처의 일부입니다. 테스트는 다른 것들과 다르게 배포되지 않으며, 유저에게 영향을 미치지도 않습니다. 하지만 분명히 테스트는 의존성 규칙을 따르는 시스템의 일부입니다.
이렇게 클린 아키텍처를 정말 정말 정말 정말 간단하게 알아보았습니다. 전체적으로 맞는 말인 것 같으면서도 틀린 말인 것 같고, 무엇보다 그래서 어쩌라고? 라는 생각이 많이 드는 책이긴 합니다. 실제로 도입하려면 너무나도 어렵구요. 제가 실제로 코드에 어떻게 적용했는지는 다음 기회에 풀어보겠습니다.
개인적으로는 클린 아키텍처를 이용하며 분명히 많은 효과를 봤습니다. 파일 두 개만 수정해서 전체 프로젝트를 axios
에서 fetch
로 마이그레이션할 수 있었고, 기획이 변경되었을 때 무슨 파일을 바꿔야 하는지 단숨에 알 수 있었고, 결함율이 낮은 코드를 작성할 수 있었습니다.
아키텍처 규칙을 생각하지 않으면 우리 코드는 금세 리액트가 없어지면 아무것도 못하는 / 브라우저가 아닌 앱으로 보여져야 하면 아무것도 못하는 / 프레임워크 버전업을 대응해주지도 못하는 무능한 코드가 됩니다. 소프트웨어는 유연해야 합니다.
이 글을 읽고 조금이라도 흥미가 생기셨다면, 한번 읽어보시길 바라겠습니다!