ServiceLoader와 SPI — 플러그인 아키텍처를 자바로 구현하는 방법
JDBC 드라이버는 JAR만 클래스패스에 넣으면 자동으로 인식되는데, 이 마법 같은 일이 어떻게 가능할까요?
SPI(Service Provider Interface) 는 프레임워크가 인터페이스를 정의하고, 제3자가 그 구현체를 제공하는 패턴입니다. ServiceLoader가 META-INF/services/ 디렉토리를 스캔하여 구현체를 자동으로 발견합니다.
JDBC 4.0 이전에는 Class.forName("com.mysql.jdbc.Driver")를 직접 호출해야 했지만, SPI 도입 이후 드라이버 JAR만 클래스패스에 넣으면 자동 인식됩니다.
SPI의 구조
┌─────────────────┐ ┌──────────────────────┐
│ 서비스 (API) │ │ 서비스 제공자 (구현체) │
│ - 인터페이스 정의 │←────│ - 인터페이스 구현 │
│ - ServiceLoader │ │ - META-INF/services │
│ 로 구현체 발견 │ │ 에 등록 │
└─────────────────┘ └──────────────────────┘
일반적인 API와의 차이:
- API: 라이브러리가 구현을 제공하고, 사용자가 호출
- SPI: 프레임워크가 인터페이스를 제공하고, 사용자(또는 제3자)가 구현
기본 사용법
1단계: 서비스 인터페이스 정의
package com.myapp.spi;
public interface MessageFormatter {
String format(String message);
String name();
}
2단계: 서비스 제공자(구현체) 작성
package com.myapp.formatters;
public class JsonFormatter implements MessageFormatter {
@Override
public String format(String message) {
return "{\"message\": \"" + message + "\"}";
}
@Override
public String name() {
return "JSON";
}
}
package com.myapp.formatters;
public class XmlFormatter implements MessageFormatter {
@Override
public String format(String message) {
return "<message>" + message + "</message>";
}
@Override
public String name() {
return "XML";
}
}
3단계: META-INF/services에 등록
META-INF/services/com.myapp.spi.MessageFormatter 파일을 생성합니다.
com.myapp.formatters.JsonFormatter
com.myapp.formatters.XmlFormatter
파일 이름이 ** 서비스 인터페이스의 FQCN**이고, 내용이 ** 구현체의 FQCN 목록 **입니다.
4단계: ServiceLoader로 로드
ServiceLoader<MessageFormatter> loader =
ServiceLoader.load(MessageFormatter.class);
// 모든 구현체 순회
for (MessageFormatter formatter : loader) {
System.out.println(formatter.name() + ": " +
formatter.format("Hello"));
}
// 특정 구현체 찾기
Optional<MessageFormatter> jsonFormatter = loader.stream()
.filter(p -> p.type() == JsonFormatter.class)
.findFirst()
.map(ServiceLoader.Provider::get);
ServiceLoader의 동작 원리
ServiceLoader.load(MessageFormatter.class)
이 한 줄이 내부적으로 하는 일:
1. Thread의 Context ClassLoader 획득
2. META-INF/services/com.myapp.spi.MessageFormatter 파일을 찾음
3. 파일의 각 줄(구현 클래스 FQCN)을 읽음
4. Iterator로 순회할 때 해당 클래스를 로드하고 인스턴스화 (지연 로딩)
5. 구현 클래스는 public 무인자 생성자가 있어야 함
지연 로딩
ServiceLoader<MessageFormatter> loader =
ServiceLoader.load(MessageFormatter.class);
// 여기서는 아직 아무 구현체도 인스턴스화되지 않음
Iterator<MessageFormatter> it = loader.iterator();
MessageFormatter first = it.next(); // 이때 첫 번째 구현체 인스턴스화
캐싱과 reload
// 한 번 로드된 서비스 제공자는 캐싱됨
for (MessageFormatter f : loader) { /* ... */ } // 첫 순회: 로드 + 캐싱
for (MessageFormatter f : loader) { /* ... */ } // 두 번째 순회: 캐시에서 반환
// 캐시를 지우고 다시 로드
loader.reload();
JDBC 드라이버 로딩 — SPI의 대표 사례
// JDBC 4.0 이전
Class.forName("com.mysql.cj.jdbc.Driver"); // 직접 로드
Connection conn = DriverManager.getConnection(url);
// JDBC 4.0 이후 — SPI로 자동 로드
Connection conn = DriverManager.getConnection(url);
// DriverManager가 ServiceLoader로 java.sql.Driver 구현체를 자동 탐색
MySQL 드라이버 JAR 안에는 다음 파일이 있습니다:
META-INF/services/java.sql.Driver
→ com.mysql.cj.jdbc.Driver
DriverManager의 내부 코드:
// DriverManager 초기화 시
ServiceLoader<Driver> loadedDrivers =
ServiceLoader.load(Driver.class);
Iterator<Driver> driversIterator = loadedDrivers.iterator();
while (driversIterator.hasNext()) {
driversIterator.next(); // 드라이버 로드 + 자동 등록
}
JPMS에서의 SPI
Java 모듈 시스템과 함께 사용할 때는 module-info.java에 선언합니다.
서비스 인터페이스 모듈
module com.myapp.spi {
exports com.myapp.spi;
}
서비스 제공자 모듈
module com.myapp.formatters {
requires com.myapp.spi;
provides com.myapp.spi.MessageFormatter
with com.myapp.formatters.JsonFormatter,
com.myapp.formatters.XmlFormatter;
}
서비스 소비자 모듈
module com.myapp.core {
requires com.myapp.spi;
uses com.myapp.spi.MessageFormatter;
}
JPMS에서는 META-INF/services 파일 대신 module-info.java의 provides/uses를 사용합니다. 컴파일 타임에 의존성을 검증할 수 있다는 장점이 있습니다.
플러그인 아키텍처 예제
실제 플러그인 시스템을 만들어 봅니다.
플러그인 인터페이스
public interface Plugin {
String id();
String name();
void initialize(Map<String, String> config);
void execute(PluginContext context);
default int priority() { return 0; }
default void shutdown() { }
}
플러그인 매니저
public class PluginManager {
private final Map<String, Plugin> plugins = new LinkedHashMap<>();
public void loadPlugins() {
ServiceLoader<Plugin> loader = ServiceLoader.load(Plugin.class);
// 우선순위 순으로 정렬하여 로드
loader.stream()
.map(ServiceLoader.Provider::get)
.sorted(Comparator.comparingInt(Plugin::priority).reversed())
.forEach(plugin -> {
plugins.put(plugin.id(), plugin);
System.out.println("플러그인 로드: " + plugin.name());
});
}
public Optional<Plugin> getPlugin(String id) {
return Optional.ofNullable(plugins.get(id));
}
public void executeAll(PluginContext context) {
plugins.values().forEach(p -> {
try {
p.execute(context);
} catch (Exception e) {
System.err.println(p.name() + " 실행 실패: " + e.getMessage());
}
});
}
public void shutdown() {
plugins.values().forEach(plugin -> {
try {
plugin.shutdown();
} catch (Exception e) {
// 로그 기록
}
});
}
}
플러그인 구현
public class LoggingPlugin implements Plugin {
@Override public String id() { return "logging"; }
@Override public String name() { return "Logging Plugin"; }
@Override public int priority() { return 100; }
@Override
public void initialize(Map<String, String> config) {
// 로깅 설정 초기화
}
@Override
public void execute(PluginContext context) {
System.out.println("[LOG] " + context.getMessage());
}
}
다른 SPI 활용 사례
JDK와 프레임워크에서 SPI를 활용하는 대표적인 사례들입니다.
| 서비스 인터페이스 | 사용처 |
|---|---|
java.sql.Driver | JDBC 드라이버 자동 로드 |
java.nio.file.spi.FileSystemProvider | 커스텀 파일 시스템 (예: ZIP) |
java.nio.charset.spi.CharsetProvider | 커스텀 문자셋 |
javax.sound.sampled.spi.AudioFileReader | 오디오 파일 형식 |
java.util.spi.LocaleServiceProvider | 로케일 서비스 |
jakarta.servlet.ServletContainerInitializer | 서블릿 컨테이너 초기화 |
주의할 점
public 무인자 생성자가 없으면 ServiceConfigurationError
ServiceLoader는 newInstance()로 인스턴스를 생성합니다. 생성자에 매개변수가 있거나 private이면 ServiceConfigurationError가 발생합니다. 의존성 주입이 필요하면 initialize(config) 같은 별도 초기화 메서드를 만들어야 합니다.
구현체 하나가 실패하면 전체 순회가 중단된다
ServiceLoader.load() 자체는 성공하지만, Iterator로 순회하다가 하나의 구현체 인스턴스화에 실패하면 ServiceConfigurationError가 throw되어 나머지 구현체도 로드되지 않습니다. 프로덕션에서는 stream() API를 사용하여 각 Provider를 개별 try-catch로 감싸야 합니다.
ServiceLoader는 스레드 안전하지 않다
멀티스레드 환경에서 같은 ServiceLoader 인스턴스의 Iterator를 동시에 사용하면 ConcurrentModificationException이 발생할 수 있습니다. 로드 결과를 List에 캐싱하거나 동기화가 필요합니다.
정리
| 항목 | 설명 |
|---|---|
| SPI 패턴 | 프레임워크가 인터페이스 정의, 제3자가 구현체 제공 |
| 등록 방법 | META-INF/services/인터페이스FQCN 파일에 구현체 FQCN 기록 |
| 로딩 방식 | ServiceLoader.load()로 자동 발견. 지연 로딩(Iterator 순회 시 인스턴스화) |
| 대표 사례 | JDBC 드라이버, Servlet Container Initializer, NIO FileSystemProvider |
| JPMS 연동 | provides ... with ... / uses로 컴파일 타임 검증 가능 |
| 주의사항 | public 무인자 생성자 필수, 순서 보장 없음, 스레드 안전하지 않음 |