※본 포스팅은 지극히 주관적이므로 정확하지 않을 수 있습니다.
시작
클래스를 작성하기 위한 다섯 가지의 유명한 디자인 패턴이다.
SOLID는 다음과 같은 원칙의 약자이다.
- S: 단일 책임 원칙(Single Responsibility Principle, SRP)
- O: 개방-폐쇄 원칙(Open-Closed Principle, OCP)
- L: 리스코프 치환 원칙(Liskov Substitution Principle, LSP)
- I: 인터페이스 분리 원칙(Interface Segregation Principle, ISP)
- D: 의존관계 역전 원칙(Dependency inversion Principle, DIP)
이 5가지의 원칙을 하나하나씩 예제와 함께 살펴보자
SRP, 단일 책임 원칙
SRP: 단일 책임 원칙(Single Responsibility Principle)
“어떤 클래스를 변경해야 하는 이유는 오직 하나뿐이어야 한다.” (로버트 C.마틴)
- 하나의 객체가 하나의 책임만 져야 한다. (책임에 대해서 궁금하다면? 역할, 책임, 협력 포스팅을 확인하자)
- 클래스를 단 한 가지 목표만 가지고 작성해야 한다는 것을 의미한다.
- 클래스가 여러 책임을 갖게되면 그 클래스는 각 책임마다 변경되는 이유가 발생한다. 클래스는 한 개의 이유로만 변경되어야 한다.
- 애플리케이션 모듈 전반에서 높은 유지보수성과 가시성 제어 기능을 유지하는 원칙이다.
- 모든 클래스는 하나의 책임만 가지며, 클래스는 그 책임을 완전히 캡슐화해야 함을 일컫는다.
- 다른 원리들을 적용하는 기초가 된다.
게임을 예시로 SRP에 대해 알아보자.
보통 RPG 게임엔 피를 뜻하는 HP와 마나를 뜻하는 MP가 존재한다. 그리고 당연히 이름이 있겠죠?
예시) BadCase
class BadCharacter{
private final String name;
private int HP;
private int MP;
BadCharacter(String name) {
this.name = name;
}
public int getHP() {
return HP;
}
public int getMP() {
return MP;
}
public void setHP(int HP) {
this.HP = HP;
}
public void setMP(int MP) {
this.MP = MP;
}
public void decreaseHP(int HP){
this.HP = this.HP - HP;
}
public void increaseHP(int HP){
this.HP = this.HP + HP;
}
public void decreaseMP(int MP){
this.MP = this.MP - MP;
}
public void increaseMP(int MP){
this.MP = this.MP + MP;
}
}
이 소스의 BadCharacter는 현재 HP를 관리하는 책임과 MP를 관리하는 책임을 도맡고 있다. HP와 MP에 대한 변경 요건이 추가된다면 BadCharacter에 계속 추가가 될 터...
예시) GoodCase
class Character{
private final String name;
private MP mp;
private HP hp;
Character(String name, HP hp, MP mp) {
this.name = name;
this.hp = hp;
this.mp = mp;
}
public MP getMp() {
return mp;
}
public HP getHp() {
return hp;
}
}
class HP{
private int healthPoint;
public HP(int healthPoint) {
this.healthPoint = healthPoint;
}
public int getHealthPoint() {
return healthPoint;
}
public void setHealthPoint(int healthPoint) {
this.healthPoint = healthPoint;
}
public void increaseHP(int HP){
this.healthPoint = this.healthPoint + HP;
}
public void decreaseHP(int HP){
this.healthPoint = this.healthPoint - HP;
}
}
class MP{
private int manaPoint;
public MP(int manaPoint) {
this.manaPoint = manaPoint;
}
public int getHealthPoint() {
return manaPoint;
}
public void setHealthPoint(int healthPoint) {
this.manaPoint = healthPoint;
}
public void increaseHP(int MP){
this.manaPoint = this.manaPoint + MP;
}
public void decreaseHP(int MP){
this.manaPoint = this.manaPoint - MP;
}
}
HP와 MP는 스스로를 관리할 수 있다.
이렇게 클래스를 나눈다면, Character 객체가 HP와 MP에 대한 정보를 알 수 없으니 캡슐화가 되어있다고도 볼 수 있다.
적용방법
- 제공해야 할 책임을 파악한다.
- 함수와 클래스 각각이 단 하나의 책임만 할당받도록 함수, 클래스를 그룹화한다.(분리된 두 클래스 간의 관계는 응집도를 높게, 결합도를 낮게 설계해야한다.)
※ 여기서 단 하나의 책임은 하나의 메서드를 말하는 것이 아니다.
OCP, 개방 폐쇄 원칙
OCP: 개방-폐쇄 원칙(Open-Closed Principle)
“소프트웨어 엔티티(클래스, 모듈, 함수 등)는 확장에 대해서는 열려 있어야 하지만 변경에 대해서는 닫혀 있어야 한다.” (로버트 C.마틴)
- 다른 개발자가 작업을 수행하기 위해 반드시 수정해야 하는 제약 사항을 클래스에 포함해서는 안 된다는 사실을 의미한다. 다른 개발자가 클래스를 확장하기만 하면 원만하게 작업을 할 수 있도록 해야 한다.
- 다양하고 직관적이며 유해하지 않은 방식으로 소프트웨어 확장성을 유지하는 원칙이다.
- 추상화와 다형성이 핵심요소이다.
- OCP는 DIP의 설계 기반이 되기도 한다.
예시) BadCase
interface BadSkill{
}
class BadWarriorSkill implements BadSkill {
}
class BadWizardSkill implements BadSkill {
}
class BadAttack{
BadSkill badSkill;
void useSkill(){
if(badSkill.getClass().equals(BadWizardSkill.class)){
// 마법데미지 계산로직
System.out.println("마법사 스킬을 사용합니다.");
}
else if(badSkill.getClass().equals(BadWarriorSkill.class)){
// 물리 데미지 계산 로직
System.out.println("전사 스킬을 사용합니다.");
}
}
}
불필요한 구현은 스킵했습니다.
BadAttack 클래스를 보면 새로운 직업의 스킬이 생길 때마다 else if를 추가해야됩니다. 직업이 늘어남에 따라 계속 if문이 추가되겠죠.
예시) GoodCase
interface Skill{
public void settingSKill();
}
class WarriorSkill implements Skill {
@Override
public void settingSKill() {
// 마법데미지 계산로직
System.out.println("마법사 스킬을 사용합니다.");
}
}
class WizardSkill implements Skill {
@Override
public void settingSKill() {
// 물리 데미지 계산 로직
System.out.println("전사 스킬을 사용합니다.");
}
}
class Attack{
Skill badSkill;
void useSkill(){
badSkill.settingSKill();
}
}
Skill인터페이스에 메서드를 정의해주고 이에 따라 상속받은 클래스들은 settingSKill을 구현하게됩니다. Attack 클래스는 이 settingSKill를 호출하기만 하면 되죠.
확장에는 열려있고, 변경에 닫혀있는 형태입니다. 도적 스킬이 추가된다면 다른 클래스 변경 없이, 도적 클래스만 추가하면 됩니다.
적용방법
- 상속 사용. (하위 클래스가 상위 클래스의 특성을 재정의 한것, IS-A 관계)
- 컴포지션 사용 (기존 클래스가 새로운 클래스의 구성요소가 되는것, HAS-A 관계)
- 변경될 것과 변경되지 않을 것을 구분 지은 후, 인터페이스를 정의한다.
컴포지션을 사용할 것을 권장한다. 상속은 상위 클래스에 강하게 의존, 결합기 때문에 설계와 유지보수에 유연하지 못하고, 다중 상속이 불가능한데다가 캡슐화를 깨트린다.
Composition(조합, 합성)
기존의 클래스를 확장(상속)하는 대신, 새로운 클래스를 만들고 private필드로 기존 클래스의 인스턴스를 참조하게 하는 방법을 통해 기능 확장을 할 수있으며, 이를 Composition(조합||구성)이라 한다.
LCP, 리스코프 치환 원칙
LCP: 리스코프 치환 원칙(Liskov Substitution Principle)
“서브 타입은 언제나 자신의 기반 타입으로 교체할 수 있어야 한다.” (로버트 C.마틴)
- 서브클래스의 객체는 슈퍼클래스의 객체와 반드시 같은 방식으로 동작해야 한다.
- 다형성을 지키기위한 원칙이다.
- LSP 위반이 발생되면 OCP 원칙이 지켜지지 않는다.
- 부모 메서드의 오버라이딩을 조심스럽게 따져가며 해야한다.
예시) BadCase
class BadMonster{
int position;
void move(int distance){
position += distance;
}
}
class BadSlime extends BadMonster{
}
class BadEagle extends BadMonster{
@Override
void move(int distance){
position += distance * 2;
}
}
몬스터를 상속받은 슬라임과 독수리.
독수리는 하늘을 날기 때문에 더 빠르다고 가정해서 부모 클래스의 move와 다르게 2배를 더 간다고 재정의했다.
이는 부모 클래스의 규약을 어기게 된 셈.
BadMonster egle = new BadEagle();
BadMonster egle2 = new BadMonster();
egle.move(10);
egle2.move(10);
System.out.println(egle.position);
System.out.println(egle2.position);
20과 10이 출력 됐다.
interface Movable{
void move(int distance);
}
class Monster{
int position;
}
class Slime extends Monster implements Movable{
@Override
public void move(int distance) {
position += distance;
}
}
class Eagle extends Monster implements Movable{
@Override
public void move(int distance) {
position += distance * 2;
}
}
부모 클래스의 move 메서드를 인터페이스로 옮겼다. 부모를 추상 클래스로 두고 추상 메서드로 선언할 수도 있긴 하다.
적용방법
- 상속의 관계를 제거하는 방법.
- 기능을 제대로 하지 못하는 메서드를 자식 클래스로 이동시키는 방법.
- 똑같은 연산을 제공하지만, 약간씩 다르게 해야한다면 공통의 인터페이스를 만들고 이를 구현한다.
- 공통된 연산이 없다면 별개의 클래스로 만든다.
ISP, 인터페이스 분리 원칙
ISP: 인터페이스 분리 원칙(Interface Segregation Principle)
“클라이언트는 자신이 사용하지 않는 메서드에 의존 관계를 맺으면 안 된다.” (로버트 C. 마틴)
- 클라이언트가 사용하지 않을 불필요한 메서드를 강제로 구현하게 해서는 안된다
- 클라이언트가 사용하지 않을 메서드를 강제로 구현하는 일이 없을 때까지 하나의 인터페이스를 2개 이상의 인터페이스로 분할하는 원칙이다.
- ISP 원칙은 SRP원칙과 관련이 있다. 다만, SRP는 클래스의 단일 책임을 강조하고, ISP는 인터페이스의 단일 책임을 강조하고 있다.
예시) BadCase
interface BadAbility{
void healing();
void dealing();
void defencing();
}
class BadPharah implements BadAbility{
@Override
public void healing() {
// 파라는 힐 능력이 없습니다.
}
@Override
public void dealing() {
}
@Override
public void defencing() {
// 파라는 피해를 막는 기능이 없습니다.
}
}
class BadSoldier implements BadAbility{
@Override
public void healing() {
}
@Override
public void dealing() {
}
@Override
public void defencing() {
//솔져는 피해를 막는 기능이 없습니다.
}
}
오버워치에서 영웅이 할 수 있는 기능은 크게 공격을 하는 기능과 아군에게 힐을 넣어주는 기능, 상대의 피해를 막아주는 기능이 있다.
BadAbility 인터페이스에 이를 모아주고 파라와 솔져가 상속받았다.
이때의 문제는 파라에겐 힐 기능과 상대의 피해를 막아주는 기능이 없고, 솔져에겐 피해를 막아주는 기능이 없다.
불필요한 메서드가 생긴 것. 결국 필요하지 않는 기능을 어쩔 수 없이 남겨두거나 구현해야되는 낭비가 생긴다.
interface Deal{
void dealing();
}
interface Heal{
void healing();
}
interface Defence{
void Defencing();
}
class Pharah implements Deal{
@Override
public void dealing() {
}
}
class Soldier implements Deal, Heal{
@Override
public void healing() {
}
@Override
public void dealing() {
}
}
BadAbility에 있던 기능들을 각 인터페이스를 만들어서 책임을 분배했다.
이렇게 만들어 두면 파라와 솔져는 자신이 사용할 수 있는 메서드를 상속 받을 수 있다.
적용방법
- 인터페이스는 각 책임에 맞게 분리하도록 하자.
- 인터페이스라는 건 한번 구성했으면 왠만해선 변하면 안되는 정책 같은 개념이다. 때문에 처음 설계부터 변화의 가능성을 염두해두고 설계해야한다.
DIP, 의존관계 역전 원칙
DIP: 의존관계 역전 원칙(Dependency inversion Principle)
“고차원 모듈은 저차원 모듈에 의존하면 안 된다.이 두 모듈 모두 다른 추상화된 것에 의존해야 한다.” “추상화된 것은 구체적인 것에 의존하면 안 된다. 구체적인 것이 추상화된 것에 의존해야 한다.” "자주 변경되는 구체(Concrete) 클래스에 의존하지 마라 “ (로버트 C, 마틴)
- 의존 관계를 맺을 때 변화하기 쉬운 것과 자주 변화하는 것 보다는 변화하기 어려운 것과 거의 변화가 없는 것에 의존하라는 의미
- 변하기 쉬운 구체화가 아닌 추상화에 의존해야한다. 그렇게 해야 변화에 영향을 받지 않는다.
- 상위 클래스, 인터페이스, 추상 클래스에 의존하라.
- 의존 역전 원칙의 지향점은 각 클래스간의 결합도를 낮추는 것이다.
예시) BadCase
class BadPlayer{
int HP;
Bow bow;
}
class BadBow{
void Attack(){
System.out.println("도끼로 공격합니다.");
}
}
class BadSword{
void Attack(){
System.out.println("검으로 공격합니다.");
}
}
class BadWand{
void Attack(){
System.out.println("지팡이로 공격합니다.");
}
}
사실 이 소스는 OCP를 위한 것이었지만, 생각해보니 DIP 예시에 더 맞을 것 같아 옮긴 소스이다.
찾아보니 DIP는 OCP와 긴밀한 연관이 있다더라.
무기가 Bow만 있을 적. 개발자는 플레이어가 Bow를 가지고 있게끔 설계를 했다. 무기의 확장가능성을 생각해두지 않은 것.
만약 훗날 검과 지팡이가 생긴다면? 플레이어가 무기를 변경할 수 있는 가능성도 생기게 된다. 그렇게 된다면 플레이어가 무기를 변경할 때마다 클래스 필드 변수를 변경해줘야 한다.
예시) GoodCase
class Weapon{
String name;
public Weapon(String name) {
this.name = name;
}
void attack(){
System.out.println(this.name + "(으)로 공격합니다.");
}
}
class Player {
int HP;
Weapon weapon;
public Player(int HP, Weapon weapon) {
this.HP = HP;
this.weapon = weapon;
}
public void attack(){
weapon.attack();
}
}
class Bow extends Weapon{
public Bow(String name) {
super(name);
}
}
class Sword extends Weapon{
public Sword(String name) {
super(name);
}
}
class Wand extends Weapon{
public Wand(String name) {
super(name);
}
}
상위 클래스를 추상 클래스나 인터페이스로 만들면 더 유연할 것 같지만, 예시니 그냥 두도록 하겠다.
타입을 Weapon으로 두었기 때문에 무기가 변경되더라도 소스가 수정될 필요는 없다.
적용방법
- 상위 클래스, 인터페이스, 추상 클래스를 타입으로 두어라
'OOP > PTUStudy' 카테고리의 다른 글
3주차. 스프링 입문을 위한 자바 객체 지향의 원리와 이해 정리(1~6장) (0) | 2023.02.15 |
---|---|
2주차. 객체지향의 사실과 오해 REVIEW (0) | 2023.02.04 |
2주차. 객체지향의 사실과 오해 정리(5~7장) (0) | 2023.02.04 |
1주차. 객체지향의 사실과 오해 정리 (1~4장) (0) | 2023.01.30 |