디자인 패턴

객체 지향 5대 원칙

공대키메라 2022. 3. 29. 00:13

최근 디자인 패턴 관련해서 객체 지향 설계의 방법론에 대해서 공부하고 있는데 

 

생각해보니 객체 지향의 5개 원칙에 대해서 익히 들었지만 이를 제대로 이해하고 있지는 않은 것 같았다.

 

그래서 생각난 김에 정리하려고 한다.

 

사실... 어제 밤에 정리했는데 나의 실수로 정리한 내용을 전부 지워버리고 말았다...

 

너무 가슴이 아프지만 다시 정리해보겠다...

 

앞으로 정리할 내용은 다음 사이트를 참고했다.

 

출처 : https://www.baeldung.com/solid-principles

 

1. What is SOLID?

Simply put, Martin and Feathers' design principles encourage us to create more maintainable, understandable, and flexible software. 

Consequently, as our applications grow in size, we can reduce their complexity and save ourselves a lot of headaches further down the road!

 

즉, SOLID는 소프르웨어 개발을 도와주는 디자인 원칙이다.

 

이 SOLID는 5개의 원칙의 줄임말이며 5개 원칙은 다음과 같다. 

 

  1. Single Responsibility : 단일 책임
  2. Open/Closed : 개방 폐쇄
  3. Liskov Substitution : 리스코프 치환
  4. Interface Segregation : 인터페이스 분리
  5. Dependency Inversion : 의존관계 역전

2. 원칙 설명

Single Responsibility principle - 단일 책임 원칙

A class should have one and only one reason to change, meaning that a class should have only one job.

 

한 클래스는 하나의 책임만 가져야 한다. 

후에 변경이 있을 때 파급 효과가 적으면 단일 책임 원칙을 잘 따른 것이다. 

 

다음 예를 들어보겠다.

script.js - before

class CalorieTracker{
    constructor(maxCaloires){
        this.maxCaloires = maxCaloires;
        this.currentCalories = 0
    }

    trackCalories(calorieCount){
        this.currentCalories += calorieCount
        if(this.currentCalories > this.maxCaloires){
            this.logCalorieSurplus()
        }
    }

    logCalorieSurplus(){
        console.log('Max calories exceeded')
    }
}

const calorieTracker = new CalorieTracker(2000)

calorieTracker.trackCalories(500)
calorieTracker.trackCalories(1000)
calorieTracker.trackCalories(700)

 

이 코드는 얼핏보면 문제가 없어 보인다. 그런데 단일 책임의 원칙을 지키지 못했다!

 

단일 책임의 원칙의 개념은 모든 클래스, 모듈, 함수등 모든 코드가 한 부분으로 나뉠 때, 오직 단 하나의 책임만 가져야 한다는 것이다. 

 

즉, 변경 시에는 하나의 이유만 가지고 변경을 해야 한다는 것이다. 

 

하지만 위의 calorieTracker는 수정할 때 두가지 이유를 가진다.

 

우리가 입력하는 칼로리가 어디까지 수용할 수 있는지를 설정할 때 trackCaloires 함수를 수정해야 하고, 

 

이러한 상황에 대해 무언가 메시지를 호출할 때 logCaloriesSurplus 함수를 호출한다. 

 

두개를 변경해야 한다는 사실이다!

그러므로 단일 책임 원칙에 위배되지 않도록 함수 하나를 밖으로 내보내면 단일 책임 원칙을 지키게 된다. 

 

즉, trackCalories 기능을 하는 클래스 하나, logCaloiesSurplus 기능을 하는 클래스 하나 하는 식으로 나누면 된다. 

 

그러면 코드는 다음과 같이 변경된다.

 script.js - after

import logMessage from "./logger.js";
class CalorieTracker{
    constructor(maxCaloires){
        this.maxCaloires = maxCaloires;
        this.currentCalories = 0
    }

    trackCalories(calorieCount){
        this.currentCalories += calorieCount
        if(this.currentCalories > this.maxCaloires){
            logMessage('Max caloires exceeded')
        }
    }

}

const calorieTracker = new CalorieTracker(2000)

calorieTracker.trackCalories(500)
calorieTracker.trackCalories(1000)
calorieTracker.trackCalories(700)

 logger.js

export default function logMessage(message){
    console.log(message);
}

 

자. 이제 칼로리 상태를 출력하는 기능과 허용 칼로리 초과를 확인하는 기능이 하나씩 구현되었고 각자 하나만의 책임을 가지고 있다고 할 수 있다.

 

참고 : https://www.youtube.com/watch?v=UQqY3_6Epbg 

Open/Closed Principle - 개방 폐쇄의 원칙

Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.

 

확장에는 열려있어야 하고, 수정에는 닫혀 있어야 한다?

 

이게 말이되나? 한 번 확인해보자

printQuiz선언

function printQuiz(questions){
    questions.forEach(question=>{
        console.log(question.description)
        switch (question.type){
            case 'boolean':
                console.log('1. true')
                console.log('2. false')
                break;
            case 'multipleChoice':
                question.options.forEach((option, index) => {
                    console.log(`${index+1}. ${option}`)
                })
                break;
            case 'text':
                console.log('Answer: __________________')
                break;
        }
        console.log('')
    })
}

const questions=[
    {
        type: 'boolean',
        description: 'This Test is useful'
    },
    {
        type: 'multipleChoice',
        description: 'what is your favorite language?',
        options: ['CSS', 'HTML',' JS', 'Python']
    },
    {
        type: 'text',
        description: 'describe your favorite JS feature.'
    }
]

printQuiz(questions)

 

출력하면 다음과 같이 나온다.

 

이 코드도 크게 문제가 없어 보인다. 우리가 입력한 값도 잘 출력하니 말이다.

 

그런데 만약 우리가 새로운 질문의 형식이나 새로운 option들을 추가하고 싶어지면 어떻게 해야할까?

printQuiz 에 기능 추가

function printQuiz(questions){
    questions.forEach(question=>{
        console.log(question.description)
        switch (question.type){
            case 'boolean':
                console.log('1. true')
                console.log('2. false')
                break;
            case 'multipleChoice':
                question.options.forEach((option, index) => {
                    console.log(`${index+1}. ${option}`)
                })
                break;
            case 'text':
                console.log('Answer: __________________')
                break;
            case 'range':
            	console.log('Minimum : xxxx');
            	console.log('Maximum : xxxx');
        }
        console.log('')
    })
}

const questions=[
    {
        type: 'boolean',
        description: 'This Test is useful'
    },
    {
        type: 'multipleChoice',
        description: 'what is your favorite language?',
        options: ['CSS', 'HTML',' JS', 'Python']
    },
    {
        type: 'text',
        description: 'describe your favorite JS feature.'
    },
    {
    	type: 'range',
        desciption: 'What is the speed limit in your city?'
    }
]
printQuiz(questions)

 

그런데 이렇게 되는 순간 OCP를 위반한다 삐빅!

 

돼~~지바!

 

분명 수정에는 닫혀있고, 확장에는 열려 있어야 하지만 현재 확장을 위해서 원래 코드를 수정하는 오류를 범했기 때문이다. 

 

즉, printQuiz 코드 자체를 수정하지 않고 기능을 추가하려면 어떻게 해야 할까?

 

class BooleanQuestion{
    constructor(description){
        this.description = description
    }

    printQuestionChoices() {
        console.log('1. True');
        console.log('2. false');
    }
}

class MultipleChoiceQuestion{
    constructor(description, options){
        this.description = description
        this.options = options
    }

    printQuestionChoices(){
        this.options.forEach((option, index) => {
            console.log(`${index+1}. ${option}`)
        })
    }
}

class TextQuestion{
    constructor(description){
        this.description = description
    }

    printQuestionChoices(){
        console.log('Answer: __________________')
    }
}

class RangeQuestion{
    constructor(description){
        this.description = description
    }

    printQuestionChoices(){
        console.log('Maximum: __________________')
        console.log('Minumum: __________________')
    }
}

function printQuiz(questions){
    questions.forEach(question=>{
        console.log(question.description)
        question.printQuestionChoices()
        console.log('')
    })
}

const questions=[
    new BooleanQuestion('This test is useful'),
    new MultipleChoiceQuestion(
        'what is your favorite language?',
        ['CSS', 'HTML', 'Java', 'Js', 'Python']
    ),
    new TextQuestion('Describe your favorite JS feature.'),
    new RangeQuestion('what is the speed limit in your city'),
]

 

참고 : https://www.youtube.com/watch?v=-ptMtJAdj40

 

개방 폐쇄의 원칙은 기존의 코드를 건드리지 않고 무언가 기능을 추가하는 것을 말한다. 

 

이렇게 추가가 된 것을 확인할 수 있다. 

 

javascript에서도 이렇게 구현하고 java에서는 이를 더 간단히 할 수 있는데, interface를 이용하면 정말 쉽다.

Liskov Subtitution Principle - 리스코프 치환 법칙

Any subclass object should be substitutable for the superclass object from which it is derived.

모든 하위 클래스 객체들은 유도된 슈퍼클래스 객체로 대체할 수 있어야 한다. 

script3.js

class Rectangle {
    constructor(width, height){
        this.width = width;
        this.height = height;
    }

    setWidth(width){
        this.width = width
    }

    setHeight(height){
        this.height = height
    }

    area() {
        return this.width * this.height
    }
}

class Square extends Rectangle{
    setWidth(width){
        this.width = width
        this.height = width
    }

    setHeight(height){
        this.height = height
        this.width = height
    }
}

function increaseRectangleWidth(rectangle) {
    rectangle.setWidth(rectangle.width + 1)
}

const rectangle1 = new Rectangle(10, 2)
const square1 = new Square(5, 5)

increaseRectangleWidth(rectangle1)
increaseRectangleWidth(square1)

console.log(rectangle1.area())
console.log(square1.area())

 

Rectangle 클래스를 선언하고 이것을 상속받은 Square 클래스가 있다.

 

Rectangle 은 가로길이와 세로길이를 따로 입력해도 되지만 Square의 경우에는 가로길이와 세로길이는 동일하다. 

 

square의 경우는 가로길이나 세로길이중 하나만 길게 해도 다른 면이 따라서 길이가 커져야 정사각형이 되므로 

 

width를 변경하던, height를 변경하던 동일하게 맞춰진다. 

 

그래서 뭐... 문제는 없어 보인다! 글쎄!

 

근데 Square를 superclass인 Rectangle로 변경하는 순간 원하는 값이 다르게 나온다. 

 

즉, 리스코프 치환 법칙을 위배한 것이다!

 

그러면 어떻게 해야 리스코프 치환 법칙을 위배하지 않을까?

 

class Shape {
    area(){
        //testetset
    }
}

class Rectangle extends Shape{
    constructor(width, height){
        this.width = width;
        this.height = height;
    }

    setWidth(width){
        this.width = width
    }

    setHeight(height){
        this.height = height
    }

    area() {
        return this.width * this.height
    }
}

class Square extends Shape{
    setWidth(width){
        this.width = width
        this.height = width
    }

    setHeight(height){
        this.height = height
        this.width = height
    }
}

function increaseRectangleWidth(rectangle) {
    rectangle.setWidth(rectangle.width + 1)
}

const rectangle1 = new Rectangle(10, 2)
const square1 = new Square(5, 5)

increaseRectangleWidth(rectangle1)
increaseRectangleWidth(square1)

console.log(rectangle1.area())
console.log(square1.area())

 

Rectangle와 Square에서 상속할 Shape 클래스를 하나 생성하는 것이다. 

 

다른 예를 들어보자.

 

class Bird {
    fly() {
        console.log('I can fly');
    }
}

class Duck extends Bird {
    quack() {
        console.log('i can quack');
    }
}

class Penguin extends Bird {
    fly() {
        throw new Error('Cannot fly');
    }

    swim() {
        console.log('i can swim');
    }
}

function makeBirdFly(bird) {
    bird.fly();
}

const duck = new Duck();
const penguin = new Penguin();

makeBirdFly(duck);
makeBirdFly(penguin);

 

언뜻 보기에 크게 문제가 없어 보인다. 하지만 실행하는 순간 에러가 발생한다. 

 

왜냐하면 펭귄을 하늘을 날 수 없기 때문이다... 슬픔...

 

 

리스코프 치환 법칙에 따르면 모든 서브 클래스는 상위 클래스가 가지고 있는 기능을 다 사용할 수 있어야 한다. 

 

고로, 법칙을 위배한다. 

 

이것을 올바르게 수정하면 다음과 같다. 

 

class FlyingBird {
    fly() {
        console.log('I can fly');
    }
}

class SwimmingBird {
    swim() {
        console.log('i can swim');
    }
}

class Duck extends FlyingBird {
    quack() {
        console.log('i can quack');
    }
}

class Penguin extends SwimmingBird{
}

function makeFlyingBirdFly(bird) {
    bird.fly();
}

function makeSwimmingBirdSwim(bird) {
    bird.swim();
}

const duck = new Duck();
const penguin = new Penguin();

makeFlyingBirdFly(duck);
makeSwimmingBirdSwim(penguin);

 

다음 코드는 위의 법칙을 잘 따른다. 즉, 하위 클래스는 상위 클래스의 기능을 깨지 않고 사용할 수 있어야 한다. 

 

참고 : https://www.youtube.com/watch?v=dJQMqNOC4Pc 

Interface segregation principle - 인터페이스 분리 원칙

 No code should be forced to depend on methods it does not use.

사용하지 않는 메소드를 의존하면 안됀다!

script4.js

interface Entity {
    attackDamage
    health
    name

    move()
    attack()
    takeDamage(amount)
}

class Character implements Entity{
    move() {

    }

    attack() {

    }
    
    takeDamage() {

    }
}

class Turret implements Entity{
    move() {

    }
}

 

위 코드를 보면 Turret은 현재 Entity interface를 상속받는데 이러면 인터페이스 분리 원칙에 위배된다!

 

즉, 터렛은 움직이지 않는데 움직이는 메소드를 상속받아서 사용하게 되는 꼴이다. 

 

다시말해, 사용하지 않는 메소드를 상속받아버렸다.

 

그냥 각자의 기능에 맞는 interface로 분리해서 작성하면 된다. 

 

참고 : https://www.youtube.com/watch?v=JVWZR23B_iE&t=6s 

Dependency Inversion Principle - 의존 관계 역전의 원칙

Based on this idea, Robert C. Martin’s definition of the Dependency Inversion Principle consists of two parts

1. 고레벨 모듈은 저 레벨 모듈에 의존해선 안됀다. 둘 다 추상에 의존해야 한다.

2. 추상화는 구체화에 의존해서는 안된다. 구체화는 추상화에 의존해야 한다. 

 

여기서 좀 헷갈릴 수 있는데 이에 대해서도 설명하고 있다.

 

다른 solid 원칙에 근거 이것은 자주 복잡하게 보인다. 결론적으로 개방 폐쇄의 원칙과 리스코프 치환 법칙을 코
드에서 적용하면, 의존 제어 역전의 원칙을 따르는 것이다. 

 

다음 예시를 보자.

 

public class DICTestService{
	//private DICTestRepository disTestRepository1 = new RealDICTestRepository1();
    private DICTestRepository disTestRepository2 = new RealDICTestRepository2();
}

 

내가 임의로 DICTestService를 생성했다.

 

DICTestService를 개방/폐쇄의 원칙으로 확장에는 열려있고, 수정에는 닫혀있도록 RealDICTestRepository1과 RealDICTestRepository2를 생성했다. 이 친구들은 구체화에 의존하지 않고 추상화에 의존해서 DICTestRepository로 반환 타입 변환이 가능하다. 아까 말했지? 개방 폐쇄 원칙과 리스코프 치환 법칙을 지키는거라고!

 

근데 말이다... 제어의 역전이란 여기서 DICTestService가 어떤 것을 구현할지 직접 선택하는 것이다. 

 

위에서 보면 DICTestService로 코드 안에서 구현 클래스를 직접 선택하는 일이 발생한다. 

 

RealDICTestRepository1혹은 RealDICTestRepository2인데,

 

이렇게 되면 변경 시에도 해당 코드로 가서 직접 코드를 변경해야 한다. 

 

DICTestService가 직접 선택한다고 했는데 코드를 변경했자나!

 

뭔가 말이 맞지 않네!

 

그러면 도대체 어떻게 해야 하나? 스프링에서 이 무언가를 더 도와주는 기능을 제공한다. 

 

스프링은 IoC 컨테이너를 제공해서 DI를 할 수 있도록 기능을 제공한다. 


필자는 javascirpt로 설명을 많이 했는데

 

youtube에서 전부 참고한 것이다.

 

그 이유는 oop라는 것이 java에서만 사용하는 특별한 것이 아니라 

 

다른 프로그래밍 언어에서도 개발 생산성과 유지보수의 용이성을 위해 어디든지 도입이 가능하다는 점이다.

 

물론 DI는 내가 이걸 javascript로 써보려니 좀 뭔가... 맞지가 않았기에 간단하게 설명했다.

 

 

 

'디자인 패턴' 카테고리의 다른 글

디자인 패턴의 종류  (0) 2022.03.28