며칠 전 대학에서 자바 입문 수업 첫 강의를 진행하다 문득 계산기로 OOP의 기본적인 방법론을 가르쳐보면 어떨까 하는 생각이 들었습니다(수업도 자바8로 진행하므로 이 글도 자바8기준으로 작성합니다)
OOP개념이 없는 입문자에게 어떻게 문제를 OOP적으로 분석해야하는가를 가르치는 것은 매우 어렵습니다. 보통 사람은 절차적(순서대로)으로 문제를 해결하는데 익숙합니다. 또한 절차적으로 해결하기 어려운 정도의 복잡한 문제를 포기하는 것은 더욱 익숙하죠 ^^;
OOP로 문제를 해결하는 과정을 묘사해보면, 곰곰히 생각하다가 갑자기 해법을 내놓는 식이 아닙니다. 차근차근 고민이 전개되면서 천천히 고도화 시켜가는 일종의 소조(塑造)에 가깝습니다.
OOP는 객체지향 프로그래밍이란 뜻인데 이미 이 말에 많은 뜻이 담겨있습니다. OOP는 객체를 지향한다는 것인데, 객체란 손님입니다. 그럼 반대말은 주인이나 주체일 수 있겠습니다만 프로그래밍 상 대칭성을 이루는 것은 호스트코드(host code)입니다. 호스트코드는 실제 그 업무를 처리하는 책임을 지는 코드입니다.
절차지향적인 방법론에서는 이 호스트 코드에 직접 해결할 문제에 대한 처리를 기술합니다. 하지만 이렇게 하면 유지보수가 힘들어집니다. 해결해야하는 문제가 복잡한 경우, 구현은 성공할 수 있지만 수정하기가 굉장히 어려워 지는 거죠.
핵심적인 이유는 코드가 길거나 복잡해서가 아닙니다. 수정사항은 원인과 분야가 굉장히 다양한데, 어떤 이유로 수정사항이 오든 호스트코드 전체를 분석해서 수정해야 하기 때문입니다.
절차적 방법론으로 만들어진 프로그램이 수정이 어렵다는 것을 개선하고자 객체지향 방법론에서는 호스트코드가 풀어야 하는 문제를 각 역할에 맞게 나눈 뒤 개별적인 객체에게 위임합니다. 이를 통해 호스트코드는 해결할 문제에 대해 직접적인 코드를 최대한 자제하고 객체들에게 위임하는 식으로 처리할 것입니다. 본인의 문제를 역할별 객체에게 위임하여 해결하는 것이야말로 객체지향 개발의 핵심이라 할 수 있습니다.
이제 뜬구름 잡는 얘기에서 실제 계산기를 만들어가며 코드에 적용해보죠. 처음에는 간단한 사칙연산만 고려하면서 진행하겠습니다. 우선 눈에 보이는 것은 키패드입니다. 보통 객체지향개발이라 하면 오해하는 부분이 인식할 수 있는 객체부터 만들고 보는 것입니다. 하지만 사고를 전개하기 위해 일반적인 오해대로 키패드부터 만들어보겠습니다 ^^
잘 관찰해보면 모든 키패드의 공통점이 있는데, 화면에 표시될 때 고유한 레이블(1,2,3..,+,-.. 처럼)을 갖고 있으며 누를 수 있다는 것입니다. 좀 더 깊이 생각해보면 키패드를 누른 결과는 상단의 패널과 상호작용 한다는 사실도 알 수 있습니다.
이 공통점들로 부터 키패드라는 클래스를 추상화해볼 수 있습니다.
1-1. KeyPad Class
class KeyPad{
private Panel panel;
private String label;
public KeyPad(Panel p, String l){
panel = p;
label = l;
}
public void click(){}
}
이미 위의 클래스에서 OOP가 지향하는 바가 드러나는데, 계산기라는 과제를 호스트코드가 해결하는게 아니라 키패드에게 입력에 대한 처리를 위임하는 식으로 전개할 것이란 걸 예상할 수 있습니다. 위에는 아직 호스트코드가 안나왔지만 KeyPad클래스를 만든 이유는 호스트코드가 키패드 관련 처리를 객체에게 위임하기 위해서 인 것입니다. 실제 호스트코드가 KeyPad객체를 이용하는 모습은 다음과 같을 것입니다.
1-2. Calculator Class
class Calculator{
public static void main(String[] a){
//각 키패드와 상호작용할 패널
Panel panel = new Panel();
//키 이름별로 키패드객체를 저장함.
Map<String, KeyPad> keys = new HashMap<>();
//키패드 생성 및 맵에 할당
Stream.of("0123456789+-/*=c".split("")).forEach(
s->keys.put(s, new KeyPad(panel, s))
);
//입력을 받을 스캐너
Scanner in;
//계속 순환하며 작동한다.
exit:
while(true){
in = new Scanner(System.in).useDelimiter("\\s*");
while(in.hasNext()){
String c = in.next();
//알 수 없는 키나 명시적인 exit로 종료됨
if(!keys.containsKey(c) || "exit".equals(c)) break exit;
//스캔된 키에 맞는 키패드에게 click을 호출
keys.get(c).click();
}
}
}
}
OOP에서 핵심은 객체(클래스)의 정의에 있는 것이 아닙니다. 호스트가 어떤 식으로 객체를 사용할 것이냐에 달려있습니다. 따라서 KeyPad로부터 만들어가는 것이 아니라 우선적으로 작성해야하는 코드는 main 쪽입니다.
호스트코드를 작성하면서 해당 로직이 하나의 역할로 구별될 수 있다면 직접 코드를 작성하지 않고 객체에게 위임해가는 것이 바로 OOP적인 방법입니다. 위의 호스트코드는 계산기 구현을 직접 처리하지 않고 입력을 처리하는 키패드와 이를 출력하는 패널로 역할을 분리하고 개별 객체로 인식하여 위임하는 형태로 작성되어있습니다. 이상적인 OOP에서 호스트코드에는 로직은 오직 책임져야하는 작은 범위만 나오고 위임할 객체와 그 객체와의 통신(메소드 호출 등)만 기술됩니다.
이왕 만든거 패널도 만들어보죠. 잠시 패널에 대해 고민해봅시다. 패널은 화면에 표시될 텍스트를 처리합니다. 그렇다면 입력에 맞춰 내부에는 계속 쌓여가는 텍스트 정보를 담는 큐가 있을 것 입니다. 또한 패널과 키패드는 서로 통신하므로 키패드가 어떤 식으로 패널과 대화할지 예상해봐야합니다.
바로 여기서 약간 혼동스러우면서 어려운 개념이 나오는데 키패드도 패널에게는 호스트코드라는 점입니다.
main만 절대적인 호스트코드가 아니라 상대적으로 다른 객체에게 할 일을 위임하면 위임을 받는 쪽은 객체가 되고 위임을 시킨 쪽은 호스트코드가 되는 식입니다. 우리 두뇌는 불확실성을 싫어하기 때문에 뭔가 딱 떨어지게 확정짓고 싶은 욕구가 스물스물 들면서 뭐가 이리 복잡해! 라고 즉시 반응할 것입니다. 하지만 안타깝게도 OOP의 세계에서 호스트코드와 객체의 관계는 상대적으로 상황마다 다르게 정의됩니다.
키패드는 입력을 받으면 패널에게 어떻게 하라고 지시를 내릴 뿐, 직접 키패드가 어떤 조치를 취하지 않을 것이기 때문에 패널의 호스트코드가 됩니다. 이제 패널이 눈치를 보고 객체로서 대응해줘야하는 것은 키패드의 사정인거죠!
따라서 패널을 정의하려면 우선 키패드의 메소드를 작성해봐야 합니다. 키패드를 제대로 작성하려면 main을 작성해봐야 하는 것과 마찬가지 원리입니다. 키패드를 좀 상세히 분류해보죠.
0~9, +, -, /, * : 이 키패드들은 단지 자신의 레이블과 동일한 값을 그저 패널에게 추가시키면 됩니다.
= : 결과를 얻어야하므로 패널에게 결과를 출력하라는 메세지를 보낼 것입니다.
c : 화면을 초기화하는 명령을 패널에게 보내게 됩니다.
위의 상황을 보면 키패드라도 세 가지 종류로 자식 클래스를 만들어야한다는 것을 알 수 있습니다. OOP에서 기본적인 전략은 상속으로 공통점은 부모쪽에 다른 쪽은 자식쪽에 기술하는 방식으로 중복을 제거합니다. 이를 통해 공통부분의 수정요청이 오면 부모만 수정하면 되고 개별 사항에 수정요청이 오면 해당 자식 클래스만 수정할 수 있죠. 사실 이러한 상속 전략도 실은 수정용이성을 강화하는 장치인 셈입니다. 이러한 내용에 대응하도록 키패드와 자식 클래스로 기존의 키패드 클래스를 리펙토링합시다.
1-3. abstract KeyPad
abstract class KeyPad{
//자식클래스가 써야함
protected Panel panel;
protected String label;
public KeyPad(Panel p, String l){
panel = p;
label = l;
}
//자식클래스별로 구상
abstract public void click();
}
1-4. InputKey(0~9+-/*)
class InputKey extends KeyPad{
public InputKey(Panel panel, String label){
super(panel, label);
}
@Override
public void click(){
panel.add(label); //패널에게 레이블값을 그대로 넘겨준다
}
}
1-5 ResultKey(=)
class ResultKey extends KeyPad{
public ResultKey(Panel panel, String label){
super(panel, label);
}
@Override
public void click(){
panel.result(); //패널에게 결과를 계산하라고 시킨다
}
}
1-6 ClearKey(c)
class ClearKey extends KeyPad{
public ClearKey(Panel panel, String label){
super(panel, label);
}
@Override
public void click(){
panel.clear(); //패널에게 초기화하라고 한다
}
}
키패드를 리펙토링 해보니 호스트코드가 될 각각의 click메소드 내부에서 이미 패널이 어떤 서비스를 제공해줘야하는지 파악됩니다. 이대로 구현하면 됩니다.
1-7 Panel
class Panel{
public Panel(){}
public void add(String v){}
public void result(){}
public void clear(){}
}
이 상태의 코드를 클래스의 레이아웃 상태라고 합시다. 윤곽을 잡았다는 뜻은 호스트코드가 원하는 바를 우선 해결해줬다는 것입니다. 실제로 위임한 객체가 제대로 작동하는지는 호스트코드의 관심사가 아닙니다.
여기서 OOP의 위임에 대한 본질이 나옵니다. 호스트코드는 그저 객체에게 올바르게 위임하는데까지만 책임을 지고 실제 그 문제의 해결에 대한 책임은 객체가 집니다. 하지만 그 객체도 또한 자신의 문제를 전부 직접 해결하려 하지 않고 일부만 해결한 뒤 또다른 호스트코드가 되어 다른 객체에 위임하는 식으로 전개됩니다.
OOP적인 패러다임에서 각 호스트코드의 책임은 적합한 객체에게 제대로 위임을 했는가 이지 실제로 그것이 잘 수행되었는가가 아닙니다. 실제 수행의 책임은 위임받을 객체가 갖게 됩니다.
이제 패널의 주요한 메소드를 도출했으니 실제 내부의 구현에 대해서 생각해 볼 차례입니다. 사실 OOP에서 객체의 실제 작동을 구현하는 단계는 가장 마지막에 일어납니다(아직 패널도 자신의 책임 일부를 위임할 객체가 있을지 모를 일입니다!)
우선 동적으로 add가 계속 될 것이므로 내부에 이를 처리하는 입력 queue를 ArrayList로 만들어주고 여기에 입력을 추가해가다 result나 clear에서 적절히 사용하고 초기화하면 될 것입니다. 또한 실제로 화면에 표시하는 시점을 생각해보면 add가 될 때도 clear나 result 시에도 갱신해야 합니다. 공통된 기능이므로 내부에 일괄 처리하는 render 메소드를 만들면 됩니다. 이상의 내용을 바탕으로 구현해보죠.
1-8 Concreate Panel
class Panel{
//입력을 담을 큐
private ArrayList<String> que = new ArrayList<>();
//수식처리기
private Expression ex;
public Panel(Expression ex){
this.ex = ex;
}
public void add(String v){
que.add(v);
render();
}
public void result(){
//수식처리기에 계산을 위임
String result = ex.calc(que);
//큐를 비우고 최종 결과만 표시
que.clear();
add(result);
}
public void clear(){
que.clear();
render();
}
private render(){
//큐출력
System.out.println(String.join("", que.toArray(new String[0])));
}
}
위에서처럼 패널 내부 구현은 이제 패널만의 사정이 되었습니다. 어떻게 구현하는지 main이나 키패드는 모르는 일입니다. 여기서 OOP로 개발하는 장점이 취해집니다.
키패드에 요구사항이 있어 수정할 때는 KeyPad만 고치면되고
패널에 요구사항이 있어 수정할 때는 Panel만 고치면 됩니다.
또한 그렇게 개별적인 수정을 가해도 다른 부분에 영향이 없어 안심할 수 있습니다. 예를 들어 지금의 패널은 System.out으로 출력하는데 스윙이나 웹으로 출력하고 싶다면 패널의 render만 고치면 될입니다. 이 수정이 키패드나 main에 전혀 여파를 주지 않는다는 게 중요합니다.
여기에 핵심적인 원리가 하나 추가되는데 바로 단방향 의존성입니다.
현재 의존하는 관계를 보면 키패드는 패널을 알고 있지만 패널은 키패드의 존재를 알지 못합니다. 만약 쌍방 참조관계로 알고 있었다면 키패드의 수정여파가 패널에게 미치고, 패널의 수정여파도 키패드에게 미치게 됩니다.
이에 반해 단방향으로 설정된 의존성에서는 키패드의 경우 패널의 메소드형태만 바뀌지 않는다면 패널의 수정에 영향을 받지 않고, 패널 또한 메소드의 형태를 바꾸지 않는 이상 내부 수정을 해도 키패드에 영향을 끼치지 않습니다. 이것이 바로 호스트코드가 객체에게 위임할 때 객체가 실제로 잘 수행하는지 관심을 두지 않는 이유입니다. 이러한 이유로 가능하면 객체와 호스트코드 간의 관계는 호스트코드만 객체를 알고 있는 형태의 단방향으로 정리해야 합니다.
위 패널의 생성자와 result메소드를 보면 새로운 Expression(수식처리기)클래스가 등장합니다. 이제 패널도 호스트코드가 될 차례입니다. 하지만 왜 이런 발상을 하게 되는지가 중요합니다.
우선 패널이 어떤 역할인가를 고민해봐야합니다.
패널은 화면에 표시될 내용을 관리하고 키패드와 상호작용하는데까지의 역할을 수행합니다. 따라서 que에 담긴 내용을 해석하거나 이를 계산하는 것은 패널의 역할이라 보기 힘듭니다. OOP에서는 되도록이면 인식이 가능한 범위 내에서 최대한 역할을 분리하려고 합니다. 왜냐면 수정사항이 올 때 해당 건수에 따라 수정할 코드의 양을 최대한 줄일 수 있기 때문입니다. 모든 건 향후 수정된 것에 대비하는 방향으로 정리됩니다. 따라서 패널이 직접 que의 내용을 해석하거나 계산하는 로직을 갖지 않고 Expression 객체에게 위임하게 됩니다.
이를 통해 사칙연산만 가능한 계산기가 더 많은 연산을 포함하거나 더 큰 수를 처리할 때는 패널을 고치는 것이 아니라 Expression만 고치면 되게 됩니다.
바로 이 점이 핵심인 것입니다. 더 확장하여 여태 짠 클래스 군을 고려해보죠.
추가적인 키패드가 필요하다면 KeyPad를 확장하면 됩니다.
만약 다른 형태로 디스플레이하려면 Panel을 수정하면 됩니다.
더 많은 연산이나 다양한 범위의 수를 지원하고 싶다면 Expression을 수정하면 됩니다.
만약 OOP의 형태가 아니었다면 어떤 수정사항이든 덩어리진 main함수를 전체적으로 검토하면서 처리해야 할 것입니다. 반대로 생각하면 제대로 된 역할로 객체를 분리하지 않았다면, 혹은 제대로 위임하지 않고 상호 의존성이 그대로 남아있다면 OOP방법론은 무용지물이 될 것입니다. 겉모습만 그럴 듯한 클래스 덩어리들이 양산되어있을 뿐이겠죠. 각 역할에 맞는 책임을 해당 객체가 최소한의 의존성으로 구현되어야만 OOP의 효과가 향후 수정사항 발생 시 발휘됩니다.
다음 시간에는 Expression 구현부터 시작하여 전반적인 계산기의 완성을 향해 진행하면서 추가적인 OOP의 기법을 익혀보죠.
* 과제1 - main이 들어있던 Calculator 클래스를 내용 전개에 맞게 고치시오.
* 과제2 - Expression을 레이아웃 레벨에서 작성하시오.
다음 편은 아래에 있습니다.