RabbitMQ - @RabbitListener를 사용할 때 주의할 점

2022. 7. 17. 19:45학습일지

RabbitMQ는 amqp 0-9-1이라는 생산자, 소비자 패턴을 기반으로 한 메시지 큐 통신을 중계해주는 일종의 메시지 브로커이다. 초기에는 RabbitMQ를 사용할 땐 수동으로 설정해줘야 하는 부분이 많았다. 하지만, 세월이 흐르면서 스프링 진영에서, RabbitMQ, JMS를 비롯한 amqp 기반의 오픈소스를 직관적으로 사용할 수 있게, 추상화를 잘해주어서, 지금은 모든 걸 알지 않아도, amqp 기반의 메시지 통신을 사용하는데 필요한 러닝 커브를 매우 줄여준다. 하지만, 이러한 편리함에 익숙해져서, 사용법만 익혀둔다면, 나중에 디버깅을 할 때 매우 헤맬 수 있다. 이번 게시글에서는 그러한 요소들 중에서 @RabbitListener를 사용하여, 메시지를 소비하는 기능을 구현할 때 조심해야할 점을 짚어보자.

 

@RabbitListener

@RabbitListener는 프로그램 내외부에서 생성한 메세지가 특정 메시지 큐에 담는다면, 해당 큐를 가리키고 있는 소비 주체가 해당 메시지를 가져다가 필요한 함수를 실행하여, 개발자의 의도에 맞는 기능을 제공한다.

@RabbitListener 애노테이션에는 어떤 메시지 큐의 메시지를 listening하고 있을지에 대한 여러 가지 설정값을 제공한다. 이 게시글은 해당 내용을 설명하기 위한 내용은 아니니, 자세한 설명은 생략하도록 하겠다. 다만, 여러 설정값 중에서 눈여겨봐야 할 점은 messageConverter이다.

MessageConverter

RabbitMQ를 비롯한 amqp 기반의 메시지 큐 라이브러리는 자바 객체를 알아서 인식하는 기능이 없다. 따라서, 자바 객체를 메시지 큐로 전송할 때, amqp의 규약에 맞는 메시지 형태로 바꿔야 하는데, java의 경우 Message로 변형을 해야하며, spring에서는 이 변형을 MessageConverter를 통해 수행한다.

Spring에서는 다양한 경우를 수용하기 위해 여러 가지 MessageConverter를 제공하는데, 종류는 다음과 같다.

  • SimpleMessageConverter
  • SerializerMessageConverter
  • Jackson2JsonMessageConverter
  • MarshallingMessageConverter
  • Jackson2XmlMessageConverter
  • ContentTypeDelegatingMessageConverter

종류가 다양한데, 사실 전부다 알 필요는 없어 보인다. 다만, 진하게 작성한 SimpleMessageConverter, Jackson2JsonMessageConverter는 생각보다 자주 쓰이는 converter이니, 이 두 가지를 잠깐 짚어보도록 하자.

SimpleMessageConverter

MessageConverter는 보통 spring에선 RabbitTemplate를 di 할 때, 설정하는데, 만약 아무런 설정을 하지 않는다면 SimpleMessageConverter가 적용된다. SimpleMessageConverter는 일반 text를 포함한 모든 메시지 바디를 전부 byte array로 변형한다.

Jackson2JsonMessageConverter

네이밍을 보고 알 수 있듯이, 메시지 바디를 json으로 변형하여 통신을 하게 하는 컨버터이다. 해당 메시지 바디를 자바 객체 혹은 json으로 변형할 때는 기본적으로는DefaultJackson2JavaTypeMapper를 통해, 자바 객체 타입을 추론하여, 목적에 맞게 메시지를 변형한다.

특이점이라면, Spring amqp 1.6 버전 이전에는 통신하는 자바의 객체 타입에 대한 정보를 설정하지 않으면, 메시지 변형이 실패했기 때문에, map 형태로 메시지를 주고받곤 했다. 그러나, 1.6 이후 메서드 레벨에 선언된 @RabbitListener를 통해 메시지를 소비할 경우, MessagePropertiesinferred type information가 추가된다고 한다. 이게 무슨 소린가 해서 찾아봤더니, 구체적인 내용은 다음과 같았다.

public class MessageProperties implements Serializable {
    ...
    private transient Type inferredArgumentType;
    ...
    /**
     * The inferred target argument type when using a method-level
     * {@code @RabbitListener}.
     * @return the type.
     * @since 1.6
     */
    public Type getInferredArgumentType() {
        return this.inferredArgumentType;
    }

    /**
     * Set the inferred target argument type when using a method-level
     * {@code @RabbitListener}.
     * @param inferredArgumentType the type.
     * @since 1.6
     */
    public void setInferredArgumentType(Type inferredArgumentType) {
        this.inferredArgumentType = inferredArgumentType;
    }
    ...
}

즉, @RabbitListener가 메서드 위에 선언될 경우, 메시지를 소비할 때, 해당 메서드의 매개 변수 중에 메시지의 페이로드에 해당하는 객체의 Type을 Reflect api를 활용하여, 해당 타입의 정보를 '추론'하는 기능이 추가되었다는 것이다.

몇몇 블로그를 보면, RabbitMQ를 사용하는 애플리케이션의 자바 클래스를 ClassMapper로 일일이 묶어서 사용하는 경우가 많았는데, 리스너 애노테이션을 활용한다면, 그런 짓을 할 필요가 없다는 것이다.

 

@RabbitHandler

우선, 공식 문서에서 서술한 예시 코드부터 보자

@RabbitListener(id="multi", queues = "someQueue")
@SendTo("my.reply.queue")
public class MultiListenerBean {

    @RabbitHandler
    public String thing2(Thing2 thing2) {
        ...
    }

    @RabbitHandler
    public String cat(Cat cat) {
        ...
    }

    @RabbitHandler
    public String hat(@Header("amqp_receivedRoutingKey") String rk, @Payload Hat hat) {
        ...
    }

    @RabbitHandler(isDefault = true)
    public String defaultMethod(Object object) {
        ...
    }

}

공식 문서에 따르면, 생산자에서 생성한 메시지 바디가 변형될 타입에 따라 (Thing2, Cat, Hat 등...) @RabbitHandler가 선언된 메서드 중에서 적절한 페이로드 타입의 매개 변수가 선언된 메서드를 찾아가서 메시지를 소비한다고 한다. 만약에 메시지 브로커가 단일 Exchange를 가지고 있으면서, 생산하는 메시지의 페이로드 타입이 다양할 경우, 위와 같은 코드를 짜면 편리할 것 같다. 그런데 다음과 같은 주의사항이 있다.

@RabbitHandler is intended only for processing message payloads after conversion, if you wish to receive the unconverted raw Message object, you must use @RabbitListener on the method, not the class.

@RabbitHandler는 이미 메시지가 특정 타입으로 변형된 이후에 진행하는 메서드이기 때문에, raw message를 받고 싶으면 @RabbitListener를 메소드 위에 사용해야 한다고 한다.

그럼 왜, 위와 같은 내용을 경고로 내놓았을까? 앞서 이야기했던 Jackson2JsonMessageConverter에 대한 내용을 보면 다음과 같은 특징이 있다.

 

This type inference can only be achieved when the @RabbitListener annotation is declared at the method level. With class-level @RabbitListener, the converted type is used to select which @RabbitHandler method to invoke. For this reason, the infrastructure provides the targetObject message property, which you can use in a custom converter to determine the type.

페이로드 타입을 추론하는 기능은 오직 @RabbitListener가 메서드 위에 선언되었을 경우에만 동작한다고 한다. 그렇다면, @RabbitListener가 클래스 위에 선언되면 무슨 일이 일어날까? 그걸 추론하려면, DefaultClassMapper를 살펴봐야 한다.

public class DefaultClassMapper implements ClassMapper, InitializingBean {

    public static final String DEFAULT_CLASSID_FIELD_NAME = "__TypeId__";

    private static final String DEFAULT_HASHTABLE_TYPE_ID = "Hashtable";

    private static final List<String> TRUSTED_PACKAGES =
            Arrays.asList(
                    "java.util",
                    "java.lang"
            );

    private final Set<String> trustedPackages = new LinkedHashSet<>(TRUSTED_PACKAGES);

    private volatile Map<String, Class<?>> idClassMapping = new HashMap<>();

    private volatile Map<Class<?>, String> classIdMapping = new HashMap<>();
    ....
}

RabbitMQ에서 메시지를 컨버팅하기 위해서는, 변형할 메세지 혹은 객체를 식별할 수 있는, map이 필요하다. 보통 '클래스타입' / 메세지바디 혹은 메세지바디/클래스타입 와 같이 키/값 쌍을 형성하여, 메시지를 식별하고 컨버팅을 수행한다.

만약 @RabbitListener가 추론을 생략한 상태에서 메세지를 소비할 때, 메세지 컨버터가 참조하는 classMapper의 키는 해당 메세지 생산자에서 선언된 메세지 페이로드 객체의 패키지 경로일 것이다.

만약 메시지의 생산과 소비가 하나의 모듈로 이루어진다면, 이 부분은 전혀 문제 될 게 없다. 그러나, 생산자 주체와 소비자 주체가 각각 다른 서버에 동작하고 있을 경우, 다음과 같은 예외를 맞닥뜨리게 될 것이다.

org.springframework.amqp.support.converter.MessageConversionException: failed to resolve class name. Class not found [org.springframework.amqp.helloworld.User] at org.springframework.amqp.support.converter.DefaultJackson2JavaTypeMapper.getClassIdType(DefaultJackson2JavaTypeMapper.java:121) at org.springframework.amqp.support.converter.DefaultJackson2JavaTypeMapper.toJavaType(DefaultJackson2JavaTypeMapper.java:90) at org.springframework.amqp.support.converter.Jackson2JsonMessageConverter.fromMessage(Jackson2JsonMessageConverter.java:145) at org.springframework.amqp.rabbit.listener.adapter.AbstractAdaptableMessageListener.extractMessage(AbstractAdaptableMessageListener.java:236) at org.springframework.amqp.rabbit.listener.adapter.MessageListenerAdapter.onMessage(MessageListenerAdapter.java:288) at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.doInvokeListener(AbstractMessageListenerContainer.java:777) ... 10 common frames omitted Caused by: java.lang.ClassNotFoundException: org.springframework.amqp.helloworld.User at org.apache.catalina.loader.WebappClassLoaderBase.loadClass(WebappClassLoaderBase.java:1305) at org.apache.catalina.loader.WebappClassLoaderBase.loadClass(WebappClassLoaderBase.java:1139) at org.springframework.util.ClassUtils.forName(ClassUtils.java:250) at org.springframework.amqp.support.converter.DefaultJackson2JavaTypeMapper.getClassIdType(DefaultJackson2JavaTypeMapper.java:118) ... 15 common frames omitted

위 로그를 분석해보면 DefaultJackson2 JavaTypeMapper를 통해, getClassIdType를 시도했으나, [org.springframework.amqp.helloworld.User라는 클래스를 찾지 못해서 메시지 컨버팅에 실패했다고 한다.

물론, 통신하는 서버를 관리하는 개발자끼리 패키지 경로는 맞추면, 해당 에러는 해결될 수도 있지만, 유연한 코딩을 방해하는 요소가 늘어날 뿐, 근본적인 해결책이 되지 않는다. 위의 같은 에러를 해결하려면 크게 2가지가 있겠다.

  1. @RabbitListener를 메서드 위에 선언하여, raw 메시지 페이로드와 매칭 되는 객체의 Type을 추론하게 한다.
  2. RabbitTemplate를 di 할 때, 통신할 클래스 타입 하나하나를 map에 포함하여, rabbitTemplate의 classMapper에 전달한다.

1의 경우, @RabbitListener를 반복하여 작성하는 경우가 많아지겠지만, 클래스 타입 관련해서 디버깅을 해야 하는 경우를 많이 줄일 수 있고

2의 경우, 코드가 간결해지겠지만, 통신할 클래스 타입이 증가할 때마다, 설정값을 검토해야 하는 번거로움이 있을 수 있다.

선택은 각자의 몫일 듯하다.

 

그래서 뭘 조심하라는 건가?

서론이 길었다... 그런데, 사실 주의해야 할 부분은 위에서 다 이야기했다고 볼 수 있겠다. RabbitMQ를 사용할 때, 자동 설정을 해주어서, 처음 사용할 때는 매우 편하겠지만, @RabbitListener@RabbitHandler를 활용할 때, 각 애노테이션의 특징을 모르고 사용할 경우, 무의미한 삽질로 고통받을 수 있겠다는 말을 위에서 길게 풀어섰다.

 

 일반적으로는, MSA 구조의 애플리케이션에서는 MQ 기반의 비즈니스 로직 트랜잭션 처리는 RabbitMQ와 같은 메시지 브로커 보다는 kafka와 같은 이벤트 스트리밍 플랫폼을 더 많이 사용한다. 활용 사례가 많고, 자체 성능과 확장성이 더 우월하기 때문이다. 그러나 이해하고 사용하는 법은 RabbitMQ가 더 쉽기 때문에, MQ가 필요하지만, 러닝 커브를 길게 잡을 수 없는 상황이라면, 우선 RabbitMQ를 활용하는 것도 나빠 보이진 않는다.

 

참고: https://docs.spring.io/spring-amqp/docs/current/reference/html/