왜 어노테이션은 구현 코드를 변경하지 않고 메타데이터를 부여하도록 구현되었을까?
나는 이것이 객체지향과 관련이 있다고 생각한다.
어노테이션 프로세서와 같이 객체의 코드를 직접적으로 변경하는 것은
compile-time-weaving이라는 안티 패턴이 되기 쉽다.
Transactional은 DIP 위배일까?
DIP의 핵심은 구현체가 아닌 추상화에 의존하는 것이다.
그래야 의존성의 구현체를 자유롭게 변경할 수 있다.
그러한 관점에서 Transactional을 살펴보자.
class UserServiceImpl implements UserService {
private UserRepository userRepository;
@Transactional
public User create(int id) {
User user = new User(id)
userRepository.save(user)
}
}
UserServiceImpl 객체가 올바르게 동작하려면 UserRepository의 구현체를 알아야한다.
UserRepository의 구현체가 Postgresql을 사용하는데,
UserServiceImpl에서 Mysql 트랜잭션을 열면 프로그램이 제대로 동작 할 수 없기 때문이다.
그렇다면 Transactional을 DIP 위배로 볼 수 있을까?
Transactional은 UserServiceImpl.create에 어떠한 코드도 추가하지 않는다.
따라서 DIP를 논할 코드조차 없으므로 DIP 위배라고 볼 수 없다.
AOP: 데코레이터 패턴
Transactional 사례로 미루어 보아 누군가는 세부적인 구현체에 대해 정확히 알고 있어야 한다.
그 책임은 Dependency Injection에 있고 AOP와 관련 있다.
AOP는 Transaction, Caching, Logging, Security, Circuit breaker 같이 모든 계층의 객체에서 필요 될 수 있는 Crosscutting Concerns한 문제를 잘 풀어내기 위한 패러다임이다.
구현법은 다양하지만, 데코레이터 패턴을 통해 본질적인 부분을 간단히 보여줄 수 있다.
class MysqlTransactionUserServiceDecorator implements UserService {
public MysqlTransactionUserServiceDecorator(
EntityManager em,
UserService decoratee
) {
this.em = em
this.decoratee = decoratee
}
public User create(int id) {
EntityTransaction tx = em.getTransaction();
tx.begin();
decoratee.create(id) // UserServiceImpl
tx.commit();
}
}
DI에서 적절히 객체를 합성하면 UserServiceImpl는 트랜잭션 같은 기술적인 부분에 의존성이 사라진다.
UserServiceImpl의 코드를 변경하지 않고도 디비를 변경하거나, Optimistic Lock 같이 트랜잭션 커밋중에 발생하는 에러 처리에 대한 로직을 선택할 수도 있다.
즉, 위에서 말한 Crosscutting Concerns한 문제들을 해결할 수 있다.
DI Container: Auto-Register 불가
Spring의 DI Container는 타입을 기반으로 구현체가 하나인 경우 자동으로 Container에 등록해준다.
따라서 다형성을 가지는 경우 수동 등록이 필요하다.
AOP는 기본적으로 다형성이 필요하기 때문에, 매번 수동 등록이 필요하다는 뜻이 된다.
이 때 어노테이션이 편의성을 제공한다.
예로 들어 스프링은 Transactional 메타데이터를 가지고 있는 객체를 Contaienr에 등록할 때,
위의 데코레이터와 유사한 Proxy 객체를 만들어 등록해준다.
KafkaListener 어노테이션의 응집성 측면
KafkaListener 어노테이션도 Transactional과 마찬가지로 DI 등록의 편의성을 가진다.
또한 응집성 측면에서도 이점을 가진다.
@KafkaListener(
id = "group-name",
topics = {"topic-a", "topic-a"},
properties={"max.poll.interval.ms:1000"}
public void listen(String message) {
}
Consumer 객체는 생성자 값이 다른 여러 인스턴스가 필요하기 때문에,
수동 등록과 qualifier 구분이 필요하다.
그러한 설정을 KafkaListener에 명시하면 수동으로 등록하지 않아도,
알아서 Consumer를 만들어준다.
KafkaListener는 단지 메시지를 제대로 처리하는 책임을 가진다.
따라서 메시지를 어떻게 가져오는지 명시하는 것은 이론적으론 응집성 누수로 보인다.
그러나 실용적인 측면의 응집성에선 올바르다고 생각한다.
Consumer 설정값은 KafkaListener와 1대1 개념이기 때문이다.
예를 들어 max.poll.interval.ms은 Listener의 처리 속도 관련된 값이다.
KafkaListener를 수정 했을 때 처리 속도가 느려진다면 함께 조정되어야 할 값이다.
마찬가지로 어노테이션은 실제 구현 내용을 변경하지 않기 때문에,
객체지향의 이론적 측면의 응집성과 실리적 측면의 응집성을 둘 다 만족시킨다.