Java 6에 추가된 기능인 SPI는 정말 유용한 기능임에도 불구하고 많은 사람들이 이 기능 자체를 모르고 있다.
이 글에서는 SPI에 대해 간단하게 소개하고, SPI의 개념을 어떻게 스프링에 응용할 수 있는지 살펴볼 것이다.
아래 코드는 자바에서 기본적으로 읽고 쓸 수 있는 이미지 포맷이다.
public class Main {
public static void main(String[] args) {
System.out.println("Readers:");
for (String s : ImageIO.getReaderFormatNames()) {
System.out.print(s + ",");
}
System.out.println();
System.out.println("Writers:");
for (String s : ImageIO.getWriterFormatNames()) {
System.out.print(s + ",");
}
System.out.println();
}
}
이 프로젝트에 아래와 같은 의존을 추가하고
implementation 'com.twelvemonkeys.imageio:imageio-jpeg:3.11.0'
다시한번 위 코드를 실행시켜보면 아래와 같은 결과가 나온다.
코드는 그대로지만 읽을 수 있는 image 포멧이 늘어났다.
이런 마법과 같은 일이 어떻게 가능할까?
바로 ImageIO 클래스의 내부가 SPI로 구현되어 있기 때문에 기존 코드의 변경 없이 이런 마법이 가능하다. 새로운 이미지 포맷을 읽을 수 있는 구현체만 추가하면 된다. 본 글에서는 코드의 내부를 살펴보는 일은 다루지 않을 것이다. 궁금하다면 내부를 직접 살펴보는도록 하자.
간단하게 SPI 를 이용하는 코드를 보자.
package org.example;
import java.util.ServiceLoader;
public class Main {
public static void main(String[] args) {
ServiceLoader<MessageService> serviceLoader = ServiceLoader.load(MessageService.class);
for (MessageService service : serviceLoader) {
service.sendMessage("Hello, SPI! " + service.getMessageType());
}
}
}
package org.example;
public abstract class MessageService {
abstract void sendMessage(String message);
abstract MessageType getMessageType();
}
package org.example;
public enum MessageType {
EMAIL, SMS
}
package org.example;
public class EmailMessageService extends MessageService {
@Override
public void sendMessage(String message) {
System.out.println("Sending email message: " + message);
}
@Override
MessageType getMessageType() {
return MessageType.EMAIL;
}
}
package org.example;
public class SmsMessageService extends MessageService {
@Override
public void sendMessage(String message) {
System.out.println("Sending SMS message: " + message);
}
@Override
MessageType getMessageType() {
return MessageType.SMS;
}
}
위와 같이 추상클래스를 선언하고 이 클래스를 확장하는 코드를 추가한 뒤
추상클래스의 이름을 파일 명으로 만들고 내부에 구현체의 패키지 명을 포함한 클래스 이름을 넣으면 된다.
이렇게 되면 서비스 로더에서 구현체를 모두 읽을 수 있다.
public class Main {
public static void main(String[] args) {
ServiceLoader<MessageService> serviceLoader = ServiceLoader.load(MessageService.class);
for (MessageService service : serviceLoader) {
service.sendMessage("Hello, SPI! " + service.getMessageType());
}
}
}
근데, 일반적으로 SPI를 이용해 라이브러리를 개발할 일은 많지는 않을 것이다. 근데 이 개념을 스프링프레임워크에 적용하여 어느정도 자동화된 코드를 작성이 가능하다.
상황에 따라 더 적절한 예시가 있을 수 있겠지만 나의 경험으로는 레디스를 이용한 이벤트(pub/sub) 코드를 작성할 때 적당히 활용했다.
나의 상황은 다음과 같았다. 서버가 여러개 떠있고, 여러 서버가 동시에 같은 이벤트를 받아야 하는 상황이었다. 이 때 이벤트의 토픽은 더 늘어날 수도 있는 상황이다.
이 문제를 해결하는 기술로는 레디스의 pub/sub을 이용했고 코드로는 확장과 spi 의 개념을 일부 빌려서 좀 더 오랫동안 살아남을 수 있는 코드를 작성했다.
3개의 토픽이 있다고 했을 때 코드는 아래와 같이 추가가 된다. (MessageListener라는 인터페이스는 spring-data-redis 의존을 추가하면 해당 프로젝트 내에 존재하는 인터페이스며, connectionFactory 설정은 각자 개발환경에 맞게 설정해서 빈에 등록하면 된다.)
@Bean
RedisMessageListenerContainer redisMessageListenerContainer(
RedisConnectionFactory connectionFactory,
MessageListener listener1,
MessageListener listener2,
MessageListener listener3
) {
RedisMessageListenerContainer listenerContainer = new RedisMessageListenerContainer();
listenerContainer.setConnectionFactory(connectionFactory);
listenerContainer.addMessageListener(listener1, PatternTopic.of("topic1"));
listenerContainer.addMessageListener(listener2, PatternTopic.of("topic2"));
listenerContainer.addMessageListener(listener3, PatternTopic.of("topic3"));
return listenerContainer;
}
@Component("listener1")
public class MessegeListener1 implements MessageListener {
@Overried
public void onMessage(Message message, byte[] pattern) {
// 이벤트를 받았을 때 실행할 로직..
}
}
@Component("litener2")
public class MessegeListener2 implements MessageListener {
@Overried
public void onMessage(Message message, byte[] pattern) {
// 이벤트를 받았을 때 실행할 로직..
}
}
@Component("litener3")
public class MessegeListener3 implements MessageListener {
@Overried
public void onMessage(Message message, byte[] pattern) {
// 이벤트를 받았을 때 실행할 로직..
}
}
위와 같은 스타일로 코딩을 할 경우 토픽이 늘어날 때마다 설정 코드도 변경해야 한다는 귀찮음과 부담을 떠맡게 된다. 더 나아가 설정이라는 공용으로 쓰는 객체가 구현체 (litener1, 2, 3...)에 의존 한다는 찝찝함도 있다.
아래부터는 이 문제를 어떻게 해결할지에 대한 나의 주관적인 해답을 적을 것이다. 만약 좀 더 생각해보고 싶다면 아래의 글은 읽지 않는게 좋을 것 같다.
설명 보다는 코드를 보는 것이 더 와닿을테니 코드를 보도록 하자
public interface CustomRedisMessageHandler extends MessageListener {
PatternTopic topic();
}
@Bean
RedisMessageListenerContainer redisMessageListenerContainer(
RedisConnectionFactory connectionFactory,
List<CustomRedisMessageHandler> messageHandlers
) {
RedisMessageListenerContainer listenerContainer = new RedisMessageListenerContainer();
listenerContainer.setConnectionFactory(connectionFactory);
messageHandlers.forEach(messageHandler -> {
listenerContainer.addMessageListener(messageHandler, messageHandler.topic());
});
return listenerContainer;
}
@Component
public class MessegeListener1 implements CustomRedisMessageHandler {
@Overried
public void onMessage(Message message, byte[] pattern) {
// 이벤트를 받았을 때 실행할 로직..
}
@Override
public PatternTopic topic() {
return PatternTopic.of("토픽1");
}
}
@Component
public class MessegeListener2 implements CustomRedisMessageHandler {
@Overried
public void onMessage(Message message, byte[] pattern) {
// 이벤트를 받았을 때 실행할 로직..
}
@Override
public PatternTopic topic() {
return PatternTopic.of("토픽2");
}
}
@Component
public class MessegeListener3 implements CustomRedisMessageHandler {
@Overried
public void onMessage(Message message, byte[] pattern) {
// 이벤트를 받았을 때 실행할 로직..
}
@Override
public PatternTopic topic() {
return PatternTopic.of("토픽3");
}
}
스프링에서는 동일한 타입의 빈이 여러개 등록되어 있을 때 해당 빈들을 컬렉션으로 받을 수 있다.
SPI에서 구현체를 추가하고 META-INF/services 파일에 해당 구현체를 기록하기만 해도 아무도 모르게 구현체의 코드가 작동하고 있는 것처럼, 위 코드도 컴포넌트만 등록하고 인터페이스가 정의한대로 코드를 짜기만 해도 별다른 설정(레디스 리스너 컨테이너에 리스너 등록) 없이 레디스 이벤트를 받는 코드가 잘동작한다. 스프링을 이용하면 스프링 컨테이너가 META-INF/services의 역할을 대신 하니 더 편하기도 하다.
위 글을 읽었을 때 SPI와 무슨 상관이지? 라는 의문이 들 수 있다. 내가 전달하고자 하는것은 어떠한 것을 새롭게 알게 됐을 때 그 지식의 바탕이 되는 아이디어를 통해 어떤 문제를 해결할 수 있는 아이디어를 얻을 수 있다는 것이다. SPI에서 여러 구현체를 컬렉션으로 제공한다는 아이디어는 스프링에서 인터페이스의 여러 구현체를 컬렉션으로 DI 받을 수 있다는 아이디어와 굉장히 유사하다. 이를 활용하여 의존을 역전하고 불필요한 코드를 제거할 수 있다.
위 예시는 내가 이용한 아주 간단한 코딩 스타일의 예시이다. 실제로 지네릭, 인터페이스, 추상클래스의 조합을 이용해서 복잡한 로직을 정말 간단하게 자동화 할 수 있다.