SOLID 객체지향 설계 원칙 완벽 가이드 (Java 예제 포함)

객체지향 설계에서 디자인 패턴을 이해하고 적용하는 것은 중요합니다. 하지만 패턴 자체에 집착하기보다는, 그 기반이 되는 설계 원칙을 이해하는 것이 더욱 핵심적입니다. 이 글에서는 Java 예제를 통해 소프트웨어 설계의 근간을 이루는 6가지 원칙을 살펴보겠습니다.

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

클래스나 메서드는 하나의 책임만 가져야 합니다. 즉, 변경의 이유가 하나여야 합니다.

잘못된 예: 작가가 글을 쓰고 출판까지 담당합니다.

public class Writer {
    public void writeBook(String title) {
        System.out.println("책을 쓰는 중: " + title);
    }

    public void publishBook(String title) {
        System.out.println("책을 출판함: " + title);
    }
}

위 코드에서 Writer 클래스는 저술과 출판이라는 두 가지 책임을 가지고 있습니다.

올바른 예: 책임을 분리합니다.

public class Author {
    private String bookTitle;

    public String writeBook() {
        System.out.println("책을 쓰는 중: " + bookTitle);
        return bookTitle;
    }
}

public class Publisher {
    public void publishBook(String title) {
        System.out.println("책을 출판함: " + title);
    }
}

// 사용 예
Author author = new Author();
Publisher publisher = new Publisher();
String title = author.writeBook();
publisher.publishBook(title);

이제 각 클래스는 하나의 변경 이유만 가지게 됩니다.

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

소프트웨어 엔티티는 확장에는 열려 있어야 하고, 수정에는 닫혀 있어야 합니다. 즉, 기존 코드를 변경하지 않고 새로운 기능을 추가할 수 있어야 합니다.

잘못된 예 (if-else 추가):

public class PhoneFactory {
    public Phone produce(String type) {
        Phone phone = null;
        if ("samsung".equals(type)) {
            phone = new SamsungPhone();
        } else if ("apple".equals(type)) {
            phone = new ApplePhone();
        }
        // 새 모델 추가 시 else if 블록이 계속 늘어남
        return phone;
    }
}

올바른 예 (인터페이스 기반 확장):

public interface PhoneFactory {
    Phone produce();
}

public class SamsungFactory implements PhoneFactory {
    @Override
    public Phone produce() {
        return new SamsungPhone();
    }
}

public class AppleFactory implements PhoneFactory {
    @Override
    public Phone produce() {
        return new ApplePhone();
    }
}

// 새 모델 추가 시, 새 Factory 클래스만 만들면 됨
public class GoogleFactory implements PhoneFactory {
    @Override
    public Phone produce() {
        return new GooglePhone();
    }
}

새로운 폰이 추가되어도 기존 코드는 전혀 수정되지 않습니다.

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

서브타입은 언제나 자신의 기반 타입으로 교체할 수 있어야 합니다. 즉, 자식 클래스는 부모 클래스의 기능을 완전히 대체할 수 있어야 합니다.

예:

public class Window {
    public void show(View view) {
        view.display();
    }
}

public abstract class View {
    public abstract void display();
    public void measure(int width, int height) {
        // 공통 크기 측정 로직
    }
}

public class Button extends View {
    @Override
    public void display() {
        // 버튼 그리기
    }
}

public class TextView extends View {
    @Override
    public void display() {
        // 텍스트 그리기
    }
}

Window.show()View의 어떤 서브타입이든 받아들일 수 있습니다. ButtonTextView는 모두 View를 대체할 수 있습니다.

4. 의존 역전 원칙 (Dependency Inversion Principle)

고수준 모듈은 저수준 모듈에 의존해서는 안 되며, 둘 다 추상화에 의존해야 합니다. 추상화는 세부 사항에 의존해서는 안 되며, 세부 사항이 추상화에 의존해야 합니다.

잘못된 예 (구체 클래스에 직접 의존):

public class Parent {
    public void tellStory(Book book) {
        System.out.println("엄마가 이야기를 들려줍니다.");
        System.out.println(book.getContent());
    }
}

이 경우 ParentBook에 강하게 결합되어, 신문이나 웹 페이지 같은 다른 매체를 사용할 수 없습니다.

올바른 예 (인터페이스에 의존):

public interface Readable {
    String getContent();
}

public class Book implements Readable {
    @Override
    public String getContent() {
        return "옛날 옛적에...";
    }
}

public class Newspaper implements Readable {
    @Override
    public String getContent() {
        return "오늘의 뉴스: ...";
    }
}

public class Parent {
    public void tellStory(Readable readable) {
        System.out.println("엄마가 이야기를 들려줍니다.");
        System.out.println(readable.getContent());
    }
}

이제 Parent는 어떤 Readable 구현체든 사용할 수 있으며, 새로운 읽기 자료가 추가되어도 Parent 코드는 변경되지 않습니다.

5. 인터페이스 분리 원칙 (Interface Segregation Principle)

클라이언트는 자신이 사용하지 않는 메서드에 의존해서는 안 됩니다. 즉, 인터페이스는 목적에 맞게 작고 구체적이어야 합니다.

예 (Closeable 인터페이스 활용):

Java 6 이하에서는 리소스 닫기를 위해 try-catch-finally 블록을 반복적으로 작성해야 했습니다.

public void saveBitmap(String path, Bitmap bitmap) {
    FileOutputStream fos = null;
    try {
        fos = new FileOutputStream(path);
        bitmap.compress(Bitmap.CompressFormat.PNG, 100, fos);
    } catch (FileNotFoundException e) {
        e.printStackTrace();
    } finally {
        if (fos != null) {
            try {
                fos.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

이를 단순화하는 유틸리티 클래스를 만들 수 있습니다. Closeable 인터페이스는 닫기 기능만 제공합니다.

public class ResourceCloser {
    private ResourceCloser() {}

    public static void safeClose(Closeable resource) {
        if (resource != null) {
            try {
                resource.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

적용 후 코드:

public void saveBitmap(String path, Bitmap bitmap) {
    FileOutputStream fos = null;
    try {
        fos = new FileOutputStream(path);
        bitmap.compress(Bitmap.CompressFormat.PNG, 100, fos);
    } catch (FileNotFoundException e) {
        e.printStackTrace();
    } finally {
        ResourceCloser.safeClose(fos);
    }
}

ResourceCloser.safeClose()Closeable 인터페이스만 알면 되며, 구체적인 구현 세부 사항은 알 필요가 없습니다.

6. 데메테르 법칙 (Law of Demeter)

객체는 자신과 직접 관련된 객체하고만 상호작용해야 합니다. "가장 가까운 친구하고만 대화하라"는 원칙입니다.

잘못된 예 (너무 많은 객체와 결합):

public class Tenant {
    public Room rentRoom(RealEstateAgent agent) {
        // Tenant가 Room의 내부 데이터에 직접 접근
        for (Room room : agent.getRoomList()) {
            if (room.area == 20 && room.price == 2000) {
                return room;
            }
        }
        return null;
    }
}

이 경우 TenantRealEstateAgent뿐만 아니라 Room의 상세 정보(area, price)까지 알아야 합니다.

올바른 예 (중개인을 통한 간접 접근):

public class RealEstateAgent {
    private List<Room> rooms;

    public RealEstateAgent() {
        rooms = new ArrayList<>();
        for (int i = 0; i < 5; i++) {
            rooms.add(new Room(20 + i, 2000 + i * 100));
        }
    }

    public Room findRoom(float targetArea, float targetPrice) {
        for (Room room : rooms) {
            if (room.matches(targetArea, targetPrice)) {
                return room;
            }
        }
        return null;
    }
}

public class Tenant {
    public Room rentRoom(RealEstateAgent agent, float area, float price) {
        return agent.findRoom(area, price);
    }
}

이제 TenantRealEstateAgent만 알면 되고, Room의 내부 구현은 알 필요가 없습니다. Room 클래스가 변경되어도 Tenant는 영향을 받지 않습니다.

태그: 객체지향설계 SOLID 단일책임원칙 개방폐쇄원칙 리스코프치환원칙

7월 21일 19:17에 게시됨