본문 바로가기

카테고리 없음

Java의 SPI(Service Provider Interface)와 응용

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 받을 수 있다는 아이디어와 굉장히 유사하다. 이를 활용하여 의존을 역전하고 불필요한 코드를 제거할 수 있다.

 

위 예시는 내가 이용한 아주 간단한 코딩 스타일의 예시이다. 실제로 지네릭, 인터페이스, 추상클래스의 조합을 이용해서 복잡한 로직을 정말 간단하게 자동화 할 수 있다.