Node.js로 배우는 클린 아키텍처 설계 방법
클린 아키텍처 원칙을 Node.js 서비스에 적용하는 방식을 설명. 엔티티·유스케이스·어댑터 계층으로 책임을 분리해 테스트와 유지보수성을 높이는 설명
목차
소개
서비스가 커질수록 코드베이스의 복잡도와 유지보수 비용이 증가한다. 클린 아키텍처는 각 계층의 책임을 분리해 이러한 문제를 완화한다. 이 글에서는 Node.js 환경에서 클린 아키텍처를 적용하는 핵심 개념과 실무 예제를 통해 구조화 방법을 설명한다. 초보자도 이해할 수 있도록 단계별로 풀어 설명하며, 예제 코드는 바로 적용 가능한 형태로 제공한다.
클린 아키텍처 핵심 개념
의존성 방향과 계층
클린 아키텍처의 기본 원칙은 안쪽 계층이 바깥쪽 계층에 의존하지 않는다는 점이다. 핵심 비즈니스 규칙(엔티티)과 애플리케이션 흐름(유스케이스)은 인프라나 프레임워크와 분리된다. 이를 통해 비즈니스 로직 변경이 외부 기술 선택에 영향을 받지 않도록 한다.
주요 계층
- Entities: 비즈니스 핵심 모델과 규칙
- Use Cases / Interactors: 애플리케이션의 흐름과 비즈니스 시나리오
- Interface Adapters: 컨트롤러, 리포지토리 인터페이스, DTO 변환
- Frameworks & Drivers: 데이터베이스, 웹 프레임워크, 외부 API
Node.js 서비스에 적용하기
디렉토리 구조 예시
src/
entities/
user.js
usecases/
createUser.js
adapters/
controllers/
userController.js
repositories/
userRepository.js
infra/
db/
postgresClient.js
app.js
간단한 코드 흐름 예제
아래 코드는 엔티티, 유스케이스, 어댑터(컨트롤러, 리포지토리)를 분리한 최소 예제다. 실제 서비스에서는 에러 처리, 로깅, DTO 검증 등이 추가되어야 한다.
// src/entities/user.js
class User {
constructor({ id, name, email }) {
this.id = id
this.name = name
this.email = email
}
}
module.exports = User
// src/usecases/createUser.js
// persistence는 인터페이스를 만족하는 구현체를 주입받음
class CreateUser {
constructor(persistence) {
this.persistence = persistence
}
async execute(userData) {
// 비즈니스 규칙 예: 이메일 중복 검사
const exists = await this.persistence.findByEmail(userData.email)
if (exists) throw new Error('Email already exists')
const user = await this.persistence.save(userData)
return user
}
}
module.exports = CreateUser
// src/adapters/repositories/userRepository.js
// DB 클라이언트를 활용한 실제 구현체
class UserRepository {
constructor(dbClient) {
this.db = dbClient
}
async findByEmail(email) {
const row = await this.db.query('SELECT * FROM users WHERE email=$1', [email])
return row.rows[0] || null
}
async save(userData) {
const result = await this.db.query(
'INSERT INTO users(name,email) VALUES($1,$2) RETURNING id,name,email',
[userData.name, userData.email]
)
return result.rows[0]
}
}
module.exports = UserRepository
// src/adapters/controllers/userController.js
const CreateUser = require('../../usecases/createUser')
class UserController {
constructor(userRepository) {
this.createUser = new CreateUser(userRepository)
}
async create(req, res) {
try {
const user = await this.createUser.execute(req.body)
res.status(201).json(user)
} catch (err) {
res.status(400).json({ error: err.message })
}
}
}
module.exports = UserController
// src/app.js
const express = require('express')
const dbClient = require('./infra/db/postgresClient')
const UserRepository = require('./adapters/repositories/userRepository')
const UserController = require('./adapters/controllers/userController')
const app = express()
app.use(express.json())
const userRepo = new UserRepository(dbClient)
const userController = new UserController(userRepo)
app.post('/users', (req, res) => userController.create(req, res))
module.exports = app
설계 상의 고려사항
- 인터페이스 중심 설계: 유스케이스는 구체 구현에 의존하지 않고 인터페이스를 통해 통신한다.
- 테스트 용이성: 각 계층을 독립적으로 단위 테스트할 수 있도록 설계한다.
- 작은 모듈화: 변경 범위를 좁히기 위해 모듈을 작게 쪼갠다.
- 적절한 추상화: 과도한 추상화는 오히려 복잡도를 높일 수 있으므로 균형을 유지한다.
Layered architecture Node.js 적용 팁
Layered architecture Node.js 구현 시에는 의존성 주입 패턴을 적절히 사용하면 테스트와 교체가 쉬워진다. 또한 DTO와 밸리데이션을 어댑터에서 처리해 내부 비즈니스 로직을 단순하게 유지한다. 로그와 모니터링은 프레임워크 계층에 두어 비즈니스 코드에 영향을 주지 않도록 분리한다.
결론
클린 아키텍처는 장기적인 유지보수성과 테스트 편의성을 크게 개선한다. 위 예시를 통해 Node.js 클린 아키텍처 예제 수준의 구조를 이해할 수 있다. 실제 프로젝트에는 보안, 성능, 에러 처리 등 추가 고려사항이 필요하며, 점진적으로 계층을 도입해 안정성을 확보하는 접근이 바람직하다.