카테고리 없음

왜 SOLID 원칙을 지켜야 하는가?

kyoulho 2024. 3. 10. 17:25

솔리드 원칙은 로버트 C. 마틴(Robert C. Martin)이 소개하였으며, 코드의 설계를 개선하고 유지보수성을 향상시키기 위한 다섯 가지 원칙을 의미한다. 개발자 면접에 단골 질문이기도 한 솔리드 원칙, 그렇다면 우리는 왜 솔리드 원칙을 지키면서 설계해야 하는 걸까??

 

SRP (Single Responsibility Principle)


단일 책임 원칙(SRP)은 하나의 클래스는 하나의 책임만 가져야 한다는 원칙이다.
 

왜 SRP를 지켜야 하는가?

한 객체에 책임이 많아질수록 클래스 내부에서 서로 다른 역할을 수행하는 코드끼리 강하게 결합될 가능성이 높아지게 된다. 책임이 이것저것 포함된 클래스는 한 책임의 변경에서 다른 책임의 변경으로의 연쇄작용이 일어나게 된다.
어떠한 역할에 대해 변경사항이 발생했을 때, 변경 영향을 받는 기능만 모아둔 클래스라면 그 책임을 지니고 있는 클래스만 수정해 주면 된다
 

SRP를 준수하는 예시

로깅 기능을 수행하는 클래스를 SRP를 준수하도록 분리한 예시를 살펴보자.

// SRP를 지키지 않은 클래스
class Logger {
    public void logToConsole(String message) {
        System.out.println("Log to console: " + message);
    }

    public void logToFile(String message) {
        try (FileWriter fileWriter = new FileWriter("logfile.txt", true);
             PrintWriter printWriter = new PrintWriter(fileWriter)) {
            printWriter.println("Log to file: " + message);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

// SRP를 지키도록 분리한 클래스
class ConsoleLogger {
    public void log(String message) {
        System.out.println("Log to console: " + message);
    }
}

class FileLogger {
    public void log(String message) {
        try (FileWriter fileWriter = new FileWriter("logfile.txt", true);
             PrintWriter printWriter = new PrintWriter(fileWriter)) {
            printWriter.println("Log to file: " + message);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

위의 예시에서 Logger 클래스는 두 가지 책임, 즉 콘솔에 로그를 출력하고 파일에 로그를 기록하는 두 가지 일을 수행한다. 그러나 SRP를 준수하도록 ConsoleLoggerFileLogger로 책임을 분리하면 각 클래스는 단일 책임을 갖게 되어 코드의 명확성과 유지보수성이 향상된다.

 

OCP (Open/Closed Principle)


개방폐쇄원칙(OCP)은 기존의 코드를 변경하지 않고 새로운 기능을 추가할 수 있도록 설계해야 한다는 원칙이다.

왜 OCP를 지켜야 하는가?

나는 지금까지 완벽하게 OCP를 지켜가며 업무하는 개발자를 본 적이 없다. 그렇다고 OCP가 무시되어도 되는 원칙은 아니다. 단지 OCP를 지키기 위해서는 다양한 디자인 패턴을 적용하거나, 적절한 때의 리팩토링을 진행해야 하기에 아무나 할 수 있는 일은 아니라고 생각한다.

 

OCP를 준수하면서 개발을 한다면, 인터페이스의 기능이 변경될 때마다 새로운 구현체를 만들어야 하고 버전 관리가 어려워진다. 이 문제를 해결하기 위해서는 전략 패턴을 떠올릴 수 있다. 새로운 전략 구현체를 만들어서 메서드의 기능을 위임하고 전략만 교체하면 된다.

만일 메소드가 20개쯤 된다면? 그때는 데코레이터 패턴 또는 프락시 패턴을 사용하여 문제를 해결할 수 있을 것이다.

그렇지만 요구사항이 빈번하게 변경된다면 얼마나 많은 클래스를 만들게 될까? 시스템은 엄청나게 복잡해질 것이다. 그렇다면 현실적으로 OCP는 지킬 수 없는 것일까? 하지만 OCP가 지켜져야 하는 이유는 확실하다. 코드가 변경될 때마다 발생하는 사이드 이펙트와 기존 테스트 코드에 수정이 이에 해당한다고 할 수 있다. 이러한 일들은 개발자를 지치게 만들고, 문제가 발생하면 그때 대응해야지 하는 마음가짐을 갖게 해 테스트에 소홀해질 수 있다.

 

 따라서 현실적으로 OCP를 지키기 위해서는 처음부터 모든 것을 추상화하려고 하지 말고 변경 가능성이 높은 부분에 초점을 맞추어 적절한 추상화를 적용하고, 단일 책임 원칙을 통해 클래스나 모듈의 책임을 명확히 하며, 정기적으로 리팩토링을 통해 코드 구조를 개선하는 것이 중요하다. 디자인 패턴을 적절히 활용하는 것도 필수적이다.

 

 

OCP를 준수하는 예시

도형을 그리는 코드를 예시로 살펴보자.

// OCP를 지키지 않은 코드
class Drawing {
    public void drawCircle() {
        System.out.println("Drawing a circle");
    }

    public void drawSquare() {
        System.out.println("Drawing a square");
    }
}

// OCP를 지키도록 분리한 코드
interface Shape {
    void draw();
}

class Circle implements Shape {
    @Override
    public void draw() {
        System.out.println("Drawing a circle");
    }
}

class Square implements Shape {
    @Override
    public void draw() {
        System.out.println("Drawing a square");
    }
}

class Drawing {
    public void drawShape(Shape shape) {
        shape.draw();
    }
}

위의 예시에서 Drawing 클래스는 OCP를 지키지 않은 코드이다. 새로운 도형이 추가되면 Drawing 클래스를 수정해야 한다.

그러나 OCP를 준수하도록 개선한 코드에서는 Shape 인터페이스를 도입하여 새로운 도형이 추가될 때 Drawing 클래스를 수정하지 않고도 기능을 확장할 수 있다.

 

 

LSP (Liskov Substitution Principle)


리스코프 치환 원칙(LSP)은 자식은 부모를 대체할 수 있어야 한다는 원칙이다.
 

왜 LSP를 지켜야 하는가?

리스코프 치환 원칙의 핵심은 부모 클래스의 행동 규약을 자식 클래스가 위반하면 안 된다는 것이다.

우리는 "자식은 부모를 대체할 수 있어야 한다."가 아니라 "자식은 부모를 대체할 수 있다"라고 배웠다. 우리에게 LSP는 지켜야 하는 규약이라기보다는 사실에 가깝다고 할 수 있다. 그렇다면 왜 LSP는 가장 중요하다는 다섯 가지 중에 하나가 되었을까?

코드의 명세를 어기는 것은 코드의 동작을 예측할 수 없게 만들며 협업하는 개발자들 간의 신뢰를 깨버리는 행위라고 할 수 있다. 때문에 LSP는 개발자들 간의 협업과 신뢰를 강조하는 것이 아닐까 생각해 본다.
 

LSP를 준수하는 예시

동물을 추상화한 예시이다.

// LSP를 지키지 않은 코드
class Bird {
    public void fly() {
        System.out.println("Flying");
    }
}

class Ostrich extends Bird {
    public void fly() {
        throw new RuntimeException("Ostrich can't fly");
    }
}

// LSP를 지키도록 분리한 코드
interface Flyable {
    void fly();
}

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

class Ostrich implements Flyable {
    public void fly() {
        System.out.println("I can't fly");
    }
}

첫 번째 코드에서는 Ostrich 클래스가 Bird 클래스를 상속하지만, 날지 못하는 타조는 예외를 발생시키고 있다.

두 번째 코드에서는 Flyable 인터페이스를 도입하여 날 수 있는 동물과 날지 못하는 동물을 구분하고 있다.

LSP를 지켜서 타조가 날 수 없음을 명시적으로 표현함으로써, 클라이언트 코드에서는 다양한 동물을 다룰 때 예상치 못한 동작을 방지할 수 있다.

 

ISP (Interface Segregation Principle)


인터페이스 분리 원칙(ISP)은 범용적인 인터페이스보다는 인터페이스를 사용에 맞게 끔 각기 분리해야 한다는 설계 원칙이다.
 

왜 ISP를 지켜야 하는가?

ISP를 준수하지 않으면 사용되지 않는 불필요한 기능을 구현해야 하기 때문이다. 또한 큰 인터페이스에서 변경이 발생하면 해당 인터페이스를 구현한 모든 클래스가 영향을 받게 된다.
 

ISP를 준수하는 예시

작업자(Worker) 인터페이스가 여러 기능을 포함하고 있는 예시이다.

// ISP를 지키지 않은 코드
interface Worker {
    void work();
    void eat();
    void sleep();
}

class Programmer implements Worker {
    @Override
    public void work() {
        System.out.println("Programming");
    }

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

    @Override
    public void sleep() {
        System.out.println("Sleeping");
    }
}

// ISP를 지키도록 분리한 코드
interface Workable {
    void work();
}

interface Eatable {
    void eat();
}

interface Sleepable {
    void sleep();
}

class Programmer implements Workable, Eatable, Sleepable {
    @Override
    public void work() {
        System.out.println("Programming");
    }

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

    @Override
    public void sleep() {
        System.out.println("Sleeping");
    }
}

첫 번째 코드에서는 Worker 인터페이스가 work, eat, sleep 세 가지 메서드를 포함하고 있다. 하지만 이는 모든 작업자가 가져야 할 필수 기능과 선택적인 기능이 함께 묶여있다고 볼 수 있다.

두 번째 코드에서는 Workable, Eatable, Sleepable 세 개의 작은 인터페이스로 분리하여 각 작업자는 필요한 인터페이스만을 구현하면 되므로 의존성이 최소화되고 유지보수성이 향상된다.

 

DIP (Dependency Inversion Principle)


의존성 역전 원칙(DIP)은 고수준 모듈은 저수준 모듈에 의존해서는 안 되며, 둘 모두 추상화에 의존해야 한다는 원칙이다.

 

왜 DIP를 지켜야 하는가?

 

DIP 준수는 단순히 코드의 변경에 대한 민감성을 줄이는 것을 넘어, 소프트웨어의 유연성과 유지보수성을 극대화하기 위함이다. DIP의 핵심은 고수준 모듈이 저수준 모듈의 세부 구현에 의존하지 않고, 오히려 추상화된 인터페이스에 의존하도록 함으로써 시스템의 결합도를 낮추고 응집도를 높이는 것이다.
 

DIP를 준수하는 예시

전구를 켜고 끄는 기능을 추상화한 예시이다.

// DIP를 지키지 않은 코드
class Switch {
    private LightBulb lightBulb;

    public Switch(LightBulb lightBulb) {
        this.lightBulb = lightBulb;
    }

    public void turnOn() {
        lightBulb.turnOn();
    }

    public void turnOff() {
        lightBulb.turnOff();
    }
}

class LightBulb {
    public void turnOn() {
        System.out.println("Light bulb turned on");
    }

    public void turnOff() {
        System.out.println("Light bulb turned off");
    }
}

// DIP를 지키도록 분리한 코드
interface Switchable {
    void turnOn();
    void turnOff();
}

class Switch {
    private Switchable device;

    public Switch(Switchable device) {
        this.device = device;
    }

    public void turnOn() {
        device.turnOn();
    }

    public void turnOff() {
        device.turnOff();
    }
}

class LightBulb implements Switchable {
    @Override
    public void turnOn() {
        System.out.println("Light bulb turned on");
    }

    @Override
    public void turnOff() {
        System.out.println("Light bulb turned off");
    }
}

첫 번째 코드에서는 Switch 클래스가 LightBulb 클래스에 직접 의존하고 있다. 하지만 DIP를 준수하도록 두 번째 코드에서는 Switchable 인터페이스를 도입하여 Switch 클래스는 Switchable에만 의존하도록 변경하였다. 이렇게 하면 Switch 클래스는 구체적인 구현에 의존하지 않고 추상화에만 의존하게 된다.

728x90