SSangeok
Clean Architecture

의존성 역전과 계층 분리

2026. 05. 17.
ArchitectureDesign

2편. 의존성 역전과 계층 분리

Clean Architecture는 결국 의존성 방향을 관리하는 아키텍처다.

어떤 클래스가 어떤 클래스를 알고 있는지, 어떤 계층이 어떤 계층을 참조하는지가 중요하다.


의존성이란 무엇인가

의존성이란 한 코드가 다른 코드를 사용하는 관계다.

예를 들어 다음 코드를 보자.

@Service
@RequiredArgsConstructor
publicclassOrderService {
 
privatefinalOrderJpaRepositoryorderJpaRepository;
 
publicvoidcancelOrder(LongorderId) {
Orderorder=orderJpaRepository.findById(orderId)
.orElseThrow();
 
order.cancel();
    }
}

여기서 OrderServiceOrderJpaRepository에 의존한다.

왜냐하면 OrderServiceOrderJpaRepository를 직접 사용하고 있기 때문이다.

OrderService → OrderJpaRepository

이 구조에서는 Service가 JPA 기술을 알고 있다.

처음에는 문제가 없어 보인다.

하지만 테스트하거나 기술을 바꿀 때 불편해진다.

Service 테스트에 JPA가 필요해진다
DB 설정이 필요해질 수 있다
JPA Repository 변경이 Service에 영향을 준다
UseCase가 저장 기술에 묶인다

Clean Architecture에서는 이런 의존성을 줄이려고 한다.


DIP, 의존성 역전 원칙

DIP는 Dependency Inversion Principle의 줄임말이다.

한국어로는 의존성 역전 원칙이라고 한다.

핵심은 이것이다.

상위 수준 모듈은 하위 수준 모듈에 의존하면 안 된다.
둘 다 추상화에 의존해야 한다.

말이 어렵다.

쉽게 보면 이렇다.

비즈니스 로직이 구체적인 DB 기술에 직접 의존하면 안 된다.
비즈니스 로직은 Interface에 의존해야 한다.
구체적인 DB 구현체가 그 Interface를 구현해야 한다.

나쁜 구조는 다음과 같다.

OrderService → OrderJpaRepository

좋은 구조는 다음과 같다.

OrderService → OrderRepository Interface
OrderPersistenceAdapter → OrderRepository Interface 구현

코드로 보면 먼저 Interface를 만든다.

publicinterfaceOrderRepository {
 
Ordersave(Orderorder);
 
Optional<Order>findById(LongorderId);
}

Service는 Interface에 의존한다.

@Service
@RequiredArgsConstructor
publicclassOrderService {
 
privatefinalOrderRepositoryorderRepository;
 
publicvoidcancelOrder(LongorderId) {
Orderorder=orderRepository.findById(orderId)
.orElseThrow();
 
order.cancel();
    }
}

JPA 구현체는 Interface를 구현한다.

@Repository
@RequiredArgsConstructor
publicclassOrderPersistenceAdapterimplementsOrderRepository {
 
privatefinalOrderJpaRepositoryorderJpaRepository;
 
    @Override
publicOrdersave(Orderorder) {
returnorderJpaRepository.save(order);
    }
 
    @Override
publicOptional<Order>findById(LongorderId) {
returnorderJpaRepository.findById(orderId);
    }
}

이제 Service는 JPA를 직접 알지 않는다.

Service는 OrderRepository라는 추상화만 안다.

이것이 의존성 역전이다.


안쪽 계층이 바깥쪽 계층을 몰라야 하는 이유

Clean Architecture에서 안쪽 계층은 바깥쪽 계층을 몰라야 한다.

안쪽 계층은 다음과 같다.

Entity
Use Case
Domain
Application Service

바깥쪽 계층은 다음과 같다.

Controller
JPA
DB
Redis
Kafka
외부 API
Spring Framework

안쪽 계층이 바깥쪽 기술을 알면 문제가 생긴다.

예를 들어 Domain 객체가 JPA Repository를 직접 사용한다고 해보자.

publicclassOrder {
 
privateOrderJpaRepositoryorderJpaRepository;
 
publicvoidcancel() {
if (status==OrderStatus.SHIPPED) {
thrownewIllegalStateException("배송 완료된 주문은 취소할 수 없습니다.");
        }
 
this.status=OrderStatus.CANCELED;
 
orderJpaRepository.save(this);
    }
}

이 코드는 좋지 않다.

Order는 주문의 비즈니스 규칙을 표현해야 한다.

그런데 지금은 저장 방식까지 알고 있다.

Order가 주문 취소 규칙을 안다
Order가 JPA Repository도 안다
Order가 저장 시점도 안다

책임이 섞였다.

좋은 구조는 다음과 같다.

publicclassOrder {
 
publicvoidcancel() {
if (status==OrderStatus.SHIPPED) {
thrownewIllegalStateException("배송 완료된 주문은 취소할 수 없습니다.");
        }
 
this.status=OrderStatus.CANCELED;
    }
}
@Transactional
publicvoidcancelOrder(LongorderId) {
Orderorder=orderRepository.findById(orderId)
.orElseThrow();
 
order.cancel();
 
orderRepository.save(order);
}

Order는 비즈니스 규칙만 알고, 저장은 Use Case 흐름에서 처리한다.


Interface를 이용한 의존성 역전

의존성 역전은 보통 Interface를 통해 구현한다.

예를 들어 회원가입 기능에서 이메일 발송이 필요하다고 해보자.

나쁜 구조는 다음과 같다.

@Service
@RequiredArgsConstructor
publicclassSignUpService {
 
privatefinalSmtpMailClientsmtpMailClient;
 
publicvoidsignUp(SignUpCommandcommand) {
// 회원 저장
smtpMailClient.send(command.email(),"가입을 환영합니다.");
    }
}

이 코드는 SMTP 구현체에 직접 의존한다.

나중에 이메일 발송 방식을 외부 API로 바꾸면 Service가 수정될 수 있다.

Interface를 두면 이렇게 바뀐다.

publicinterfaceMailSender {
 
voidsend(Stringto,Stringsubject,Stringcontent);
}

Service는 Interface에 의존한다.

@Service
@RequiredArgsConstructor
publicclassSignUpService {
 
privatefinalMailSendermailSender;
 
publicvoidsignUp(SignUpCommandcommand) {
// 회원 저장
mailSender.send(
command.email(),
"가입을 환영합니다.",
"회원가입이 완료되었습니다."
        );
    }
}

SMTP 구현체는 Interface를 구현한다.

@Component
@RequiredArgsConstructor
publicclassSmtpMailSenderimplementsMailSender {
 
privatefinalSmtpClientsmtpClient;
 
    @Override
publicvoidsend(Stringto,Stringsubject,Stringcontent) {
smtpClient.send(to,subject,content);
    }
}

외부 메일 API로 바뀌어도 Service는 그대로 둘 수 있다.

@Component
@RequiredArgsConstructor
publicclassExternalApiMailSenderimplementsMailSender {
 
privatefinalExternalMailApiClientmailApiClient;
 
    @Override
publicvoidsend(Stringto,Stringsubject,Stringcontent) {
mailApiClient.send(to,subject,content);
    }
}

핵심은 이것이다.

UseCase는 Interface에 의존한다.
구체적인 기술 구현체가 Interface를 구현한다.

UseCase와 Repository Interface

Clean Architecture에서 Repository Interface는 안쪽 계층에 둔다.

처음에는 이상하게 느껴질 수 있다.

보통 Repository는 DB와 관련 있으니까 바깥쪽에 있어야 할 것 같다.

하지만 Clean Architecture에서는 Repository Interface와 Repository 구현체를 구분한다.

Repository Interface
- 안쪽 계층
- UseCase가 필요로 하는 저장소 계약

Repository 구현체
- 바깥쪽 계층
- JPA, MyBatis, QueryDSL 등 기술 사용

예를 들어 UseCase는 주문을 조회하고 저장해야 한다.

그래서 안쪽 계층에 이런 Interface를 둔다.

publicinterfaceOrderRepository {
 
Ordersave(Orderorder);
 
Optional<Order>findById(LongorderId);
}

UseCase는 이 Interface를 사용한다.

@Service
@RequiredArgsConstructor
publicclassCancelOrderServiceimplementsCancelOrderUseCase {
 
privatefinalOrderRepositoryorderRepository;
 
    @Transactional
publicvoidcancelOrder(LongorderId) {
Orderorder=orderRepository.findById(orderId)
.orElseThrow();
 
order.cancel();
 
orderRepository.save(order);
    }
}

실제 구현은 바깥쪽에서 한다.

@Repository
@RequiredArgsConstructor
publicclassJpaOrderRepositoryAdapterimplementsOrderRepository {
 
privatefinalSpringDataOrderRepositoryspringDataOrderRepository;
 
    @Override
publicOrdersave(Orderorder) {
returnspringDataOrderRepository.save(order);
    }
 
    @Override
publicOptional<Order>findById(LongorderId) {
returnspringDataOrderRepository.findById(orderId);
    }
}

이렇게 하면 UseCase는 JPA를 모르고, JPA 구현체가 UseCase의 요구사항에 맞춰진다.


DTO와 Domain 객체 분리

Clean Architecture에서는 DTO와 Domain 객체를 구분하는 것이 중요하다.

Controller에서 받는 Request DTO가 있다고 해보자.

publicrecordCreateOrderRequest(
LongmemberId,
List<CreateOrderItemRequest>items
) {
}

이 DTO는 HTTP 요청 형식에 맞춰져 있다.

이 객체를 Domain까지 그대로 넘기는 것은 좋지 않다.

publicclassOrder {
 
publicstaticOrdercreate(CreateOrderRequestrequest) {
// ...
    }
}

이렇게 하면 Domain이 Web 계층의 DTO를 알게 된다.

Domain → CreateOrderRequest

좋지 않은 의존성이다.

대신 Request DTO를 UseCase 입력 모델로 변환한다.

publicrecordCreateOrderCommand(
LongmemberId,
List<CreateOrderItemCommand>items
) {
}

Controller에서 변환한다.

@PostMapping("/orders")
publicResponseEntity<Long>createOrder(@RequestBodyCreateOrderRequestrequest) {
LongorderId=createOrderUseCase.createOrder(request.toCommand());
returnResponseEntity.status(HttpStatus.CREATED).body(orderId);
}

UseCase는 Command를 받는다.

publicinterfaceCreateOrderUseCase {
 
LongcreateOrder(CreateOrderCommandcommand);
}

Domain은 필요한 값만 받는다.

publicclassOrder {
 
publicstaticOrdercreate(LongmemberId,List<OrderItem>orderItems) {
returnnewOrder(memberId,orderItems);
    }
}

정리하면 이렇게 나눌 수 있다.

Request DTO
- Web 계층
- HTTP 요청 형식

Command
- UseCase 입력 모델
- 애플리케이션 기능 실행에 필요한 값

Domain 객체
- 비즈니스 규칙과 상태
- 외부 요청 형식을 몰라야 함

테스트하기 쉬운 구조가 되는 이유

Clean Architecture를 적용하면 테스트가 쉬워진다.

이유는 UseCase가 구체적인 기술에 직접 의존하지 않기 때문이다.

예를 들어 주문 취소 UseCase가 있다.

publicclassCancelOrderServiceimplementsCancelOrderUseCase {
 
privatefinalOrderRepositoryorderRepository;
 
publicCancelOrderService(OrderRepositoryorderRepository) {
this.orderRepository=orderRepository;
    }
 
publicvoidcancelOrder(LongorderId) {
Orderorder=orderRepository.findById(orderId)
.orElseThrow();
 
order.cancel();
 
orderRepository.save(order);
    }
}

이 클래스는 Spring 없이도 테스트할 수 있다.

DB 없이도 테스트할 수 있다.

테스트용 Fake Repository를 만들면 된다.

publicclassFakeOrderRepositoryimplementsOrderRepository {
 
privatefinalMap<Long,Order>store=newHashMap<>();
 
    @Override
publicOrdersave(Orderorder) {
store.put(order.getId(),order);
returnorder;
    }
 
    @Override
publicOptional<Order>findById(LongorderId) {
returnOptional.ofNullable(store.get(orderId));
    }
}

테스트 코드는 이렇게 만들 수 있다.

classCancelOrderServiceTest {
 
privateFakeOrderRepositoryorderRepository;
privateCancelOrderServicecancelOrderService;
 
    @BeforeEach
voidsetUp() {
orderRepository=newFakeOrderRepository();
cancelOrderService=newCancelOrderService(orderRepository);
    }
 
    @Test
void 주문을_취소한다() {
Orderorder=Order.createForTest(1L,OrderStatus.CREATED);
orderRepository.save(order);
 
cancelOrderService.cancelOrder(1L);
 
OrdersavedOrder=orderRepository.findById(1L).orElseThrow();
assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.CANCELED);
    }
}

이 테스트는 실제 DB를 사용하지 않는다.

Spring Context도 필요 없다.

그래서 빠르다.

빠른 테스트 가능
외부 시스템 없이 테스트 가능
비즈니스 규칙만 집중해서 검증 가능

이것이 Clean Architecture가 테스트하기 쉬운 구조가 되는 이유다.


정리

Clean Architecture의 핵심은 의존성 방향이다.

안쪽 계층은 바깥쪽 계층을 몰라야 한다.

이를 위해 DIP를 사용한다.

UseCase는 구체적인 JPA Repository에 의존하지 않는다.
UseCase는 Repository Interface에 의존한다.
JPA 구현체가 그 Interface를 구현한다.

DTO와 Domain 객체도 분리하는 것이 좋다.

Request DTO
- Web 요청 형식

Command
- UseCase 입력 모델

Domain
- 비즈니스 규칙

가장 중요한 기준은 이것이다.

비즈니스 로직이 외부 기술에 직접 묶이지 않게 만든다.

이렇게 하면 테스트하기 쉽고, 변경에 강한 구조를 만들 수 있다.