구현체를 설정 파일로 배선하는 JDK 표준 메커니즘(Service Provider Interface).
public interface PaymentGateway { Receipt charge(long amount); }
// META-INF/services/com.example.PaymentGateway 파일 내용
// com.example.tosspay.TossGateway
ServiceLoader<PaymentGateway> loaded = ServiceLoader.load(PaymentGateway.class);
PaymentGateway gw = loaded.findFirst().orElseThrow();
무엇이 특별한가
-
애플리케이션 코드에 구현 클래스 이름이 '한 번도' 등장하지 않는다
-
jar 을 클래스패스에 넣고 빼는 것만으로 구현이 바뀐다
-
진짜 의미의 플러그인 구조
-
컴파일 의존도 없다. 구현 jar 이 없으면 그냥 비어 있을 뿐 컴파일은 된다
실제로 이 위에서 도는 것들
- JDBC — DriverManager 가 java.sql.Driver 구현을 찾는다
- (그래서 Class.forName("com.mysql.cj.jdbc.Driver") 가 필요 없어졌다)
- SLF4J — 바인딩 jar 을 하나 넣으면 그것이 선택된다
- 두 개 넣으면 "Class path contains multiple SLF4J bindings" 경고
- JAXP — DocumentBuilderFactory.newInstance() 의 구현 탐색
- 문자셋·로케일 — Charset · LocaleServiceProvider 확장
모듈 시스템에서의 선언
// module-info.java — 파일 대신 언어 문법으로 선언한다
module com.example.tosspay {
provides com.example.PaymentGateway with com.example.tosspay.TossGateway;
}
// 사용하는 쪽
module com.example.app {
uses com.example.PaymentGateway;
}
한계 — 왜 스프링을 대체하지 못하나
- 인스턴스를 만들 때 '기본 생성자' 만 쓴다
- 의존성을 주입할 방법이 없다
- 선택 규칙이 없다 (클래스패스 순서에 의존)
- 여러 구현 중 무엇을 쓸지 조건부로 고르기 어렵다
- 생명주기 관리가 없다 (초기화·소멸 훅 없음)
그래서 '프레임워크 확장점' 에는 맞고, '애플리케이션 빈 배선' 에는 DI 컨테이너를 쓴다
로딩은 지연되고, 실패는 늦게 드러난다
ServiceLoader<Gateway> loader = ServiceLoader.load(Gateway.class);
// 이 시점에는 아무것도 안 만든다
for (Gateway g : loader) { ... } // 순회할 때 하나씩 인스턴스화된다
구현 클래스가 없거나 생성자가 실패하면 순회 도중에 ServiceConfigurationError 가 난다 — 시작 시점이 아니다
- 애플리케이션 기동 시 한 번 훑어 검증하는 편이 안전하다
- 운영 중 첫 요청에서 터지는 것보다 낫다
스프링에서의 대응물
spring.factories / AutoConfiguration.imports
-
스프링 부트의 자동 설정이 같은 발상으로 동작한다
-
jar 을 넣으면 설정이 따라 들어온다
-
차이 — ServiceLoader 는 기본 생성자만 쓴다
- 스프링은 조건(@ConditionalOnClass · @ConditionalOnMissingBean)으로
- '언제 켤지' 까지 표현할 수 있다
즉 스프링 부트의 '의존성만 추가하면 동작한다' 는 SPI 발상에 조건부 배선을 얹은 결과다
면접 함정
- ❌ "ServiceLoader는 싱글톤을 준다" →
load()를 다시 부르면 새로 만든다. 반복 호출은 캐싱해야 한다. - ❌ "인터페이스만 있으면 동작한다" → 구현 쪽에 등록 파일 또는
provides선언이 있어야 한다.