객체 지향 설계의 5가지 원칙 SOLID 제대로 짚고 넘어가기 - SRP, OCP, LSP, ISP, DIP

1. SRP (Single Responsibility Principal)

SRP는 단일 책임 원칙으로 객체는 단 하나의 책임만 가져야 한다는 원칙을 말한다. 

 

여기서 책임은 하나의 기능 담당이다. 즉, 하나의 클래스는 하나의 기능을 담당하여 하나의 책임을 수행해야한다. 

 

여러 개의 기능이 하나에 담겨있다면 더 효율적이고 좋다고 생각할 수도 있다. 하지만 이건 사용자의 입장이다. 사용자가 아닌 코드를 설계하는 개발자 입장에선 마이너스 적인 요소로 작용하게 된다. 

 

 

하나의 클래스에 여러 기능(책임)을 넣느냐, 따로따로 클래스를 분리하여 기능(책임)을 분산시키느냐 설계는 프로그램의 유지보수와 밀접한 관련이 있다. 

 

하나의 클래스에 여러 책임이 포함되어 있다면 한 책임의 변경에서 다른 책임의 변경을 야기할 수 있다.

예를 들어 A 기능을 수정했더니 B기능도 수정해야하고, C기능도 수정해야 하는 상황이 발생할 수 있다. 

 

SRP 원칙이 잘 지켜진다면 각 클래스마다 알맞은 하나의 책임을 가짐으로써 책임 영역이 활실해지게 된다. 

 

즉, SRP 원칙이 잘 지켜졌다면 모듈이 변경되는 이유는 단 한가지 일 것이다. 

 

 

SRP 원칙을 준수하다보면 전체 코드 길이는 길어질 수 있지만, 하나의 클래스를 사용하는 것 보다 여러 클래스로 분리하는 것이 유지보수 측면에서 더욱 효율적이다.

 

 

 

SRP를 위반한 코드

아래 코드는 단일 책임 원칙을 위반한 코드의 예시이다. 

 

public class Animal {

    private String animal;

    public void setAnimal(String animal) {
        this.animal = animal;
    }

    public void cry () {

        if(animal == "Dog") { // 강아지
            System.out.println("bark!");
        }
        else if(animal == "Cat") { // 고양이
            System.out.println("meow..");
        }
    }

}

 

Animal 클래스에서 동물의 울음소리를 출력하는 cry() 메서드는 Animal의 값이 Cat이면 고양이 울음소리를, Dog이면 강아지 울음 소리를 출력한다. 즉, 두 기능이 분리되어 있지 않고 하나의 메서드가 두 기능을 모두 가지고 있기 때문에 단일 책임 원칙을 위반한다. 

 

 

 

SRP 적용 후

아래 코드에서는 단일 책임 원칙 위반 문제를 해결하기 위해 추상 클래스를 만들어 각각의 하위 클래스에서 메서드를 상속 받아 사용하고 있다. 

 

abstract class Animal {
    abstract void cry();
}

class Dog extends Animal {
    @Override
    void cry() {
        System.out.println("bark!!!");
    }
}

class Cat extends Animal {
    @Override
    void cry() {
        System.out.println("Meow...");
    }
}

 

각각의 Dog, Cat 클래스를 만들어 Animal을 상속 받아 각자의 클래스에 자신의 울음소리만을  구현하고 있다. 즉, 하나의 클래스에 하나의 기능을 가지고 있어 SRP가 잘 지켜지고 있다.

 

 

 

 

 

 

 

 

 

 

2. OCP (Open Closed Principle)

개방 폐쇄의 원칙(OCP)이란 기존의 코드를 변경하지 않으면서, 기능을 추가할 수 있도록 설계가 되어야 한다는 원칙을 말한다. 

 

기능이 변하는 것과 확장은 가능한데, 어떻게 기존 코드를 수정하지 않을 수 있지 라는 의문이 들 수 있다. 

 

대표적인 예시로 프로그래밍 시 사용하는 라이브러리를 생각할 수 있다. 라이브러리를 사용하는 객체의 코드가 변경된다고 해서 라이브러리의 코드가 변경되는 일은 없다.  

 

확장에 열려있다 라는 것은 모듈의 확장성을 보장하는 것을 의미한다. 새로운 변경사항이 발생했을 때 유연하게 코드를 추가함으로써 애플리케이션의 기능을 큰 힘을 들이지 않고 확장할 수 있다. 

 

변경에 닫혀있다 라는 것은 객체를 직접적으로 수정하는 건 제한해야 한다는 것을 의미한다. 만약 새로운 변경 사항이 발생했을 때 객체를 직접적으로 수정해야 한다면 새로운 변경사항에 대해 유연하게 대응할 수 없는 애플리케이션이다. 

 

따라서 객체를 직접 수정하지 않고도 변경사항을 적용할 수 있도록 설계하는 것이 OCP의 핵심이다. 

 

 

코드 예시

OCP 코드 예시는 위에서 살펴봤던 SRP 예시 코드를 그대로 가져와 사용할 수 있다. 

public class Animal {

    private String animal;

    public void setAnimal(String animal) {
        this.animal = animal;
    }

    public void cry () {

        if(animal == "Dog") { // 강아지
            System.out.println("bark!");
        }
        else if(animal == "Cat") { // 고양이
            System.out.println("meow..");
        }
    }

}

 

위 코드에선 새로운 Animal인 토끼가 추가된다면 기존 코드인 Animal 클래스를 수정해야 한다. 

 

 

하지만 아래 코드에선 토끼가 추가된다 해도 Animal을 상속받아 추가하면 되기 때문에 기존 코드의 변경으로부터 자유롭다. 

abstract class Animal {
    abstract void cry();
}

class Dog extends Animal {
    @Override
    void cry() {
        System.out.println("bark!!!");
    }
}

class Cat extends Animal {
    @Override
    void cry() {
        System.out.println("Meow...");
    }
}

 

 

 

 

 

 

 

 

 

 

3. LSP (Liskov Substitution Principal)

리스코프 치환 원칙은 1988년 바바라 리스코프(Barbara Liskov)가 올바른 상속 관계의 특징을 정의하기 위해 발표한 것으로, 서브 타입은 언제나 기반 타입으로 교체할 수 있어야 한다는 것을 뜻한다. 

 

즉, 자식 클래스는 최소한 자신의 부모 클래스에서 가능한 행위는 수행이 보장되어야 한다.

 

LSP 원칙이 잘 지켜졌을 경우, 부모 클래스의 인스턴스를 사용하는 위치에 자식 클래스를 대신 사용했을 때에도 코드가 원래 의도대로 잘 작동해야 한다. 

 

자바 프로그래밍의 다형성 원리가 이에 해당한다. 

 

 

다형성 기능을 이용하기 위해서는 클래스를 상속 시켜 타입을 통합할 수 있게 설정하고, 업캐스팅을 해도 메소드 동작에 문제없게 잘 설계하여야 한다.

 

이러한 LSP 원칙을 잘 적용한 얘제가 자바의 컬렉션 프레임워크(Collection Framework) 이다.

 

만일 변수에 LinkedList 자료형을 담아 사용하다, 중간에 전혀 다른 HashSet 자료형으로 바꿔도 add() 메서드 동작을 보장받기 위해서는 Collection 이라는 인터페이스 타입으로 변수를 선언하여 할당하면 된다.

 

왜냐하면 인터페이스 Collection의 추상 메서드를 각기 하위 자료형 클래스에서 implements하여 인터페이스 구현 규약을 잘 지키도록 미리 잘 설계되어 있기 때문이다.

 

 

 

LSP 원칙 적용 주의점

정리하면 리스코프 치환 원칙이란 다형성의 특징을 이용하기 위해 상위 클래스 타입으로 객체를 선언하여 하위 클래스의 인스턴스를 받는 것이다. 

 

그리고 LSP 원칙의 핵심은 상속이다. 

 

하지만, 객체 지향 프로그래밍에서 상속은 부모 클래스와 자식 클래스 사이에 IS-A 관계가 있을 경우에만 사용하도록 제한하고 있다. 

 

그 외의 경우는 합성(Composition)을 이요하도록 권고하고 있다. 

 

 

 

 

 

 

 

 

 

 

4. ISP (Interface Segregation Principle)

ISP 원칙이란 범용적인 인터페이스 보다는 클라이언트(사용자)가 실제로 사용하는 Interface를 만들어야 한다는 의미로, 인터페이스를 사용에 맞게 끔 각기 분리해야 한다는 설계 원칙이다.

 

즉, ISP란 객체는 자신이 사용하는 메서드에만 의존해야 하는 것이고, 객체가 사용하지 않는 메서드를 의존해서는 안 된다.

 

 

위 그림에서는 user1, user2, user3 모두 OPS를 상속 받고 있다. 이때 user1 op1 메서드만, user2는 op2 메서드만, user3는 op3 메서드만 사용한다고 가정해보자. 

 

user1은 op1 메서드만 사용하지만 op1, op2, op3 메서드를 포함하고있는 ops를 상속받았기 때문에 전부 사용할 수 있다. 하지만 user1은 op2, op3의 메서드는 불필요하다. 

user1은 op2를 사용하지 않음에도 불구하고 만약 op2의 메서드에 변경이 일어나면, 함께 변경되어 재컴파일 & 재배포 과정을 거쳐야 하는 문제가 생긴다. 

 

ISP 원칙을 고려하여 재설계하면 아래와 같이 설계할 수 있다.

 

 

 

OPS는 OPS를 상속 받는 U1Ops, U2Ops, U3Ops로 잘게 분리 되었다. 이렇게 분리되면, 각각의 객체들은 오직 자신이 필요한 메서드만을 사용할 수 있는 구조가 된다. 

 

이 경우에는 op2 메서드의 변경이 일어나더라도 user1 객체에는 전혀 영향이 가지 않습니다. ISP를 준수한 바람직한 설계 구조라고 볼 수 있다.

 

 

예제 코드로 ISP를 살펴보자. 

 

 

 

 

두 객체를 구현하기 위해 더 큰 개념인 교통수단 이라는 추상 클래스가 있다. 

Transportation 클래스에는 두 객체의 공통 개념인 boarding() 메서드와 자동차와 관련된 메서드, 배에 관련된 메서드들이 있다.

 

abstract public class Transportation {

    public void boarding() {
        System.out.println("탑승합니다.");
    }

	//Car
    public void drive() {
        System.out.println("운전합니다.");
    }
    public void driveLeft() {
        System.out.println("왼쪽으로 운전합니다.");
    }
    public void driveRight() {
        System.out.println("오른쪽으로 운전합니다.");
    };

	//Ship
    public void steer() {
        System.out.println("조종합니다.");
    };
    public void steerLeft() {
        System.out.println("왼쪽으로 조종합니다.");
    };
    public void steerRight() {
        System.out.println("오른쪽으로 조종합니다.");
    };
}

 

 

 

 

위에서 만든 Transportation 클래스를 상속 받은 car 객체이다.

자동차에 필요한 메서드들을 오버라이드했다.

 

//Car 객체
public class Car extends Transportation{

    @Override
    public void boarding() {
    	//구현...
    }

    @Override
    public void drive() {
    	//구현...
    }
    @Override
    public void driveLeft() {
    	//구현...
    }
    @Override
    public void driveRight() {
    	//구현...
    }


    @Override
    public void steer() {
        System.out.println("불필요");
    }

    @Override
    public void steerLeft() {
        System.out.println("불필요");
    }

    @Override
    public void steerRight() {
        System.out.println("불필요");
    }

}

 

위 Car 객체에는 본인이 필요하지 않은, 배를 조종할 때 필요한 steer(), steerLeft(), steerRight() 메서드들이 포함되어 있고, 이는 불필요하다. 

 

 

 

 

Transportation 클래스를 상속받는 Ship 객체도 마찬가지이다.

 

//Ship 객체
public class Ship extends Transportation{

    @Override
    public void boarding() {
    	//구현...
    }
    
    @Override
    public void drive() {
        System.out.println("불필요");
    }
    @Override
    public void driveLeft() {
        System.out.println("불필요");
    }
    @Override
    public void driveRight() {
        System.out.println("불필요");
    }


    @Override
    public void steer() {
    	//구현...
    }

    @Override
    public void steerLeft() {
    	//구현...
    }

    @Override
    public void steerRight() {
    	//구현...
    }
    
}

 

Ship 객체에선 자동차를 운전할 때 필요한 drive(), driveLeft(), driveRight() 메서드들이 전혀 필요하지 않다. 하지만 Transportation 클래스를 상속받았기 때문에 해당 기능의 메서드를 강제로 상속받게 된다.

 

 

 

 

위 코드를 ISP를 적용하면 아래와 같이 인터페이스를 설계할 수 있다.

public interface boarding {
    public void boarding();
}

public interface drive {
    public void dive();
}

public interface driveLeft {
    public void driveLeft();
}

public interface driveRight {
	public void driveRight();
}

public interface steer {
    public void steer();
}

public interface steerLeft {
    public void steerLeft();
}

public interface steerRight {
	public void steerRight();
}

 

 

 

 

위와 같이 인터페이스를 구현했다면, Car 객체는 본인이 필요한 메서드를 포함한 인터페이스만 implements 하면 된다. 

 

public class Car implements boarding,drive,driveLeft,driveRight{

    @Override
    public void boarding() {
        // 구현...
    }

    @Override
    public void dive() {
        System.out.println("운전합니다.");
    }

    @Override
    public void driveLeft() {
        System.out.println("왼쪽으로 운전합니다.");
    }

    @Override
    public void driveRight() {
        System.out.println("오른쪽으로 운전합니다.");
    }

}

 

 

즉, 인터페이스 분리 원칙이란 반드시 객체가 자신에게 필요한 기능만을 가지도록 제한하는 원칙이다. 불필요한 상속과 구현을 최대한 방지함으로써 객체의 불필요한 책임을 제거한다.

 

 

 

 

 

 

 

 

 

 

 

5. DIP (Dependency Inversion Principle)

DIP 원칙이란 객체에서 어떤 Class를 참조해서 사용해야하는 상황이 생긴다면, 그 Class를 직접 참조하는 것이 아니라 그 대상의 상위 요소(추상 클래스 or 인터페이스)로 참조하라는 원칙이다.

 

 

고수준 모듈은 저수준 모듈의 구현에 의존해서는 안 된다. 대신 저수준 모듈이 고수준 모듈에서 정의한 추상 타입에 의존해야 한다.

 

객체들이 서로 정보를 주고 받을 때는 의존 관계가 형성되는데, 추상성이 낮은 클래스보다 추상성이 높은 클래스와 통신을 해야한다는 것이 DIP 원칙이다.

 

 

만약 사용자가 상속관계로 이루어진 모듈을 가져다 사용할 때 하위 모듈을 직접 가져다 쓰지 말라는 뜻이다.

만약 하위 모듈을 가져다 사용한다면 하위 모듈의 구체적인 내용에 클라이언트가 의존하게 되어 하위 모듈에 변화가 있을 때 마다 클라이언트나 상위 모듈의 코드를 자주 수정해야 되기 때문이다.

 

 

 

 

DIP 원칙을 잘 지켜지고 있는 대표적인 것 중 하나는 자바의 컬렉션 프레임워크이다. 개발을 해오며 보통 ArrayList나 HashSet 자료형을 인스턴스화 할 때 변수 타입을 ArrayList, HashSet 같은 구체 클래스 타입으로 선언하는 것이 아닌, List나 Set 같은 인터페이스 타입으로 선언해왔을 것이다.

 

// 변수 타입을 고수준의 모듈인 인터페이스 타입으로 선언하여 저수준의 모듈을 할당 
List<String> myList = new ArrayList()<>; 
Set<String> mySet = new HashSet()<>; 
Map<int, String> myMap = new HashMap()<>;

 

 

 

 

 

 

DIP 를 위반한 코드는 아래와 같다.

 

 

public class Kid {
    private Robot toy;

    public void setToy(Robot toy) {
        this.toy = toy;
    }

    public void play() {
        System.out.println(toy.toString());
    }
}



public class Main{
    public static void main(String[] args) {
        Robot robot = new Robot();
        Kid k = new Kid();
        k.setToy(robot);
        k.play();
    }
}

 

 

kid는 robot 이라는 구체적인 객체에 의존하고 있다. 이때 Kid가 갖고 노는 장난감이 변경되는 경우, 다음과 같이 Kid 클래스를 수정해야 한다.

 

public class Kid {

    private Robot toy;
    private Lego toy; //레고 추가  !코드변경!

    // 아이가 가지고 노는 장난감의 종류만큼 Kid 클래스 내에 메서드가 존재해야함.
    public void setToy(Robot toy) {
        this.toy = toy;
    }
    public void setToy(Lego toy) {
        this.toy = toy;
    }

    public void play() {
        System.out.println(toy.toString());
    }
}

 

 

이렇게 아이라는 고수준 모듈이, 변하기 쉬운 장난감이라는 저수준 모듈에게 의존하면 영향에 직접적으로 노출된다. 장난감을 하나 더 추가하기 위해 kid 클래스에 수정까지 일어나기 때문이다.

 

 

DIP 원칙을 만족시키기 위해선 아래와 같이 설계할 수 있다.

DIP는 의존 관계를 맺을 때 자신보다 변화하기 쉬운 것을 의존해서는 안 되고, 거의 변화가 없는 개념에 의존해야 한다.

 

 

 

 

위와 같은 설계에선 장난감의 종류가 아무리 추가되고, 변경되어도 아이 객체에 영향을 미치지 않는다. 위 내용을 코드로 구현하면 아래와 같다.

 

 

public class Kid {
    private Toy toy;

    public void setToy(Toy toy) {
        this.toy = toy;
    }

    public void play() {
        System.out.println(toy.toString());
    }
}
    
    
public class Robot extends Toy {

	public String toString() {
        return "Robot";
    }
}



public class Main{
    public static void main(String[] args) {
        Toy robot = new Robot();
        Kid k = new Kid();
        k.setToy(robot);
        k.play();
    }
}

 

Kid 클래스 내에선 구체적인 장난감 객체들에 의존하고 있지 않고 더 큰 개념의 장난감 이라는 클래스에 의존하고있다.

 

 

만약 로봇을 가지고 놀고 싶다면, Toy라는 추상 클래스를 상속 받아 위와 같이 Robot 객체를 구현할 수 있다.

 

 

 

 

그렇다면 로봇이 아닌 레고를 가지고 싶다면 다음과 같이 Toy 클래스를 상속 받는 Lego 클래스를 구현해주면 된다.

 

public class Lego extends Toy {
    public String toString() {
        return "Lego";
    }
}



public class Main{
    public static void main(String[] args) {
        Toy lego = new Lego();
        Kid k = new Kid();
        k.setToy(lego);
        k.play();
    }
}

 

 

새로운 장난감이 추가된다 해도 Kid 클래스의 코드에는 어떠한 변화도 일어나지 않고, 어떠한 영향도 미치지 않는다.

 

 

 

 

 

 

스프링에서는 이 DIP 원칙을 의존성 주입(DI, Dependency Injection)을 통해 구현할 수 있다. 

 

아래는 스프링에서 DIP를 적용하지 않아 문제가 되는 상황이다.

public class KakaoPay {
    public void processPayment(int amount) {
        // 카카오페이를 통한 결제 처리 로직
        System.out.println("KakaoPay로 " + amount + "원 결제 완료");
    }
}

public class PaymentService {
    private KakaoPay kakaoPay = new KakaoPay();  // KakaoPay에 직접 의존

    public void makePayment(int amount) {
        kakaoPay.processPayment(amount);
    }
}

 

 

 

만약 KakaoPay가 아닌 다른 결제 수단(예: PayPal 또는 CreditCard)으로 결제 방식을 바꾸고 싶다면, PaymentService의 코드를 수정해야 한다.

다음과 같이 새로운 결제 수단을 추가할 때마다 PaymentService 코드 자체가 영향을 받게 된다.

 

public class PayPal {
    public void processPayment(int amount) {
        System.out.println("PayPal로 " + amount + "원 결제 완료");
    }
}

// PaymentService 수정
public class PaymentService {
    private KakaoPay kakaoPay = new KakaoPay();
    private PayPal payPal = new PayPal();  // 새로운 결제 수단 추가

    public void makePayment(int amount, String method) {
        if (method.equals("KakaoPay")) {
            kakaoPay.processPayment(amount);
        } else if (method.equals("PayPal")) {
            payPal.processPayment(amount);
        }
    }
}

 

 

 

 

 

이런 문제를 DI를 통해 풀어낼 수 있다. 

 

먼저 Component 어노테이션을 붙여 Bean으로 등록해준다.

public interface PaymentMethod {
    void processPayment(int amount);
}

@Component
public class KakaoPay implements PaymentMethod {
    @Override
    public void processPayment(int amount) {
        System.out.println("KakaoPay로 " + amount + "원 결제 완료");
    }
}

@Component
public class PayPal implements PaymentMethod {
    @Override
    public void processPayment(int amount) {
        System.out.println("PayPal로 " + amount + "원 결제 완료");
    }
}

 

 

 

PaymentService 코드에선 의존성 주입을 통해 PaymentMethod 를 주입 받는다.

PaymentMethod를 주입받을 땐 @Qualifier 어노테이션을 사용하여 구체적으로 주입할 빈을 선택할 수 있다. 예를 들어 KakaoPay를 사용하고 싶다면 @Qualifier("kakaoPay")로 지정할 수 있다. 

@Service
@RequiredArgsConstructor
public class PaymentService {

	@Qualifier("kakaoPay") // 주입받을 구현체를 지정
    private final PaymentMethod paymentMethod;

    public void makePayment(int amount) {
        paymentMethod.processPayment(amount);
    }
}

 

 

 

참고로, @Component("kakaoPay")를 사용하지 않아도 특별히 이름을 지정하지 않으면 스프링이 자동으로 kakaoPay라는 이름의 빈으로 등록한다.