C# 클린 아키텍처 완벽 가이드: 면접 대비 및 구현 패턴 2026

C#에서 클린 아키텍처의 설계 원칙, SOLID 원칙의 실무 적용 방법, 기술 면접에서 자주 출제되는 질문과 답변을 상세히 다룹니다.

C# 클린 아키텍처 설계도

C# 개발에서 클린 아키텍처는 유지보수성과 확장성이 뛰어난 애플리케이션을 구축하는 핵심 기반입니다. 이 글에서는 클린 코드 원칙부터 SOLID 원칙의 구현, 그리고 면접에서 중요하게 다뤄지는 포인트까지 체계적으로 살펴봅니다.

클린 아키텍처를 숙달하면 테스트 가능하고 변경에 강한 코드베이스를 구축할 수 있습니다. 이는 기술 면접에서도 높이 평가받는 역량입니다.

클린 아키텍처의 정의

클린 아키텍처는 Robert C. Martin(Uncle Bob)이 제안한 설계 철학입니다. 이 설계 철학의 핵심은 비즈니스 로직을 외부 세부사항(데이터베이스, UI, 프레임워크)으로부터 분리하는 것입니다.

C#에서 클린 아키텍처는 다음과 같은 계층으로 구성됩니다:

  • Domain 계층(엔티티): 비즈니스 규칙과 엔티티
  • Application 계층(유스케이스): 애플리케이션 고유의 비즈니스 규칙
  • Infrastructure 계층: 데이터베이스 액세스, 외부 서비스
  • Presentation 계층: UI와 API

의존성 방향은 항상 안쪽(Domain 계층)을 향해야 합니다. 이를 통해 비즈니스 로직이 프레임워크나 기술적 세부사항에 의존하지 않게 됩니다.

SOLID 원칙의 실무 적용

SOLID 원칙은 클린 코드 작성을 위한 5가지 설계 원칙입니다. C#에서의 구현 예시를 살펴봅니다.

단일 책임 원칙(SRP)

csharp
// ❌ Bad: 여러 책임을 가진 클래스
public class UserService
{
    public void CreateUser(User user) { /* ... */ }
    public void SendEmail(string email, string message) { /* ... */ }
    public void LogActivity(string activity) { /* ... */ }
}

// ✅ Good: 단일 책임으로 분리
public class UserService
{
    private readonly IEmailService _emailService;
    private readonly ILogger<UserService> _logger;

    public UserService(IEmailService emailService, ILogger<UserService> logger)
    {
        _emailService = emailService;
        _logger = logger;
    }

    public async Task CreateUserAsync(User user)
    {
        // 사용자 생성에만 집중
        await _userRepository.AddAsync(user);
        _logger.LogInformation("User created: {UserId}", user.Id);
    }
}

개방-폐쇄 원칙(OCP)

csharp
// ❌ Bad: 새로운 결제 방식 추가 시 클래스 수정 필요
public class PaymentProcessor
{
    public void ProcessPayment(string paymentType, decimal amount)
    {
        if (paymentType == "CreditCard")
        {
            // 신용카드 처리
        }
        else if (paymentType == "PayPal")
        {
            // PayPal 처리
        }
    }
}

// ✅ Good: 확장에는 열려있고 수정에는 닫혀있음
public interface IPaymentStrategy
{
    Task ProcessAsync(decimal amount);
}

public class CreditCardPayment : IPaymentStrategy
{
    public async Task ProcessAsync(decimal amount)
    {
        // 신용카드 고유 처리
    }
}

public class PayPalPayment : IPaymentStrategy
{
    public async Task ProcessAsync(decimal amount)
    {
        // PayPal 고유 처리
    }
}

public class PaymentProcessor
{
    private readonly IPaymentStrategy _paymentStrategy;

    public PaymentProcessor(IPaymentStrategy paymentStrategy)
    {
        _paymentStrategy = paymentStrategy;
    }

    public async Task ProcessPaymentAsync(decimal amount)
    {
        await _paymentStrategy.ProcessAsync(amount);
    }
}

리스코프 치환 원칙(LSP)

csharp
// ❌ Bad: LSP 위반 - 파생 클래스가 기반 클래스의 계약을 위반
public class Bird
{
    public virtual void Fly() { /* ... */ }
}

public class Penguin : Bird
{
    public override void Fly()
    {
        throw new NotSupportedException("Penguins cannot fly");
    }
}

// ✅ Good: 인터페이스로 적절히 분리
public interface IFlyable
{
    void Fly();
}

public interface ISwimmable
{
    void Swim();
}

public class Sparrow : IFlyable
{
    public void Fly() { /* ... */ }
}

public class Penguin : ISwimmable
{
    public void Swim() { /* ... */ }
}

인터페이스 분리 원칙(ISP)

csharp
// ❌ Bad: 거대한 인터페이스
public interface IWorker
{
    void Work();
    void Eat();
    void Sleep();
}

// ✅ Good: 역할별로 분리된 인터페이스
public interface IWorkable
{
    void Work();
}

public interface IFeedable
{
    void Eat();
}

public interface ISleepable
{
    void Sleep();
}

public class HumanWorker : IWorkable, IFeedable, ISleepable
{
    public void Work() { /* ... */ }
    public void Eat() { /* ... */ }
    public void Sleep() { /* ... */ }
}

public class RobotWorker : IWorkable
{
    public void Work() { /* ... */ }
}

의존성 역전 원칙(DIP)

csharp
// ❌ Bad: 상위 모듈이 하위 모듈에 의존
public class OrderService
{
    private readonly SqlOrderRepository _repository = new SqlOrderRepository();

    public void CreateOrder(Order order)
    {
        _repository.Save(order);
    }
}

// ✅ Good: 둘 다 추상화에 의존
public interface IOrderRepository
{
    Task SaveAsync(Order order);
    Task<Order?> GetByIdAsync(Guid id);
}

public class OrderService
{
    private readonly IOrderRepository _repository;

    public OrderService(IOrderRepository repository)
    {
        _repository = repository;
    }

    public async Task CreateOrderAsync(Order order)
    {
        await _repository.SaveAsync(order);
    }
}

클린 아키텍처 프로젝트 구조

실제 C# 프로젝트에서는 다음과 같은 구조가 권장됩니다:

text
Solution/
├── src/
│   ├── Domain/
│   │   ├── Entities/
│   │   ├── ValueObjects/
│   │   ├── Interfaces/
│   │   └── Exceptions/
│   ├── Application/
│   │   ├── Common/
│   │   │   ├── Interfaces/
│   │   │   └── Behaviors/
│   │   ├── Features/
│   │   │   └── Orders/
│   │   │       ├── Commands/
│   │   │       └── Queries/
│   │   └── DTOs/
│   ├── Infrastructure/
│   │   ├── Persistence/
│   │   ├── Services/
│   │   └── DependencyInjection.cs
│   └── WebApi/
│       ├── Controllers/
│       ├── Filters/
│       └── Program.cs
└── tests/
    ├── Domain.Tests/
    ├── Application.Tests/
    └── Integration.Tests/

CQRS와 MediatR 패턴

클린 아키텍처에서는 CQRS(명령 쿼리 책임 분리) 패턴이 널리 사용됩니다. MediatR 라이브러리를 활용한 구현 예시를 살펴봅니다:

csharp
// Command 정의
public record CreateOrderCommand(string CustomerId, List<OrderItem> Items) 
    : IRequest<Result<Guid>>;

// Command 핸들러
public class CreateOrderCommandHandler 
    : IRequestHandler<CreateOrderCommand, Result<Guid>>
{
    private readonly IOrderRepository _orderRepository;
    private readonly IUnitOfWork _unitOfWork;

    public CreateOrderCommandHandler(
        IOrderRepository orderRepository,
        IUnitOfWork unitOfWork)
    {
        _orderRepository = orderRepository;
        _unitOfWork = unitOfWork;
    }

    public async Task<Result<Guid>> Handle(
        CreateOrderCommand request, 
        CancellationToken cancellationToken)
    {
        var order = Order.Create(request.CustomerId, request.Items);
        
        await _orderRepository.AddAsync(order, cancellationToken);
        await _unitOfWork.SaveChangesAsync(cancellationToken);
        
        return Result.Success(order.Id);
    }
}

// Query 정의
public record GetOrderByIdQuery(Guid OrderId) : IRequest<OrderDto?>;

// Query 핸들러
public class GetOrderByIdQueryHandler 
    : IRequestHandler<GetOrderByIdQuery, OrderDto?>
{
    private readonly IOrderRepository _orderRepository;
    private readonly IMapper _mapper;

    public GetOrderByIdQueryHandler(
        IOrderRepository orderRepository,
        IMapper mapper)
    {
        _orderRepository = orderRepository;
        _mapper = mapper;
    }

    public async Task<OrderDto?> Handle(
        GetOrderByIdQuery request, 
        CancellationToken cancellationToken)
    {
        var order = await _orderRepository
            .GetByIdAsync(request.OrderId, cancellationToken);
        
        return order is null ? null : _mapper.Map<OrderDto>(order);
    }
}

유효성 검사와 에러 처리

FluentValidation을 사용한 입력 검증 구현:

csharp
public class CreateOrderCommandValidator 
    : AbstractValidator<CreateOrderCommand>
{
    public CreateOrderCommandValidator()
    {
        RuleFor(x => x.CustomerId)
            .NotEmpty()
            .WithMessage("고객 ID는 필수입니다");

        RuleFor(x => x.Items)
            .NotEmpty()
            .WithMessage("주문에는 최소 1개의 상품이 필요합니다");

        RuleForEach(x => x.Items)
            .ChildRules(item =>
            {
                item.RuleFor(x => x.Quantity)
                    .GreaterThan(0)
                    .WithMessage("수량은 1 이상이어야 합니다");
            });
    }
}

// ValidationBehavior(MediatR 파이프라인)
public class ValidationBehavior<TRequest, TResponse> 
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;

    public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
    {
        _validators = validators;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        if (!_validators.Any())
            return await next();

        var context = new ValidationContext<TRequest>(request);
        var failures = _validators
            .Select(v => v.Validate(context))
            .SelectMany(result => result.Errors)
            .Where(f => f != null)
            .ToList();

        if (failures.Any())
            throw new ValidationException(failures);

        return await next();
    }
}

기술 면접 빈출 질문

클린 아키텍처 관련 면접에서는 다음과 같은 질문이 자주 출제됩니다:

Q: 클린 아키텍처와 어니언 아키텍처의 차이점은 무엇입니까?

두 아키텍처는 매우 유사하지만, 클린 아키텍처가 더 범용적인 개념입니다. 어니언 아키텍처는 도메인 모델을 중심에 두고 의존성 방향을 엄격하게 정의합니다. 클린 아키텍처는 이를 더욱 발전시켜 유스케이스 계층을 명확하게 분리합니다.

Q: Domain 계층이 프레임워크에 의존하면 안 되는 이유는 무엇입니까?

Domain 계층이 프레임워크에 의존하면 프레임워크 변경이 비즈니스 로직에 영향을 미칩니다. 또한 단위 테스트가 어려워지고 비즈니스 규칙의 재사용성도 저하됩니다.

Q: Repository 패턴과 Unit of Work 패턴의 관계를 설명해 주십시오.

Repository 패턴은 데이터 액세스의 추상화를 제공하고, Unit of Work 패턴은 트랜잭션 경계를 관리합니다. 두 패턴을 조합하면 여러 리포지토리 작업을 단일 트랜잭션으로 실행할 수 있습니다.

.NET 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

모범 사례와 안티패턴

클린 아키텍처 구현 시 피해야 할 안티패턴이 있습니다:

피해야 할 안티패턴

  1. 빈혈 도메인 모델: 엔티티가 데이터 컨테이너 역할만 하고 로직이 서비스 계층으로 누출되는 현상
  2. 과도한 추상화: 모든 것에 인터페이스를 만들어 불필요한 복잡성 유발
  3. 계층 경계 위반: 프레젠테이션 계층에서 도메인 엔티티를 직접 반환
  4. 순환 참조: 계층 간 양방향 의존관계 형성

권장 모범 사례

  1. 풍부한 도메인 모델: 비즈니스 로직을 엔티티 내에 캡슐화
  2. DTO의 적절한 사용: 계층 간 데이터 전송에는 DTO 활용
  3. 예외의 적절한 사용: 도메인 고유 예외를 정의하고 적절히 처리
  4. 테스트 주도 개발: 각 계층에 대해 적절한 테스트 작성

결론

C#에서 클린 아키텍처를 구현하는 것은 장기적인 유지보수성과 테스트 용이성을 확보하기 위한 중요한 투자입니다. SOLID 원칙을 이해하고 적절한 계층 분리를 수행함으로써 변경에 강하고 이해하기 쉬운 코드베이스를 구축할 수 있습니다. 기술 면접에서는 이러한 개념을 실제 코드로 설명할 수 있는 것이 중요합니다. 이론뿐만 아니라 실제 구현 경험을 쌓음으로써 클린 아키텍처의 진정한 가치를 발휘할 수 있습니다.

오늘의 챌린지

.NET 코드의 버그를 찾을 수 있나요

실제 코드 한 조각, 숨은 버그 하나, 하루 한 번. 계정 없이 바로 도전할 수 있습니다.

Anthony Fillion-Maillet

작성자

Anthony Fillion-Maillet

SharpSkill 창업자

10년 이상 풀스택 개발을 해왔습니다. SharpSkill을 운영하며 이곳에 게시되는 모든 내용에 책임을 집니다.

2026년 8월 24일 업데이트

태그

#csharp
#clean-architecture
#solid
#design-patterns
#interview

공유

관련 기사