🏗️ 아키텍처

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 개발에서는 과도한 추상화 없이 빠르게 기능을 구현하고, 필요에 따라 점진적으로 공통 로직을 추출하는 방식이 효과적입니다. 단, 슬라이스 간 공통 로직이 많아지면 중복 코드가 발생할 수 있어 적절한 리팩토링이 필요합니다.

💻 코드 예제

프로젝트 폴더 구조 (Feature 기반)

# 전통적인 계층형 구조 (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

C# + MediatR를 이용한 Vertical Slice 구현

// 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);
});

TypeScript (Node.js/NestJS 스타일) Vertical Slice

// 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 };

🗣️ 실무에서 이렇게 말해요

  • "주문 기능 수정할 때 Vertical Slice로 해놓으니까 Orders 폴더만 보면 되네요. 다른 도메인 건드릴 걱정 없어요"
  • "신규 기능 추가할 때 Feature 폴더 하나 만들고 Query/Command/Handler 넣으면 끝이에요. 기존 코드 안 건드려도 돼요"
  • "MediatR로 Request/Handler 패턴 쓰니까 각 슬라이스가 완전히 독립적이에요. 테스트도 슬라이스 단위로 가능하고요"
  • "CQRS랑 Vertical Slice 같이 쓰면 읽기는 Dapper로 빠르게, 쓰기는 EF Core로 도메인 모델 태워서 처리할 수 있어요"
  • "Vertical Slice Architecture는 기술적 계층(Controller, Service, Repository)이 아닌 비즈니스 기능(Feature) 단위로 코드를 구성하는 패턴입니다."
  • "전통적인 계층형에서 하나의 기능 수정 시 여러 레이어를 수정해야 하는 산탄총 수술(Shotgun Surgery) 문제를 해결합니다."
  • "각 슬라이스는 독립적이므로 팀 간 코드 충돌이 줄고, 마이크로서비스로의 전환도 슬라이스 단위로 할 수 있습니다."
  • "CQRS와 결합하면 Query 슬라이스는 성능 최적화된 직접 쿼리를, Command 슬라이스는 풍부한 도메인 모델을 사용할 수 있어 유연성이 높아집니다."
  • "이 기능은 다른 슬라이스랑 공유할 게 없으니 슬라이스 내부에 DTO 직접 정의해도 될 것 같아요. 공통 Models 폴더에 안 넣어도 돼요"
  • "Handler에서 다른 슬라이스의 Handler 직접 호출하지 말고 MediatR로 Send해주세요. 의존성 역전 유지해야 해요"
  • "비슷한 Validation 로직이 3개 슬라이스에 중복되네요. 이 정도면 공통 Validator로 추출할 타이밍인 것 같아요"
  • "Query Handler에서 Entity 반환하지 말고 DTO로 Select해주세요. 읽기 최적화 원칙이에요"

⚠️ 주의사항

🔗 관련 용어

📚 더 배우기