Layered Architecture Pattern
Layered Architecture Pattern
도메인
- 개발자 대부분은 비즈니스 프로세스를 개선하거나 자동화하기 위해 일한다. 도메인은 이런 프로세스가 지원하는 활동을 의미한다.
- 가구 판매 회사의 도메인은 구매 및 조달, 제품 설계, 물류 및 배달 등 다른 분야를 뜻할 수 있습니다.
도메인 (Domain)의 예시
- 개발자 입장에서 온라인 서점을 구현해야 할 소프트웨어의 대상이 됩니다.
- 온라인 서점 = 소프트웨어로 해결하고자 하는 문제의 영역 = 도메인 (Domain)
- 한 도메인은 다시 하위 도메인으로 나눌 수 있습니다.
- 도메인 구성 : 주문, 회원, 혜택, 결제, 배송, 정산, 카탈로그, 리뷰
- 하위 도메인은 다른 하위 도메인과 연동하여 완전한 기능을 제공합니다.
- ex) 고객 → 물건 주문 → 결제 → 배송 → 혜택 제공
- 하위 도메인을 어떻게 구성할 지 여부는 상황에 따라 달라집니다.
- B2B(Business-to-Business), B2C(Business to Consumer)
도메인 모델
-
도메인 모델이란 유용한 특성을 포함하는 프로세스나 현상의 지도(Map)를 뜻합니다. 도메인 모델은 비즈니스를 수행할 사람이 자신의 비즈니스에 대해 마음속에 가지고 있는 지도와 같습니다.
-
도메인 모델이란 특정 도메인을 개념적으로 정리한 모델입니다.
-
도메인 모델은 사용할 개체를 기억하기 쉬운 이름(식별자)을 부여해 대상을 쉽게 공유할 수 있게 한다.
-
만약 누군가가 저희에게 공을 던진다면 우리는 무의식적으로 이 공의 움직임을 예측해 회피할 것입니다.
- 우리들의 머릿속에 이미 공간상 물체가 움직이는 방식에 대한 모델이 있어서 움직임을 무의식적으로 예측할 수 있습니다.
도메인 모델링 (Domain Modeling)의 종류
- 엔티티 (Entity)
- 값 객체 (Value object)
- 도메인 서비스 (Domain service)
엔티티
-
엔티티(Entity)는 실제 DB 테이블과 연관되어 있는 핵심 클래스이고, 엔티티를 기준으로 테이블이 생성되고 DB 스키마가 변경됩니다.
ex) Sequelize Models -
엔티티를 **요청(Request)**이나 **응답값(Response)**으로 전달하는 클래스로 사용하면 안됩니다.
-
엔티티 내부의 속성이 변경되더라도 여전히 동일한 엔티티로 남아있습니다.
-
엔티티는 시간에 따라 변하는 속성이 포함될 수 있습니다.
-
어떤 요소가 엔티티를 유일하게 식별하는지 정의하는 것 또한 중요합니다.
- 보통 이름이나 참조 번호 등을 사용합니다.
- ex)
User
클래스의userId
아키텍처 패턴
- 아키텍처 패턴은 소프트웨어의 구조를 구성하기위한 가장 기본적인 토대를 제시합니다.
- 아키텍처 패턴은 각각의 시스템들과 그 역할이 정의되어 있고, 여러 시스템 사이의 관계와 규칙 등이 포함되어 있습니다.
- 검증된 구조로 개발을 진행하기 때문에 안정적인 개발이 가능합니다.
- 아키텍처 패턴을 도입할 경우 도메인이 복잡할수록 모델이나 코드를 더 쉽게 변경할 수 있다는 측면에서 큰 이익을 얻을 수 있습니다.
대표적인 아키텍처 패턴
-
저장소 패턴 (Repository pattern)
- 영속적인 저장소에 대한 추상화
-
서비스 계층 패턴 (Service layer pattern)
- 유스 케이스(Usecase)의 시작과 끝을 명확하게 정의하기 위한 패턴
-
작업 단위 패턴 (Unit of work pattern)
- 원자적 연산을 제공합니다.
-
애그리게이트 패턴 (Aggregate pattern)
- 데이터 정합성을 강화하기 위한 패턴
-
아키텍처 패턴을 도입하기 전에 고민해야 할 것
- 아키텍처 패턴이 주는 이익과 비용에 대해 확실한 이유가 있어야합니다.
- 해당하는 아키텍처 패턴을 채택했을 때 어떤 장단점이 존재하는지 명확하게 인지해야 합니다.
- 여러 계층을 추가하기 위해 들이는 노력과 시간을 투자할 만한 가치가 있을 정도로 어플리케이션과 도메인이 복잡한 경우에만 아키텍처 패턴을 도입해야 합니다.
계층형 아키텍처 패턴
- 계층형 아키텍처 패턴(Layered Architecture Pattern)은 계층을 분리해서 관리하는 아키텍처 패턴이고, 현재 가장 흔하게 사용되고 있는 아키텍처 패턴 중 하나입니다. 단순하고 대중적이면서 비용도 적게 들어 모든 어플리케이션의 사실상 표준 아키텍처입니다.
어떤 아키텍처 패턴을 도입할지 확신이 없을 때에는 계층형 아키텍처 패턴은 좋은 선택지가 될 수 있습니다.
계층형 아키텍처 패턴은 어떤 경우든 계층을 분리해서 유지하고, 각 계층이 자신의 바로 아래 계층에만 의존하게 만드는 것이 목표입니다.
계층화의 핵심은 각 계층은 응집도(Cohesion)가 높으면서, 다른 계층과는 낮은 결합도(Coupling)를 가지고 있어야합니다.
여기서 상위 계층은 하위 계층을 사용할 수 있지만, 하위 계층은 자신의 상위 계층에 누가 있는지 알 수 없고, 사용할 수 조차 없도록 구성해야합니다.
일반적으로 계층형 아키텍처 패턴의 경우 규모가 작은 어플리케이션의 경우 3개, 크고 복잡한 경우는 그 이상의 계층으로 구성됩니다.
저희가 알아볼 아키텍처 패턴은 **3계층 아키텍처(3-Layered Architecture)**입니다.
3계층 아키텍처에서 구성되는 각각의 **계층(Layer)**는 아래와 같습니다.
- 프레젠테이션 계층 (Presentation Layer)
- 비즈니스 로직 계층 (Business Logic Layer)
- 데이터 엑세스 계층 (Data Access Layer) | 영속 계층(Persistence Layer)
-
계층형 아키텍처 패턴의 장점 저희는 이렇게 계층별로 아키텍처를 분리했을때 아래의 장점을 얻을 수 있습니다.
- 관심사를 분리하여 현재 구현하려하는 코드를 명확하게 인지할 수 있습니다.
- 각 계층별로 의존성이 낮아 모듈을 교체하더라도 코드 수정이 용이합니다.
- 각 계층별로 단위 테스트를 작성할 수 있어 테스트 코드를 조금 더 용이하게 구성할 수 있습니다.
-
3계층 아키텍처 (3-Layered Architecture)
3-Layered Architecture는 아래의 3가지의 처리과정을 이용해서 구현합니다.
- Controller : 어플리케이션의 가장 바깥 부분, 요청/응답을 처리함.
- 클라이언트의 요청을 처리 한 후 서버에서 처리된 결과를 반환해주는 역할을 합니다.
- Service : 어플리케이션의 중간 부분, 실제 중요한 작동이 많이 일어나는 부분
- 아키텍처의 가장 핵심적인 비즈니스 로직이 수행되는 부분입니다.
- Repository : 어플리케이션의 가장 안쪽 부분, DB와 맞닿아 있음.
- 실제 데이터베이스의 데이터를 사용하는 계층입니다.
- Controller : 어플리케이션의 가장 바깥 부분, 요청/응답을 처리함.
3-Layered Architecture에서는 아래의 플로우를 기반으로 로직이 수행됩니다.
- **클라이언트(Client)**가 **요청(Request)**을 보냅니다.
- **요청(Request)**을 URL에 알맞은 컨트롤러(**Controller)**가 수신 받습니다.
- 컨트롤러(**Controller)**는 넘어온 요청을 처리하기 위해 **서비스(Service)**를 호출합니다.
- **서비스(Service)는 필요한 데이터를 가져오기위해 저장소(Repository)**에게 데이터를 요청합니다.
- **서비스(Service)**는 **저장소(Repository)**에서 가져온 데이터를 가공하여 컨트롤러(**Controller)**에게 데이터를 넘깁니다.
- 컨트롤러(**Controller)**는 **서비스(Service)**의 **결과물(Response)**을 **클라이언트(Client)**에게 전달해줍니다.
- 👉 서버 개발자들은 서버에서의 처리과정이 대부분 비슷하다는 걸 깨닫고, 처리 과정을 크게 3개로 분리하였고 Controller, Service, Repository 입니다. 각 역할 별로 하는 일을 정리해 보겠습니다.
Controller
- 클라이언트의 요청을 받음
- 요청에 대한 처리는 서비스에게 전담
- 클라이언트에게 응답
Service
- 사용자의 요구사항을 처리 ('비즈니스 로직') 하는 실세!
- 현업에서는 서비스 코드가 계속 비대해짐
- DB 정보가 필요할 때는 Repository 에게 요청
Repository
- DB 관리 (연결, 해제, 자원 관리)
- DB CRUD 작업 처리
최종적으로 다음과 같은 그림이 됩니다.
구현
컨트롤러 (Controller)
- 컨트롤러(Controller)란 클라이언트의 요청을 처리 한 후 서버에서 처리된 결과를 반환해주는 역할을 합니다.
**컨트롤러(Controller)**는 추가적으로 아래의 역할을 가지고 있습니다.
- 클라이언트의 요청(Request)을 수신합니다.
- 요청(Request)에 들어온 데이터 및 내용을 검증합니다.
- 서버에서 수행된 결과를 클라이언트에게 반환(Response)합니다.
프레젠테이션 계층(Presentation Layer) 이란?
-
3계층 아키텍처 패턴에서는 프레젠테이션 계층(Presentation Layer)이라고 표현되며, 대표적으로는 컨트롤러(Controller)로 사용됩니다. 사용자(클라이언트)가 서버에 요청(Request)를 하게되면 가장 먼저 만나게 되는 계층입니다.
-
**하위 계층(서비스 계층, 저장소 계층)**에서 발생하는 **예외(Exception)**를 처리 합니다.
-
클라이언트가 전달한 데이터에 대해 유효성을 검증하는 기능을 수행합니다.
-
클라이언트의 요청을 처리한 후 서버에서 처리된 결과를 반환합니다.
Express로 구현하는 컨트롤러
- 클라이언트의 요청을 받음
- 요청에 대한 처리는 서비스에게 전담
- 클라이언트에게 응답
위에서 배운 내용을 바탕으로 컨트롤러(Controller)를 구현할 것입니다. 프로젝트를 시작하기 전 3계층 아키텍처 프로젝트에서 어떤 API를 만들지 확인해보겠습니다.
Express에서는 컨트롤러와 라우터를 연결하기 위해서 express.Router에서 특정 URI와 HTTP Method를 요청받았을 때 컨트롤러로 요청된 내용을 전달 해줘야합니다.
routes 폴더에서 posts.routes.js 라는 파일을 만들어 PostsConrtoller와 연결하도록 구성합니다.
// routes/posts.routes.js
const express = require('express');
const router = express.Router();
// 컨트롤러 모듈을 가져온다.
const PostsController = require('../controllers/posts.controller');
// 컨트롤러 모듈을 생성한다.
const postsController = new PostsController();
// get 메소드로 요청이 온 경우 포스트들을 보내준다.
// post 메소드로 요청이 온 경우 포스트를 생성한다.
router.get('/', postsController.getPosts);
router.post('/', postsController.createPost);
module.exports = router;
// controllers/posts.controller.js
const PostService = require('../services/posts.service');
// Post의 컨트롤러(Controller)역할을 하는 클래스
class PostsController {
postService = new PostService(); // Post 서비스를 클래스를 컨트롤러 클래스의 멤버 변수로 할당합니다.
getPosts = async (req, res, next) => {
// 서비스 계층에 구현된 findAllPost 로직을 실행합니다.
const posts = await this.postService.findAllPost();
res.status(200).json({ data: posts })
}
createPost = async (req, res, next) => {
const { nickname, password, title, content } = req.body;
// 서비스 계층에 구현된 createPost 로직을 실행합니다.
const createPostData = await this.postService.createPost(nickname, password, title, content);
res.status(201).json({ data: createPostData });
}
}
module.exports = PostsController;
await this.postService.findAllPost();
는 PostsController
클래스에서 멤버 변수로 정의한 postService
에서 findAllPost
메소드를 실행하는 코드입니다.
컨트롤러에서는 하위 계층이 어떠한 내부 구조를 가지고 있는지 신경쓰지 않고, 외부에 드러나 있는 메소드를 호출하기만 합니다. 이것이 가능한 이유는 **추상화(Absctraction)**의 특성 덕분입니다.
PostsController
에서는 들어온 요청(Request)을 처리하기 위해 PostService
를 호출하도록 구현하였습니다. 실제로 컨트롤러에서 비즈니스 로직을 수행하지 않고, 클라이언트의 **요청(Request)**을 바로 서비스 계층으로 전달 하도록 구현한 것을 확인 할 수 있습니다.
PostController에서는 클라이언트의 요청(Request)을 서비스 계층으로 데이터를 전달하는 것을 확인할 수 있습니다. 서비스 계층에서 어떠한 내부 구조를 통해 비즈니스 로직을 수행하는 것인지 상위 계층인 컨트롤러에게는 중요하지 않습니다.
Express로 구현하는 서비스
-
서비스 계층(Service Layer)이란 비즈니스 로직 계층(Business logic layer)이라고도 불리는데요 아키텍처의 가장 핵심적인 비즈니스 로직을 수행하고 실제 사용자(클라이언트)가 원하는 요구사항을 구현하는 계층입니다.
-
**프레젠테이션 계층(Presentation Layer)**과 데이터 엑세스 계층(Data Access Layer) 사이에서 중간 다리 역할을 하며 서로 다른 두 계층이 직접 통신하지 않게 만들어 줍니다.
-
**서비스(Service)**는 데이터가 필요할 때 **저장소(Repository)**에게 데이터를 요청합니다.
-
어플리케이션의 규모가 커지면 커질수록 서비스의 역할 및 코드또한 점점 더 커지게 됩니다.
-
어플리케이션의 핵심적인 비즈니스 로직을 수행하여 클라이언트들의 요구사항을 반영하여 원하는 결과를 반환해주는 계층입니다.
서비스 계층의 장점
각각의 **유스 케이스(Use Case)**와 **워크플로우(Workflow)**를 명확히 정의할 때 도움이 됩니다.
- **저장소(Repository)**에게 얻을 필요가 있는 데이터가 무엇인지 이해할 수 있습니다.
- 어떤 사전 검사와 현재 상태 검증을 필수적으로 해야하는 것인지 이해할 수 있습니다.
- 어떤 내용을 저장해야 하는지 이해할 수 있습니다.
→ 유스 케이스(Use Case)에 대해 자세히 알고 싶다면 여기를 클릭하세요!
- 비즈니스 로직을 API 뒤에 감췄기 때문에 서비스 계층의 코드를 자유롭게 리팩터링할 수 있습니다.
- 저장소 패턴(Repository Pattern) 및 **가짜 저장소(Fake Repository)**와 조합하면 높은 수준의 테스트를 작성할 수 있습니다.
서비스 계층의 단점
- 서비스 계층또한 다른 추상화 계층에 불과합니다.
- 서비스 계층에 너무 많은 기능을 넣으면 **빈약한 도메인 모델(Anemic Domain Model)**과 같은 안티 패턴이 생길 수 있습니다.
구현
Service
- 사용자의 요구사항을 처리 ('비즈니스 로직') 하는 실세!
- 현업에서는 서비스 코드가 계속 비대해짐
- DB 정보가 필요할 때는 Repository 에게 요청
우리는 이전에 PostsController
가 PostService
에게 클라이언트의 **요청(Request)**을 전달하는 것을 확인하였습니다.
이번 **서비스 계층(Service Layer)**에서는 비즈니스 로직의 수행 방법과 **저장소(Repository)**에게 데이터를 어떤방식으로 요청하는지 확인해보도록 하겠습니다.
// services/posts.service.js
const PostRepository = require('../repositories/posts.repository');
class PostService {
postRepository = new PostRepository();
findAllPost = async () => {
// 저장소(Repository)에게 데이터를 요청합니다.
const allPost = await this.postRepository.findAllPost();
// 호출한 Post들을 가장 최신 게시글 부터 정렬합니다.
// 오름차순
allPost.sort((a, b) => {
return b.createdAt - a.createdAt;
})
// 비즈니스 로직을 수행한 후 사용자에게 보여줄 데이터를 가공합니다.
return allPost.map(post => {
return {
postId: post.postId,
nickname: post.nickname,
title: post.title,
createdAt: post.createdAt,
updatedAt: post.updatedAt
}
});
}
createPost = async (nickname, password, title, content) => {
// 저장소(Repository)에게 데이터를 요청합니다.
const createPostData = await this.postRepository.createPost(nickname, password, title, content);
// 비즈니스 로직을 수행한 후 사용자에게 보여줄 데이터를 가공합니다.
return {
postId: createPostData.null,
nickname: createPostData.nickname,
title: createPostData.title,
content: createPostData.content,
createdAt: createPostData.createdAt,
updatedAt: createPostData.updatedAt,
};
}
}
module.exports = PostService;
이번 **서비스 계층(Service Layer)**에서는 PostService
가 PostRepository
의 findAllPost, createPost
메소드를 호출한 것을 확인할 수 있습니다. 해당 코드는 서비스가 비즈니스 로직을 수행하기 위해 필요한 데이터를 **저장소 계층(Repository Layer)**에게 요청하여 가져오는 것을 확인 할 수 있습니다.
그리고 서비스에서 특별한 점은 return allPost.map(post => {});
와 같이 데이터를 가공하는 코드인데요, 만약 Repository에서 데이터를 가져와 가공하지 않고 클라이언트에게 전달할 경우 사용자의 Password같이 알아서는 안될 정보까지 전달되게 되어 보안성이 떨어지는 결과를 낳게됩니다.
Express로 구현하는 저장소
-
저장소 계층(Repository Layer)이란 데이터 엑세스 계층(Data Access Layer)이라고도 불리는데요 대표적으로 Database와 관련된 작업을 수행하는 계층입니다.
-
모든 데이터가 Memory상에 존재하는 것처럼 가정해 데이터 접근과 관련된 세부 사항을 감춥니다
-
대표적인 저장소 계층의 메소드
- add() : 새 원소를 저장소에 추가합니다.
- get() : 이전에 추가한 원소를 저장소에서 가져옵니다.
-
저장소 계층을 구현했을 때 데이터를 저장하는 방법을 더 쉽게 변경할 수 있고, 테스트 코드 작성시 **가짜 저장소(Mock Repository)**를 제공하기가 더 쉬워집니다.
-
어플리케이션의 다른 계층에서는 저장소의 세부 사항이 어떤 방식으로 구현되어 있더라도 영향을 받지 않습니다. → 객체 지향의 개념 중에서 추상화(Abstraction)와 관계가 있습니다.
-
저장소 계층은 데이터 저장소를 간단히 추상화한 것으로 이 패턴을 사용하면 모델 계층과 데이터 계층을 분리할 수 있습니다.
장점
- 모델과 인프라에 대한 사항을 완전히 분리했기 때문에 **단위 테스트(Unit test)**를 위한 **가짜 저장소(Fake Repository)**를 쉽게 만들 수 있습니다.
- 도메인 모델을 미리 작성하면 처리해야 할 비즈니스 문제에 더 잘 집중할 수 있다.
- 접근 방식을 바꾸고 싶을 때 외래키나 마이그레이션 등을 염려하지 않고 모델에 반영할 수 있다.
- 객체를 테이블에 매핑하는 과정을 원하는 대로 제어할 수 있어서 DB 스키마를 단순화할 수 있다.
- 저장소 계층에 ORM을 사용하면 필요할 때 MySQL과 Postgres와 같이 DB를 서로 바꾸기 쉬워집니다.
단점
-
저장소 계층이 없더라도 ORM이 어느 정도 (모델과 저장소의) 결합을 완화시켜줍니다.
-
ORM 매핑을 수동으로 하려면 개발 코스트가 더욱 소모됩니다.
→ 여기서 설명하는 ORM은 저희가 이전에 사용한 Sequelize와 같은 라이브러리를 말합니다.
구현
- DB 관리 (연결, 해제, 자원 관리)
- DB CRUD 작업 처리
3계층 아키텍처의 마지막 계층인 **저장소 계층(Repository Layer)**입니다!
이전 코드에서 **서비스 계층(Service Layer)**인 PostServices
에서 PostRepository
를 호출하여 데이터를 요청하는 것을 확인 할 수 있었는데요, **저장소 계층(Repository Layer)**에서는 데이터베이스의 데이터를 어떠한 방식으로 가져와 상위 계층에게 반환하는지 확인해보도록 하겠습니다.
// repositories/posts.repository.js
const { Posts } = require('../models');
class PostRepository {
findAllPost = async () => {
// ORM인 Sequelize에서 Posts 모델의 findAll 메소드를 사용해 데이터를 요청합니다.
const posts = await Posts.findAll();
return posts;
}
createPost = async (nickname, password, title, content) => {
// ORM인 Sequelize에서 Posts 모델의 create 메소드를 사용해 데이터를 요청합니다.
const createPostData = await Posts.create({ nickname, password, title, content });
return createPostData;
}
}
module.exports = PostRepository;