3.3 리스코프 치환 원칙
SOLID 원칙 중 리스코프 치환 원칙에 대해 설명한다. 사실 리스코프 치환 원칙은 매우 느슨한 설계 원칙이기 때문에 정상적인 상황에서 우리가 작성하는 코드는 이 설계 원칙을 위반하지 않는다. 따라서 이 원칙은 이해도 쉽고 실제로 적용하는 것도 쉽다.
3.3.1 리스코프 치환 원칙의 정의
리스코프 치환 원칙은 1996년에 MIT 바바라 리스코프 교수에 의해 제안된 원칙이다.
리스코프는 이 원리에 대해 만약 S가 T의 하위 유형인 경우 T유형의 객체는 프로그램을 중단하지 않고도 S 객체로 대체될 수 있다고 설명한다.
로버트 마틴은 “기본 클래스에서 참조 포인터를 사용하는 함수는 특별히 인지하지 않고도 파생 클래스의 객체를 사용할 수 있어야 한다”라고 설명한다.
두 설명을 조합하여 다음과 같이 정의할 수 있다.
- 하위 유형 또는 파생 클래스의 객체는 프로그램 내에서 상위 클래스가 나타나는 모든 상황에서 대체 가능하며, 프로그램이 원래 가지는 논리적인 동작이 변경되지 않으며 정확성도 유지된다.
[코드 예제]
상위 클래스인 Transport 클래스는 org.apach.http 라이브러리의 HttpClient 클래스를 사용하여 네트워크 데이터를 전송하고, 하위 클래스인 SecurityTranspoter 클래스는 상위 클래스인 Transporter 클래스를 상속받아 보안 인증 정보인 apiId와 appToken을 데이터 전송 시 추가한다.
public class Transporter { private HttpClient httpClient; public Transporter(HttpClient httpClient) { this.httpClient = httpClient; } public Response sendRequest(Request request) { // ...httpClient를 통해 발송 요청하는 코드 생략... } } public class SecurityTransporter extends Transporter { private String appId; private String appToken; public SecurityTransporter(HttpClient httpClient, String appId, String appToken) { super(httpClient); this.appId = appId; this.appToken = appToken; } @Override public Response sendRequest(Request request) { if (StringUtils.isNotBlank(appId) && StringUtils.isNotBlank(appToken)) { request.addPayload("app-id", appId); request.addPayload("app-token", appToken); } return super.sendRequest(request); } } public class Demo { public void demoFunction(Transporter transporter) { Request request = new Request(); // ... request 객체에 값을 설정하는 코드 생략... Response response = transporter.sendRequest(request); //…일부 코드 생략... } } // 리스코프 치환 원칙 /* 아래 코드에서 하위 클래스는 리스코프 치환 원칙을 따르기 때문에 해당 객체는 상위 클래스 객체가 나타나는 모든 위치에서 대체가 가능함 의조했던 대로 논리적 동작이 변경되지 않으며 정확성도 그대로 유지된다. */ Demo demo = new Demo(); demo.demofunction(new SecurityTrasnpoter(/*생략*/));
3.3.2 리스코프 치환 원칙과 다형성의 차이점
리스코프 치환 원칙은 객체지향 특성인 다형성을 단순하게 이용한 것이 아닐까 하는 의구심이 들 수 있다.
만약 그렇다면 다형성과 리스코프 치환 원칙은 같은 것이라고 봐도 되는 것일까?
- 앞의 코드와 리스코프 치환 원칙의 정의를 기준으로 살펴보면, 리스코프 치환 원칙과 다형성은 보기에는 비슷하지만 실제로는 완전히 다른 의미를 담고 있다.
앞의 예제 코드를 이용하여 살펴 보자. 그 전에 SecurityTranspoter 클래스에서 sendRequest()메서드를 약간 수정할 필요가 있다.
수정 전에는 appId 속성이나 appToken 속성이 설정되지 않았다면 보안 검증을 하지 않았지만, 수정한 코드에서는 appId 속성이나 appToken 속성이 설정되지 않으면 예외가 발생한다.
수정 후 코드
수정된 코드에서는 상위 클래스 Transporter 클래스의 객체가 demofunction()메서드로 전달되는 경우에는 어떤 예외도 발생시키지 않지만 하위 클래스의 객체로 전달되면 예외를 발생시킬 수 있다.
코드 내에서 명시적으로 try-catch 처리를 하지 않아 실행 시간 예외가 발생하지만, 하위 클래스가 상위 클래스로 대체하면서 논리적 동작이 변경되었다.
수정된 코드는 여전히 Java의 다형성 구문을 통해 동적으로 상위 클래스인 Transporter 클래스를 하위 클래스인 SecurityTransPoter 클래스로 대체할 수 있으며 오류가 발생하지 않는다.
그러나 설계관점 에서 살펴보면 SecurityTraspoter 클래스의 설계는 리스코프 치환 원칙을 따르지 않는다.
다형성은 코드를 구현하는 방식에 해당하지만, 리스코프 치환 원칙은 상속 관계에서 하위 클래스의 설계 방식을 설명하는 설계 원칙에 해당한다.
다시말해 상위 클래스를 대체할 때 프로그램의 원래 논리적 동작이 변경되지 않고 프로그램의 정확성이 손상되지 않도록 해야한다는 원칙을 제시하고 있는 것이다.
@Override public Response sendRequest(Request request) { if (StringUtils.isBlank(appId) || StringUtils.isBlank(appToken)) { throw new Exception(); } request.addPayload("app-id", appId); request.addPayload("app-token", appToken); return super.sendRequest(request); }
3.3.3 리스코프 치환 원칙을 위반하는 안티 패턴
리스코프 원칙에는 좀 더 이해하기 쉬운 설명 방식이 있는데, 바로 계약에 따른 설계 라는 표현이다.
하위 클래스를 설계할 때는 상위 클래스의 동작 규칙을 따라야 한다. 상위 클래스는 함수의 동작 규칙을 정의하고 하위 클래스는 함수의 내부 구현 논리를 변경할 수 있지만 함수의 원래 동작 규칙은 변경할 수 없다.
여기서 말하는 동작 규칙에는 함수가 구현하기 위해 선언한것, 입력, 출력, 예외에 대한 규칙, 주석에 나열된 모든 특수 사례 설명이 포함된다. 사실 여기에서 연급된 상위 클래스와 하위 클래스 간의 관계는 인터페이스와 구현 클래스 간의 관계로 대체될 수 있다.
규칙을 위반하는 코드
- 하위 클래스가 구현하려는 상위 클래스에서 선언한 기능을 위반하는 경우
- 예를 들어 상위 클래스가 주문 정렬을 위한 sortOrderByAmount() 함수를 정의하여 금액에 따라 작은 것부터 큰 것 순서대로 주문을 정렬하게 구성되어 있을 때, 하위 클래스에서 생성 날짜에 따라 주문을 정렬하도록 sortOrdersByAmount() 함수를 재정의 하는 경우이다. 그러면 이 하위 클래스의 설계는 리스코프 치환 원칙을 위반하게 된다.
- 하위 클래스가 입력, 출력 및 예외에 대한 상위 클래스의 계약을 위반하는 경우
- 어떤 함수의 계약에 따르면 상위 클래스에서 작업 시 오류가 발생하면 null을 반환하며 값을 얻을 수 없을 때는 빈 컬렉션을 반환하지만, 하위 클래스에서 이 함수를 재정의하면서 구성이 변경되어 null 대신에 예외를 발생시키고 값을 얻을 수 없을 때 null을 반환하는 경우 위반된다.
- 다른 예로 상위 클래스에서는 입력 시 모든 정수를 받아들일 수 있지만, 하위 클래스에서 이 함수를 재정의하면서 양의 정수만 받을 수 있도록 변경되고, 음의 정수일 경우 예외가 발생시킨다면 이 부분 또한 위반하는 것이다.
- 마지막으로 상위 클래스에서 던지는 예외가 하나일 경우 하위 클래스에서도 동일한 예외만 발생시킬 수 있으며 이를 지키지 않는다면 위반된다.
- 하위 클래스가 상위 클래스의 주석에 나열된 특별 지침을 위반하는 경우
3.4 인터페이스 분리 원칙
로버트 마틴은 인터페이스 분리 원칙을 다음과 같이 정의하고 있다.
클라이언트는 필요하지 않은 인터페이스를 사용하도록 강요되어서는 안된다.
여기서 클라이언트는 인터페이스 호출자나 사용자로 이해하면 된다.
사실, 인터페이스라는 용어는 소프트웨어 개발의 여러 가지 상황에서 사용될 수 있다. 인터페이스는 추상적인 규칙의 집합으로 간주하ㅣ거나, 시스템끼리 서로 호출하는 API를 구체적으로 지정할 수도 있으며, 객체지향 프로그래밍 언어의 인터페이스를 의미하기도 한다.
인터페이스 분리 원칙에서 이야기하는 인터페이스는 크게 세 가지중 하나를 의미함
- API나 기능의 집합
- 단일 API 또는 기능
- 객체지향 프로그래밍의 인터페이스
3.4.1 API나 기능의 집합으로서의 인터페이스
다음 예제 코드는 마이크로 서비스 사용자 시스템은 등록, 로그인, 사용자 정보 획득과 같은 사용자 관련 API 집합을 제공한다.
public interface UserService { boolean register(String cellphone, String password); boolean login(String cellphone, String password); UserInfo getUserInfoById(long id); UserInfo getUserInfoByCellphone(String cellphone); } // 구현체 생략
- 현재 백그라운드 관리 시스템에서 사용자 삭제 기능이 추가되여아 한다고 판단했고 그에 따라 기능이 추가되기를 원하고 있다.
- 이것을 구현하는 것이 매우 쉽다고 생각할 수 있다. delete하는 인터페이스만 추가하면 되기 때문이다. 하지만 이 방법은 문제를 해결할 수 있지만 일부 보안 위험이 드러나게 된다.
사용자 삭제는 신중하게 수행해야 하는 작업이므로 백그라운드 관리 시스템을 통해서만 수행되어야 하며, 인터페이스의 사용 범위는 백그라운드 관리 시스템으로 제한되어야 한다.
하지만 이 인터페이스에 넣기 되면 인터페이스를 사용하는 모든 곳에서 호출이 가능해진다. 이와같이 사용하면 실수로 인해 사용자 정보가 삭제될 가능성이 높아진다.
여기서 권장되는 솔루션 아키텍처 설계 수준에서 인터페이스 인증을 통해 호출을 제한하는 것이다. 다만 현재 인증 프레임워크 지원이 없다면 코드 설계 수준에서 인터페이스 오용을 방지하고 사용자 삭제를 위한 RestrictedUserService 인터페이스에 넣고 RestrictedUserService 인터페이스를 별도로 묶어 백그라운드 관리 시스템에서 제공할 수 있다.
이러한 방식을 통해 호출자는 필요한 인터페이스에만 의존하고, 필요하지 않은 인터페이스에는 의존하지 않을 수 있으며 결과적으로 분리 원칙을 만족시킬 수 있음
public interface RestrictedUserService { boolean deleteUserByCellphone(String cellphone); ~~~ } // 구현체 생략(프로바이더 개념)
3.4.2 단일 API나 기능으로서의 인터페이스
이번에는 단일 API나 기능으로서의 인터페이스를 다루는 방법에 대해 살펴본다. 이 경우에는 인터페이스 분리 원칙은 다음과 같이 적용될 수 있다.
API나 기능은 가능한 한 단순해야 하며 하나의 기능에 여러 다른 기능 논리를 구현하지 않아야 한다.
[예시 코드]
해당 예시 코드에서 count() 메서드는 최대, 최소, 평균 여러 다른 통계 함수를 포함하기에 단일하지 않다.
인터페이스 분리 원칙에 따라 count 메서드를 여러개의 작은 단위 메서드로 분할해야 하며 각각의 메서드 단위는 다른 함수를 포함하지 않는 독립적인 통계 기능을 제공해야 한다.
public class Statistics { private Long max; private Long min; private Long average; private Long sum; private Long percentile99; private Long percentile999; // ...생성자, getter, setter 메서드 생략... public Statistics count(Collection<Long> dataSet) { Statistics statistics = new Statistics(); // ...계산 코드 생략... return statistics; } }
그러나 다른 관점에서 보면 count() 메서드가 단일 책임이 아니라고 말하기 어려운데 결국 통계와 관련된 작업만 수행하기 때문이다. 앞에서 단일 책임 원칙을 언급할 때 특정 기능이 단일인지 여부를 판별하는 것이 시나리오에 따라 달라질 수 있다고 언급한 바가 있다.
3.4.3 객체지향 프로그래밍에서의 인터페이스
프로젝트에서 Reid’s, MySQL, Kafka 3가지 시스템이 연계되어 사용된다고 가정해보자. 각 시스템은 IP주소, 포트, 엑세스 제한 시간과 같은 설정 정보에 대응한다.
프로젝트의 다른 모듈에서도 이 정보를 사용할 수 있도록 각각의 Config 클래스를 구현하여 설정 정보를 메모리에 올릴 수 있도록 한 코드는 아래와 같다.
public class RedisConfig { private ConfigSource configSource; // 설정 센터(Zookeeper 등) private String address; private int timeout; private int maxTotal; // ..maxwaitMillis, maxIdle, minIdle 등 일부 설정 정보 생략... public RedisConfig(ConfigSource configSource) { this.configSource = configSource; } public String getAddress() { return this.address; } // get(), init() 메서드 등은 생략 public void update() { // configSource에서 address, timeout, maxTotal을 읽어온다. } } public class KafkaConfig { // ... } public class MysqlConfig { // ... }
그런데 갑자기 Redis, Kafka의 설정 정보 핫 업데이트를 지원하라는 새로운 요구사항이 전달되었다면?
핫 업데이트는 설정 센터에서 설정 정보가 변경되면 시스템을 다시 시작하지 않고도 최신 설정 정보를 메모리에 다시 올릴 수 있어야 함을 의미한다. 그러나 Redis, Kafka와 달리 모종의 이유로 MySQL은 설정 정보를 핫 업데이트 하면 안되는 상황이다.
이러한 요구사항을 만족시키기 위해 RedisConfig 클래스와 KafkaConfig 클래스의 update() 메서드에서 실행 간격 정보인 periodInSeconds 속성을 호출하여 일정 시간마다 반복하여 설정 정보를 업데이트하는 ScheduleUpdater 클래스를 구현한다.(코드 생략)
3.5 의존 역전 원칙
단일 책임 원칙과 개방 폐쇄 원칙은 이해는 비교적 간단하지만 실제로 사용하는데 어려움을 느끼며 반대로 의존 역전 원칙은 사용하기는 쉽지만 이해하기가 어렵다. 다음 질문에 답을 할 수 있는가?
- 의존 역전이 뜻하는 것은 어떤 대상 사이의 역전인가? 그리고 어떤 의존이 역전되는 것인가? 여기서 말하는 역전은 무엇인가?
- 종종 제어 반전과 의존성 주입이라는 두 가지 개념을 접할 수 있는데 이 개념은 의존 역전과 같은 개념에 속하는가? 그렇지 않다면 차이는 무엇인가?
- 스프링 프레임워크의 IoC는 앞에서 언급한 세 가지 개념과 어떤 관련이 있는가?
3.5.1 제어 반전
먼저 제어 반전에 대해 살펴보자 자바에 익숙한 경우 스프링 프레임워크의 IoC가 바로 떠오르겠지만, 여기서 다루는 제어 반전은 스프링과는 무관하다.
[예제 코드]
public class UserServiceTest { public static boolean doTest() { // ... } public static void main(String[] args) { // 이 코드는 프레임워크에 넣을 수 있음 if (doTest()) { System.out.println("Test succeeded."); } else { System.out.println("Test failed."); } } }
이 코드는 어떠한 테스트 프레임워크에도 의존하지 않는 테스트 코드로서, 테스트 코드의 실행 과정을 외부에서 직접 작성하고 제외할 수 있는 코드다. 하지만 이 코드에서 테스트 프레임워크를 추상화 할 수 있기 때문에 아래 처럼 코드를 변경할 수 있다.
public abstract class TestCase { public void run() { if (doTest()) { System.out.println("Test succeeded."); } else { System.out.println("Test failed."); } } public abstract boolean doTest(); } public class JunitApplication { private static final List<TestCase> testCases = new ArrayList<>(); public static void register(TestCase testCase) { testCases.add(testCase); } public static final void main(String[] args) { for(TestCase testCase : testCases) { testCase.run(); } } }
이 코드는 테스트 프레임워크를 단순화한 것으로, 이후 특정 클래스를 테스트하려면 TestCase 클래스의 실행 흐름을 담당하는 main() 함수를 직접 작성할 필요 없이 프레임워크에서 제공하는 확장 포인트인 doTest() 추상 메서드에 테스트 코드를 채우기만 하면 된다.
public class UserServiceTest extends TestCase{ @Override public boolean doTest() { // ... } } // 명시적으로 register()를 호출하여 등록하는 대신 설정을 통해 구현 가능 JunitApplicationm.register(new UserServiceTest());
위의 예제 코드는 프레임워크를 통해 구현한 제어 반전의 일반적인 형태이다. 프레임워크는 객체를 조합하고 전체 실행 흐름을 관리하기 위한 확장 가능한 코드 골격을 제공한다.
프로그래머가 프레임워크를 사용할 때는 제공되는 확장 포인트에 비즈니스 코드를 작성하는 것만으로 전체 프로그램이 실행된다.
여기서 제어는 프로그램 실행 흐름을 제어하는 것을 의미하며, 역전이 되는 대상은 프레임워크를 사용하기 전에 직접 작성했던 전체 프로그램 흐름의 실행을 제어하는 코드다.
프레임워크 사용한 후 전체 프로그램의 실행 흐름은 프레임워크에 의해 제어되고, 흐름의 제어는 프로그래머에서 프레임워크로 역전되는 것이다.
사실 제어 반전을 구현하는 방법에는 여러가지가 있는데, 위의 예제 코드에서 살펴봤던 템플릿 디자인 패턴과 유사한 방법 외에도 의존성 주입 등의 방법이 있다. 따라서 제어 반전은 특정한 기술이 아니라 일반적으로 프레임워크를 사용할 때 만나게 되는 보편적인 설계 사상에 가깝다.
3.5.2 의존성 주입
제어 반전과 달리 의존성 주입은 특정한 프로그래밍 기술이다. 따라서 의존성 주입은 이해하거나 적용하기 쉬울 뿐만 아니라 매우 유용하다.
의존성 주입을 한 문장으로 요약하면 new 키워드를 사용하여 클래스 내부에 종속되는 클래스의 객체를 생성하는 대신, 외부에서 종속 클래스의 객체를 생성한 후 생성자 함수의 매개변수 등을 통해 클래스를 주입하는 것을 의미한다.
아래 코드를 의해할 수 있다면 의존성 주입을 마스터한 것이다.
public class Notification { private MessageSender messageSender; public Notification(MessageSender messageSender) { this.messageSender = messageSender; // new를 사용하는 대신 의존성 주입 } public void sendMessage(String cellphone, String message) { this.messageSender.send(cellphone, message); } } public interface MessageSender { void send(String cellphone, String message); } // 문자 메시지 발송 클래스 public class SmsSender implements MessageSender { @Override public void send(String cellphone, String message) { // ... } } public class InboxSender implements MessageSender { @Override public void send(String cellphone, String message) { // ~~~ } } MessageSender messageSender = new SmsSender(); Notification notification = new Notification(messageSender);
3.5.3 의존성 주입 프레임워크
의존성 주입을 도입한 결과 클래스 내부에서 new 키워드를 통해 직접 생성할 필요는 없어졌지만 여전히 프로그래머가 직접 작성해주어야 한다는 점은 동일하다.
물론 이 코드는 클래스 내부가 아니라 상위 코드로 옮겨진다는 차이점이 있으며 다음 예제와 같이 작성될 수 있다.
public class Demo { public static final void main(String[] args) { MessageSender sender = new SmsSender(); Notification notification = new Notification(sender); //의존성 주입 } }
실제 소프트웨어 개발 시 수십, 수백 개의 클래스가 필요할 수 있으며, 이에 따라 클래스 객체의 생성과 의존성 주입은 매우 복잡해진다.
만약 이 작업을 프로그래머가 직접 코드를 작성하는 방식으로 진행된다면 오류가 발생하기 쉽고 리소스도 많이 든다. 때문에 프레임워크에 의해 자동으로 완성되는 코드 형태로 완전히 추상화 될 수 있다. 그리고 이러한 프레임워크를 의존성 주입 프레임워크라 한다.
3.5.4 의존 역전 원칙
의존 역전 원칙에 대한 정의는 다음과 같다.
상위 모듈은 하위 모듈에 의존하지 않아야 하며, 추상화에 의존해야만 한다. 또한 추상화가 세부 사항에 의존하는 것이 아니라, 세부 사항이 추상화에 의존해야 한다.
그렇다면 상위 모듈과 하위 모듈을 어떻게 구분할까? 간단히 말해 호출자는 상위 모듈에 속하고 수신자는 하위 모듈에 속한다. 의존 역전 원칙은 앞에서 언급했던 제어 반전과 유사하게 프레임워크의 설계를 사용하도록 하는 데 주로 사용된다. Tomcat을 예로 설명해보자.
Tomcat은 Java 웹 애플리케이션을 실행하기 위한 컨테이너다. 우리가 작성한 웹 애플리케이션 코드는 톰캣 컨테이너에 배포만 하면 별도의 작업 필요 없이 톰켓 컨테이너에서 호출하고 실행할 수 있다. 앞의 정의에 따라 구분해보면 톰캣은 상위 모듈이고 우리가 작성하는 웹 애플리케이션 코드는 하위 모듈이다.
3.6 KISS 원칙과 YAGNI 원칙
3.6.1 KISS 원칙의 정의와 해석
KISS 원칙은 가능한 한 단순하게 유지하라는 대전제는 비슷하지만 실제로 약자는 keep It Simple And Stupid, Keep It Short And Simple, Keep It Simple and Stratght forward 와 같이 여러 해석이 존재한다.
해당 원칙은 많은 상황에 적용될 수 있는 포괄적인 설계 원칙이다. 소프트웨어 개발뿐만 아니라 시스템 설계와 제품 설계에도 많이 사용된다.
예를 들면 냉장고, 티비, 건물, 휴대폰의 설계에서 이 원칙을 적용하는 경우가 많다.
개발 관점에서의 원칙은 코드를 읽고 유지관리할 수 있도록 해주는 중요한 수단이다. 코드가 매우 간단하기 때문에 읽기 쉬울 뿐만 아니라 버그를 찾아내기도 쉽다.
그러나 KISS 원칙은 우리에게 코드를 단순하게 유지하라고 말할 뿐, 어떤 종류의 코드가 단순한 것인지 알려주지 않으며 이런 코드를 어떻게 개발해야 하는지 명확한 방법도 제공하지 않기 때문에 구현하기가 쉬운것은 아니다