Vertical Slice Architecture
수직 슬라이스 아키텍처
기능별로 코드를 수직 분할하는 아키텍처
수직 슬라이스 아키텍처
기능별로 코드를 수직 분할하는 아키텍처
Vertical Slice Architecture(수직 슬라이스 아키텍처)는 Jimmy Bogard가 제안한 소프트웨어 아키텍처 패턴으로, 전통적인 계층형 아키텍처(Layered Architecture)의 대안입니다. 계층형 아키텍처가 Controller, Service, Repository 등 기술적 관심사별로 코드를 수평 분할하는 반면, Vertical Slice는 "상품 목록 조회", "주문 생성", "결제 처리" 등 비즈니스 기능(Feature) 단위로 수직 분할합니다. 각 슬라이스는 해당 기능에 필요한 모든 계층(UI, 비즈니스 로직, 데이터 접근)을 포함합니다.
이 아키텍처의 핵심 원칙은 "결합도는 낮추고 응집도는 높인다"입니다. 전통적인 계층형에서는 하나의 기능을 수정하려면 Controller, Service, Repository, DTO 등 여러 계층에 걸쳐 파일을 수정해야 합니다. 반면 Vertical Slice에서는 관련된 모든 코드가 하나의 폴더(또는 파일) 안에 있어 변경 범위가 명확하고 다른 기능에 영향을 주지 않습니다. 이는 마이크로서비스에서 서비스 간 독립성을 추구하는 것과 비슷한 맥락입니다.
Vertical Slice는 CQRS(Command Query Responsibility Segregation) 패턴과 자연스럽게 결합됩니다. 각 슬라이스를 Command(쓰기 작업)와 Query(읽기 작업)로 분리하면 각각을 최적화할 수 있습니다. 예를 들어 Query는 Entity Framework로 직접 DB를 조회하여 DTO를 반환하고, Command는 도메인 모델을 거쳐 비즈니스 규칙을 검증할 수 있습니다. .NET 생태계에서는 MediatR 라이브러리가 Request/Handler 패턴을 통해 이러한 구조를 쉽게 구현하도록 돕습니다.
Vertical Slice Architecture는 Clean Architecture나 Onion Architecture를 대체하는 것이 아니라 보완하는 관점으로 볼 수 있습니다. 도메인 핵심 로직은 여전히 순수하게 유지하면서, 애플리케이션 계층의 Use Case 구성을 Feature 중심으로 재조직하는 것입니다. 특히 스타트업이나 MVP 개발에서는 과도한 추상화 없이 빠르게 기능을 구현하고, 필요에 따라 점진적으로 공통 로직을 추출하는 방식이 효과적입니다. 단, 슬라이스 간 공통 로직이 많아지면 중복 코드가 발생할 수 있어 적절한 리팩토링이 필요합니다.
# 전통적인 계층형 구조 (Horizontal Layers)
src/
├── Controllers/
│ ├── ProductsController.cs
│ ├── OrdersController.cs
│ └── UsersController.cs
├── Services/
│ ├── ProductService.cs
│ ├── OrderService.cs
│ └── UserService.cs
├── Repositories/
│ ├── ProductRepository.cs
│ ├── OrderRepository.cs
│ └── UserRepository.cs
└── Models/
├── Product.cs
├── Order.cs
└── User.cs
# Vertical Slice 구조 (Feature 기반)
src/
├── Features/
│ ├── Products/
│ │ ├── GetProductList/
│ │ │ ├── GetProductListQuery.cs
│ │ │ ├── GetProductListHandler.cs
│ │ │ └── ProductListDto.cs
│ │ ├── GetProductDetail/
│ │ │ ├── GetProductDetailQuery.cs
│ │ │ └── GetProductDetailHandler.cs
│ │ └── CreateProduct/
│ │ ├── CreateProductCommand.cs
│ │ ├── CreateProductHandler.cs
│ │ └── CreateProductValidator.cs
│ ├── Orders/
│ │ ├── CreateOrder/
│ │ │ ├── CreateOrderCommand.cs
│ │ │ ├── CreateOrderHandler.cs
│ │ │ └── OrderCreatedEvent.cs
│ │ └── GetOrderHistory/
│ │ ├── GetOrderHistoryQuery.cs
│ │ └── GetOrderHistoryHandler.cs
│ └── Users/
│ ├── RegisterUser/
│ └── GetUserProfile/
├── Domain/
│ ├── Product.cs
│ ├── Order.cs
│ └── User.cs
└── Infrastructure/
├── AppDbContext.cs
└── DependencyInjection.cs
// Features/Products/GetProductList/GetProductListQuery.cs
using MediatR;
namespace MyApp.Features.Products.GetProductList;
// Query: 요청 정의
public record GetProductListQuery(
string? Category,
int Page = 1,
int PageSize = 20
) : IRequest<GetProductListResult>;
// Result: 응답 정의
public record GetProductListResult(
List<ProductListItem> Products,
int TotalCount,
int TotalPages
);
public record ProductListItem(
int Id,
string Name,
decimal Price,
string Category,
int StockCount
);
// Features/Products/GetProductList/GetProductListHandler.cs
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace MyApp.Features.Products.GetProductList;
public class GetProductListHandler
: IRequestHandler<GetProductListQuery, GetProductListResult>
{
private readonly AppDbContext _db;
public GetProductListHandler(AppDbContext db)
{
_db = db;
}
public async Task<GetProductListResult> Handle(
GetProductListQuery request,
CancellationToken cancellationToken)
{
var query = _db.Products.AsQueryable();
// 필터링
if (!string.IsNullOrEmpty(request.Category))
{
query = query.Where(p => p.Category == request.Category);
}
// 총 개수
var totalCount = await query.CountAsync(cancellationToken);
var totalPages = (int)Math.Ceiling(totalCount / (double)request.PageSize);
// 페이징 및 매핑 (Entity → DTO 직접 변환)
var products = await query
.OrderBy(p => p.Name)
.Skip((request.Page - 1) * request.PageSize)
.Take(request.PageSize)
.Select(p => new ProductListItem(
p.Id,
p.Name,
p.Price,
p.Category,
p.Stock
))
.ToListAsync(cancellationToken);
return new GetProductListResult(products, totalCount, totalPages);
}
}
// Features/Products/CreateProduct/CreateProductCommand.cs
using FluentValidation;
using MediatR;
namespace MyApp.Features.Products.CreateProduct;
public record CreateProductCommand(
string Name,
string Description,
decimal Price,
string Category,
int InitialStock
) : IRequest<CreateProductResult>;
public record CreateProductResult(int ProductId, bool Success, string? Error);
// Validator (FluentValidation)
public class CreateProductValidator : AbstractValidator<CreateProductCommand>
{
public CreateProductValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("상품명은 필수입니다")
.MaximumLength(100);
RuleFor(x => x.Price)
.GreaterThan(0).WithMessage("가격은 0보다 커야 합니다");
RuleFor(x => x.InitialStock)
.GreaterThanOrEqualTo(0);
}
}
// Features/Products/CreateProduct/CreateProductHandler.cs
using MediatR;
namespace MyApp.Features.Products.CreateProduct;
public class CreateProductHandler
: IRequestHandler<CreateProductCommand, CreateProductResult>
{
private readonly AppDbContext _db;
private readonly IPublisher _publisher;
public CreateProductHandler(AppDbContext db, IPublisher publisher)
{
_db = db;
_publisher = publisher;
}
public async Task<CreateProductResult> Handle(
CreateProductCommand request,
CancellationToken cancellationToken)
{
// 도메인 엔티티 생성 (비즈니스 규칙 적용)
var product = new Product(
request.Name,
request.Description,
request.Price,
request.Category
);
product.AddStock(request.InitialStock);
_db.Products.Add(product);
await _db.SaveChangesAsync(cancellationToken);
// 이벤트 발행 (필요시)
await _publisher.Publish(
new ProductCreatedEvent(product.Id, product.Name),
cancellationToken
);
return new CreateProductResult(product.Id, true, null);
}
}
// API Endpoint (Minimal API 스타일)
// Program.cs 또는 Features/Products/ProductEndpoints.cs
app.MapGet("/api/products", async (
[AsParameters] GetProductListQuery query,
IMediator mediator) =>
{
var result = await mediator.Send(query);
return Results.Ok(result);
});
app.MapPost("/api/products", async (
CreateProductCommand command,
IMediator mediator) =>
{
var result = await mediator.Send(command);
return result.Success
? Results.Created($"/api/products/{result.ProductId}", result)
: Results.BadRequest(result.Error);
});
// src/features/orders/create-order/index.ts
// 하나의 파일에 모든 관련 코드를 포함
import { z } from 'zod';
import { prisma } from '@/infrastructure/database';
import { EventBus } from '@/infrastructure/events';
// 1. 입력 스키마 (Validation)
export const CreateOrderSchema = z.object({
customerId: z.string().uuid(),
items: z.array(z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive(),
})).min(1, '최소 1개 상품이 필요합니다'),
shippingAddress: z.object({
street: z.string(),
city: z.string(),
zipCode: z.string(),
}),
});
export type CreateOrderInput = z.infer<typeof CreateOrderSchema>;
// 2. 출력 타입
export interface CreateOrderResult {
orderId: string;
totalAmount: number;
status: string;
createdAt: Date;
}
// 3. 비즈니스 로직 (Handler)
export async function createOrder(input: CreateOrderInput): Promise<CreateOrderResult> {
// 상품 조회 및 재고 확인
const productIds = input.items.map(item => item.productId);
const products = await prisma.product.findMany({
where: { id: { in: productIds } },
});
// 가격 계산
let totalAmount = 0;
const orderItems = input.items.map(item => {
const product = products.find(p => p.id === item.productId);
if (!product) {
throw new Error(`상품을 찾을 수 없습니다: ${item.productId}`);
}
if (product.stock < item.quantity) {
throw new Error(`재고 부족: ${product.name}`);
}
const subtotal = product.price * item.quantity;
totalAmount += subtotal;
return {
productId: product.id,
productName: product.name,
unitPrice: product.price,
quantity: item.quantity,
subtotal,
};
});
// 트랜잭션으로 주문 생성 + 재고 차감
const order = await prisma.$transaction(async (tx) => {
// 주문 생성
const createdOrder = await tx.order.create({
data: {
customerId: input.customerId,
totalAmount,
status: 'PENDING',
shippingAddress: input.shippingAddress,
items: {
create: orderItems,
},
},
});
// 재고 차감
for (const item of input.items) {
await tx.product.update({
where: { id: item.productId },
data: { stock: { decrement: item.quantity } },
});
}
return createdOrder;
});
// 이벤트 발행
await EventBus.publish('order.created', {
orderId: order.id,
customerId: order.customerId,
totalAmount: order.totalAmount,
});
return {
orderId: order.id,
totalAmount: order.totalAmount,
status: order.status,
createdAt: order.createdAt,
};
}
// 4. HTTP Handler (Express/Fastify 라우트)
import { Router } from 'express';
const router = Router();
router.post('/orders', async (req, res, next) => {
try {
const input = CreateOrderSchema.parse(req.body);
const result = await createOrder(input);
res.status(201).json(result);
} catch (error) {
next(error);
}
});
export { router as createOrderRoute };