소프트웨어 아키텍처 패턴 중 하나인 포트 어댑터 패턴(Port Adapter Pattern), 흔히 헥사고날 아키텍처(Hexagonal Architecture)로도 알려진 이 패턴은 시스템의 내부 로직과 외부 인터페이스를 명확히 분리하여 유연하고 확장 가능한 소프트웨어를 설계하는 데 중점을 둡니다. 이번 글에서는 포트 어댑터 패턴에서 자주 사용하는 디렉토리 및 파일 구조와 각 컴포넌트의 역할에 대해 상세히 알아보겠습니다.
포트 어댑터 패턴이란?
포트 어댑터 패턴(Port Adapter Pattern) 또는 헥사고날 아키텍처(Hexagonal Architecture)는 시스템의 내부 로직을 외부 세계와 분리하여 독립성을 유지하고, 변경에 강한 구조를 만드는 소프트웨어 아키텍처 패턴입니다. 이 패턴은 시스템을 여러 "포트(Ports)"와 "어댑터(Adapters)"로 나누어, 포트는 시스템의 기능을 외부에 노출하는 인터페이스 역할을 하고, 어댑터는 외부 시스템과의 실제 통신을 담당합니다.
헥사고날 아키텍처는 시스템의 중심(Core)을 외부 인터페이스와 구분하여, 내부 로직이 외부의 변화에 영향을 받지 않도록 설계합니다. 이는 테스트 용이성, 유지보수성, 확장성을 크게 향상시킵니다.
포트 어댑터 패턴의 장점
- 유연성: 외부 시스템과의 의존성이 최소화되어 새로운 기술이나 서비스로의 전환이 용이합니다.
- 테스트 용이성: 내부 로직을 외부 시스템과 독립적으로 테스트할 수 있어 단위 테스트가 간편해집니다.
- 유지보수성: 시스템의 각 구성 요소가 명확히 분리되어 있어 특정 부분의 변경이 전체 시스템에 미치는 영향을 최소화합니다.
- 확장성: 새로운 기능이나 서비스를 추가할 때 기존 시스템을 크게 변경할 필요 없이 어댑터를 추가하면 됩니다.
포트 어댑터 패턴의 디렉토리 및 파일 구조
포트 어댑터 패턴을 효과적으로 구현하기 위해서는 체계적이고 일관된 디렉토리 및 파일 구조가 필요합니다.
포트 어댑터 패턴의 디렉토리 구조는 다음과 같은 계층으로 구성됩니다:
/project-root
│
├── /core
│ ├── /domain
│ │ ├── /entities
│ │ ├── /value-objects
│ │ └── /repositories
│ │
│ ├── /application
│ │ ├── /services
│ │ └── /use-cases
│
├── /adapters
│ ├── /primary
│ │ ├── /controllers
│ │ └── /api
│ │
│ ├── /secondary
│ ├── /repositories
│ └── /external-services
│
├── /infrastructure
│ ├── /database
│ ├── /external-apis
│ └── /config
│
├── package.json
├── tsconfig.json
└── README.md
주요 디렉토리 설명
1. core
시스템의 핵심 로직과 비즈니스 규칙을 포함하는 디렉토리입니다. 이는 외부 인터페이스와 독립적으로 동작하며, 시스템의 "진짜" 기능을 구현합니다.
- domain: 비즈니스 도메인과 관련된 모든 것을 포함합니다.
- entities: 비즈니스 엔티티를 정의합니다. 예를 들어, 사용자, 주문 등.
- value-objects: 엔티티의 속성 중 변하지 않는 값 객체를 정의합니다. 예: 이메일, 주소 등.
- repositories: 도메인 엔티티의 저장소 인터페이스를 정의합니다.
- application: 비즈니스 로직을 실행하는 애플리케이션 서비스를 포함합니다.
- services: 도메인 로직을 조합하여 애플리케이션 서비스를 구현합니다.
- use-cases: 특정 비즈니스 시나리오를 구현하는 유스케이스를 포함합니다.
2. adapters
시스템의 내부 로직과 외부 세계를 연결하는 어댑터를 포함하는 디렉토리입니다.
- primary: 시스템 외부로부터의 입력을 처리하는 어댑터입니다.
- controllers: HTTP 요청을 처리하고 응답을 반환하는 컨트롤러를 포함합니다.
- api: REST API 또는 GraphQL API 등의 외부 인터페이스를 포함합니다.
- secondary: 시스템 내부 로직과 외부 시스템을 연결하는 어댑터입니다.
- repositories: 도메인 저장소 인터페이스의 실제 구현체를 포함합니다.
- external-services: 외부 API, 메시징 시스템 등과의 통신을 담당하는 서비스 구현체를 포함합니다.
3. infrastructure
시스템의 기술적 인프라를 관리하는 디렉토리입니다. 데이터베이스 설정, 외부 API 통합, 환경 설정 등을 포함합니다.
- database: 데이터베이스 연결 설정, 마이그레이션 스크립트 등을 포함합니다.
- external-apis: 외부 API와의 통합을 위한 설정 및 유틸리티를 포함합니다.
- config: 전체 시스템의 설정 파일을 포함합니다.
예시 디렉토리 구조
아래는 포트 어댑터 패턴을 적용한 예시 디렉토리 구조입니다:
/project-root
│
├── /core
│ ├── /domain
│ │ ├── /entities
│ │ │ └── user.entity.ts
│ │ ├── /value-objects
│ │ │ └── email.vo.ts
│ │ └── /repositories
│ │ └── user.repository.interface.ts
│ │
│ ├── /application
│ │ ├── /services
│ │ │ └── user.service.ts
│ │ └── /use-cases
│ │ └── create-user.use-case.ts
│
├── /adapters
│ ├── /primary
│ │ ├── /controllers
│ │ │ └── user.controller.ts
│ │ └── /api
│ │ └── user.routes.ts
│ │
│ ├── /secondary
│ ├── /repositories
│ │ └── user.repository.ts
│ └── /external-services
│ └── email.service.ts
│
├── /infrastructure
│ ├── /database
│ │ └── database.config.ts
│ ├── /external-apis
│ │ └── payment.gateway.ts
│ └── /config
│ └── app.config.ts
│
├── package.json
├── tsconfig.json
└── README.md
각 컴포넌트의 역할
포트 어댑터 패턴에서 각 디렉토리와 파일의 역할을 이해하는 것은 시스템을 효과적으로 설계하고 유지보수하는 데 필수적입니다. 아래에서는 주요 컴포넌트들의 역할을 상세히 설명하겠습니다.
Core
시스템의 핵심 로직을 포함하며, 외부 의존성 없이 독립적으로 동작합니다.
Domain
비즈니스 도메인과 관련된 모든 것을 정의합니다.
- Entities: 비즈니스 객체를 정의합니다. 예를 들어,
User
엔티티는 사용자 정보를 나타냅니다.
// core/domain/entities/user.entity.ts
export class User {
constructor(
public id: string,
public username: string,
public email: string
) {}
}
- Value Objects: 변하지 않는 값 객체를 정의합니다. 예를 들어,
Email
VO는 이메일 주소의 유효성을 보장합니다.
// core/domain/value-objects/email.vo.ts
export class Email {
constructor(public readonly address: string) {
if (!this.validateEmail(address)) {
throw new Error('Invalid email address');
}
}
private validateEmail(email: string): boolean {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return regex.test(email);
}
}
- Repositories: 도메인 엔티티의 저장소 인터페이스를 정의합니다.
// core/domain/repositories/user.repository.interface.ts
import { User } from '../entities/user.entity';
export interface IUserRepository {
findById(id: string): Promise<User | null>;
create(user: User): Promise<void>;
}
Application
비즈니스 로직을 실행하는 애플리케이션 서비스를 포함합니다.
- Services: 도메인 로직을 조합하여 애플리케이션 서비스를 구현합니다.
// core/application/services/user.service.ts
import { IUserRepository } from '../../domain/repositories/user.repository.interface';
import { User } from '../../domain/entities/user.entity';
import { Email } from '../../domain/value-objects/email.vo';
export class UserService {
constructor(private userRepository: IUserRepository) {}
async createUser(id: string, username: string, email: string): Promise<void> {
const user = new User(id, username, new Email(email).address);
await this.userRepository.create(user);
}
async getUser(id: string): Promise<User | null> {
return await this.userRepository.findById(id);
}
}
- Use Cases: 특정 비즈니스 시나리오를 구현하는 유스케이스를 포함합니다.
// core/application/use-cases/create-user.use-case.ts
import { UserService } from '../services/user.service';
export class CreateUserUseCase {
constructor(private userService: UserService) {}
async execute(id: string, username: string, email: string): Promise<void> {
await this.userService.createUser(id, username, email);
}
}
Adapters
시스템의 내부 로직과 외부 세계를 연결하는 어댑터를 포함합니다.
Primary Adapters
시스템 외부로부터의 입력을 처리하는 어댑터입니다.
- Controllers: HTTP 요청을 처리하고 응답을 반환하는 컨트롤러를 포함합니다.
// adapters/primary/controllers/user.controller.ts
import { Request, Response } from 'express';
import { CreateUserUseCase } from '../../../core/application/use-cases/create-user.use-case';
export class UserController {
constructor(private createUserUseCase: CreateUserUseCase) {}
async createUser(req: Request, res: Response) {
const { id, username, email } = req.body;
await this.createUserUseCase.execute(id, username, email);
res.status(201).send('User created successfully');
}
}
- API Routes: REST API 또는 GraphQL API 등의 외부 인터페이스를 포함합니다.
// adapters/primary/api/user.routes.ts
import { Router } from 'express';
import { UserController } from '../controllers/user.controller';
import { CreateUserUseCase } from '../../../core/application/use-cases/create-user.use-case';
import { UserService } from '../../../core/application/services/user.service';
import { UserRepository } from '../../secondary/repositories/user.repository';
const router = Router();
const userRepository = new UserRepository();
const userService = new UserService(userRepository);
const createUserUseCase = new CreateUserUseCase(userService);
const userController = new UserController(createUserUseCase);
router.post('/users', (req, res) => userController.createUser(req, res));
export default router;
Secondary Adapters
시스템 내부 로직과 외부 시스템을 연결하는 어댑터입니다.
- Repositories: 도메인 저장소 인터페이스의 실제 구현체를 포함합니다.
// adapters/secondary/repositories/user.repository.ts
import { IUserRepository } from '../../../core/domain/repositories/user.repository.interface';
import { User } from '../../../core/domain/entities/user.entity';
import { Database } from '../../../infrastructure/database/database.config';
export class UserRepository implements IUserRepository {
async findById(id: string): Promise<User | null> {
const result = await Database.query('SELECT * FROM users WHERE id = ?', [id]);
if (result.length === 0) return null;
const userData = result[0];
return new User(userData.id, userData.username, userData.email);
}
async create(user: User): Promise<void> {
await Database.execute('INSERT INTO users (id, username, email) VALUES (?, ?, ?)', [
user.id,
user.username,
user.email,
]);
}
}
- External Services: 외부 API, 메시징 시스템 등과의 통신을 담당하는 서비스 구현체를 포함합니다.
// adapters/secondary/external-services/email.service.ts
import nodemailer from 'nodemailer';
export class EmailService {
private transporter = nodemailer.createTransport({
service: 'Gmail',
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS,
},
});
async sendEmail(to: string, subject: string, text: string): Promise<void> {
await this.transporter.sendMail({
from: process.env.EMAIL_USER,
to,
subject,
text,
});
}
}
Infrastructure
시스템의 기술적 인프라를 관리하는 디렉토리입니다. 데이터베이스 설정, 외부 API 통합, 환경 설정 등을 포함합니다.
- Database: 데이터베이스 연결 설정, 마이그레이션 스크립트 등을 포함합니다.
// infrastructure/database/database.config.ts
import { createConnection } from 'typeorm';
export const Database = createConnection({
type: 'mysql',
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT || '3306', 10),
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
entities: ['core/domain/entities/*.ts'],
synchronize: true,
});
- External APIs: 외부 API와의 통합을 위한 설정 및 유틸리티를 포함합니다.
// infrastructure/external-apis/payment.gateway.ts
import axios from 'axios';
export class PaymentGateway {
private apiUrl = process.env.PAYMENT_API_URL;
async processPayment(amount: number, currency: string): Promise<any> {
const response = await axios.post(`${this.apiUrl}/payments`, { amount, currency });
return response.data;
}
}
- Config: 전체 시스템의 설정 파일을 포함합니다.
// infrastructure/config/app.config.ts
export const AppConfig = {
port: process.env.PORT || 3000,
env: process.env.NODE_ENV || 'development',
};
포트 어댑터 패턴 사용 시 고려사항
포트 어댑터 패턴은 많은 장점을 제공하지만, 몇 가지 고려사항도 존재합니다:
- 초기 설계 복잡성: 패턴의 구조가 복잡할 수 있어 초기 설계 시 신중한 계획이 필요합니다.
- 추가적인 추상화 계층: 인터페이스와 구현체 간의 추가적인 추상화 계층이 필요하여 코드량이 늘어날 수 있습니다.
- 팀의 이해도: 팀원들이 패턴의 개념과 구조를 충분히 이해하고 있어야 효과적으로 적용할 수 있습니다.
- 적절한 적용 범위: 모든 프로젝트에 무조건적으로 적용하기보다는, 규모와 복잡성에 맞게 적절히 적용하는 것이 중요합니다.
포트 어댑터 패턴은 시스템의 내부 로직과 외부 인터페이스를 명확히 분리하여 유연하고 확장 가능한 소프트웨어를 설계하는 데 매우 유용한 아키텍처 패턴입니다. core, adapters, infrastructure와 같은 명확한 디렉토리 구조를 통해 각 컴포넌트의 역할을 분리하고, 의존성을 효과적으로 관리함으로써 유지보수성과 확장성을 크게 향상할 수 있습니다.
패턴을 올바르게 구현하면, 시스템의 테스트 용이성, 코드 재사용성, 협업 효율성이 크게 향상되며, 변화하는 요구사항에 유연하게 대응할 수 있습니다. 다만, 초기에 빠른 개발이 필요한 경우에는 신중히 고려해보는 것이 좋습니다.
추가 자료
'개발일지 > 기타' 카테고리의 다른 글
스택(Stack): 효율적인 후입선출 데이터 관리 (0) | 2024.11.13 |
---|---|
데이터베이스에서 사용하는 자료구조 (4) | 2024.11.12 |
SQL과 NoSQL의 차이 (5) | 2024.11.10 |
모니터링의 중요성 : 성능 비교의 기회 (0) | 2024.11.08 |
효율적인 Monorepo 브랜치 전략: 안정적인 배포를 위한 가이드 (6) | 2024.11.07 |