앞서 객체지향의 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원칙을 기반으로 탄생한 디자인패턴에 대해 알아볼 예정이다.

객체지향 프로그래밍은 개발자에게 친숙하면서도 때로는 어려운 개념이지만, 반드시 이해하고 넘어가야 할 중요한 개념이다. 이 시리즈에서는 객체지향 프로그래밍의 탄생 배경을 시작으로, 객체 지향 언어가 가진 객체지향 4대 특성을 알아보고, 객체지향 언어로 올바른 설계를 이루기 위한 SOLID 원칙을 살펴보겠다. 마지막으로, SOLID 원칙을 기반으로 한 객체지향 베스트 프랙티스인 디자인 패턴은 이후 다른 시리즈에서 자세히 다룰 예정이다.

 

 

객체지향 프로그래밍의 탄생 배경에 대해 알아보자

프로그래밍 언어는 개발자가 이해하고 다루기 쉽도록 점차 발전해 왔다. 초기에는 사람이 이해하기 어려운 기계어에서 시작해, 어셈블리어를 거쳐 절차지향 언어로 발전하며 사용 편의성이 개선되었지만, 여전히 기계 중심의 사고에 기반한 방식이었다. 그러던 중, "현실 세계를 바라보는 방식 그대로 프로그램을 작성할 수는 없을까?"라는 질문에서 객체지향 프로그래밍의 개념이 탄생했다. 사실, 우리가 살아가는 세상은 수많은 객체들, 즉 사물들의 상호작용으로 이루어져 있다. 우리가 보고, 만지고, 인식하는 모든 것을 객체로 정의할 수 있으며, 이 객체들이 서로 소통하고 협력하면서 현실세계의 다양한 현상을 만들어낸다. 이러한 객체의 개념을 프로그래밍에 도입한 것이 객체지향 프로그래밍이다. OOP를 통해 현실 세계를 더 직관적으로 모델링할 수 있게 되면서, 인간의 사고방식에 맞는 자연스러운 프로그램 설계가 가능해지게 되었다.

 

 

객체지향 사상을 잘 반영하는 언어는 무엇일까?

대표적인 객체지향 언어는 Java, Kotlin, C#, Ruby, Python 등이 있으며, 그 외에도 많은 언어들이 객체지향 사상을 반영하고 있다. 

 

그렇다면, 어떠한 기준으로 우리는 이러한 언어들을 객체지향 언어라고 부를 수 있을까? 절대적인 기준은 없지만, 오랜 세월에 걸쳐 정립된  객체 지향 4대 특성을 가지고 있다면 객체지향 언어라고 부를 수 있다. 그렇다면 객체 지향 4대 특성이 무엇이진 알아보자.

 

 

객체 지향 4대 특성이란?

객체지향 4대 특성에는 캡슐화, 상속, 추상화, 다형성이 있다. 객체지향 언어인 Java 예제 코드와 함께 알아보자.

 

1. 캡슐화

객체의 내부 구조를 감추고 외부에서는 필요한 인터페이스만 제공한다

public class BankAccount {
    private double balance; // 외부 접근 불가

    public double getBalance() {
        return balance;
    }

    public void deposit(double amount) {
        if (amount > 0) balance += amount;
    }

    public void withdraw(double amount) {
        if (amount > 0 && amount <= balance) balance -= amount;
    }
}

 

 

2. 상속(확장)

기존의 클래스를 확장하여 새로운 클래스를 만드는 개념이다. 부모 클래스의 속성과 메서드를 자식 클래스가 물려받게 되고, 자식 클래스는 부모의 기능을 그대로 사용할 수 있고, 필요에 따라 확장하거나 재정의를 할 수 있다.

class Employee {
    protected String name;
    protected double salary;

    public void work() {
        System.out.println(name + " is working.");
    }
}

class Manager extends Employee {
    private int teamSize;

    public void manageTeam() {
        System.out.println(name + " is managing a team of " + teamSize);
    }
}

 

 

3. 추상화(모델링)

실제 사물을 정확히 복제하는 것이 아니라 목적에 맞게 관심 있는 특성만 추출해 단순하게 표현하는 것이다. 

// 추상 인터페이스: Database
interface Database {
    void connect();
    void disconnect();
    void executeQuery(String query);
}

// 구체적인 클래스: MySQLDatabase
class MySQLDatabase implements Database {
    @Override
    public void connect() {
        System.out.println("MySQL 데이터베이스에 연결합니다.");
    }

    @Override
    public void disconnect() {
        System.out.println("MySQL 데이터베이스 연결을 종료합니다.");
    }

    @Override
    public void executeQuery(String query) {
        System.out.println("MySQL에서 쿼리를 실행합니다: " + query);
    }
}

// 구체적인 클래스: OracleDatabase
class OracleDatabase implements Database {
    @Override
    public void connect() {
        System.out.println("Oracle 데이터베이스에 연결합니다.");
    }

    @Override
    public void disconnect() {
        System.out.println("Oracle 데이터베이스 연결을 종료합니다.");
    }

    @Override
    public void executeQuery(String query) {
        System.out.println("Oracle에서 쿼리를 실행합니다: " + query);
    }
}

 

 

4. 다형성

다형성은 객체가 여러 형태를 가질 수 있게 하는 개념으로, 상위 클래스 타입으로 하위 클래스의 객체를 참조하여 동일한 메서드를 호출할 때 다른 동작을 하도록 한다.

class Animal {
    public void sound() {
        System.out.println("Some sound...");
    }
}
class Dog extends Animal {
    @Override
    public void sound() {
        System.out.println("멍멍!");
    }
}
class Cat extends Animal {
    @Override
    public void sound() {
        System.out.println("야옹!");
    }
}

public class Main {
    public static void main(String[] args) {
        Animal[] animals = {new Dog(), new Cat()};
        for (Animal animal : animals) {
            animal.sound();
        }
    }
}
//(출력)
//멍멍!
//야옹!

 

 

정리

  • 객체지향 프로그래밍은 현실 세계를 바라보는 방식 그대로 프로그래밍하기 위해 탄생했다.
  • 객체지향 4대 특성을 가지는 언어를 객체지향 언어라고 한다. 
  • 객체 지향 4대 특성에는 캡슐화, 상속, 추상화, 다형성이 있다.
  • 다음 장에서는 객체지향 설계 5원칙인 SOLID에 대해 다루어 볼 것이다.

+ Recent posts