클론코더

레이어드 아키텍쳐란? 왜 코드를 나눠야할까

app.ts 하나에 코드를 다 때려 넣으면 무엇이 문제인지, 왜 Controller · Service · Repository · Model 네 개로 나누는지 TypeScript 예제로 확인합니다.

클론코더··6

app.ts, app.py, app.java 파일 하나에 코드 다 때려 넣은 경험이 있으신분이라면 잠깐 집중해주세요. 단순 설명만이 아니라, 코드와 함께 설명드리겠습니다.

한 파일에 다 넣기

app.ts, app.py, app.java. 여기에 코드를 다 때려 넣은 경험이 있다면.

파일 하나에 요청 처리와 업무 규칙과 SQL이 함께 들어 있는 상태.

레이어드 아키텍쳐의 개념, 의미

어플리케이션을 역할별로 계층을 나눈 구조를 의미합니다. 핵심은 단방향 의존입니다. 위 계층은 바로 아래 계층만 호출하고, 아래 계층은 위를 알지 못하는 상태를 만족시켜야합니다.

단방향 의존

상위 계층은 컨트롤러와 어댑터, 중간 계층은 서비스, 하위 계층은 리포지토리. 위 계층은 바로 아래만 호출한다. 아래 계층은 위를 알지 못한다.

위 계층은 바로 아래 계층만 호출하고, 아래 계층은 위를 알지 못한다.

DDD, Hexogonal, Onion ... 다양한 아키텍쳐들이 있습니다. 그들 역시 단방향 의존을 지킵니다. 왜 많은 아키텍쳐들이 단방향 의존을 지키는 철학을 가지고 있을까요? 왜 역할별로 파일, 폴더를 분리해서 나누면 좋은걸까요?

이 질문에 답변드리기 이전에 우선 레이어드 아키텍쳐라는 단어를 뜯어보겠습니다. 레이어드 = Layered. Layer가 핵심 단어군요! 혹시, 2-Tier나 3-Tier 라는 단어도 들어보셨나요? Tier와 Layer는 다른 구분 기준을 가지고 있습니다. Tier는 물리적으로 구분이 될 때 씁니다. 예를들어 클라이언트(웹 브라우저 크롬)와 서버 컴퓨터는 2-Tier입니다. 클라이언트 ~ Application Rest API 서버 ~ Database Server는 3-Tier로 구분할 수 있겠네요. Layer는 추상적인 기준으로 구분할 때 쓰입니다. 이게 무슨 말이냐고요?

왜 레이어(폴더, 파일)을 역할별로 나눠야할까요?

웹 서버 로직을 만든다고 가정했을 때. 한 파일에 요청을 처리하고 + 업무 규칙 + 업무 흐름 관리 + SQL로 Database 저장하는 그런 코드를 전부 넣으면 안 되나요? 전문적인 용어로 한 파일에 기능, 코드 다 때려박으면 안 되는걸까요? 안되는게 어디있습니까! 됩니다. 파일을 잘 구분해두거나 vs 한 파일에 모든 기능을 다 넣거나 외부 사용자가 기능을 쓸 때 똑같은 동작을 한다면 두 프로그램을 유저 입장에서 구분하기 어렵습니다. 앗, 이게 중요합니다. '유저 입장'에서 기능적으로는 구분하기가 어렵다.

저희는 개발자, 엔지니어입니다. 유저가 아니죠. 유저의 책임은 프로그램을 사용하는 것이고, 개발자의 책임은 프로그램을 고도화, 유지보수 해야하는 것 입니다. 한 파일에 코드를 때려박는 경우에 문제는 코드를 수정해야하는 경우에 생깁니다.

수정 요청

Todo API에서 제목이 최소 3글자 ~ 최대 80글자만 추가할 수 있도록 수정해주세요

라는 요청이 들어왔는데, 화면용 코드를 고치다가 SQL을 건드리고, 변경된 SQL에 의해 이 SQL을 쓰는 다른 로직이 망가집니다.

변경이 번져 나간다

화면용 코드를 고쳤을 뿐인데 SQL을 건드리게 되고, 그 SQL을 쓰던 다른 로직이 망가진다.

고친 곳은 하나인데, 깨진 곳은 열어보지도 않은 파일이다.

서로 관계없는(상관없는) 관심사가 한 덩어리의 코드로 붙어 있으면, 변경 하나하나가 어디까지 번질지 예측을 할 수가 없습니다. 대충 설계해서 기능 돌아가게 만들고나면 기능 고도화할 때 점점 개발 시간이 길어지는 것을 느껴보신분 계실겁니다. 혹은, "아 그냥 내가 처음부터 짜면 짜겠는데.. 중간에 수정하려니까 머리 터지겠다"는 생각도 해보셨을거에요.

파일, 폴더별로 추상적인 기준(역할, 책임, 기능 등등)을 적용해 나눠두면 변경이 일으키는 파급 범위를 예측할 수 있게 만들어주기 때문입니다. Layer는 추상적인 기준으로 구분할 때 쓰이는 단어입니다. 핵심 철학은 단방향이고, 주로 웹 서비스를 많이들 개발하니 Controller, Service, Repository, Model 형태로 구분하게 된 것 입니다. 이게 무슨 말이냐면. 웹 개발시 발생하는 코드에 대한 변경사항, 수정 요청사항이 주로 4개의 방향에서 오기 때문에 4가지 레이어로 구분하게 되었습니다.

어떻게 나누나요? 예시좀 보여주세요

보통 백엔드를 기준으로는 프레젠테이션(Controller), 비즈니스(Service), 데이터 접근(Repository), 모델(Model)로 나눕니다. 이 구조를 MVC 패턴이라고도 부르고 Service 개념을 추가해서 MVCS 패턴이라고 부르기도 합니다만, 이번 글에서는 레이어드 아키텍쳐라는 이름으로 부르도록하겠습니다.

변경 축과 레이어

이 코드는 무엇 때문에 고치게 되나. 화면과 입출력 방식이 바뀌어서 컨트롤러와 어댑터. 사업 규칙이 바뀌어서 모델. 저장 기술이 바뀌어서 리포지토리. 처리 절차가 바뀌어서 서비스.

수정 요청이 오는 방향마다 레이어가 하나씩 생긴다.

[1] 백엔드 기준 외부 요청과 응답이 바뀐다 = 외부가 바뀌는 케이스

REST API를 GraphQL로 바꾼다거나. 웹 프론트엔드만 지원하다가 App을 출시한다거나. 프론트에서 보내는 요청 형식이 바뀌거나, 응답 형식을 바꿔야할 때. 이 때는 내부 비즈니스 로직, 저장소 규칙 호출 방법이 바뀌지 않아야하고, 바뀔 이유가 없습니다. 철저히 다른 관심사죠.

[2] 업무 규칙이 바뀐다

Todo API 기준으로 Todo Create Policy - 제목, 상세 설명 길이 제한이 생기거나 혹은 길이가 변경되거나(기존 80글자 -> 100글자로 늘려주세요). 서비스 사용자의 역할이 ( FREE | PRO ) 에서 ( OWNER, VIEWER )가 추가되어야 한다거나, 서비스 사용 UI, UX 자체가 크게 바뀌는 기획이 바뀔 때 변해야하는 코드와 변하지 않아도 되는 코드가 있습니다.

이렇듯 각 레이어는 각자 바뀌는 이유가 다르고, 바뀌는 속도 역시 다릅니다. 함께 바뀌는것 끼리 묶고 따로 바뀌는 것은 떼어놓아야 합니다. 많은 시행착오를 거쳐 웹 업계에서 Controller, Service, Repository, Model로 나누자는 얘기가 나온것이구요.

ㅇㅋ 개념은 이제 알았고, 코드를 보여줘

typescript를 사용하겠습니다. 아~주 간단한 Model, Service 코드를 src/lib/todo.ts 에 작성하겠습니다.

src/lib/todo.ts
import crypto from 'node:crypto';
 
// Model
export type Todo = {
  id: string;
  title: string;
  completed: boolean;
  createdAt: Date;
};
 
// Service
export function createTodo(title: string): Todo {
  return {
    id: crypto.randomUUID(),
    title,
    completed: false,
    createdAt: new Date()
  }
}

Controller 역할을 하는 2개의 앱을 만들겠습니다. 첫번째는 터미널 환경에서 동작하는 src/cli.ts. 두번째는 nodejs express 기반의 간단한 express server를 만들어보겠습니다.

첫번째 Controller ~ Web Server Service

아~주 간단한 src/server.ts 입니다. npx tsx src/server.ts로 실행합니다.

src/server.ts
import express from 'express';
import { type Todo, createTodo } from './lib/core/todo';
 
const todos: Todo[] = [];
 
const app = express();
app.use(express.json());
 
app.get('/todos', (req,res) => {
  res.json(todos);
})
 
app.post('/todos', (req, res) => {
  const todo = createTodo(req.body.title);
  todos.push(todo);
  res.status(201).json(todo);
})
 
app.listen(4000, () => console.log('할 일 관리 (웹) localhost:4000'));

core에서 모두 import 해옵니다. 현재 src/server.ts 는 controller + server instance 띄우기 2가지 역할, 책임만을 다합니다. todo 생성 로직, 생성에 있어 고려할 정책(Policy)들은 src/server.ts 의 책임이 아닙니다 = 관심사가 아닙니다.

Todo 생성 요청 보내기

terminal
curl -sS -X POST http://localhost:4000/todos \
  -H "content-type:application/json". \
  -d '{"title":"NodeJs 스터디"}'

Todo 조회 요청 보내기

terminal
curl -sS -X GET http://localhost:4000/todos

레이어드 아키텍처(Layered Architecture) 기준으로 '단방향 의존성' 만족합니다. 단, Repository(저장소 역할)은 src/server.tsconst todos: Todo[] =[]; 가 맡고 있네요. 이것도 분리해야겠습니다. 현재는 코드 파일의 라인 수가 적어서 한눈에 파악이 가능하므로 Model, Service를 구분하지 않았습니다. 이제 Todo Create API를 고도화해나가며 파일들을 구분하며 기능들을 얹어보겠습니다.

두번째 Controller ~ 터미널 서비스 src/cli.ts

여기도 아주 간단한 Terminal 기반의 서비스입니다.

src/cli.ts
import * as readline from 'node:readline/promises';
import { type Todo, createTodo } from './lib/core/todo';
 
async function main() {
  const todos: Todo[] = [];
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
  });
 
  // Ctrl+D 등으로 입력이 끊기면 question()이 매달리므로 여기서 종료
  rl.on('close', () => process.exit(0));
 
  console.log('할 일 관리 (터미널) . add <제목> / list / exit');
 
  while (true) {
    const line = await rl.question('> ');
    const [cmd, ...rest] = line.trim().split(' ');
 
    if (cmd === 'exit') {
      break;
    }
    if (cmd === 'add') {
      todos.push(createTodo(rest.join(' ')));
      console.log('추가됨');
    }
    if (cmd === 'list') {
      todos.forEach((t) => console.log(`[${t.completed ? "x" : " "}] ${t.title}`));
    }
  }
 
  rl.close();
}
 
main();

core에서 모두 import 해옵니다. 현재 cli.ts 는 controller + terminal instance 띄우기 2가지 역할, 책임만을 다합니다. todo 생성 로직, 생성에 있어 고려할 정책(Policy)들은 src/cli.ts 의 책임이 아닙니다 = 관심사가 아닙니다.

이 글은 네이버 블로그에 먼저 실린 글을 옮긴 것입니다.