객체 지향 설계의 5가지 원칙(SOLID)

RID·2024년 5월 16일
0

OOP

목록 보기
3/3

배경


객체 지향에 관심을 가지고 공부를 하다보면 SOLID라는 5가지 원칙을 알게된다. 수학이나 물리 등과 같은 과목을 공부할 때도 그렇지만 우리는 원칙, 법칙, 공식 등과 같은 단어가 등장하면 무의식 중에 이를 사용하거나 지키려고 집착하는 경향이 있다.

하지만 이 원칙이 왜 중요하고, 지키지 않았을 때 무엇이 문제가 되는지 이해하고 있지 않으면 결국 제대로 사용하지 못하는 것이나 다름없다(지금의 내가 그렇다...).

이번 글에서는 개인적으로 조금 더 객체지향을 제대로 이해하고, 더 객체 지향적인 코드를 구성할 수 있도록 5가지 원칙에 대해서 공부해보고자 한다. 각 원칙을 단순히 외우는 것이 아니라 왜 중요한지, 왜 지켜야하는지에 대한 관점으로 살펴보자.

왜 객체지향인가?


객체지향에 대한 단어를 많이 들었지만 결국 왜 객체지향적인 코드를 작성해야 하는지부터 생각해보자. 대학교 과제 같은 것을 하면서 느꼈겠지만, 실은 원하는 결과를 낼 수 있게 작성만 하면 점수를 받는다. 그렇다면, 이제 그 과제를 다시 열어서 대체 내가 무슨 코드를 짰었는지 한 번 살펴보자.

1년 전쯤 굉장히 많은 시간을 들여 작성했던 데이터 베이스 과제를 보고왔다. 뭔소린지 하나도 모르겠다... 결국 '코드가 돌아가게만 하자!' 마인드의 절차지향적인 코드는 아래와 같은 문제를 가진다.

1) 읽기 어렵다.
2) 코드를 수정하기 어렵다.(변경에 유연하지 못하다)

위와 같은 문제는 프로젝트가 커지면 커질 수록 더 크게 문제가 드러난다. 프로젝트의 기능은 점점 늘어날 테고, 변경에 유연하지 않은 구조는 프로그램의 확장과 성장을 어렵게 한다.


위와 같은 문제를 최소화 하기 위해서 등장한 것이 객체지향 프로그래밍이다. 객체지향이 추구하는 방향을 지키면서 코드를 작성하면 우리는 훨씬 더 읽기 쉽고, 변경에 용이한 코드를 만들어낼 수 있다.

결국 프로그래밍이라는 것은 문제 해결이라는 목표를 달성하기 위한 과정이고, 아래와 같은 핵심가치를 지키면서 객체지향적으로 코드를 작성할 수 있다.

각자의 역할을 가진 등장인물들이 서로 협력 관계를 이루며 문제를 해결하는 것
1) 협력에 참여하는 등장인물(객체)은 각자의 역할(책임)을 가지며, 그 역할에 대해 전문성을 가진다.
2) 목표는 하나의 객체의 지휘아래 달성되지 않으며, 등장인물의 협력으로 완성된다.
3) 각 객체는 자신이 못하는 것(전문성이 부족한 부분)을 다른 등장인물에게 요청해서 해결한다
-> 이때 그 등장인물이 어떻게(How) 역할을 수행할 지는 전적으로 믿고 위임한다.
4) 책임을 가지는 객체들은 자율성을 가지고 자신의 역할을 수행한다.

위의 핵심가치에는 몇 가지 중요한 키워드가 등장한다. 협력, 역할, 전문성, 요청, How 등등. 이러한 용어들의 핵심을 제대로 알고 있어야 더 좋은 객체지향 프로그램을 작성할 수 있고 이제부터 얘기할 SOLID는 이를 도와준다.

위의 핵심가치를 계속 생각하면서 SOLID 5가지 원칙이 어떻게 우리가 핵심에 다가갈 수 있게 하는지 살펴보자.

1. SRP: 단일 책임 원칙

Single Responsibility principle


첫 번째 원칙은 말 그대로 각 객체가 하나의 책임만을 가져야 한다는 것이다.

표현이 굉장히 모호하다. 그 이유는 책임 이라는 단어 자체가 가지는 모호성 때문이다.
그러니 우리는 수시로 생각해야 한다. 내가 작성한 코드의 책임이 너무 많지는 않은가? 만약 특정 객체가 가지는 책임을 다른 등장인물을 도입하여 나눈다면 훨씬 더 좋은 코드가 되지 않을까?

이제 여기서 우리는 좋은 코드가 무엇인지 다시 한번 생각할 필요가 있다. 바로 변경에 유연한 구조인가를 살펴봐야 한다. 내가 작성한 코드에 변경이 필요할 때 지나치게 많은 변경 사항이 있다면 이는 객체가 너무 많은 책임을 가지고 있을 가능성이 많다.

이럴 때는 객체의 역할을 나눠 각자의 전문성으로 동일한 목표를 달성할 수 있도록 해보자.

2. OCP: 개방-폐쇄 원칙

Open-Closed principle


확장에는 열려있고(Open), 변경에는 닫혀(Closed) 있어야 한다 라는 뜻의 이 원칙은 말장난 같아 보인다. 공존할 수 없는 두 형용사가 붙어있기 때문이다.

하지만 이 원칙이 중시하는 가치는 매우 중요하다. 위에서 말했듯이 우리의 프로그램은 시간이 지날 수록 더 복잡해지고, 기능이 추가되며 확장된다. 이러한 확장 과정에서 기존 코드를 매번 변경해야 한다면 이는 객체지향이 등장해야만 했던 이유가 아무 의미가 없는 것이다.

그렇다면 어떻게 이 Open과 Closed를 동시에 달성할 수 있을까? 이는 바로 캡슐화에 있다. 저번 글에서 여러 번 얘기했지만 '구체적인 것을 숨기고 Client가 이를 모르게 하는 것'이 캡슐화이다.

다르게 말하면 협력 관계를 맺고 있는 객체들이 서로의 역할(What) 에 대해서만 알고 있고, 그 역할을 각 객체가 어떻게(How) 수행하는지 알지 못하게 하면 된다.(몰라야 한다!)

자동차로 예시를 들어보자. 우리는 브레이크를 밟으면 차가 서서히 멈춘다 라는 브레이크의 역할(what)에 대해서 명확히 알고 있다. 하지만 브레이크가 어떻게 차를 멈추게 하는지 즉, How에 대해서는 알 필요도 없다. 만약 알고 있다면 차종마다 다른 브레이크의 원리를 알아야지만 차를 바꿀 수 있는 것이다.

위와 같은 과정은 interface를 이용한 다형성을 활용해 캡슐화를 달성함으로서 해결할 수 있다.
(사실 interface를 활용한 다형성을 제대로 구현하기 위해서는 DI가 반드시 필요한데, 이는 아래 글에서 다시 확인해보자. DI가 중요한 이유 역시 이 OCP를 위배하지 않기 위해서 필요함을 이해해보자.)

DI와 IoC에 대해서 - 1

3. LSP: 리스코프 치환 원칙

Liskov substitution principle


이 세 번째 원칙은 자식 객체가 부모 객체를 완벽하게 대체할 수 있어야 한다는 것이다.

조금 더 풀어써보면 위에서 언급한 객체지향의 핵심가치 중 믿고 위임 이라는 키워드로 접근할 수 있을 것 같다. 우리가 다형성을 구현하는 이유는 구체적인 것을 숨기고 추상적인 것에만 의존해 변경에 유연한 구조를 만들기 위함이다. 그런데 만약 특정 객체가 역할에 대해 마구잡이로 구현을 해놓으면 어떻게 될까?

만약 특정 자동차의 브레이크를 밟았더니 앞으로 가버리는 상황이 펼쳐지면 어떻게 될까. 운전자는 브레이크라는 객체의 역할을 알고있었고, 브레이크 객체의 전문성을 전적으로 믿고 위임했는데 예상하지 못하는 결과가 나타난 것이다.

이는 단순히 컴파일 적인 오류를 떠나서 우리가 해선 안되는 행동에 대해 알려주는 것이다. 다형성을 통해 구현한 여러 객체는 반드시 interface의 역할에 대한 정확성을 건드려서는 안된다.

4. ISP: 인터페이스 분리 원칙

Interface segregation principle


이 원칙은 클라이언트가 자신이 이용하지 않는 메소드에 의존하지 않아야 한다는 원칙이다. 이 말도 맞지만 사실 나는 조금 와닿지가 않아서 조금 센 표현을 써서 이렇게 이해를 하려고 한다.

사용하지 않아도 되는 메소드에 대해서는 존재 자체도 알 필요가 없다는 것이다.

다시 자동차 예시를 들어보자. 자동차를 구매할 때 우리는 여러 옵션을 사용한다. 그런데 우리가 사용하지도 않을 옵션을 내 자동차에 끼워 넣으면 비용(객체 지향에서의 비용은 의존성이다!)이 발생한다. 특정 클라이언트 객체가 몰라도 되는 객체는 모르게 하는 것이 중요하다는 것이다. (작성하고 보니 그리 좋은 예시는 아닌 것 같지만..)

이러한 원칙이 중요한 이유는 직관적으로 생각할 수 있다. 사용하지도 않을 객체나 함수에 대해 알고 있으면, 해당 객체가 변경될 때 반드시 변경이 전파된다. 늘 그렇듯이 객체간의 의존성은 사실 '알고 있다는 사실' 하나만으로 충분하기 때문이다. 그렇기 때문에 쓸데 없는 변경의 전파를 막으려면 사용하지 않는 것에 대해 모르고 있는게 좋다.

그래서 Interface segregation principle, 즉 인터페이스를 분리해서 Client가 반드시 알아야만 하는 것들만 알려주자는 것이다.

5. DIP: 의존관계 역전 원칙

Dependency inversion principle


이 원칙은 사실 OOP관련 모든 글에서 한 번씩은 언급했던 캡슐화에 대한 내용이다. Client로 하여금 구체적인 것을 숨겨 이를 모르게 하는 것 이다.

이번에는 조금 더 코드적인 관점에서 얘기해보면 구현체에 의존하지 말고 interface에 의존하라는 의미이다.

즉 특정 intercface의 역할(책임)에 대해서만 알고, LSP를 만족하게 구현이 되었다면, 우리는 interface를 상속받은 모든 객체들을 믿고 사용할 수 있다. 그리고 interface를 상속받은 다른 새로운 객체가 등장하더라도 client의 변경 없이 확장할 수 있다.

DIP는 사실 의존성 주입 없이 달성될 수 없다. 결국에는 런타임 내에 Client 객체를 사용하기 위해서는 interface가 아닌 구체적인 객체와 협력관계를 맺어야 하기 때문이다. 그렇기 때문에 언젠가는 의존성이 주입이 되어야하고, 이를 IoC를 통해 달성하기도 한다.

DI와 IoC에 대해서 - 1
DI와 IoC에 대해서 - 2

마무리


객체 지향을 처음 접하고 공부하면서 되게 많이 어려웠었다. 다형성, 추상화, 캡슐화 등과 같은 개념이 정말 직관적이지 못했고, 프로그램이라는게 어떻게든 작성해서 돌아간다면 그게 좋지 않은 형태인지 스스로 판단하기가 되게 어려웠기 때문이다.

그러던 찰나에 객체지향 강의를 듣게 되었고, DI/IoC에 대해서 개인적으로 공부하다보니 어느정도 이해가 되기 시작했다.

왜 내가 어려워할 수 밖에 없었는지도 이해할 수 있었고, 이 모든 객체지향에 대한 배경, 다형성만으로 이룰 수 없는 OCP와 DIP, 이를 위해 등장한 Spring의 배경까지도 흐름을 읽을 수 있게 되었다.

늘 그렇듯 지식 이전에는 항상 문제가 있고, 해결하고자 했던 과정이 있다. 우리는 때로 그 과정을 생략하고 지식만 습득하는 경향이 강하다. 한때 열심히 공부했던 기억을 떠올려 보면 내가 빨리 성장할 수 있었던 때는 항상 이런 배경에 관심을 가졌던 것 같다. 이렇게 객체 지향에 대해 이해하는 시간이 지나니 나는 자연스럽게 Spring의 역할이 궁금해졌다.

이전에도 이렇게 언어와 프레임워크 공부를 했으면 되게 재미있었을 텐데 하는 아쉬움이 뒤늦게 남는다. 앞으로는 Spring 프레임워크에 대해 조금 열심히 파보자.

0개의 댓글