백엔드 개발에서 변경은 피할 수 없다. 비즈니스가 바뀌면 도메인과 유스케이스가 함께 바뀌는 건 자연스럽다. 문제는 그 반대다. 비즈니스 규칙은 그대로인데, DB/프레임워크/외부 SDK 같은 ‘디테일’ 변화가 핵심 로직까지 밀고 들어오면서 수정 범위가 폭발하는 순간이 있다. 이 글은 그 폭발의 원인을 “레이어”가 아니라 의존성 방향으로 설명하는 원칙, 즉 Dependency Rule(의존성은 안쪽으로만 흐른다) 하나에만 집중한다. 그럼 여기서 말하는 ‘의존성’은 정확히 무엇일까? 자바에서는 특히 import/타입 참조로 이 규칙이 깨지기 쉽다.
Dependency Rule이 말하는 ‘의존성’의 정확한 의미
클린 아키텍처에서 말하는 “의존성”은 추상적인 분위기 용어가 아니다. 특히 자바에서는 의존성이 꽤 구체적이고 기계적으로 드러난다. 가장 중요한 구분은 다음이다.
- 컴파일 타임 의존(Compile-time dependency): 소스 코드가 특정 타입/패키지/모듈을 알고 있어야 컴파일되는 의존
- 런타임 연결(Runtime wiring): DI 컨테이너가 실행 시점에 객체를 연결해 주는 행위
클린 아키텍처의 Dependency Rule이 통제하려는 대상은 대부분 컴파일 타임 의존이다.
즉 “실행할 때 주입받는다”가 아니라, “코드가 그 타입을 import/참조하고 있느냐”가 핵심이다.
1) 자바에서 ‘컴파일 타임 의존’이 생기는 순간들
아래 중 하나라도 해당하면 의존성이 생긴다.
(1) import / 타입 참조
import org.springframework.transaction.annotation.Transactional;
public class CreateUserUseCase {
// ...
}
- import 자체가 없어도, 코드 어디선가 Transactional 타입을 참조하면 이미 의존이다.
(2) 상속 / 구현
public interface UserRepository extends JpaRepository<UserEntity, Long> { }
- extends/implements는 가장 강한 형태의 결합이다.
- 이 한 줄로 “안쪽 코드가 바깥 프레임워크 타입을 안다”가 확정된다.
(3) 애너테이션(어노테이션)
@Service
public class CreateUserUseCase { }
- 애너테이션도 결국 “특정 타입에 대한 참조”다.
- 그래서 도메인/유스케이스에 프레임워크 애너테이션이 들어오면, 그 순간 안쪽이 바깥을 아는 셈이 된다.
(4) 제네릭 타입/반환 타입/파라미터 타입
public ResponseEntity<UserResponse> execute(CreateUserRequest req) { ... }
- 반환/입력 타입에 웹/프레임워크 타입이 섞이는 순간, 경계가 흐려지기 시작한다.
- 특히 ResponseEntity, HttpServletRequest, JpaRepository 같은 타입이 대표적인 “디테일 타입”이다.
핵심: 의존성은 ‘객체가 누구를 new 하느냐’보다 ‘타입을 누구를 아느냐’가 더 크다.
2) “런타임 DI는 괜찮은데, 컴파일 타임 의존은 조심해야 한다”는 말의 의미
스프링 DI를 예로 들면, 유스케이스는 실행 시점에 UserRepository 구현체를 주입받아도 된다.
하지만 유스케이스 코드가 JpaRepository 같은 구체 타입을 알기 시작하면 교체가 어려워진다.
Dependency Rule은 “런타임에 뭘 주입받으면 안 된다”가 아니라,
“안쪽 코드가 바깥 타입을 import/참조하지 않게 하라”에 가깝다.
클린 아키텍처에서 “바깥”은 보통 이런 것들이다.
- Web: Controller, Request/Response DTO, ResponseEntity, HttpServlet*
- DB/ORM: JpaRepository, EntityManager, @Entity, @Table
- Infra: 외부 API SDK, 메시지 큐 클라이언트, 클라우드 SDK
- Framework: 스프링 애너테이션, AOP, Transaction 등
Dependency Rule이 깨지는 대표 패턴 3가지
Dependency Rule은 한 문장으로 요약된다.
안쪽은 바깥을 몰라야 한다.
(정책/핵심 로직이 디테일(DB·웹·프레임워크)에 끌려가지 않게)
그런데 실제 코드에서는 “레이어를 나눴는데도” 이 규칙이 쉽게 깨진다. 이유는 대부분 컴파일 타임 의존 때문이다. 즉, 코드가 특정 타입을 import 하거나, 상속하거나, 애너테이션을 붙이는 순간 안쪽이 바깥을 “알게” 된다.
아래 3가지는 내가 봤을 때(그리고 대부분의 프로젝트에서) Dependency Rule을 가장 흔하게 무너뜨리는 패턴이다.
패턴 1) 안쪽(UseCase/Domain)에 프레임워크 타입/애너테이션이 들어온다
대표적으로 이런 것들:
- @Service, @Transactional 같은 스프링 애너테이션
- ResponseEntity, HttpServletRequest 같은 웹 타입
- @Entity, EntityManager 같은 ORM 타입
문제는 “스프링을 쓴다”가 아니라, 안쪽 코드가 프레임워크 타입을 참조하는 순간 의존성 방향이 바뀐다는 점이다.
이렇게 되면:
- 프레임워크를 교체하기 어려워지고
- 단위 테스트가 프레임워크 컨텍스트에 묶이기 쉽고
- 무엇보다 “정책”이 “디테일”에 끌려다닌다
패턴 2) Port(인터페이스)가 디테일을 “노출”한다
클린 아키텍처에서 Port는 안쪽에 존재한다. 그런데 Port가 바깥 타입을 노출하는 순간, 안쪽이 바깥을 알아버린다.
가장 흔한 예가 이거다:
- UserRepository extends JpaRepository<...>
이 한 줄은 사실상 “포트가 아니라 JPA 전용 인터페이스”가 되어버린다. 즉, 유스케이스는 결국 JPA의 존재를 전제로 돌아가게 되고, 저장 기술 교체가 어려워진다.
또 다른 형태도 있다:
- 유스케이스/포트의 반환 타입이 ResponseEntity 같은 웹 타입
- 포트 메서드 시그니처에 특정 ORM 엔티티 타입이 박혀 있는 경우
패턴 3) UseCase 경계가 웹 DTO로 오염된다 (Input/Output이 바깥 모델에 고정)
이 패턴은 특히 개발을 빨리 할 때 자주 생긴다.
- Controller의 Request DTO를 그대로 useCase.execute(requestDto)로 넘김
- UseCase 결과도 Response DTO 형태로 바로 반환
처음엔 편하지만, 곧 이런 문제가 생긴다:
- API 버전/필드명/직렬화 규칙 같은 “표현의 변화”가 유스케이스에 직접 영향을 준다
- 결과적으로 “디테일 변화 → 정책 변화”처럼 보이는 거대한 도미노가 발생한다
핵심은 경계 모델을 분리하는 것이다.
- Web DTO는 바깥
- UseCase는 자신만의 입력 모델(Command) / 출력 모델(Result)을 가진다
이게 “변경을 없애는” 게 아니라,
- 표현 변화(디테일)가
- 유스케이스(정책)를 흔드는 것을 막는 장치다.
'Deep Dive > 아키텍처' 카테고리의 다른 글
| 클린아키텍처 1편: 왜 클린 아키텍처가 필요해졌나? (0) | 2026.01.02 |
|---|
