2024. 3. 1. 17:36ㆍJava
프로젝트의 시작
어느 날 익명의 기업으로부터 흥미로운 과제를 받았습니다.
과제의 내용은 다음과 같았습니다.
💡 회사는 스프린트 단위로 프로젝트를 진행합니다.
이번 스프린트에서는 사칙연산, 다음 스프린트에서는 삼각함수를 구현할 예정입니다.
추후에는 로그, 미적분이 예정되어 있습니다. 확장에 유리한 설계를 해주세요.
v1. 설계의 시작과 문제점 인식
초기에 저는 사칙연산을 처리할 수 있는 간단하고 직관적인 설계를 구상했습니다.
이를 위해, 사용자의 입력을 분석하고 연산을 수행하는 Calculator 클래스와
연산을 추상화하는 Operation 인터페이스를 도입했습니다.
또한, 연산 종류에 따라 적절한 연산 객체를 생성하는 OperationFactory 클래스도 만들었습니다.
하지만 이 설계는 곧 두 가지 큰 문제에 부딪혔습니다.
1. 첫째, 새로운 연산을 추가할 때마다 OperationFactory의 코드를 수정해야 했습니다.
2. 둘째, 또한 새롭게 클래스를 일일이 작성해야 합니다.
즉, 하나의 연산을 추가할 때마다 새롭게 2가지의 변경점이 생깁니다.
이런 단순한 어플리케이션에서 기능 하나 추가에 변경점이 2가지 라는건 설계가 잘못되었다고 생각,
다른 방법을 알아봤습니다.
v2. 설계의 수정과 새로운 문제 발견
변경점은 다음과 같습니다.
1. 계산에 필요한 데이터를 전달하는 CalculateDto 클래스를 만들고
2. 기존의 연산들을 하나로 관리하기 위해 Calculator Enum 을 만들고, Operation 을 구현하도록 했습니다.
Enum 은 먼저 인스턴스인 상수들을 먼저 초기화 한 다음 Enum 클래스 안에 선언된 필드를 초기화 합니다.
이 점을 이용해 values() 를 통해 인스턴스들을 loop를 돌아
map 안에 <String, Calculator> Map을 선언,
외부에서 operator 를 통해 접근시 O(1) 시간안에 원하는 연산을 찾도록 만들었습니다.
그러나 v2 설계 역시 몇 가지 문제가 있었습니다.
1. CalculateDto 클래스가 Calculator 객체를 포함하는 것은
객체 지향 설계 관점에서 이상적이지 않았습니다. (→ 쉽게 말해서 말이 안된다.)
2. 게다가, 이후 삼각함수와 같은 단항 연산을 지원해야 할 때, 현재의 설계로는 확장성이 부족했습니다.
V3으로의 진화: 확장성과 유연성의 향상
이러한 문제들을 해결하고자 저는 설계를 다시 한 번 개선했습니다.
이번에는 **UnaryOperation**과 **BinaryOperation**이라는 두 개의 인터페이스를 도입하여
단항 및 이항 연산을 분리했습니다.
또한,확장성을 극대화하기 위해 OperationRegistry 클래스를 만들고,
@OperationType 이라는 애노테이션을 만들어
해당 애노테이션이 달린 클래스들을 가져와 등록하도록 했습니다.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface OperationType {
String value();
}
public class OperationRegistry {
private OperationRegistry() {}
private static final Map<String, Operation> operationMap = new HashMap<>();
static {
initializeOperations();
}
private static void initializeOperations() {
Reflections reflections = new Reflections("org.example.operation");
Set<Class<?>> annotated = reflections.getTypesAnnotatedWith(OperationType.class);
for (Class<?> cls : annotated) {
try {
OperationType operationAnnotation = cls.getAnnotation(OperationType.class);
Object instance = cls.getDeclaredConstructor().newInstance();
if (instance instanceof Operation operation) {
operationMap.put(operationAnnotation.value(), operation);
} else {
System.err.println("Class " + cls.getName() + " does not implement Operation interface");
}
} catch (Exception e) {
System.err.println(e.getMessage());
}
}
}
public static Operation getOperation(String operationType) {
return operationMap.get(operationType);
}
}
이 클래스는
1. 애플리케이션의 시작 시 모든 가능한 연산들을 등록하고,
2. 필요에 따라 해당 연산을 검색하는 역할을 합니다.
이는 미래에 새로운 연산이 추가될 때, 기존 코드를 변경하지 않고도
새로운 연산 클래스를 만들고 @OperationType 이라는 애노테이션만 추가하면 되므로 OCP 원칙을 준수하게 됩니다.
@OperationType("+")
public class Addition implements BinaryOperation {
@Override
public double calculate(double operand1, double operand2) {
return operand1 + operand2;
}
}
이 아이디어는 Spring 이 Bean 을 등록하는 방법을 응용했습니다.
더불어, Main 클래스는 이제 InputParser로부터 파싱된 데이터를 받아 Calculator에 전달하고,
계산 결과를 콘솔에 출력하는 단순한 책임만을 지니게 되었습니다.
이는 단일 책임 원칙(SRP)에 부합하며, 유지보수와 테스트가 용이해졌습니다. (실제로 테스트 코드 짜다가 어려운걸 보고 분리를 했음)
느낀점
사실 과제라고 하면 제출하고 나서 땡하고 그만일수도 있다고 생각합니다.
하지만 계산기에 대해 명세와 아키텍쳐를 노트에 써가며 하다보니
단순하더라도 확장성 있는 설계는 어렵고 고민이 많이 필요하다고 느끼게 되었습니다.
또 면접때 CTO 님이 틀린 점을 지적한다기 보단, 어떻게 하면 더 좋을수 있는지에 대해
너무 친절하게 피드백 주셨기에 마음이 꺾이지 않았던것도 있습니다.
이 글을 보신다면 여러분도 꼭 한번 계산기라는 단순한 프로젝트를 한번 해보시는걸 추천드립니다 :)
'Java' 카테고리의 다른 글
정적 유틸리티 클래스 -> 인터페이스 기반 설계로: java에서 OCP 원칙을 적용 (1) | 2024.02.05 |
---|---|
필드 Null 체크 없애기, Optional<String> vs String? (1) | 2023.10.13 |
영한갓님 JPA 실전 강의 들었는 데 CQS, CQRS 안다, 모른다? (2) | 2023.09.11 |
???: "규칙에 예외를 둘 순 없어요", equals 오버라이딩은 일반 규약을 지켜 재정의하기 - (1) (2) | 2023.08.01 |
finalize, cleaner 는 쓸모도 없는 데 대체 왜 있을까? (0) | 2023.07.28 |