오늘은 JAVA에서 분기를 위해 사용하는 문법인 if문과 switch문을 Map으로 대체하는 기법에 대해서 알아보려고 한다.
나는 당연하게도 분기를 위해서 if문을 사용하고 switch문으로 표현할 수 있는 경우에는 의도적으로 switch로 리팩토링하며 개발을 해왔다. if문과 switch문의 사용에서도 장단점이 있지만, 특정값의 값에 따라 명확한 분류를 해주는 switch문이 더 가독성이 좋으며 default를 항상 작성하는 방식으로 진행하면 기본적인 예외처리를 고려하게 된다는 점이 좋았다.
오늘은 그런 switch를 대신할지도 모르는 Map을 이용한 분기기법에 대해 알아보자!
우리는 여태까지 JAVA의 분기를 위한 문법인 if와 switch만으로도 개발을 잘만 해왔다. 그런데, 이렇게 따로 만들어준 문법보다 자료구조를 활용하는 방법이 어떤 이점이 있을까?
switch나 if문을 사용한다면, 분기와 그에 따른 처리가 근처의 코드영역에서 일어나게 된다. 분기의 경우의 수가 얼마 없다면 그리 복잡해보이지 않지만, 경우의 수가 많아질수록 핵심이 되는 논리를 알아채기가 불편해진다.
하지만 Map을 사용하면 요소들을 put하는 영역과 사용부가 완전히 분리되므로, 사용부가 비교적 간결해진다.
먼저 switch문의 예시를 확인해보자.
switch (convertFileDto.getFileType()) {
case BODY: {
RetryUtil.retry(convertApiService::convert, DEFAULT_RETRY_COUNT, faxConvertApiRequest, FileType.BODY);
}
break;
case COVER: {
RetryUtil.retry(convertApiService::convert, DEFAULT_RETRY_COUNT, faxConvertApiRequest, FileType.COVER);
}
break;
case ALL: {
RetryUtil.retry(convertApiService::convert, DEFAULT_RETRY_COUNT, faxConvertApiRequest, FileType.BODY);
RetryUtil.retry(convertApiService::convert, DEFAULT_RETRY_COUNT, faxConvertApiRequest, FileType.COVER);
}
break;
default: {
throw new IllegalArgumentException("Not supported FaxFileType");
}
}
위와 같이 Enum에 대한 케이스들을 고려하여 처리하는 로직을 정의해줄 수 있다. 이는 if문으로도 동일하게 구현할 수 있지만, 특정 Enum에 대한 케이스들을 명시적으로 나타내고 이를 통해 경우의 수를 고려할 수 있다는 점을 장점이라고 생각한다.
또한 default를 의식적으로 사용하면 정의하지 않은 케이스에 대한 예외를 고려할 수 있어 습관처럼 사용하고 있다.
다음으로 Map을 이용한 구현을 확인해보자. 특정 Enum을 key로 가지고, value로 Consumer를 가지는 Map을 정의하였다.
private static final Map<FileType, Consumer<FileType>> CONVERT_METHODS = new EnumMap<>(FileType.class)
static {
CONVERT_METHODS.put(FileType.BODY, fileType -> RetryUtil.retry(ConvertApiService::convert, DEFAULT_RETRY_COUNT, ConvertApiRequest, fileType));
CONVERT_METHODS.put(FileType.COVER, fileType -> RetryUtil.retry(faxConvertApiService::convert, DEFAULT_RETRY_COUNT, faxConvertApiRequest, fileType));
CONVERT_METHODS.put(FileType.ALL, fileType -> {
RetryUtil.retry(faxConvertApiService::convert, DEFAULT_RETRY_COUNT, faxConvertApiRequest, FaxFileType.MAIN);
RetryUtil.retry(faxConvertApiService::convert, DEFAULT_RETRY_COUNT, faxConvertApiRequest, FaxFileType.COVER);
});
}
// 사용부
CONVERT_METHODS.getOrDefault(convertFileDto.getFileType(), fileType -> {
throw new IllegalArgumentException("Not supported FileType");
}).accept(convertFileDto.getFileType());
클래스 초기화 블럭을 통해, 원하는 케이스들을 정의하므로서 케이스에 따른 처리를 하는 사용부가 비교적 간단해진 것을 확인할 수 있다.
또한 switch에서 default를 통해 발생시켰던 예외의 경우도 Consumer를 value로 가지는 Map이라면 위와 같이 발생시킬 수 있다.
특정한 값의 유형에 따라 특정 동작을 수행하는 switch문과 달리, 다형성을 적용하기 용이하다. Shape라는 부모클래스를 가지는 도형 클래스를 통한 예시를 확인해보자.
public class Shape {
public void draw() {
System.out.println("Drawing a shape...");
}
}
public class Circle extends Shape {
public void draw() {
System.out.println("Drawing a circle...");
}
}
public class Square extends Shape {
public void draw() {
System.out.println("Drawing a square...");
}
}
public class Drawing {
private Map<String, Shape> shapeMap = new HashMap<>();
public Drawing() {
shapeMap.put("circle", new Circle());
shapeMap.put("square", new Square());
}
public void drawShape(String shapeName) {
Shape shape = shapeMap.get(shapeName.toLowerCase());
if (shape != null) {
shape.draw();
} else {
System.out.println("Shape not found.");
}
}
}
public class Main {
public static void main(String[] args) {
Drawing drawing = new Drawing();
drawing.drawShape("circle"); // Output: Drawing a circle...
drawing.drawShape("square"); // Output: Drawing a square...
drawing.drawShape("triangle"); // Output: Shape not found.
}
}
전략패턴은 수정이 있을 때, 개방폐쇄의 원칙을 고려해 기존 클래스의 수정없이 전략을 바꿔끼우는 디자인 패턴이다.
Map을 사용하면 전략패턴을 적용하기 용이하다. 현대카드와 페이팔을 가지고서 결제를 진행하는 코드를 확인해보자.
public enum PaymentMethod {
HYUNDAI_CARD,
PAYPAL,
// other payment methods
}
위의 결제수단들의 특성에 맞는 전략패턴을 구현하여주자. 현재는 신용카드와 온라인 결제에 대한 전략만을 구현하였지만, 추후 다양한 전략을 추가할 수 있다.
public interface PaymentStrategy {
void pay(double amount);
}
public class CreditCardPaymentStrategy implements PaymentStrategy {
@Override
public void pay(double amount) {
// implementation for credit card payment
}
}
public class OnlinePaymentStrategy implements PaymentStrategy {
@Override
public void pay(double amount) {
// implementation for online payment
}
}
그리고 결제수단과 결제전략을 매핑하고, 이에 맞게 결제를 진행해줄 클래스를 구현해보자. 해당 클래스는 key로 결제수단을, value로 결제전략을 가지는 Map을 필드로 가진다.
Map의 데이터를 어떻게 구성하느냐에 따라서, 결제수단 별 전략패턴이 정해지므로 추후 유지보수가 용이하다.
public class PaymentProcessor {
private final Map<PaymentMethod, PaymentStrategy> paymentStrategies;
public PaymentProcessor() {
paymentStrategies = Map.of(
PaymentMethod.HYUNDAI_CARD, new CreditCardPaymentStrategy(),
PaymentMethod.PAYPAL, new OnlinePaymentStrategy()
// add other payment strategies here
);
}
public void processPayment(PaymentMethod paymentMethod, double amount) {
PaymentStrategy paymentStrategy = paymentStrategies.get(paymentMethod);
if (paymentStrategy == null) {
throw new IllegalArgumentException("Unsupported payment method: " + paymentMethod);
}
paymentStrategy.pay(amount);
}
}
다음으로 위의 구현들을 토대로 결제를 하는 코드를 구현하여보자. PaymentProcessor
를 사용하여 간단하게 구현할 수 있다.
PaymentProcessor paymentProcessor = new PaymentProcessor();
paymentProcessor.processPayment(PaymentMethod.HYUNDAI_CARD, 100.0);
paymentProcessor.processPayment(PaymentMethod.PAYPAL, 50.0);
// other payment method examples
이렇게 전략패턴과 Map을 이용해 구현을 하고나면, 결제수단과 전략패턴을 추가하면서 전략패턴의 구현외에 다른 부분은 크게 수정을 요하지 않게된다.
추가적인 결제수단이나 결제수단의 결제방법의 변경과 같은 요구사항에도 기존 전략패턴은 수정될 필요가 없으므로, 개방폐쇄의 원칙에 부합하는 디자인 패턴이라고 볼 수 있다.
이를 결제수단에 따라 처리를 하는 if문이나 switch문으로 구성하였다면 위와 같은 요구사항을 따라가기 쉽지않았을 것이다.
자료구조를 사용한 방식이기때문에, Map의 value에 분기에 따른 동작을 정의해주어야한다.
이 때 Map의 초기화 방식에 따라 제약이 생기게 된다.
static 초기화 블럭을 사용 할 경우, static이 아닌 변수를 사용하여 분기에 따른 동작을 정의할 수 없다. 클래스의 생성자를 사용하는 경우는 static 메소드등을 사용 할 수 없어 초기화에 어려움이 있다.