Spring의 3대 철학 — DI, AOP, PSA가 만드는 코드의 품격
Spring을 처음 배울 때, 나는 어노테이션 수집가였다.
@Autowired를 붙이면 객체가 알아서 들어오고, @Transactional을 붙이면 트랜잭션이 알아서 관리되고, @Cacheable을 붙이면 캐시가 알아서 동작했다. "알아서"라는 말 뒤에 숨은 원리를 몰랐다. 그냥 마법이라고 생각했다.
그러다 문제가 생겼다. @Transactional을 붙였는데 롤백이 안 됐다. 같은 클래스 안에서 메서드를 호출했기 때문이었다. 원인을 찾는 데 반나절이 걸렸고, 결국 Spring AOP의 프록시 동작 원리를 이해하고 나서야 해결할 수 있었다.
그날 깨달았다. 어노테이션 뒤에 숨은 철학을 모르면, 문제가 생겼을 때 속수무책이라는 것을.
Spring Framework는 세 가지 핵심 철학 위에 서 있다. DI(Dependency Injection), AOP(Aspect-Oriented Programming), PSA(Portable Service Abstraction). 이 세 가지는 독립적인 기술이 아니라, 서로 맞물려 돌아가는 톱니바퀴다. 이 글은 각 철학이 왜 필요하고, 어떻게 동작하며, 어떤 문제를 해결하는지를 코드와 함께 파헤친다.
1. DI — 객체의 생사여탈권을 넘기다
의존성이란 무엇인가
public class OrderService {
private final OrderRepository repository = new JdbcOrderRepository();
public void placeOrder(Order order) {
repository.save(order);
}
}
이 코드의 문제가 보이는가? OrderService가 JdbcOrderRepository를 직접 생성하고 있다. OrderService는 OrderRepository의 구현체가 무엇인지 알고 있고, 그 생성 방법까지 알고 있다. 이것이 강한 결합(tight coupling)이다.
만약 데이터베이스를 MongoDB로 바꿔야 한다면? OrderService의 코드를 수정해야 한다. 테스트에서 가짜 저장소를 쓰고 싶다면? 역시 코드를 수정해야 한다. 사용하는 쪽이 구현체를 알고 있으면, 구현체가 바뀔 때 사용하는 쪽도 바뀌어야 한다.
제어의 역전 — 내가 만들지 않겠다
DI의 핵심은 Inversion of Control(IoC), 제어의 역전이다. 객체를 내가 만들지 않고, 외부에서 만들어서 넣어주는 것이다.
public class OrderService {
private final OrderRepository repository;
public OrderService(OrderRepository repository) {
this.repository = repository;
}
public void placeOrder(Order order) {
repository.save(order);
}
}
OrderService는 이제 OrderRepository가 JDBC인지, JPA인지, MongoDB인지 모른다. 인터페이스에만 의존한다. 구현체는 외부(Spring 컨테이너)가 결정하고 주입한다.
@Configuration
public class AppConfig {
@Bean
public OrderRepository orderRepository() {
return new JpaOrderRepository();
}
@Bean
public OrderService orderService(OrderRepository orderRepository) {
return new OrderService(orderRepository);
}
}
이제 저장소를 바꾸고 싶으면 AppConfig만 수정하면 된다. OrderService는 건드릴 필요가 없다.
생성자 주입이 권장되는 진짜 이유
Spring에서 DI를 하는 방법은 세 가지다. 필드 주입, 세터 주입, 생성자 주입. Spring 공식 문서는 생성자 주입을 권장한다. 왜일까?
필드 주입의 문제:
@Service
public class OrderService {
@Autowired
private OrderRepository repository; // final이 아니다
}
생성자 주입의 장점:
@Service
public class OrderService {
private final OrderRepository repository; // final 선언 가능
public OrderService(OrderRepository repository) {
this.repository = repository;
}
}
차이는 final 한 글자에 있다. 하지만 이 한 글자가 만드는 차이는 크다.
| 관점 | 필드 주입 | 생성자 주입 |
| 불변성 | final 불가, 런타임에 변경 가능 | final 선언으로 불변 보장 |
| NPE 방지 | 주입 실패 시 null, 런타임에 NPE | 생성 시점에 누락 감지, 컴파일 타임 안전 |
| 순환 참조 | 런타임에 발견 | 애플리케이션 시작 시 즉시 감지 |
| 테스트 | 리플렉션 필요 | new로 직접 생성 가능 |
| 의존성 파악 | 필드 흩어져 있어 파악 어려움 | 생성자 파라미터로 한눈에 파악 |
생성자의 파라미터가 10개가 넘어간다면? 그것은 생성자 주입의 문제가 아니라, 그 클래스가 너무 많은 책임을 지고 있다는 신호다. 생성자 주입은 이 신호를 눈에 보이게 만들어준다. 필드 주입은 이 신호를 숨긴다.
DI가 만드는 변화
DI는 단순히 "객체를 대신 만들어주는 편의 기능"이 아니다. DI는 설계를 바꾼다.
- 구현이 아닌 인터페이스에 의존하게 만든다 (DIP — 의존 역전 원칙)
- 객체의 생성과 사용을 분리한다 (SRP — 단일 책임 원칙)
- 구현체를 자유롭게 교체할 수 있게 만든다 (OCP — 개방-폐쇄 원칙)
DI는 SOLID 원칙을 코드에 자연스럽게 녹이는 장치다.
2. AOP — 흩어진 관심사를 한 곳에 모으다
OOP의 시선, AOP의 시선
전통적인 객체지향 프로그래밍에서 우리는 코드를 가로로 바라본다. Controller → Service → Repository, 레이어를 따라 위에서 아래로 흐르는 비즈니스 로직에 집중한다.
그런데 로깅, 트랜잭션, 보안 같은 관심사는 이 가로 흐름을 세로로 관통한다. 모든 레이어에 동일한 코드가 반복된다. OOP는 이 세로 방향의 중복을 해결할 도구가 없다.
OOP의 시선은 가로(→)다. Controller → Service → Repository, 비즈니스 로직의 흐름을 따라간다. AOP의 시선은 세로(↑)다. 로깅, 트랜잭션, 보안이 모든 모듈을 관통한다.
OOP가 가로(비즈니스 로직의 흐름)를 모듈화한다면, AOP는 세로(여러 모듈을 관통하는 공통 관심사)를 모듈화한다. 관점(Aspect)이라는 이름이 붙은 이유가 여기에 있다. 코드를 바라보는 관점 자체를 바꾸는 것이다.
이 시선의 전환을 이해하면, AOP가 OOP를 대체하는 것이 아니라 보완하는 기술이라는 것이 명확해진다.
문제: 코드 전체에 퍼진 로깅
@Service
public class OrderService {
public void placeOrder(Order order) {
long start = System.currentTimeMillis();
log.info("placeOrder 시작: {}", order.getId());
try {
// 비즈니스 로직
orderRepository.save(order);
paymentService.process(order);
notificationService.notify(order);
} finally {
long elapsed = System.currentTimeMillis() - start;
log.info("placeOrder 완료: {}ms", elapsed);
}
}
}
@Service
public class PaymentService {
public void process(Order order) {
long start = System.currentTimeMillis();
log.info("process 시작: {}", order.getId());
try {
// 비즈니스 로직
paymentGateway.charge(order.getAmount());
} finally {
long elapsed = System.currentTimeMillis() - start;
log.info("process 완료: {}ms", elapsed);
}
}
}
실행 시간을 측정하는 로깅 코드가 모든 서비스에 복붙되어 있다. 이것이 횡단 관심사(Cross-Cutting Concern)다. 비즈니스 로직과는 무관하지만, 여러 모듈에 가로질러 존재하는 코드다.
횡단 관심사의 대표적인 예:
- 로깅/모니터링
- 트랜잭션 관리
- 보안/인증
- 캐싱
- 예외 처리
이런 코드를 각 서비스에 직접 작성하면 두 가지 문제가 생긴다. 비즈니스 로직이 부가 로직에 파묻히고, 변경이 필요할 때 모든 서비스를 수정해야 한다.
AOP의 해결책 — 관심사를 분리하다
AOP는 이 횡단 관심사를 Aspect라는 모듈로 분리한다.
@Aspect
@Component
public class ExecutionTimeAspect {
@Around("execution(* com.example.service.*.*(..))")
public Object measureExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
String methodName = joinPoint.getSignature().getName();
log.info("{} 시작", methodName);
try {
return joinPoint.proceed();
} finally {
long elapsed = System.currentTimeMillis() - start;
log.info("{} 완료: {}ms", methodName, elapsed);
}
}
}
이제 OrderService와 PaymentService에서 로깅 코드를 모두 제거할 수 있다. 비즈니스 로직만 남는다.
@Service
public class OrderService {
public void placeOrder(Order order) {
orderRepository.save(order);
paymentService.process(order);
notificationService.notify(order);
}
}
서비스는 자신의 핵심 로직에만 집중한다. 로깅이라는 관심사는 Aspect가 담당한다. 로깅 방식을 바꾸고 싶으면 Aspect 하나만 수정하면 된다.
프록시 — AOP의 동작 원리
여기서 "왜?"라는 질문을 던져야 한다. Aspect의 코드가 서비스에 없는데, 어떻게 실행되는 걸까?
답은 프록시(Proxy)에 있다. Spring은 AOP가 적용된 빈을 생성할 때, 원본 객체 대신 프록시 객체를 만들어 컨테이너에 등록한다.
호출자 → [프록시] → 원본 객체
│
├── Before Advice 실행
├── 원본 메서드 호출
└── After Advice 실행
Spring Boot 2.0부터는 CGLIB 프록시를 기본으로 사용한다. CGLIB은 대상 클래스의 서브클래스를 런타임에 생성하여 메서드를 오버라이드하는 방식으로 프록시를 만든다.
이 프록시 메커니즘을 이해하면, 아까 내가 겪었던 문제의 원인이 명확해진다.
self-invocation 문제 — 프록시의 함정
@Service
public class OrderService {
@Transactional
public void placeOrder(Order order) {
orderRepository.save(order);
this.sendNotification(order); // ← 프록시를 거치지 않는다!
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void sendNotification(Order order) {
notificationRepository.save(new Notification(order));
}
}
placeOrder()에서 this.sendNotification()을 호출하면, sendNotification()의 @Transactional이 동작하지 않는다. 왜? this는 프록시가 아니라 원본 객체이기 때문이다.
외부 호출자 → [프록시] → placeOrder()
│
└── this.sendNotification() ← 프록시를 우회!
외부에서 orderService.sendNotification()을 호출하면 프록시를 거치므로 @Transactional이 동작한다. 하지만 같은 클래스 내부에서 this로 호출하면 프록시를 건너뛰고 원본 메서드가 직접 호출된다.
해결 방법: sendNotification()을 별도의 빈으로 분리하거나, ApplicationContext에서 프록시를 직접 가져와 호출한다. 근본적으로는 클래스의 책임을 분리하는 것이 올바른 접근이다.
이런 함정은 프록시 기반 AOP의 동작 원리를 이해해야만 피할 수 있다. 마법이 아니라 메커니즘으로 이해해야 하는 이유다.
3. PSA — 기술을 갈아끼워도 코드는 그대로
프레임워크에 종속된 코드의 시대
Spring 이전, Java 엔터프라이즈의 표준은 EJB(Enterprise JavaBeans)였다. EJB로 비즈니스 로직을 작성하려면 이런 코드가 필요했다.
// EJB 2.x 시절의 서비스 코드
public class OrderServiceBean implements SessionBean {
private SessionContext ctx;
public void setSessionContext(SessionContext ctx) { this.ctx = ctx; }
public void ejbCreate() {}
public void ejbRemove() {}
public void ejbActivate() {}
public void ejbPassivate() {}
// 겨우 여기서부터 비즈니스 로직
public void placeOrder(Order order) {
// ...
}
}
SessionBean 인터페이스를 구현해야 하고, ejbCreate, ejbRemove 같은 생명주기 메서드를 강제로 오버라이드해야 한다. 비즈니스 로직은 프레임워크 코드에 파묻힌다. 이 클래스는 EJB 컨테이너에 강하게 의존하기 때문에, 컨테이너 없이는 일반적인 단위 테스트가 사실상 불가능에 가까웠다. 코드가 프레임워크에 종속된 것이다.
Spring은 이 문제를 근본적으로 다르게 접근했다. 같은 비즈니스 로직을 Spring에서는 이렇게 작성한다.
// Spring의 서비스 코드
public class OrderService {
private final OrderRepository repository;
public OrderService(OrderRepository repository) {
this.repository = repository;
}
public void placeOrder(Order order) {
repository.save(order);
}
}
프레임워크 클래스를 상속하지 않는다. 특정 인터페이스를 구현하지 않는다. 순수한 Java 객체, POJO다. 이 클래스는 Spring 없이도 new OrderService(mockRepo)로 인스턴스를 만들어 테스트할 수 있다.
그렇다면 트랜잭션, 캐싱, 보안 같은 엔터프라이즈 기능은 누가 처리하는가? 바로 PSA다. Spring이 추상화 계층을 제공하고, 개발자의 POJO 위에 기능을 입혀주는 것이다. 개발자는 @Transactional 하나만 선언하면 되고, 그 뒤의 복잡한 트랜잭션 관리는 Spring의 추상화가 처리한다.
정리하면 PSA에는 두 가지 측면이 있다:
- Spring 내부의 메커니즘 —
PlatformTransactionManager같은 인터페이스로 구현체를 추상화한다 - 개발자가 얻는 결과 — 비즈니스 코드가 프레임워크에 종속되지 않는 POJO로 남는다
인터페이스 추상화는 Spring 쪽의 이야기이고, POJO는 개발자 쪽의 이야기다. 둘은 동전의 양면이다.
추상화가 없는 세계
만약 Spring의 트랜잭션 추상화가 없다면, JDBC로 트랜잭션을 관리하는 코드는 이렇게 생겼을 것이다.
public void placeOrder(Order order) throws SQLException {
Connection conn = dataSource.getConnection();
try {
conn.setAutoCommit(false);
// 비즈니스 로직
PreparedStatement ps = conn.prepareStatement("INSERT INTO orders ...");
ps.executeUpdate();
conn.commit();
} catch (Exception e) {
conn.rollback();
throw e;
} finally {
conn.close();
}
}
비즈니스 로직이 JDBC API에 완전히 종속되어 있다. 여기서 JPA로 전환하려면? 코드를 전부 다시 작성해야 한다. Connection, PreparedStatement, commit(), rollback() — 이 모든 것이 JDBC라는 특정 기술에 묶여 있기 때문이다.
PSA — 기술 위에 놓인 추상화 계층
PSA는 Portable Service Abstraction, 이동 가능한 서비스 추상화다. 특정 기술에 종속되지 않고, 추상화된 인터페이스를 통해 일관된 방식으로 기술을 사용할 수 있게 한다.
Spring의 트랜잭션 추상화를 보자. 핵심은 PlatformTransactionManager 인터페이스다.
public interface PlatformTransactionManager extends TransactionManager {
TransactionStatus getTransaction(TransactionDefinition definition)
throws TransactionException;
void commit(TransactionStatus status) throws TransactionException;
void rollback(TransactionStatus status) throws TransactionException;
}
이 인터페이스의 구현체는 기술에 따라 달라진다:
| 기술 스택 | 구현체 |
| JDBC | DataSourceTransactionManager |
| JPA/Hibernate | JpaTransactionManager |
| JTA (분산 트랜잭션) | JtaTransactionManager |
리액티브 환경에서는 별도의 ReactiveTransactionManager 인터페이스와 R2dbcTransactionManager 구현체가 존재한다. 명령형과 리액티브의 트랜잭션 관리가 완전히 분리된 것도 PSA 설계의 일부다.
하지만 개발자는 이 구현체를 직접 다루지 않는다. @Transactional 하나면 된다.
@Service
public class OrderService {
@Transactional
public void placeOrder(Order order) {
orderRepository.save(order);
paymentService.process(order);
}
}
JDBC를 쓰든, JPA를 쓰든, R2DBC를 쓰든 — 이 코드는 변하지 않는다. 기술이 바뀌면 Spring이 알아서 다른 TransactionManager 구현체를 주입할 뿐이다. 이것이 Portable, 이동 가능하다는 의미다.
PSA가 적용된 곳들
@Transactional만 PSA인 것이 아니다. Spring 곳곳에 PSA가 녹아 있다.
캐시 추상화:
@Service
public class ProductService {
@Cacheable("products")
public Product findById(Long id) {
return productRepository.findById(id).orElseThrow();
}
}
@Cacheable 뒤에서 동작하는 CacheManager의 구현체는 바뀔 수 있다.
| 설정 | 구현체 |
| 기본 | ConcurrentMapCacheManager |
| Redis | RedisCacheManager |
| Caffeine | CaffeineCacheManager |
| JSR-107 호환 (Ehcache 3 등) | JCacheCacheManager |
Caffeine에서 Redis로 캐시를 교체해도 @Cacheable이 붙은 서비스 코드는 한 줄도 바뀌지 않는다. 의존성과 설정만 바꾸면 된다.
Spring Data:
public interface OrderRepository extends JpaRepository<Order, Long> {
List<Order> findByCustomerId(Long customerId);
}
이 인터페이스는 JPA에 종속된 것처럼 보이지만, Spring Data의 추상화 덕분에 같은 패턴으로 다양한 저장소를 사용할 수 있다.
| 저장소 | 상위 인터페이스 |
| JPA (RDB) | JpaRepository |
| MongoDB | MongoRepository |
| Elasticsearch | ElasticsearchRepository |
| Redis | CrudRepository |
메서드 이름 기반 쿼리 생성, 페이징, 정렬 — 이 모든 기능이 저장소 기술에 관계없이 동일한 방식으로 동작한다.
PSA의 본질 — DIP를 프레임워크 레벨에서 실현하다
PSA의 구조를 도식화하면 이렇다:
[내 애플리케이션 코드]
│
▼
[Spring 추상화 계층] ←── @Transactional, @Cacheable, Repository
│
▼
[구현체] ←── JpaTransactionManager, RedisCacheManager, ...
│
▼
[실제 기술] ←── Hibernate, Redis, Elasticsearch, ...
내 코드는 Spring의 추상화 계층에만 의존한다. 그 아래의 구현체와 실제 기술은 설정으로 교체할 수 있다. 이것은 앞서 살펴본 DI의 의존 역전 원칙(DIP)을 프레임워크 레벨에서 대규모로 적용한 것이다.
4. 세 철학의 연결 — 하나의 목표를 향해
DI, AOP, PSA는 독립적으로 존재하지 않는다. 서로 맞물려 돌아간다.
@Transactional 하나를 예로 들어보자. 이 어노테이션이 동작하려면 세 가지 철학이 모두 필요하다:
- PSA —
@Transactional은PlatformTransactionManager라는 추상화에 의존한다. 기술에 종속되지 않는다. - AOP —
@Transactional이 붙은 메서드를 프록시가 감싸서, 메서드 실행 전후에 트랜잭션을 시작하고 커밋/롤백한다. - DI — 프록시가 사용할
TransactionManager구현체를 Spring 컨테이너가 주입한다.
@Transactional이 동작하는 과정:
1. [DI] Spring 컨테이너가 JpaTransactionManager를 생성하고 주입
2. [AOP] 프록시가 메서드 호출을 가로챔
3. [PSA] PlatformTransactionManager.getTransaction() 호출
4. 원본 메서드 실행
5. [PSA] 성공 시 commit(), 예외 시 rollback()
DI가 없으면 AOP의 프록시가 올바른 TransactionManager를 받을 수 없다. AOP가 없으면 @Transactional을 메서드에 선언하는 것만으로는 트랜잭션이 적용되지 않는다. PSA가 없으면 기술이 바뀔 때마다 트랜잭션 로직을 다시 작성해야 한다.
세 철학이 향하는 궁극적 목표는 하나다. POJO 기반의 엔터프라이즈 개발. 비즈니스 로직을 담은 객체가 특정 프레임워크나 기술에 종속되지 않고, 순수한 Java 객체로 남을 수 있게 하는 것이다.
// 이 클래스는 Spring에 대해 아무것도 모른다.
// 하지만 DI, AOP, PSA 덕분에 트랜잭션, 캐싱, 로깅이 모두 적용된다.
public class OrderService {
private final OrderRepository repository;
public OrderService(OrderRepository repository) {
this.repository = repository;
}
public void placeOrder(Order order) {
repository.save(order);
}
}
Spring의 어노테이션을 모두 걷어내도 이 클래스는 컴파일되고, 테스트되고, 동작한다. 이것이 Spring이 추구하는 코드의 품격이다.
마무리
Spring을 쓰면서 @Autowired, @Transactional, @Cacheable을 "그냥 붙이면 되는 것"으로 생각했다면, 이제는 그 뒤에서 세 가지 철학이 어떻게 맞물려 돌아가는지 보일 것이다.
- DI — 객체의 생성과 사용을 분리하여, 느슨한 결합과 유연한 구조를 만든다
- AOP — 횡단 관심사를 분리하여, 비즈니스 로직을 깨끗하게 유지한다
- PSA — 기술을 추상화하여, 구현체가 바뀌어도 코드가 바뀌지 않게 한다
이 세 가지는 결국 하나의 목표를 향한다. 내 코드가 특정 기술에 종속되지 않고, 변화에 유연하게 대응할 수 있는 구조를 만드는 것. Spring이 20년 넘게 Java 생태계의 표준으로 자리 잡은 이유가 여기에 있다.
어노테이션 뒤의 원리를 이해하자. 그래야 마법이 풀렸을 때 당황하지 않는다.
참고 자료
- Spring Framework Core - IoC Container — Spring 공식 IoC/DI 문서
- Spring Framework Core - AOP — Spring 공식 AOP 문서
- Understanding the Spring Framework Transaction Abstraction — Spring 트랜잭션 추상화 공식 문서
- Proxying Mechanisms :: Spring Framework — Spring AOP 프록시 메커니즘 공식 문서
- 왜 Constructor Injection을 사용해야 하는가? | Tecoble — 생성자 주입 권장 이유
- Spring PSA(Portable Service Abstraction) — PSA 개념 설명

