[OOP] 객체 지향 설계 5원칙: SOLID
이전의 “객체지향의 특성들” 글에서는 객체지향 프로그래밍에 대한 개념과 주요 특징들을 서술하였다. 여기에 더해, 객체지향 설계 5원칙인 SOLID 원칙도 존재한다. 이 글에서는 이러한 SOLID 원칙에 대해 알아보겠다.
SOLID 원칙은 5가지 원칙들의 앞글자들을 따 지어진 이름인데, 이 각각의 원칙들이 서로에게 영향을 끼치기도 하고, 또한 객체지향 특징인 추상화, 다형성 등의 개념들과도 연관되어 있다. 이러한 SOLID 원칙은 디자인 패턴에서도 근간이 된다고 하니 더더욱 잘 알아둘 필요가 있을 것이다.
이 글에서는 예시 코드들을 자바로 사용하여 보이겠지만, 그렇다고 해서 이 글에서 소개할 SOLID 원칙이 자바라는 언어만의 것이라는 오해를 해선 안된다. 객체지향 프로그래밍 자체가 특정 프로그래밍 언어에 종속된 개념이 아닌 좀 더 일반적, 포괄적, 이론적인 개념인 것처럼 SOLID 원칙도 그러하기에 객체지향 프로그래밍을 지원하는 어떠한 언어든지 이러한 원칙들을 얼마든지 적용할 수 있을 것이다.
SOLID 원칙 개요
SOLID 원칙은 객체지향 설계 시 지켜야할 5가지 소프트웨어 개발 원칙들을 말한다. 각각의 원칙들은 다음과 같다.
- SRP (Single Responsibility Principle) : 단일 책임 원칙
- OCP (Open - Closed Principle) : 개방 - 폐쇄 원칙
- LSP (Liskov Substitution Principle) : 리스코프 치환 원칙
- ISP (Interface Segragation Principle) : 인터페이스 분리 원칙
- DIP (Dependency Inversion Principle) : 의존 역전 원칙
이러한 SOLID 원칙은 객체지향 프로그래밍을 통해 개발 시 필요에 따라 기능을 확장하면서도 동시에 코드의 복잡성을 불필요하게 늘리지 않게끔하여 결과적으로 유연한 개발과 유지보수 관리의 편리성을 제공한다.
위와 같이 5가지 원칙들을 하나로 모아 SOLID 원칙으로 부르지만, 그렇다고 해서 해당 원칙들을 어떤 특정한 순서대로 적용해야한다든가, 5가지 원칙 모두 하나도 빠짐없이 무조건 적용해야한다든가 하지는 않는다.
SRP (Single Responsibility Principle) : 단일 책임 원칙
단일 책임 원칙은 객체 또는 클래스는 단 하나의 책임만 가져야 한다는 원칙이다. 여기서의 “책임”은 곧 “기능”이라고 봐도 된다. 즉, 하나의 객체의 코드를 변경하려는 이유가 단 하나여야만한다는 것이다.
예를 들어 하나의 객체가 데이터(상태)를 가지고 비즈니스 로직을 처리하는 역할을 하면서 그와 동시에 처리 완료된 데이터를 화면에 예쁘게 출력하는 기능까지 담당하면 이는 SRP 원칙을 위배한 것이라 볼 수 있다. 비즈니스 로직에 변경을 줘야할 때에도, 화면 출력 기능에 변경을 줘야할 때도 모두 하나의 객체를 대상으로 수행해야하기 때문이다. 이로 인해 데이터 및 비즈니스 로직만을 담당하는 Model, 사용자에게 보여줄 화면을 출력하는 View, 그리고 HTTP 요청의 종류를 분석하고 어떤 Model에게 데이터 처리를 맡기고 어떤 View에게 화면 출력을 담당하게 할 것인지 결정하는 지휘관 역할의 Controller 이 3개로 분리하여 웹 앱을 제작하는 방식인 MVC 패턴도 어떻게 보면 이러한 SRP 원칙을 지키기 위해 각 계층들이 분리되었다고 볼 수도 있는 것이다.
만약 하나의 객체에 둘 이상의 여러 책임들을 맡기게 된다면 다음의 문제점들이 예상된다.
- 둘 이상의 기능들이 하나의 객체에 포함되어 있으므로 해당 기능들이 서로 복잡하게 얽힐 수 있고, 이는 스파게티 코드로도 이어질 수도 있다.
- 이로 인해 하나의 기능만 수정하려고 해도 의도치 않게 다른 기능까지 건드려버려 예상치 못한 오류나 버그를 발생시킬 수 있다.
- 둘 이상의 기능들이 하나의 객체에 포함되어 있다보니 하나의 기능을 수정해보니 다른 기능을 수정해야하고, 또 다른 기능을 수정해야하는 일종의 연쇄효과가 발생할 수 있고, 이는 비효율적이면서도 생산성을 저해하는 요인이 된다.
따라서 하나의 객체에는 하나의 기능만 담당하도록 하는 것이 좋은 것이다. 그러나 실무에서는 어디서 어디까지를 “하나의 기능”으로 구분할지 쉽지는 않다고 한다. 이는 전적으로 개발자의 역량에 달려 있다.
이러한 SRP 원칙을 적용할 때에는 다음과 같은 주의사항들을 참고해야한다.
- 클래스명을 지을 때 어떠한 기능을 담당하는지 알 수 있게 작성하는 것이 좋다.
- 필자 개인적인 생각으로, 이로 인해 클래스명에 “And”와 같은 단어들은 사용하지 않도록 하는 것이 좋을 것 같다. “ThisAndThat”과 같이 지으면 그 자체로 하나의 클래스가 두 개의 기능을 담당하고 있다고 봐도 무방할 것이기 때문이다.
- 책임을 아무렇게나 분리하지 말고, 결합도와 응집도를 생각하면서 분리한다.
- 응집도는 프로그램을 구성하는 요소들이 얼마나 뭉쳐져 있는지를 나타내는 척도.
- 결합도는 구성 요소들이 서로 어느 정도의 의존성을 보이는지를 나타내는 척도.
- 좋은 프로그램은 응집도를 높게, 결합도를 낮게 설계하는 것이다.
- 반면, 과한 책임 분할로 인해 프로그램 전반에 이곳저곳에 책임이 여러 군데로 파편화되어있는 경우, 일명 “산탄총 수술”을 통해 그 응집력을 높이는 작업도 필요하다.
여기서 “산탄총 수술”이라는 것은 말 그대로 신체 여러 군데에 파편화되어 박힌 샷건 총알을 제거해야하는 수술처럼, 하나의 책임이 여러 클래스에 분산되어 있어 하나의 기능 변경을 위해 여러 군데에서 코드를 수정해야하는 구조를 의미한다. 로깅, 보안 및 인증, 트랜잭션과 같은 부가 기능들이 이 예에 해당된다. 이 기능들은 자칫 여러 모듈들에서 필요하단 이유로 각 모듈마다 똑같은 부가 기능들을 복사, 붙여넣기 하는 것마냥 중복 정의하여 사용하기 쉽상이다. 따라서 이러한 부가 기능들은 하나로 모듈화하여 필요한 곳에서 가져다 쓰도록 하는 것이 좋다. 관점 지향 프로그래밍(AOP)이 이를 해결하는 예라고 볼 수 있다. 관점 지향 프로그래밍에 대해선 이전에 필자가 작성한 글인 “Spring Framework 개요” 의 “관점 지향 프로그래밍 (AOP, Aspect Oriented Programming)” 파트를 참고하면 되겠다.
OCP (Open - Closed Principle) : 개방 - 폐쇄 원칙
OCP 원칙은 확장에는 열려(Open) 있으며, 수정에는 닫혀(Closed)있어야 한다는 원칙이다. 여기서 “확장에는 열려있어야 한다”라는 것은 새로운 기능의 추가가 필요할 때마다 객체를 확장하기 쉽게 하도록 만드는 것을 의미한다. 또한 “수정에는 닫혀있어야 한다”라는 것은 새로운 기능을 추가할 때 기존의 객체들을 수정하는 일을 최소화하여야한다는 뜻이다. 즉, 새로운 기능을 추가할 때 추가 자체가 쉽도록 하면서도 동시에 기존에 존재하는 객체(코드)를 수정해야하는 일이 최소화되어야한다는 원칙인 것이다.
OCP 원칙을 지키려면 어떻게 해야할까? 필자가 이전에 작성한 “객체지향의 특성들” 글에서 소개한 예제 5-1 ~ 5-10을 참고하면 알 수 있을 것이다. 해당 예제들을 여기에 다시 작성해보면 다음과 같다.
마피아 게임을 만들어본다고 가정하겠다. 이를 위해 우선 “마피아”와 “시민”을 표현하는 객체들을 각각 정의하였다.
package mafiaGame.characters;
/**
* 마피아 클래스.
*/
public class Mafia {
private String role = "mafia";
private String nickname = "Javas";
public String getNickname() {
return nickname;
}
/**
* 마을에서 방출할 사람을 투표한다.
* @param targetName 투표할 대상의 닉네임
*/
public void vote(String targetName) {
System.out.println(nickname + "님이 " + targetName + "님에게 투표하였습니다.");
}
/**
* 자신의 정체를 공개한다.
*/
public void revealMySelf() {
System.out.println(nickname + "님의 정체는 " + role + "이었습니다!");
}
}
예제 1-1.
package mafiaGame.characters;
/**
* 시민 클래스
*/
public class Citizen {
private String role = "citizen";
private String nickname = "kimquel";
public String getNickname() {
return nickname;
}
/**
* 마을에서 방출할 사람을 투표한다.
* @param targetName 투표할 대상의 닉네임
*/
public void vote(String targetName) {
System.out.println(nickname + "님이 " + targetName + "님에게 투표하였습니다.");
}
/**
* 자신의 정체를 공개한다.
*/
public void revealMySelf() {
System.out.println(nickname + "님의 정체는 " + role + "이었습니다!");
}
}
예제 1-2.
그리고 이러한 캐릭터들을 가지고 마피아 게임을 일정한 순서대로 진행시킬 또 다른 코드를 다음과 같이 작성하였다.
package mafiaGame.logic;
import mafiaGame.characters.Citizen;
import mafiaGame.characters.Mafia;
/**
* 마피아 게임을 실행하는 간단한 로직
*/
public class MafiaTool {
void executeGame(Mafia mafia, Citizen citizen) {
mafia.vote(citizen.getNickname());
mafia.revealMySelf();
}
void executeGame(Citizen citizen, Mafia mafia) {
citizen.vote(mafia.getNickname());
citizen.revealMySelf();
}
}
예제 1-3.
그리고 실제로 이 게임을 실행시킬 로직을 main 메서드에 다음과 같이 작성하였다.
package mafiaGame.logic;
import mafiaGame.characters.Citizen;
import mafiaGame.characters.Mafia;
public class Main {
public static void main(String[] args) {
MafiaTool mTool = new MafiaTool();
Mafia mafia = new Mafia();
Citizen citizen = new Citizen();
mTool.executeGame(mafia, citizen);
mTool.executeGame(citizen, mafia);
}
}
예제 1-4.
위 코드는 그 자체로는 문제 없이 잘 작동할 것이다. 문제는 따로 있다. 바로 새로운 캐릭터를 추가하는 것이다. 새로운 캐릭터를 추가할 때마다 앞선 예제 1-3에서 보인 MafiaTool 객체의 코드는 그만큼 길어질 것이다.
package mafiaGame.logic;
import mafiaGame.characters.Citizen;
import mafiaGame.characters.Doctor;
import mafiaGame.characters.Mafia;
import mafiaGame.characters.Police;
/**
* 마피아 게임을 실행하는 간단한 로직
*/
public class MafiaTool {
void executeGame(Mafia mafia, Citizen citizen) {
mafia.vote(citizen.getNickname());
mafia.revealMySelf();
}
void executeGame(Citizen citizen, Mafia mafia) {
citizen.vote(mafia.getNickname());
citizen.revealMySelf();
}
// 신 캐릭터 추가로 인해 추가된 코드들
void executeGame(Doctor doctor, Mafia mafia) {
doctor.vote(mafia.getNickname());
doctor.revealMySelf();
}
void executeGame(Police police, Mafia mafia) {
police.vote(mafia.getNickname());
police.revealMySelf();
}
}
예제 1-5.
package mafiaGame.logic;
import mafiaGame.characters.Citizen;
import mafiaGame.characters.Doctor;
import mafiaGame.characters.Mafia;
import mafiaGame.characters.Police;
public class Main {
public static void main(String[] args) {
MafiaTool mTool = new MafiaTool();
Mafia mafia = new Mafia();
Citizen citizen = new Citizen();
// 추가된 코드
Doctor doctor = new Doctor();
Police police = new Police();
mTool.executeGame(mafia, citizen);
mTool.executeGame(citizen, mafia);
// 추가된 코드
mTool.executeGame(doctor, mafia);
mTool.executeGame(police, mafia);
}
}
예제 1-6.
위 예제에서는 새로운 캐릭터 2개를 추가하였는데, 그만큼 MafiaTool 객체의 코드도 길어진 것을 볼 수 있다. 사실 조금 더 엄격히 하자면, MafiaTool 객체에는 이제 총 캐릭터의 수가 4개가 되었으니 모든 경우의 수를 만족시키기 위해 4 * 3 = 12개의 메서드들을 정의해야하는 셈이다. 추가된 캐릭터의 수보다 더 많은 양의 코드를 작성하게된 셈이다.
이러한 사례는 확장에 대해 수정이 닫혀있지 않은 명백한 사례라 볼 수 있을 것이다. 그러면 이러한 문제를 해결하고 OCP 원칙을 준수하게끔 하려면 어떻게 해야할까? 바로 추상화(구현 은닉)와 다형성(타입 은닉)을 이용하는 것이다.
먼저, 각 캐릭터들의 공통된 특성들을 뽑아 이를 추상화시킨다. 필자는 다음과 같은 인터페이스를 작성해보았다.
package mafiaGame.characters;
public interface Person {
String getNickname();
void vote(String targetName);
void revealMySelf();
}
예제 1-7.
그리고 각 캐릭터들의 구현체들은 이 인터페이스를 구현하도록 한다.
package mafiaGame.characters;
/**
* 마피아 클래스.
*/
public class Mafia implements Person {
private String role = "mafia";
private String nickname = "Javas";
@Override
public String getNickname() {
return nickname;
}
/**
* 마을에서 방출할 사람을 투표한다.
* @param targetName 투표할 대상의 닉네임
*/
@Override
public void vote(String targetName) {
System.out.println(nickname + "님이 " + targetName + "님에게 투표하였습니다.");
}
/**
* 자신의 정체를 공개한다.
*/
@Override
public void revealMySelf() {
System.out.println(nickname + "님의 정체는 " + role + "이었습니다!");
}
}
예제 1-8.
그러면 앞서 문제가 되었던 MafiaTool 클래스는 다음과 같이 확연히 그 코드가 줄어들게 된다.
package mafiaGame.logic;
import mafiaGame.characters.Citizen;
import mafiaGame.characters.Doctor;
import mafiaGame.characters.Mafia;
import mafiaGame.characters.Person;
import mafiaGame.characters.Police;
/**
* 마피아 게임을 실행하는 간단한 로직
*/
public class MafiaTool {
void executeGame(Person votePerson, Person targetPerson) {
votePerson.vote(targetPerson.getNickname());
votePerson.revealMySelf();
}
}
예제 1-9.
위와 같이 만들면, 아무리 새로운 캐릭터를 추가시킨다 하더라도 MafiaTool 클래스에는 아무런 수정사항을 가하지 않아도 된다. 이로 인해 해당 클래스는 말 그대로 “확장에는 열려있고, 수정에는 닫혀져 있는” OCP 원칙을 준수하게 되는 것이다.
이렇듯, OCP 원칙을 지키기 위해서는 추상화와 다형성을 이용하면 되는 것이다.
LSP (Liskov Substitution Principle) : 리스코프 치환 원칙
LSP 원칙은 “자식 타입은 언제나 부모 타입으로 교체할 수 있어야 한다”를 의미한다. 보통 다형성을 확보하기 위해 부모 클래스를 만든 뒤, 자식 클래스가 이를 상속받도록 구성할 것이다. 이 때, 자식 객체를 부모 타입으로 upcasting 하더라도 부모 클래스에 정의된 기능들이 본래 의도대로 잘 동작하도록 해야한다는 것이다. 즉, 부모 객체를 상속하여 자식 객체를 작성할 때 부모 객체의 원래 의도를 훼손시키면서까지 작성하면 안된다는 것이다. 부모 클래스와 자식 클래스 간의 일관성을 강조하는 원칙인 것이다.
이러한 LSP 원칙을 잘 지킨 사례로는 자바의 Collection 인터페이스이다. 필자가 예전에 작성했던 글인 “컬렉션 프레임워크 (Collection framework)” 글의 맨 첫 부분에도 소개했듯, 자바에는 Collection 인터페이스가 있고, 이를 상속받은 각각 List, Set이라는 하위 인터페이스들이 있으며, List에는 ArrayList, LinkedList 등의 구현체가, Set에는 TreeSet, HashSet 등의 구현체가 존재한다. 이 때, Collection collection = new ArrayList() 처럼 자식 구현체를 부모 타입의 변수가 참조하도록 하면, 중간에 collection 변수가 HashSet 이라는 전혀 다른 구현체를 참조하도록 하더라도 add , addAll 등의 메서드들을 원래 의도대로 문제없이 사용할 수 있다. 이는 Collection 이라는 최상위 인터페이스에 이미 해당 메서드들이 추상 메서드로 정의되어 있으며, 또한 각각의 구현체들이 이 최상위 인터페이스에 정의된 추상 메서드들을 그 원래 의도에 맞게끔 잘 오버라이딩하였기에 이렇게 문제없이 사용 가능한 것이다.
한 편 반대로 LSP 원칙을 위배한 사례로는 필자가 작성했던 이전 글인 “객체지향의 특성들” 의 “상속 (Inheritance)”에서 소개했던 HashSet 을 잘못 상속받은 사례로 설명할 수 있겠다.
기존에 자바에 존재하는 HashSet 클래스에 대해, 요소가 이 컬렉션에 추가될 때마다 해당 컬렉션에 요소들이 총 몇 번 추가되었는지 확인하기 위해 MySet 이라는 자식 클래스를 다음과 같이 작성한다고 해보자.
package inheritance;
import java.util.Collection;
import java.util.HashSet;
public class MySet<E> extends HashSet<E> {
private int addCount = 0;
public int getAddCount() {
return addCount;
}
@Override
public boolean add(E e) {
++addCount;
return super.add(e);
}
@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
}
예제 2-1.
import inheritance.MySet;
import lombok.extern.slf4j.Slf4j;
import java.util.Arrays;
@Slf4j
public class Main {
public static void main(String[] args) {
MySet<Integer> mySet = new MySet<>();
mySet.addAll(Arrays.asList(1, 2, 3));
mySet.add(10);
log.info(mySet.toString());
log.info(String.valueOf(mySet.getAddCount()));
// 예상 출력 결과: 4, 실제 출력 결과: 7
}
}
예제 2-2.
위 코드에서는 MySet 이 HashSet 을 상속받도록 하였으며, 기존의 HashSet 에 있던 add 메서드와 addAll 메서드를 오버라이딩하여 요소가 추가될 때마다 총 몇 번 추가되었는지 카운트하도록 커스텀하고 있다.
그런데 위 코드는 의도대로 동작하지 않는다. 위 예제 2-2의 주석에도 작성해놨듯, 원래 카운트가 4번 되어야 했는데 실제로는 7번 되었다고 나온다. 이러한 버그가 발생한 이유는 애초에 HashSet의 조부모 클래스인 AbstractCollection 클래스에 정의된 addAll 메서드가 내부에서 add 메서드를 사용하기 떄문이다.
public boolean addAll(Collection<? extends E> c) {
boolean modified = false;
for (E e : c)
if (add(e))
modified = true;
return modified;
}
예제 2-3.
이로 인해 새로운 카운팅 기능이 제대로 추가되지 못한 것이다. 이는 부모 클래스에 정의된 기능들을 제대로 파악하지 못한 채 이를 오버라이딩하여 발생한 문제이다. 이 문제를 해결하기 위해 기존의 상위 클래스의 해당 메서드들을 수정한다면 또 어떤 버그가 새로 발생할 지 모르는 일이고, 이는 다른 개발자들과 협업하는 상황이라면 원래 알고 있던 자바의 Collection 객체의 add , addAll 메서드의 작동 방식이 달라지기에 다른 개발자들과의 소통, 협업도 힘들어지는 길이다.
위 코드의 문제를 해결하려면 상속 대신 합성(Composition)을 이용하는 것이다.
public class MyNewSet<E> {
private int addCount = 0;
private HashSet<E> set = new HashSet<>();
public int getAddCount() {
return addCount;
}
public boolean add(E e) {
++addCount;
return set.add(e);
}
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return set.addAll(c);
}
}
예제 2-4.
위 코드로 변경하면 원래 출력되어야 할 값인 4가 잘 출력된다. 이는 상속 대신 합성을 이용하여 기존의 HashSet 내부 코드를 건드리지 않고 독립적으로 새 기능을 추가했기에 가능한 것이다.
위 사례에서 알 수 있었던 사실들을 정리하자면, LSP 원칙을 적용할 때 주의해야할 점은 다음과 같다.
- 부모 클래스의 메서드를 되도록 오버라이딩하지 않고, 새로운 기능 확장에만 집중하도록 한다.
- 애초에 LSP는 상속 관계에 대해 다루고 있으므로, 부모와 자식 객체 간 관계가 마치 “한국인은 사람이다”처럼 IS-A 관계일 때에만 상속 관계로 제한시켜야한다. 그 외에는 상속 대신 합성(Composition)을 이용한다.
이 관점에서 보면 앞선 예제 2-4는 위 두 사항을 모두 지키고 있다. 상속 대신 합성을 사용하였으며, 따라서 애초에 상속 자체를 하지 않았으므로 오버라이딩도 존재하지 않는 상황인 것이다.
위 사례에서 알 수 있듯, LSP 원칙을 위반하면 OCP 원칙도 위반할 수밖에 없게 된다. 카운트 기능이라는 새 기능을 추가하기 위해 부모 클래스의 add , addAll 메서드를 수정해야할 수도 있게 되듯, 새 기능 확장을 위해 기존 코드를 수정하게 될 수도 있기 때문이다.
이러한 LSP 원칙을 잘 준수하면 OCP 원칙을 지킬 수 있고, 또한 다형성이란 특징도 문제없이 잘 이용할 수 있게 될 것이다.
ISP (Interface Segragation Principle) : 인터페이스 분리 원칙
SRP가 클래스에 대한 단일 책임을 이야기한다면, ISP는 인터페이스에 대한 단일 책임을 이야기한다. 즉, 하나의 인터페이스에 너무 많은 추상 메서드들을 정의하지 않도록 해야하며, 그렇게 함으로써 하나의 인터페이스가 하나의 책임만 지도록 하는 것이다.
이러한 ISP 원칙을 무시하고 하나의 인터페이스에 너무 많은 추상 메서드들을 정의하면, 이 인터페이스를 구현하는 구현체 입장에서는 불필요한 추상 메서드까지 강제로 구현해야한다는 낭비가 발생한다. 또 이로 인해 이 구현체를 사용하는 클라이언트의 입장에서는 전혀 예상치 못한 에러 또는 버그가 발생할 수도 있는 것이다.
예를 들어 마피아 게임을 만든다고 해보자. 마피아, 시민, 의사 등 모든 캐릭터들이 사람이므로 Person이라는 인터페이스로 추상화한다고 해보자. 여기에는 낮에 다른 대상에게 투표할 수 있는 vote() , 캐릭터가 투표로 인해 죽거나 게임이 모두 끝났을 떄 자신의 정체를 공개하는 revealMySelf() 등의 추상 메서드들을 추가한다. 그런데 만약 이 인터페이스에 밤에 몰래 다른 대상을 죽이는 killAtNight() 와 같은 추상 메서드를 추가하면 문제가 된다. 마피아 캐릭터에게는 필요한 기능이지만, 시민, 의사 등의 다른 캐릭터들에게는 필요하지도 않고, 있어서도 안되는 기능이기 때문이다. 이 상황에서는 시민 등의 다른 구현체에서는 해당 메서드를 빈 껍데기로라도 구현을 해야해서 이는 낭비가 된다. 또한 클라이언트 입장에서도 시민 편 캐릭터 객체의 killAtNight() 메서드를 실행했음에도 아무런 기능이 수행되지 않거나 오류, 버그가 발생할 것인데, 이러면 왜 이런 메서드를 만든 건지 굉장히 의아해할 것이다. 즉, 구현체를 만든 개발자와 이 구현체를 사용하는 개발자 간의 협업도 제대로 되지 않는 상황이 발생할 수 있다는 것이다. 따라서 이러한 경우에는 차라리 Person 인터페이스와는 별도로 분리된 Killer 같은 별도의 인터페이스를 만든 후 이 인터페이스에 killAtNight() 추상 메서드를 넣도록 하고, 이 기능을 필요로 하는 Mafia 클래스만 이 인터페이스를 구현하도록 하는 것이다. 이러면 ISP 원칙을 지킬 수 있게 된다.
이러한 ISP는 인터페이스를 사용하는 클라이언트의 입장에서 분리하는 것이 좋다. 그렇게 해서 클라이언트의 목적과 용도에 딱맞는 인터페이스만을 제공하는 것이다.
ISP 원칙은 쉽게 말해 “어떤 기능이든지 모두 포함하는 범용 인터페이스가 아닌, 특정 목적에만 특화된 인터페이스로 분리하여 사용하자”란 뜻이다.
ISP 원칙을 위배하는 인터페이스는 맡은 책임이 단 하나가 아닌 여러개인 상태이다. 따라서 이를 구현하는 클래스도 자연스레 둘 이상의 책임을 맡게 되니 자연스레 SRP를 위배하게 된다. 그래서 ISP 원칙을 만족해야 SRP 원칙도 만족시킬 수 있다.
그러나 반대로 SRP 원칙을 만족시킨다고 해서 무조건 ISP 원칙도 자동으로 만족되는 것은 아니다. 이에 대한 예외가 있다. 예를 들어 게시판을 구현한다고 해보자. 게시판에는 글의 작성, 조회, 수정, 삭제의 CRUD 기능이 필요할 것이다. 그래서 이 4가지 기능들을 추상화한 메서드들을 모두 포함한 BulletinBoard 라는 인터페이스를 정의했다고 해보자. 이 인터페이스의 구현체도 이를 구현하기 때문에 CRUD의 4가지 기능 모두 있을 것이고, 이는 모두 “게시판”이라는 하나의 책임만을 맡게 되기 때문에 SRP 원칙을 만족시킨다. 하지만 이 게시판의 이용 주체가 “일반 사용자”와 “관리자”로 나뉘고, 글 삭제 기능은 오로지 관리자만 가능하도록 제한을 두고자 한다면 이는 엄연히 ISP 원칙 위반이라 볼 수 있다. 일반 사용자 클래스도 글 삭제 메서드까지 포함된 BulletinBoard 인터페이스를 강제로 구현해야 하기 때문이다. 따라서 이 경우, 글 삭제 기능만을 별도의 인터페이스로 분리하고, 일반 사용자 구현체에서 이 인터페이스까지 구현하도록 하는 것으로 변경해야 할 것이다. 그리고 해당 구현체에서는 기존에 작성한 글 삭제 기능을 삭제해야할 것이다. 이렇게 보면, “게시판”이라는 단일 책임만을 구현체가 맡게 된 것 같지만, 사실 “일반 사용자의 권리”와 “관리자의 권리”라는 이 두 가지 책임들을 모두 구현하고 있던 셈이었다. 이는 처음부터 글 삭제 권리를 사용 주체에 따라 나눌 것을 계획하고 있었다면 애초부터 SRP를 만족했다고 보기 힘든 것이고, 기존 계획에서는 없었으나 요구사항의 변화로 인해 사용 주체가 나뉘고 글 삭제 권리도 특정 사용자애게만 주도록 요구사항을 추가한 것이라면, 기존에는 SRP을 만족했지만 새 요구사항 추가로 인해 SRP가 깨진 것이라 볼 수 있겠다.
정리하자면, ISP를 만족시키면 SRP도 만족시키지만, 그 역인 SRP를 만족한다고 해서 반드시 ISP도 만족한다는 보장은 없다는 것이다.
한 편, ISP 원칙 적용 시 주의해야할 점은, 인터페이스는 가급적 한 번만 분리, 구성하도록 하는 것이다. 거의 대부분의 구상 클래스들이 몇몇 인터페이스를 구현하는 의존적인 관계가 생기기 때문에 인터페이스 자체를 분리하면 이를 구현하던 수많은 구상 클래스들도 그에 맞게 변경해야할 수도 있는 것이다. 이로 인해 인터페이스는 사실상 설계 단계에서부터 기능 변화를 염두해두고 설계해야한다는 어려움이 있기도 하다.
DIP (Dependency Inversion Principle) : 의존 역전 원칙
DIP 원칙은 어떤 객체가 다른 객체를 참조하여 사용해야할 때 즉, 두 객체 간의 의존성이 필요할 때, 해당 객체를 직접 참조하지 말고, 그 객체의 상위 요소(추상 클래스나 인터페이스)로 참조하라는 것이다. 즉, 구현체가 아닌 추상 클래스 또는 인터페이스 타입으로 참조하라는 것이다.
정보 은닉의 관점에서는 구현 은닉을 통해 구현체가 추상 클래스 또는 인터페이스를 구현하게끔 한 후, 이를 타입 은닉으로 활용하라는 것이다.
이는 두 객체 간의 느슨한 결합을 강조한 것으로, 느슨한 결합을 통해 언제든지 다른 의존성으로 교체할 수 있도록 하여 유연한 개발을 가능케 하기 위함이다. 이는 OCP로도 이어지는 점이라는 것도 알 수 있다.
위 설명을 통해 알 수 있는 것은, DIP 원칙을 지키려면 역시 추상화와 다형성을 이용해야하며, 이를 통해 OCP 원칙도 지킬 수 있다는 점이다.
앞서 OCP 원칙을 설명할 때 소개한 마피아 게임 예제에서도 DIP 원칙을 살펴볼 수 있다. 예제 1-5에서의 MafiaTool.executeGame() 메서드에서는 구체적 타입의 캐릭터 객체들을 참조하는 방식으로 하니 새 캐릭터 추가 시 기존 코드도 더 많이 수정해야한다는 단점이 있음을 보았다. 그런데 예제 1-8과 같이 기존 구현체들을 모두 Person 이라는 인터페이스를 구현하도록 하고, MafiaTool.executeGame() 메서드의 매개변수에서는 구현체 대신 Person 이라는 상위 요소를 참조함으로써 새로운 캐릭터가 추가되더라도 MafiaTool 클래스 내부를 전혀 건드리지 않아도 된다는 장점을 살펴보았다. DIP 원칙을 적용하였기에 OCP 원칙도 자연스레 지켜진 셈이다.
정리
지금까지 기술한 내용들을 정리하면 다음과 같다.
- SOLID 원칙은 객체지향 설계 5원칙이다. 유연한 개발과 편리한 유지보수성을 확보할 수 있다.
- SRP(Single Responsibility Principle, 단일 책임 원칙) - 하나의 클래스는 하나의 책임만 맡아야 한다.
- OCP (Open - Closed Principle, 개방 - 폐쇄 원칙) - 기능의 확장에는 열려있고, 수정에는 닫혀있어야 한다.
- 이 원칙을 지키기 위해서는 추상화와 다형성을 이용한다.
- LSP (Liskov Substitution Principle, 리스코프 치환 원칙) - 자식 타입은 언제나 부모 타입으로 교체할 수 있어야 한다.
- 자식 클래스는 부모 클래스의 본 기능을 훼손해서는 안된다는 원칙.
- 이 원칙을 위배하면 부모 클래스에도 수정을 가해야할 수 있기에 OCP 원칙도 자연스럽게 위배하게 된다.
- 이 원칙을 지키기 위해, IS-A 관계인 경우에만 상속을 이용하도록 하고, 나머지 경우에는 되도록 합성(Composition)을 이용한다. 그리고 상속 관계를 이용하는 경우, 되도록 부모 메서드를 오버라이딩하지 않도록 하고 자식 클래스의 확장에만 집중한다.
- ISP (Interface Segragation Principle, 인터페이스 분리 원칙) - 하나의 인터페이스는 하나의 책임만 맡아야 한다.
- ISP 원칙을 만족하면 SRP도 만족할 수 있다. 그러나 SRP를 만족한다고 해서 ISP도 만족한다는 보장은 없다. 이는 초기 설계 시 각각의 책임(기능)들을 잘 구분하지 못했거나, 요구사항이 변하여 이전에는 하나의 책임이라고 여겼던 것이 깨지게 되는 경우에 발생할 수 있다.
- 보통 인터페이스를 구현하는 구상 클래스들이 많기 때문에 인터페이스 분리는 되도록 딱 한 번만 하도록 하는 것이 좋다.
- DIP (Dependency Inversion Principle, 의존 역전 원칙) - 한 객체가 다른 객체를 참조할 때 그 객체의 상위 요소, 즉 추상 클래스 또는 인터페이스로 참조하라는 원칙.
- 이를 위해 구상 클래스는 추상 클래스 또는 인터페이스(추상화)를 구현하도록 강제하여 구현 은닉을 이용하고, 다른 객체가 이 객체를 참조할 때에는 추상 클래스 또는 인터페이스 타입으로 참조하도록 하여 타입 은닉(다형성)을 이용한다.
- 두 객체 간 느슨한 결합을 강조한 원칙으로, 이로 인해 유연한 개발을 할 수 있게 된다.
- 상위 요소를 구현하는 또 다른 구현체로 언제든지 바꿀 수 있어 OCP 원칙도 자연스레 만족하게 된다.
References
[1] 내 블로그 - 객체지향의 특성들
[2] 💠 객체 지향 설계의 5가지 원칙 - S.O.L.I.D
[3] [Java] OOP(객체지향 프로그래밍) 설계 원칙 - Heee’s Development Blog
[5] 객체 지향 프로그래밍/원칙
[7] 내 블로그 글 - AOP
[8] 냄새 8. 산탄총 수술(Shotgun Surgery)
This content is licensed under
CC BY-NC 4.0
댓글남기기