JDBC 드라이버는 JAR만 클래스패스에 넣으면 자동으로 인식되는데, 이 마법 같은 일이 어떻게 가능할까요?

SPI(Service Provider Interface) 는 프레임워크가 인터페이스를 정의하고, 제3자가 그 구현체를 제공하는 패턴입니다. ServiceLoaderMETA-INF/services/ 디렉토리를 스캔하여 구현체를 자동으로 발견합니다.

JDBC 4.0 이전에는 Class.forName("com.mysql.jdbc.Driver")를 직접 호출해야 했지만, SPI 도입 이후 드라이버 JAR만 클래스패스에 넣으면 자동 인식됩니다.

SPI의 구조

PLAINTEXT
┌─────────────────┐     ┌──────────────────────┐
│  서비스 (API)     │     │  서비스 제공자 (구현체)  │
│  - 인터페이스 정의  │←────│  - 인터페이스 구현      │
│  - ServiceLoader │     │  - META-INF/services  │
│    로 구현체 발견   │     │    에 등록             │
└─────────────────┘     └──────────────────────┘

일반적인 API와의 차이:

  • API: 라이브러리가 구현을 제공하고, 사용자가 호출
  • SPI: 프레임워크가 인터페이스를 제공하고, 사용자(또는 제3자)가 구현

기본 사용법

1단계: 서비스 인터페이스 정의

JAVA
package com.myapp.spi;

public interface MessageFormatter {
    String format(String message);
    String name();
}

2단계: 서비스 제공자(구현체) 작성

JAVA
package com.myapp.formatters;

public class JsonFormatter implements MessageFormatter {
    @Override
    public String format(String message) {
        return "{\"message\": \"" + message + "\"}";
    }

    @Override
    public String name() {
        return "JSON";
    }
}
JAVA
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 파일을 생성합니다.

PLAINTEXT
com.myapp.formatters.JsonFormatter
com.myapp.formatters.XmlFormatter

파일 이름이 ** 서비스 인터페이스의 FQCN**이고, 내용이 ** 구현체의 FQCN 목록 **입니다.

4단계: ServiceLoader로 로드

JAVA
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의 동작 원리

JAVA
ServiceLoader.load(MessageFormatter.class)

이 한 줄이 내부적으로 하는 일:

PLAINTEXT
1. Thread의 Context ClassLoader 획득
2. META-INF/services/com.myapp.spi.MessageFormatter 파일을 찾음
3. 파일의 각 줄(구현 클래스 FQCN)을 읽음
4. Iterator로 순회할 때 해당 클래스를 로드하고 인스턴스화 (지연 로딩)
5. 구현 클래스는 public 무인자 생성자가 있어야 함

지연 로딩

JAVA
ServiceLoader<MessageFormatter> loader =
    ServiceLoader.load(MessageFormatter.class);
// 여기서는 아직 아무 구현체도 인스턴스화되지 않음

Iterator<MessageFormatter> it = loader.iterator();
MessageFormatter first = it.next(); // 이때 첫 번째 구현체 인스턴스화

캐싱과 reload

JAVA
// 한 번 로드된 서비스 제공자는 캐싱됨
for (MessageFormatter f : loader) { /* ... */ } // 첫 순회: 로드 + 캐싱
for (MessageFormatter f : loader) { /* ... */ } // 두 번째 순회: 캐시에서 반환

// 캐시를 지우고 다시 로드
loader.reload();

JDBC 드라이버 로딩 — SPI의 대표 사례

JAVA
// 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 안에는 다음 파일이 있습니다:

PLAINTEXT
META-INF/services/java.sql.Driver
→ com.mysql.cj.jdbc.Driver

DriverManager의 내부 코드:

JAVA
// DriverManager 초기화 시
ServiceLoader<Driver> loadedDrivers =
    ServiceLoader.load(Driver.class);
Iterator<Driver> driversIterator = loadedDrivers.iterator();
while (driversIterator.hasNext()) {
    driversIterator.next(); // 드라이버 로드 + 자동 등록
}

JPMS에서의 SPI

Java 모듈 시스템과 함께 사용할 때는 module-info.java에 선언합니다.

서비스 인터페이스 모듈

JAVA
module com.myapp.spi {
    exports com.myapp.spi;
}

서비스 제공자 모듈

JAVA
module com.myapp.formatters {
    requires com.myapp.spi;

    provides com.myapp.spi.MessageFormatter
        with com.myapp.formatters.JsonFormatter,
             com.myapp.formatters.XmlFormatter;
}

서비스 소비자 모듈

JAVA
module com.myapp.core {
    requires com.myapp.spi;
    uses com.myapp.spi.MessageFormatter;
}

JPMS에서는 META-INF/services 파일 대신 module-info.javaprovides/uses를 사용합니다. 컴파일 타임에 의존성을 검증할 수 있다는 장점이 있습니다.

플러그인 아키텍처 예제

실제 플러그인 시스템을 만들어 봅니다.

플러그인 인터페이스

JAVA
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() { }
}

플러그인 매니저

JAVA
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) {
                // 로그 기록
            }
        });
    }
}

플러그인 구현

JAVA
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.DriverJDBC 드라이버 자동 로드
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 무인자 생성자 필수, 순서 보장 없음, 스레드 안전하지 않음
댓글 로딩 중...