당니의 개발자 스토리

좋은 객체 지향 설계의 5가지 원칙(SOLID) 본문

스프링/스프링 핵심 원리 - 기본편

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

clainy 2024. 1. 16. 11:47

이번 시간에는 좋은 객체지향 설계의 5가지 원칙, SOLID에 대해서 알아보겠습니다.

SOLID는 클린 코드로 유명한 로버트 마틴이라는 분이 좋은 객체지향 설계의 5가지 원칙을 정리를 한 거예요. 기존에 다 있었던 개념들인데, 이거를 정리를 해서 용어를 기가 막히게 만든 거예요.

하나씩 설명을 해드릴게요.


첫 번째, SRP 인데요.

SPR단일 책임 원칙이에요. 뭐냐면 하나의 클래스는 하나의 책임만 가져야 된다 라는 거예요.

근데 하나의 책임이라는 게 사실은 실무에선 모호합니다. 이 책임이라는 게 클 수도 있고 작을 수도 있어요. 그리고 문맥과 상황에 따라서 좀 달라요. 그러면 이 부분에서 약간 경험이 필요한 거예요.

근데 그러면 어떻게 하는 게 설계가 잘 됐다고 볼 수 있을까? 라고 하면, 중요한 판단의 기준은 변경이라고 봐요. 뭔가 변경이 있을 때 파급이 적으면 단일 책임 원칙을 잘 따르게 설계를 한 거예요.

그래서 우리 코드가 계층이 잘 나눠져 있는 이유는 다 단일 책임 원칙을 지키려고 하는 거라고 보시면 돼요.

그리고 책임의 범위를 적절하게 잘 조절하는게 객체지향 설계의 묘미입니다.

말씀드렸던 대로, 변경이 있을 때 딱 하나의 클래스나 하나의 지점만 고치면 그게 단일 책임 원칙을 잘 따르는 거라고 볼 수 있습니다.


두 번째는 OCP 인데요.

이게 가장 중요한 원칙입니다. 개방 폐쇄 원칙인데요, Open/Closed Principle 이라는 건데 사실은 약간 말이 안 되는 거에요. Software 요소확장에는 열려 있으나 변경에는 닫혀 있어야 된다.

'아니 뭔가 기능을 확장을 하려면 코드가 변경이 돼야될텐데, 변경을 안해도 된대!! 어떻게 코드의 변경 없이 기능을 추가할 수 있는 거지???'

지 생각을 해보세요.

우리 그 이전으로 쭉 돌아가서 다형성의 예시자동차 그림 생각을 해보면,

자동차가 기름차인 K3에서 전기차인 테슬라로 바꿔도, 운전자는 그냥 운전할 수 있잖아요. 전기차라는 새로운 기능을 얻어도(확장), 클라이언트 코드에 영향을 안 미치는 것(변경), 이게 바로 개방폐쇄의 원칙이에요.

결국 Java 언어에서의 다형성을 잘 활용해서 이 개방 폐쇄 원칙을 지킬 수가 있어요.

그래서 인터페이스를 구현한 새로운 클래스를 하나 만드는 것은 기존 코드를 변경하는 게 아니죠. 뭔가 인터페이스가 있고, 그걸 구현하는 클래스를 하나 만드는 거는 기존 코드에 변경을 전혀 주는 게 아니란 말이에요. 그래서 지금까지 배운 역할과 구현을 분리해서 생각해보면, 확장에는 열려있고 변경에는 닫혀있는 게 불가능하지 않아요.

다형성을 활용하면, 소프트웨어를 확장에는 열려있으나 변경에는 닫혀있게 할 수 있습니다.


자 그래서

우측 위에 보시면, 지금 멤버 서비스가 멤버 리포지토리를 알고 있죠. 그리고 멤버 리포지토리의 구현으로 메모리 멤버 리포지토리가 있고, jdbc 멤버 리포지토리가 있는데, 만약 메모리 멤버 리포지토리로 하다가 갑자기 db에 저장하는게 필요해서 jdbc 멤버 리포지토리 라는 새로운 코드를 짰어요.

그리고 내가 jdbc로 바꾸고 싶어요. 그러면 우리가 OCP 원칙을 잘 지키고 있다고 하면, 다른 코드를 바꿀 필요 없이 jdbc 멤버 리포지토리를 적용할 수 있어야 돼요.

근데 지금 어떻게 됩니까? 메모리 멤버 리포지토리를 jdbc 멤버 리포지토리를 바꾸려고 하면,

MemberService의 기존 코드가 바뀌죠. 위에 걸 주석처리 했고 밑에 JdbcMemberRepository()로 바뀌잖아요.

오? OCP랑 다른데? 변경에는 닫혀 있어야 된다면서! 변경에 안 닫혀있죠. 변경해야 되죠.

자 이 OCP, 개방 폐쇄 원칙은 사실 지금까지 제가 다형성 설명하면서 이 이야기를 사실 일부러 안 했는데, 무슨 문제가 있는지 설명을 드릴게요.

멤버 서비스 클라이언트직접 구현 클래스를 선택하고 있어요.

멤버 서비스가 멤버 리포지토리에 대한 인터페이스를 보고는 있지만,

왼쪽에는 인터페이스, 오른쪽에는 구현 객체죠.

처음에는 메모리 멤버 리포지토리로 선택을 해놨단 말이에요. 그런데 나중에는 DB에 붙어야 돼서 JDBC 멤버 리포지토리를 쓰려면 기존 코드를 변경해야 되는 거예요.

즉 클라이언트 코드인 멤버 서비스, 서버는 멤버 리포지토리라고 했을 때, 구현 객체를 바꾸려면 이 클라이언트 코드를 변경을 해야 됩니다. 분명히 지금 다형성을 잘 사용하고 있단 말이에요. 인터페이스를 잘 만들었고 구현체를 잘 만들었어요. 구현체를 만드는 것까지는 괜찮았어. 근데 적용을 하려다 보니까 OCP가 깨지는 거예요.

OCP가 깨진다는 말은 클라이언트가 변경을 해야 되는 거예요. 소프트웨어가 기존 코드를 변경을 하지 않으면 안 되는 거예요. 다형성을 활용해서 기능을 넣을 수 있어야 되는데, 기존 코드 변경 없이는 지금 그게 안 되는 거죠. 그래서 여기서는 OCP 원칙을 지킬 수가 없습니다.

그럼 이 문제를 어떻게 해결을 해야 되냐? 라고 하면, 객체를 생산하고 이런 관계를 맺어주는, 별도의 조립을 해주는 설정자가 별도로 필요해요. 바로 이 별도의 뭔가를 스프링이 해주는 겁니다. 소위 스프링 컨테이너라는 애가 해주는 역할이고요.

OCP 원칙을 지키기 위해서 DI도 필요하고 IoC 컨테이너라는 것도 필요한 거예요. 근데 이거를 말로 설명해서는 진짜 이해가 안 돼요. 나중에 코드로 이해를 하시면 됩니다.


세 번째는 LSP, 좀 쉬운 원칙인데요. 리스코프 치환 원칙입니다.

프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입인스턴스로 바꿀 수 있어야 한다. 그냥 간단하게 얘기하면 이런겁니다. 그냥 어떤 인터페이스가 있어요. 근데 그거에 대한 구현체가 있는 거예요.

예시로 들면, 자동차 인터페이스가 있어요. 그러면 이 자동차를 구현체가 구현하면 되겠죠.

만약 엑셀이라는 기능을 구현해야 돼요. 원래 엑셀을 누르면 앞으로 가야하는데, 엑셀을 누르면 뒤로 가는 특별한 클래스를 만든 거에요. 그렇게 해도 java 문법이 틀린 건 아니니까, 컴파일 에러는 안나잖아요.

리스코프 치환 원칙(LSP)은 단순히 컴파일 단계를 얘기하는 게 아니에요.

인터페이스 규약이 "엑셀은 무조건 앞으로 가야 돼!" 라는 규약이 있잖아요. 그러면 이 규약을 무조건 맞춰야 돼요. 기능적으로 그거에 대해서 보장을 해줘야 된다는 거예요.

그래서 내가 뒤로 가게 구현을 하면, 이거는 LSP, 즉 리스코프 치환 원칙을 위배하는 거예요.


네 번째는 인터페이스 분리 원칙인데, ISP입니다.

특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다.

예를 들어볼게요. 자동차라는 인터페이스가 있어요. 그럼 여기는 운전과 관련된 인터페이스 기능들도 있고 자동차 정비와 관련된 인터페이스 기능들도 쫙 있겠죠. 이런 경우에 자동차 인터페이스 하나만 있으면 너무 크니까 이거를 운전이라는 인터페이스정비라는 인터페이스 두 개로 딱 분리하는 거예요.

그러면 뭐가 좋냐면, 사용자 클라이언트운전자 클라이언트정비사 클라이언트로 분리할 수가 있어요.

그래서 예를 들어서 정비와 관련된 문제가 있어서 기능을 바꿔야 해요. 그러면 정비와 관련된 인터페이스와 정비 인터페이스를 사용하는 정비사 관련된 부분을 바꾸면 되지, 운전자 클라이언트 인터페이스를 바꿀 필요가 없죠. 그리고 또 기능이 너무 많으면 복잡하잖아요.

그래서 기능을 딱 거기에 맞게, 인터페이스도 적당한 크기로 잘 쪼개는 게 중요하다는 뜻입니다.

그래서 분리하면 정비 인터페이스 자체가 변해도 운전자 클라이언트에 영향을 주지 않게 됩니다. 그리고 인터페이스가 명확해지고 대체 가능성이 높아져요. 아무래도 덩어리가 크면 그걸 다 구현하기 힘들잖아요. 근데 덩어리가 작으면, 이 기능만 살짝 구현하는 것을 구현체로 바꾸기는 되게 쉽겠죠.

그래서 인터페이스 분리 원칙(ISP)이 있고, Spring Framework 코드를 까보면 정말 철저하게 엄청나게 분리되어 있습니다.


그 다음 마지막 다섯번째는 DIP, 의존관계 역전 원칙입니다. 이것도 사실 중요한 원칙이에요.

제일 중요한 원칙이 OCPDIP 두 개거든요. 두 개가 또 연관이 있어요.

D는 뭐냐면 Dependency, 그러니까 의존성, 의존관계 이렇게 표현을 하는데요. Dependency Inversion은 의존관계 역전인데, 프로그래머는 추상화에 의존해야지 구체화에 의존하면 안 된다. 의존성 주입 이 원칙을 따르는 방법 중 하나다. 라고 정리가 되어 있습니다.

근데 또 말만 들으면 "추상화가 무슨 말이지??" 할 텐데요,

쉽게 이야기하면 클라이언트 코드가 구현 클래스를 바라보지 말고 인터페이스만 바라보라는 뜻이에요. 멤버 서비스가 멤버 리포지토리 인터페이스만 바라보고, 메모리 멤버 리포지토리나 jdbc 멤버 리포지토리에 대해서는 몰라야 된다는 뜻이에요. 앞서 이야기한 역할에 의존하는 거랑 똑같은 이야기예요.

다시 자동차 예시를 보면,

운전자는 자동차 역할에 대해서 알아야지, K3에만 집중적으로 알아요. 그러면 아반떼로 바꾸면 어렵겠죠?

공연도 마찬가지에요.

예를 들어서, 원빈이 김태희랑만 공연 연습을 했어요. 김태희가 아니면 다른 사람이랑 공연하기가 힘들다. 이러면 안되잖아요. 공연 매뉴얼을 보고 연습하는 게 아니라, 둘이 따로 연습한 거예요. 그러면 나중에 다른 배우로 바뀌면 대체 가능성이 없어지잖아요.

그래서 시스템도 역할과 구현을 철저하게 분리하도록 설계를 해야 돼요. 그래서 시스템도 언제든지 갈아 끼울 수 있게 설계를 해야 됩니다. 그게 가능하려면 역할(Role)에 의존해야지, 구현에 의존하면 절대 안된다. 배우도 대본에 의존해서 배우를 해야지, 대본이 아닌 다른 실제 담당 배우랑 따로 대본없이 연습하면 다른 배우로 바뀌면 공연은 망하겠죠.

그래서 앞서 얘기한 역할에 의존해야 된다는 이야기랑 DIP는 똑같은 이야기예요. 객체 세상도 클라이언트가 인터페이스에 의존해야 유연하게 구현체를 변경할 수가 있어요. 구현체에 의존하게 되면 변경이 아주 어려워집니다.


자 앞에서 OCP 부분에서 설명한 멤버 서비스는 분명히 인터페이스에 의존해요. 그런데 동시에 구현 클래스도 의존합니다.

위에 코드를 보면, 멤버 서비스가 분명히 멤버 리포지토리 필드를 가지고 있죠. 그런데 오른쪽에 new에서 뭘 할당했습니까? 메모리 멤버 리포지토리 할당한 거에요. 그러면 멤버 서비스는 메모리 멤버 리포지토리에도 의존하고 있는 거에요. 의존한다는 건 내가 저 코드를 안다는 거에요. 그냥 내가 저 코드에 대해서 알기만 하면 다 의존하는 거에요.

자 그래서 지금 멤버 서비스는 멤버 리포지토리 인터페이스만 아는게 아니라, 메모리 멤버 리포지토리 까지 알고 있는거에요. 그래서 이 메모리 멤버 리포지토리를 다른걸로 바꾸려고 할 때마다 코드를 변경을 해야 돼요.

그래서 이제 이 멤버 서비스 클라이언트가 구현 클래스를 직접 선택하고 있는 거에요. 즉, 의존하고 있는 거에요.

이렇기 때문에 DIP를 위반하는 거에요. DIP 위반은 무슨 뜻이다? 추상화에 의존해야지 구체화에 의존하면 안 된다. 그런데 분명히 멤버 리포지토리라는 추상화 인터페이스에 잘 의존하고 있어요. 그런데 지금 보면 구현체인 메모리 멤버 리포지토리에도 동시에 의존하고 있죠. 둘 다 의존하고 있는 거예요.

사실은 DIP를 위반한 거예요. 그래서 코드를 변경해야 되는 문제들이 생기는 겁니다.

그럼 도대체 어떻게 하라는 거야?

클래스 레벨에서 설계할 때, 멤버 서비스는 멤버 리포지토리 인터페이스에만 의존하도록 설계를 해야 돼요.

어떻게 하는지를 뒤에서 설명을 쭉 드릴 거에요.


이제 정리를 해보면,

객체 지향의 핵심은 다형성입니다.

근데 다형성만으로는 쉽게 부품을 갈아 끼우듯이 개발을 할 수가 없어요. 메모리 멤버 리포지토리에서 JDBC 멤버 리포지토리를 개발하는 것까지는 할 수 있는데, 이걸 클라이언트에 영향을 주지 않고 바꿔끼울 수가 없는 거예요. 그래서 다형성만으로는 구현 객체를 변경할 때 클라이언트 코드도 함께 변경이 되어버려요.

그래서 이 다형성만으로는 OCP랑 DIP를 지킬 수가 없어요. 그래서 뭔가가 더 필요해요.

인터페이스에만 의존하면 구현체가 없는데, 어떻게 코드가 돌아가겠어요. 안 돌아가겠죠.

자 그래서 뭔가가 더 필요합니다. 객체 지향을 제대로 공부한 개발자라면 누구나 여기까지 와요.

'아 다형성만으로는 뭔가 클라이언트 코드의 변경을 막을 수가 없구나, 뭔가 다른 거가 더 필요하구나" 라는 데까지 와요. 누구나 고민하고 고민하면 그 끝에 다가서면 똑같은 고민을 합니다.


다음 시간에는 객체지향 설계Spring에 대해서 알아보겠습니다.