좋은 객체 지향 설계의 5가지 원칙 - SOLID

Jina·2024년 7월 15일
0

객체지향

목록 보기
2/2

블로그 제목을 어떻게 할까 고민하던 중, 마침 듣고 있던 김영한님 강의에서 SOLID를 다루고 있길래...이렇게 제목을 베껴...아니 차용해왔다.

SOLID?

먼저, SOLID란 무엇일까?

사실 SOLID라는 개념은 정말 많이 돌아다니고, 많은 사람들이 학습하는 개념이지만 제대로 알고 있는 사람은 많지 않다.

실제로 책에서 소개하는 SOLID 개념을 살펴보면 외계어가 따로 없다.

  • S (SRP, 단일 책임 원칙): 한 클래스는 하나의 책임만 가져야 한다.
  • O (OCP, 개방-폐쇄 원칙): 소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다.
  • L (LSP, 리스코프 치환 원칙): 프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다.
  • I (ISP, 인터페이스 분리 원칙): 특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다.
  • D (DIP, 의존관계 역전 원칙): 프로그래머는 추상화에 의존해야 하며, 구체화에 의존하면 안된다.

처음 보면 이게 뭔소린가 싶고, 자연스럽게 다시 떠나보내게 된다.
나 또한 그랬다. 나는 정처기 할 때 이 개념을 제대로 처음 봤는데, 조용히 다음 장으로 넘기며 안나오길 기도했다...


개발자에게 이해하기에 가장 쉬운 도구는 '코드'이다. 위 개념들을 '코드'를 통해서 제대로 이해해보자.

1. S (SRP, 단일 책임 원칙)

말 그대로 정말 한 클래스는 하나의 책임을 가져야 한다는 뜻인데, 이렇게만 말해버리면 앞의 설명과 다를 게 없다.

대체 책임이란 무엇일까?

이게 굉장히 애매하다.
따라서 우리는 '변경'을 중요한 기준으로 본다. 변경이 있을 때 파급 효과가 적으면 단일 책임 원칙을 잘 따른 것으로 판단한다.

먼저 SRP를 따르지 않은 코드부터 살펴보자

참고로 아래 코드에서 PediaStudent 클래스는 다음과 같은 기능을 수행하고 있다.

  • takeQrcode(): qr코드를 인증하는 메소드(알파코 1팀과 알파코 2팀이 사용)
  • startEducationTime() : 알파코 1팀이 정규교육시간을 시작하는 메소드
  • startSelfStudyTime() : 알파코 2팀이 자율학습시간을 시작하는 메소드
class PediaAlpha{
    String name;
    String positon;

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

	// Qr코드를 찍는 메서드 (두 팀에서 공유하여 사용)
    void takeQrcode() {
        // ...
    }

    // 정규교육 시작 (알파코 1팀에서 사용)
    void startEducationTime() {
        // ...
        this.takeQrcode();
        // ...
    }

    // 자율학습 시작  (알파코 2팀에서 사용)
    void startSelfStudyTime() {
        // ...
        this.takeQrcode();
        // ...
    }
}

위 코드에서 takeQrcode()라는 메서드를 startEducationTime() 메서드와 startSelfStudyTime() 메서드에서 공유하고 있다.

그런데 만약 startEducationTime() 메서드를 사용하는 알파코 1팀이 takeQrcode() 메서드를 변경해야 해서 변경한다면...?

알파코 2팀에서 사용하던 startSelfStudyTime() 메서드에도 영향을 미치고 이는 곧 심각한 문제로 이어질 수도 있다.

하나를 변경했는데, 여러 곳에 영향을 미친다는 이 얘기가 바로 SRP를 따르지 않았다는 뜻이다.

따라서 아래와 같이 코드를 변경해야 한다.

SRP를 따른 코드


class PediaAlpha {
    private String name;
    private String positon;

    PediaAlpha(String name, String position) {
        this.name = name;
        this.positon = position;
    }
    
    // 정규교육시간을 시작하는 메소드 (알파코 1팀에서 사용)
    void startEducationTime() {
        // ...
        new EducationTimeStarter().startEducationTime();
        // ...
    }

    // 자율학습시간을 시작하는 메소드 (알파코 2팀에서 사용)
    void startSelfStudyTime() {
        // ...
        new SelfStudyTimeStarter().startSelfStudyTime();
        // ...
    }
}

// 알파코 1팀에서 사용되는 전용 클래스
class EducationTimeStarter {
    // qr코드를 인증하는 메서드
    void takeQrcode() {
        // ...
    }
    void startEducationTime() {
        // ...
        this.takeQrcode();
        // ...
    }
}

// 알파코 2팀에서 사용되는 전용 클래스
class SelfStudyTimeStarter {
    // qr코드를 인증하는 메서드
    void takeQrcode() {
        // ...
    }
    void startSelfStudyTime() {
        // ...
        this.takeQrcode();
        // ...
    }
}

startEducationTime() 메서드와 startSelfStudyTime() 정의하는 클래스를 따로 따로 정의하고, 그 안에 takeQrcode() 메서드도 각각 정의한다.

이렇게 되면 알파코 1팀이 takeQrcode() 메서드를 변경해도 2팀에는 영향을 미치지 않는다.

즉, 단일 책임 원칙을 잘 따랐다고 할 수 있다.

2. O (OCP, 개방-폐쇄 원칙)

'확장에는 열려 있으나 변경에는 닫혀 있어야 한다.'
이게 진짜 무슨 외계어인지 모르겠다...

이걸 풀어서 좀 더 쉽게 말하면 다음과 같이 말할 수 있다.

기능을 추가할 때는 추가하는 부분 추가만 해야 하며, 기존 코드를 변경하면 안된다.

물론 이렇게 말해도 잘 와닿지 않을 것이다.
그렇다면 우리가 이해할 수 있는 수단은 역시 코드이다.

OCP가 지켜지지 않은 코드

// Car 클래스
public class Car {
    String type;

    public Car(String type) {
        this.type = type;
    }
    public void charge(){
        if(type.equals("GasCar")){
            System.out.println("기름을 주유합니다.");
        }
        else if(type.equals("ElectricCar")) {
            System.out.println("전기차를 충전합니다.");
        }
        // 다른 차를 추가하려면 아래와 같이 추가해줘야 함
        else if(type.equals("HydrogenCar")){
            System.out.println("수소차를 충전합니다.");
        }
    }
}

// Main
public class Main {
    public static void main(String[] args) {
        Car gasCar = new Car("GasCar");
        Car electricCar = new Car("ElectricCar");
        Car hydrogenCar = new Car("HydrogenCar"); // 수소차 추가

        gasCar.charge();
        electricCar.charge();
        hydrogenCar.charge(); // 수소차 추가
    }
}

위 코드에서 새로운 차인 수소차를 추가하려면 기존 코드인 Car 클래스의 charge 메서드를 수정해야 한다.
새로운 차가 추가될 때마다 매번 코드를 변경해줘야 한다는 것은 상당히 번거로운 일이고 유지보수에도 최악이다.

바로 이렇게 기존 코드, 정확히는 클라이언트 코드를 수정한다는 것이 OCP에 위배된다.

따라서 아래와 같이 OCP를 지킬 수 있도록 수정해야 한다.

OCP가 지켜진 경우

// Car 인터페이스
public interface Car {
    void charge();
}

// ElectricCar 클래스
public class ElectricCar implements Car{
    @Override
    public void charge() {
        System.out.println("전기차를 충전합니다.");
    }
}

// GasCar 클래스
public class GasCar implements Car{
    @Override
    public void charge() {
        System.out.println("기름을 주유합니다.");
    }
}

// HydrogenCar 클래스 // 다른 차 추가
public class HydrogenCar implements Car{
    @Override
    public void charge() {
        System.out.println("수소차를 충전합니다.");
    }
}

// Main
public class Main {
    public static void main(String[] args) {
        Car gasCar = new GasCar();
        Car electricCar = new ElectricCar();
        Car hydrogenCar = new HydrogenCar(); // 수소차 추가

        gasCar.charge();
        electricCar.charge();
        hydrogenCar.charge(); // 수소차 추가
    }
}

위 코드와 같이 다형성 및 상속을 통해서 OCP를 지킬 수 있다.

이게 왜 OCP를 지킨 코드인지 이해가 잘 안되시는 분들을 위해 부연 설명을 하자면,

이 코드는 수소차를 추가해야 할 때, 기존 클라이언트 코드를 수정하는 것이 아니라 HydrogenCar 클래스를 추가하기만 하면 된다.

처음 코드도 그냥 if문 조건을 추가한 것이 아니냐고 반문할 수도 있는데, 기존 클래스를 손봐야 한다는 것은 엄연히 추가가 아니고 수정이다.

3. L (LSP, 리스코프 치환 원칙)

리스코프... 괜히 사람 이름이 더 어렵게 느껴지게 만든다.

하지만 이것도 한마디로 풀어쓰자면 다음과 같다.

자식은 부모가 정의한 대로 구현되어야 한다.

즉, 어떤 인터페이스를 구현한 하위 인스턴스는 딱 인터페이스에 있는 만큼만 구현해야 한다는 뜻이다.

인터페이스는 "공통 규약"인데, 하위 인스턴스가 그 이상을 구현하면 그 의미가 퇴색된다.

이것도 코드로 더 자세히 보자.

LSP가 지켜지지 않은 경우

interface Parent {
    void A();
    void B();
}

public class Child implements Parent {
    public void A() {}
    public void B() {}
    public void C() {}
    public void D() {}
}

분명 부모 인터페이스에는 A와 B만 있는데 자식 클래스가 멋대로 C와 D 메서드까지 구현해버렸다.

이런 걸 우리는 LSP를 위반했다고 한다.

그렇다면 LSP를 지키려면?

interface Parent1 {
    void A();
    void B();
}

interface Parent2 {
    void C();
    void D();
}

public class Child implements Parent1, Parent2 {
    public void A() {}
    public void B() {}
    public void C() {}
    public void D() {}
}

C와 D 메서드를 갖는 다른 인터페이스도 부모로 껴주자,,

여기에서 그냥 Parent1이 A, B, C, D 모두 갖고 Child가 Parent1만 상속받으면 되지 않느냐는 의문이 생길 수도 있는데, 타당한 의문이다.

그렇게 해도 LSP는 지켜진다.
그런데 굳이 이렇게 한 이유를 설명하라고 하면, 아래 ISP 때문이다.

4. I (ISP, 인터페이스 분리 원칙)

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

이것도 개념만 보면 대체 뭔 소린가 싶다.
좀 더 쉽게 풀어보자.

자식 클래스는 자신이 사용하는 메서드만 존재하는 인터페이스를 상속해야 한다.
이렇게 하기 위해 인터페이스는 너무 비대해지면 안된다.

아직도 무슨 소리인지 모르겠다면 아래 코드를 보자.

ISP가 지켜지지 않은 경우

interface Car {
	String PowerOn();
	void lockWindow();
	String speakAI();
}

class Avante implements Car {
	String PowerOn(){
		...
	}
	void lockWindow() {
		...
	}
	String speakAI() {
		return "지원하지 않는 기능입니다.";
	}
}

class Grandeur implements Car {
	String PowerOn(){
		...
	}
	void lockWindow() {
		...
	}
	String speakAI() {
		...
	}
}

위 코드에서 Avante 클래스는 speakAI() 메서드가 필요가 없다.
하지만 Car라는 인터페이스를 상속받아서 어쩔 수 없이 구현해야 한다.

뭔가 문제가 있다고 느껴지지 않나?

Car 인터페이스가 너무 많은 인터페이스를 갖고 있어서 생긴 문제이다.

따라서 다음과 같이 이를 해결할 수 있다.

ISP가 지켜진 경우

interface Car {
	String PowerOn();
	void lockWindow();
}

interface AICompanion {
	String speakAI();
}

class Avante implements Car {
	String PowerOn(){
		...
	}
	void lockWindow() {
		...
	}
}

class Grandeur implements Car, AICompanion {
	String PowerOn(){
		...
	}
	void lockWindow() {
		...
	}
	String speakAI() {
		...
	}
}

Car의 기본 기능은 Car 인터페이스에 구현해두고, Grandeur의 추가 기능은 AICompanion이라는 인터페이스를 생성하여 추가 구현해준다.

이렇게 하면 Avante는 사용하지 않는 메서드를 굳이 구현할 필요가 없다.

5. D (DIP, 의존관계 역전 원칙)

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

알듯 말듯한...무슨 소리인가 싶다.
쉽게 말해서, 아래와 같다.

구현 클래스에 의존하지 말고, 인터페이스에 의존해야 한다.
즉, 역할에 의존해야 한다는 뜻!

잘 모르겠으면 이것도 코드로 보자.

DIP를 지키지 않은 코드

public class WindowsXPComputer { 
	private final StandardKeyboard keyboard;
	private final BallMouse mouse; 
	
	public Windows98Machine() { 
		mouse = new BallMouse(); 
		keyboard = new StandardKeyboard(); 
	} 
}

WindowsXPComputer 클래스는 StandardKeyboard와 BallMouse라는 구현 클래스에 의존하고 있다.

그런데 만약 볼마우스가 고장난다면? 단종되어서 더 이상 팔지 않는다면?

이 코드에서는 볼마우스가 사라지면 WindowsXPComputer도 고장난다.
따라서 WindowsXPComputer에게 다양한 키보드와 마우스를 선택해 사용할 수 있도록 해야 한다.

DIP가 지켜진 경우


public class WindowsXPComputer {
    private  Keyboard keyboard;
    private  Mouse mouse;

    public void WindowsXPComputer(Keyboard keyboard, Mouse mouse) {
        this.keyboard = keyboard;
        this.mouse = mouse;
    }
}

public interface Mouse {
    void move();
}

public class VerticalMouse implements Mouse {
    public void move() {}
}

public class BallMouse implements Mouse {
    public void move() {}
}

public interface Keyboard {
    // 키보드 관련 메서드 정의
}

public class StandardKeyboard implements Keyboard {
    // StandardKeyboard의 구현
}

public class MechanicalKeyboard implements Keyboard {
    // MechanicalKeyboard의 구현
}

이런 식으로 WindowsXPComputer 클래스가 StandardKeyboard와 BallMouse라는 구현 클래스가 아닌 Keyboard와 Mouse라는 인터페이스에 의존하게 되면 위의 문제는 사라진다.

이걸 DIP를 지켰다고 한다.

SOLID는 이제 알겠고...

추가로 의존성과 응집도, 결합도 개념을 살펴보자.
갑자기 왜 의존성, 응집도, 결합도를 살펴보는 건가 싶을 수도 있다.

하지만 이 세 특징은 결국 앞서 살펴본 SOLID 원칙과 연결되어 있다.

그 예로 SRP만 집어서 설명을 해보자면, SRP를 지킨다는 것은 해당 프로그램에서 한 클래스 내의 응집도가 높다는 것이고, 클래스끼리는 관련이 적다는 것이므로 결합도가 낮다는 것을 의미한다. 그렇다면 당연히 의존성도 낮을 것이다.

결국 다 객체지향 원칙을 다루는 만큼, SOLID 원칙과 의존성, 응집도, 결합도는 따로 학습할 수 없는 개념이므로 여기에서 한 번에 다루기로 하자.

의존성

의존성은 하나의 모듈 또는 클래스가 다른 모듈이나 클래스를 필요로 하는 정도를 의미한다.

즉, 특정 객체가 다른 객체를 사용하거나, 특정 객체의 기능에 의존하여 동작하는 관계를 나타낸다.

class Engine {
    void start() {
        System.out.println("Engine started");
    }
}

class Car {
    private Engine engine;

    Car() {
        engine = new Engine(); // Car는 Engine에 의존적
    }

    void startCar() {
        engine.start(); // Car는 Engine의 메소드에 의존적
    }
}

위 코드에서 Car 클래스는 Engine 클래스에 강하게 의존적이다. Engine이 없으면 Car는 동작할 수 없기 때문이다.

객체지향 설계에서는 의존성을 최소화하여 모듈 간의 독립성을 높이고, 유지보수와 확장을 용이하게 한다.

응집도와 결합도는 모듈의 독립성을 측정하는 기준이라고 할 수 있다.

그렇다면, 이제 응집도와 결합도에 대해 알아보자.

응집도와 결합도

1) 응집도

응집도는 한 모듈 내의 구성 요소 간의 밀접한 정도를 의미한다.

객체지향의 관점에서 응집도는 객체 또는 클래스에 얼마나 관련 있는 책임들을 할당했는지를 나타낸다.

응집도가 낮다면?

모듈 내부에 서로 관련 없는 함수나 데이터들이 존재하거나 관련성이 적은 여러 기능들이 서로 다른 목적을 추구하며 산재해 있다.

응집도가 높다면?

하나의 모듈 안에 함수나 데이터와 같은 구성 요소들이 하나의 기능을 구현하기 위해 필요한 것들만 배치되어 있고 긴밀하게 협력한다.
즉, 응집도가 높을 수록 독립성이 높은 모듈이며 좋은 소프트웨어는 높은 응집도를 유지해야 한다.

2) 결합도

결합도는 모듈간의 상호 의존 정도를 의미한다.

객체지향의 관점에서 클래스나 메소드가 적절한 수준의 관계만을 유지하고 있는지를 나타낸다.

결합도가 높다면?

결합도가 높다는 것은 다른 클래스와의 연관성이 높다는 것을 의미한다.

이는 한 클래스를 수정할 때 연관된 다른 클래스도 변경해야 할 가능성이 높아진다는 것을 뜻한다.

예를 들어, 자동차의 경우를 생각해보면, 핸들, 바퀴, 엔진 등 여러 모듈이 서로 의존되어 결합된 상태인데, 만약 자동차의 결합도가 너무 높게 설계되었다면, 바퀴를 교체하는데 엔진까지 모두 바꿔야 하는 상황이 발생할 수 있다.
(뭔가 SOLID 원칙에서 다룬 듯한 느낌이 든다면 제대로 이해한 것이 맞다.)

결합도가 낮다면?

모듈 간의 의존성이 줄어들어, 하나의 모듈을 변경하더라도 다른 모듈에 미치는 영향을 최소화할 수 있다.
그리고 이것은 유지보수성과 확장성을 높이는 중요한 요소이다.
즉, 좋은 소프트웨어는 낮은 결합도를 가지고 있다.

1개의 댓글

comment-user-thumbnail
2024년 7월 15일

넘상세하게 적어주셨네요 많은도움 됐습니다

답글 달기