Justin의 개발 로그

단일책임원칙 single responsibility principle, SRP

클래스와 모듈(Module)은 하나의 책임만 가지고 있어야 한다. 

여기서 모듈은 하나의 역할을 위해 만들어진 클래스의 군집으로 이해하면 된다. 하나의 클래스는 하나의 역할을 해야 하며, 하나의 모듈 내에 있는 다수의 클래스들은 더 큰 하나의 역할을 처리하기 위한 기능들로 구성되어야 한다. 

이 책임(역할)을 벗어나는 기능이나 로직이 클래스 또는 모듈 내에 있다면 클래스 설계 관점에서 클래스 또는 모듈의 분리 대상이 된다. 

 

단일책임원칙에서 클래스 분해의 수준은 프로젝트의 규모나 복잡도에 따라 달라질 수 있다. 
이 해석이 모호하다면 코드의 가독성, 확장성, 재사용성, 유지보수성을 판단해서 적정한 수준까지 설계하면 된다. 

 

아래 암호화 클래스를 예를 들어보면, Encrypt 클래스는 암호화 메소드와 복호화 메소드 두 가지 기능을 제공하고 있다. 

public class Encrypt {
  private static final String ENC_KEY = "xxxxxxxxxxx";
  
  public byte[] Encode(String message) {
  ... return encryptedMessage;
  }
  
  public String Decode(Byte[] encryptedMessage) {
  ... return message;
  }
}

단일책임의 원칙을 강하게 적용하면 암호화용 클래스와 복호화용 클래스를 분리해야 한다. 

public class Encrypt {
  private static final String ENC_KEY = "xxxxxxxxxxx";
  
  public byte[] Encode(String message) {
  ... return encryptedMessage;
  }
}

public class Decrypt {
  private static final String ENC_KEY = "xxxxxxxxxxx";

  public String Decode(Byte[] encryptedMessage) {
  ... return message;
  }
}

이렇게 분리를 하면 ENC_KEY가 양쪽 클래스에 중복으로 선언되어 있어서 중복 제거를 해야 한다.

public class EncryptKey {
  public static final String ENC_KEY = "xxxxxxxxxxx";
}
  
public class Encrypt {  
  public byte[] Encode(String message) {
  ... return encryptedMessage;
  }
}

public class Decrypt {
  public String Decode(Byte[] encryptedMessage) {
  ... return message;
  }
}


리팩토링 전과 후를 비교했을 때 확실히 단일책임의 원칙을 강하게 준수하고 있다. 그렇다면 가독성, 확장성, 재사용성, 유지보수성 측면에서는 리팩토링 후가 더 좋다고 확신할 수 있나? 
Encrypt Key가 public static이 되면서 다른 클래스에서 이 키값을 사용할 가능성이 생겼고, 이로 인해 side effect이 발생할 수 있으며, 암호화키를 변경하는 것도 영향 범위를 파악하기 어려워졌다. 결국 처음의 코드가 암/복호화를 담당한다는 단일책임의 원칙에서 역할 그룹으로 봤을 때 적정했으며, 가독성, 확장성, 재사용성, 유지보수성 측면에서도 적정 수준이었다.

 

개방폐쇄원칙 open-closed principle, OCP

확장에는 개방되어 있고, 수정할 때는 폐쇄가 되어야 한다는 원칙으로 확장에는 개방이라는 말은 대부분 쉽게 이해하는데, 수정에는 폐쇄라는 것을 오해하기 쉽다. 수정할 때는 폐쇄라니 가급적 수정을 조금만 하라는 말일까?


수정에는 폐쇄라는 말은 수정된 코드가 이 클래스를 사용하던 다른 클래스에 영향이 없어야 한다는 말이다. 

리팩토링 과정에서 부득이하게 다른 클래스도 함께 수정되어야 할 일은 발생할 수 있다. 더 중요한 것은 이번 수정 이후에 개방폐쇄원칙이 적용되어 이후 수정부터는 다른 코드에 영향을 미치지 않는다며 과감하게 리팩토링을 해도 된다.(아니 하는 것이 좋다.)

 

소프트웨어를 수정할 때 메서드, 클래스, 모듈 단위에서의 수정은 반드시 일어날 수 밖에 없다. 수정 이후에 개방폐쇄원칙이 더 강하게 적용되는 수정이라면 바람직한 방향으로 수정된다고 볼 수 있다.

GoF 디자인 패턴은 대부분 코드 확장성 문제를 해결할 수 있도록 설계되어 있으며, 기존 코드에 디자인 패턴을 적용하는 경우 여러 코드를 손대게 되는 경우가 많다.
지금 당장 많은 코드에 수정사항이 발생한다고 해서 개방폐쇄원칙에 위배되는 것이 아님을 말하고자 하는 것이며, 리팩토링 이후(디자인패턴을 적용을 포함)에 SOLID 원칙이 더 강하게 반영되는 리팩토링이라면 바람직한 방향이라는 것이다.

즉, 개방폐쇄원칙은 이번 수정만 고려하는 것이 아닌, 수정 이후의 변화된 코드의 효율성까지 생각해야 하는 원칙이라는 점을 기억하자.

 

[주요 디자인패턴 한 줄 카탈로그]

=생성=

팩토리 패턴 : 다양한 인스턴스의 실행타임 결정/주입 (템플릿메서드 또는 전략패턴과 함께 사용해야 효과적)
싱글턴 : 객체 유일성 보장, 안티 패턴으로 사용하지 않는 추세(static method가 있는데 굳이...)

빌더 패턴 : 메서드 체인 형식으로 다양한 조합으로 로직을 처리(예-커피/피자 주문 시 토핑 처리)할 때 또는 생성자의 과도한 오버로딩을 개선

프로토 타입 패턴 : 객체의 복제본을 제공해야 하는 경우, Cloneable 인터페이스를 구현

플라이웨이트 패턴 : 팩토리 패턴 + 객체 캐시 기능으로 이미 생성된 객체는 즉시 리턴. 예)팩코리 클래스 내에 Map<String, FlyWeightObject> pool; 로 생성된 객체를 보관. 클라이언트가 객체 생성을 요청하면 Map에 있으면 즉시 리턴

 

=행동=

템플릿메서드 패턴 : 추상 클래스 상속을 통한 확장성 제공 - 특정 부분의 로직을 하위모듈에서 주입

전략패턴 : 인터페이스 구현을 통한 확장성 제공 - 알고리즘을 선택 또는 템플릿메서드 패턴을 전략패턴으로 적용해야 하는 경우(람다식, 스트림에서는 전략패턴으로 중복 코드의 리팩토링 가능)

옵저버 패턴 : 데이터 제공자와 소비자의 커플링 방지, 데이터 소비자가 다수, 가변인 경우

반복자 패턴 : Iterator 인터페이스 사용

커맨드 패턴 : 전략패턴의 확장, 커맨드 인스턴스를 만들어서 연결하거나, 순서대로 처리해야 하는 일련의 커맨드를 큐 또는 리스트에 담아 순서대로 처리 또한 취소(Cancel)함. interface Command는 execute(), undo() 메서드 정의로 구성됨

중재자 패턴 : 하나의 중재자가 여러 제공자와 소비자를 관리. 옵저버가 1개의 제공자가 여러 소비자를 관리하는 것에서 차이가 있다. 중재자 패턴은 재사용은 낮은 단점이 있으나 특정 비즈니스 상황에서 데이터제공자, 소비자가 복잡한 경우에는 옵저버 패턴보다 적합하다.

메멘토 패턴 : 객체를 이전 상태로 되돌리는 기능이 필요할 때 사용한다. 

책임연쇄 패턴 : 1개의 요청을 2개 이상의 객체에서 순차적으로 처리해야 하는 경우 예)회원가입 검증 - 아이디 객체(아이디 검증)->비밀번호객체(비밀번호 검증)->주소객체(주소 검증) ...

 

=구조=

데코레이터, 어댑터, 퍼사드 패턴 : 개방성 낮은 클래스의 개방성 확보

  데코레이터 : 인터페이스는 변경하지 않고, 책임(기능)만 추가 
  어댑터 : 하나의 인터페이스를 다른 인터페이스로 변경
  퍼사드(facade) : 인터페이스를 간단하게 변경 (예-전원 스위치를 켜면 전등, TV, 에이컨을 모두 켜거나 끈다.)

컴포지트 패턴 : 트리 노드 형태로 복합 구조를 만들 때 사용



 

 

코드의 확장성은 종종 코드의 가독성을 떨어뜨린다. 확장성이 당장 필요 없고, 이후로도 확장 가능성이 높지 않은 경우 확장성을 미리 준비하는 것은 Over-engineering 이다.

 

리스코프 치환 원칙 Liskov substitution priciple, LSP

상위 클래스의 참조를 사용하는 경우에는 특별히 인지하지 않고도 파생 클래스의 객체를 사용할 수 있어야 한다.

하위 유형 또는 파생 클래스의 객체는 프로그램 내에서 상위 클래스가 사용(주입, 파라미터 등)되는 모든 상황에서 대체 가능하며, 논리적인 동작이 변경되지 않고, 정확성도 유지되어야 한다.

 

리스코프 치환 원칙을 위해하는 사례

  • 상위 클래스가 선언한 기능을 위반하는 경우 
    • sortOrderByAmount 라는 메소드를 override 하면서 금액순이 아닌 다른 순으로 정렬되도록 로직을 적용하는 경우 
  • 상위 클래스에서는 값이 없으면 null을 반환하는데, 하위 클래스에서는 값이 없으면 예외를 발생하는 경우

리스코프 치환 원칙을 다형성(override)에 대한 설명 정도로 이해하면 안된다. 다형성 보다 강력한 제약으로 하위 객체를 구현할 때 동작 안정성에 대한 원칙을 제시한 것으로 이해해야 한다.

 

인터페이스 분리 원칙 Interface segregation priciple ISP

클라이언트는 필요하지 않은 인터페이스를 사용하도록 강요되어서는 안된다. 호출자가 인터페이스의 일부 또는 기능의 일부만 사용하는 경우 인터페이스 분리 원칙을 따르지 않았음을 알 수 있으며, 이 경우 단일 책임의 원칙도 위배될 것이다.

 

인터페이스 분리 원칙을 위배하는 사례 

  • Front에서만 사용해야 하는 기능과 BO에서만 사용해야 하는 기능을 하나의 Interface로 제공하는 경우 
    • Front에서 BO에서 써야 할 인터페이스를 사용해서 보안에 위배되거나, 잘 못된 비즈니스 데이터 조작이 일어날 수 있음
  • 하나의 인터페이스 결과에서 너무 많은 결과가 담겨 있는 경우 
    • getStatistics() 메서드의 결과로 sum, max, min, avg 등 여러가지 값을 반환하는 경우이나 실제 클라이언트는 이 값이 모두 필요치 않고, 특정 값만 사용하는 경우에 sum, max, min, avg 등을 각각의 메서드로 분리해야 함. 이는 성능 상으로도 문제이며, 접근하면 안되는 데이터에 추가로 접근할 수 있는 잠재적인 문제가 있음

 

의존관계 역전 원칙 dependency inversion principle, DIP

상위 모듈은 하위 모듈에 의존하지 않아야 하며, 추상화에 의존해야만 한다. 또한 추상화가 세부 사항에 의존하는 것이 아니라, 세부 사항이 추상화에 의존해야 한다.

고수준 모듈이 저수준 모듈에 의존하지 않도록 설계해야 하며, 둘 다 추상화에 의존해야 한다는 원칙, 저수준 모듈은 추상화에 의존해서 로직이 구현되어야 하며, 고수준 모듈은 추상화에 의존해서 저수준 모듈을 사용해야 한다.

 

여기서 '고수준 모듈'이란 비즈니스 로직이나 사용자의 요구사항을 처리하는 로직의 흐름을 제어하는 부분이고, '저수준 모듈'이란 (파일 시스템 접근, 네트워크 통신, 데이터베이스 관리 등과 같은 보다) 구체적인 작업을 담당하는 부분입니다. DIP의 주요 목적은 모듈 간의 결합도를 줄이고, 시스템의 유연성과 확장성을 향상시키는 것입니다. 이를 통해 하나의 모듈을 변경하더라도 다른 모듈에 미치는 영향을 최소화할 수 있습니다.

 

class Character {
    final String NAME;
    int health;
    OneHandSword weapon; // 의존 저수준 객체

    Character(String name, int health, OneHandSword weapon) {
        this.NAME = name;
        this.health = health;
        this.weapon = weapon;
    }

    int attack() {
        return weapon.attack(); // 의존 객체에서 메서드를 실행
    }

    void chageWeapon(OneHandSword weapon) {
        this.weapon = weapon;
    }

    void getInfo() {
        System.out.println("이름: " + NAME);
        System.out.println("체력: " + health);
        System.out.println("무기: " + weapon);
    }
}

 

캐릭터가 OneHandSword 외에 TwoHandSword, Bow, Axe, WarHammer 등으로 장비를 교체할 수 있어야 한다는 요구사항이 추가되면 어떻게 될까?

// 추상화
interface Weapon {
    int attack();
}

// 하위 모듈(저수준 모듈)
class OneHandSword implements Weaponable {
    final String NAME;
    final int DAMAGE;

    OneHandSword(String name, int damage) {
        NAME = name;
        DAMAGE = damage;
    }

    public int attack() {
        return DAMAGE;
    }
}

class TwoHandSword implements Weaponable {
	// ...
}


class BatteAxe implements Weaponable {
	// ...
}

class WarHammer implements Weaponable {
	// ...
}

// 상위 모듈(=고수준 모듈)
class Character {
    final String NAME;
    int health;
    OneHandSword weapon; // 의존 저수준 객체

    Character(String name, int health, Weapon weapon) {
        this.NAME = name;
        this.health = health;
        this.weapon = weapon;
    }

    int attack() {
        return weapon.attack(); // 의존 객체에서 메서드를 실행
    }

    void chageWeapon(Weapon weapon) {
        this.weapon = weapon;
    }

    void getInfo() {
        System.out.println("이름: " + NAME);
        System.out.println("체력: " + health);
        System.out.println("무기: " + weapon);
    }
}

 

DIP(의존관계 역전 원칙)은 OCP(개방 폐쇄 원칙)과 밀접한 관계가 있다. 저수준 모듈이 확장될 가능성이 없고, 요구사항이 단순한 경우에 DIP와 OCP는 Over-engineering이 되기도 한다. 템플릿 메서드 패턴이나 전략 패턴은 대표적인 DIP 패턴 사례이며, 
Spring의 DI(Dependency Injection 의존성 주입)도 DIP에 의해 설계된 것이다. 

 

Spring의 DI로 인해 우리는 데이터베이스 접속, 사용할 Cache의 선택 뿐 아니라 직접 구현하는 로직에 사용될 구체적인 클래스까지도 외부에서 주입을 할 수 있으며, 이는 OCP, ISP, DIP, LSP의 원칙을 모두 준수하도록 클래스가 설계 되었을 때 얻을 수 있는 장점이다.

 

 

profile

Justin의 개발 로그

@라이프노트

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!