객체지향 프로그래밍(Object-Oriented Programming) 에 대해서 들어본 적이 있을 것이다. 지금 까지 내가 알고 있는 객체지향이란 단순히 '객체'를 만들고 이 객체들이 각각의 역할을 가지고 소통하게 끔 하는 것 정도였다.
어떻게 하는 것이 객체지향 프로그래밍의 장점을 제대로 살릴 수 있는지, 어떤 생각을 가지고 코드를 작성해야 하는지에 대한 부분은 항상 모호하게 느껴졌었다.
오늘 간단한 계산기 프로그램을 단계별로 구현하는 과제 해설을 통해 객체 지향의 목적성과 이해도를 조금 넓힐 수 있었다.
각 단계별로 변경되는 요구사항에서 올바르게 생각하고 코드를 작성하는 방법을 살펴보자.
기본적으로 클래스를 생성하지 않고 원하는 요구 사항을 할 수 있는 프로그램은 아래와 같이 만들 수 있다.
fun calculator(){
print("Enter Operator(+,-,*,/) : ")
val operator = readln()
println("Enter first operand: ")
val num1 = readln().toDouble()
println("Enter second operand: ")
val num2 = readln().toDouble()
print("Result: ")
when(operator){
"+" -> println("$num1 + $num2")
"-" -> println("$num1 - $num2")
"*" -> println("$num1 * $num2")
"/" -> println("$num1 / $num2")
else -> throw ArithmeticException()
}
return
}
사실 위의 코드가 가지고 있는 기능이 생각보다 굉장히 간단해서 굳이 Class로 만들어야 할 이유가 명확히 존재하는 것은 아니다. 하지만 만약 역할이 하나씩 추가된다면 함수는 점점 더 복잡해진다.
여기서는 사칙연산 기능이 복잡하다고 생각하고 이 일을 더 잘할 수 있는, 다시말해 계산을 전문적으로 하는 등장인물 을 도입해 역할을 위임하는 것이다.
Calculator.kt
class Calculator {
fun add(num1: Double, num2: Double) = num1 + num2
fun subtract(num1: Double, num2: Double) = num1 - num2
fun multiply(num1: Double, num2: Double) = num1 * num2
fun divide(num1: Double, num2: Double): Double {
if (num2 == 0.0) throw ArithmeticException()
return num1 / num2
}
}
이렇게 연산(4가지 연산)을 전문적으로 하는 등장인물이 도입되면 다른 등장인물은 관련 역할을 수행해야 할 때 이 Calculator
라는 등장인물에게 '숫자 두 개 줄 테니 더해줘!' 등과 같은 요청을 할 수 있는 것이다.
Main.kt
import input
fun main() {
val operator = input("Operator (+, -, *, /) : ")
val x = input("First Number : ").toDouble()
val y = input("Second Number : ").toDouble()
val calculator = Calculator()
if (operator == "+") {
println("Result : ${calculator.add(x, y)}")
} else if (operator == "-") {
println("Result : ${calculator.subtract(x, y)}")
} else if (operator == "*") {
println("Result : ${calculator.multiply(x, y)}")
} else if (operator == "/") {
println("Result : ${calculator.divide(x, y)}")
} else {
throw RuntimeException("Wrong Operator!")
}
}
main
이라는 객체는 Calculator
라는 객체를 활용해 소통하면서 사칙연산을 효과적으로 할 수 있다.
여기까지 보았을 때 별로 효율적이지 못하다고 느껴질 수 있는데, 이는 예시가 간단한 사칙연산이기 때문이다. main
에서 더 복잡한 기능을 수행해야 한다면 각 역할을 잘 수행할 수 있는 등장인물을 만들고 해당 등장인물들과 잘 소통할 수 있도록 코드를 작성해야한다.
사실 위의 코드 구조와 동일하게 나머지 연산을 할 수 있는 객체를 쉽게 생성할 수 있다. 하지만 굳이 Lv2로 다루는 이유에 대해서 고민해보자.
나머지 연산이 추가되는 것도 일종의 변경이다. 이번 경우에는 간단하게 한 두줄의 코드를 추가해 수정할 수 있지만 이 단계에서 중요하게 생각해야 하는 것은 변경을 할 때 어떤 부분을 고민해야 하는지가 될 것이다.
Calculator.kt
class Calculator {
fun add(x: Double, y: Double) = x + y
fun subtract(x: Double, y: Double) = x - y
fun multiply(x: Double, y: Double) = x * y
fun divide(x: Double, y: Double): Double {
if (y == 0.0) {
throw ArithmeticException("Cannot be divided by zero!")
}
return x / y
}
fun remain(x: Double, y: Double) = x % y
}
Main.kt
import input
fun main() {
val operator = input("Operator (+, -, *, /, %) : ")
val x = input("First Number : ").toDouble()
val y = input("Second Number : ").toDouble()
val calculator = Calculator()
when (operator) {
"+" -> println("Result : ${calculator.add(x, y)}")
"-" -> println("Result : ${calculator.subtract(x, y)}")
"*" -> println("Result : ${calculator.multiply(x, y)}")
"/" -> println("Result : ${calculator.divide(x, y)}")
"%" -> println("Result : ${calculator.remain(x, y)}")
else -> throw RuntimeException("Wrong Operator!")
}
}
사실 Lv1과 비교하여 정말 변한 것이 없다. 하지만 여기에는 우리가 중요하게 생각해야 할 부분이 있다.
만약 Client 부분, 즉 main
함수에서 '나머지 연산'이라는 추가적인 요구사항이 발생했을 때, 이 변경사항이 어디까지 영향을 미치는지 살펴보아야 한다. Lv3에는 이 변경이 어떻게 영향을 주는지에 대해 집중해서 살펴보자.
문제 해결이라는 목표를 위해 여러 등장인물(객체)들이 존재하고, 각 등장인물은 고유한 책임(각자 잘하는 부분)이 존재한다.
이 등장인물들이 서로 대화, 협력을 하면서 문제 해결을 하는 것이 객체 지향 프로그래밍이다.
이제 등장인물을 더 만들어보자. 네 가지 사칙연산을 하는 Calculator
의 책임이 너무 많으므로(많다고 생각하자!), 각 연산을 잘 수행하는(고유한 책임을 가진) 4가지 객체를 추가로 생성하는 것이다.
Operator.kt
class AddOperator {
fun operate(x: Double, y: Double) = x + y
}
class SubtractOperator {
fun operate(x: Double, y: Double) = x - y
}
class MultiplyOperator {
fun operate(x: Double, y: Double) = x * y
}
class DivideOperator {
fun operate(x: Double, y: Double): Double {
if (y == 0.0) {
throw ArithmeticException("Cannot be divided by zero!")
}
return x / y
}
}
이제 기존 Calculator
객체의 역할은 사칙연산을 수행하는 것이 아니다. 단순히 위에 생성한 네 가지 객체에 각 연산 기능을 위임하면 된다!
Calculator.kt
import Operator
class Calculator {
fun add(operator: AddOperator, x: Double, y: Double) = operator.operate(x, y)
fun subtract(operator: SubtractOperator, x: Double, y: Double) = operator.operate(x, y)
fun multiply(operator: MultiplyOperator, x: Double, y: Double) = operator.operate(x, y)
fun divide(operator: DivideOperator, x: Double, y: Double) = operator.operate(x, y)
}
Main.kt
import input
import Calculator
import Operator
fun main() {
val operator = input("Operator (+, -, *, /) : ")
val x = input("First Number : ").toDouble()
val y = input("Second Number : ").toDouble()
val calculator = Calculator()
when (operator) {
"+" -> println("Result : ${calculator.add(AddOperator(), x, y)}")
"-" -> println("Result : ${calculator.subtract(SubtractOperator(), x, y)}")
"*" -> println("Result : ${calculator.multiply(MultiplyOperator(), x, y)}")
"/" -> println("Result : ${calculator.divide(DivideOperator(), x, y)}")
else -> throw RuntimeException("Wrong Operator!")
}
}
이제 main
즉, client 입장에서 연산을 수행하기 위해 어떤 절차가 진행되는지 보자.
사실 아직은 이 과정이 조금 이해하기 어렵다. 굳이..? 라는 느낌이 들기 때문이다. Lv4에서 해당 부분에 대한 해답을 줄 수 있을 것 같으니 이 단계에서 집중해야 하는 부분에 대해서 살펴보자.
Calculator
객체와 AddOperator
가 협력하며 결과를 이루어 낸다. 여기까지 보았을 때 일반적으로 등장인물끼리 협력한다는 것이 무엇인지 대충 느낌이 왔을 것 같다.
Calculator
가AddOperator
에게 숫자 두개와 함께operate()
라는 기능을 써줘! 라고 요청을 보낸다. 이 과정이 객체 사이의 협력 과정이며, 이 때 두 객체 사이에 의존성이 생겼다고 말한다.
하지만 이런 의존성은 변경의 전파를 만들어낸다. 예를 들어 현재 같은 상황에서 만약
AddOperator
에 변경 사항이 생겼다면, 이를 의존하는Calculator
에도 변경이 요구될 수 있는 것이다.
이렇게 되면 복잡한 구조를 가지는 프로젝트에서는 하나의 변경 사항이 수 십개 객체의 변경을 요구할 수 있기 때문에, 제대로 된 객체지향 프로그래밍을 하기 위해서는 변경이 전파되지 않도록 구현하는 것이 중요할 것이다.
Lv3에서 이룬 부분을 조금 더 살펴보자.
Calculator
는 복잡한 일을 직접 수행하지 않아도 된다. (책임 / 위임)AddOperator
는 operate()
라는 메소드를 어떻게 처리할지 자유롭게 변경이 가능하다. (자율성)operate()
라는 메소드의 변경이 Calculator
에게 전파되지 않으며, AddOperator.operate()
의 동작 방식이 캡슐화 되었다고 말할 수 있다. Lv3까지 진행하다보면 아직 해결되지 않은 부분이 있다. AddOperator.operate()
함수 내용에 대한 변경은 Calculator
에 전파되지 않는다. 하지만 만약 Lv2에서 처럼 새로운 연산 기능이 추가된다면 어떻게 될까? 나머지 연산이 추가적으로 필요한 경우를 생각해보자.
Q. Lv3까지의 상황에서는 반드시
Calculator
의 변경이 수반된다. 만약 이를 피하고 싶으면 어떻게 구현하면 될까?
그 전에 먼저 추상적인 것과 구체적인 것에 대한 구분을 조금 해보자.
Calculator
의 입장에서 구체적인 것과 추상적인 것은 무엇이었을까?
Calculator
는 AddOperator
라는 객체의 존재를 알고 있다. (구체적)Calcaultor
는 AddOperator.operate()
가 무슨 일을 하는지 정확히 모른다. (추상적) 앞으로 추상적인 것과 구체적인 것에 대한 기준이 모호할 수 있는데 이는 변경 주기를 생각해서 판단해보자.
이제 Lv3에서 했던 얘기와 연결지어 보자.
자, 이제 우리는 위 질문에 대해 얘기할 준비가 되었다.
Calculator
는 특정 Operator
의 구체적인 존재에 의존하고 있기 때문에 새로운 연산자가 생기면(새로운 존재가 등장) 반드시 변경의 전파가 일어날 수 밖에 없는 것이다.
그렇다면 어떻게 해야 변경의 전파를 피할 수 있을까?
답은 Calculator
가 구체적으로 의존하고 있는 연산자의 존재를 추상화하면 된다!
AbstractOperator.kt
interface AbstractOperator {
fun operate(x: Double, y: Double): Double
}
모든 연산 기능을 하는 객체의 형태를 위와 같이 추상화한 것이다.
Operator.kt
class AddOperator : AbstractOperator{
override fun operate(x: Double, y: Double) = x + y
}
class SubtractOperator : AbstractOperator{
override fun operate(x: Double, y: Double) = x - y
}
class MultiplyOperator : AbstractOperator{
override fun operate(x: Double, y: Double) = x * y
}
class DivideOperator : AbstractOperator{
override fun operate(x: Double, y: Double): Double {
if (y == 0.0) {
throw ArithmeticException("Cannot be divided by zero!")
}
return x / y
}
}
이제 연산 기능을 하는 모든 객체는 위의 AbstractOperator
의 껍데기를 장착한 채로 만들어진다. 이제 Calculator
에게 Operator
의 존재를 감추기만 하면 된다.
Calculator.kt
class Calculator {
fun operate(operator: AbstractOperator, x: Double, y: Double) = operator.operate(x, y)
}
Main.kt
import input
import Operator
fun main() {
val operator = input("Operator (+, -, *, /) : ")
val x = input("First Number : ").toDouble()
val y = input("Second Number : ").toDouble()
val calculator = Calculator()
when (operator) {
"+" -> AddOperator()
"-" -> SubtractOperator()
"*" -> MultiplyOperator()
"/" -> DivideOperator()
else -> throw RuntimeException("Wrong Operator!")
}.let {
println("Result : ${calculator.operate(it, x, y)}")
}
}
이제 Calculator
객체는 연산 기능을 하는 객체가 구체적으로 무엇이 있는지 정확히 알지 않아도 된다. 존재가 추상화 되었기 때문에 이제 연산 기능을 하는 추가적인 객체가 필요하더라도 Calculator.kt
는 수정하지 않아도 된다. 즉, 변경의 전파가 일어나지 않은 것이다!
사실 지금까지 공부하면서 객체지향 프로그래밍에 대해 정확히 알지 못하는 부분이 너무 많았다.
왜 객체를 굳이 사용해야 하는지, 추상화의 개념은 무엇인지, 캡슐화는 무엇인지, 왜 추상화를 해야하는 지 등 중요한 개념을 알지 못한채로 단순히 코드만 작성했던 것 같다.
이번 과제 역시 단순히 기능 구현에만 목적을 두고 진행했었는데, 각 단계에서 일어나는 변화가 객체지향적인 관점에서 어떤 배경 때문이었는지, 어느 점이 나아졌는지에 대해 이해하면서 많이 배운 것 같다.
궁금했던 질문들을 물어보기도 했었는데 시간이 되면 해당 내용을 아래에 추가하도록 해야겠다.