이전 1편에 이어 내용이 전개되오니 혹시 안보신 분들께서는 여기에서 먼저 보시길 권해드립니다.
이제 패널은 큐에 들어있던 입력 값에 대해 직접 처리하지 않고 이를 처리할 수식처리기에게 위임하게 됩니다.
패널이 수식처리기에게 요구하는 메소드는 현재로서는 딱 하나로 리스트를 인자로 받아 문자열을 반환하는 calc메소드입니다.
2-1 Expression
class Expression{
public Expression(){}
public String calc(List<String> que){}
}
외부에 노출되는 레이아웃은 상당히 간단하지만 실제 수식계산기를 작성하는 부분은 복잡합니다. OOP로 구현하다보면 말단에 있는 객체의 경우 복잡하고 어려운 로직을 구현해야하는 경우가 많은데 이를 정면으로 돌파해보죠. OOP라 하더라도 종단에 배치되는 객체는 더 이상 책임을 전가할 수 없어 최종적인 로직을 구현해야 합니다. 물론 라이브러리나 외부의 코드를 가져와서 또 위임할 수도 있지만 위임만하다보면 본인이 정말 개발할 능력은 있는지 스스로도 의문이 들곤 하죠.
우선 수식처리기가 넘겨받은 que에는 어떤 값이 들어있을까요? 아마도 ["1", "+", "2"] 같은 값이 들어있을겁니다. 더 복잡하게는 ["1", "+", "2", "*", "3"] 같은 연산자 우선순위가 관여하는 값도 들어갈 수 있겠죠.
숫자 역시 1자리 숫자만 들어오리라는 법은 없습니다. ["1", "2", "+", "3", "4"]처럼 12 + 34를 표현하는 리스트가 올 수도 있죠.
이러한 데이터를 처리하는 고전적인 방법론은 렉서와 스캐너입니다. 하지만 현재 사칙연산을 처리하기 위해 이렇게 복잡한 시스템을 섣불리 도입할 필요는 없습니다. 언제나 현재의 요구사항을 안전하게 만족할 수 있는 범위 내에서 최소한으로 구현해야합니다. OOP의 장점은 이후 수정이나 확장이 제한된 범위에서 일어난다는 것입니다. 이 장점은 반대로 얘기하면 미래에 불확실한 유연성이나 수정가능성을 미리 대비할 필요가 없다는 뜻이기도 합니다. 따라서 위에 설명했던 수식처리기의 조건을 최소한으로 따져보면 다음과 같이 정리할 수 있습니다.
1. 넘어온 리스트에서 숫자와 연산자를 추출한다.
2. 연산자 우선 순위에 따라 먼저 *, /를 연산하고 이후 +, - 를 연산한다.
지금으로서는 딱 요만큼만 구현하면 됩니다. 1강에서도 다뤘던 스캐너를 사용하면 손쉽게 1번 과정을 처리할 수 있습니다. 우선 스캐너를 이용해 1번부터 처리해보죠.
2-2 calc #1
public String calc(List<String> que){
//문자열 변환 후 연산자 사이에 공백 삽입
//["1", "2", "+", "3"] → "12 + 3"
String raw = String.join(",", que.toArray(new String[0]))
.replaceAll("([+*/\\-])", " $1 ");
//스캐너로 새로운 리스트에 정리
Scanner scanner = new Scanner(raw);
List<String> ex = new ArrayList<>();
while(scanner.hasNext()) ex.add(scanner.next());
//ex에 대해 연산처리
}
이제 간단하게 숫자와 연산자가 들어있는 리스트를 얻었습니다. 그렇다면 다음은 2번에 해당되는 곱하기 나누기 연산을 먼저 처리하는 것입니다. 이를 이해하기 위해 12 + 3 * 2 라는 식을 생각해보죠.
리스트에는 [12, +, 3, *, 2] 로 들어있을 것입니다.
이를 루프돌면서 *나 /를 만나면 그 앞 뒤에 있는 항목을 해당 연산자로 연산하여 3개 항목(3, *, 2)을 빼내고 그 자리에 결과(6)를 넣어줍니다.
이 결과 첫번째 루프를 돌고 나면 [12, +, 6] 으로 변형되어있을 것입니다.
이제 동일하게 다시 루프를 돌면서 12 + 6을 처리하면 최종결과인 18을 얻게 됩니다.
자바는 원래 강력한 타입을 지원하지만 위의 로직에 개별적인 타입을 적용하면 코드가 복잡해지므로 현재의 구현에서는 간단히 타입을 변환해가면서 구현하겠습니다.
우선 각 연산자에 대응하는 실제 연산 처리기를 등록해야합니다. 다음과 같이 간단하게 내장 BiFunction으로 정의해볼 수 있습니다.
2-3 operations
Map<String, BiFunction<Integer,Integer,Integer>> op = new HashMap<>();
op.put("+", (l, r)->l + r);
op.put("-", (l, r)->l - r);
op.put("*", (l, r)->l * r);
op.put("/", (l, r)->l / r);
이제 두 번 루프를 돌건데 한 번은 */를 돌고 그 다음은 +-를 돌 것이므로 간단히 배열로 루프돌면 됩니다. 개별 루프에서는 위에서 작성한 ex리스트를 스캔하면서 해당 연산자에 대한 작업을 할 것입니다.
여기까지만 작성해보죠.
2-4 main loop
//곱하기나누기와 더하기빼기에 걸쳐 2번 루프
Stream.of("[*/],[+\\-]".split(",")).forEach(rex->{
//매번 ex를 순회
int i = 0;
while(i < ex.size()){
String c = ex.get(i);
if(c.matches(rex)){
//..여기서 처리
}
i++;
}
});
람다 안에서 위에서 2-2에서 정의한 ex를 사용하려면 final을 선언해야 합니다. 실제 위의 if문 안에서 처리할 내용을 생각해보죠. 이해하기 쉽게 [1, +, 2, *, 3, -, 1] 의 상황을 각 단계에 맞춰 같이 전개해보겠습니다.
현재 항목인 c에 해당되는 연산함수를 op에서 가져온다.
- i = 3인 시점에 c = "*"가 되고, 2-3에서 정의한 *용 람다함수를 가져오게 됨.
현재 항목 앞 뒤의 i - 1과 i + 1 항목을 숫자로 변환해 연산함수로 연산한 결과를 얻는다.
- i = 3이므로 [1, +, 2, *, 3] 에서 2번항목 2와 4번항목 3을 얻어 apply를 통해 6을 반환받음.
이제 리스트는 정리해서 기존의 i - 1 자리에는 연산의 결과를 넣고 나머지 두개는 제거한다.
- [1, +, 2, *, 3] 에서 연산결과 6은 2의 자리에 들어가고 *와 3원소는 제거하여 결과적으로 [1, +, 6] 만 남음
이제 곱하기 나누기를 적용했으므로 덧셈뺄셈에 대해서도 동일한 작업을 반복하면 최종 결과만 남게 됨.
- [1, + , 6]을 동일하게 연산하면 [7]만 남게 됨.
코드는 매우 간단합니다.
2-5 calculator core
int i = 0;
while(i < ex.size()){
String c = ex.get(i);
if(c.matches(rex)){
//i기준 앞 뒤 항목을 연산식에 보낸다.
int ret = op.get(c).apply(
Integer.parseInt(ex.get(i - 1)),
Integer.parseInt(ex.get(i + 1))
);
//원래 자리에 계산결과를 넣어주고.
ex.set(i - 1, Integer.toString(ret));
//필요없어진 두개의 항목 제거
ex.remove(i);
ex.remove(i);
//두개를 지웠으므로 i를 하나 다시 앞당겨준다.
i--;
}
i++;
}
지금까지의 내용을 모두 모아 Expression 클래스를 재구성해보죠.
2-6 Expression
class Expression{
//op람다정의
static private Map<String, BiFunction<Integer,Integer,Integer>> op = new HashMap<>();
static {
op.put("+", (l, r)->l + r);
op.put("-", (l, r)->l - r);
op.put("*", (l, r)->l * r);
op.put("/", (l, r)->l / r);
}
public Expression(){}
public String calc(List<String> que){
//스캐너로 ex리스트 구성
final List<String> ex = new ArrayList<>();
Scanner scanner = new Scanner(
String.join("", que.toArray(new String[0]))
.replaceAll("([+*/\\-])", " $1 ")
);
while(scanner.hasNext()) ex.add(scanner.next());
//2pass reduce처리
Stream.of("[*/],[+\\-]".split(",")).forEach(rex->{
int i = 0;
while(i < ex.size()){
String c = ex.get(i);
if(c.matches(rex)){
int ret = op.get(c).apply(
Integer.parseInt(ex.get(i - 1)),
Integer.parseInt(ex.get(i + 1))
);
ex.set(i - 1, Integer.toString(ret));
ex.remove(i);
ex.remove(i);
i--;
}
i++;
}
});
return ex.get(0);
}
}
사칙연산 계산기에서 가장 복잡할거 같은 수식계산기를 만들어봤습니다. 그리 어렵지는 않네요. 간단한 사칙연산만 처리해주면 되는거라 사칙연산 레벨의 연산자 우선 순위만 간단히 막아줬습니다.
이제 필요한 모든 클래스를 정리했으므로 마지막으로 Calculator 클래스를 다시 살펴보겠습니다. 기존의 구현은 main에 집중되어있는데 이 또한 Calculator객체에게 위임해야합니다. 그렇다면 다음과 같이 코드가 변경될 것입니다.
2-6 Calculator
class Calculator{
public static void main(String[] a){
//이제 메인에서는 그저 계산기를 실행시킬 뿐
new Calculator().on(System.in);
}
//패널과 키패드들
private Panel panel = new Panel(new Expression());
private Map<String, KeyPad> keys = new HashMap<>();
public Calculator(){
Stream.of("0123456789+-/*".split("")).forEach(
s->keys.put(s, new InputKey(panel, s))
);
keys.put("=", new ResultKey(panel, "="));
keys.put("c", new ClearKey(panel, "c"));
}
public void on(InputStream src){
Scanner in = new Scanner(src).useDelimiter("\\s*");
exit:
while(true){
in.reset();
while(in.hasNext()){
String c = in.next();
if(!keys.containsKey(c) || "exit".equals(c)) break exit;
keys.get(c).click();
}
}
}
}
2회에 걸친 글에서 등장한 모든 클래스를 정리해보겠습니다. 여태까지 살펴본대로 OOP에서 중요한건 역할별로 분리하여 다른 객체에게 위임하는 것입니다. 반대로 다른 객체에게 위임하려면 먼저 그 객체와 관계를 맺어야하죠. 상속만 하더라도 자식클래스가 부모클래스를 알아야만 합니다. 하물며 위임할 객체가 상속관계가 아니라면 직접적으로 관계를 맺어야합니다. UML에서는 이러한 객체 간의 관계를 크게 6가지로 정의하고 있습니다.
Generalization(일반화) - 보통 상속받는 관계를 나타냅니다.
Realization(실체화) - 인터페이스를 구상하는 경우를 나타냅니다.
Dependency(의존) - 하나의 객체가 다른 하나의 객체에 의존(위임)하는 경우 입니다.
Association(연관) - 의존과 비슷하지만 필드에서 직접 다른 객체를 참조하는 경우입니다.
Aggregation(집합) - 연관과 동일하지만 대상이 하나가 아니라 집합적이라는데 차이가 있습니다.
Composition(합성) - 합성은 집합과 유사하지만 소유하는 객체의 일부인가 독립적인가의 차이가 있습니다.
상세하게 들어가면 더욱 많은 종류가 있습니다만 입문하시는 입장에서 저 정도를 파악하시면 충분합니다.
위의 관계를 이용하여 하나씩 풀어보죠. 우선 KeyPad클래스를 생각해보겠습니다.
Generalization은 의존성의 방향을 가리키는 실선과 속이 빈 삼각형으로 나타냅니다. 자식클래스는 부모클래스를 알아야하지만 부모는 자식을 알 필요도 알 수도 없기 때문에 의존성의 방향이 자식에서 부모를 가리키고 있습니다.
위 그림을 더 자세히 보면 KeyPad의 속성에서 panel이 Panel클래스와 Association(연관)관계를 맺고 있다는 사실을 확인할 수 있습니다. 이를 다이어그램에 포함시키면 다음과 같이 변합니다.
Association은 실선으로 포현하되 직접적인 참조라면 꺽쇠화살표를 이용해 의존성을 표현해줍니다. 이 경우도 Panel은 KeyPad를 모르지만 keyPad가 Panel을 알아야하는 상황이므로 화살표의 방향이 Panel을 가리킵니다. 헌데 Panel안의 ex도 Expression객체와 마찬가지 Association관계입니다. 이것도 다이어그램에 포함시켜버리죠.
최종적으로 마지막에 구현한 Calculator의 경우 의존하는 객체는 KeyPad의 자식 클래스과 패널입니다.
InputKey는 다수를 포함하므로 Aggregation(집합)에 해당되고 나머지 Key나 Panel은 Association에 해당됩니다.
이제 이 최종적인 다이어그램을 보면 개별 객체가 어떤 객체에 의존하고 있는지 한 눈에 드러납니다.
Calculator - 여기서 뻗어나가는 화살표는 총 네 개로 KeyPad자식들과 Panel을 알고 있어야합니다. 반대로 말하면 계산기는 그들에게 책임을 위임하게 됩니다.
KeyPad - 자신이 해야할 일 일부를 Panel에게 위임하고 있습니다.
Panel - 수식처리에 대해서 Expression에게 위임하고 있습니다.
이러한 이유로 최종적인 main함수는 Calculator만 알게 되고 계산관련 일체를 위임하게 됩니다. 뿐만 아닙니다. 1편에서 강조했던 단방향 의존성도 그대로 드러납니다. 화살표의 방향이 양쪽을 가리키거나 간접적인 우회경로로 다시 돌아오는 일 없이 한 방향으로만 이루어져있습니다. 이는 의존성이 단방향임을 나타내고 나아가 의존성의 방향이 호스트코드가 위임하는 객체를 향해 있는 것도 확인할 수 있습니다.
지금까지 알아본 OOP의 장점은 주로 위임을 통한 수정용이성이었습니다만 최종 결과물을 보면 인스턴스화라는 추가적인 이점을 얻게 됩니다.
만약 main함수에 전부를 코드로 기술했다면 여러대의 계산기를 만드는 것은 거의 불가능에 가까워집니다.
하지만 Calculator라는 계산기를 완전히 위임받는 객체가 존재하므로 그저 new Calculator() 를 할 때마다 새로운 계산기가 태어납니다. 이 점은 굉장히 중요한 OOP의 특성으로 OOP에서는 되도록이면 인스턴스 컨텍스트를 사용하려는 특성을 갖습니다. 만약 OOP라고 하면서 싱글톤이 난무하고 사방에 정적함수가 사용되고 있다면 OOP로서의 장점을 거의 활용하지 못하는 중이라 생각해도 무방합니다.
여기까지 총 2회에 걸쳐 계산기 예제를 통해 간단한 자바8기반의 OOP입문을 다뤄봤습니다.
언젠가 이 글의 다음 편이 필요하다는 분들이 많아지시면 더 진행해보는 걸로..긴 글 읽어주신 여러분 모두에게 감사드립니다.
글에서 사용된 전체 코드는 다음과 같습니다.
https://gist.github.com/hikaMaeng/ea0c60e608366f116581f74c6797350f
실제 실행하시면 콘솔에서 표준 입력을 통해 다음과 같은 화면을 보실 수 있을 겁니다.
과제1 - Calculator의 on을 수정하여 스윙 컴포넌트의 버튼 이벤트에 반응하도록 수정하라
과제2 - Panel의 render를 수정하여 스윙 컴포넌트에서 표현하도록 수정하라