# `application-core`는 왜 Spring DI만 허용했을까 ## 코드보다 먼저 드러난 문제 Clean Architecture를 적용하면 흔히 “코어에서 프레임워크를 제거해야 한다”는 문장부터 떠올린다. 이 원칙을 그대로 밀어붙이면 `application-core`의 use case도 Spring을 전혀 모르는 순수 Java 객체가 된다. 처음에는 경계가 가장 선명해 보인다. 문제는 조립 단계에서 드러났다. use case가 늘어날 때마다 `@Configuration`에 bean 등록 코드를 추가해야 했고, 생성자 의존성이 바뀔 때마다 조립 코드도 함께 수정해야 했다. 비즈니스 흐름과 무관한 등록 코드가 반복되면서 “Spring을 제거했다”는 이점보다 조립 비용이 더 빠르게 커졌다. ca-tmpl이 풀려던 질문은 Spring을 쓰느냐 마느냐가 아니었다. `application-core`가 맡아야 할 책임은 지키면서, use case 등록에 필요한 반복 작업을 어디까지 줄일 것인가가 핵심이었다. 이 글은 그 결정을 다룬다. 모든 Clean Architecture 프로젝트에 같은 경계를 권하는 글은 아니며, 로깅 라이브러리 선택이나 운영 성능까지 설명하지 않는다. ## 문제를 어렵게 만든 제약 `application-core`는 application policy를 소유한다. command와 query, inbound port와 outbound port, transaction boundary의 의도는 이 계층에 있다. 반면 HTTP, JPA, Spring MVC, 구체적인 transaction 실행 방식은 adapter나 bootstrap 쪽 책임이다. DI 편의를 허용하더라도 이 구분이 무너지면 안 됐다. 그러나 의존성의 유무만으로 경계를 판단할 수는 없다. `spring-context`를 참조한다는 사실과 `@Transactional`로 transaction 정책을 표현한다는 사실은 같은 종류의 의존이 아니다. 전자는 객체를 컨테이너에 등록하는 조립 편의이고, 후자는 application policy를 Spring annotation으로 표현하는 설계 선택이다. 단순히 “Spring 있음/없음”으로 나누면 두 결정을 구분할 수 없다. 팀원이 규칙을 기억하는 데 의존하면 시간이 지날수록 예외가 쌓인다. 이를 막기 위해 허용과 금지의 경계는 문서에 적어 두는 데서 끝내지 않고, Gradle dependency graph와 source import graph에서 각각 위반을 검출할 수 있어야 했다. ## 검토한 선택지와 막힌 지점 가장 엄격한 선택은 `application-core`에서 Spring을 완전히 제거하는 것이다. use case는 순수 Java class로 두고 bootstrap module의 `@Configuration`에서 모두 수동 등록한다. framework 의존 경계는 가장 단순해지지만, use case 수와 생성자 의존성이 늘수록 조립 코드가 함께 증가한다. 프로젝트는 이 반복 비용을 실제 문제로 보았다. 반대쪽 선택은 Spring 편의를 application layer 전반에 허용하는 것이다. `@Service`뿐 아니라 `@Transactional`, Spring Web type, JPA annotation까지 사용할 수 있게 두면 구현 속도는 빨라질 수 있다. 그러나 transaction, transport, persistence 정책이 application code에 섞이면서 adapter를 교체하거나 경계를 검증하기 어려워진다. 편의를 허용하는 목적이 bean 등록을 넘어서는 순간이었다. 그래서 선택지를 “Spring을 제거할 것인가”와 “Spring을 사용할 것인가”로 나누지 않았다. 대신 의존 목적을 기준으로 잘랐다. 객체 등록에 필요한 DI stereotype은 허용하고, transaction 실행과 web·persistence 기술은 금지하는 중간 경계를 검토했다. ## 선택의 이유와 지킨 경계 ca-tmpl은 `application-core`에서 `@Service`와 `@Component`를 허용했다. use case를 component scanning으로 등록해, 각 use case마다 `@Configuration`에 bean을 수동 선언하는 반복을 피하기 위해서다. `spring-context`와 `spring-beans`를 compile dependency로 유지하는 비용도 함께 받아들였다. 다만 허용 목적을 DI 등록으로 한정했다. `spring-tx`, Spring Web, JPA annotation은 계속 금지한다. transaction boundary는 application use case가 결정하지만, 실행 방식은 `TransactionPort` 뒤로 숨긴다. application code는 `inWrite`, `inRead`, `inNew`처럼 필요한 transaction 의미를 요청하고, Spring의 `TransactionTemplate`을 사용하는 구현은 바깥에서 제공한다. 이 경계가 중요한 이유는 선택의 이점과 비용을 같은 위치에 묶어 두기 때문이다. 얻는 것은 use case 조립 코드의 감소다. 수용한 비용은 application module이 Spring core DI에 의존한다는 사실이다. 그 비용이 다른 프레임워크 의존으로 번지지 않도록 transaction, transport, persistence 의존을 명시적으로 금지했다. 따라서 “`application-core`는 framework-free다”라는 설명은 정확하지 않다. 더 정확한 설명은 “bean 등록을 위한 Spring DI는 허용하지만 application policy를 framework annotation과 adapter type으로 표현하지 않는다”이다. ## 선택이 코드와 흐름에 반영되는 방식 use case class는 application package에 놓이고 `@Service` 또는 `@Component`로 등록된다. 생성자에는 domain service나 outbound port 같은 application 경계의 dependency가 들어간다. controller DTO, JPA entity, Spring MVC type은 들어오지 않는다. transaction이 필요한 write use case를 예로 들면 흐름은 다음과 같다. ```text HTTP adapter → command 생성 → application use case 호출 → TransactionPort.inWrite(...) 요청 → SpringTransactionPort가 TransactionTemplate 실행 → outbound port 호출 → persistence adapter가 실제 저장 수행 ``` application use case가 알고 있는 것은 write transaction이 필요하다는 정책과 outbound port 계약이다. 어떤 transaction manager를 사용하고 어떤 persistence 기술이 저장을 수행하는지는 알지 못한다. DI stereotype은 use case를 찾고 연결하는 데만 쓰이며, transaction 구현을 application 안으로 가져오는 통로로 쓰이지 않는다. 이 구조의 불변조건은 세 가지다. application package는 adapter와 bootstrap에 의존하지 않는다. `@Transactional`을 직접 사용하지 않는다. `ApplicationContext`에서 bean을 런타임 조회하지 않는다. 이 조건이 지켜져야 DI 허용이 service locator나 framework policy 유입으로 확대되지 않는다. ## 결정이 지켜지는지 확인하는 방법 경계는 두 종류의 검사로 확인한다. Gradle의 dependency matrix는 module 간 `project()` 의존을 검사한다. 허용하지 않은 module dependency가 추가되면 build가 실패한다. 이 검사는 물리적인 build graph를 담당한다. ArchUnit은 source와 bytecode의 의존 관계를 검사한다. application package가 adapter, bootstrap, Spring Web, persistence, Hibernate에 의존하지 않는지 확인한다. `@Transactional`과 `ApplicationContext` 직접 의존도 별도 rule로 차단한다. 의도된 위반 class를 test fixture에 두고 rule이 실제로 실패하는지도 검증한다. 검증 범위에는 한계가 있다. 정적 분석은 `getBean(String)`이나 `Class.forName(String)`처럼 문자열과 reflection을 이용한 우회를 모두 잡지 못한다. 따라서 빌드가 통과했다는 사실은 선언된 import와 dependency graph가 규칙을 지켰다는 뜻이지, 모든 런타임 우회가 불가능하다는 뜻은 아니다. 이 부분은 code review checklist로 보완한다. 또한 이 결정은 로컬 build와 architecture test로 확인됐다. 운영 배포와 운영 metric으로 검증된 선택이라고 확대해서 말할 수는 없다. ## 얻은 것, 잃은 것, 적용하지 않을 때 이 선택으로 use case 등록을 위한 반복적인 configuration code를 줄이면서도 transaction, web, persistence 경계를 유지할 수 있었다. “프레임워크 의존 0개”라는 단순한 규칙 대신, 허용 목적과 금지 범위를 더 세밀하게 표현하게 됐다. 반대로 규칙의 설명과 검증 비용은 늘었다. `spring-context`는 허용하지만 `spring-tx`는 금지한다는 차이를 팀원이 이해해야 하고, dependency matrix와 ArchUnit rule도 계속 관리해야 한다. 이 구분을 유지하는 이유는 bean 조립 편의가 transaction policy 유입의 근거로 확대되는 것을 막기 위해서다. Spring core DI 의존 자체를 제거해야 하는 library나 여러 DI container를 지원해야 하는 제품이라면 이 선택이 맞지 않을 수 있다. 그런 환경에서는 수동 조립이나 별도 composition module이 더 적합하다. 남은 위험은 허용된 stereotype이 점차 더 넓은 Spring 사용의 근거로 오해되는 것이다. 그래서 새 framework dependency를 추가할 때는 “application policy를 표현하기 위한가, 객체 조립을 위한가”를 먼저 묻는다. 전자라면 application 경계 밖으로 밀어내고, 후자라도 기존 허용 범위 안인지 build rule로 확인한다. ## 결국 지키려던 것은 무엇이었나 ca-tmpl이 지키려던 것은 framework-free라는 이름이 아니라 application 책임의 경계였다. bean 등록의 반복 비용을 줄이기 위해 Spring DI는 허용했지만, transaction·transport·persistence 정책이 application code로 들어오는 것은 막았다. 비슷한 결정을 내려야 한다면 의존성 개수부터 세지 않는 편이 낫다. 그 의존이 해결하는 구체적인 문제는 무엇인지, 제거했을 때 생기는 비용은 무엇인지, 허용 범위가 넓어지지 않도록 어떤 검사가 실패해야 하는지를 연속해서 답할 수 있어야 한다.