클래스과 객체지향(OOP)

RID·2024년 4월 26일
0

OOP

목록 보기
1/3

배경


객체지향 프로그래밍(Object-Oriented Programming) 에 대해서 들어본 적이 있을 것이다. 지금 까지 내가 알고 있는 객체지향이란 단순히 '객체'를 만들고 이 객체들이 각각의 역할을 가지고 소통하게 끔 하는 것 정도였다.
어떻게 하는 것이 객체지향 프로그래밍의 장점을 제대로 살릴 수 있는지, 어떤 생각을 가지고 코드를 작성해야 하는지에 대한 부분은 항상 모호하게 느껴졌었다.
오늘 간단한 계산기 프로그램을 단계별로 구현하는 과제 해설을 통해 객체 지향의 목적성과 이해도를 조금 넓힐 수 있었다.
각 단계별로 변경되는 요구사항에서 올바르게 생각하고 코드를 작성하는 방법을 살펴보자.

계산기 프로그램


Lv1. 사칙연산을 할 수 있는 계산기 만들기

기본적으로 클래스를 생성하지 않고 원하는 요구 사항을 할 수 있는 프로그램은 아래와 같이 만들 수 있다.


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 
}

Q. 위의 코드만 하더라도 사칙연산을 구현하는 데에는 문제가 없다. 그렇다면 왜 굳이 Class를 사용해야할까?

사실 위의 코드가 가지고 있는 기능이 생각보다 굉장히 간단해서 굳이 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. Lv1 계산기에 나머지 연산 추가하기

사실 위의 코드 구조와 동일하게 나머지 연산을 할 수 있는 객체를 쉽게 생성할 수 있다. 하지만 굳이 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에는 이 변경이 어떻게 영향을 주는지에 대해 집중해서 살펴보자.

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 입장에서 연산을 수행하기 위해 어떤 절차가 진행되는지 보자.

  • Calculator 객체야 숫자 두 개 넘겨줄테니까 더해줘!
  • 근데 너 직접 연산을 못하니까 내가 더하기 전문 객체를 같이 넘겨줄게
  • 더하기 전문 객체한테 시켜서 결과만 나한테 가져다가 줘!

사실 아직은 이 과정이 조금 이해하기 어렵다. 굳이..? 라는 느낌이 들기 때문이다. Lv4에서 해당 부분에 대한 해답을 줄 수 있을 것 같으니 이 단계에서 집중해야 하는 부분에 대해서 살펴보자.

  • 복잡한 일을 다른 객체한테 위임했다는 것.
  • 더하기라는 목표를 수행하기 위해 Calculator 객체와 AddOperator협력하며 결과를 이루어 낸다.

여기까지 보았을 때 일반적으로 등장인물끼리 협력한다는 것이 무엇인지 대충 느낌이 왔을 것 같다. CalculatorAddOperator에게 숫자 두개와 함께 operate()라는 기능을 써줘! 라고 요청을 보낸다. 이 과정이 객체 사이의 협력 과정이며, 이 때 두 객체 사이에 의존성이 생겼다고 말한다.

하지만 이런 의존성은 변경의 전파를 만들어낸다. 예를 들어 현재 같은 상황에서 만약 AddOperator에 변경 사항이 생겼다면, 이를 의존하는 Calculator에도 변경이 요구될 수 있는 것이다.

이렇게 되면 복잡한 구조를 가지는 프로젝트에서는 하나의 변경 사항이 수 십개 객체의 변경을 요구할 수 있기 때문에, 제대로 된 객체지향 프로그래밍을 하기 위해서는 변경이 전파되지 않도록 구현하는 것이 중요할 것이다.

Lv3에서 이룬 부분을 조금 더 살펴보자.

  • 이 과정을 통해 Calculator는 복잡한 일을 직접 수행하지 않아도 된다. (책임 / 위임)
  • AddOperatoroperate()라는 메소드를 어떻게 처리할지 자유롭게 변경이 가능하다. (자율성)
    - 이렇게 operate()라는 메소드의 변경이 Calculator에게 전파되지 않으며, AddOperator.operate()의 동작 방식이 캡슐화 되었다고 말할 수 있다.

Lv4. 추상화 클래스 도입하기

Lv3까지 진행하다보면 아직 해결되지 않은 부분이 있다. AddOperator.operate() 함수 내용에 대한 변경은 Calculator에 전파되지 않는다. 하지만 만약 Lv2에서 처럼 새로운 연산 기능이 추가된다면 어떻게 될까? 나머지 연산이 추가적으로 필요한 경우를 생각해보자.

  • RemainOperator 객체 정의
  • RemainOperator.operate() 정의
  • Calculator에서 remain() 함수 추가 정의

Q. Lv3까지의 상황에서는 반드시 Calculator의 변경이 수반된다. 만약 이를 피하고 싶으면 어떻게 구현하면 될까?

그 전에 먼저 추상적인 것과 구체적인 것에 대한 구분을 조금 해보자.

Calculator 의 입장에서 구체적인 것과 추상적인 것은 무엇이었을까?

  • CalculatorAddOperator라는 객체의 존재를 알고 있다. (구체적)
  • CalcaultorAddOperator.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는 수정하지 않아도 된다. 즉, 변경의 전파가 일어나지 않은 것이다!


정리


사실 지금까지 공부하면서 객체지향 프로그래밍에 대해 정확히 알지 못하는 부분이 너무 많았다.
왜 객체를 굳이 사용해야 하는지, 추상화의 개념은 무엇인지, 캡슐화는 무엇인지, 왜 추상화를 해야하는 지 등 중요한 개념을 알지 못한채로 단순히 코드만 작성했던 것 같다.

이번 과제 역시 단순히 기능 구현에만 목적을 두고 진행했었는데, 각 단계에서 일어나는 변화가 객체지향적인 관점에서 어떤 배경 때문이었는지, 어느 점이 나아졌는지에 대해 이해하면서 많이 배운 것 같다.

궁금했던 질문들을 물어보기도 했었는데 시간이 되면 해당 내용을 아래에 추가하도록 해야겠다.

0개의 댓글