SRP : Single Responsibility Principle
- 한 줄 요약 - 객체를 변경시키는 요인은 무조건 Only 1 하나뿐이어야 한다.
1. What 책임이란 무엇일까 ?
책임이란, 객체에 의해 정의되는 응집도(행동과 관련된 상태를 한 클래스에 모아 놓는 것) 있는 행위의 집합으로, 객체가 유지해야 하는 정보(상태)와 수행할 수 있는 행동(메서드)에 대해 추상적으로 서술한 문장이다.
그래서 책임과 기능의 크기는 다르다. 책임은 객체가 수행할 수 있는 행동을 종합적이고 간략하게 서술하기 때문에 기능 보다 추상적이고 개념적으로 더 크다.
2. Why 왜 중요할까 ?
고객의 편의성을 증대 시키기 위해서 “커피 주문 시스템”를 개발하기로 했다고 하자.
최초 설계 단계에서 1)주문 받기, 2)커피 제조하기, 3)준비된 커피 전달하기
이렇게 총 3개의 큰 기능이 필요하다고 판단했고, 이 모든 책임을 Cashier라는 객체에게 할당하는 설계를 했다고 가정해보자.
public class Cashier {
private Orderbook orderbook;
public boolean takeOrders(String menuName, int quantity){
Coffee makedCoffee = makeCoffee(menuName, quantity);
deliveryOrder(orderbook.getUserName(), makedCoffee);
}
public long calculatePrice(String menuName, int quantity){
}
public Coffee makeCoffee(String menuName, int quantity){
}
public void deliveryOrder(Strint toCustomer, Coffee coffee){
}
}
기능을 구현한 최초 설계 단계에서는 문제가 없다. 하지만, 미래에 “메뉴 추가” 또는 “가격 계산방식 변경”이라는 기능이 추가된다면 Cashier 클래스는 변경되어야 한다. 즉, Cashier 클래스를 변경 시키는 요소를 2개 이상 가지고 있다는 의미이다.
이렇게 하나의 객체에 다수의 책임이 할당 되어 있는 상태에서 시간이 흐르고 많은 변화를 겪는 코드에는 아래와 같은 문제점을 가지게 된다.
첫 번째로, 각기 다른 책임의 기능들이 강한 결합도(Coupling)를 맺게 되고, 변화가 발생했을 때 연쇄적으로 변화가 발생한다.
두 번째로, 적절한 관심사 분리가 되어 있지 않아서 코드의 가독성이 많이 떨어진다. 많은 개발자들이 단일 책임 클래스가 많아지면 큰 그림을 이해하기 어려워지기 때문에 클래스를 넘나들어야 하는 이유로 SRP에 대한 반론이 많다고 한다. 하지만, 작은 덩어리로 조각을 내거나, 큰 덩어리로 조각을 내거나 결국에 크기는 변화지 않는다. 하지만, 복잡도와 가독성에는 큰 변화가 온다. 시스템의 규모가 큰 수록 체계적인 정리가 필수이다. 그래야만 개발자가 무엇이 어디 있는지 알 수 있고, 변경을 가할 때 직접 영향이 미치는 컴포넌트만 이해해도 충분히 코드를 수정하는데 문제가 없다.
세 번째로, 재사용성이 떨어진다.
3. How 어떻게 적용할 수 있을까?
SRP의 핵심은 어떤 변화에 의해 클래스를 변경해야 한다면! 이유가 오직 하나뿐이어야 한다.
- 클래스는 오직 책임(변경의 요소)를 1개만 가지도록 설계해야 한다.
- 하나의 책임이 여러 개의 클래스에 분산되어 있는 경우, 하나의 클래스에 모아서 하나의 책임을 할당한다.
- 커피 주문 시스템을 실제 구현 한다면 “결제 금액”과 관련된 코드가 여러 곳에 분산되어 각자의 클래스에서 사칙연산을 수행하는 메서드를 만들어서 사용하고 있을 것이다. 이것을 하나의 클래스(Money)에 책임을 할당한다면 흩어져 있던 책임을 한 곳으로 모으는 결과를 만들어 낼 것이다.
Cashier 클래스를 오직 “주문”과 관련된 변경사항이 발생했을 때만 변경되도록 변경할 것이다. 음료수 정보와 관련된 모든 책임은 Beverage 클래스에게 “음료수 정보” 책임을 할당하고 음료수 제조와 관련된 모든 책임은 Barista 클래스에게 “음료수 제조” 책임을 할당하여 책임을 분산 시켰다. [관심사의 분리]
public class Cashier {
private Orderbook orderbook;
private Beverage beverage;
public boolean takeOrders(String menuName, int quantity){
Coffee makedCoffee = makeCoffee(menuName, quantity);
deliveryOrder(orderbook.getUserName(), makedCoffee);
}
public long calculatePrice(String menuName, int quantity){
return beverage(menuName, quantity);
}
public boolean sendToOrderInfoToBarista(Barista barista){
barista.receiveOrder(orderbook.getOrder());
}
public void deliveryOrder(Strint toCustomer, Coffee coffee){
}
}
OCP : Open and Close Principle
- 한 줄 요약 - 구체화에 의존하지 말고 추상화에 의존해라.
1. What 열기와 닫기는 무엇일까?
확장에는 열려 있고, 변경에는 닫혀 있어야 한다. 즉, 기존 기능에 기능을 추가하는데 클래스를 수정 하지 않고 해야한다.
예를 들어 “할인 정책”에 2가지 정책이 있다고 하자. 하나는 통신사 할인 정책이 있고, 다른 하나는 멤버쉽 할인 정책이다. 그런데, 할인 정책이 없어서 할인 요금이 0(ZERO)인 케이스도 존재할 수도 있고 장사가 잘되지 않아 새로운 할인정책을 추가한다면 어떻게 해야할까?
아마도 구현만 생각하고 있다면 아래와 같이 코드를 작성할 수 있다.
switch(type){
case "telecome":
// 통신사 할인 정책 기반 할인 요금 계산 로직
case "membership":
// 맴버쉽 할인 정책 기반 할인 요금 계산 로직
case "payco":
// 페이코 할인 정책 기반 할인 요금 계산 로직
default:
return 0;
위 예제 코드 처럼 구현하면 새로 할인 정책(새로운 기능)을 추가해야 하는 경우, 우리는 기존의 코드(or 클래스)를 수정해야 한다. 이러한 경우가 발생했을 때 기존 코드의 수정 없이 기능을 추가하는 기법(or 원칙)을 열기와 닫기 원칙이라고 말한다.
2. Why 왜 중요할까?
최초 Application 개발 단계에서는 “깨끗하고 체계적인 소프트웨어”보다 “돌아가는 소프트웨어”에 초점을 맞추고 개발을 진행한다. Application을 정해진 일정에 개발완료 하였고, 성공적으로 신규 서비스를 오픈하였다. 그리고 많은 시간이 흐르고 서비스를 운영 하다보니, 다양한 변경사항이 발생하였다.
그래서 기획서가 Jira Ticket으로 등록 되었고 기획서에 맞게 클래스에 ‘손대어’ 고쳐야 한다. 개발자에게 있어서 코드를 만지는 일은 어렵지 않다. 문제는 기존 코드에 ‘손대면’ 위험이 생긴다는 사실이다. 어떤 변경이든 코드에 손대면 다른 코드를 망가뜨릴 잠정적인 위험이 존재하고, 테스트 또한 완전히 새롭게 해야 한다는 것이다. 그래서 기존 코드의 변경 없이 새로운 기능을 추가 할 수 있는 구조는 매우 중요하다.
3. How 어떻게 적용할 수 있을까?
규칙
- 변경될 것과 변화지 않을 것을 구분하기
- “할인 정책”은 변경 된다.
- “모든 할인 정책은 적용 여부를 확인하고 할인 금액을 계산한다.”라는 사실은 변하지 않는다.
- 공통된 특징을 기반으로 추상화 또는 인터페이스 정의하기
- 적용 여부 판단과 할인 금액 계산은 모든 할인 정책 객체에서 응답할 수 있어야하는 메시지이다.
- 공통으로 사용될 상태(인스턴스 변수)가 없기 때문에 여기서는 인터페이스를 선택
- 구현에 의존하지 말고 추상화에 의존하기
- 구현에 의존하면 변경에 유연 대응할 수 없다.
아래 예제를 코드를 보면 추상화, 인터페이스와 다형성이 OCP를 적용할 수 있도록 도와주는 가장 중요한 매커니즘이다. 이를 통해서 우리는 변경의 유연성을 가져갈 수 있지만, 코드의 가독성 난이도는 올라간다는 단점이 있다. 코드의 가독성보다는 변경의 유연성이 더 높은 가치를 가진다면 다형성을 선택하고 그렇지 않다면 코드의 가독성이 더 높은 분기 처리를 하는 것이 더 효율적일 것이다.
// 추상화 또는 인터페이스
public interface class DiscountPolicy {
public boolean isSatisfied();
public long calculateDiscountAmount();
}
// 팩토리얼
public DiscountPolicyFactory {
public static DiscountPolicy makeDiscountPolicyBy(String type){
switch(type){
case "telecome":
return new TelecomeDiscountPolicy();
case "membership":
return new MembershipDiscountPolicy();
case "payco":
return new PaycoDiscountPolicy();
default:
return new NoneDiscountPolicy();
}
}
// 호출부
String discountType = @RequestParam type;
DiscountPolicy discountPolicy =
DiscountPolicyFactory.makeDiscountPolicyBy(discountType)
getDiscountAmount(discountPolicy);
LSP: Liskov Substitution Principle
- 한 줄 요약 - 기능 명세를 지켜라. (OCP를 더 단단하게 해주는 원리)
1. What 리스코프란 무엇인가?
상위 타입의 객체를 하위 타입 객체로 치환해도 상위 타입을 사용하는 프로그램은 정상적으로 동작해야 한다라는 의미를 가진다.
즉, 부모 객체를 호출하는 동작(p.upcasting()) 에서 자식 객체가 부모 객체를 완전히 대체(new ChildClass())할 수 있어야 하고 올바른 상속을 위해서 자식 객체의 확장이 부모 객체의 방향을 온전히 따르도록 권하는 원칙이다.
public class ParentClass {
public void upcasting(){
System.out.println("Up Casting");
}
}
public class ChildClass extends ParentClass {
@Override
public void upcasting(){
System.out.println("Down Casting");
}
}
public static void main(String[] args) {
ParentClass p = new ChildClass();
p.upcasting(); // expect output: "Up Casting"
}
2. Why 왜 중요할까?
리스코프 치환 원칙이 위반되면 코드 변경의 유연성을 보장해주는 개방 폐쇄 원칙 또한 지킬 수 없게 되기 때문이다.
아래 예제 코드는 InputStream#read() 메서드는 스트림의 끝에 도달해서 더 이상 읽을 데이터가 없다면 -1를 리턴한다고 정의되어 있다. 그래서 CopyUtil#copy()는 이 규칙에 따라 is.read()의 리턴 값이 -1 아닐 때까지 while문을 반복해서 데이터를 읽고 out에 쓴다.
public class CopyUtil {
public static void copy(InputStream is, OutputStream out) {
byte[] data = new byte[512];
int len = -1;
// InputStream.read() 메서드는 스트림의 끝에 도달하면 -1을 리턴
while((len = is.read(data)) != -1) {
out.write(data, 0, len);
}
}
}
그런데 InputStream을 상속한 CustomInputStream에서 read() 메서드를 아래와 같이 구현 한다면 어떻게 될까?
public class CustomInputStream implements InputStream {
public int read(byte[] data) {
...
return 0; // 데이터가 없을 때 0을 리턴하도록 구현
}
}
CustomInputStream#read() 메서드는 InputStream#read()와 다르게 스트림의 끝에 도달해서 더 이상 읽을 데이터가 없다면 0를 리턴하도록 기존 구현과 다르게 수정하였다.
InputStream input = new CustomInputStream(someData);
OutputStream output = new FileOutputStream(filePath);
CopyUtil.copy(input, output);
위와 같이 하위 타입으로 치환하여 호출하게 되면 무한 루프에 빠지게 된다.
위 예제 코드에서 발생한 문제점을 보면 리스코프 치환 원칙에서 하고 싶은 주장은 "상위 타입에서 정의한 기능의 명세서 는 하위 타입에서도 무조건 지켜서 구현해야 예상하지 못했던 에러가 발생하지 않는다" 인것 같다.
그렇지 않고 하위 타입이 기능 명세와 다르게 행동을 정의 한다면 해당 기능 명세를 기반해서 구현한 코드는 비정상적으로 동작할 수 있다. 그래서 발견한 오류를 잡기 위해서 if/else문으로 도배가 되고 그렇게 개방 폐쇄 원칙(OCP) 또한 무너지게 되고 코드는 변경의 유연함을 잃어버리게 된다.
3. How 어떻게 적용할 수 있을까?
CASE#1
아래 예제 코드는 상품에 쿠폰을 적용해서 할인되는 액수를 구해 주는 기능을 구현한 케이스이다. 다음 코드 처럼 Coupon 클래스에서 Item 클래스를 의존하여 상품의 가격을 구하고 할인율을 곱해서 할인 금액을 계산하는 예제이다.
public class Coupon {
public int calculateDiscountAmount(Item item) {
return item.getPrice() * discountRate;
}
}
// 할인을 받을 수 있는 Item
public class Item {
private String itemName;
private String price;
}
그런데 비즈니스의 정책이 변경되어 할인이 안되는 상품 추가 되었다. 그래서 우리는 기존의 코드를 재사용 및 중복 제거를 위해서 Item클래스를 상속 받는 NoDiscountItem 클래스를 정의했다.
// 기존 Item과 모든 비즈니스 로직이 동일하지만,
// 할인을 받을 수 없다는 큰 차이점을 가지고 있음.
public class NoDiscountItem extends Item {
// 구현부...
}
이렇게 하위 타입 클래스를 추가하고 보니, Coupon#calculateDiscountAmount(Item i)에서 NoDiscountItem 객체의 경우에는 0으로 리턴 해야하는 상황이 발생했다. 그래서 우리는 아주 쉽게 아래 처럼 코드를 변경한다.
public int calculateDiscountAmount(Item item) {
if(item instanceof NoDiscountItem)
return 0;
else if (item instanceof DoubleDiscountItem)
return item.getPrice() * (discountRate * 2);
else if (item instanceof OwnerCrayItem)
return item.getPrice() * (discountRate * 70);
return item.getPrice() * discountRate;
}
// DoubleDiscoutItem
// OwnerCrayItem
위 코드는 리스코프 치환 원칙을 위반한 사례이다.
calculateDiscountAmount 메서드는 Item 클래스의 하위 타입을 알 필요가 없어야 한다. 하지만, instanceof 연산자를 통해서 NoDiscountItem에 대한 존재여부를 알고 해당 타입 여부를 확인하고 있다. 즉, 하위 타입인 NoDiscountItem이 상위 타입인 Item을 완벽하게 대체할 수 없다는 말과 똑같다. 이렇게 되면 새로운 하위 타입이 생성될 때마다 우리는 해당 메서드의 IF문을 추가 해야하는 상황이 발생한다. 즉, 개방 폐쇄 원칙을 위반하게 되는 것이다.
그럼 이 코드를 어떻게 하면 알맞게 고칠 수 있을까? 중복 코드 제거를 위해서 추상 클래스 형태로 수정할 수 있다.
1. 추상화
public abstract class Item {
// 변경되지 않는 것.
private String itemName;
private int price;
// 변경되는 것.
private boolean isDiscount;
// 변경되는 것과 변경되지 않는 것을 이어주는 인터페이스
public boolean isSatisfied(){
return isDiscount;
}
}
public class EnableDiscountItem extends Item {
}
public class DisableDiscountItem extends Item {
public DisableDiscountItem(String i, int p , boolean d){
suprer(i,p,d);
}
}
public class Coupon {
private int discountRate;
public int calculateDiscountAmount(Item item) {
if(item.isSatisfied())
return item.getPrice() * discountRate;
else
return 0;
}
}
calculateDiscountAmount(new DisableDiscountItem("iphon15", 1500000, false);
2. 인터페이스
package com.wanted.preonboarding.clean.code.solid.lsp;
public class lspInterfaceExample {
interface Item {
public boolean isEnableDiscount();
public int getPrice();
}
class EnableDiscountItem implements Item {
private final String itemName;
private final int price;
private final boolean isDiscount;
public EnableDiscountItem(String i, int p, boolean d){
itemName = i;
price = p;
isDiscount = d;
}
@Override
public int getPrice(){
return price;
}
@Override
public boolean isEnableDiscount() {
return isDiscount;
}
}
class DisableDiscountItem implements Item {
private final String itemName;
private final int price;
private final boolean isDiscount;
public DisableDiscountItem(String i, int p, boolean d){
itemName = i;
price = p;
isDiscount = d;
}
@Override
public boolean isEnableDiscount() {
return false;
}
@Override
public int getPrice(){
return price;
}
}
public class Coupon {
private int discountRate;
public int calculateDiscountAmount(Item item) {
if(item.isEnableDiscount())
return item.getPrice() * discountRate;
else
return 0;
}
}
}
CASE#2
예제2 - 잘못된 코드
우리의 예상(output: ”Green”)과 다른 결과(output: “Blue”)가 나온다. 왜 이렇게 나오는 걸까?
상위 클래스와 하위 클래스의 기능의 명세가 달라서 그렇다. 즉, 동일한 동작(행동)은 하지만, 행동을 구현하는 방식이 다르다.
public class Green {
public void getColor() {
System.out.println("Green");
}
}
public class Blue extends Green {
public void getColor() {
System.out.println("Blue");
}
}
public class Main{
public static void main(String[] args) {
Green green = new Blue();
green.getColor();
}
}
예제2- 올바른 코드
그럼 어떻게 고칠 수 있을까? Interface를 이용해서 이 문제를 해결할 수 있다. 동일한 메시지를 응답할 수 있으나 구체적인 처리 방식은 하위 타입에서 정의하는 방식이다.
public interface IColor{
public void getColor();
}
public class Green implements IColor {
public void getColor() {
System.out.println("Green");
}
}
public class Blue implements IColor {
public void getColor() {
System.out.println("Blue");
}
}
public class Main{
public static void main(String[] args) {
IColor color = new Blue();
color.getColor();
//output: Blue
}
}
CASE#3
예제3 - 잘못된 코드
public interface Bird{
public void fly();
public void walk();
}
public class Parrot implements Bird{
public void fly(){ // to do}
public void walk(){ // to do }
}// ok
public class Penguin implements Bird{
public void fly(){ // to do }
public void walk(){ // to do }
} // it's break the principle of LSP. Penguin can not fly.
Bird p = new Penguin();
p.fly(); // none
예제3 - 올바른 코드
public interface Bird{
// to do;
}
public interface FlyingBird extends Bird{
public void fly(){}
}
public interface WalkingBird extends Bird{
public void work(){}
}
public class Parrot implements FlyingBird, WalkingBird {
public void fly(){ // to do}
public void walk(){ // to do }
}
public class Penguin implements WalkingBird{
public void walk(){ // to do }
}
'CS' 카테고리의 다른 글
디자인패턴 기반으로 알아보는 SOLID - (2) (6) | 2023.10.31 |
---|---|
컴퓨터특강 2주차 : OOP (2) | 2023.10.03 |
컴퓨터특강 1주차 : Clean Code (1) | 2023.09.18 |
[가상면접 사례로 배우는 대규모 시스템 설계 기초]사용자 수에 따른 규모 확장성사용자 수에 따른 규모 확장성 (0) | 2023.09.04 |