객체지향 5원칙 : SOLID

allocProc
11 min readMay 28, 2020

--

디자인 패턴에 대해 공부하던 중 예전 소프트웨어 공학 때 배운 객체지향 5원칙(SOLID)에 대해 다시 정리해보기로 했습니다.

원칙의 사전적인 의미를 먼저 살펴보면 다음과 같습니다.

원칙 (原則)

어떤 행동이나 이론 따위에서 일관되게 지켜야 하는 기본적인 규칙이나 법칙.

위의 사전적 의미가 말하듯 원칙이란 아주 기본적이고 상식적인 기준입니다.

아주 기본적이고 상식적이지만 오히려 그런점 때문인지 꾸준히 지켜가며 생활하기는 어렵기도 합니다.

좋은 소프트웨어란 무엇을 의미할까요?

보는 관점에 따라 굉장히 다양한 의미를 내포하고 있겠지만 우선 좋은 소프트웨어를 만들기 위한 첫 걸음은 원칙을 항상 생각하고 최대한 지키며 개발하기라고 생각합니다.

원칙을 지키려면 우선 원칙이 어떤 것이 있는지 왜 지켜야 하는지 이해해야겠습니다.

객체지향 5원칙(SOLID)이란?

  • SOLID란 로버트 마틴이 2000년대 초에 명명한 객체 지향 프로그래밍의 다섯 가지 기본 원칙을 마이클 페더스가 원칙의 앞 글자를 따서 다시 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

5가지 원칙의 핵심

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

THERE SHOULD NEVER BE MORE THAN ONE REASON FOR A CLASS TO CHANGE.

SOLID의 S에 해당하는 원칙으로 모든 클래스는 각각 하나의 기능만 가진다는 의미입니다. 다시 말하면 해당 클래스가 제공하는 모든 서비스는 단 하나의 책임을 수행하는 데 집중되어야 한다는 원칙입니다.

SRP 원칙을 적용하면 다른 클래스들이 서로 영향을 미치는 연쇄작용을 줄일 수 있습니다. 응집도(cohesion)는 높이고 결합도(coupling)은 낮출 수 있죠. 뿐만 아니라 책임을 적절하게 분배함으로써 코드의 가독성 향상, 유지보수 용이라는 이점까지 누릴 수 있으며 다른 원칙들을 적용하는 기초가 됩니다. 사뭇 단순한 원칙이라고 생각될 수 있으나, 막상 실무에 적용하려 하면 프로젝트의 복잡하고 빈번하게 변하는 성격때문에 적용하기가 쉽지 않습니다.

2. 개방폐쇄의 원칙 : OCP (Open Close Principle)

YOU SHOULD BE ABLE TO EXTEND A CLASSES BEHAVIOR, WITHOUT MODIFYING IT.

SOLID의 O에 해당하는 원칙으로 소프트웨어의 모든 구성요소(클래스, 모듈, 함수)는 확장에는 열려있고, 변경에는 닫혀있어야한다는 원칙입니다. 다시 말하면 요구사항의 변경이나 추가사항의 발생하더라도, 기존 구성요소는 수정이 일어나지 말아야하며 쉽게 확장이 가능하여 재사용할 수 있어야 한다는 뜻입니다. 로버트 마틴은 OCP는 관리가 용이하고 재사용 가능한 코드를 만드는 기반이며, OCP를 가능케 하는 중요한 메커니즘은 추상화(Abstraction)와 다형성(Polymorphism)이라고 설명합니다. OCP는 객체지향의 장점을 극대화하는 아주 중요한 원리입니다.

클래스를 설계할 때 변할 부분과 변하지 않을 부분을 명확히 구분해야 겠습니다.

변할 수 있는 부분은 추상화하여 상속하는 클래스가 의존할 수 있게 코드를 작성합니다.

적당한 추상화 레벨을 선택해야 합니다. 그래디 부치에 의하면 추상화란 ‘다른 모든 종류의 객체로부터 식별될 수 있는 객체의 본질적인 특징’ 이라고 정의합니다. 이 ‘본질적인 특징’을 명확히 정의할 수 있어야겠습니다.

Interface란 이런 변하지않을 본질적인 특징에 관한 약속입니다. Interface의 한 예시로 게임 캐릭터들의 스킬이 있습니다. 캐릭터가 스킬을 습득해서 스킬 버튼이 활성화 되었을 경우 스킬이 어떤 내용인지는 모르더라도 버튼을 누를경우 캐릭터가 어떤 행동을 할것이라는 사실은 자명합니다. 뭐 빙글뱅글 돌수도있고 점프를 할 수도 있겠죠. 스킬의 자세한 내용은 레벨업을 하거나 전직을 하거나 하면서 달라질 수 있지만 버튼을 누르면 스킬이 나간다는 사실은 변하지 않습니다.

위 예가 변하는 부분과 변하지 않는 부분이 잘 나뉘어있는 즉 interface의 좋은예라고 생각합니다.

코드로 살펴봅시다.

다음과 같은 코드로 게임을 진행중이었습니다.

class 캐릭터{
public fun 베기(){
print("🗡🗡🗡");
}
public fun 점프(){
print("🤸‍♂️");
}
}
//Game
fun playGame(어떤캐릭터 : 캐릭터){
어떤캐릭터.점프();
어떤캐릭터.베기();
}

위 코드에서 업데이트를 통해 캐릭터에 수정이 생겼습니다. 베기 스킬을 없애고 회전베기로 바꾼것입니다,.

class 캐릭터{
/* public fun 베기(){
print("🗡🗡🗡");
}
*/
public fun 회전베기(){
print("⚔⚔⚔");
}
public fun 점프(){
print("🤸‍♂️");
}
}

위 코드를 그대로 playGame에 적용한다면 캐릭터.베기()에서 컴파일 에러가 날 것입니다.

//Game
fun playGame(어떤캐릭터 : 전사){
어떤캐릭터.점프();
어떤캐릭터.베기(); //에러!!
}

이렇듯 스킬은 변하기 아주 쉬움에도 불구하고 그대로 상속해버리면 문제가 발생하기 쉽습니다.

스킬 두개가 각각 Q와 W버튼을 눌렀을 때 반응한다고 가정하면 Q버튼과 W버튼을 누르는 동작은 절대 변하지 않습니다. 이런 부분을 Interface로 만들어줍니다.

interface 캐릭터 {
fun QPressed()
fun WPressed()
}

그리고 interface를 상속하여 캐릭터를 구현합니다.

class 어떤캐릭터 : 캐릭터 {
override fun QPressed(){
회전베기();
}
override fun WPressed(){
점프();
}
private fun 회전베기(){
print("⚔⚔⚔");
}
private fun 점프(){
print("🤸‍♂️");
}
}

위와같이 interface를 상속하여 변하는 부분과 변하지 않을 부분을 정확히 나누어 변할 수 있는 부분은 private으로 선언하여 접근할 수 없게 만들었습니다.

또한 스킬에 변동 사항이 생기더라도 게임 진행에 전혀 문제가 없을것입니다.

이렇게 하면 확장에는 열려있되, 변경에는 닫히게 됩니다.

3. 리스코브 치환의 원칙 : LSP (the Liskov Substitution Principle)

FUNCTIONS THAT USE POINTERS OR REFERENCES TO BASE CLASSES MUST BE ABLE TO USE OBJECTS OF DERIVED CLASSES WITHOUT KNOWING IT.

SOLID의 L에 해당하는 원칙으로 부모 클래스를 카리키는 포인터에 해당 클래스를 상속하는 자식 클래스를 할당하더라도 모든 기능이 정상적으로 작동해야 하며 자식 클래스의 상세 내부를 부모 클래스는 알 필요가 없다는 뜻입니다. MIT 컴퓨터 공학 교수인 리스코브가 제안한 설계 원칙으로 서브 클래스가 확장에 대한 인터페이스를 준수해야 함을 의미합니다.

한마디로 부모 클래스를 상속한 자식 클래스는 부모 클래스의 역할을 정확히 해내야한다는 뜻입니다.

정말 당연한 말이지만 좀 처럼 지켜지지 않는 원칙입니다.

보통 부모 클래스의 메소드를 override하면서 문제가 발생합니다. 부모 클래스의 기존 메소드를 자식 클래스가 수정하면서 문제가 생기는 것이죠.

LSP를 지키는 가장 간단한 방법은 상속을 하되 override를 안하는 것입니다. 하지만 이게 무조건 적인 방법은 아닙니다.

상속을 할 때 override가 필요하다면 기존 부모 클래스의 메소드가 하던 역할을 충실히 수행하고 기능의 추가만 신중하게 수행하면 됩니다.

LSP는 결국 상속의 과정 중 메소드의 재정의가 필요하다면 현재 자식 클래스가 부모 클래스의 기존 메소드의 의미를 해치지는 않는지 신중히 고민하고 올바르게 상속하라는 의미라고 생각합니다.

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

CLIENTS SHOULD NOT BE FORCED TO DEPEND UPON INTERFACES THAT THEY DO NOT USE.

SOLID의 I에 해당하는 원칙으로 자신이 사용하지 않는 인터페이스는 구현하지 말아야 한다는 원칙입니다. 다시 말하면, 하나의 큰 인터페이스를 상속 받기 보다는 인터페이스를 구체적이고 작은 단위들로 분리시켜 꼭 필요한 인터페이스만 상속하자는 의미입니다. SRP가 클래스의 단일책임을 강조했다면 ISP는 인터페이스의 단일책임을 강조합니다.

인터페이스 하나의 크기가 크다는 것은 한번에 지켜야할 약속이 많아진다는 것을 의미합니다.

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

A. HIGH LEVEL MODULES SHOULD NOT DEPEND UPON LOW LEVEL MODULES. BOTH SHOULD DEPEND UPON ABSTRACTIONS.

B. ABSTRACTIONS SHOULD NOT DEPEND UPON DETAILS. DETAILS SHOULD DEPEND UPON ABSTRACTIONS

SOLID의 마지막인 D에 해당하는 원칙입니다. 위 원문을 그대로 번역하면 ‘상위 모듈은 하위 모듈에 의존해서는 안된다. 둘 다 추상화에 의존해야한다.’, ‘추상화는 구체적인 것에 의존해서는 안된다. 구체적인 것은 추상화에 의존해야한다.’ 입니다. 글만 읽었을 때는 무슨 뜻인지 쉽게 와닿지 않습니다.

클래스 사이에는 의존관계가 존재하기 마련입니다. 다만 의존 관계가 존재하되, 구체적인 클래스에 의존하지 말고 최대한 추상화한 클래스에 의존하라는 뜻입니다. 다시말하면 interface를 적극적으로 활용하라는 의미이기도 합니다.

간단한 예시를 들어보겠습니다.

사용자가 존재하고 사용자는 아이폰을 사용합니다.

class 아이폰 {fun 전화(){
print("📞📞📞")
}
fun 검색(){
print("🔎🔎🔎")
}
}
class 사용자 {val 내폰 = 아이폰()fun 전화(){
내폰.전화()
}
fun 검색(){
내폰.검색()
}
}

위 예시 코드에서 사용자는 아이폰 클래스에 의존하고 있습니다. 그리고 아이폰 클래스는 구체적인 클래스이기 때문에 변화에 취약합니다.

만약 사용자가 아이폰을 다른 스마트폰으로 바꾸고 싶어한다면 코드에 상당한 변화가 필요할 것입니다.

이 취약한 구조를 개선하기 위해 의존성을 역전시킬 필요가 있습니다. 현재 사용자가 의존하고 있는 아이폰 클래스를 덜 구체적인 추상화된 클래스로 만드는 것입니다.

스마트폰이라는 추상화된 interface를 각종 구체화된 클래스들이 상속하고 사용자는 스마트폰 interface에 의존합니다. '구체적인 것은 추상화에 의존해야한다.'는 말의 의미가 이제 조금 와닿습니다.

기존에 사용자 클래스(상위 계층 = 정책 결정)가 아이폰 클래스(하위 계층 = 세부 사항)에 의존하던 상황을 반전시켜서 구현으로부터 독립되었습니다. 이제 사용자와 아이폰 모두 추상화에 의존하는 상황으로 바뀐거죠. 마침내 '상위 모듈은 하위 모듈에 의존해서는 안된다. 둘 다 추상화에 의존해야한다.'는 말의 의미도 제대로 이해가 된 것 같습니다.

코드는 다음과 같이 바꿀 수 있겠죠.

interface 스마트폰 {
fun 전화()
fun 검색()
}
class 아이폰 : 스마트폰 {override fun 전화(){
print("📞📞📞")
}
override fun 검색(){
print("🔎🔎🔎")
}
}
class 사용자(내폰 : 스마트폰){fun 전화(){
내폰.전화()
}
fun 검색(){
내폰.검색()
}
}
//실제 사용
val 나 = 사용자(object : 아이폰())
나.전화()
나.검색()

위의 코드에는 의존성 주입(DI : Dependency Injection)이라는 개념도 사용되었는데 다른 포스팅에서 자세히 설명하겠지만 간단히 의존 관계가 있는 클래스를 외부에서 주입한다는 개념입니다.

이렇게 의존하는 클래스를 추상화하고 외부에서 주입함으로써 외부 변동에 유연하게 대처할 수 있는 코드로 개선되었습니다.

의존성 역전의 원칙을 적용하여 의존성 주입이라는 이점까지 취할 수 있게 되었죠.

--

--