객체 지향 프로그래밍에 대해 알아보자

홍예석·2023년 5월 8일
0

객체 지향은 갑자기 왜?

백엔드 개발자라면, 어느 언어를 사용하든지 개발을 시작할 때부터 접하게 되는 개념인 동시에 반드시 공부하게 되는 개념이다. 그리고 대부분 처음에는 지금부터 배우게 될 언어 (ex. C, Java, Python)가 절차지향인지, 객체지향인지 정도만 알고 넘어간다. 이렇게 잘 모르는 상태에서 개발을 하다가 취업을 위해 CS공부를 시작하면 다시 되돌아와서 공부하게 되는데, 지금 내 상황이다. 여러 멘토님들로부터 나중에 어차피 공부하게 될 것이라는 이야기를 항상 들었는데 지금이 그 나중인 것 같다.

사실 객체 지향을 공부해야 한다는 필요성은 계속 느끼고 있었긴 하다. 나는 현재 JAVA를 주 언어로 사용하고 있고, 객체 지향 언어의 예시를 들어보라고 하면 가장 먼저 예시로 JAVA가 등장할 정도로 JAVA는 객체 지향을 강조하기 때문에 내가 코드를 작성하면서도 내 코드가 전혀 객체지향적이지 않다는 것이 바로 느껴지기 때문이다. 그래서 이번에 정리하는 글을 한 번 작성해 보려고 한다.

객체 지향과 절차 지향

프로그래밍 언어에는 크게 객체 지향과 절차 지향이 있다. 이들 각각은 '지향(oriented)'이라는 단어가 보여주듯 객체 지향 언어에서 절차적 프로그래밍을 할 수 없는 것도 아니고 절차 지향 언어에서 객체적 프로그래밍을 할 수 없는 것은 아니다. 하지만 그렇게 프로그래밍을 한다면 해당 언어의 특징과 강점의 빛이 바랠 수밖에 없다. 말 그대로 객체 지향 언어로 프로그래밍을 한다면, 객체 지향적으로 코드를 작성하는 것이 권장된다(사실 권장이 아니라 필수다). 속도와 효율의 측면에서 차이가 극심하게 나타나기 때문이다. 그렇다면 절차 지향과 객체 지향 각각의 개념이 무엇이고, 장단점에는 무엇이 있는지 한 번 알아보자.

절차 지향 프로그래밍객체 지향 프로그래밍
개념        물이 위에서 아래로 흐르는 것처럼 순차적인 처리가 중요시 되며 프로그램 전체가 유기적으로 연결되도록 만드는 프로그래밍 기법실제 세계를 모델링하여 소프트웨어를 개발하는 방법
장점속도가 빠르다유지 보수와 디버깅이 쉽다
단점유지 보수와 디버깅이 어렵다속도가 절차 지향에 비해 느리다

이 외에 여러 특징이 있지만 사실 가장 핵심적인 차이는 위와 같이 속도 vs 유지보수 & 디버깅이다. 절차 지향은 처리 과정 자체가 컴퓨터의 처리 과정과 유사해서 속도가 빠른 반면, 코드 더미마다 절차가 정해져 있어 유사한 작업에 기존 코드를 활용하려고 보면 정말 사소한 차이 때문에 아예 못 쓰는 경우가 발생하기도 한다.

객체 지향은 반대로 객체마다의 기능을 설계할 때부터 분절적으로 설계하기 때문에, 다른 곳에서 유사한 작업을 하고자 하면 특정 몇몇 기능과 변수명 정도만 수정해서 재활용이 용이하다. 하지만 인간의 사고 방식대로 처리 과정이 진행되기 때문에 컴퓨터 입장에서는 하지 않아도 되는 작업까지 하는 것과 같아서 속도가 상대적으로 느릴 수밖에 없다.

객체 지향의 특징

이러한 객체 지향 프로그래밍의 특징을 잘 활용하기 위해 '로버트 마틴'이라는 사람이 SOLID라는 객체 지향 프로그래밍과 설계에 있어서의 대원칙 5가지를 제안했고, 적어도 현재 JAVA 진영에서는 이 대원칙 5가지를 지킬 것을 권장하고 있으며, JAVA에서 가장 많이 사용되는 Spring 역시 이 원칙에 기반하여 설계되어 있다.

SOLID

본 게시글의 SOLID는 Inpa Dev님의 Tstory의 예시를 상당 부분 인용하고 있음을 밝힙니다.

SOLID는 각 원칙의 앞 글자만을 따 와서 만든 원칙이다. 단일 책임 원칙, 개방 폐쇄 원칙, 리스코프 치환 원칙, 인터페이스 분리 원칙, 의존관계 역전 원칙으로 구성된 이 대원칙은 객체 지향 언어의 강점을 가장 잘 활용할 수 있도록 개발자를 안내하는 역할을 한다. 지금부터 각각의 원칙에 대해 하나씩 알아가 보도록 하자.

단일 책임 원칙(Single responsibility principle)

참고한 게시글
SRP라고 불리는 단일 책임 원칙은 아래의 원칙을 따를 것을 요구한다.

한 클래스는 하나의 책임만 가져야 한다.

말 그대로 하나의 클래스는 하나의 책임만을 가져야 한다는 원칙이다. 아래의 예시는 Employee 클래스에 여러 책임이 부여되어 있는 상황이다.

class Employee {
    String name;
    String positon;

    Employee(String name, String position) {
        this.name = name;
        this.positon = position;
    }

	// * 초과 근무 시간을 계산하는 메서드 (두 팀에서 공유하여 사용)
    void calculateExtraHour() {
        // ...
    }

    // * 급여를 계산하는 메서드 (회계팀에서 사용)
    void calculatePay() {
        // ...
        this.calculateExtraHour();
        // ...
    }

    // * 근무시간을 계산하는 메서드 (인사팀에서 사용)
    void reportHours() {
        // ...
        this.calculateExtraHour();
        // ...
    }

    // * 변경된 정보를 DB에 저장하는 메서드 (기술팀에서 사용)
    void saveDatabase() {
        // ...
    }
}

이 예시에서 회계팀과 인사팀에서 각각 직원의 급여 계산과 근무 시간 계산을 위해 calculateExtraHour()를 사용하고 있다. 그런데 만약 회계팀에서 초과 근무 시간을 계산하는 방식이 달라졌다고 하면, 기술팀에서는 난감할 수밖에 없다. 하나의 메서드로 공유하고 있었는데, 이제 메서드를 둘로 나누어야 한다.

여기서부터 기술팀의 속칭 '노가다'가 시작된다. 그냥 둘로 나누면 끝나는 게 아닌가? 싶지만 일단 이 예시가 팀이 2개밖에 없는 굉장히 쉬운 예시라는 점과, 보통 회사 규모의 프로젝트 크기는 매우 크다는 점이 문제다. 회계팀에서 Employee 클래스의 calculateExtraHour() 메서드를 사용하고 있는 클래스를 모두 찾아낸 후, 해당 메서드를 새로 만든 초과 근무 시간 계산 매서드로 바꿔줘야 한다.

그런데, 객체 지향 언어의 장점 중 '유지 보수와 디버깅이 쉽다'라는 내용이 있었다. 이러한 방식의 유지 보수가 과연 쉬운 과정인가? 찾고 바꾸기만 하면 된다고는 하지만 실수의 여지가 너무 크다.

따라서 처음부터 회계 팀의 초과 근무 시간을 계산하는 책임과 인사 팀의 초과 근무 시간을 계산하는 책임을 분리해 단일 책임 원칙에 맞도록 '설계'를 했어야 했던 것이다. (올바른 설계를 했을 때의 코드가 궁금하다면 위의 참고한 게시글 링크를 따라가 보도록 하자.)

개방 - 폐쇄 원칙 (Open/closed principle)

참고한 게시글
OCP라고 불리는 개방 - 폐쇄 원칙은 아래의 원칙을 따를 것을 요구한다.

"소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다."

사실 잘 와닿지가 않는 원칙이다. 확장에 열려 있고 변경에 닫혀 있어야 한다는 말로는 납득이 잘 가지 않는다. 분명 비즈니스 로직이 변경될 필요가 있다면 코드를 변경해야 할 텐데, 어떻게 변경을 하지 않는다는 건지 자연스러운 의문이 들게 된다. 이 역시 이해를 위해 아래의 예시를 살펴보자

class Animal {
	String type;
    
    Animal(String type) {
    	this.type = type;
    }
}

// 동물 타입을 받아 각 동물에 맞춰 울음소리를 내게 하는 클래스 모듈
class HelloAnimal {
    void hello(Animal animal) {
        if(animal.type.equals("Cat")) {
            System.out.println("냐옹");
        } else if(animal.type.equals("Dog")) {
            System.out.println("멍멍");
        }
    }
}

public class Main {
    public static void main(String[] args) {
        HelloAnimal hello = new HelloAnimal();
        
        Animal cat = new Animal("Cat");
        Animal dog = new Animal("Dog");

        hello.hello(cat); // 냐옹
        hello.hello(dog); // 멍멍
    }
}

먼저 확장의 경우를 가정해 보자. 만약 다른 동물이 추가된다면? 이 코드 역시 메서드가 하나 뿐인 아주 쉬운 예시기 때문에 if문 분기만 추가하면 끝난다. 하지만 동물의 행동이 hello만 있는 것도 아니고, 이후 여러 동물의 여러 행동이 메서드로 추가되어야 할 텐데 메서드가 추가될 때마다 동물 갯수만큼의 메서드 안에 if문을 모두 작성해야 한다.

다음으로 변경의 경우를 가정해 보자. 변경에는 닫혀 있어야 한다는 말은 클래스의 변경으로 인해 기존 비즈니스 로직을 수정해야 할 필요가 없어야 한다는 말이다. 만약 변수 타입이 String에서 Enum으로 변경되었다고 해 보자. 그렇다면 HelloAnimal과 Main 모두에서 오류가 발생한다.

되게 당연해 보이고, 이러한 변경이 있다면 어쩔 수 없이 또 기술팀의 속칭 '노가다'가 발생할 것으로 보인다. 하지만 OCP를 처음부터 지켰다면 Animal 클래스에서 변경이 일어난다고 해서 비즈니스 로직에서 오류가 발생하는 일은 발생하지 않는다.
(올바른 설계를 했을 때의 코드가 궁금하다면 위의 참고한 게시글 링크를 따라가 보도록 하자.)

이러한 상황은 역시 마찬가지로 진짜 실수 없이 모두 코드를 작성하기만 하면 잘 작동하기는 한다. 하지만 실수의 여지가 크기도 하고 전혀 '유지 보수 & 디버깅'에 장점을 갖고 있는 코드가 아니다. 또한 '실제 세계를 모델링하여 소프트웨어를 개발하는 방법'이라고 하기에는 hello 안에 Animal이 매개 변수로 들어가는 것도 이상하다. Animal이 hello를 메서드로 갖고 있는 편이 훨씬 '실제'적이지 않은가? 이렇게 OCP를 지키지 않았기 때문에 객체 지향의 특징과 강점을 놓치는 경우가 발생하게 된다.

리스코프 치환 원칙 (Liskov substitution principle)

LSP라고 불리는 리스코프 치환 원칙은 아래의 원칙을 따를 것을 요구한다.

"프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스터스로 바꿀 수 있어야 한다."

조금 더 구체적으로 말을 바꿔 보면, 부모 클래스의 인스턴스를 사용하는 위치에 자식 클래스의 인스턴스를 대신 사용했을 때 코드가 원래 의도대로 작동해야 한다는 의미라고 할 수 있다. 아래 예시를 보자

/**
a, b, c, d는 각각 사각형의 각 변의 길이이다.
*/
// 사각형
class Quadrilateral {
    private int a;
    private int b;
    private int c;
    private int d;
    
    //네 변이 모두 꼭짓점에서 맞닿아 있다 등 사각형으로서의 정의
    
    void resize(){
        
    }
}

// 직사각형
class Rectangle extends Quadrilateral{
    // 마주 보는 두 변의 길이가 같고 모든 꼭짓점의 각도가 90도
}

// 정사각형
class Square extends Rectangle{
    // 네 변의 길이가 모두 같고 모든 꼭짓점의 각도가 90도
    // 를 정의하지 않은 클래스
}

위 예시는 우선 잘못된 설계인 것은 상식적으로 알 수 있다. 정사각형은 마주 보는 두 변의 길이가 같고 모든 꼭짓점의 각도가 90도인가?는 옳은 명제이기는 하지만 추가적으로 네 변의 길이가 같아야 한다는 조건이 추가되어야 한다. 즉 클래스를 잘못 정의한 것이다. 하지만 상속 관계에 있기 때문에 직사각형 인스턴스의 위치에 정사각형 인스턴스가 대체되어도 컴파일 상에서는 오류가 나지 않을 수 있다. 하지만 비즈니스 로직의 과정 혹은 결과에서 오류가 발생하게 될 것임은 자명하다.

처음부터 정사각형은 직사각형이 아닌 사각형을 상속받아야 했고, 정사각형의 정의에 맞는 설계를 따로 구현했어야 했다. 이처럼 LSP를 지키지 않을 경우 문제가 발생할 수 있고 만약 기본적으로 클래스 설계를 잘 해 두었다면 처음부터 컴파일 에러가 발생할 가능성이 높다.

인터페이스 분리 원칙 (Interface segregation principle)

ISP라고 불리는 인터페이스 분리 원칙은 아래의 원칙을 따를 것을 요구한다.

"특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다."

인터페이스 분리 원칙은 클라이언트가 자신이 사용하지 않는 메서드에 의존 관계를 맺으면 안 된다는 원칙이다. 이는 단순히 설명하면 인터페이스를 최대한 작게 유지하라는 것이다. 일단 기본적으로 인터페이스는 다중 상속이 허용되기 때문에 한 번에 2개의 역할을 가진 A 인터페이스를 만드는 것보다 B 인터페이스, C인터페이스를 만들어 둘 다 상속받아 쓰는 방법이 낫다.

물론 기능을 분리할 때는 단일 책임 원칙에도 위배되지 않아야 하고, 인터페이스마다 자신에게 할당된 책임을 수행하지 못 할 정도로 분리하지 않는 선에서만 분리해야 한다. 만약 A인터페이스에 사용해야 하는 메서드 5개가 있고, 5개는 필요가 없다 하더라도, 인터페이스를 상속받는 클래스는 반드시 나머지 5개도 어떤 식으로든 구현을 해야 하기 때문에, 필요 없는 코드를 작성하게 된다.

의존관계 역전 원칙 (Dependency inversion principle)

참고한 게시글
DIP라고 불리는 의존관계 역전 원칙은 아래의 원칙을 따를 것을 요구한다.

"프로그래머는 추상화에 의존해야지, 구체화에 의존하면 안된다."

DIP는 OCP와 아주 밀접한 연관이 있다. 어떻게 보면 OCP를 지키기 위해 DIP를 지키는 것이기 때문이다. 확장에는 열려 있어야 하고 변경에는 닫혀 있어야 하기 때문에, 구체 클래스에 의존하기보다는 추상 클래스에 의존하게 되기 때문이다. 아래 예시를 보자. 무기 클래스와 해당 무기 클래스를 인스턴스로 갖는 캐릭터 클래스가 있다.

class Character {
    final String NAME;
    int health;
    OneHandSword weapon; // 의존 저수준 객체

    Character(String name, int health, OneHandSword weapon) {
        this.NAME = name;
        this.health = health;
        this.weapon = weapon;
    }
    ...
}
class OneHandSword {
    final String NAME;
    final int DAMAGE;

    OneHandSword(String name, int damage) {
        NAME = name;
        DAMAGE = damage;
    }

    int attack() {
        return DAMAGE;
    }
}

class TwoHandSword {
    // ...
}

class BatteAxe {
    // ...
}

class WarHammer {
    // ...
}

위 코드의 상태에서 캐릭터가 무기를 TwoHandSword로 변경하려고 한다고 치자. 그러면 changeWeapon()과 같은 메서드로 바꾸게 될텐데, 현재 Character는 인스턴스로 OneHandSword만 가질 수 있기 때문에 오류가 발생한다. 따라서, 다른 무기를 사용할 수 없도록 하거나, 프로그램이 실행 중일 때가 아니라 프로그램을 끄고, 인스턴스 변수의 타입을 TwoHandSword로 바꾸고, 다시 프로그램을 실행시키거나, 인스턴스 변수로 모든 무기를 들고 있게 하는 등 전혀 직관적이지 않은 코드로 만들어야 한다. 따라서 기존 코드를 변경하게 되어 OCP 역시 지키지 못하게 된다.

이렇게 문제가 발생하는 이유는 Character 클래스가 구체 클래스에 의존하고 있기 때문이다. OneHandSword는 구체 클래스이기 때문에, 다른 타입의 인스턴스를 담을 수 없다. 따라서 Character 클래스는 OneHandSword가 아니라 모든 무기 클래스가 공통으로 상속하고 있는 어떠한 추상적인 클래스 (아마 이 예시에서는 Weapon)에 의존하고 있어야 한다.
예시를 말로 바꿔 보면,

'캐릭터가 한손검을 들고 있다.'
-> '캐릭터가 무기를 들고 있다.' + '현재 캐릭터의 무기는 ???이다.'

여기서 ???에는 '무기'인 것들 중 아무거나 비즈니스 로직에서 넣으면 된다. 즉, 캐릭터는 Weapon에 무엇이 들어올 지는 모르는 상태에서, 외부에서 주입(inject) 해 주면 그 무기를 갖게 된다. 따라서 Charater가 갖게 될 인스턴스를 외부에서 주입해 주는 개념이기 때문에 의존 관계 '역전'이 일어나는 것. 이러한 설계 이후에는 Character 객체를 생성할 때 Weapon이 null인 상태로 둘 지, 기본 무기라도 함께 생성할 지 등은 팀마다 회의를 통해 결정하면 된다.

마치며

계속 원칙과 개념 이후 예시를 들며 이해하다 보니 납득이 잘 갔을 수도, 아닐 수도 있다. 다만 계속 언급하고 있듯이 모든 예시가 이해를 위해 다소 극단적이거나 너무 쉬운 상황만을 가정하고 있다. 실제 개발 단계에서는 상대적으로 훨씬 어려운 상황이 등장하기 때문에, 앞으로 개발에 앞서 설계를 깊이 고민해 보는 경험이 필요해 보인다.

profile
잘 읽어야 쓸 수 있고 잘 들어야 말할 수 있다

0개의 댓글