본문 바로가기
Spring(boot)

SOLID: 좋은 객체 지향 설계의 5가지 원칙

by 글발 2024. 1. 5.
728x90
반응형

소개

객체 지향 설계는 소프트웨어 개발에서 중요한 역할을 합니다.

클린코드로 유명한 로버트 마틴이 좋은 객체 지향 설계의 5가지 원칙을 정리했습니다.

그것이 바로 SOLID입니다.

5가지 원칙에 앞 글자만 따왔습니다. 5가지 원칙은 아래와 같습니다.

  • SRP: 단일 책임 원칙(single responsibility principle)
  • OCP: 개방-폐쇄 원칙 (Open/closed principle)
  • LSP: 리스코프 치환 원칙 (Liskov substitution principle)
  • ISP: 인터페이스 분리 원칙 (Interface segregation principle)
  • DIP: 의존관계 역전 원칙 (Dependency inversion principle)

SOLID 원칙은 효율적이고 유지보수가 용이한 소프트웨어를 만들기 위한 다섯 가지 기본 원칙을 정의합니다.

미리 말씀드리지만 OCP와 DIP를 조금 더 중점적으로 봐야합니다.(중요합니다.)

이 글에서는 SOLID 원칙에 대해 자세히 알아보고,

각 원칙이 실제로 어떻게 적용되는지 예시와 함께 살펴보겠습니다.

1. 단일 책임 원칙 (Single Responsibility Principle - SRP)

단일 책임 원칙은 하나의 클래스는 하나의 책임만 가져야 한다는 원칙입니다.

하나의 책임이라는 것이 가끔은 문맥과 상황에 따라서 모호하기도 합니다.

여기서 중요한 기준은 변경입니다.

변경이 있을 때 파급효과가 적으면 단일 책임 원칙(SRP)을 잘 따른 것이라고 할 수 있습니다.

즉, 클래스는 변경의 이유가 하나면 좋습니다.

이를 통해 코드의 응집성을 높이고, 클래스를 변경할 때 다른 클래스에 영향을 덜 주도록 합니다.

예) UI 변경, 객체의 생성과 사용을 분리

단일 책임 원칙(SRP)을 위반한 사례 코드를 먼저 간단히 보겠습니다.

// SRP를 지키지 않은 예시
class Report {
  void generateReport() {
    // 보고서 생성 로직
  }

  void saveToFile() {
    // 파일에 저장하는 로직
  }
}

위 코드는 SRP를 위반한 경우로, 보고서 생성과 파일 저장이 같은 클래스에 존재합니다.

이를 분리하여 단일 책임 원칙(SRP)를 따르겠습니다.

// SRP를 지킨 예시
class ReportGenerator {
  void generateReport() {
    // 보고서 생성 로직
  }
}

class ReportSaver {
  void saveToFile() {
    // 파일에 저장하는 로직
  }
}

위 코드를 보면 SRP를 지키면서

각 클래스는 하나의 책임만을 가지게 되어 유지보수가 용이해집니다.

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

개방 폐쇄 원칙(OCP)은 확장에는 열려 있어야 하고,

수정(변경)에는 닫혀 있어야 한다는 원칙입니다.

즉, 기존의 코드를 변경하지 않고도 새로운 기능을 추가할 수 있도록 설계해야 합니다.

다형성과 연관이 깊습니다.

개방 폐쇄 원칙(OCP)을 위반한 코드를 먼저 보겠습니다.

// OCP를 위반한 예시
class Rectangle {
  int width;
  int height;
}

class AreaCalculator {
  int calculateArea(Rectangle rectangle) {
    return rectangle.width * rectangle.height;
  }
}

위의 코드를 보면

만약 다른 도형의 넓이를 계산하는 기능이 추가 된다면 Rectangle 클래스를 수정해야 합니다.

다음은 개방 폐쇄 원칙(OCP)을 지킨 예시입니다.

// OCP를 지킨 예시
interface Shape {
  int calculateArea();
}

class Rectangle implements Shape {
  int width;
  int height;

  @Override
  public int calculateArea() {
    return width * height;
  }
}

class Square implements Shape {
  int side;

  @Override
  public int calculateArea() {
    return side * side;
  }
}

인터페이스를 도입하여 각 도형이 Shape를 구현하도록 하면,

새로운 도형이 추가되어도 기존 코드를 수정할 필요가 없습니다.

그렇다면 인터페이스를 이용한 다른 예시를 볼까요?

MemberService 클라이언트가 구현 클래스를 직접 선택하는 예시입니다.

예를 들어서 인터페이스 MemberRepository를 구현한

MemoryMemberRepositoryJdbcMemberRepository가 있다고 가정을 해봅시다.

MemberService에서 기존의 코드가

MemberRepository m = new MemoryMemberRepository(); 였을 때

MemoryMemberRepositoryJdbcMemberRepository 로 변경하고 싶다면 어떻게 할까요?

여기서 개방 폐쇄 원칙(OCP)의 문제가 들어납니다.

방법은 위 코드를 주석치고 다시

MemberRepository m = new JdbcMemberRepository (); 이렇게 선언해주는 것인데

이상하지 않나요?

구현 객체를 변경하려면 클라이언트 코드(MemberService)를 변경해야 합니다.

분명 다형성을 사용했지만 OCP 원칙을 지킬 수 없게 된 것입니다.

이 문제를 어떻게 해결하냐면

객체를 생성하고, 연관관계를 맺어주는 별도의 조립, 설정자가 필요합니다.

그것이 바로 Spring입니다.

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

리스코프 치환 원칙은 자식 클래스는 언제나 부모 클래스를 대체할 수 있어야 한다는 원칙입니다.

다시 말해서,

프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 합니다.

이는 상속 관계에서 하위 클래스는 상위 클래스와 호환성을 유지하면서 사용될 수 있어야 함을 의미합니다.

이 원칙이 필요한 이유는

다형성에서 하위 클래스는 인터페이스 규약을 다 지켜야 한다는 것과,

다형성을 지원하기 위한 원칙이라는 점,

인터페이스를 구현한 구현체는 믿고 사용하려면 이 원칙이 필요합니다.

예를 들어서 자동차 인터페이스의 엑셀은 앞으로 가라는 기능이며

뒤로 가게 구현하면 LSP 위반입니다. 느리더라도 앞으로 가야합니다.

단순히 컴파일에 성공하는 것을 넘어서는 이야기입니다.

다음은 먼저 리스코프 치환 원칙(LSP)를 위반한 예시입니다.

// LSP를 위반한 예시
class Bird {
  void fly() {
    // 새가 날다.
  }
}

class Ostrich extends Bird {
  void fly() {
    throw new UnsupportedOperationException("타조는 날지 못합니다.");
  }
}

타조는 날 수 없는데 Bird 클래스에는 날 수만 있는 fly()만 있습니다.

Ostrich 클래스는 부모 클래스(Bird)의 메소드를 대체할 수 없습니다.

이를 수정해보겠습니다.

// LSP를 지킨 예시
interface Bird {
  void fly();
}

class Sparrow implements Bird {
  void fly() {
    // 참새가 날다.
  }
}

class Ostrich implements Bird {
  void fly() {
    // 타조는 날지 못한다.
  }
}

인터페이스를 사용하여 각각의 새 클래스가 Bird 인터페이스를 구현하도록 하면,

각 클래스는 자신의 특성에 맞게 fly 메소드를 구현할 수 있습니다.

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

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

클라이언트는 자신이 사용하지 않는 메소드에 의존 관계를 맺으면 안된다는 원칙입니다.

즉, 인터페이스는 그 인터페이스를 사용하는 클라이언트를 기준으로 작게 분리되어야 합니다.

특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다는 말이 있습니다.

예를 들어서

  • 자동차 인터페이스 -> 운전 인터페이스, 정비 인터페이스로 분리
  • 사용자 클라이언트 -> 운전자 클라이언트, 정비사 클라이언트로 분리

위 예시처럼 분리하면 정비 인터페이스 자체가 변해도 운전자 클라이언트에 영향을 주지 않습니다.

페이스가 명확해지고, 대체 가능성이 높아집니다.

마찬가지로 인터페이스 분리 원칙 (ISP)를 위반한 예시코드를 먼저 보겠습니다.

// ISP를 위반한 예시
interface Worker {
  void work();
  void eat();
}

class Engineer implements Worker {
  void work() {
    // 엔지니어가 일한다.
  }

  void eat() {
    // 엔지니어가 식사한다.
  }
}

class Waiter implements Worker {
  void work() {
    // 웨이터가 일한다.
  }

  void eat() {
    // 웨이터가 식사한다. (오류 발생)
  }

웨이터는 eat 메소드를 구현해야 하는데,

이는 웨이터가 식사를 하는 개념이 아니기 때문에(예시) ISP를 위반합니다.

수정해보겠습니다.

// ISP를 지킨 예시
interface Workable {
  void work();
}

interface Eatable {
  void eat();
}

class Engineer implements Workable, Eatable {
  void work() {
    // 엔지니어가 일한다.
  }

  void eat() {
    // 엔지니어가 식사한다.
  }
}

class Waiter implements Workable {
  void work() {
    // 웨이터가 일한다.
  }
}

인터페이스를 분리하여 각 클래스가 필요한 인터페이스만 구현하도록 하면,

불필요한 의존 관계를 막고 클라이언트가 필요한 메소드에만 의존하게 됩니다.

5. 의존관계 역전 원칙 (Dependency Inversion Principle - DIP)

의존관계 역전 원칙(DIP)은

고수준 모듈은 저수준 모듈에 의존해서는 안되며, 둘 모두 추상화에 의존해야 한다는 원칙입니다.

즉, 추상화된 것은 세부 구현에 의존하면 안되며, 세부 구현이 추상화에 의존해야 합니다.

프로그래머는 추상화에 의존해야지, 구체화에 의존하면 안됩니다.

의존성 주입은 이 원칙 을 따르는 방법 중 하나입니다.

쉽게 이야기해서 구현 클래스에 의존하지 말고, 인터페이스에 의존하라는 뜻입니다.

객체 세상도 클라이언트가 인터페이스에 의존해야 유연하게 구현체를 변경할 수 있습니다.

구현체에 의존하게 되면 변경이 아주 어려워집니다.

의존관계 역전 원칙(DIP)을 위반한 예시코드를 먼저 보겠습니다.

// DIP를 위반한 예시
class LightBulb {
  void turnOn() {
    // 전구를 켠다.
  }

  void turnOff() {
    // 전구를 끈다.
  }
}

class Switch {
  LightBulb bulb;

  Switch(LightBulb bulb) {
    this.bulb = bulb;
  }

  void operate() {
    // 스위치를 조작한다.
    if (bulb.isOn()) {
      bulb.turnOff();
    } else {
      bulb.turnOn();
    }
  }
}

스위치 클래스가 LightBulb 클래스에 직접 의존하고 있어 DIP를 위반합니다.

수정하겠습니다.

// DIP를 지킨 예시
interface Switchable {
  void turnOn();
  void turnOff();
}

class LightBulb implements Switchable {
  void turnOn() {
    // 전구를 켠다.
  }

  void turnOff() {
    // 전구를 끈다.
  }
}

class Switch {
  Switchable device;

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

  void operate() {
    // 스위치를 조작한다.
    if (device.isOn()) {
      device.turnOff();
    } else {
      device.turnOn();
    }
  }
}

Switch 클래스가 Switchable 인터페이스에 의존함으로써,

높은 수준의 모듈(Switch)이 저수준 모듈(LightBulb)에 의존하지 않도록 DIP를 준수하고 있습니다.

그런데 개방 폐쇄 원칙(OCP) 에서 설명한 MemberService는

인터페이스에 의존하지만, 구현 클래스도 동시에 의존합니다.

MemberService 클라이언트가 구현 클래스를 직접 선택하기 때문입니다.

ex) MemberRepository m = new MemoryMemberRepository();

이것은 DIP 위반인데 어떻게 해결할까요?

마찬가지로 Spring에서 해결이 가능합니다.

마무리

SOLID 원칙은 객체 지향 설계의 기본 원칙으로,

효율적이고 확장 가능한 소프트웨어를 만들기 위한 중요한 지침을 제공합니다.

이 원칙들을 잘 이해하고 적용하면, 코드의 유지보수성이 향상되고 새로운 기능을 추가하기가 더욱 수월해집니다.

따라서 SOLID 원칙을 적용하여 좋은 객체 지향 소프트웨어를 개발하는 데에 주목하시기 바랍니다.

추가적으로 더 정리를 해보자면

SOLID의 설명을 보면 다형성과 연관이 깊다는 것을 알 수 있습니다.

객체 지향의 핵심은 다형성입니다.

하지만 다형성만으로는 쉽게 부품을 갈아 끼우듯이 개발할 수 없으며

구현 객체를 변경할 때 클라이언트 코드도 함께 변경됩니다.

즉, 다형성 만으로는 OCP, DIP를 지킬 수 없습니다.

이것을 Spring이 가능하게 해줍니다.

스프링은 다음 기술로 다형성 + OCP, DIP를 가능하게 지원합니다.

  • DI(Dependency Injection): 의존관계, 의존성 주입
  • DI 컨테이너 제공

Spring을 이용하여 클라이언트 코드의 변경 없이 기능을 확장하고

쉽게 부품을 교체하듯이 개발할 수 있습니다.

우리가 Spring을 사용하는 이유 중 하나입니다.

 

<김영한 선생님의 스프링 핵심원리 기본편 예제 및 설명이 들어갔습니다.>