객체지향 설계 5원칙 - SOLID

2023. 4. 12. 19:38ETC/OOP

이전에 작성한 객체 지향 생활 체조 글에서 SOLID 원칙을 언급한 바 있다.

SOLID 원칙을 잘 적용하기 위해 객체 지향 생활 체조를 적용한다고 했었는데 그렇다면 이제 SOLID에 대해 알아보도록 하자.

2023.03.03 - [Java] - 객체지향 생활 체조

 

객체 지향 생활 체조

친형이 자바를 이용한 미션을 주고 코드리뷰를 진행해 주었다. 이때 제일 먼저 알려준 것이 바로 객체지향 생활 체조 9가지 원칙이다. SOLID 원칙을 적용하기 위한 객체지향 생활 체조 9 원칙은 코

koomchang.tistory.com

 

SOLID 원칙이란?

SOLID 원칙이란 객체지향 프로그래밍을 위해 지켜야 할 원칙 5가지의 첫 글자를 딴 것이다.

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

객체지향 프로그래밍의 설계를 위해 지켜야 할 원칙들이고, 이후 유지 보수와 확장이 쉽게 만들기 위함이다.

 

 

단일 책임 원칙 (SRP)

하나의 클래스 혹은 모듈은 단 하나의 책임을 가져야 한다.

소프트웨어에서 하나의 클래스가 여러 책임(기능)을 가지고 있다면 어떤 변경사항이 생겼을 때 한 측면을 변경하면 여러 측면이 영향을 받을 가능성이 크기 때문이다. 즉 응집도는 높이고 결합도는 낮추는 것이

중요하다.

더보기

응집도 : 모듈이 독립적인 기능으로 정의되어 있는 정도

결합도 : 모듈간의 상호 의존 정도

예시

class User 가 있고 이메일을 보내는 기능, 비밀번호 변경 기능, admin으로 바꿔주는 기능이 있다고 가정하자.

 

단일 책임 원칙을 적용하지 않은 코드는 다음과 같을 것이다.

public class User {
    private String username;
    private String password;
    private String email;
    private boolean isAdmin;

    public void sendEmail(String message) {
        // 메일 보내는 기능
    }

    public void changePassword(String newPassword) {
        // 비밀번호 변경 기능
    }

    public void promoteToAdmin() {
        // admin으로 바꾸는 기능
    }
}

 

만약 우리가 email 보내는 기능에 알림을 추가한다고 가정하면 우리는 User라는 class에서 해당 작업을 해야 한다. 분명 email과 관련된 작업을 하는데 왜 User class에서 작업을 하는 것일까? 이런 접근보다는 단일 책임 원칙에 맞게 class를 분리해 주는 작업을 하는 것이 적합할 것 같다. 아래 개선 코드를 보자

public class User {
    private String username;
    private String password;
    private String email;
    private boolean isAdmin;
}

public class EmailService {
    public void sendEmail(User user, String message) {
        // 메일 보내는 기능
    }
}

public class UserManager {
    public void changePassword(User user, String newPassword) {
        // 비밀번호 변경 기능
    }

    public void promoteToAdmin(User user) {
        // admin으로 바꾸는 기능
    }
}

이제 우리는 email에서 알림 기능을 추가할 때 User가 아닌 EmailService 라는 적합한 class에서 작업을 할 수 있다. 물론 알람 기능도 Alarm class를 만들어서 분리하면 더 좋은 코드가 될 것이다. 하지만 User class의 관점에서 보면 User가 많은 책임을 가지고 있지 않고 해당 기능에 맞는 class들로 나누었을 때 유지보수도 수월하며 확장에서도 이점을 누릴 수 있다는 것을 알 수 있다.

 

 

개방 폐쇄 원칙 (OCP)

확장(기능 추가)에 대해서는 개방적이고 수정에 대해서는 폐쇄적이어야 한다.

확장은 가능하지만 수정사항에 대해 코드 수정은 지양한다는 뜻이다. 바로 예시로 보자

 

OCP 적용 전 코드

public enum ShapeType {
    RECTANGLE,
    CIRCLE
}

public class Shape {
    private ShapeType type;
    private double width;
    private double height;
    private double radius;

    public double area() {
        if (type == ShapeType.RECTANGLE) {
            return width * height;
        } else if (type == ShapeType.CIRCLE) {
            return Math.PI * radius * radius;
        } else {
            throw new IllegalArgumentException("Unknown shape type");
        }
    }
}

Shape이라는 class가 있다. area() 메소드는 넓이를 구하는 메소드이다. 만약 ShapeType에 사각형 등 다른 도형이 추가된다면 어떨까? 이 코드만 보았을 때는 ShapeType에 추가해 주고, Shape class의 area 메소드에서 else if문을 추가하여 그에 맞는 코드를 넣어줘야 한다. 모든 도형의 넓이를 다루는 area() 메소드를 직접 건드리는 것은 너무 위험하다. 도형이 1000개가 추가된다면 저 코드에다가 다 넣을 것인가? 절대 아닐 것이다. 때문에 이를 OCP를 적용하여 개선해 보자.

 

개선 코드

public abstract class Shape {
    public abstract double area();
}

public class Rectangle extends Shape {
    private double width;
    private double height;

    @Override
    public double area() {
        return width * height;
    }
}

public class Circle extends Shape {
    private double radius;

    @Override
    public double area() {
        return Math.PI * radius * radius;
    }
}

Shape라는 추상클래스를 만들고 area()라는 추상메소드를 정의해둔다. 이를 상속해서 쓴다면 각기 다른 class 안에서 area() 메소드를 구현할 수 있다. 다른 도형을 추가하더라도 해당 도형의 클래스를 만든 후 상속 받은 추상메소드를 정의해 주면 된다. 기존 코드를 수정하지 않고 확장할 수 있다는 것 이것이 OCP이다. 이런 식으로 OCP를 사용하면 기능 확장, 재사용, 유지 보수 등에 도움이 된다

 

 

리스코프 치환 원칙 (LSP)

하위 타입은 언제나 자신의 상위 타입으로 교체할 수 있어야 한다. -> 다형성을 지원하기 위한 원칙

동물 하위에는 포유류, 파충류, 조류 등이 있다. 포유류 하위에는 고래, 박쥐, 펭귄이 있다.

포유류 pororo = new 박쥐();

동물 pororo = new 박쥐();

모두 모순 없이 말이 된다.

 

아래 코드에는 외할아버지, 외할머니 하위에 어머니가 있고 어머니 아래에 '나'가 있다.

외할아버지 금장 = new 나();

어머니 금장 = new 나();

위의 코드는 말이 안 된다.

 

동물, 포유류, 박쥐의 예시처럼 하위 타입 객체를 상위 타입 객체로 치환하여도 문제가 생기지 않는다. 하지만 외할아버지, 외할머니, 어머니, 나 의 예시에서는 치환하면 말이 안 된다.

 

class Animal {
    int speed = 10;

    int move(int distance) {
        return speed * distance;
    }
}

class Bird extends Animal {
    String move(int distance, boolean flying) {
        if (flying)
            return distance + "만큼 날아서 갔습니다.";
        else
            return distance + "만큼 걸어서 갔습니다.";
    }
}

public class Main {
    public static void main(String[] args) {
        Animal crow = new Bird();
        crow.move(10, true);
    }
}

 

Bird crow = new Bird(); // 성공
Animal crow = new Bird(); // 오류 -> 리스코프 치환 원칙에 위배

리스코프 치환 원칙을 잘 지켜야 객체지향에서 다형성을 잘 살릴 수 있다.

 

 

인터페이스 분리 원칙 (ISP)

인터페이스를 구체적으로 분리해서 자신과 관련 없는 메소드는 구현하지 않아야 한다.

Printer라는 interface를 만들자. 이 Printer는 프린트, 스캔, 팩스 기능이 있다.

public interface Printer {
    void print(Document document);
    void scan(Document document);
    void fax(Document document);
}

그런데 나의 프린터는 print 기능 밖에 없다.

public class MyPrinter implements Printer {
    @Override
    public void print(Document document) {
        // 프린트 하는 코드
    }
    
    @Override
    public void scan(Document document) {
        // 이 기능은 없는 프린터임
    }
    
    @Override
    public void fax(Document document) {
        // 이 기능은 없는 프린터임
    }
}

나의 프린터는 관련도 없는 메소드를 이용하게 된다. 당연히 불필요하고 없어야 되는 메소드 들이다.

기존에 만들었던 Printer라는 interface를 분리해 보자.

public interface Printer {
    void print(Document document);
}

public interface Scanner {
    void scan(Document document);
}

public interface Fax {
    void fax(Document document);
}

interface도 단일 책임 원칙처럼 기능 별로 나누어 구현을 한다.

자, 이제 나의 프린터를 만드는데 이번에는 print, scan이 가능하다고 해보자. 그럼 이렇게 구현할 수 있을 것이다.

public class MyPrinter implements Printer, Scanner {
    @Override
    public void print(Document document) {
        // 프린트 하는 코드
    }
    
    @Override
    public void scan(Document document) {
        // 이 기능은 없는 프린터임
    }
}

나에게 필요한 기능을 가진 interface만 가지고 구현이 가능하다.

interface를 구체적으로 분리해서 자신과 관련 없는 메소드는 구현하지 않도록 하자.

 

 

의존 역전 원칙 (DIP)

객체는 저수준 모듈보다 고수준 모듈에 의존해야 한다.

더보기

고수준 모듈 : 추상적이고 세부적인 사항은 덜 구체적인 잘 변화하지 않는 모듈 -> interface

저수준 모듈 : 세부적인 사항이 구체적이고 변화하기 쉬운 모듈 -> 구현된 객체

이 말인즉슨, 객체는 다른 객체보다 인터페이스에 의존하는 것이 좋다는 것이다.

 

DIP를 적용하지 않은 코드

public class Americano {
    public void prepare() {
        System.out.println("아메리카노 준비 중");
    }
}

public class Person {
    private Americano americano;

    public Person() {
        americano = new Americano();
    }

    public void drinkCoffee() {
        System.out.println("커피 주문");
        americano.prepare();
    }
}

public class Main {
    public static void main(String[] args) {
        Person person = new Person();
        person.drinkCoffee(); //
    }
}

Person class는 Americano class에 의존하고 있다. 커피가 하나라면 상관없겠지만 커피숍에서 여러 종류의 커피를 더 내놓았다고 생각해 보자. 카페라떼, 바닐라 라떼, 에스프레소 등 여러 커피가 추가된다면 우리는 각각의 class를 만들어줄 뿐 아니라 Person 클래스 내부도 수정이 필요하다.

 

DIP 적용 후 코드

public interface Coffee {
    void prepare();
}

public class Americano implements Coffee {
    public void prepare() {
        System.out.println("아메리카노 준비 중");
    }
}

public class Person {
    private Coffee coffee;

    public Person(Coffee coffee) {
        this.coffee = coffee;
    }

    public void drinkCoffee() {
        System.out.println("커피 주문");
        coffee.prepare();
    }
}

public class Main {
    public static void main(String[] args) {
        Coffee americano = new Americano();
        Person person = new Person(americano);
        person.drinkCoffee();
    }
}

커피가 추가되더라도 Person 클래스의 변동을 없애기 위해서 Coffee라는 interface를 만들고 Americano 클래스가 Coffee interface를 상속할 수 있도록 만들어 주었다. 이런 식으로 구현하면 커피가 추가되더라도 (기능이 추가되더라도) Coffee를 상속받는 새로운 커피 클래스를 만들고 Main 함수에서만

Coffee espresso = new Espresso();
Person person = new Person(espresso);

로 바꿔 주면 된다. Dependency Injection(DI) 개념을 이용하는 것이다. 기능이 추가되더라도 Person 클래스에는 변화가 생기지 않는다. 이런식으로 객체가 고수준 모듈에 의존하면 변경사항이 생기더라도 다른 부분에 영향을 끼치지 않을 수 있고 기능도 쉽게 추가, 제거할 수 있다. 또한 테스트도 다른 코드는 변경되지 않았으니 변경된 코드만 테스트해 보면 된다.

 

 

이 글을 쓰며

SOLID 원칙을 이해하는 데는 어렵지 않았다. 하지만 왜 이 원칙을 쓰는 것이 좋지에 대해 생각하는 것은 상당히 오래 걸렸다. 원칙을 적용하지 않았을 예시에서 기능 추가 등의 변경사항이 생겼을 때 어떤 점이 불편할지에 대해 생각해 보게 되었고 이를 통해 SOLID 원칙을 쓰면 추후 리팩토링이나 기능 추가 할 때 훨씬 신속하고 정확한 작업을 할 수 있을 것이라고 생각이 들었다.