Search

서비스 제공자 프레임워크 service provider framework 이해하기

작성일
2022/09/17 14:51
수정일
카테고리
자바
태그
Architecture

들어가며

이펙티브 자바 item 1에서 등장하는 서비스 제공자 프레임워크에 대해 학습하고, java의 이미지 유틸 클래스 Image IO를 통한 실제 사용 예제를 살펴봅니다.

이펙티브 자바 item 1, 생성자 대신 정적 팩터리 메서드를 고려하라

이펙티브 자바 item 1, 생성자 대신 정적 팩터리 메서드를 고려하라를 보다보면 정적 팩터리 메서드의 장점 5번째로 다음 이유를 거론합니다.
다섯 번째, 정적 팩터리 메서드를 작성하는 시점에는 반환할 객체의 클래스가 존재하지 않아도 된다. 이런 유연함은 서비스 제공자 프레임워크(service provider framework)를 만드는 근간이 된다.
오잉 또잉~? 이해가 잘 가지 않는 문구가 두 줄 연속으로 나옵니다. 한 줄씩 보겠습니다.

Line 1. 반환할 객체의 클래스가 존재하지 않아도 된다.

첫번째 줄, 정적 팩터리 메서드를 작성하는 시점에는 반환할 객체의 클래스가 존재하지 않아도 된다.
이 말은 무슨 말일까요?
팩터리 메서드는 new 연산자와 동일하게 특정 클래스의 인스턴스를 생성하는 역할을 합니다.
그러나 new 연산자는 그 객체 자체를, 그리고 구현체로 생성하도록 강요합니다. new 연산자와 호응하는 생성자constructor는 마지막 라인에 return this; 가 생략되어 있습니다.
하지만 정적 팩토리 메서드는 해당 클래스의 하위타입이기만 하면 어떤 타입을 반환해도 관계없습니다.
햄버거 예시를 통해 살펴 보겠습니다.

Hamburger 예시

햄버거 객체가 있습니다. 햄버거 객체는 재료와 이름으로 이루어져 있습니다.
public class Hamburger { private final Map<String, Ingredient> ingredients = new HashMap<>(); // 기본 생성자 public Hamburger() { } // 정적 팩토리 메서드, 확장성을 위해 String 파라미터를 하나 받겠습니다 public static Hamburger of(String name) { switch (name) { case "hamburger" -> { return new Hamburger(); } default -> throw new IllegalStateException("Unexpected value: " + name); } } }
Java
복사
기본 생성자와 정적 팩토리메서드가 구현된 햄버거 객체
new 연산자인 경우 결과물의 타입은 항상 Hamburger 입니다. 현재로선 정적 팩토리 메서드와 new 생성자는 차이가 없습니다.
Hamburger hamburger1 = new Hamburger(); Hamburger hamburger2 = Hamburger.of("hamburger");
Java
복사
맛있는 햄버거 인스턴스 생성
햄버거가 탄생한 지 1년쯤 흘러, 먼 미래에 Hamburger의 하위 타입인 CheeseBurger가 탄생했습니다. 정적 팩토리 메서드를 활용했다면 기존 메서드를 재활용할 수 있습니다.
public class Hamburger { private final Map<String, Ingredient> ingredients = new HashMap<>(); // prevent use default constructor private Humburger(){} public static Hamburger of(String name) { switch (name) { case "hamburger" -> { return new Hamburger(); } case "cheese" -> { return new ChessesBurger(); // extends Hamburger class } default -> throw new IllegalStateException("Unexpected value: " + name); } } }
Java
복사
정적 팩토리 메서드에서 새로운 객체 타입을 반환하도록 수정한 햄버거
// new를 활용하여 치즈버거 인스턴스를 받고 싶다면, 반드시 현재 치즈버거 객체가 구현되어있어야 합니다. Hamburger cheeseBurger = new CheeseBurger(); // 하지만 정적 팩토리 메서드를 활용한다면, 나중에 인자로 전달하는 name만 수정하면 됩니다. // CheeseBurger가 없을 땐 IllegalStateException, 존재하면 Cheessburger 반환 Hamburger cheeseBurger = new Hamburger("cheese");
Java
복사
사용 코드 입장에선 작성 시점에 치즈버거가 없어도 관련없다.
이제 <정적 팩터리 메서드를 작성하는 시점에는 반환할 객체의 클래스가 존재하지 않아도 된다.> 를 다시 보겠습니다. 이 말은 이렇게 다시 쓸 수 있습니다.
→ 정적 팩토리 메서드를 활용하면, 반환할 객체의 클래스가 컴파일 타임에는 존재하지 않아도 된다. 특정한 하위 타입이 필요한 런타임 시점 때, 정적 팩토리 메서드 내에서 찾을 수만 있으면 된다.
그래서?
이 개념은 서비스 제공자 프레임워크에서 서비스 접근 API를 구현하는데 활용됩니다.

Line 2. 서비스 제공자 프레임워크를 만드는 근간이 된다.

그러면 서비스 제공자 프레임워크는 무엇일까요?
이펙티브 자바에서는 서비스 제공자 프레임워크에 대해 이렇게 설명합니다.
서비스 제공자 프레임워크(service provider framework)에서 서비스를 제공하는 제공자(provider)는 서비스의 구현체다. 이 구현체들을 클라이언트에 제공하는 역할을 프레임워크가 통제하여, 클라이언트를 구현체로부터 분리해준다.
그리고 3개(상황에 따라 4개)의 핵심 컴포넌트로 이루어져있다고 말하는데요,

핵심 컴포넌트

1.
서비스 인터페이스 service interface
a.
구현체의 동작을 정의합니다.
2.
제공자 등록 API (provider registation API)
a.
제공자가 구현체를 등록할 때 사용합니다.
3.
서비스 접근 API (service access API)
a.
클라이언트가 서비스 인스턴스를 얻을 때 사용합니다.
4.
서비스 제공자 인터페이스 (service provider interface) - 쓰일 때도 있음
a.
서비스 구현체를 인스턴스로 만들 때 팩토리 역할을 합니다. 해당 인터페이스가 없다면 리플렉션을 활용해야 합니다.
음……
글만 읽고 다 이해하고 싶다
다행히 조슈아 블로크 센세는 JDBC(Java Database Connectivity) 프레임워크를 예시로 들어 주셨습니다. JDBC를 예시로 이해해봅시다.

JDBC 예시

 이하 예제는 실제 JDBC 구현과는 무관한 상상 속의 코드입니다.
JDBC는 java를 사용해 데이터베이스에 접속할 수 있는 API를 제공하는 프레임워크입니다.
JDBC가 제공하는 서비스는 무엇일까요? “Java를 통해 DB와 소통할 수 있도록 커넥션Connection을 만들어준다” 입니다.
해당 기능을 구현하는 개발자가 되었다고 생각해봅시다. 상용 DB는 여러 종류가 있기 때문에, 각 DB마다 커넥션을 획득하는 방법이 다를 겁니다. 하지만 JDBC 사용자는 어떤 DB든 JDBC를 통해 커넥션을 획득하는 것을 원할 것이기 때문에, 미리 모든 DB의 접속 방법을 구현해두어야 할 것입니다.
class Jdbc { Map<String, Driver> DbDrivers = new HashMap<>(); static{ // 현존하는 모든 드라이버를 미리 setting해 놓아야 한다. DbDrivers.put("mysql", new MysqlDriver()); DbDrivers.put("oracle", new OracleDriver()); DbDrivers.put("postgresql", new PostgresqlDriver()); DbDrivers.put("redis", new RedisDriver()); // .... } // Connection 객체의 정적 팩토리 메서드! public static Connection getConnection(String dbName){ Driver dbDriver = DbDriverNameMap.get(dbName); if(dbDriver == null){ throw new IllegalArgumentException("잘못된 DB name"); } return dbDriver.getConnection(); } }
Java
복사
미리 모든 Driver를 등록해놓는 JDBC 클래스
뿌듯함도 잠시… 기존 데이터베이스의 끊임없는 버전과, 새롭게 시장에 떠오르는 신흥 데이터베이스들이 등장합니다. 우리는 그때마다 JDBC 라이브러리를 업데이트하고 재배포해야합니다. JDBC 개발자는 아주 억울해집니다.
 ”이걸로 돈 버는건 DB 회사인데? 자기들이 만드는 게 맞지 않나? 아! 일하기 싫어.”  ”흠… 이제부터는 DB회사가 새 제품내면 Java 드라이버는 DB회사, 즉 서비스 제공자Provider가 등록하라고 해야겠다. JDBC는 인터페이스만 제공하고 중개만 하는거지.”  ”이게 완료되면 새로운 제품이 나와도 JDBC는 업데이트 하지 않아도 되겠어. 휴가 갈 수 있을지도...”
서비스 제공자 프레임워크는, 이와같이 서비스를 제공하는 자가 직접 서비스를 구현하여 등록하면, 사용자가 쓸 수 있도록 중개해주는 프레임워크를 뜻합니다.
프레임워크를 만들기 위한 핵심 컴포넌트도 JDBC 입장에서 다시 톺아보겠습니다.

JDBC의 핵심 컴포넌트

서비스 인터페이스 : 커넥션 객체를 생성하는 인터페이스를 정의합니다. 각 DB회사는 이 인터페이스를 구현하여 JDBC에 등록해야 합니다.
package java.jdbc.driver interface DbDriver { String getName(); Connection getConnection(); }
Java
복사
제공자 등록 API : 각 DB회사가 DbDriver 객체를 열심히 구현하였습니다. 하지만 이 구현체를 등록해주려면 PR을 리뷰하고, 레포지토리에 해당 코드를 병합하고, 드라이버를 미리 세팅하는 코드를 추가하고, 재배포 해주어야 합니다. 이런 일을 막으려면 서비스 제공자가 구현체를 구현만 해놓으면 자동으로 등록할 수 있는 메커니즘을 만들어야 합니다. java 6부터 지원되는 공용 서비스 제공자 프레임워크 ServiceLoader는, 서비스 제공자가 구현한 서비스 jar파일 내 META-INF/services 폴더에 서비스 인터페이스의 구현 위치를 등록해놓으면 자동으로 찾을 수 있는 look up 메커니즘을 지원합니다.
1.
서비스 제공자는 서비스의 구현체를 구현합니다.
package com.mydb.driver; // JDBC 프레임워크에 추가하는 게 아니라, 프레임워크의interface를 import해와서 구현합니다. import java.jdbc.driver class MyDbDriver implements DbDriver { public String getName(){ return "나만의DB" } public Connection getConnection() { return 쿵따리샤바라(); } private Connection 쿵따리샤바라(){ // 엄청난 로직 } }
Java
복사
2.
그리고 프레임워크의 look up 메커니즘에서 찾을 수 있도록 형식에 알맞는 file을 남깁니다. ServiceLoader에서는 class path에 포함된 jar 파일의 META-INF/services 폴더를 순회하며 등록 가능한 구현체가 있는지 찾습니다.
# META-INF/services/com.jdbc.driver.DbDriver 경로에 구현하고 배포하면 com.mydb.driver.MyDbDrvier
Shell
복사
META-INF/services/서비스명 디렉토리에 구현체 path를 작성한다.
3.
JDBC 개발자는 서비스 로더를 이용해 구현체를 자동 등록할 수 있도록 프레임워크를 구현합니다.
class Jdbc { Map<String, DbDriver> DbDrivers = new HashMap<>(); static{ // 공용 서비스 제공자 유틸을 사용해 서비스 제공자의 구현체를 받음 ServiceLoader<DbDriver> loader = ServiceLoader.load(DbDriver.class); loader.forEach( dbdriver -> DbDrivers.put(dbdriver.getName(). dbdriver); ); } // Connection 객체의 정적 팩토리 메서드! public static Connection getConnection(String dbName){ Driver dbDriver = DbDriverNameMap.get(dbName); if(dbDriver == null){ throw new IllegalArgumentException("잘못된 DB name"); } return dbDriver.getConnection(); } }
Kotlin
복사
4.
그러므로 사용자는 서비스 제공자의 구현체만 class path에 포함하면, 자동으로 MyDbDriver를 찾아서 사용할 수 있게 됩니다.
// 사용자의 build.gradle implementation("com.mydb.driver")
Kotlin
복사
서비스 접근 API : 사용자가 서비스 제공자의 구현체를 얻을 수 있게 해주는 API 입니다. 우리의 작고 귀여운 JDBC 예제에서 DB Driver를 직접 얻을 수 있는 API는 없지만, getConnection(String dbName) API에서 “나만의DB”라고 넘기면 MyDb의 Connection 객체를 얻을 수 있으므로 이를 서비스 접근 API라고 표현해도 무방할 거 같습니다.
package com.mad.developer public static void main(String[] args) { Connection connection = Jdbc.getConnection("나만의DB"); connection.updateQuery("DELETE FROM myTable WHERE 1=1"); }
Java
복사
위험한 쿼리를 날리는 사용자
위처럼 구현하면, JDBC 개발자는 더 이상 새롭게 출시된 DB Driver를 추가하기 위해 배포할 필요가 없습니다! DB 회사들은 새롭게 출시한 DbDriver 인터페이스를 구현하여 배포하고, 사용자는 해당 Driver를 다운로드받아 JDBC와 같은 클래스 패스에 위치시키기만 하면 런타임 때 사용할 수 있습니다.
여기서 정적 팩토리 메서드는 제공자 등록 API에서 Connection의 하위 타입을 리턴할 수 있게 하여, 미래의 구현될 Connection 객체도 반환할 수 있는데 혁혁한 공을 세웠습니다.
이제 처음으로 돌아와, 다음 문장을 이해할 수 있습니다.
다섯 번째, 정적 팩터리 메서드를 작성하는 시점에는 반환할 객체의 클래스가 존재하지 않아도 된다. 이런 유연함은 서비스 제공자 프레임워크(service provider framework)를 만드는 근간이 된다.

실제 사용 사례 - Image IO

ImageIO는 CYMK 칼라 타입을 read하지 못 한다.

자바에는 Image를 처리하는 유틸 클래스인 ImageIO 클래스가 있습니다. ImageIO 클래스에선 서비스 제공자 프레임워크를 사용해 ImageReaderSpi, ImageWriterSpi를 서비스 제공자가 등록할 수 있게 지원합니다.
temurin-17 버전 기준 ImageIO.read() 클래스는 안타깝게도 CYMK 칼라 방식의 JPG 파일을 read하지 못합니다.
 JPG 이미지 형식은 CYMK(Cyan 청록, Yellow노랑, Magenta 자홍, Key 검정) 칼라 타입과 RGB (Red Green Blue)타입을 지원합니다.
아래 테스트는 실패하는 테스트입니다.
... import javax.imageio.ImageIO; import org.apache.commons.io.IOUtils; ... class ImageTest { @DisplayName("다양한 형식의 jpg 파일을 ImageIO로 읽을 수 있는지 확인") @ParameterizedTest @ValueSource(strings = { "rgb-color-mode.jpg", // color mode가 rgb "cymk-color-mode.jpg", // color mode가 cymk }) void typeReadTest(String fileName) throws IOException { ClassPathResource resource = new ClassPathResource(fileName); InputStream input = resource.getInputStream(); ByteArrayOutputStream baos = new ByteArrayOutputStream(); IOUtils.copy(input, baos); byte[] bytes = baos.toByteArray(); // when BufferedImage result = ImageIO.read(new ByteArrayInputStream(bytes)); // then - no exception assertThat(result).isNotNull(); } }
Java
복사
RGB 타입은 read하지만, CYMK타입은 IO Exception이 발생한다. (javax.imageio.IIOException: Unsupported Image Type)
이를 해결하려면 CYMK 타입도 읽을 수 있도록 ImageReaderSpi를 구현해주면 됩니다. 하지만 저는 이미지에 대해서 쥐뿔도 모릅니다..  다행히 누군가 이 문제를 해결해 두었습니다.
TwelveMonkeys
haraldk
위 라이브러리는 다양한 타입의 ImageReaderSpi와 ImageWriterSpi를 구현해두었습니다. 지원하는 이미지 타입 목록은 해당 깃헙 Readme.md에 잘 작성되어 있습니다.
implementation("com.twelvemonkeys.imageio:imageio-jpeg:3.8.2")
Kotlin
복사
CYMK 타입을 read할 수 있는 ImageReaderSpi 추가
제가 할 일은 여느날과 마찬가지로 선배들의 아름다운 발자취에 감읍하며 12몽키즈에게 절을 하는 것과 위 라이브러리를 포함하여 새로 빌드하는 것 뿐입니다.
JetBrain과 모든 OpenSource 개발자분들께..
빌드를 마치고 재실행하면 성공  여부를 확인할 수 있습니다.
서비스 제공자의 코드를 추가 한 후 성공하는 테스트

서비스 제공자 프레임워크의 발자취 찾아보기

서비스 제공자인 twelvemonkeys의 META-INF 폴더를 확인해보면, ServiceLoader의 제공 형식에 맞게 구현체의 위치를 명시해놓았습니다.
서비스 제공자의 구현체 위치 명시
서비스 제공자 프레임워크인 ImageIO에선 해당 구현체를 이용해 read하고 있습니다. ImageIO.read() 코드 하위의 getImageReaders()의 코드 중 일부입니다.
ImageReaderSpi의 구현체를 반환하고 있는 theRegistry 인스턴스
theRegistry의 클래스인 IIORegistry는 어플리케이션 클래스패스를 기준으로 ServiceLoader를 활용해 서비스 제공자 구현체를 캐시해 놓는다.

정리

서비스 제공자 프레임워크의 핵심 컴포넌트 관점에서 보면 다음과 같이 정리할 수 있습니다.
구분
정의
ImageIO에서 해당 역할을 하는 것
서비스 인터페이스
구현체의 동작을 정의합니다.
ImageReaderSpi 인터페이스
제공자 등록 API
제공자가 구현체를 등록할 때 사용합니다.
ServiceLoader의 lookup 메커니즘을 이용해 등록
서비스 접근 API
클라이언트가 서비스 인스턴스를 얻을 때 사용합니다.
ImageIO.read()

마무리

첫 시작은 CYMK 타입 이미지 파일을 읽기 위해 구글링하다가, 단순히 implement 하기만 하면 기존에 실패하던 기능이 성공하는 게 신기해서 조사하게 되었습니다. 고도로 발달한 DI 프레임워크인 스프링도 서비스 제공자 프레임워크의 일종으로 볼 수 있습니다.
학습해보니 미래에 구현될 기능 변경사항도 대응할 수 있는 멋진 패턴이라는 생각이 들었습니다.
다들 행복하세요.

레퍼런스

백기선님 유투브 영상 ‘자바, 서비스 제공자 프레임워크’ https://www.youtube.com/watch?v=R-YvrkKkOAM