1. Layered Pattern이란?


코드의 구조를 구상할 때 고려해야할 점은 여러가지다. 또, 코드 구조의 하나하나가 모두 중요하기 때문에, 매번 좋은 코드의 구조를 생각해 내기란 쉽지 않습니다. 다행히도 “코드의 구조를 어떻게 구성하고 관리해야 하는가 ” 에 대한 문제는 이미 많은 개발자들이 다뤘던 문제며, 그에 관한 정석같은 패턴이 굉장히 많이 나와 있다. 레이어드(layered) 패턴은 백엔드 API 코드에 가장 널리 적용되는 패턴 가운데 하나다.

Multi-tier 아키텍처 패턴이라고도 하는 이 Layered 아키텍처는 코드를 논리적인 부분 혹은 역할에 따라 독립된 모듈로 나누어서 구성하는 패턴이다. 각 모듈이 서로의 의존도에 따라 층층히 쌓듯이 연결되어 전체의 시스템을 구현하는 구조다. 그래서 마치 레이어(layer)를 쌓아 놓은 것 같은 형태의 구조가 된다. 즉, 역할에 따라 분리했던 파일들은 하나의 레이어로 볼 수 있다.

[그림1] Layered Pattern 작업 흐름도

각 시스템마다 경우가 다를 수 있으나 일반적으로 보통 다음과 같은 3개의 레이어가 존재한다.

  • Presentation Layer
  • Business Layer
  • Persistence Layer

Presentation Layer

[그림2] Layered Pattern | Presentation Layer

Presentation Layer는 해당 시스템을 사용하는 사용자 혹은 클라이언트 시스템과 직접적으로 연결되는 부분이다. 백엔드 API에서 엔드포인트 부분에 해당된다. 그러므로 Presentation Layer에서 API의 엔드포인트들을 정의하고 전송된 HTTP 요청(request)들을 읽어 들이는 로직을 구현한다. 하지만 그 이상의 역할은 담당하지 않는다. 실제 시스템이 구현하는 비즈니스 로직은 다음 레이어(layer)로 넘기게 된다.

Business Layer

[그림3] Layered Pattern | Business Layer

Business Layer는 이름 그대로 비즈니스 로직을 구현하는 부분이다. 실제 시스템이 구현해야 하는 로직들을 이 레이어에서 구현하게 됩니다. 예를 들어, 인스타그램 API의 회원가입 엔드포인트에서 비밀번호가 8자가 넘는지를 확인하여, 만약 8자 미만이라면 회원가입을 거부하는 로직 등이 비즈니스 로직이다. 즉 우리 회사의 비즈니스를 운영하기 위해 필요한 로직이며, business layer에서 구현하게 된다.

Persistence Layer

[그림4] Layered Pattern | Persistence Layer

Persistence Layer데이터베이스와 관련된 로직을 구현하는 부분입니다. Business Layer에서 필요한 데이터 생성, 수정, 읽기 등을 처리하여 실제로 데이터베이스에서 데이터를 저장, 수정, 읽어 들이기를 하는 역할을 한다.









2. Layered 아키텍처의 핵심 요소


단방향 의존성

Layered 아키텍처의 핵심 요소는 바로 `단방향 의존성과 관심사 분리(Seperation Of Concern)`다. 우선 단방향 의존성이란, 각각의 레이어는 오직 자기보다 하위에 있는 레이어에만 의존하고 있음을 의미합니다.

  • Presentation layer는 business layer에게 의존하고, business layer는 persistence layer에게만 의존한다. Presentation layer에서 Business layer를 건너뛰고 Persistence layer에 접근하는 일은 절대 일어나지 않는다.
  • Business Layer는 presentation layer에 대해 완전히 독립적이며, persistence layer는 business layer나 presentation layer에 대해 완전히 독립적입니다.

✅ 관심사 분리(SOC)

그리고 관심사 분리란, 각 레이어 별 역할이 구분되어 명확하다는 사실을 의미한다. 즉 역할의 중첩이 없다.

  • Presentation layer에는 비즈니스 로직이 전혀 구현되어 있지 않다.
  • Business layer에는 데이터베이스 관련 로직이 전혀 구현되어 있지 않다. 데이터베이스 처리를 하기 위해서는 persistence layer의 코드를 호출해서 사용해야 한다.
  • 요리사가 어떤 과정을 거쳐서 요리를 하는지 중간 직원은 전혀 알 수 없다. 과정을 거친 결과물 요리만을 전달하기 때문이다. 이와 동일한 맥락.

✅ Layered 아키텍처 적용시 장점

Layered로 이루어진 아키텍쳐를 코드에 적용하면 아래와 같은 장점을 누릴 수 있다.

  • 확장성 : 각 레이어가 서로 독립적이고 역할이 분명하므로 서로에게 끼치는 영향을 최소화하면서 확장하거나 수정할 수 있다.
  • 가독성 : 레이어가 완벽하게 분리되어 있고 역할이 명확하므로 가독성이 높아진다. 코드의 구조를 파악하기가 쉬울 뿐 아니라 각 레이어의 코드의 목적이 명확하고 범위도 확실하기 때문이다.
  • 재사용성 : 레이어가 독립적이므로 business layer는 여러 다른 presentation layer에 적용될 수 있다. Express 기반의 API 엔드포인트에 적용된 business layer가 다른 프레임워크를 사용한 엔드포인트에 사용될 수도 있다.
  • 테스트 가능성 : 이미 명확한 역할에 의거해 나뉘어 있으므로 각 레이어를 테스트하는 코드도 레이어가 나뉘는 것처럼 명확하게 나눌 수 있다. 또한 복잡한 로직이 아니기에 명확하고 범위가 확실한 기능만을 테스트하기 쉽다. 그리고 레이어들 자체가 또 다른 레이어에서 사용되는 구조이므로 테스트에서 호출해 테스트해보기가 쉽다.









3. Layered 아키텍처 적용하기


역할에 따른 폴더 및 파일 생성

인스타그램의 회원가입 기능에 layered architecture를 접목해보자. 현재 app.js 파일에 속해있는 signUp 함수가 하는 기능을 역할에 따라 나누어 서로 다른 모듈에서 진행하도록 분리한다. 각 레이어에 속하는 모듈의 이름을 아래와 같이 칭한다.

  • Presentation layer → userController.js
  • Business layer → userService.js
  • Persistence layer → userDao.js (Data Access Objective)

그러므로 app.js 단일 파일로만 존재하던 구조를 아래 형태로 분리해 준다.

westagram
├── node_modules
├── package.json
├── routes
│   ├── index.js
│   └── userRoute.js
├── services
│   └── userService.js
├── controllers
│   └── userController.js
├── models
│   └── userDao.js
└── app.js

역할에 따른 코드 분리

[그림6] 역할에 따른 회원가입 로직 분리

위 그림과 같이 app.js 에 작성 될 수 있는 signUp() 함수를 역할에 따라 각각 controller, service, model 모듈로 분리해준다. 먼저 기존의 코드를 살펴보면 아래와 같다.

// app.js

const express = require('express');

const cors = require('cors');
const morgan = require('morgan');
const dotenv = require("dotenv")
dotenv.config()

const { DataSource } = require('typeorm');

const myDataSource = new DataSource({
	  type: process.env.TYPEORM_CONNECTION,
    host: process.env.TYPEORM_HOST,
    port: process.env.TYPEORM_PORT,
    username: process.env.TYPEORM_USERNAME,
    password: process.env.TYPEORM_PASSWORD,
    database: process.env.TYPEORM_DATABASE
})

myDataSource.initialize()
    .then(() => {
        console.log("Data Source has been initialized!")
    })

const PORT = 3000;
const app = express();

app.use(cors());
app.use(morgan('combined'));
app.use(express.json());

// health check
app.get("/ping", (req, res) => {
  res.status(200).json({"message" : "pong"});
})

// user signup logic
app.post("/users/signup", async (req, res) => {
	const { name, email, profile_image, password } = req.body

	await myDataSource.query(
		`INSERT INTO users(
			name,
			email,
			profile_image,
			password
		) VALUES (?, ?, ?, ?);
		`,
		[ name, email, profile_image, password ]
	);
   res.status(201).json({ message : "successfully created" });
});

📍 App: 앱의 초기화

위의 코드 중 app.js 파일에서 유지되는 코드는 아래와 같다.

// app.js

const http = require("http");
const express = require('express');
const cors = require('cors');
const morgan = require('morgan');

const dotenv = require("dotenv")
dotenv.config()

const routes = require("./routes");

const app = express();

app.use(cors());
app.use(morgan('combined'));
app.use(express.json());
app.use(routes);

app.get("/ping", (req, res) => {
  res.json({ message: "pong" });
});

const server = http.createServer(app);
const PORT = process.env.PORT;

const start = async () => {
  try {
    server.listen(PORT, () => console.log(`Server is listening on ${PORT}`));
  } catch (err) {
    console.error(err);
  }
};

start();

📍 Routes : endpoint 경로 라우팅

이후 외부에서 들어노는 요청을 가장 먼저 맞이하여 하위 폴더로 안내하는 길잡이 역할을 하는 Router 파일이다. index.js 는 향후 확장성을 고려하여 생성될 수 있는 다양한 Router(예시: userRouter, productRouter 등)들을 한 곳에 모아 관리하는 역할을 한다.

//routes/index.js

const express = require("express");
const router = express.Router();

const userRouter = require("./userRouter");
router.use("/users", userRouter.router);

module.exports = router;
//routes/userRouter.js

const express = require('express');
const userController = require('../controllers/userController');

const router = express.Router();

router.post('/signup', userController.signUp);

module.exports = {
	router
}

📍 Controller (Presentation Layer)

다음은 API의 엔드포인트들을 정의하고 전송된 HTTP 요청(request)들을 읽어 들이는 로직을 구현하는 Controller다. Controller에서는 비즈니스 로직으로 흘러들어가야할 데이터들이 올바른 형태를 띄고 있는지 선검증 작업이 이루어 진다. 아래는 다양한 예시 중, 서버에서 원하는 특정 데이터의 ‘Key’ 값이 요청시 전해지지 않았을 때 발생하는 Key Error 를 사전에 에러처리로 검열한 모습이다.

//controller/userController.js

const userService = require('../services/userService');

const signUp = async (req, res) => {
  try {
    const { name, email, password, profileImage } = req.body;

    if ( !name || !email || !password || !profileImage ) {
      return res.status(400).json({ message: 'KEY_ERROR' });
    }

    await userService.signUp( name, email, password, profileImage );
    return res.status(201).json({
      message: 'SIGNUP_SUCCESS',
    });
  } catch (err) {
    console.log(err);
    return res.status(err.statusCode || 500).json({ message: err.message });
  }
};

module.exports = {
	signUp
}

📍 Service

다음은 실제 비즈니스 규칙(Rule)과 로직(Logic)들이 접목되는 Service 레이어의 코드다. 어플리케이션을 직접 다루는 운영자 입장에서 기획한 비즈니스 모델들이 접목 되어야 한다. 아래는 비밀번호의 조합이 특정 형태를 띄어야 하는 규칙을 위해 정규표현식을 도입한 모습이다.

//service/userService.js

const userDao = require('../models/userDao')

const signUp = async (name, email, password, profileImage) => {
    // password validation using REGEX
    const pwValidation = new RegExp(
      '^(?=.*[A-Za-z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{8,20})'
    );
    if (!pwValidation.test(password)) {
      const err = new Error('PASSWORD_IS_NOT_VALID');
      err.statusCode = 409;
      throw err;
    }
      const createUser = await userDao.createUser(
          name,
          email,
          password,
          profileImage
        );

        return createUser;
      };

  module.exports = {
      signUp
  }

📍 Models

다음은 실질적으로 데이터베이스와 소통하는 Model 레이어다. 아래는 typeorm을 이용하여 db에 연결한 후, 앞선 레이어들을 모두 무사히 통과한 데이터들을 db에 직접 Insert 하는 모습을 띄고 있다.

//models/userDao.js

const { DataSource } = require('typeorm');

const myDataSource = new DataSource({
	type: process.env.TYPEORM_CONNECTION,
    host: process.env.TYPEORM_HOST,
    port: process.env.TYPEORM_PORT,
    username: process.env.TYPEORM_USERNAME,
    password: process.env.TYPEORM_PASSWORD,
    database: process.env.TYPEORM_DATABASE
})

myDataSource.initialize()
  .then(() => {
    console.log("Data Source has been initialized!");
  })
  .catch((err) => {
    console.error("Error occurred during Data Source initialization", err);
	  myDataSource.destroy();
  });

const createUser = async ( name, email, password, profileImage ) => {
	try {
		return await myDataSource.query(
		`INSERT INTO users(
			name,
			email,
			password
			profile_image,
		) VALUES (?, ?, ?, ?);
		`,
		[ name, email, password, profileImage ]
	  );
	} catch (err) {
		const error = new Error('INVALID_DATA_INPUT');
		error.statusCode = 500;
		throw error;
	}
};

module.exports = {
  createUser
}

Layer dependency(의존성) 순서

**App → Router → Controller → Service → Models** 순으로 갈수록 데이터베이스의 접근에 가까워 진다. 또한, 각각의 파일에서 export 한 module들을 어느 파일들이 require하고 있는지 그 연결흐름을 잘 살펴보면, 상위 레이어에서 오직 하위 레이어로만 의존하는 방향성의 특성이 드러난다.

다음은 실제 Express를 이용한 서버 웹 개발시 Layered Pattern을 이루는 기본 구조다.

  • app.js | server.js: Express App 으로 서버를 여는 로직. 그리고 Express App 인스턴스를 만들고, 필요한 미들웨어를 붙이는 로직이다. 경우에 따라 app.js 와 server.js 에 각기 다른 기능을 유도하여 두가지 파일 모두를 유지할 수도 있고, 둘의 기능을 한데 모아 한 파일만을 유지할 수도 있다. 개발자의 의도에 맞게 파생되어질 수 있는 다양한 경우의 수에 유의하며 코드를 작성해야 한다.
  • routes: 라우팅(엔드 포인트 나누기) 로직을 담당한다.
  • controllers: 엔드포인트에 해당하는 함수 로직 - http 요청에 따른 에러 핸들링, service 로직에서 데이터를 받아와서 응답으로 내보내는 로직이다.
  • services: controller에서 넘겨받은 인자로 다양한 알고리즘(필터, 정렬 등..)을 처리해 데이터로 접근하는 로직이다.
  • models: 데이터베이스에 접근하기 위한 모델(DAO)이 정의되어 있는 폴더다.

✔️ 하위 모듈은 의존성 없이 다양한 레이어에서 사용될 수 있지만 반복되는 로직이기에 분리해 놓은 폴더다.

  • middlewares: 컨트롤러에 닿기 전에 반복되는 로직을 모듈화 해 놓는 폴더(ex. validateToken - 인증 / 인가)
  • utils: 의존성 없이 모든 레이어에서 공통적으로 자주 사용되는 로직을 모듈화 해 놓는 폴더(ex. errorGenerator)
  • .env: 프로젝트 내에서 사용할 환경 변수를 선언해 놓는 곳이며 샘플 양식은 .env.sample에 따로 기입하여 공유.
  • node_modules: 노드 패키지 모듈.
  • .gitignore: 위의 두 모듈을 깃이 관리하지 않도록 한다.
  • package.json: 노드 모듈을 관리하는 파일.

[그림7] 최종 완성 코드 폴더 구조









4. 마무리


  • Layered pattern은 기본적으로 세가지 layer(Presentation, Business, Persistence) 구조로 구성되어 있다.
  • Layered pattern은 ‘단방향 의존성’과 ‘관심사 분리’ 라는 두가지 핵심 원리에 기반한 개념이다.
  • 각각의 Layer들은 외부 요청을 처리하는 Controller - Service - Model의 상위 → 하위방향으로 의존성이 연결되어 있다.
profile
helloworld

0개의 댓글