숫자야구게임을 만들 때 들었던 궁금즘과 고민은 “어떻게 객체를 나누고 쪼개는가?”였다. 무엇을 기준으로 객체를 나눠야 하는지, 얼마나 작게 나눠야 하는지 등의 궁금증이 생겨서 찾아보던 중에, OOP으로 설계를 할 때 따르면 좋은 SOLID 원칙이 있다는 것을 발견하고 정리해봤다.

내 결론

객체지향이 나온 이유는 C언어처럼 절차지향적으로 짜던 코드의 가독성과 유지보수성을 높이기 위해서이다. 위에서 아래로 작성된 방대한 양의 코드를 이해하기 쉽도록 비슷한 목적의 코드를 객체로 묶어서 작성하는 것이다. 즉 사람이 이해하기 쉽게, 더 유지보수하기 쉬운 코드를 작성하기 위해 나온 프로그래밍 방법론 중 하나가 OOP다.

OOP의 목적이 좋은 유지보수성에 있고, 더 좋은 객체 지향 프로그래밍을 위해 고민한 결과로 나온 설계 원칙이 SOLID 원칙이므로, 당연하게도 이 원칙의 목적은 좋은 유지보수성이다.

SOLID 원칙은 단지 원칙일 뿐이고, 이걸 안다고 해서 좋은 코드를 작성할 수 있는 것이 아니다. 물론 알고 있다면 주어진 상황에 어떻게 SOLID 원칙을 적용해서 코드의 질을 높일 수 있을지 고민하기는 더 쉬워지긴 할 것 같다. 객체를 나누는 기준은 추상적이고 명확하게 정해진 답이 없어서, 주어진 상황에 맞게 적절하게 객체를 쪼개는 것은 온전히 개발자와 팀의 선택인 것 같다.

OOP가 나온 본질은 좋은 유지보수성이므로, 객체지향 프로그래밍을 하는 개발자에게는 어떻게 하면 유지보수성이 좋은 코드를 작성할 수 있을지, 더 확장성이 좋은 코드를 작성할 수 있을지 고민하는 자세가 필요하다.

지금은 더 좋은 구조에 대해서 고민해본 경험이 많이 없어서 SOLID 원칙에 대해 읽어도 이해하는데 한계가 있는 것 같다. 나중에 경험이 쌓인 후에 다시 공부하자.



SOLID OOP 설계 5대원칙

SOLID 원칙은 유지보수가 쉽고, 확장이 쉬운 소프트웨어를 만들기 위해 사용하는 객체지향 디자인 원칙이다. 이 원칙을 따라 소프트웨어를 설계하면 변경사항이나 새로운 요구사항에 유연하게 대응할 수 있다.



1. 단일 책임 원칙 SRP(Single Responsibility Principle)

이름에서 유추할 수 있듯이 하나의 클래스는 하나의 책임을 갖는다는 원칙이다. 책임이란 단어가 약간 모호할 수 있는데, 쉽게 생각하면 한 클래스가 하나의 기능만 하도록, 또는 하나의 목표만 갖도록 설계하는 원칙이라고 생각할 수 있는 것 같다.

다르게 표현하면 클래스의 변화를 야기하는 이유는 하나뿐이어야한다고 말할 수도 있다. 하나의 기능/책임/목표를 갖는 클래스가 있으면 그 기능이 바뀌지 않는한 클래스도 바뀌는 일은 거의 없다.

SRP의 장점은 각각의 클래스의 책임이 명확하게 나뉘기 때문에 시스템에 변경사항이 있을 때, 하나의 변경 사항이 다른 변경사항을 유발하는 연쇄작용에서 자유로울 수 있다는 점이다. 결론적으로 소프트웨어의 유지보수성이 좋아진다.



2. 개방 폐쇄 원칙 OCP(Open-Closed Principle)

클래스는 확장에는 열려있고, 변경에는 닫혀있어야 한다는 원칙이다. 즉 기존 코드는 변경하지 않고 확장을 통한 기능을 추가할 수 있도록 해야한다는 의미다.

OCP는 OOP의 추상화와 다형성과 밀접하게 연관되는 원칙이다. OOP에서는 상속이나 인터페이스의 구현을 통해서 새로운 변화에 대응하고 코드 재사용성을 높이고 다형성을 얻을 수 있다. 이와 비슷하게 OCP는 객체들을 추상화한 interface/abstract class를 사용하여 이미 생성된 구현체는 변경하지 않고 변화가 있을 때 확장을 통해 새로운 구현체를 만들어 유연하게 기능을 추가할 수 있게 한다.

이때 interface는 가능하면 변경하지 않아야 한다. OCP를 따르면 client는 interface의 규약을 사용해서 구현체와 소통하는데, interface가 변경되면 해당 interface를 사용하는 client의 모든 코드에 변경사항이 불가피한 문제가 생기게 된다.


image

매번 새로운 게임이 나올 때마다 새로운 게임 객체를 만들어서 User가 사용하도록 하는 것이 아니라, 게임들의 공통 특징을 같는 interface를 통해 User가 게임을 할 수 있도록 해서 OCP를 따를 수 있다.



3. 리스코프 치환 원칙 LSP(Liskov Substitution Principle)

부모 클래스를 사용하는 기능(client)은 언제나 자식 클래스를 사용할 수 있어야 한다는 원칙이다. 즉 자식 클래스의 행위와 부모 클래스의 행위가 일관성이 있어야 한다. 서브 타입은 언제나 기반 타입으로 교체할 수 있어야(호환될 수 있어야) 한다고 말하기도 한다.

위의 내용이 너무 모호하게 느껴지면, method가 부모 클래스나 interface에서 의도한대로 동작해야한다고 생각하면 편할 수도 있다.

예를들어, OOP에서 다형성을 얻는 방법 중 하나가 method overriding이다. 만약 부모의 method(행위)를 overriding해서 부모 클래스에서의 의도와 다르게 변경한다면 LSP에 위반된다. 이런 경우 자식 클래스를 사용하면 프로그램의 동작이 변경되기 때문이다.

다르게 표현하면 overriding으로 다형성과 확장성을 얻을 수 있지만, overriding시에 주의할 필요가 있다는 거다. Overriding을 할 때는 interface의 규약을 잘 따르고 부모 클래스에서의 행동과 일관성 있어야 하고 기존 코드에서 보장하던 조건/원칙을 위반하면 안된다. 그래야만 client에서 사용할 때 의도한대로 동작할 수 있음을 보장할 수 있다.

Method를 overriding하지 않는 것이 LSP를 지키는 가장 쉬운 방법이지만, interface나 abstract class를 구현/상속하는 경우에는 interface와 abstract class에서 설정한 의도대로 동작하도록 overriding해야 한다.

Java에서 LSP를 잘 적용한 예시는 Collection이다.

image (https://www.javatpoint.com/collections-in-java)


Collection을 구현하는 구현체들이 method overriding을 할 때 LSP를 준수했기 때문에, Collection type의 변수에 ArrayList 자료형을 담거나 HashSet을 담아도 동일한 add()함수를 사용하여 동일한 행동을 할 수 있다.

public static void main(String[] args) {
    Collection<Integer> collection1 = new ArrayList<>();
    Collection<Integer> collection2 = new HashSet<>();
    Collection<Integer> collection3 = new LinkedList<>();

    addElement(collection1, 10);
    addElement(collection2, 20);
    addElement(collection3, 30);

    System.out.println(collection1); // [10]
    System.out.println(collection2); // [20]
    System.out.println(collection3); // [30]

}
public static void addElement(Collection collection, int element) {
    collection.add(element);
}



4. 인터페이스 분리 원칙 ISP(Interface Segregation Principle)

Class 하나가 하나의 interface를 구현하는 것보다, 여러 개의 구체적인 interface를 구현하는 것이 더 낫다. 하나의 class를 여러 client가 이용하고 각각의 client는 해당 class의 특정 부분만을 사용한다면, 특정 부분에 해당하는 method를 별개의 interface로 구분해야한다. SRP와 비슷하게 ISP는 Interface의 단일책임을 강조하는 원칙이다.

범용적인 interface하나를 생성하고 이 interface를 구현하는 구현체들을 만들게 되면, 구현체들은 실제로 사용하지도 않을 method를 구현해야하고, interface의 규약이 바뀌는 경우 구현하는 클래스에서도 수정해야한다. 이런 문제를 해결하기 위해 interface를 client의 목적에 적합하게 분리하는 것이다.

스마트폰을 예시로 들어보면 통화, 메세지, 사진, 충전 등이 공통적인 기능이다. 하지만 최신 스마트폰의 경우 무선충전, 생체인식 등 이전 기종에는 없는 기능이 있기도하고, 브랜드마다 독립적으로 보유하고 있는 Apple의 Air Drop이나 갤럭시의 Quick share같은 기능들도 있다.

만약 최신 스마트폰에 있는 기능을 하나로 모아 범용적인 interface를 만들면 아래 그림과 같다.

image


이 경우 실제 구현체에서 사용하지 않는 method들도 구현해야하고, 하나의 범용 interface를 구현하는 것으로 ISP를 지키지 않는 방식이다.

ISP를 지켜서 interface하나가 단일 책임을 갖도록 수정하면 아래와 같을 수 있다.

image


이처럼 interface는 다중 상속이 가능하므로, 하나의 단일책임으로 분리할 수 있으면 분리하여 구현하여 사용하는 것이 좋다.



5. 의존성 역전 원칙 DIP(Dependency Inversion Principle)

객체간 의존 관계(e.g. 한 class안에서 다른 class의 기능을 사용)에서 구체적인 구현체에 의존하는 것이 아니라 추상적인 것에 의존해야 한다. 즉 class의 구현체를 직접 참조하는 것이 아니라, 그 구현체의 추상적인 상위요소(abstract class 또는 interface)를 참조하라는 원칙이다.

DIP를 따른다고 해서 객체간의 실제 사용 관계를 바뀌지 않지만, 구체적인 의존관계를 추상을 매개로 하는 의존관계로 바꾸어 두 객체가 최대한 coupling되지 않도록 하는 원칙이다.

구현체는 변화하기 쉽고, interface는 거의 변하지 않는다는 점에서 객체 사이의 관계에서는 변하지 않는 것에 의존하라는 의미가 될 수도 있다. 쉽게 변하지 않는 추상적인 interface로 의존관계를 설정하는 것을 통해 변화에 유연한 프로그램을 설계할 수 있게된다.

위의 핸드폰 예제에서 사용자가 핸드폰의 기능을 사용하기 위해 User class의 생성자에 Phone객체를 주입한다고 해보자.

// User.java
public class User {

    GalaxyS23 phone;
    public User(GalaxyS23 galaxyS23) {
      phone = galaxyS23;
    }
    
    public void makePhoneCall() {
        phone.call();
    }
}

// GalaxyS23.java
public class GalaxyS23 implements SmartPhone{
  ...
}

// IPhone13.java
public class IPhone14 implements SmartPhone {
  ...
}

위의 코드에선 DI로 GalaxyS23 타입의 객체를 넣어주었다. 이렇게 되면 구체적인 객체와 객체 사이의 의존관계를 갖게 되어 유연한 대응이 어렵게 된다. User가 IPhone14로 핸드폰을 바꾸고 싶으면 User의 코드를 수정해야 한다. 또 다른 핸드폰으로 바꾸고 싶으면 또 다시 User의 코드를 수정해야 한다. 변화에 대한 대응을 잘 하지 못하는 코드인 것이다.

이렇게 객체 간의 의존관계를 실제 구현체 사이의 의존관계로 설정하는 대신에, 추상적인 interface를 사용하면 객체간의 coupling이 적어진다.

// User.java
public class User {

    SmartPhone phone;
  
    public User(SmartPhone smartPhone) {
      phone = smartPhone;
    }
    
    public void makePhoneCall() {
        phone.call();
    }
}

// GalaxyS23.java
public class GalaxyS23 implements SmartPhone{
  ...
}

// IPhone13.java
public class IPhone14 implements SmartPhone {
  ...
}

위 코드에선 생성자로 SmartPhone interface 타입의 객체를 받아온다. 이렇게 함으로써 User객체와 스마트폰 객체들 사이의 coupling이 적어지고 다른 핸드폰을 사용하고 싶은 경우에도 User의 코드를 수정하지 않아도 된다.

image



Reference

널널한 개발자 - 객체지향 프로그래밍과 디자인 패턴

객체지향 개발 5대 원리: SOLID

객체 지향 설계의 5가지 원칙 - S.O.L.I.D

[Java] OOP(객체지향 프로그래밍) 설계 원칙

완벽하게 이해하는 ISP (인터페이스 분리 원칙)

Categories:

Updated: