객체 지향 설계 원칙 - SOLID

홍예석·2023년 1월 26일
0

기초 학습

목록 보기
5/5

객체 지향 설계 원칙 - SOLID

원칙을 설명하기에 앞서 이해해 두고 넘어가야 하는 개념이 있다. 절차 지향 / 객체 지향 프로그래밍 개념이다.

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

  보면 두 기법의 장 단점이 매우 명확하다. 절차 지향은 처리 과정 자체가 컴퓨터의 처리 과정과 유사해서 속도가 빠른 반면, 코드 더미마다 절차가 정해져 있어 유사한 작업에 기존 코드를 활용하려고 보면 정말 사소한 차이 때문에 아예 못 쓰는 경우가 발생하기도 한다. 객체 지향은 반대로 객체마다의 기능을 설계할 때부터 분절적으로 설계하기 때문에, 다른 곳에서 유사한 작업을 하고자 하면 특정 몇몇 기능과 변수명 정도만 수정해서 재활용이 용이하다. 하지만 처리 과정이 컴퓨터로 하여금 인간의 사고 방식대로 이루어지기 때문에 컴퓨터 입장에서는 하지 않아도 되는 작업까지 하는 것과 같아서 속도가 느릴 수밖에 없다.

  예를 들어 보자. 이 프로그래밍은 소설에 비유할 수 있다. 코드를 작성하는 우리는 작가다. 그리고 이 코드를 읽는 독자는 컴퓨터고, 컴퓨터는 인간의 언어를 이해하지 못 하기 때문에 컴파일러가 번역, 즉 컴파일을 해서 컴퓨터가 읽을 수 있도록 해 준다. 작가는 우리고, 번역가는 컴파일러, 독자는 컴퓨터다. 소설의 내용이 A동에 불이 나서 소방서에서 불을 끄러 와서 불을 껐다는 내용이라고 하자.
절차 지향은

'A동에서 불이 나서 소방서에서 소방관들이 와서 불을 껐다.'

라는 사건 자체에 대해 서술한 소설이다.
반면 객체 지향에서는 A동에서 절대 그냥 불이 나고 꺼지지 않는다. 반드시 객체 간 상호 작용의 결과에 의해서만 불이 나고 꺼질 수 있다.

'등장 인물1이 A동에서 담배 꽁초를 잔디에 버렸다가 불이 났고, 등장 인물 2가 소방서에 신고를 해서 소방관 10명이 소방차 2대와 함께 A동에 와서 소방차의 호스를 이용해 물탱크에서 물을 A동에 뿌려서 불을 껐다'

이처럼 사건의 내부적인 상황까지를 서술한 소설이다.

  여기서 주의해야 할 점이 두 가지 있다. 우선 두 프로그래밍에 모두 '지향'이라는 말이 붙는다는 것이다. 말 그대로 지향한다는 것이지 객체 지향이라고 순서가 없는 것이 아니고, 절차 지향이라고 객체 개념이 아예 없는 것은 아니다. 객체 지향이라고 해서 불이 난 적 없는 곳의 불을 미리 끌 수는 없다. 절차 지향이라고 해서 등장 인물이 한 명도 등장하면 안 되는 것이 아니다.

  둘 째는 등장 인물 == 객체가 아니라는 것이다. 위 소설을 클래스화 해 본다면, 객체로 설정할 수 있는 요소는 등장 인물 1, 2, A동, 소방서, 소방관, 물탱크, 경우에 따라서는 물과 불까지 객체가 된다. 말 그대로 어떠한 작용, 동작 등 대상으로 지정될 수 있다면 모두 객체가 될 수 있다. 이 설명이 객체의 완벽한 설명은 아니지만 받아들이기에 큰 무리는 없다.

  완벽히 절차 지향과 객체 지향을 다 설명하기는 어렵지만, 아래의 5가지 원칙을 이해하는 데 있어 필요한 만큼의 설명은 된 듯 하다. 자세한 설명은 다음 기회에 하기로 하고, 이제 5원칙에 들어가 보자.

단일 책임 원칙

  말 그대로 단일 책임 원칙이다. 하나의 클래스는 하나의 책임만을 갖고 있어야 한다는 것이다. 계산기를 예로 들어 보자

public class Calculator {

    public int calculate(String operator, int firstNumber, int secondNumber) {
        int answer = 0;

        if(operator.equals("+")){
            answer = firstNumber + secondNumber;
        }else if(operator.equals("-")){
            answer = firstNumber - secondNumber;
        }else if(operator.equals("*")){
            answer = firstNumber * secondNumber;
        }else if(operator.equals("/")){
            answer = firstNumber / secondNumber;
        }

        return answer;
    }
}

보면 계산기가 계산이라는 하나의 책임만을 가진 것으로 보일 수 있지만, Calculator는 총 5가지의 책임을 갖고 있다.

  1. 연산자가 어느 조건과 일치하는지 비교하는 책임
  2. 연산자가 "+"일 경우 더하기 연산을 할 책임
  3. 연산자가 "-"일 경우 빼기 연산을 할 책임
  4. 연산자가 "*"일 경우 곱하기 연산을 할 책임
  5. 연산자가 "/"일 경우 나누기 연산을 할 책임

계산기 자체가 우리에게 친숙한 객체이기 때문에 이렇게 구현해도 계산기라는 한 가지 책임만을 갖고 있는 것으로 받아들여지지만 계산기는 이처럼 5가지 책임을 갖고 있다. 따라서 연산을 진행할 클래스를 생성하여 책임을 분할해 주어야 한다.

class Calculator {
    public int calculate(String operator, int firstNumber, int secondNumber) {
        
        int answer = 0;

        if(operator.equals("+")){
            answer = AddOperation.operate(firstNumber,secondNumber);
        }else if(operator.equals("-")){
            answer = SubstractOperation.operate(firstNumber,secondNumber);
        }else if(operator.equals("*")){
            answer = MultiplyOperation.operate(firstNumber,secondNumber);
        }else if(operator.equals("/")){
            answer = DivideOperation.operate(firstNumber,secondNumber);
        }
        return answer;
    }
}

abstract class AddOperation{
    
    static int operate(int firstNumber, int secondNumber){
        return firstNumber + secondNumber;
    }
}
abstract class SubstractOperation{
    
    static int operate(int firstNumber, int secondNumber){
        return firstNumber - secondNumber;
    }

}
abstract class MultiplyOperation{
    
    static int operate(int firstNumber, int secondNumber){
        return firstNumber * secondNumber;
    }
}
abstract class DivideOperation{
    
    static int operate(int firstNumber, int secondNumber){
        return firstNumber / secondNumber;
    }
}

이렇게 보면 코드 자체가 훨씬 길어져 비효율적이라고 생각될 수 있다. 하지만 우리가 과연 사칙 연산을 계산기에서만 쓰는지 생각해 보아야 한다. 계산기 외에도 사칙 연산이 필요한 소프트웨어는 많고, 그 중 덧셈과 뺄셈만 필요한 경우도 있다. 그런데 사칙 연산이 들어가는 모든 소프트웨어마다 4개의 분기를 가진 if문을 작성하는 것보다는, 사칙연산 각각을 분리하여 클래스화한 후, 필요한 곳에서는 불러오기만 하는 것이 전체 코드의 측면에서는 훨씬 효율적이다. 이처럼 유지 보수와 재사용의 측면을 고려할 때, 단일 책임 원칙은 지킬 수 있다면 지킬수록 좋은 코드를 작성할 수 있게 된다.

개방 - 폐쇄 원칙

개방 - 폐쇄 원칙은 클래스를 확장하는 데는 개방되어 있어야 하지만 외부의 변화에는 폐쇄되어 있어야 한다는 원칙이다. 즉 클래스를 상속 받는 자식 클래스에서는 얼마든지 클래스를 오버라이딩 등의 방식으로 확장할 수 있어야 하지만, 특정 변화에 의해 반대로 클래스를 수정해야 하는 일은 발생해서는 안 된다는 것이다. 가장 빈번히 발생하는 사례로, 클래스에서 경로를 절대 경로로 설정하는 경우가 있다. 개인이 로컬에서 개발할 때는 파일 경로를 변경할 일이 거의 없기 때문에 문제가 없을 수 있지만, 배포할 경우 절대 경로로 된 경로는 다른 사용자들의 로컬에서는 오류가 난다. 만약 절대 경로인 채로 둔다면 사용자마다 자신의 절대 경로를 코드 상에서 새로 입력해야 하기 때문에, 외부의 변화에 따라 클래스가 변경되는, 폐쇄 원칙을 지키지 않은 코드가 된다.

리스코프 치환 원칙

리스코프 치환 원칙은 서브 타입은 언제나 자신의 기반 타입으로 교체할 수 있어야 한다는 원칙이다. 이는 사각형의 예시로 이해할 수 있습니다. 정사각형, 직사각형, 사각형을 클래스화 했을 때, 정사각형과 직사각형을 사각형의 하위 클래스가 될 수 있다. 이 경우, 사각형이라면 할 수 있는 일들은 모두 정사각형과 직사각형도 할 수 있어야 한다. 만약 이 사례를 코드로 작성했을 때, 사각형이 할 수 있는 일을 정사각형과 직사각형이 해내지 못하고 있다면, 이는 리스코프 치환 원칙을 위반하고 있는 것이다. 예를 들어, 정사각형의 대표적인 성질은 네 변의 길이가 같고 각도가 모두 90도로 같다는 점입니다. 그런데 만약 set메서드가 정사각형 클래스에 있어서 한 변의 길이만을 임의로 변경했다면, 각도가 변경되지 않기 때문에 변의 아귀가 맞지 않게 되고, 네 개의 직선의 끝점끼리 맞닿아 있어야 한다는 사각형의 조건을 무력화(위반)합니다. 이 경우 리스코프 치환 원칙을 위반한 것으로 본다.

인터페이스 분리 원칙

인터페이스 분리 원칙은 클라이언트가 자신이 사용하지 않는 메서드에 의존 관계를 맺으면 안 된다는 원칙이다. 이는 단순히 설명하면 인터페이스를 최대한 작게 유지하라는 것이다. 일단 기본적으로 인터페이스는 다중 상속이 허용되기 때문에 한 번에 10개의 기능을 가진 A 인터페이스를 만드는 것보다 5개의 기능을 가진 B 인터페이스, C인터페이스를 만들어 둘 다 상속받아 쓰는 방법이 낫다. 물론 인터페이스가 자신에게 할당된 책임을 수행하지 못 할 정도로 분리하지 않는 선에서만 분리해야 한다. 만약 A인터페이스에 사용해야 하는 기능 5개가 있고, 5개는 필요가 없다 하더라도, 인터페이스를 상속받는 클래스는 반드시 나머지 5개도 어떤 식으로든 구현을 해야 하기 때문에, 필요 없는 코드를 작성하게 된다. 앞서 계산기 예시에서, 만약 사칙 연산을 하나의 인터페이스로 구현했다고 할 때, 오로지 덧셈 기능만 필요한 클래스가 있다고 하자. 이 경우 덧셈 기능만 구현하면 되는데 쓰지도 않는 나머지 기능까지 구현해야 사용할 수 있게 된다. 이를 인터페이스 분리 원칙을 위반한 것으로 본다.

의존성 역전 원칙

이 의존성 역전 원칙을 잘 이해할 수 있는 문장이 있다.

"고차원 모듈은 저차원 모듈에 의존하면 안 된다. 이 두 모듈 모두 다른 추상화된 것에 의존해야 한다.”
“추상화된 것은 구체적인 것에 의존하면 안 된다. 구체적인 것이 추상화된 것에 의존해야 한다.”
"자주 변경되는 구체(Concrete) 클래스에 의존하지 마라“ - 로버트 C. 마틴

특히 두 번째 문장이 개인적으로 이해에 큰 도움이 됐다. 수학에서 예를 들어 보자. 근의 공식이라는 공식이 있다. 근의 공식은 고차원 모듈이고, 변수를 포함하고 있는 공식이며, 변경되지도 않는다. 즉 클래스의 입장에서 볼 때 추상적인 것이다. 그리고 근의 공식을 이용해 파생되는 여러 다른 공식들, 즉 다른 모듈들은 근의 공식이라는 모듈에 의존하고 있다. 이는 의존성이 전혀 역전되지 않는다.
이 역시 예를 들어 보자. A라는 부모 클래스가 있고 A를 상속받는 자식 클래스 B가 있다. 그런데 부모 클래스의 멤버, 혹은 생성자 매개 변수 등에 B 클래스가 포함되어 있다고 치자. 이는 모순이지만 코드를 작성하다 보면 이런 실수가 등장하기도 한다. B는 A의 모든 멤버를 다 포함하고 있는데, A의 멤버에는 B가 있다. 그렇다면 B는 B를 포함한다. 그럼 뒤의 B역시 다시 B를 포함한다. 결국 끝 없는 자기 호출이 발생하게 된다.
이러한 극단적인 사례가 아니더라도 의존성 역전 원칙을 위반할 여지는 충분히 많다. 상속 관계가 아니더라도 저차원 A클래스가 멤버로 고차원 B클래스를 갖고 있다면 B클래스가 변경될 때 A클래스가 지속적으로 의존하고 있기 때문에 오류가 발생할 수 있다.

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

0개의 댓글