객체지향 설계에서 디자인 패턴을 이해하고 적용하는 것은 중요합니다. 하지만 패턴 자체에 집착하기보다는, 그 기반이 되는 설계 원칙을 이해하는 것이 더욱 핵심적입니다. 이 글에서는 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의 어떤 서브타입이든 받아들일 수 있습니다. Button과 TextView는 모두 View를 대체할 수 있습니다.
4. 의존 역전 원칙 (Dependency Inversion Principle)
고수준 모듈은 저수준 모듈에 의존해서는 안 되며, 둘 다 추상화에 의존해야 합니다. 추상화는 세부 사항에 의존해서는 안 되며, 세부 사항이 추상화에 의존해야 합니다.
잘못된 예 (구체 클래스에 직접 의존):
public class Parent {
public void tellStory(Book book) {
System.out.println("엄마가 이야기를 들려줍니다.");
System.out.println(book.getContent());
}
}
이 경우 Parent는 Book에 강하게 결합되어, 신문이나 웹 페이지 같은 다른 매체를 사용할 수 없습니다.
올바른 예 (인터페이스에 의존):
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;
}
}
이 경우 Tenant는 RealEstateAgent뿐만 아니라 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);
}
}
이제 Tenant는 RealEstateAgent만 알면 되고, Room의 내부 구현은 알 필요가 없습니다. Room 클래스가 변경되어도 Tenant는 영향을 받지 않습니다.