앞서 객체지향의 4대 특성을 가지는 언어를 객체지향 언어라고 정의했다. 이제 객체 지향 프로그래밍을 할 수 있는 언어는 준비되었으니, 이를 잘 활용해 올바른 객체 지향 프로그래밍을 해야 할 차례이다. 좋은 방법이 있을까?

 

2000년 초반, 로버트 C. 마틴은 객체지향 언어를 잘 사용하기 위한 5가지 원칙, SOLID 원칙을 제안하였다. 현재 많은 객체지향 프로그램이 SOLID 원칙을 따르고 있으며, 이 원칙을 준수한다면 올바른 객체 지향 프로그램을 설계하는 데 큰 어려움이 없을 것이다. 물론 원칙에는 예외가 존재하며 몇몇 디자인 패턴을 보면 SOLID 원칙에 위반되는 것들이 있다. 하지만 테니스에 비유하자면, SOLID는 기본자세와 같다. 기본자세를 제대로 익혀야 변형된 자세에서도 정확하고 안전한 스트로크를 구사할 수 있듯, 기본은 언제나 중요하다. 이제 SOLID 원칙을 제대로 알아보도록 하자.

(요즘 필자는 테니스에 푹 빠져 있다. 테니스 비유가 다소 낯설게 느껴지는 분들께는 양해를 부탁드린다.)

 

 

SOLID 원칙 이란?

SOLID 원칙에는 SRP, OCP, LSP, ISP, DIP 가 있다. 객체지향 언어인 Java 예제 코드와 함께 알아보자.

 

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

클래스는 하나의 책임만 가져야 한다. 즉, 클래스가 변경되는 이유는 하나여야 한다.

 

[잘못된 예]

class User {
    private String name;
    private String email;
	
    public void saveToDatabase() {
        // 데이터베이스에 사용자 정보 저장
    }

    public String getName() {
        return name;
    }

    public String getEmail() {
        return email;
    }
}

문제점: User 클래스가 데이터베이스 관련 책임까지 가지고 있다.

 

 

[SRP 적용 예]

class User {
    private String name;
    private String email;

    public String getName() {
        return name;
    }

    public String getEmail() {
        return email;
    }
}

class UserRepository {
    public void save(User user) {
        // 데이터베이스에 사용자 정보 저장
    }
}

 

 

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

클래스는 확장에는 열여 있어야 하고, 수정에는 닫혀 있어야 한다. 즉, 기존 코드를 변경하지 않고도 기능을 확장할 수 있어야 한다.

 

[잘못된 예]

class NotificationService {
    public void sendNotification(String message, String type) {
        if (type.equals("email")) {
            sendEmail(message);
        } else if (type.equals("sms")) {
            sendSms(message);
        }
    }
    
    private void sendEmail(String message) {
        System.out.println("Email Notification: " + message);
    }

    private void sendSms(String message) {
        System.out.println("SMS Notification: " + message);
    }
}

문제점: send 방식이 추가될 때마다 NotificationService의 코드가 수정되어야 한다.

 

[OCP 적용 예]

interface Notification {
    void send(String message);
}

class EmailNotification implements Notification {
    public void send(String message) {
        System.out.println("Email Notification: " + message);
    }
}

class SmsNotification implements Notification {
    public void send(String message) {
        System.out.println("SMS Notification: " + message);
    }
}

// 알림 서비스를 사용하는 클래스
class NotificationService {
    private final List<Notification> notificationMethods;

    public NotificationService(List<Notification> notificationMethods) {
        this.notificationMethods = notificationMethods;
    }
	
    public void sendNotifications(String message) {
        for (Notification notification : notificationMethods) {
            notification.send(message);
        }
    }
}

 

 

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

서브타입(자식타입)은 언제나 상위타입(부모타입)으로 교체할 수 있어야 한다. 

 

[잘못된 예]

class Bird {
    public void fly() {
        System.out.println("Bird is flying.");
    }
}

class Penguin extends Bird {
    @Override
    public void fly() {
        throw new UnsupportedOperationException("Penguins can't fly!");
    }
}

문제점: 모든 새는 날 수 있다는 가정하에 fly메서드를 추가했다. 하지만 이를 상속한 펭귄은 날 수 없다. 

 

[LSP 적용 예]

// 새의 일반적인 특징을 정의하는 Bird 클래스
class Bird {
    public void eat() {
        System.out.println("Bird is eating.");
    }
}

// 날 수 있는 새를 위한 인터페이스
interface Flyable {
    void fly();
}

// 참새는 날 수 있는 새로 정의
class Sparrow extends Bird implements Flyable {
    @Override
    public void fly() {
        System.out.println("Sparrow is flying.");
    }
}

// 펭귄은 날 수 없는 새로 정의
class Penguin extends Bird {
    // Penguin은 fly 메서드를 구현하지 않음
}

 

 

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

클라이언트는 사용하지 않는 인터페이스에 의존하지 않아야 한다. 인터페이스를 작게 유지하고, 특정한 기능을 위한 인터페이스를 여러 개로 분리하는 것이 좋다. 

 

[잘못된 예]

interface AllInOnePrinter {
    void print();
    void scan();
}

class AdvancedPrinter implements AllInOnePrinter {
    @Override
    public void print() {
        System.out.println("Printing document...");
    }

    @Override
    public void scan() {
        System.out.println("Scanning document...");
    }
}

class BasicPrinter implements AllInOnePrinter {
    @Override
    public void print() {
        System.out.println("Printing document...");
    }

    @Override
    public void scan() {
        // 이 프린터는 스캔 기능이 필요하지 않음
    }
}

문제점: 베이직 프린터는 스캔 기능이 필요하지 않다. 

 

[ISP 적용 예]

interface Printable {
    void print();
}

interface Scannable {
    void scan();
}

class AdvancedPrinter implements Printable, Scannable 
    @Override
    public void print() {
        System.out.println("Printing documents...");
    }

    @Override
    public void scan() {
        System.out.println("Scanning documents...");
    }
}

class BasicPrinter implements Printable {
    @Override
    public void print() {
        System.out.println("Printing documents...");
    }
}

 

 

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

고수준 모듈은 저수준 모듈에 의존해서는 안되며, 추상화에 의존해야 한다. 즉, 구체적인 구현이 아닌, 인터페이스나 추상 클래스에 의존하게 설계해야 한다. 

 

[잘못된 예]

class Keyboard {}
class Monitor {}

class Computer {
    private Keyboard keyboard;
    private Monitor monitor;

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

문제점: 컴퓨터라는 클래스는 저수준 모듈인, Keyboard와 Monitor에 의존하고 있다.

 

[DIP 적용 예]

interface Keyboard {}
interface Monitor {}

class StandardKeyboard implements Keyboard {}
class HDMonitor implements Monitor {}

class Computer {
    private Keyboard keyboard;
    private Monitor monitor;

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

 

정리

  • SOLID는 객체지향 언어로 올바른 객체지향 프로그램을 설계하기 위한 원칙이다.
  • 원칙에는 예외가 존재하며 몇몇 디자인패턴  SOLID 원칙에 위반하기도 한다. 
  • 하지만, SOLID는 객체지향 프로그래밍의 기본기이며 이를 잘 익히는 것은 중요하다.
  • 다음 시리즈는 디자인패턴이다. SOLID원칙을 기반으로 탄생한 디자인패턴에 대해 알아볼 예정이다.

+ Recent posts