다시보는 개방 폐쇄 원칙(OCP)
클래스나 모듈은 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다.
우리가 작성한 코드를 보면 다음과 같은 특징이 있다. (두 특징을 가진 코드가 혼재되어 있다)
변경을 통해서 기능의 확장을 이루려 하는 코드가 있다.
우리는 더욱더 유연한 코드를 만들기 위해서 다음을 생각해보아야 한다.
각각 다른 목적과 이유에 의해 다른 시점에 독립적으로 변경될 수 있는 효율적인 구조를 만들어야 한다.
템플릿이란? (템플릿 콜백 패턴)
코드 중에는 변경이 거의 일어나지 않으며, 일정한 패턴으로 유지되는 특성을 가진 부분을 (템플릿)
자유롭게 변경이 되는 특성을 가지 부분으로부터 독립시켜 효과적으로 사용할 수 있도록 하는 방법 (콜백)
WebApiExRateProvider 리팩토링
기존 WebApiExRateProvider
클래스 리팩토링 포인트
불필요한 throws IOException
제거
Java 20 에서 @Deprecated
된 URL
제거
WebApiExRateProvider
코드의 예외 처리
try-with-resource
를 이용한 리소스 반환 (AutoCloseable)
package spring.hellospring.exrate;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;
import spring.hellospring.payment.ExRateProvider;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.math.BigDecimal;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.stream.Collectors;
public class WebApiExRateProvider implements ExRateProvider {
public BigDecimal getExRate(String currency) {
String url = "https://open.er-api.com/v6/latest/" + currency;
URI uri;
try {
uri = new URI(url);
} catch (URISyntaxException e) {
throw new RuntimeException(e);
}
String response;
try{
HttpURLConnection connection = (HttpURLConnection) uri.toURL().openConnection();
try(BufferedReader br = new BufferedReader(new InputStreamReader(connection.getInputStream()))){
response = br.lines().collect(Collectors.joining());
}
} catch (IOException e) {
throw new RuntimeException(e);
}
try {
ObjectMapper mapper = new ObjectMapper();
ExRateData data = mapper.readValue(response, ExRateData.class);
return data.rates().get("KRW");
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}
변하는 코드 분리하기 - 메서드 추출
WebApiExRateProvider 구성
URI 를 준비하고, 예외처리를 위한 작업을 하는 코드
API 로부터 환율 정보를 가져오는 코드의 기본 틀 (변경X)
API 를 실행하고, 서버로부터 받은 응답을 가져오는 코드
API 를 호출하는 기술과 방법이 변경될 수 있다. (변경O)
JSON 문자열을 파싱하고, 필요한 환율정보를 파싱하는 코드
API 응답의 JSON 의 구조에 따라서 정보를 추출하는 방식이 변경 (변경O)
변하는 코드 부분을 메서드로 추출하자.
package spring.hellospring.exrate;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;
import spring.hellospring.payment.ExRateProvider;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.math.BigDecimal;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.stream.Collectors;
public class WebApiExRateProvider implements ExRateProvider {
public BigDecimal getExRate(String currency) {
String url = "https://open.er-api.com/v6/latest/" + currency;
URI uri;
try {
uri = new URI(url);
} catch (URISyntaxException e) {
throw new RuntimeException(e);
}
String response;
try{
// 변하는 코드
response = executeApi(uri);
} catch (IOException e) {
throw new RuntimeException(e);
}
try {
// 변하는 코드
return parseExRate(response);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
private static BigDecimal parseExRate(String response) throws JsonProcessingException {
ObjectMapper mapper = new ObjectMapper();
ExRateData data = mapper.readValue(response, ExRateData.class);
return data.rates().get("KRW");
}
private static String executeApi(URI uri) throws IOException {
String response;
HttpURLConnection connection = (HttpURLConnection) uri.toURL().openConnection();
try(BufferedReader br = new BufferedReader(new InputStreamReader(connection.getInputStream()))){
response = br.lines().collect(Collectors.joining());
}
return response;
}
}
변하지 않는 코드 분리하기 - 메서드 추출
템플릿?
템플릿은 어떤 목적을 위해 만들어둔 모양이 있는 틀
고정된 틀 안에 바꿀 수 있는 부분을 넣어서 사용하도록 만들어진 오브젝트
템플릿 메서드 패턴?
템플릿 메서드 패턴은 고정된 틀의 로직이 가진 템플릿 메서드를 슈퍼 클래스로 두고, 바뀌는 부분을 서브클래스의 메서드에 두는 구조로 이루어진다.
변하지 않는 부분을 메서드로 추출하자.
package spring.hellospring.exrate;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;
import spring.hellospring.payment.ExRateProvider;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.math.BigDecimal;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.stream.Collectors;
public class WebApiExRateProvider implements ExRateProvider {
public BigDecimal getExRate(String currency) {
String url = "https://open.er-api.com/v6/latest/" + currency;
return runApiForExRate(url);
}
// 바뀌지 않는 고정된 틀
private static BigDecimal runApiForExRate(String url) {
URI uri;
try {
uri = new URI(url);
} catch (URISyntaxException e) {
throw new RuntimeException(e);
}
String response;
try{
response = executeApi(uri);
} catch (IOException e) {
throw new RuntimeException(e);
}
try {
return extractExRate(response);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
private static BigDecimal extractExRate(String response) throws JsonProcessingException {
ObjectMapper mapper = new ObjectMapper();
ExRateData data = mapper.readValue(response, ExRateData.class);
return data.rates().get("KRW");
}
private static String executeApi(URI uri) throws IOException {
String response;
HttpURLConnection connection = (HttpURLConnection) uri.toURL().openConnection();
try(BufferedReader br = new BufferedReader(new InputStreamReader(connection.getInputStream()))){
response = br.lines().collect(Collectors.joining());
}
return response;
}
}
ApiExecutor 분리 - 인터페이스 도입과 클래스 분리
위 코드에서 변하는 코드와 변하지 않는 코드를 메서드로 분리했다.
하지만, 메서드로 분리된 코드 내에서 변경이 생겨난다면, 전체 코드를 바꾸어주어야 할 수도 있다. (확장성이 떨어짐)
때문에, 인터페이스 도입과 클래스 분리를 통해 영향을 받지 않도록 리팩토링을 해주자.
클래스 분리만 한다면 클라이언트 코드와 결합도가 생겨 여전히 확장성이 떨어진다..
인터페이스 도입과 클래스를 분리를 함께 사용해야만 클라이언트 코드에서 변경이 없는 확장성 있는 코드를 만들 수 있다.
코드
package spring.hellospring.api;
import java.io.IOException;
import java.net.URI;
public interface ApiExecutor {
String execute(URI uri) throws IOException;
}
package spring.hellospring.api;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URI;
import java.util.stream.Collectors;
public class SimpleApiExecutor implements ApiExecutor {
@Override
public String execute(URI uri) throws IOException {
String response;
HttpURLConnection connection = (HttpURLConnection) uri.toURL().openConnection();
try(BufferedReader br = new BufferedReader(new InputStreamReader(connection.getInputStream()))){
response = br.lines().collect(Collectors.joining());
}
return response;
}
}
package spring.hellospring.exrate;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;
import spring.hellospring.api.ApiExecutor;
import spring.hellospring.api.SimpleApiExecutor;
import spring.hellospring.payment.ExRateProvider;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.math.BigDecimal;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.stream.Collectors;
public class WebApiExRateProvider implements ExRateProvider {
public BigDecimal getExRate(String currency) {
String url = "https://open.er-api.com/v6/latest/" + currency;
return runApiForExRate(url);
}
// 바뀌지 않는 고정된 틀
private static BigDecimal runApiForExRate(String url) {
URI uri;
try {
uri = new URI(url);
} catch (URISyntaxException e) {
throw new RuntimeException(e);
}
String response;
try{
response = new SimpleApiExecutor().execute(uri);
} catch (IOException e) {
throw new RuntimeException(e);
}
try {
return extractExRate(response);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
private static BigDecimal extractExRate(String response) throws JsonProcessingException {
ObjectMapper mapper = new ObjectMapper();
ExRateData data = mapper.readValue(response, ExRateData.class);
return data.rates().get("KRW");
}
}
ApiExecutor 콜백과 메서드 주입
콜백?
콜백이란 실행되는 것을 목적으로 다른 오브젝트의 메서드에 전달되는 오브젝트 파라미터로 전달되지만,
값을 참조하기 위함이 아니라, 특정 로직을 담은 메서드를 실행시키기 위함이 목적.
하나의 메서드를 가진 인터페이스 타입(SAM : Single Abstract Method) 의 오브젝트 또는 람다 오브젝트
템플릿 콜백 패턴은 전략 패턴의 특별한 케이스
템플릿 콜백 패턴은 메서드 하나만 가진 전략 인터페이스를 사용하는 전략 패턴
메서드 호출 주입
DI 의 일종으로 컨테이너 구성 정보에 포함되지 않고, 메서드 실행 시점에 의존 오브젝트를 파라미터로 주입하는 방식으로 동작한다.
스프링의 룩업 메서드 주입(lookup method injection) 과는 다르다. 스프링 메서드 주입은 런타임 상속을 통해서 메서드의 구현 코드를 직접 주입하는 방식이다.
package spring.hellospring.exrate;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;
import spring.hellospring.api.ApiExecutor;
import spring.hellospring.api.SimpleApiExecutor;
import spring.hellospring.payment.ExRateProvider;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.math.BigDecimal;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.stream.Collectors;
public class WebApiExRateProvider implements ExRateProvider {
public BigDecimal getExRate(String currency) {
String url = "https://open.er-api.com/v6/latest/" + currency;
return runApiForExRate(url, new SimpleApiExecutor());
}
private static BigDecimal runApiForExRate(String url, ApiExecutor apiExecutor) {
URI uri;
try {
uri = new URI(url);
} catch (URISyntaxException e) {
throw new RuntimeException(e);
}
String response;
try{
response = apiExecutor.execute(uri);
} catch (IOException e) {
throw new RuntimeException(e);
}
try {
return extractExRate(response);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
private static BigDecimal extractExRate(String response) throws JsonProcessingException {
ObjectMapper mapper = new ObjectMapper();
ExRateData data = mapper.readValue(response, ExRateData.class);
return data.rates().get("KRW");
}
}
템플릿 콜백 패턴의 작업 흐름을 살펴보자.
extractExRate
메서드의 기능도 ApiExecutor
인터페이스와 같이 콜백 패턴으로 변경해보자.
package spring.hellospring.api;
import com.fasterxml.jackson.core.JsonProcessingException;
import java.math.BigDecimal;
public interface ExRateExtractor {
BigDecimal extract(String response) throws JsonProcessingException;
}
package spring.hellospring.api;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import spring.hellospring.exrate.ExRateData;
import java.math.BigDecimal;
public class ErApiExRateExtractor implements ExRateExtractor{
@Override
public BigDecimal extract(String response) throws JsonProcessingException {
ObjectMapper mapper = new ObjectMapper();
ExRateData data = mapper.readValue(response, ExRateData.class);
return data.rates().get("KRW");
}
}
package spring.hellospring.exrate;
import com.fasterxml.jackson.core.JsonProcessingException;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;
import spring.hellospring.api.ApiExecutor;
import spring.hellospring.api.ExRateExtractor;
import spring.hellospring.api.SimpleApiExecutor;
import spring.hellospring.api.ErApiExRateExtractor;
import spring.hellospring.payment.ExRateProvider;
import java.io.IOException;
import java.math.BigDecimal;
import java.net.URI;
import java.net.URISyntaxException;
public class WebApiExRateProvider implements ExRateProvider {
public BigDecimal getExRate(String currency) {
String url = "https://open.er-api.com/v6/latest/" + currency;
return runApiForExRate(url, new SimpleApiExecutor(), new ErApiExRateExtractor());
}
// 바뀌지 않는 고정된 틀
private static BigDecimal runApiForExRate(String url, ApiExecutor apiExecutor, ExRateExtractor exRateExtractor) {
URI uri;
try {
uri = new URI(url);
} catch (URISyntaxException e) {
throw new RuntimeException(e);
}
String response;
try{
response = apiExecutor.execute(uri);
} catch (IOException e) {
throw new RuntimeException(e);
}
try {
return exRateExtractor.extract(response);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}
ApiTemplate
ApiTemplate
환율정보 API 로부터 환율을 가져오는 기능을 제공하는 오브젝트
코드
템플릿의 경우 내용을 코드의 변경이 있을 이유가 거의 없다.
때문에, 확장을 기반한 설계를 하지 않아도 된다.
(인터페이스 사용하지 X)
WebApiExRateProvider
입장에서 코드가 상당히 간결해졌다.
package spring.hellospring.api;
import com.fasterxml.jackson.core.JsonProcessingException;
import java.io.IOException;
import java.math.BigDecimal;
import java.net.URI;
import java.net.URISyntaxException;
public class ApiTemplate {
public BigDecimal getExRate(String url, ApiExecutor apiExecutor, ExRateExtractor exRateExtractor) {
URI uri;
try {
uri = new URI(url);
} catch (URISyntaxException e) {
throw new RuntimeException(e);
}
String response;
try{
response = apiExecutor.execute(uri);
} catch (IOException e) {
throw new RuntimeException(e);
}
try {
return exRateExtractor.extract(response);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}
package spring.hellospring.exrate;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;
import spring.hellospring.api.*;
import spring.hellospring.payment.ExRateProvider;
import java.io.IOException;
import java.math.BigDecimal;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class WebApiExRateProvider implements ExRateProvider {
ApiTemplate apiTemplate = new ApiTemplate();
public BigDecimal getExRate(String currency) {
String url = "https://open.er-api.com/v6/latest/" + currency;
return apiTemplate.getExRate(url, new SimpleApiExecutor(), new ErApiExRateExtractor());
}
}
디폴트 콜백과 템플릿 빈
디폴트 콜백
그런데 위 코드(WebApiExRateProvider
) 처럼 코드 내에서 어떠한 콜백 인스턴스를 사용하겠다고 명시적으로 보여줄 필요가 있을까?
만약 콜백 인스턴스를 지정하지 않았을 때 사용할 디폴트 콜백을 지정할 수 있지 않을까?
-> 가능하다!
코드
package spring.hellospring.exrate;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;
import spring.hellospring.api.*;
import spring.hellospring.payment.ExRateProvider;
import java.io.IOException;
import java.math.BigDecimal;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class WebApiExRateProvider implements ExRateProvider {
ApiTemplate apiTemplate = new ApiTemplate();
public BigDecimal getExRate(String currency) {
String url = "https://open.er-api.com/v6/latest/" + currency;
return apiTemplate.getExRate(url);
}
}
package spring.hellospring.api;
import com.fasterxml.jackson.core.JsonProcessingException;
import java.io.IOException;
import java.math.BigDecimal;
import java.net.URI;
import java.net.URISyntaxException;
public class ApiTemplate {
private final ApiExecutor apiExecutor;
private final ExRateExtractor exRateExtractor;
public ApiTemplate() {
this.apiExecutor = new HttpClientApiExecutor();
this.exRateExtractor = new ErApiExRateExtractor();
}
// 디폴트 콜백1
public BigDecimal getExRate(String url) {
return this.getExRate(url, this.apiExecutor, this.exRateExtractor);
}
// 디폴트 콜백2
public BigDecimal getExRate(String url, ApiExecutor apiExecutor) {
return this.getExRate(url, apiExecutor, this.exRateExtractor);
}
// 디폴트 콜백3
public BigDecimal getExRate(String url, ExRateExtractor exRateExtractor) {
return this.getExRate(url, this.apiExecutor, exRateExtractor);
}
public BigDecimal getExRate(String url, ApiExecutor apiExecutor, ExRateExtractor exRateExtractor) {
URI uri;
try {
uri = new URI(url);
} catch (URISyntaxException e) {
throw new RuntimeException(e);
}
String response;
try{
response = apiExecutor.execute(uri);
} catch (IOException e) {
throw new RuntimeException(e);
}
try {
return exRateExtractor.extract(response);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}
템플릿 빈
ApiTemplate
클래스가 다른 클래스에서도 사용될 경우를 생각해서 스프링 빈으로 등록할 수 있을까?
-> 가능하다!
템플릿에 변경 가능한 상태가 있는 경우, 빈으로 등록해서는 안된다!
빈은 기본적으로 싱글톤으로 관리되기 때문에, 전역적으로 상태가 관리되면 안된다.
package spring.hellospring.api;
import com.fasterxml.jackson.core.JsonProcessingException;
import java.io.IOException;
import java.math.BigDecimal;
import java.net.URI;
import java.net.URISyntaxException;
public class ApiTemplate {
private final ApiExecutor apiExecutor;
private final ExRateExtractor exRateExtractor;
public ApiTemplate() {
this.apiExecutor = new HttpClientApiExecutor();
this.exRateExtractor = new ErApiExRateExtractor();
}
public ApiTemplate(ApiExecutor apiExecutor, ExRateExtractor exRateExtractor) {
this.apiExecutor = apiExecutor;
this.exRateExtractor = exRateExtractor;
}
public BigDecimal getExRate(String url) {
return this.getExRate(url, this.apiExecutor, this.exRateExtractor);
}
public BigDecimal getExRate(String url, ApiExecutor apiExecutor) {
return this.getExRate(url, apiExecutor, this.exRateExtractor);
}
public BigDecimal getExRate(String url, ExRateExtractor exRateExtractor) {
return this.getExRate(url, this.apiExecutor, exRateExtractor);
}
public BigDecimal getExRate(String url, ApiExecutor apiExecutor, ExRateExtractor exRateExtractor) {
URI uri;
try {
uri = new URI(url);
} catch (URISyntaxException e) {
throw new RuntimeException(e);
}
String response;
try{
response = apiExecutor.execute(uri);
} catch (IOException e) {
throw new RuntimeException(e);
}
try {
return exRateExtractor.extract(response);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}
package spring.hellospring;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import spring.hellospring.api.ApiTemplate;
import spring.hellospring.api.ErApiExRateExtractor;
import spring.hellospring.api.SimpleApiExecutor;
import spring.hellospring.exrate.WebApiExRateProvider;
import spring.hellospring.payment.ExRateProvider;
import spring.hellospring.payment.PaymentService;
import java.time.Clock;
@Configuration
public class PaymentConfig {
@Bean
public PaymentService paymentService() {
return new PaymentService(exRateProvider(), clock());
}
@Bean
public ApiTemplate apiTemplate() {
return new ApiTemplate(new SimpleApiExecutor(), new ErApiExRateExtractor());
}
@Bean
public ExRateProvider exRateProvider() {
return new WebApiExRateProvider(apiTemplate());
}
@Bean
public Clock clock(){ return Clock.systemDefaultZone(); }
}
스프링이 제공하는 템플릿
JdbcTemplate
SQL 쿼리를 수행하거나 등록, 수정, 프로시저를 호출할 때 사용할 수 있는 템플릿이다.
스프링 6 에는 JdbcTemplate
을 좀 더 모던하게 만든 JdbcClient 가 추가되었다.
JdbcTemplate
에서 사용하는 RowMapper
와 같은 콜백을 사용할 수 있다.
RestTemplate
스프링이 제공하는 가장 오래된 동기 방식의 REST 클라이언트 기술 중 하나이다.
GET, POST 메서드를 사용하는 간단한 HTTP API 를 호출할 때 사용하기에 편리하다. 다양한 HTTP API 기술을 이용하도록 만들 수 있다.
최근에 스프링에 추가된 RestClient
을 이용하면 모던한 API 스타일로 된 HTTP API 를 호출하는 코드를 만들 수 있다. 여러가지 콜백 오브젝트를 지원한다.
TransactionTemplate
스프링의 트랜잭션 추상화 기술과 함께 사용 가능한 데이터 트랜잭션 작업용 템플릿이다.
@Transactional
이 제공하는 트랜잭션 경계설정 기능을 TransactionTemplate
으로도 모두 적용할 수 있다.
JDBC, JPA, Mybatis, Hibernate 등의 다양한 데이터 기술에 모두 사용이 가능하다.
Last updated