로버트 마틴 아저씨가 설계한 객체 지향 프로그래밍의 원칙(SOLID)을 이해해 보자.
SOLID는 5가지 기본 원칙의 시작 글자를 합친 것이며, 5가지는 다음과 같다.
- 단일 책임 원칙 (Single responsibility principle, SRP)
- 개방-폐쇄 원칙 (Open/closed principle, OCP)
- 리스코프 치환 원칙 (Liskov substitution principle, LSP)
- 인터페이스 분리 원칙 (Interface segregation principle, ISP)
- 의존관계 역전 원칙 (Dependency inversion principle, DIP)
이 원칙들은 유기적으로 연관되어 있지만, 이해하기 쉽게 각각 하나씩 분할 정복해 보자.
# 단일 책임 원칙 (Single responsibility principle, SRP)
단일 책임 원칙은 간단하다.
하나의 클래스는 오직 하나의 책임만 가져야 한다는 것이다.
여기서 이해하기 어려운 건 책임이라는 게 모호하기 때문인데, 이 책임은 상황에 따라 클 수도, 작을 수도 있다.
그래서, 단일 책임 원칙을 잘 지키기 위한 중요한 기준은 바로 변경이다.
책임의 범위를 정하는 게 어렵다면, 코드에서 변경이 있을 때 해당 변경으로 인한 파급효과가 적게 하는 것이 단일 책임 원칙을 잘 지킨 것으로 볼 수 있다.
클린 아키텍처 책의 예시보다 더 와닿는 코드로 살펴보면,
class 남자 {
public void 효도하기() {
System.out.println("부모님에게 효도를 함.");
}
public void 데이트하기() {
System.out.println("여자친구와 데이트를 함.");
}
public void 출근하기() {
System.out.println("회사에 출근 함.");
}
}
남자 클래스가 있고, 각각 아들로서 효도, 남자친구로서 데이트, 직장인으로서 출근하는 기능이 존재한다.
이 클래스는 남자가 가질 수 있는 여러 책임들을 모두 가지고 있기에 특정 기능이 변경될때마다 클래스가 계속 수정될 것이며,
하나의 모듈은 하나의, 오직 하나의 액터에 대해서만 책임져야 한다.
* 모듈 : 소스 파일(함수와 데이터 구조로 구성된 응집된 집합)
* 액터 : 변경을 요청하는 한 명 이상의 사람들
라는 로버트 마틴 아저씨의 말에 위배된다.
따라서, SRP 관점으로 남자 클래스를 리팩토링 한다면 다음과 같다.
class 아들 {
public void 효도하기() {
System.out.println("부모님에게 효도를 함.");
}
}
class 남자친구 {
public void 데이트하기() {
System.out.println("여자친구와 데이트를 함.");
}
}
class 직장인 {
public void 출근하기() {
System.out.println("회사에 출근 함.");
}
}
남자 클래스를 아들, 남자친구, 직장인이라는 책임에 따라 분리하였고,
이제는 특정 기능의 변경이 일어나도 해당 클래스만 변경해 주면 된다.
이처럼 단일 책임 원칙을 지키면 코드의 가독성 향상과 유지 보수가 용이해지는 이점이 있다.
하지만, 분리할 이유가 없는데 과도하게 분리할 경우 오히려 복잡성이 증가하게 되므로 주의해야 한다.
# 개방-폐쇄 원칙 (Open/closed principle, OCP)
개방 폐쇄 원칙은 확장에는 열려 있으나, 변경에는 닫혀 있어야 한다는 것이다.
좀 더 자세히 풀어보면,
열린 확장이란 새로운 클래스를 추가함으로써 기능을 확장하는 걸 의미하고,
닫힌 변경이란 확장이 발생했을 때 해당 코드를 사용하는 쪽에서 변경이 없어야 한다는 것을 의미한다.
예시 코드는 이해하는 걸 최우선 목표로 아주 린하게 구성했다.
어떤 역할이 주어질 때 해당 서비스를 콜 하는 간단한 스프링 Controller를 만들어보자.
만약, OCP를 생각하지 않고 Controller를 짠다면 이렇게 될 게 뻔한데.. 바로 안티 패턴이 나타난다.
@GetMapping("/playrole")
public void playRole(@RequestParam String 역할) {
if(역할.equals("아들")) {
아들Service.do();
} else if (역할.equals("남자친구")) {
남자친구Service.do();
}
// 다른 역할 계속 추가..?
}
예를 들어 기존 역할 이외에 "직장인"이라는 역할이 확장될 경우 직장인Service뿐 아니라 해당 Service를 사용하는 Controller까지 같이 변경되기 때문이다. (+ 무한 if ?)
따라서, OCP 관점에서 리팩토링을 한다면 다음과 같다.
@GetMapping("/playrole")
public void playRole(@RequestParam String 역할) {
final 역할Service인터페이스 역할Service = 역할Factory.createService(역할);
역할Service.do();
}
위의 코드를 보면 앞으로 역할이 확장되어 수많은 Service가 추가되더라도 Controller의 코드는 변경되지 않는 걸 알 수 있다.
이것이 바로 확장에는 열려 있으나, 변경에는 닫혀있는 OCP가 지켜진 코드이다.
물론, 역할에 맞는 Service를 선택해 주는 역할Factory 클래스가 별도로 추가되었는데, 해당 부분은 디자인 패턴 중 팩토리와 연관되므로 이 글에서는 설명하지 않겠다.
# 리스코프 치환 원칙 (Liskov substitution principle, LSP)
리스코프 치환 원칙은 상위 타입의 클래스가 하위 타입의 클래스로 치환되어도 동일한 동작을 보장해야 한다는 원칙이다.
이는 당연하지만 매우 중요한데, 객체지향의 다형성과도 연관이 깊다.
다형성을 위해 하위 클래스는 인터페이스의 규약을 지켜야 하고, 이 자체로 LSP를 지키는 것이라 볼 수 있다.
이러한 LSP를 잘 적용한 예시는 Java의 컬렉션 프레임워크이다.
위의 계층도를 보면 상위 타입에 속하는 어떤 하위 타입의 클래스를 대입해도 동일하게 동작하는 걸 알 수 있다.
Map 자료구조만 놓고 자세히 확인해 보면,
최상위 Map 타입에 Hashtable, HashMap, TreeMap 등 어떤 하위 클래스를 대입할지라도 LSP가 잘 지켜져 있기에 동일한 Map 자료구조의 동작이 수행됨을 보장한다.
# 인터페이스 분리 원칙 (Interface segregation principle, ISP)
인터페이스 분리 원칙은 SRP와 비슷한데 클래스가 아닌 인터페이스에 초점이 맞춰져 있으며,
클라이언트는 사용하지 않는 인터페이스에 강제로 의존하면 안 된다는 것이다.
예시 코드로 확인해 보자.
먼저, ISP가 지켜지지 않은 예시이다.
interface 동물 {
void 고기를먹음();
void 풀을먹음();
}
class 호랑이 implements 동물 {
@Override
public void 고기를먹음() {
System.out.println("고기를 먹는다.");
}
@Override
public void 풀을먹음() {
System.out.println("풀을 먹는다.");
}
}
class 사슴 implements 동물 {
@Override
public void 고기를먹음() {
// 사용할 수 없음!!
new Exception("고기를 먹을 수 없음...");
}
@Override
public void 풀을먹음() {
System.out.println("풀을 먹는다.");
}
}
위의 예시를 보면, 동물 인터페이스에는 고기를 먹는 행동과 풀을 먹는 행동이 있다.
호랑이는 고기와 풀을 둘 다 먹을 수 있으므로 두 가지 행동 모두 만족하지만, 사슴의 경우 초식동물이기에 고기를 먹지 못하므로 에러가 발생하며 이는 ISP에 위배된다.
따라서, ISP를 지키려면 다음과 같이 리팩토링을 해야 한다.
interface 육식동물 {
void 고기를먹음();
}
class 호랑이 implements 육식동물 {
@Override
public void 고기를먹음() {
System.out.println("고기를 먹는다.");
}
}
interface 초식동물 {
void 풀을먹음();
}
class 사슴 implements 초식동물 {
@Override
public void 풀을먹음() {
System.out.println("풀을 먹는다.");
}
}
동물 인터페이스를 클라이언트별로 세분화하여 육식 동물, 초식 동물로 분리함으로써 ISP를 만족하게 되었다.
# 의존관계 역전 원칙 (Dependency inversion principle, DIP)
SOLID의 마지막, 의존관계 역전 원칙이다.
Depend upon Abstractions. Do not depend upon concretions.
위 인용구를 모토로 흔히 말하는 DI(의존성 주입)가 이 원칙을 따르는 방법 중 하나이며,
쉽게 말해 구현 클래스에 의존하지 말고 인터페이스에 의존하라는 뜻이다. (따라서 LSP, OCP와 매우 밀접한 관련이 있다.)
간단한 예시 코드로 확인해 보자.
class 대리기사 {
private K5 k5;
private Sonata sonata;
// 다른 자동차 계속 추가..?
public void setK5(K5 k5) {
this.k5 = k5;
}
public void setSonata(Sonata sonata) {
this.sonata = sonata;
}
// 다른 자동차 세터 계속 추가..?
public void driveK5() {
k5.drive();
}
public void driveSonata() {
sonata.drive();
}
// 다른 자동차 관련 메서드 계속 추가..?
}
public class Main {
public static void main(String[] args) {
K5 k5 = new K5();
Sonata sonata = new Sonata();
대리기사 이수근 = new 대리기사();
이수근.setK5(k5);
이수근.driveK5();
이수근.setSonata(sonata);
이수근.driveSonata();
}
}
편의상 K5, Sonata 같은 자동차 클래스 코드는 생략했다.
대리기사 클래스를 보면 K5와 Sonata라는 구현 클래스에 의존하고 있는 모습을 볼 수 있다.
언뜻 보기엔 별문제 없어 보이지만, 만약 대리기사가 벤츠를 몰아야 한다면 어떻게 될까?
벤츠 클래스를 만들고, 대리기사 클래스에도 벤츠 관련 코드가 추가되어야 할 것이다.
대리기사가 운전하게 될 차가 오직 저 두 종류뿐만이 아니기에, 이후 차 종류가 추가될수록 대리기사 클래스 또한 계속 변경되는 악순환이 발생하며 이는 DIP를 위배한 것이다.
따라서, DIP를 지키기 위해 리팩토링을 해보자.
class 대리기사 {
private Car car;
public void setCar(Car car) {
this.car = car;
}
public void driveCar() {
car.drive();
}
}
public class Main {
public static void main(String[] args) {
Car k5 = new K5();
Car sonata = new Sonata();
Car benz = new Benz();
대리기사 이수근 = new 대리기사();
이수근.setCar(k5);
이수근.driveCar();
이수근.setCar(sonata);
이수근.driveCar();
이수근.setCar(benz);
이수근.driveCar();
}
}
마찬가지로 편의상 자동차 관련 클래스는 생략했고, Car는 인터페이스이다.
대리기사 클래스에서 구체화된 구현 클래스가 아닌 추상화된 인터페이스에 의존하므로 DIP를 만족하게 되었고, 이제 자동차가 추가되더라도 대리기사 클래스는 코드 변경이 일어나지 않는 것을 알 수 있다.