3353 단어
17 분
5가지 객체 지향 원칙 (SOLID)

SOLID 원칙이란?#

TIP

로버트 C.마틴’이 2000년에 소개한 객체지향 설계 5가지 원칙을 ‘마이클 페더스’가 기억술로 소개한 것이 SOLID

객체지향 설계에서 지켜줘야 할 5개의 소프트웨어 개발 원칙을 말한다.

✋ 5개의 개발 원칙#

  • SRP (Single Responsibility Principle) : 단일 책임 원칙
  • OCP (Open Closed Priciple) : 개방 폐쇄 원칙
  • LSP (Listov Substitution Priciple) : 리스코프 치환 원칙
  • ISP (Interface Segregation Principle) : 인터페이스 분리 원칙
  • DIP (Dependency Inversion Principle) : 의존 역전 원칙

좋은 설계#

좋은 설계 : 시스템에 새로운 요구사항이나 변경사항이 있을 때, 영향을 받는 범위가 적은 구조

  • 예상치 못한 변경사항이 발생하더라도, 유연하게 대처 가능
  • 확장성 있는 시스템 구조를 만들 수 있음
  • 코드 확장 및 유지 보수 관리가 더 쉬워짐
  • 불필요한 복잡성을 제거해 리팩토링에 필요한 시간이 줄어 개발의 생산성 향상

SRP (단일 책임 원칙) : 객체는 한 가지 역할만 가져야 한다.#

“어떤 클래스를 변경 해야 하는 이유는 오직 하나뿐 이어야 한다”

책임이 변경의 축이기 때문에 분할되는 것이 중요

  • 요구사항 변경이 일어났을 때 책임의 변경을 통해 반영
  • 책임이 여러 개면 클래스가 커지고, 책임 간 결합도가 높아져 연쇄적 변경이 필요
  • 객체가 담당하는 책임이 많아질수록 그 객체의 변경에 따른 영향도의 양과 범위가 매우 커지게 됨

단일 책임 원칙을 준수한다면 한 책임의 변경에서 다른 책임의 변경의 연쇄작용에서 자유로울 수 있습니다. 즉, 코드의 의존성과 결합도를 줄입니다.

SRP를 위반한 예제 코드#

public class Sports {
private String sport;
public void setSport(String sport) {
this.sport = sport;
}
public void itemReady() {
if (sport == "soccer") {
System.out.println("축구공을 가져옵니다.");
}
else if (sport == "basketball") {
System.out.println("농구공을 가져옵니다.");
}
else if (sport == "run") {
throw new UnsupportedOperationException("달리기에는 아무것도 필요 없습니다.");
}
}
public void play() {
if (sport == "soccer") {
System.out.println("축구를 합니다.");
}
else if (sport == "basketball") {
System.out.println("농구를 합니다.");
}
else if (sport == "run") {
System.out.println("달리기를 시작합니다.");
}
}
}
  • 특정 스포츠를 진행하기 전, 공을 준비하기 위한 소스코드이며, ready()를 통해 공을 가져올 수 있습니다.
  • ready() 메서드는 sport가 soccer이면 축구공을, basketball이면 농구공을 가져온다고 출력합니다.
  • 하지만 스포츠에 대한 종류를 점점 늘리거나, 수정이 필요한 경우는 많은 것들을 의존하고 있는 ready()를 수정해야합니다.
  • 결국 두 기능은 분리되어 있지 않고 하나의 메서드가 두 기능을 모두 가지고 있어 “단일 책임 원칙”을 위반하고 있습니다.
interface ItemReady { void itemReady(); }
interface Play { void play(); }
abstract class Sport implements ItemReady, Play {}
class Soccer extends Sport {
@Override
public void itemReady() {
System.out.println("축구공을 가져옵니다.");
}
@Override
public void play() {
System.out.println("축구를 합니다.");
}
}
class Basketball extends Sport {
@Override
public void itemReady() {
System.out.println("농구공을 가져옵니다.");
}
@Override
public void play() {
System.out.println("농구를 합니다.");
}
}
class Run extends Sport {
@Override
public void itemReady() {
throw new UnsupportedOperationException("달리기에는 아무것도 필요 없습니다.");
}
@Override
public void play() {
System.out.println("달리기를 시작합니다.");
}
}
  • Ready와 Play는 각각의 인터페이스를 만들어 각각 다른 책임 영역을 생성
  • Sport 라는 추상 클래스를 만들어, 자식 개체는 이를 상속받아 역할을 분리했습니다.
  • 이렇게 책임별로 클래스를 분리한다면 SRP를 따르게 됩니다.
  • 이러한 경우 변경이 발생하더라도 다른 관련 없는 동작에 영향을 미치지 않게 됩니다.

OCP (개방 폐쇄 원칙) : 객체는 확장에 열려있고 변경에는 닫혀있어야 한다.#

새로운 기능이 추가되더라도 기존 코드를 수정하지 않고 확장할 수 있어야 한다.

💡 인터페이스와 추상화 등을 사용하여 다형성을 적용해 기능을 확장, 코드 변경을 최소화 확장에 열려있다

  • 객체의 행위가 확장될 수 있다.
  • 행위를 추가해 객체가 하는 일을 바꿀 수 있다.
  • 변경에 닫혀있다

객체의 확장이 소스 코드의 변경을 초래하지 않아야 한다.

  • OCP를 적용한다면 기존 코드를 쉽게 확장할 수 있으므로 유연성, 재사용성, 유지보수성이 좋아짐

이를 적용하려면?

  • 기존 코드 수정 없이 새로운 기능을 추가할 수 있어야한다.
  • 조건문(if-else, switch)을 추가하지 않고 다형성을 활용해야 한다.

OCP를 위반한 코드#

class NotificationService {
sendNotification(type: string, message: string) {
if (type === "email") {
console.log("Sending Email:", message);
} else if (type === "sms") {
console.log("Sending SMS:", message);
} else if (type === "push") {
console.log("Sending Push Notification:", message);
}
}
}
  • 해당 코드는 알림을 전송하는 방식에 관한 코드
  • 만약, 새로운 기능을 추가하려면 (if-else)를 수정해야 하므로 OCP를 위반
interface Notifier {
send(message: string): void;
}
class EmailNotifier implements Notifier {
send(message: string) {
console.log("Sending Email:", message);
}
}
class SmsNotifier implements Notifier {
send(message: string) {
console.log("Sending SMS:", message);
}
}
class PushNotifier implements Notifier {
send(message: string) {
console.log("Sending Push Notification:", message);
}
}
class NotificationService {
private notifier: Notifier;
constructor(notifier: Notifier) {
this.notifier = notifier;
}
sendNotification(message: string) {
this.notifier.send(message);
}
}
  • 새로운 알림 유형(Notifier)이 추가될 때 NotificationService 코드를 변경할 필요가 없어짐
  • 이를 통해 확장성을 지키고, 재사용성, 유지보수성이 좋아짐

LSP (리스코프 치환 원칙) : 서브 타입은 자신의 기반 타입으로 교체할 수 있어야 한다.#

라스코프 치환 원칙은 부모 객체와 자식 객체가 있을 때 부모 객체를 호출하는 동작에서 자식 객체가 부모 객체를 완전히 대체할 수 있어야 한다.

  • 상속이 일어나면, 하위 타입인 자식 객체는 상위 타입인 부모 객체의 특성을 가지며, 그 특성을 토대로 확장할 수 있음.
  • 리스코프 치환 원칙은 올바른 상속을 위해, 자식 객체의 확장이 부모 객체의 방향을 온전히 따르도록 권고하는 원칙.

LSP를 위반한 코드#

interface ItemReady { void itemReady(); }
interface Play { void play(); }
abstract class Sport implements ItemReady, Play {}
class Soccer extends Sport {
@Override
public void itemReady() {
System.out.println("축구공을 가져옵니다.");
}
@Override
public void play() {
System.out.println("축구를 합니다.");
}
}
class Basketball extends Sport {
@Override
public void itemReady() {
System.out.println("농구공을 가져옵니다.");
}
@Override
public void play() {
System.out.println("농구를 합니다.");
}
}
class Run extends Sport {
@Override
public void itemReady() {
throw new UnsupportedOperationException("달리기에는 아무것도 필요 없습니다.");
}
@Override
public void play() {
System.out.println("달리기를 시작합니다.");
}
}

이는 LSP를 위반하고 있습니다.

  • Run인 달리기 부분인데, 자신이 사용하지 않는 인터페이스에 의존하고 있습니다.

  • 이는 불필요한 메서드나 기능을 구현해야 하는 불필요한 인터페이스 의존성을 피해야 합니다.

  • throw new UnsupportedOperationException(“달리기에는 아무것도 필요 없습니다.”); 는 모든 스포츠에서 물품 준비 과정을 수행한다고 기대했지만, 이는 예외를 던지는 방식으로 구현되고 있습니다.

만약 실제 상황에서 예기치 못한 상황에 대한 예외가 발생하는 경우가 생기므로 이는 LSP 위반입니다.

이를 해결하려면 스포츠는 물품이 필요한 스포츠와, 필요하지 않는 스포츠로 나눠야 합니다.

interface ItemReady {
void itemReady();
}
interface Play {
void play();
}
// 물품이 필요한 스포츠 추상 클래스
abstract class SportWithItem implements ItemReady, Play{}
// 물품이 필요하지 않은 스포츠 추상 클래스
abstract class SportWithoutItem implements Play {}
class Soccer extends SportWithItem {
@Override
public void itemReady() {
System.out.println("축구공을 가져옵니다.");
}
@Override
void play() {
System.out.println("축구를 합니다.");
}
}
class Basketball extends SportWithItem {
@Override
public void itemReady() {
System.out.println("농구공을 가져옵니다.");
}
@Override
void play() {
System.out.println("농구를 합니다.");
}
}
class Run extends SportWithoutItem {
@Override
void play() {
System.out.println("달리기를 시작합니다.");
}
}
  • 물품을 준비해야 하는 스포츠들을 SportWithItem, 그렇지 않은 스포츠들을 SportWithoutItem으로 구현
  • 달리기(Run)은 SprotWithoutItem을 상속받아 play만 구현하면 됨
  • 준비 과정이 필요없는 달리기(run)의 경우 상속하지 않음 → 불필요한 의존성 제거

✨ 다만, 지나치게 세분화하는 건 오히려 가독성이 복잡해질 수 있음. 상황에 따라 필요한 기능만 인터페이스로 분리하여 구현하는 방법이 유연성, 확장성에 좋을 수 있음


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

클라이언트는 자신이 사용하지 않는 메서드에 의존하지 않아야 한다.

하나의 거대한 인터페이스보단 여러 개의 작은 인터페이스로 분리하는 원칙

인터페이스는 자신의 클라이언트가 사용할 메서드만 가지고 있어야 한다 객체가 사용하지 않는 메서드를 의존해서는 안된다.

ISP를 위반한 코드#

interface Worker {
void work();
void eat();
}
class Robot implements Worker {
@Override
public void work() {
System.out.println("로봇이 작업을 합니다.");
}
@Override
public void eat() {
throw new UnsupportedOperationException("로봇은 식사를 하지 않습니다.");
}
}
  • Worker에는 work(), eat() 메서드를 둘다 포함하고 있음.
  • 하지만, Robot은 식사를 하지 않으므로 오류를 던지고 있음
  • 따라서 자신이 사용하지 않는 메서드에 의존하지 않아야 한다는 규칙을 지키지 않고 있으며, 이는 ISP 위반으로 볼 수 있음
interface Workable {
void work();
}
interface Eatable {
void eat();
}
class Robot implements Workable {
@Override
public void work() {
System.out.println("로봇이 작업을 합니다.");
}
}
  • 로봇은 자신에게 필요한 기능인 Workable만 가져오고 있음
  • 불필요한 메서드를 구현할 필요가 없어짐에 따라 ISP를 준수하고 있음

DIP (의존 역전 원칙) : 추상화에 의존하며, 구체화에 의존하면 안된다.#

상위 모듈은 하위 모듈에 의존해서는 안된다

  • 추상화는 세부 사항에 의존해서는 안된다

  • 의존 관계는 변화하기 쉬운 것에 의존하기보다, 변화하지 않는 것에 의존해야 한다는 원칙

  • 고수준 모듈은 저수준 모듈에 의존하면 안 되고, 둘 다 추상화에 의존해야 한다.

  • 추상화된 인터페이스는 구체적인 구현에 의존하지 않으며, 구체적인 클래스는 인터페이스에 구현해야 한다.

DIP를 위반한 코드#

class LightBulb {
void turnOn() {
System.out.println("전구가 켜졌습니다.");
}
void turnOff() {
System.out.println("전구가 꺼졌습니다.");
}
}
class Switch {
private LightBulb bulb;
public Switch(LightBulb bulb) {
this.bulb = bulb;
}
public void operate() {
bulb.turnOn();
}
}
  • 전구를 키고 끄는 기능을 구현하고 있음
  • Switch 클래스는 LightBulb 라는 구체적인 클래스에 “직접 의존”하고 있음
  • Switch는 고수준 모듈이며, LightBulb라는 저수준 모듈에 직접 의존하는 형태이며, 만약 LightBulb의 구현이 변경된다면 Switch 클래스도 수정해야 함
  • 이는 DIP를 위반하며 유연성이 떨어지고, 변경에 대한 의존성이 높고, 확장성이 낮아짐.
interface Switchable {
void turnOn();
void turnOff();
}
class LightBulb implements Switchable {
@Override
public void turnOn() {
System.out.println("전구가 켜졌습니다.");
}
@Override
public void turnOff() {
System.out.println("전구가 꺼졌습니다.");
}
}
class Switch {
private Switchable device;
public Switch(Switchable device) {
this.device = device;
}
public void operate() {
device.turnOn();
}
}
  • Switch는 Switchable이라는 인터페이스에 의존
  • LightBulb와 같은 클래스는 Switchable 인터페이스를 구현하며, 이는 확장 시 다양한 디바이스가 스위치를 사용할 수 있게 됨
  • Switch는 더 이상 구체적인 클래스에 의존하지 않으며, 추상화된 인터페이스에 의존
  • 다른 말로 고수준 모듈은 저수준 모듈에 의존하지 않고 추상화된 인터페이스에 의존하므로 DIP 준수

TIP

SOLID를 준수한다면 분명 좋은 코드지만, 상황(lombok, 뭉치는 게 오히려 보기 편한 것 등)에 따라서는 유동적으로 판단할 필요가 있음


마무리#

  • 5가지 객체 지향 설계 원칙인 SOLID의 핵심은 추상화와 다형성
  • 구체 클래스에 의존하지 않고 추상 클래스에 의존함으로써 유연하고 확장가능한 개발이 가능
  • SOLID 원칙을 잘 준수하면 불필요한 변경을 최소화, 테스트와 디버깅이 쉬워짐
5가지 객체 지향 원칙 (SOLID)
https://devlog.jpstudy.org/posts/2025/cs/solid/
저자
SY
게시일
2025-03-03
라이선스
CC BY-NC-ND 4.0