코드가 커지면 관심사 분리가 필요하다. HTTP 요청 처리, 비즈니스 로직, DB 접근이 한 클래스에 섞이면 변경의 영향 범위를 예측할 수 없다. 레이어드 아키텍처는 기술적 책임 기준으로 코드를 수평 계층으로 나누어 이 문제를 해결한다.
핵심은 계층 분리 자체가 아니라 의존성 방향의 제어다.
전통적 3계층 구조
가장 기본적인 형태는 Presentation, Business Logic, Data Access 3계층이다. 웹 요청을 받아서 비즈니스 로직을 실행하고 DB에 저장하는 흐름을 반영한다.
의존성은 위에서 아래로 흐른다. Presentation이 Business Logic을, Business Logic이 Data Access를 호출한다.
문제는 비즈니스 로직이 데이터 접근 기술에 직접 의존한다는 점이다. DB를 MySQL에서 MongoDB로 바꾸거나, 외부 API 호출 방식을 변경하면 비즈니스 로직까지 수정해야 한다. 비즈니스 규칙은 변하지 않았는데 기술 선택이 바뀌었다는 이유로 비즈니스 로직까지 수정하게 된다.
4계층 구조
이 문제를 해결하기 위해 계층을 더 세분화하고 의존성 방향을 재설계한다.
Presentation: HTTP 요청/응답을 처리한다. 라우팅, 요청 파싱, 응답 직렬화가 이 계층의 책임이다. 컨트롤러, 인터셉터, 예외 필터가 여기에 속한다.
Application: 비즈니스 로직을 조율한다. 여러 서비스를 호출하고, 데이터를 변환하고, 트랜잭션 경계를 정의한다. 이 계층이 유스케이스의 진입점이다. 인프라 계층과의 경계를 위한 인터페이스도 이 계층에 정의한다.
Domain: 비즈니스 엔티티와 규칙을 담는다. 기술적 의존성이 없는 순수한 모델이다. 다른 계층의 변경이 이 계층에 영향을 주지 않는다.
Infra: 기술적 구현을 담당한다. DB 접근, 외부 API 호출, 메시징, 캐시 등 외부 시스템과의 연동이 모두 여기에 속한다. Application 계층에 정의된 인터페이스를 구현한다.
의존성 방향과 DIP
3계층 구조에서는 의존성이 Presentation → Business → Data 순서로 아래를 향한다. 4계층 구조에서는 이 방향을 뒤집는다.
핵심은 의존성 역전 원칙, DIP이다. 상위 계층이 하위 계층에 직접 의존하지 않는다. 대신 상위 계층이 인터페이스를 정의하고, 하위 계층이 그 인터페이스를 구현한다.
Presentation → Application → Domain
↑
Infra (인터페이스 구현)
Application 계층은 “주문을 저장한다"는 인터페이스만 알고 있다. 그 저장이 MySQL인지 MongoDB인지는 모른다. Infra 계층이 그 인터페이스를 구현하고, DI 컨테이너가 둘을 연결한다.
이 구조에서 Domain은 어디에도 의존하지 않는다. 가장 안정적인 계층이다. Infra는 Application이 정의한 인터페이스에 의존한다. 의존성 화살표가 핵심 비즈니스 로직을 향해 수렴한다.
코드 예시
인터페이스를 Application 계층에 정의하고, 구현체를 Infra 계층에 둔다.
// application/abstraction/order-repository.ts
interface OrderRepository {
save(order: Order): Promise<Order>;
findById(id: string): Promise<Order | null>;
}
// infra/persistence/order-repository-impl.ts
class OrderRepositoryImpl implements OrderRepository {
constructor(private readonly db: Database) {}
async save(order: Order): Promise<Order> {
const entity = OrderEntity.from(order);
await this.db.save(entity);
return entity.toDomain();
}
async findById(id: string): Promise<Order | null> {
const entity = await this.db.findById(id);
return entity?.toDomain() ?? null;
}
}
Application 계층의 서비스는 인터페이스에만 의존한다.
// application/service/order-service.ts
class OrderService {
constructor(private readonly orderRepository: OrderRepository) {}
async createOrder(request: CreateOrderDto): Promise<OrderDto> {
const order = Order.create(request);
const saved = await this.orderRepository.save(order);
return OrderDto.from(saved);
}
}
DB를 교체하거나 테스트에서 mock을 주입할 때 Infra 계층의 구현체만 바꾸면 된다. OrderService는 변경할 필요가 없다.
적합한 경우
레이어드 아키텍처는 단일 도메인에 비즈니스 로직이 집중된 서비스에 적합하다. 하나의 API가 복잡한 처리 파이프라인을 실행하는 구조에서 계층별 책임 분리가 효과를 발휘한다.
기술 변경이 잦은 환경에서도 유리하다. 필터링 엔진을 내부 구현에서 외부 라이브러리로 교체하거나, DB를 전환하는 상황에서 Infra 계층만 수정하면 된다. Application 계층의 인터페이스가 변경의 전파를 차단한다.
한계
도메인이 여러 개인 서비스에서는 적합하지 않을 수 있다. 사용자, 상품, 주문이 각각 독립된 도메인이라면 수평 계층보다 도메인별 수직 분할이 응집도를 높인다.
서비스 클래스가 비대해지는 문제도 있다. 하나의 Application 서비스에 많은 유스케이스가 모이면 코드가 길어진다. 이 경우 유스케이스별로 서비스를 분리하거나 파사드 패턴으로 외부 인터페이스를 줄이는 방법이 있다.
계층 간 DTO 변환 비용도 발생한다. Presentation DTO, Application DTO, Domain 엔티티, Infra 엔티티가 각각 존재하면 변환 코드가 늘어난다. 계층 간 경계의 이점과 변환 비용 사이에서 균형을 잡아야 한다.
코드가 커지면 관심사 분리가 필요하다. 레이어드 아키텍처는 그 분리를 기술적 책임 기준으로 수행한다. 계층을 나누는 것 자체보다 의존성이 어디를 향하는지가 더 중요하다.