SOLID 5원칙
에피소드
예전에 전 직장 동료 분들과 함께 하는 점심 식사 자리에서 데이터 모델링에 대한 심화 적인(?) 주제로 대화를 나누다가
객체지향에 대한 주제로 번졌고,
그 중 동료분이 “SOLID 아시죠?” 라는 질문에 저는 “가수 SOLID요?” 라고 답을 하고 서로 폭소 했던 기억이 있습니다 ㅋㅋㅋ(실화입니다.) 전공 시간 중 객체지향이라는 과목에서 배우며 넘어갔던 기억이 살짝 스쳤는데 내가 SOLID도 모르고 객체지향 언어를 다루는게 정말 맞는걸까? 라는 생각도 들고 참 여러가지 생각이 많았습니다.
어쨌든 지금부터라도 클린 코드를 위해 다시 제대로 알고 가자 라는 의미에서 이 글을 적게 되었습니다.
먼저 SOLID는 다섯개의 원칙인데, 각 원칙의 앞 글자를 모두 따서 S.O.L.I.D가 됩니다. (정말 앞글자만 따서 SOLID가 된걸까요? 아시는 분 계시면 코멘트 부탁드립니다.)
어쨌든 SOLID는 이미 객체지향적 프로그래밍 방법론에 있어서는 거의 성경이라고 불릴 만한 어마어마한 원칙인데요 그럼에도 불구하고 다음과 같은 의문을 가질 수도 있습니다.
SOLID 5원칙을 지키면 훌륭한 객체 지향 디자인을 할 수 있을까요?
네, 적어도 안 지키는 것보단 나을거라고 생각합니다.
SOLID 5원칙을 지키면 어떤 게 좋은데요?
유연하고 확장성 있는 객체를 설계하여, 똑똑한 소프트웨어를 만들 수 있습니다. (흔히 말하는 후발 주자로 들어온 개발자가 똥을 치워야 하는 일이 줄어드는!?)
SRP(Single responsibility principle) 단일 책임 원칙
객체(Class) 는 단 하나의 책임을 가진다.
이는 클래스의 역할과 책임에 너무 많은 것을 부여하려고 하면 안된다 라고 해석해도 될 것 같습니다.
class 계산 {
public int 사칙연산(int args...) {
}
}
위 와 같이 계산 클래스가 있다고 가정해봅시다. 이 상태의 계산 클래스는 오직 사칙연산 기능만을 책임지고 있는 것을 볼 수 있습니다.
그런데 이 때 만약 이 프로그램이 대대적인 수정이 필요하게 되었다면? 계산 클래스가 수정 될 만한 사유는 사칙연산 메소드에 관련 된 문제 뿐이라고 개발자는 쉽게 판단할 수 있습니다. 이처럼 단일 책임 원칙은 객체(Class)의 목적을 명확히 함으로써 구조가 난잡해지거나 수정사항이 불필요하게 넓게 퍼지는 것을 예방하고 기능을 명확히 분리할 수 있게 합니다.
OCP(Open-Closed Principle) 개방-폐쇄 원칙
객체(Class)는 확장에 대해서는 개방적이고 수정에 대해서는 폐쇄적이어야 한다
class 유닛 {
// 공통필드...
private String 이름;
private Integer 체력;
private Double 속도;
public void 이동(int x, int y) {
}
// ...
}
위 와 같이 스타크래프트의 유닛을 객체로 디자인하고 있습니다. 이런저런 공통사항을 생각하며 메소드와 필드를 정의하고, 이 중에는 이동 메소드도 있습니다. 이동 메소드는 단순히 이동 위치를 매개변수로 받아, 유닛 속도에 따라서 대상 위치까지 이동시키는 메소드 입니다. 그런데 브루들링 같은 유닛은 이동시 그냥 이동되지 않고 기묘하게 움직이며 이동 해야 합니다.
이럴 때 유닛 클래스의 이동 메소드를 수정하는 것이 아니라, 서브클래스로서 브루들링 이라는 유닛을 상속받는 클래스를 생성한 후 브루들링만의 이동메소드를 재 정의 해주어야 합니다.
class 브루들링 extends 유닛 {
@Override
public void 이동(int x, int y) {
//...
}
}
이렇게 하면 기존의 유닛 클래스의 이동 메소드는 수정할 필요 없고(수정 폐쇄), 브루들링 클래스의 이동 메소드를 재정의(확장성 개방) 하여 개방-폐쇄 원칙을 지킬 수 있습니다.
LSP (Liskov Substitution Principle) 리스코프 치환 원칙
서브 클래스는 언제나 슈퍼클래스를 대체할 수 있다
is-a 관계가 망가지지 않아야 하는 구조를 말합니다.
is-a 관계란? 해석 되는대로 “~는 ~이다.” 가 성립이 되는 관계를 뜻 합니다.
예를 들어 위 OCP에서 브루들링은 유닛이다. 가 성립하고 있습니다. 이처럼 상속관계를 is-a 로 표현했을 때 어색하지 않을 것을 is-a 관계가 성립된다고 볼 수 있습니다.
또한 업캐스팅이 가능한 상속 관계여야 합니다.
브루들링 b = new 브루들링();
유닛 u = (유닛) b;
ISP (Interface Segregation Principle) 인터페이스 분리 원칙
클라이언트가 자신이 이용하지 않는 메서드에 의존하지 않아야 한다
얼핏 보면 OCP와 비슷하면서도 다른 원칙인데, 또 스타크래프트 도메인을 가지고 예를 들어보겠습니다.
interface 공격 {
공중공격();
지상공격();
}
class 저글링 extends 유닛 implements 공격 {
@Override
공중공격() {} // 쓸모없음
@Override
지상공격() {}
}
class 발키리 extends 유닛 implements 공격 {
@Override
공중공격() {}
@Override
지상공격() {} // 쓸모없음
}
위와 같은 유닛이 공격할 수 있는 메소드를 가지고 있는 공격 인터페이스가 설계 되어 있다면 공격이 가능한 유닛은 위 인터페이스를 구현할 것입니다. 그런데 만약 저글링과 같은 지상 공격만 할 수 있거나, 발키리와 같이 공중공격만 하는 한가지의 공격만 할 수 있는 유닛 타입에서는 강제적으로 다른 타입의 공격메소드도 구현받아야 할 것입니다. 그렇다면 이 구조는 ISP가 지켜지지 않은 구조 이므로 인터페이스 설계를 다시 해야 합니다. 일반적인 방법으론 인터페이스 역할에 맞게 분리하는 방법이 있습니다.
그렇다고 너무 잘게 쪼개는 것은 비권장되는 방법이라고 합니다. (역할의 기준을 명확하게 나눌 수 있을때만 분리하는 것이 좋을 것 같습니다.)
DIP (Dependency Inversion Principle) 의존성 역전 원칙
추상성이 높고 안정적인 고수준의 클래스는 구체적이고 불안정한 저수준의 클래스에 의존해서는 안된다
고수준: 의미있는 단일 기능
저수준: 고수준을 구현하기 위해 필요한 하위 기능들
위 말 자체로는 되게 추상적이고 바로 와닿지 않아서 제일 이해하기 힘든 부분이었는데, 타 블로그를 많이 서치해보면서 이해하게 되었습니다.
“의존관계를 가지게 하려거든, 자주 변하는 대상 보다는 자주 변하지 않을 대상에게 가지게 하자”
여기서 자주 변할 대상과 자주 변하지 않을 대상은 이렇게 구분할 수 있습니다.
자주 변할 대상: 구체적인 방식, 사물
자주 변하지 않을 대상: 정책, 전략, 흐름 또는 개념같은 추상적인 것
“나는 김치찌개를 먹는다”, “나는 된장찌개를 먹는다” 등 먹는다 라는 사실 자체보다는 먹을 대상에 대한 부분이 변화를 일으킬 가능성이 높음
DIP를 만족시키는 설계를 하기 위해서는 인터페이스나 추상클래스는 자주 변하지 않을 대상으로 모델링 하는게 좋고,
구현체 클래스는 자주 변할 수 있는 대상을 타겟으로 잡는 것이 좋습니다.
DIP가 만족될 시 비로소 DI(의존성 주입)를 적극 활용하여 유연한 소프트웨어를 만들 수 있는 기반을 가지게 할 수 있습니다.
DI는 객체 간에 의존성을 최대한 줄이고 외부에서 객체를 생성하여 주입 해줌으로써 변경이 잦은 소프트웨어라 하더라도 의존 관계를 무너뜨리지 않고 수정할 수 있게 합니다. (이미 우리는 DI를 활용하여 프로그래밍 하고 있습니다. @Autowired
필드 주입, 생성자 파라미터 주입 등)