SOLID, 객체지향 설계 5원칙
0. 글을 시작하며
객체지향 설계 5원칙 SOLID는 우리가 스프링을 공부하거나 기술면접을 준비할 때 아주 쉽게 접하게 되는 개념입니다. 보통 기술면접을 준비하면서 많이 접하게 되고 5가지 원칙을 거의 암기하는 방식으로 학습하게 되는 데 그렇다보니 금방 잊어버리고, 해당 원칙을 준수함으로서 가져오게 되는 효과 등 본질적인 내용은 정작 이해하지 못한다는 문제점이 있었습니다.
그래서 이번 글을 통해서 객체지향 언어를 사용하는 개발자라면 꼭 알아야할 객체지향 설계 5원칙을 구성하는 각 원칙의 개념정리와 함께 구체적 예시, 이해하기 어려운 부분들을 정리해보고자 합니다.
흔히 SOLID라고 불리는 이 용어의 정의는 다음과 같습니다.
좋은 객체지향 설계가 지켜야할 5가지 원칙 (SRP, OCP, LSP, ISP, DIP)
간단하게 이 개념의 역사 이야기를 해보면 SOLID는 클린코드의 저자로 유명한 로버트 마틴이 정의한 좋은 객체지향적 설계가 지켜야할 5가지 원칙을 마이클 페더스라는 사람이 원칙들의 순서를 재배열하여 앞 글자만 따서 만들어낸 용어입니다. 실제로는 로버트 마틴이 여러 사람들과 이 객체지향 설계 원칙의 개념을 토론하고 정립해가는 과정에서 없어지거나 합쳐지는 규칙들이 생기는 등 여러 수정이 있었고, 그 결과로 나온 5가지 원칙의 순서를 재배열하면 SOLID라는 단어를 만들 수 있다는 내용의 이메일을 마이클 페더스가 보내면서 이 개념이 탄생하게 되었다고 합니다.
이렇게 정립된 SOLID를 구성하는 5가지 원칙은 다음과 같습니다.
- SRP (Single Responsibility Principle)
- OCP (Open / Closed Principle)
- LSP (Liskov Substitution Principle)
- ISP (Interface Segregation Principle)
- DIP (Dpendency Inversion Principle)
이렇게 용어로만 보아서는 해당 개념이 무엇을 의미하는 지 잘 와닿지 않습니다. 지금부터 1개씩 각 원칙의 의미를 정리해보겠습니다. 이 설명에서 가장 큰 전제가 되는 내용 중 하나는 변경되는 요구사항에 대해서 기존 코드에 대한 수정이 최소화 되어야 한다는 점입니다. 이 가장 큰 전제를 기억하면서 개념설명과 예시코드를 함께 읽게되면 이해에 도움이 될 것입니다.
1. SRP - 단일책임원칙
하나의 클래스는 "하나의 책임" 만을 가져야 한다.
SRP는 "Single Responsibility Principle"의 줄임말로 우리 말로는 "단일책임원칙" 이라고 표현합니다. 즉, 우리가 객체지향 프로그래밍 언어로 프로그램을 개발할 때 정의하게 되는 각 클래스들이 여러 책임을 가지지 않고 하나의 책임을 가져야 한다는 것 입니다. 하나의 클래스 or 메서드가 여러 개의 책임을 가진다면 이는 요청사항의 변경이 발생할 경우 한 책임에서의 변화점이 다른 책임까지도 영향을 미칠 가능성이 높습니다.
여기서 다소 햇갈릴 수 있는 점은 "책임의 단위를 어떻게 볼 것인가?" 라는 점입니다. 우리가 프로그래밍 언어로 작성하게 되는 비즈니스 로직은 결국 여러 책임들이 모여 더 큰 단위의 동작을 수행하는 클래스를 만들게 됩니다. 그래서 어떤 기능이나 책임을 바라보는 문맥과 상황에 따라서 그 책임의 단위, 크기가 달라지기 때문에 "하나의 책임" 이라는 단위가 다소 모호하게 느껴질 수 있습니다.
여기서의 이 애매함을 바로잡기 위한 하나의 좋은 기준은 바로 "변경의 정도"에 집중하는 것 입니다. 어떤 변경된 요구사항으로 인해 소프트웨어의 변경이 필요할 때, 그 파급효과가 적다면 단일책임 원칙을 잘 따르는 것입니다.
대표적인 예시가 바로 우리가 Spring으로 서비스를 개발하면서 Controller, Service, Repository로 구분하고, 거기서도 도메인 별로 클래스를 나눠서 구성하는 입니다. 만약 회원과 주문에 대한 서비스를 구성한다고 할 때 아래와 같이 회원을 위한 Service와 주문을 위한 Service을 분리하여 구성합니다. 즉, 각 클래스의 책임이 다음과 같이 할당되는 것 입니다.
- MemberService : 회원과 관련된 비즈니스 로직을 관리함
- OrderService : 주문과 관련된 비즈니스 로직을 관리함
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
@Transactional
public save(MemberCreateRequestDTO request) { ... }
public Member findById(long id) { ... }
public Member findByName(String name) { ... }
public Member findAll() { ... }
@Transactional
public Member updateMember(MemberUpdateRequestDTO request) { ... }
@Transactional
public long delete(long id) { ... }
}
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
@Transactional
public save(OrderCreateRequestDTO request) { ... }
public Order findById(long id) { ... }
public Order findAll() { ... }
@Transactional
public Order updateOrder(OrderUpdateRequestDTO request) { ... }
@Transactional
public long delete(long id) { ... }
}
2. OCP - 개방 / 폐쇄 원칙
소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다.
OCP는 "Open / Closed Principle"의 줄임말로 우리 말로는 "개방폐쇄원칙" 이라고 표현합니다. 사실 위 문장을 읽었을 때 구체적으로 그 의미가 잘 와닿지 않습니다. 즉, 조금 더 풀어서 설명해보면 소프트웨어가 어떤 변경사항이나 새로운 기능 구현에 대한 요구사항이 발생했을 때 기존에 작성한 코드를 변경하는 것이 아니라 확장하는 방식으로 이를 달성할 수 있으면 이 원칙을 잘 따르는 것 입니다.
이를 달성하기 위한 방법은 다형성의 개념을 활용하여 인터페이스를 구현한 새로운 클래스를 하나 만들어서 새로운 기능을 구현하는 방식으로 프로그램을 작성하는 것입니다. 즉, 새로운 요청사항이 들어와 소프트웨어의 변경사항이 발생했을 때 클래스의 확장을 통해 기능을 구현하고 기존 코드의 수정은 최소화하는 전략입니다.
대표적인 예시로 스프링 빈 메타 설정정보를 읽어오는 클래스에 대한 부분 입니다.
스프링 빈 메타정보를 읽어오는 방식은 크게 다음 두 가지 방식이 있습니다.
- 에노테이션
- XML
그 외에도 다른 방식들이 존재할 수 있는데, 여기서 핵심은 사용자가 새로운 방식으로 스프링 빈 메타정보를 읽어오는 기능을 구성하고 싶은 경우 공유되는 인터페이스를 구현하는 새로운 클래스를 구현하여 기능을 추가하는 것이지 기존에 존재하던 에노테이션, XML 관련 코드는 전혀 수정하지 않는 다는 것 입니다.
이는 객체지향 언어가 가지는 특징 중 하나인 "다형성"의 개념을 활용한 예시로 볼 수 있으며, 이렇게 코드를 구성함으로서 기존 코드에 대한 수정없이 새로운 클래스 작성만으로 새로운 기능을 유연하게 추가할 수 있다는 강력한 장점이 발생합니다. 물론 이를 잘 달성하기 위해서는 기반이 되는 인터페이스를 잘 정의하는 작업이 대단히 중요합니다.
3. LSP - 리스코프 치환원칙
프로그램의 객체는 프로그램의 정확성을 유지하면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다.
LSP는 "Liskov Segregation Principle"의 줄임말로 우리 말로는 "리스코프 치환원칙" 이라고 표현합니다. MIT의 리스코프 교수가 제안한 설계원칙이라 그의 이름을 따서 지어진 이름입니다. 리스코프 치환원칙은 객체지향 언어가 가지는 주요한 특징인 "다형성"을 지원하기 위해서 반드시 필요한 원칙 입니다. 인터페이스에 대한 구현체를 믿고 사용하려면 이 원칙이 필요합니다.
즉, 올바른 상속을 위해서 자식 객체의 확장이 부모객체의 방향을 따라야 한다는 것입니다. 이는 코드보다 조금 더 직관적인 예시로 설명할 수 있는 데 그 대표적인 예시가 바로 자동차 입니다.
우리는 자동차가 가지는 표준(인터페이스)을 정의하고 있습니다. 예를 들어 다음과 같은 내용들입니다.
- 오른쪽의 엑셀패달을 밟으면 전진, 왼쪽의 브레이크 페달을 밟으면 차가 정지한다.
- 기어로 P를 넣으면 파킹, N을 넣으면 중립, R을 넣으면 후진, D를 넣으면 주행한다.
- 핸들을 조향하는 방향으로 자동차의 주행방향이 변경된다.
아무리 제조사가 다르고 그 디자인이 다른 자동차라고 하더라도 이러한 인터페이스는 공통적으로 지켜서 구현됩니다. 만약 이를 지키지 않고 브레이크 페달을 밟는데 차가 나간다거나 파킹을 넣었는 데 기어가 중립이 된다면 어떻게 될까요? 그렇게 되면 기존 부모 클래스에서 정의했던 구현방향이 틀어지고, 이를 상속한 코드를 신뢰하고 사용할 수 없다는 문제가 발생하며, 이를 사용하는 사용자에게 큰 혼란을 야기하게 된다는 큰 문제점이 발생합니다.
위에서 말한 "프로그램의 정확성을 유지하면서" 라는 문장이 바로 이러한 맥락을 의미하는 것 이었습니다.
즉, 단순히 컴파일을 성공하는 것을 넘어서는 중요한 이야기 입니다.
4. ISP - 인터페이스 분리 원칙
특정 클라이언트를 위한 여러 개의 인터페이스가 하나의 범용 인터페이스 보다 낫다.
ISP는 "Interface Segregation Principle"의 줄임말로 우리 말로는 "인터페이스 분리원칙" 이라고 표현합니다. 예를 들어서 우리가 사용하는 에어팟에 대한 인터페이스를 다음 두 가지 관점으로 바라보겠습니다.
첫번째는 하나의 범용 인터페이스로 사용하는 관점입니다.
// 에어팟에 대한 인터페이스
interface Airpods {
void connect();
void changeSoundMode();
void caseCharging();
void unitCharging();
}
예를 들어서 위와 같이 에어팟에 대한 하나의 범용 인터페이스를 작성하고, 이를 구현해서 사용한다면 어떤 문제가 있을까요? 만약 새로운 신제품이 출시되어서 케이스는 그대로이지만 유닛에 대한 기능이 업그레이드 되었다고 가정해보겠습니다. 이렇게 되면 케이스에 대한 변화가 없더라도 유닛에 대한 변경사항 때문에 인터페이스 전체가 영향을 받게 됩니다. 그리고 이러한 인터페이스의 변경은 이에 대한 구현체 전체로 퍼져나가기 때문에 꽤 복잡한 문제가 됩니다.
// 에어팟 케이스에 대한 인터페이스
interface AirpodsCase {
void caseCharging();
void unitCharging();
}
// 에어팟 유닛(좌/우)에 대한 인터페이스
interface AirpodsUnit {
void connect();
void changeSoundMode();
}
하지만 반대로 위와 같이 인터페이스를 케이스에 대한 인터페이스와 유닛에 대한 인터페이스로 나눠서 구성하였다면, 유닛에 대한 변경사항이 생겼을 때 유닛 인터페이스만 변경되면 되기 때문에 케이스에 대한 인터페이스는 영향을 받지 않습니다. 그래서 케이스에 대한 구현체는 변경의 영향을 받지않고, 변경의 대상인 유닛에 대한 구현체만 수정하면 되기 때문에 변경에 대한 영향이 줄어들게 됩니다.
즉, 이렇게 하나의 범용 인터페이스보다 여러 개의 인터페이스로 분리해서 구성하게 되면 인터페이스가 명확해지고, 대체 가능성이 높아지게 됩니다. 쉽게 정리해보면 인터페이스를 적절히 분리해서 설계하는 것이 변경의 범위를 최소화하며 작은 범위로 격리하기 때문에 다양한 변경사항에 대응해야하는 객체지향 설계에의 관점에서 반드시 필요한 원칙이 됩니다.
5. DIP - 의존관계 역전원칙
객체지향 프로그램은 추상화에 의존해야지 구체화에 의존해서는 안된다.
DIP는 "Dependency Inversion Principle"의 줄임말로 우리 말로는 "의존관계 역전원칙" 이라고 표현합니다. 조금 다르게 표현하면 구현 클래스에 의존하는 것이 아니라 인터페이스에 의존해야한다는 것 입니다. 예를 들어서 외부에서 Map을 전달받아 그 Map의 Key-Value Pair를 출력하는 메서드를 작성했다고 가정해보겠습니다.
// 인자가 인터페이스 Map이 아닌 구현체 HashMap에 의존한다.
public void printMapContent(HashMap<String, List<String>> map) {
for(Map.Entry<String, List<String>> entry : map.entrySet()) {
System.out.println(entry.getKey() + " " + entry.getValue());
}
}
만약 위와 같이 해당 메서드가 인자로 구현체인 HashMap 클래스를 받는다면 어떨까요? 애초에 HashMap을 사용하도록 프로그램이 설계되었고, 인자로 HashMap만 넘겨야하는 상황이라면 괜찮습니다. 하지만 어떠한 이유에서 HashMap이 아닌 TreeMap을 사용해야하는 상황이 된다면 어떻게 될까요? 해당 메서드의 인자를 받는 부분을 다음과 같이 수정해야 합니다. 이 예시는 워낙 간단하기 때문에 그 번거로움과 비효율성이 잘 느껴지지 않지만, 프로그램의 규모가 커지면 이는 중요한 문제가 됩니다.
// 인자로 넘어가는 Map 구현체가 TreeMap으로 변경되었다.
public void printMapContent(TreeMap<String, List<String>> map) {
for(Map.Entry<String, List<String>> entry : map.entrySet()) {
System.out.println(entry.getKey() + " " + entry.getValue());
}
}
이렇게 인터페이스가 아닌 구현체에 의존하는 순간 요청사항의 변경에 유연한 대응이 어려운 코드가 됩니다.
// 인자가 인터페이스 Map에 의존한다.
public void printMapContent(Map<String, List<String>> map) {
for(Map.Entry<String, List<String>> entry : map.entrySet()) {
System.out.println(entry.getKey() + " " + entry.getValue());
}
}
하지만 위와 인터페이스에 의존하도록 코드를 변경한다면 외부에서 HashMap을 사용하던, TreeMap을 사용하던 모두 Map 인터페이스의 구현체이기 때문에 요청사항의 변경으로 사용하는 Map 구현체가 변경되어도 해당 메서드 자체에서는 수정할 코드가 없습니다. 즉, 요청사항의 변경에서 기존 코드의 수정을 최소화하는 유연한 코드가 됩니다.
6. 글을 마치며
사실 각 객체지향설계 원칙을 이렇게 짧은 코드를 예시로 들어서 설명하기에는 다소 어려움이 있습니다. 하지만 오히려 복잡한 코드는 해당 개념에 대한 이해를 방해하기 때문에 정말 간단한 예시를 코드로 작성하면서도 최대한 해당 원칙을 코드에 녹여 표현해보고자 노력하였습니다. 혹시 잘못된 맥락을 사용된 예시코드가 있거나 더 나은 방향이 있다면 댓글로 피드백 주시면 좋겠습니다.
이 원칙을 잘 준수하며 좋은 객체지향 코드를 작성하는 개발자로 성장하기 위해서는 단순히 이 개념을 보고 이해하는 것에서 그쳐서는 안됩니다. 가장 좋은 방법은 여러 클래스들이 협업하여 핵심 비즈니스 로직을 구성하는 어느정도 규모가 있는 프로젝트를 진행하면서 다양한 기능들을 구현해보는 것입니다. 이 과정에서 변경되는 요구사항에 대응할 수 있는 유연한 코드를 작성하는 방법에 대한 고민이 시작되고, 이를 위한 코드를 작성하는 과정에서 SOLID, 객체지향 설계 5원칙을 준수하기 위해 노력하는 자신을 발견할 수 있을 것입니다.
'Web Backend > Spring' 카테고리의 다른 글
댓글
이 글 공유하기
다른 글
-
Spring & MyBatis - DataAccessResourceFailureException
Spring & MyBatis - DataAccessResourceFailureException
2022.08.11 -
MyBatis - 2개 이상의 Query를 mapper에 한번에 작성하고 싶은 경우
MyBatis - 2개 이상의 Query를 mapper에 한번에 작성하고 싶은 경우
2022.01.14 -
web.xml (A field of identity constraint 'web-app-servlet-name-uniqueness' matched element 'web-app', but this element does not have a simple type.)
web.xml (A field of identity constraint 'web-app-servlet-name-uniqueness' matched element 'web-app', but this element does not have a simple type.)
2022.01.12 -
STS - java.lang.ExceptionInInitializerError
STS - java.lang.ExceptionInInitializerError
2022.01.11