package spring.hellospringv1;
import java.io.IOException;
import java.math.BigDecimal;
public class Client {
public static void main(String[] args) throws IOException {
PaymentService paymentService = new WebApiExRatePaymentService();
Payment payment = paymentService.prepare(100L, "USD", BigDecimal.valueOf(50.7));
System.out.println(payment);
}
}
이후부터는 상속을 통한 클래스 구현으로 환율을 가져오는 정책을 변경할 수 있다.
고정된 환율을 가져오는 SimpleExRatePaymentService 를 만들어보자.
문제 없이 사용되는 것을 볼 수 있다.
package spring.hellospringv1;
import java.io.IOException;
import java.math.BigDecimal;
// 다른 정책이 추가되더라도, 슈퍼클래스에서 사용할 메서드를 오버라이딩하기만 하면 된다.
public class SimpleExRatePaymentService extends PaymentService {
@Override
BigDecimal getExRate(String currency) throws IOException {
if(currency.equals("USD")) return BigDecimal.valueOf(1000);
throw new IllegalArgumentException("지원하지 않는 통화입니다.");
}
}
package spring.hellospringv1;
import java.io.IOException;
import java.math.BigDecimal;
public class Client {
public static void main(String[] args) throws IOException {
// PaymentService paymentService = new WebApiExRatePaymentService();
PaymentService paymentService = new SimpleExRatePaymentService();
Payment payment = paymentService.prepare(100L, "USD", BigDecimal.valueOf(50.7));
System.out.println(payment);
}
}
문제점
상속을 통해서 관심사가 다른 코드를 분리해내고, 서로 독립적으로 변경 또는 확장할 수 있도록 만드는 것은 간단하면서도 효과적인 방법이다.
하지만, 이 방법은 상속을 사용했다는 것이 큰 단점이다..
자바는 다중상속을 허용하지 않는다. 따라서 또 다른 관심사를 분리할 경우, 확장을 이용하기가 매우 어렵다!
예를 들어, VIP 에게 추가 환율을 적용해주는 정책이 추가되면 해당 기능을 구현하는 메서드를 추가해야 한다.
해당 정책을 사용하지 않는 서브 클래스 또한, 해당 메서드를 구현해야 한다.
또, 상속은 상하위 클래스의 관계가 매우 밀접하다. 상위 클래스의 변경에 따라 하위 클래스를 모두 변경하는 상황이 발생할 수 있다.
유연성이 높은 설계는 결합도를 낮추는 것이 중요하다.
디자인 패턴에 일부 적용된 특별한 목적을 위해서 사용하는 경우가 아니라면, 상속을 통한 확장은 관심사 분리에 사용하기에는 분명한 단점이 있다. (상속의 한계)
4. 클래스 분리
단순히 상속을 사용하는 것은 부모, 자식 클래스 간 강한 결합이 발생한다는 점이 큰 단점이다.
관심사에 따라서 클래스를 분리해서 각각 독립적으로 사용할 수도 있다.
예를 들어, 환율정보를 가져오는 관심사를 클래스 분리를 통해 각각 독립적으로 사용할 수 있다.
package spring.hellospringv2;
import java.io.IOException;
import java.math.BigDecimal;
public class SimpleExRateProvider {
// 메서드 인터페이스가 통일되지 않는다.(물론 통일될 수도 있다)
public BigDecimal getExRate(String currency) throws IOException {
if (currency.equals("USD")) {
return BigDecimal.valueOf(1000);
}
throw new IllegalArgumentException("지원되지 않는 통화입니다.");
}
}
package spring.hellospringv2;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.math.BigDecimal;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.stream.Collectors;
public class WebApiExRateProvider {
// 메서드 인터페이스가 통일되지 않는다.(물론 통일될 수도 있다)
public BigDecimal getWebExRate(String currency) throws IOException {
URL url = new URL("https://open.er-api.com/v6/latest/" + currency);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
BufferedReader br = new BufferedReader(new InputStreamReader(connection.getInputStream()));
String response = br.lines().collect(Collectors.joining());
br.close();
ObjectMapper mapper = new ObjectMapper();
ExRateData data = mapper.readValue(response, ExRateData.class);
return data.rates().get("KRW");
}
}
package spring.hellospringv2;
import java.io.IOException;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public class PaymentService {
public Payment prepare(Long orderId, String currency, BigDecimal foreignCurrencyAmount) throws IOException {
// 분리된 클래스를 사용하는 쪽과 강력한 의존성이 생긴다.
WebApiExRateProvider exRateProvider = new WebApiExRateProvider();
BigDecimal exRate = exRateProvider.getWebExRate(currency);
BigDecimal convertedAmount = foreignCurrencyAmount.multiply(exRate);
LocalDateTime validUntil = LocalDateTime.now().plusMinutes(30);
return new Payment(orderId, currency, foreignCurrencyAmount, exRate, convertedAmount, validUntil);
}
}
여기서 한발자국 더 나아가보자, PaymentService 입장에서 환율을 가져오는 로직은 필수적이다 때문에, 해당 정책을 관리하는 인스턴스 변수를 만들고 생성자를 통해서 초기화해주자.
package spring.hellospringv2;
import java.io.IOException;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public class PaymentService {
private final WebApiExRateProvider exRateProvider;
public PaymentService() {
this.exRateProvider = new WebApiExRateProvider();
}
public Payment prepare(Long orderId, String currency, BigDecimal foreignCurrencyAmount) throws IOException {
BigDecimal exRate = exRateProvider.getWebExRate(currency);
BigDecimal convertedAmount = foreignCurrencyAmount.multiply(exRate);
LocalDateTime valiedUntil = LocalDateTime.now().plusMinutes(30);
return new Payment(orderId, currency, foreignCurrencyAmount, exRate, convertedAmount, valiedUntil);
}
}
문제점
결과적으로 클래스 레벨에 사용 의존관계가 만들어지기 때문에, 강한 코드 수준의 결합이 생긴다. 실제로 사용할 클래스가 변경되면 이를 이용하는 쪽에서도 변경이 일어나게 된다. 상속을 통한, 확장 처럼 유연한 확장도 불가능해진다..
상속한 것이 아니기 때문에 사용하는 클래스의 메서드 이름과 구조도 제각각일 수 있다. 그래서 클래스가 변경되면 많은 코드가 따라서 변경되어야 한다.
클래스가 다르다는 것을 제외하면 관심사의 분리가 잘 된 방법은 아니다..
5. 인터페이스 도입
현재 PaymentService 입장에서 환율을 가져오는 방법이 변경되면 아예 사용하는 코드가 달라질 수 있다.
목적은 같지만, 동작의 일관성이 없을 수 있다..
환율을 가져오는 클래스는 인터페이스를 상속해 PaymentService 입장에서는 동일한 인터페이스의 메서드를 사용하도록 한다. 때문에 PaymentService 가 의존하는 클래스가 변경되더라도 사용하는 메서드 이름의 변경이 일어나지 않는다.
package spring.hellospringv2;
import java.io.IOException;
import java.math.BigDecimal;
public class SimpleExRateProvider implements ExRateProvider {
// 메서드 인터페이스가 동일하다.
@Override
public BigDecimal getExRate(String currency) throws IOException {
if (currency.equals("USD")) {
return BigDecimal.valueOf(1000);
}
throw new IllegalArgumentException("지원되지 않는 통화입니다.");
}
}
package spring.hellospringv2;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.math.BigDecimal;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.stream.Collectors;
public class WebApiExRateProvider implements ExRateProvider {
// 메서드 인터페이스가 동일하다.
public BigDecimal getExRate(String currency) throws IOException {
URL url = new URL("https://open.er-api.com/v6/latest/" + currency);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
BufferedReader br = new BufferedReader(new InputStreamReader(connection.getInputStream()));
String response = br.lines().collect(Collectors.joining());
br.close();
ObjectMapper mapper = new ObjectMapper();
ExRateData data = mapper.readValue(response, ExRateData.class);
return data.rates().get("KRW");
}
}
package spring.hellospringv2;
import java.io.IOException;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public class PaymentService {
private final ExRateProvider exRateProvider;
public PaymentService() {
// 일부분이지만, 구현체 변경에 따라서 코드의 변경이 발생한다.
this.exRateProvider = new WebApiExRateProvider();
}
public Payment prepare(Long orderId, String currency, BigDecimal foreignCurrencyAmount) throws IOException {
BigDecimal exRate = exRateProvider.getExRate(currency);
BigDecimal convertedAmount = foreignCurrencyAmount.multiply(exRate);
LocalDateTime validUntil = LocalDateTime.now().plusMinutes(30);
return new Payment(orderId, currency, foreignCurrencyAmount, exRate, convertedAmount, validUntil);
}
}
문제점
하지만, 클래스 인스턴스는 만드는 생성자를 호출하는 코드에는 클래스 이름이 등장하기 때문에, 사용하는 환율 정보를 가져오는 클래스가 변경되면 PaymentService 의 코드도 일부분이지만 따라서 변경되어야 한다.
PaymentService 에서는 환율을 가져오는 구현체와 강력한 결합도를 가진다.
여전히 상속을 통한 확장만큼의 유연성도 가지지 못한다.
6. 관계설정 책임의 분리
인터페이스를 사용했을 때 단점
앞에서 ExRateProvider 인터페이스를 사용하도록 작성했지만, 구현 클래스에 대한 정보를 가지고 있다면, PaymentService 여전히 ExRateProvider 를 구현한 특정 클래스에 의존하는 코드가 된다.
자신이(PaymentService) 어떤 클래스의 오브젝트를 사용할지를 결정한다면 관계설정 책임을 직접 가지고 있는 것이다.
이 관계설정 책임을 자신을 호출하는 앞의 오브젝트에게(Client) 넘길 수 있다. 이렇게 되면 코드 레벨의 의존관계에서 자유로워진다. 이후에는 오직 인터페이스에만 의존하는 코드가 되기 때문에, 구현 클래스의 오브젝트를 사용하게 되더라도 PaymentService 의 코드가 변경되지 않는다.
관계설정의 책임을 가진 앞의 클래스(Client) 는 생성자를 통해서 어떤 클래스의 오브젝트를 사용할지 결정한 것을 전달해주면 된다.
package spring.hellospringv2;
import java.io.IOException;
import java.math.BigDecimal;
import java.time.LocalDateTime;
// PaymentService 는 OCP 원칙이 적용되어 있는 클래스이다.
public class PaymentService {
private final ExRateProvider exRateProvider;
public PaymentService(ExRateProvider exRateProvider) {
this.exRateProvider = exRateProvider;
}
public Payment prepare(Long orderId, String currency, BigDecimal foreignCurrencyAmount) throws IOException {
BigDecimal exRate = exRateProvider.getExRate(currency);
BigDecimal convertedAmount = foreignCurrencyAmount.multiply(exRate);
LocalDateTime validUntil = LocalDateTime.now().plusMinutes(30);
return new Payment(orderId, currency, foreignCurrencyAmount, exRate, convertedAmount, validUntil);
}
}
package spring.hellospringv2;
import java.io.IOException;
import java.math.BigDecimal;
public class Client {
public static void main(String[] args) throws IOException {
ExRateProvider exRateProvider = new WebApiExRateProvider();
PaymentService paymentService = new PaymentService(exRateProvider);
Payment payment = paymentService.prepare(100L, "USD", BigDecimal.valueOf(50.7));
System.out.println(payment);
}
}
7. 오브젝트 팩토리
현재 Client 는 클라이언트로서의 책임과 PaymentService 의 관계를 설정해주는 2가지의 책임을 가지고 있다.
관계설정의 책임은 다른 클래스로 넘겨주도록 하자.
결합도가 낮은 설계는 하나의 클래스에 하나의 책임만 있어야 한다는 것이다.
ObjectFactory 클래스를 만들어 관계설정의 책임을 부여하자.
package spring.hellospringv2;
public class ObjectFactory {
public PaymentService paymentService() {
return new PaymentService(exRateProvider());
}
// 내가 원하는 전략을 만들어 사용한다.(ExRateProvider)
public ExRateProvider exRateProvider() {
return new WebApiExRateProvider();
}
}
package spring.hellospringv2;
import java.io.IOException;
import java.math.BigDecimal;
// 원칙과 패턴까지!
public class Client {
public static void main(String[] args) throws IOException {
ObjectFactory objectFactory = new ObjectFactory();
PaymentService paymentService = objectFactory.paymentService();
Payment payment = paymentService.prepare(100L, "USD", BigDecimal.valueOf(50.7));
System.out.println(payment);
}
}
8. 원칙과 패턴
원칙은 객체지향의 원칙, 패턴은 디자인 패턴을 의미한다.
개방-폐쇄 원칙(Open-Closed Principle)
클래스나 모듈은 확장에 열려 있어야 하고, 변경에는 닫혀 있어야 한다.
클래스가 기능을 확장할 때 클래스의 코드는 변경되지 않아야 한다.
아래 그림을 볼 때 ExRateProvider 의 기능이 변경(확장) 되더라도, PaymentService 의 코드는 변경되지 않는다.
아래 그림에서는 ExRateProvider 의 구현체를 Client 에서 넣어준다.
높은 응집도와 낮은 결합도(High Coherence and low coupling)
응집도가 높다는 것은 하나의 모듈이 하나의 책임 또는 관심사에 집중되어 있다는 뜻.
때문에, 변화가 일어날 때 해당 모듈에서 변하는 부분이 크다.
응집도가 높은 코드는 코드의 변경이 일어날 시, 다른 코드에 영향도가 적다.
유지보수 비용이 적다!
책임과 관심사가 다른 모듈과는 낮은 결합도, 즉 느슨하게 연결된 형태를 유지하는 것이 좋다.
전략 패턴(Strategy Pattern)
자신의 기능 맥락(Context) 에서, 필요에 따라서 변경이 필요한 알고리즘을 인터페이스를 통해 통째로 외부로 분리시키고, 이를 구현한 구체적인 알고리즘 클래스를 필요에 따라서 바꿔 사용할 수 있게 하는 디자인 패턴
우리 코드에서는 PaymentService 라는 맥락 안에서 ExRateProvider 에 해당 하는 전략을 Client 가 수정하여 사용할 수 있다.
다른 예로, 아래처럼 Collections.sort() 를 사용할 때 다양한 전략들을 넣어주어 사용할 수 있다.