[공부] SOLID 뭔지 알지, 근데 하긴 해?

Junkyu_Kang·2024년 12월 27일

SOLID 원칙은 아마 객체지향을 공부한 사람이라면 모두 알고 있을 거다.

특히 면접 준비한다고 s는 뭐고 o는 뭐고.. 외우기만 하는 사람 많을텐데 비난하진않는다. 나도 그랬으니까..

근데 일 하다보니 과연 나는 옳은 코드를 짜고 있는걸까 생각이 자주 든다.

가장 큰 원인으로 항상 일정에 맞춰 개발하느라 주먹구구식으로 기능 개발하고 결국 리팩토링 하는 과정을 거치는 것이 마음에 걸렸다.

그래서 든 생각이 설계 자체를 잘해보자 라는 생각을 했다!

간단하게 설명해보면 SOLID는 객체 지향 프로그래밍(OOP)에서 유지보수와 확장 측면에서 좋은 설계를 위한 원칙으로 볼 수 있다.

1. 단일 책임의 원칙

정의: 클래스는 단 하나의 책임만 가져야 한다. 즉, 클래스는 변경 이유가 하나뿐이어야 한다.
설명: 하나의 클래스가 너무 많은 역할을 하면 유지보수가 어렵고 버그 발생 확률이 높아지기 때문이다.

위 내용은 아주 간단하면서도 지키지 않는 경우가 많다.
하나의 클래스에 다양한 기능을 넣어 분리가 되지도 않고 책임도 단일이 아니라 리팩토링을 요구하는 경우가 있다.

class User {
    void saveUser() {
        // 사용자 저장
    }

    void validateUser() {
        // 사용자 검증
    }

    void sendEmail() {
        // 이메일 전송
    }
}

위 코드를 보면 User class 안에 기능이 3개가 들어있다. 위 원칙은 class 별 단일 책임이라 하였는데 아니 3개나 책임을 지는게 맞는가!

누가 이렇게 짜나! 죄송합니다!

class User {
    String name;
    String email;
    // 사용자 관련 데이터 및 메서드
}

class UserRepository {
    void saveUser(User user) {
        // 사용자 저장
    }
}

class UserValidator {
    boolean validateUser(User user) {
        // 사용자 검증
        return true;
    }
}

class EmailService {
    void sendEmail(User user) {
        // 이메일 전송
    }
}

클래스를 나눌 때 기능별로 나누어 적용하는 것이 단일 책임의 원칙이다.
하지만 클래스를 여러개 만들 때 아무래도 비효율적인 면이 있어 보통은 repository와 service 쪽에서 domain 별로 관리를 하곤 한다.

하지만 기능별로 class를 나누어 관리를 하다보니 문제가 되는 부분에 대한 파악이 빠르고 유지보수도 관계가 얽혀있지 않아 빠르게 조정할 수 있었따.

기억하자 필수는 아니지만 각 클래스는 하나의 책임만 가진다.

2. 개방-폐쇄 원칙 (Open/Closed Principle, OCP)

정의: 소프트웨어 엔티티(클래스, 모듈, 함수 등)는 확장에 열려 있어야 하며, 변경에는 닫혀 있어야 한다.
설명: 기존 코드를 수정하지 않고도 새로운 기능을 추가할 수 있어야 한다.

class Rectangle {
    double width, height;
}

class Circle {
    double radius;
}

class AreaCalculator {
    double calculateArea(Object shape) {
        if (shape instanceof Rectangle) {
            Rectangle rect = (Rectangle) shape;
            return rect.width * rect.height;
        } else if (shape instanceof Circle) {
            Circle circle = (Circle) shape;
            return Math.PI * circle.radius * circle.radius;
        }
        return 0;
    }
}

잘못된 예시를 보자. 도형의 높이와 너비가 지정되어 있다.
그리고 AreaCalculator에서 도형과 원의 넓이를 지정해놓았지만 새로운 기능이 추가되어야한다면?

혹시 else if를 추가할건가요?
흠.. 되긴하겠지만 가독성 면에서 별로지 않을까?

interface Shape {
    double calculateArea();
}

class Rectangle implements Shape {
    double width, height;

    public double calculateArea() {
        return width * height;
    }
}

class Circle implements Shape {
    double radius;

    public double calculateArea() {
        return Math.PI * radius * radius;
    }
}

class AreaCalculator {
    double calculateArea(Shape shape) {
        return shape.calculateArea();
    }
}

그래서 이런 식으로 각 모양에 따른 계산식을 나누어 interface를 통해 implement로 shape를 받아 계산식 자체를 class로 만든 것을 볼 수 있을 것이다.
위 내용은 유지보수에 용이할 뿐만 아니라 새로운 기능 추가도 되며, 캡슐화를 진행할 수 있다는 장점이 있다!

3. 리스코프 치환 원칙 (Liskov Substitution Principle, LSP)

정의: 자식 클래스는 언제나 부모 클래스를 대체할 수 있어야 한다.
설명: 부모 타입으로 선언된 객체가 자식 클래스의 객체로 대체되더라도 프로그램은 정상적으로 동작해야 한다.

class Bird {
    void fly() {
        System.out.println("Flying");
    }
}

class Penguin extends Bird {
    @Override
    void fly() {
        throw new UnsupportedOperationException("Penguins cannot fly!");
    }
}

위 코드를 보면 팽귄이 새를 상속 받았지만 fly 메서드를 쓸 수 없다!

interface Bird {
    void makeSound();
}

interface FlyingBird extends Bird {
    void fly();
}

class Sparrow implements FlyingBird {
    public void makeSound() {
        System.out.println("Chirp Chirp");
    }

    public void fly() {
        System.out.println("Flying");
    }
}

class Penguin implements Bird {
    public void makeSound() {
        System.out.println("Honk Honk");
    }
}

자녀는 항상 부모를 대체할 수 있어야하기 때문에 인터페이스를 구현하여 sparrow를 구현함으로써 구분하고 진행할 수 있게 되었다!

결국 상속 받는 내용에 대해 자세히 알고 있어야 한다.
해당 내용을 상속받지만 쓸 수 없는 내용이라면 어떻게 할건가? 그럼 에러만 던지고 말건가?
그것보단 어떻게 유동적으로 유도리있게 쓸 수 있는가를 생각해야하지 않을까?

4. 인터페이스 분리 원칙 (Interface Segregation Principle, ISP)

정의: 클라이언트는 자신이 사용하지 않는 메서드에 의존하지 않아야 한다.
설명: 하나의 큰 인터페이스보다는 여러 개의 작은 인터페이스로 분리해야 한다.

interface Worker {
    void work();
    void eat();
}

class Robot implements Worker {
    public void work() {
        System.out.println("Working");
    }

    public void eat() {
        throw new UnsupportedOperationException("Robots do not eat!");
    }
}
interface Worker {
    void work();
    void eat();
}

class Robot implements Worker {
    public void work() {
        System.out.println("Working");
    }

    public void eat() {
        throw new UnsupportedOperationException("Robots do not eat!");
    }
}

위 코드를 보자. 로봇은 worker를 상속 받았지만 로봇이 먹는 기능이 필요한가? 아니지 아니한가.
즉 필요한 인터페이스는 분리하여 사용한단 얘기가 된다.

interface Workable {
    void work();
}

interface Eatable {
    void eat();
}

class Human implements Workable, Eatable {
    public void work() {
        System.out.println("Working");
    }

    public void eat() {
        System.out.println("Eating");
    }
}

class Robot implements Workable {
    public void work() {
        System.out.println("Working");
    }
}

위 코드를 보면 먹는것과 일하는 것을 분리하여 상속하였다.
인간에 대해서는 두 인터페이스를 넘겨줌으로써 모두 구현이 가능하고 로봇의 경우 필요한 것만 넘겨줘 구현하는 것을 볼 수 있다.

5. 의존 역전 원칙 (Dependency Inversion Principle, DIP)

정의: 고수준 모듈은 저수준 모듈에 의존해서는 안 되며, 둘 다 추상화에 의존해야 한다.
설명: 구현이 아닌 인터페이스나 추상 클래스에 의존해야 한다.

class Keyboard {
}

class Computer {
    private Keyboard keyboard;

    public Computer() {
        keyboard = new Keyboard();
    }
}

잘못된 예시를 보면 computer는 keyboard의 구체적인 구현에 의존한다. 이렇게 될 경우 computer는 keyboard를 따로 선언하여 사용함에 있어 불편함을 겪는다. 차라리 인터페이스를 분리하여 사용한다면 다르게 사용이 가능하다.

interface Keyboard {
    void type();
}

class MechanicalKeyboard implements Keyboard {
    public void type() {
        System.out.println("Typing on Mechanical Keyboard");
    }
}

class Computer {
    private Keyboard keyboard;

    public Computer(Keyboard keyboard) {
        this.keyboard = keyboard;
    }

    public void useKeyboard() {
        keyboard.type();
    }
}

위 코드를 보면 computer는 keyboard의 인터페이스에 의존하지만 구체적인 구현에 의존하는 것이 아닌 필요한 내용에 대해서만 사용하는 것을 알 수 있다. 즉 키보드의 객체를 새롭게 선언하여 사용하는 것이 아닌 추상 클래스를 사용하여 할 수 있도록 진행하는 것이다.

물론 나도.. 다 지키지 못하고 까먹고 실수하고 하지만
항상 생각해보려고 한다.. 어렵지만서도 성장할 방법이 아닐까

그래도 계속 공부해보자고..

keep going!!

profile
강준규

0개의 댓글