[컴퓨터 이야기] 객체 지향 프로그래밍 - SOLID 원칙이란?



 객체 지향 설계를 배우다보면 한 번쯤은 들어본 적이 있을겁니다. 사실 뭔가 어려운 내용도 아니고 객체 지향 설계에서 굉장히 중요한 내용을 담고 있는 개념이긴한데, 막상 설명하려고 하면 디테일하게 설명하기 어렵더군요. 머릿속에서 어중간하게 떠돌아다니는 내용을 좀 더 구체적으로 정리하기 위해 글을 남겨봅니다. 한 번 볼까요?



SOLID


SOLID 란?

SOLID는 어떤 원칙들의 앞글자만 따서 만들어진 이름입니다. 처음 볼 때 굉장히 생소할 수 있는 이름들인데요. 아래의 5가지입니다.

  1. 단일 책임 원칙 (Single Responsibility Principle, SRP)
  2. 개방 - 폐쇄 원칙 (Open-Closed Principle, OCP)
  3. 리스코프 치환 원칙 (Liskov Substitution Principle, LSP)
  4. 인터페이스 분리 원칙 (Interface Segregation Principle, ISP)
  5. 의존관계 역전 원칙 (Dependency Inversion Principle, DIP) 

얘네들이 무엇이고, 어떤 개념을 담고 있는지, 그리고 왜 중요한지에 대해서 하나씩 살펴보도록 하겠습니다.


단일 책임 원칙 (Single Responsibility Principle, SRP) - 한 클래스는 하나의 책임만 가져야 한다.

다행히 내용이 크게 어렵지 않군요. 클래스에 책임을 하나만 부여하라고 합니다. 조금 자세하게 말하면 클래스에 너무 많은 책임을 부여하지 말라는 의미인데, 만약 우리가 '개발자'라는 클래스를 설계한다고 생각해봅시다. 그럼 클래스를 어떻게 구성하게 될까요? 아래와 같은 클래스로 만들었다고 생각해봅시다.


class Developer {
    fun webDevelop() { ... }
    fun gameDevelop() { ... }
    fun androidDevelop() { ... }
    fun documentation() { ... }
}

개발자의 종류에는 여러가지가 존재할 수 있죠. 웹 개발자나, 게임 개발자나, 안드로이드 개발자나... 그런데 하나의 개발자 클래스가 많은 일을 하고 있다고 생각이 듭니다. 차라리 WebDeveloper, GameDeveloper와 같은 방법으로 역할을 좀 나누면 좋아보이네요. 물론 한명의 개발자가 저 일을 다 할 수 있습니다. (저도 가능해지면 좋겠네요..!) 어떻게보면 Developer의 일이기는 하니까요. 완전 어색한 것은 아닙니다. 그렇다면 적어도 문서 작업 정도는 다른 클래스로 분류하면 좋지 않을까 생각이 듭니다. 개발자의 일이 문서 작업을 하는 것은 아니니까요.

이렇게 책임을 적당한 기준으로 최소화시키는 것이 단일 책임 원칙 (SRP) 입니다. 단일 책임 원칙은 위에서 느낄 수 있듯이 꽤나 추상적인 느낌입니다. 말했다시피 한명의 개발자가 웹, 게임, 안드로이드를 모두 개발할 수 있고, 저런 것들을 하는 것이 개발자이므로 3가지 메서드를 가진다고 SRP를 어겼다고 하기에는 조오오오금 애매하네요. 그래도 문서 작업은 확실하게 다른 클래스로 책임을 넘길 수 있겠죠? 이제 개발할 때 클래스의 일을 조금 줄일 수 있게 노력해봅시다.



개방 - 폐쇄 원칙 (Open-Closed Principle, OCP) - 소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다.

뭔가 쓱 읽었을 때는 느낌이 확 안 올 수도 있는 내용입니다. 느낌이 바로 오시면 천성 개발자이신걸로.. 확장에 열려있다는 것이 뭐고, 변경에 닫혀 있다는 것이 뭘까요? 우주선 클래스를 만든다고 가정해 봅시다. 이 우주선은 미사일을 쏠 수 있고, 부스터를 써서 일시적으로 빠르게 비행할 수 있고, 비상 탈출을 하면 낙하산이 펴집니다. 



open class SpaceShip {
    open fun shot() { ... }
    open fun boost() { ... }
    open fun emergencyEscape() { ... }
}

대충 이런 코드입니다. 이제 이 기본 형태의 우주선을 이용해서 좀 더 업그레이드된 여러 형태의 우주선을 만든다고 생각해 봅시다. 가령 레이저를 쏘는 기능이 추가된 T100이라는 우주선을 만든다고 생각해 봅시다.



class T100 : SpaceShip() {
    override fun shot() { super.shot() }
    override fun boost() { super.boost() }
    override fun emergencyEscape() { ... }
    fun laser() { ... }
}

이 코드는 SpaceShip의 기존 코드를 수정하나요? 아닙니다. 그냥 불러다 쓸 뿐, SpaceShip의 기본 코드는 유지합니다. 하지만 기존의 코드는 유지한 채, 또는 기존의 코드를 가지고 새로운 기능을 만들 수 있게 됩니다. 물론 상속 자체는 결합도 자체를 높일 수 있기 때문에 추상화나 인터페이스를 사용해서 구현하는 것이 결합도를 낮출 수 있습니다만, 대충 이런 느낌이라는 이야기를 하고 싶었습니다. 기존의 코드는 수정하지 않고, 기존의 코드를 가지고 새로운 무언가를 만들 수 있게 하는 것. 개방 - 폐쇄 원칙이었습니다.



리스코프 치환 원칙 (Liskov Substitution Principle, LSP) - 프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다.

말이 점점 더 어려워지는 것 같네요. 이번엔 무슨 이야기일까요? 말만 어렵지 생각보다 어렵지 않습니다. 위의 2개의 우주선 클래스에 더해 아래의 자동차 클래스가 하나 더 있다고 가정합시다. 그럼 그 밑의 main 함수의 4줄의 코드 중 제대로 실행이 되는 코드는 무엇일까요?



class Car {
    fun run() { ... }
}



fun main() {
    val t1001: SpaceShip = T100() // 1번
    val t1002: T100 = SpaceShip() // 2번
    val car1: Car = SpaceShip() // 3번
    val car2: Car = T100() // 4번
}


1번 밖에 없습니다. 일단 3번과 4번은 애당초 Car <-> SpaceShip 간에 상속 관계든 뭐든, 즉 어떠한 관계도 가지고 있지 않기 때문에 아예 의미가 없습니다. 그렇기 때문에 저렇게 인스턴스를 생성하는 것 자체가 말이 되지 않습니다.

2번의 경우 T100 타입을 SpaceShip 타입으로 변환하려고 하는데, T100은 SpaceShip의 모든 기능을 가지고 있긴 하지만, SpaceShip은 T100의 모든 기능을 가지고 있나요? 물론 아닙니다. 레이저를 쏘는 기능이 빠졌죠. 따라서 T100의 타입을 선언하고 SpaceShip 타입으로 인스턴스를 생성할 수가 없고, 레이저를 쏘는 기능이 추가된 T100 타입 그대로 인스턴스를 생성하는 것이 올바릅니다.

1번의 경우 기존의 SpaceShip 타입을 이용해서 T100 타입의 객체를 만들고 있죠. '이 객체는 SpaceShip 타입인데, SpaceShip 중에서도 레이저를 쏘는 기능이 추가된 T100 타입으로 생성할 것이다.' 라는 의미가 되겠죠. 이렇게 하위 타입의 인스턴스로 변환하는 것이 LSP입니다.





인터페이스 분리 원칙 (Interface Segregation Principle, ISP) - 특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다.

위의 단일 책임 원칙의 Developer 클래스를 다시 봅시다. Developer 클래스에 뭔가 기능이 많다고 이야기했는데, 이걸 완전히 쪼개버린다면 어떨까요? 즉, 아래와 같은 코드로 분리를 하게 만드는 것입니다.



interface WebDeveloper {
    fun webDevelop()
}

interface GameDeveloper {
    fun gameDevelop()
}

interface AndroidDeveloper {
    fun androidDevelop()
}

interface Writer {
    fun documentation()
}

class Developer : WebDeveloper, GameDeveloper, AndroidDeveloper, Writer {

    fun webDevelop() { ... }

    fun gameDevelop() { ... }

    fun androidDevelop() { ... }

    fun documentation() { ... }
}

Developer가 각 인터페이스를 상속받아서 각 역할에 맞는 메서드만 override 해서 구현하면 됩니다. 이렇게하면 하나의 Developer는 웹도 개발할 수 있고, 게임도 개발할 수 있고, 안드로이드도 개발할 수 있고, 문서 작업을 할 수 있고, 그 중에 뭐가 하나 빠질 수도 있고 내 입맛대로 세팅할 수 있습니다. 심지어 각각의 역할을 모두 인터페이스로 쪼개놨기 때문에 각 인터페이스는 쓸 데 없는 메서드를 가지고 있지 않습니다. 이렇게 하면 코드 수정이 일어날 때 수정하기 매우 편하겠죠? 이렇게 자신이 이용하지 않는 메서드에 의존하지 않게끔 인터페이스를 여러개로, 그리고 구체적으로 쪼개는 것이 인터페이스 분리 원칙입니다.



의존관계 역전 원칙 (Dependency Inversion Principle, DIP) - 프로그래머는 "추상화에 의존해야지, 구체화에 의존하면 안된다."

마지막까지 뭔가 추상적인 설명이네요. 조금 풀어서 말하자면, 상위 모듈이 하위 모듈의 구현된 내용 자체를 이용해 구현하는 것이 아니라 추상화된 개념에 의존해야 한다는 이야기입니다. 즉, 객체를 직접 생성해서 쓰지 말고 매개 변수로 넘기던가 하는 식으로 의존성을 낮추라는 의미입니다. 이 DIP에서 의존성 주입이라는 개념이 등장하게 됩니다. 그런데 왜 이렇게 구현해야 할까요? 



class Worrier {
    val sword = Sword()
    
    fun attack() {
        sword.attack()
    }
}

class Sword {
    fun attack() { ... }
}

Worrier클래스는 Sword 클래스를 직접 가져다가 객체를 생성해서 사용하고 있습니다. 보기에는 큰 문제가 없어 보입니다. 전사가 공격할 때도 검의 공격 메서드를 가져다 쓰니까.. 뭐 어떻게든 공격을 하겠구나. 하고 생각합니다. 

겉보기에는 그런데, Sword 클래스에 어떠한 변경 사항이 발생하게 될 경우 문제가 발생합니다. 위의 코드는 그냥 Sword 객체를 생성해서 사용하고 있긴 하지만, 간단하게 Sword 클래스에 생성자가 추가된다면 어떻게 될까요? Worrier 클래스에서 생성하고 있는 Sword 클래스에도 생성자를 추가해야 됩니다. 이에 따라 또 추가되어야 할 데이터가 생길 수 있고, 연쇄적으로 수정이 발생할 수 있습니다.

심지어 그냥 Sword가 아닌 LongSword 클래스가 새로 만들어져서 그걸 가져다 써야하는 일이 발생할 수도 있습니다. 이 경우에는 LongSword 인스턴스를 만들고 attack 메서드도 LongSword.attack()으로 불러야할 것입니다. 이렇게 직접 인스턴스를 가져다 사용하게 되면 수정이 발생할 때 연쇄적으로 수정이 일어날 수 있습니다. 그럼 어떻게 하면 좋을까요?



class Worrier {
    fun attack(weapon: Weapon) {
        weapon.attack()
    }
}

interface Weapon {
    fun attack()
}

class Sword : Weapon {
    override fun attack() { ... }
}

class LongSword : Weapon {
    override fun attack() { ... }
}

fun main() {
    val worrier = Worrier()
    worrier.attack(Sword())
    worrier.attack(LongSword())
}

무기는 Weapon 인터페이스를 상속받아서 구현하고, attack 메서드에서 Weapon 타입을 가져와서 attack 메서드를 실행해 줍니다. 이렇게 하면 Weapon을 상속 받는 모든 무기에 대해 attack 메서드를 실행할 수도 있고, Sword, LongSword 클래스에 변경이 생겨도 Worrier는 따로 수정할 필요가 없어집니다. 이렇게 추상화된 내용을 기준으로 상위 모듈이 하위 모듈의 내용에 의존하지 않게끔 구현하는 것이 DIP입니다. Hilt, Dagger2, Koin과 같은 라이브러리들이 위와 같이 매개 변수 형태로 의존성을 넘겨주는 것, 즉 의존성 주입을 위해 만들어진 라이브러리입니다. 






마치며..

사실 저렇게 글로 배우지 않고 객체 지향 프로그래밍을 하다보면 몸으로 느끼는 내용이기는 합니다. 원칙 자체를 들어본 적은 없지만, 뭔가 '아, 그렇지. 이렇게 구현했었지.' 라는 느낌이 들었을 수도 있을 것입니다. 우리는 이미 SOLID 원칙에 맞춰 개발을 하고 있었다는 것입니다. 그 내용을 텍스트로 풀어냈을 뿐입니다.

뭔가 글로 쓰려니까 제대로 설명한 것이 맞는지 모르겠네요. 최대한 열심히 써봤는데, 이상한 것이 있으면 바로 이야기해주세요. 빠르게 수정하겠습니다. 읽어주셔서 감사합니다!


댓글