객체지향 프로그래밍의 핵심 설계 원칙 SOLID: 유지보수 가능한 코드를 만드는 5가지 법칙

프로그래밍을 배우면서 코드를 작성하다 보면 어느 순간 자신이 작성한 코드가 복잡하게 얽혀 있어서 수정하기 어렵다는 것을 깨닫게 됩니다. 새로운 기능을 추가하려고 하면 기존 코드 여러 곳을 동시에 고쳐야 하고, 한 부분을 수정했더니 전혀 관련 없어 보이던 다른 부분에서 오류가 발생하는 경험을 하게 되죠. 이런 문제들은 단순히 경험 부족 때문만이 아니라 설계 원칙을 제대로 이해하지 못했기 때문에 발생합니다.

객체지향 프로그래밍에서 SOLID 원칙은 이러한 문제를 해결하기 위한 다섯 가지 핵심 설계 원칙입니다. 2000년대 초반 로버트 마틴(Robert C. Martin, 일명 Uncle Bob)이 정리한 이 원칙들은 지난 20년간 소프트웨어 개발 분야에서 가장 중요한 지침으로 자리 잡았습니다. 이 글에서는 각 원칙을 실제 코드 예제와 함께 깊이 있게 살펴보고, 왜 이 원칙들이 중요한지, 그리고 실무에서 어떻게 적용할 수 있는지를 상세하게 설명하겠습니다.

SOLID 원칙이 필요한 이유: 소프트웨어의 본질적인 문제

소프트웨어 개발에서 가장 큰 비용은 실제로 코드를 처음 작성하는 데 드는 시간이 아닙니다. 오히려 유지보수, 버그 수정, 기능 추가 등 기존 코드를 변경하고 확장하는 데 훨씬 더 많은 시간과 비용이 듭니다. 연구에 따르면 소프트웨어 전체 생명주기 동안 발생하는 비용의 70퍼센트 이상이 유지보수 단계에서 발생한다고 합니다.

SOLID 원칙은 바로 이 문제를 해결하기 위해 존재합니다. 이 원칙들을 따르면 코드의 변경이 필요할 때 최소한의 수정만으로 원하는 결과를 얻을 수 있고, 한 부분의 변경이 다른 부분에 미치는 영향을 최소화할 수 있습니다. 또한 새로운 기능을 추가할 때 기존 코드를 거의 건드리지 않고도 확장할 수 있게 됩니다.

S: 단일 책임 원칙(Single Responsibility Principle)

단일 책임 원칙은 SOLID의 첫 번째 원칙으로, 한 클래스는 단 하나의 책임만 가져야 한다는 원칙입니다. 여기서 ‘책임’이란 변경의 이유를 의미합니다. 즉, 클래스가 변경되어야 하는 이유는 오직 하나여야 한다는 뜻입니다.

이 원칙을 위반한 코드를 먼저 살펴보겠습니다. 사용자 정보를 관리하는 클래스를 예로 들어보죠.

java

// 나쁜 예: 여러 책임을 가진 클래스
public class User {
    private String name;
    private String email;
    
    public User(String name, String email) {
        this.name = name;
        this.email = email;
    }
    
    // 사용자 데이터 검증 책임
    public boolean validateEmail() {
        return email.contains("@") && email.contains(".");
    }
    
    // 데이터베이스 저장 책임
    public void saveToDatabase() {
        // 데이터베이스 연결 코드
        // SQL 쿼리 실행 코드
        System.out.println("사용자를 데이터베이스에 저장: " + name);
    }
    
    // 이메일 발송 책임
    public void sendWelcomeEmail() {
        // SMTP 서버 연결 코드
        // 이메일 발송 코드
        System.out.println(email + "로 환영 이메일 발송");
    }
    
    // 리포트 생성 책임
    public String generateUserReport() {
        return "사용자 리포트\n이름: " + name + "\n이메일: " + email;
    }
}

위 코드는 한 클래스에 사용자 데이터 검증, 데이터베이스 저장, 이메일 발송, 리포트 생성이라는 네 가지 책임이 모두 들어 있습니다. 이렇게 되면 어떤 문제가 발생할까요? 만약 데이터베이스를 MySQL에서 PostgreSQL로 변경해야 한다면 User 클래스를 수정해야 합니다. 이메일 발송 방식을 SMTP에서 API 기반으로 바꾸려 해도 User 클래스를 수정해야 합니다. 리포트 형식을 JSON으로 바꾸려 해도 마찬가지입니다. 하나의 클래스가 너무 많은 이유로 변경되어야 하는 것이죠.

이제 단일 책임 원칙을 적용한 코드를 보겠습니다.

java

// 좋은 예: 단일 책임을 가진 클래스들
public class User {
    private String name;
    private String email;
    
    public User(String name, String email) {
        this.name = name;
        this.email = email;
    }
    
    public String getName() { return name; }
    public String getEmail() { return email; }
}

// 검증만 담당하는 클래스
public class UserValidator {
    public boolean validateEmail(String email) {
        return email != null && email.contains("@") && email.contains(".");
    }
    
    public boolean validateUser(User user) {
        return user.getName() != null && validateEmail(user.getEmail());
    }
}

// 데이터베이스 저장만 담당하는 클래스
public class UserRepository {
    public void save(User user) {
        // 데이터베이스 연결 및 저장 로직
        System.out.println("사용자를 데이터베이스에 저장: " + user.getName());
    }
}

// 이메일 발송만 담당하는 클래스
public class EmailService {
    public void sendWelcomeEmail(User user) {
        // 이메일 발송 로직
        System.out.println(user.getEmail() + "로 환영 이메일 발송");
    }
}

// 리포트 생성만 담당하는 클래스
public class UserReportGenerator {
    public String generate(User user) {
        return "사용자 리포트\n이름: " + user.getName() + 
               "\n이메일: " + user.getEmail();
    }
}

이제 각 클래스는 하나의 명확한 책임만 가지고 있습니다. User 클래스는 사용자 데이터를 표현하는 것만 담당하고, UserValidator는 검증만, UserRepository는 저장만, EmailService는 이메일 발송만, UserReportGenerator는 리포트 생성만 담당합니다. 데이터베이스를 변경하려면 UserRepository만 수정하면 되고, 이메일 발송 방식을 바꾸려면 EmailService만 수정하면 됩니다.

단일 책임 원칙의 핵심은 변경의 이유를 하나로 제한하는 것입니다. 이렇게 하면 코드의 응집도는 높아지고 결합도는 낮아져서 유지보수가 훨씬 쉬워집니다.

O: 개방-폐쇄 원칙(Open-Closed Principle)

개방-폐쇄 원칙은 소프트웨어 엔티티는 확장에는 열려 있어야 하지만 수정에는 닫혀 있어야 한다는 원칙입니다. 쉽게 말해서, 새로운 기능을 추가할 때 기존 코드를 변경하지 않고도 확장할 수 있어야 한다는 의미입니다.

이 원칙을 이해하기 위해 도형의 면적을 계산하는 프로그램을 예로 들어보겠습니다.

java

// 나쁜 예: 새로운 도형이 추가될 때마다 기존 코드를 수정해야 함
public class Rectangle {
    public double width;
    public double height;
}

public class Circle {
    public double radius;
}

public class AreaCalculator {
    public double calculateArea(Object shape) {
        double area = 0;
        
        if (shape instanceof Rectangle) {
            Rectangle rectangle = (Rectangle) shape;
            area = rectangle.width * rectangle.height;
        } else if (shape instanceof Circle) {
            Circle circle = (Circle) shape;
            area = Math.PI * circle.radius * circle.radius;
        }
        // 새로운 도형(예: Triangle)이 추가되면 이 메서드를 수정해야 함
        
        return area;
    }
}

위 코드의 문제는 삼각형이나 오각형 같은 새로운 도형이 추가될 때마다 AreaCalculator의 calculateArea 메서드를 수정해야 한다는 것입니다. 이는 기존 코드의 수정을 요구하므로 개방-폐쇄 원칙을 위반합니다. 또한 수정 과정에서 실수로 기존에 잘 작동하던 Rectangle이나 Circle의 면적 계산 로직에 버그를 만들 수도 있습니다.

이제 개방-폐쇄 원칙을 적용한 코드를 보겠습니다.

java

// 좋은 예: 추상화를 통해 확장에는 열려있고 수정에는 닫혀있음
public interface Shape {
    double calculateArea();
}

public class Rectangle implements Shape {
    private double width;
    private double height;
    
    public Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }
    
    @Override
    public double calculateArea() {
        return width * height;
    }
}

public class Circle implements Shape {
    private double radius;
    
    public Circle(double radius) {
        this.radius = radius;
    }
    
    @Override
    public double calculateArea() {
        return Math.PI * radius * radius;
    }
}

// 새로운 도형을 추가할 때 기존 코드는 전혀 수정하지 않음
public class Triangle implements Shape {
    private double base;
    private double height;
    
    public Triangle(double base, double height) {
        this.base = base;
        this.height = height;
    }
    
    @Override
    public double calculateArea() {
        return 0.5 * base * height;
    }
}

public class AreaCalculator {
    // 이 메서드는 새로운 도형이 추가되어도 전혀 수정할 필요가 없음
    public double calculateArea(Shape shape) {
        return shape.calculateArea();
    }
    
    // 여러 도형의 총 면적도 쉽게 계산 가능
    public double calculateTotalArea(List shapes) {
        double totalArea = 0;
        for (Shape shape : shapes) {
            totalArea += shape.calculateArea();
        }
        return totalArea;
    }
}

이제 새로운 도형을 추가하려면 Shape 인터페이스를 구현하는 새로운 클래스만 만들면 됩니다. AreaCalculator 클래스는 전혀 수정할 필요가 없습니다. 이것이 바로 확장에는 열려있고 수정에는 닫혀있다는 의미입니다.

개방-폐쇄 원칙의 핵심은 추상화입니다. 변하지 않는 부분을 추상화하고, 변하는 부분은 구체적인 구현으로 분리함으로써 기존 코드의 수정 없이 기능을 확장할 수 있게 됩니다.

L: 리스코프 치환 원칙(Liskov Substitution Principle)

리스코프 치환 원칙은 하위 타입은 언제나 상위 타입으로 교체할 수 있어야 한다는 원칙입니다. 바버라 리스코프가 1987년에 제안한 이 원칙은 상속 관계에서 매우 중요한 의미를 가집니다.

이 원칙을 더 쉽게 설명하면, 부모 클래스의 인스턴스를 사용하는 곳에 자식 클래스의 인스턴스를 대신 사용해도 프로그램의 동작이 올바르게 유지되어야 한다는 뜻입니다. 이는 단순히 문법적으로 가능하다는 의미를 넘어서, 의미론적으로도 올바르게 동작해야 한다는 것을 의미합니다.

위반 사례를 먼저 살펴보겠습니다.

java

// 나쁜 예: 리스코프 치환 원칙 위반
public class Rectangle {
    protected int width;
    protected int height;
    
    public void setWidth(int width) {
        this.width = width;
    }
    
    public void setHeight(int height) {
        this.height = height;
    }
    
    public int getArea() {
        return width * height;
    }
}

public class Square extends Rectangle {
    // 정사각형은 너비와 높이가 같아야 하므로
    @Override
    public void setWidth(int width) {
        this.width = width;
        this.height = width;  // 높이도 함께 변경
    }
    
    @Override
    public void setHeight(int height) {
        this.width = height;  // 너비도 함께 변경
        this.height = height;
    }
}

// 이 코드는 Rectangle로는 제대로 작동하지만 Square로는 작동하지 않음
public class AreaTest {
    public void testArea(Rectangle r) {
        r.setWidth(5);
        r.setHeight(4);
        // Rectangle이면 면적이 20이 되어야 하지만
        // Square면 면적이 16이 됨 (4*4)
        assert r.getArea() == 20; // Square일 때 실패!
    }
}

위 코드에서 Square는 수학적으로는 Rectangle의 특수한 형태이지만, 프로그래밍 관점에서는 Rectangle을 상속받는 것이 적절하지 않습니다. testArea 메서드는 Rectangle 타입을 받아서 너비와 높이를 독립적으로 설정할 수 있다고 가정하는데, Square는 이 가정을 깨뜨립니다.

리스코프 치환 원칙을 지키는 올바른 설계는 다음과 같습니다.

java

// 좋은 예: 리스코프 치환 원칙 준수
public interface Shape {
    int getArea();
}

public class Rectangle implements Shape {
    protected int width;
    protected int height;
    
    public Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }
    
    public void setWidth(int width) {
        this.width = width;
    }
    
    public void setHeight(int height) {
        this.height = height;
    }
    
    @Override
    public int getArea() {
        return width * height;
    }
}

public class Square implements Shape {
    private int side;
    
    public Square(int side) {
        this.side = side;
    }
    
    public void setSide(int side) {
        this.side = side;
    }
    
    @Override
    public int getArea() {
        return side * side;
    }
}

// 이제 모든 Shape 구현체가 올바르게 동작함
public class AreaCalculator {
    public int calculateArea(Shape shape) {
        return shape.getArea();
    }
}

이제 Rectangle과 Square는 독립적인 클래스이며, 둘 다 Shape 인터페이스를 구현합니다. Square를 Rectangle의 하위 타입으로 만들지 않음으로써 리스코프 치환 원칙을 위반하지 않게 되었습니다.

리스코프 치환 원칙의 핵심은 상속은 단순히 코드 재사용을 위한 것이 아니라, 진정한 is-a 관계를 나타낼 때만 사용해야 한다는 것입니다. 자식 클래스는 부모 클래스의 모든 계약을 준수해야 하며, 부모 클래스의 행동을 의미론적으로도 올바르게 확장해야 합니다.

I: 인터페이스 분리 원칙(Interface Segregation Principle)

인터페이스 분리 원칙은 클라이언트는 자신이 사용하지 않는 메서드에 의존하지 않아야 한다는 원칙입니다. 즉, 하나의 거대한 인터페이스보다는 여러 개의 구체적인 인터페이스가 낫다는 의미입니다.

위반 사례를 먼저 보겠습니다.

java

// 나쁜 예: 너무 많은 기능을 포함한 비대한 인터페이스
public interface Worker {
    void work();
    void eat();
    void sleep();
    void attendMeeting();
    void writeReport();
}

// 로봇은 먹거나 자지 않지만 인터페이스를 구현하려면 모든 메서드를 구현해야 함
public class Robot implements Worker {
    @Override
    public void work() {
        System.out.println("로봇이 일합니다");
    }
    
    @Override
    public void eat() {
        // 로봇은 먹지 않는데 구현해야 함
        throw new UnsupportedOperationException("로봇은 먹지 않습니다");
    }
    
    @Override
    public void sleep() {
        // 로봇은 자지 않는데 구현해야 함
        throw new UnsupportedOperationException("로봇은 자지 않습니다");
    }
    
    @Override
    public void attendMeeting() {
        System.out.println("로봇이 회의에 참석합니다");
    }
    
    @Override
    public void writeReport() {
        System.out.println("로봇이 보고서를 작성합니다");
    }
}

public class Human implements Worker {
    @Override
    public void work() {
        System.out.println("사람이 일합니다");
    }
    
    @Override
    public void eat() {
        System.out.println("사람이 식사합니다");
    }
    
    @Override
    public void sleep() {
        System.out.println("사람이 잠을 잡니다");
    }
    
    @Override
    public void attendMeeting() {
        System.out.println("사람이 회의에 참석합니다");
    }
    
    @Override
    public void writeReport() {
        System.out.println("사람이 보고서를 작성합니다");
    }
}

위 코드의 문제는 Robot이 eat()과 sleep() 메서드를 사용하지 않는데도 구현해야 한다는 것입니다. 이렇게 되면 코드가 불필요하게 복잡해지고, 예외를 던지거나 빈 구현을 만들어야 하는 상황이 발생합니다.

인터페이스 분리 원칙을 적용하면 다음과 같이 개선할 수 있습니다.

java

// 좋은 예: 역할별로 분리된 인터페이스
public interface Workable {
    void work();
}

public interface Eatable {
    void eat();
}

public interface Sleepable {
    void sleep();
}

public interface Meetable {
    void attendMeeting();
}

public interface Reportable {
    void writeReport();
}

// 로봇은 필요한 인터페이스만 구현
public class Robot implements Workable, Meetable, Reportable {
    @Override
    public void work() {
        System.out.println("로봇이 일합니다");
    }
    
    @Override
    public void attendMeeting() {
        System.out.println("로봇이 회의에 참석합니다");
    }
    
    @Override
    public void writeReport() {
        System.out.println("로봇이 보고서를 작성합니다");
    }
}

// 사람은 모든 인터페이스를 구현
public class Human implements Workable, Eatable, Sleepable, Meetable, Reportable {
    @Override
    public void work() {
        System.out.println("사람이 일합니다");
    }
    
    @Override
    public void eat() {
        System.out.println("사람이 식사합니다");
    }
    
    @Override
    public void sleep() {
        System.out.println("사람이 잠을 잡니다");
    }
    
    @Override
    public void attendMeeting() {
        System.out.println("사람이 회의에 참석합니다");
    }
    
    @Override
    public void writeReport() {
        System.out.println("사람이 보고서를 작성합니다");
    }
}

// 클라이언트 코드는 필요한 인터페이스만 의존
public class WorkManager {
    public void manageWork(Workable worker) {
        worker.work();
    }
}

public class MeetingOrganizer {
    public void organizeMeeting(Meetable participant) {
        participant.attendMeeting();
    }
}

이제 각 클래스는 자신에게 필요한 인터페이스만 구현하면 됩니다. Robot은 eat()과 sleep()을 구현할 필요가 없고, 클라이언트 코드도 자신이 실제로 사용하는 메서드만 포함된 인터페이스에만 의존하게 됩니다.

인터페이스 분리 원칙의 핵심은 인터페이스를 클라이언트의 관점에서 설계해야 한다는 것입니다. 구현하는 쪽이 아니라 사용하는 쪽의 편의를 고려해서 인터페이스를 분리해야 합니다.

D: 의존성 역전 원칙(Dependency Inversion Principle)

의존성 역전 원칙은 상위 수준의 모듈은 하위 수준의 모듈에 의존해서는 안 되며, 둘 다 추상화에 의존해야 한다는 원칙입니다. 또한 추상화는 구체적인 사항에 의존해서는 안 되며, 구체적인 사항이 추상화에 의존해야 합니다.

이 원칙을 위반한 코드를 먼저 살펴보겠습니다.

java

// 나쁜 예: 상위 수준 모듈이 하위 수준 모듈에 직접 의존
public class MySQLDatabase {
    public void connect() {
        System.out.println("MySQL 데이터베이스에 연결");
    }
    
    public void executeQuery(String query) {
        System.out.println("MySQL 쿼리 실행: " + query);
    }
}

public class UserService {
    private MySQLDatabase database;
    
    public UserService() {
        // UserService가 MySQLDatabase에 직접 의존
        this.database = new MySQLDatabase();
    }
    
    public void saveUser(String userName) {
        database.connect();
        database.executeQuery("INSERT INTO users VALUES ('" + userName + "')");
    }
}

위 코드의 문제는 UserService가 MySQLDatabase라는 구체적인 클래스에 직접 의존한다는 것입니다. 만약 데이터베이스를 PostgreSQL이나 MongoDB로 변경하려면 UserService 코드를 수정해야 합니다. 또한 UserService를 테스트하려면 반드시 실제 MySQL 데이터베이스가 필요하게 됩니다.

의존성 역전 원칙을 적용하면 다음과 같이 개선됩니다.

java

// 좋은 예: 추상화를 통한 의존성 역전
public interface Database {
    void connect();
    void executeQuery(String query);
}

public class MySQLDatabase implements Database {
    @Override
    public void connect() {
        System.out.println("MySQL 데이터베이스에 연결");
    }
    
    @Override
    public void executeQuery(String query) {
        System.out.println("MySQL 쿼리 실행: " + query);
    }
}

public class PostgreSQLDatabase implements Database {
    @Override
    public void connect() {
        System.out.println("PostgreSQL 데이터베이스에 연결");
    }
    
    @Override
    public void executeQuery(String query) {
        System.out.println("PostgreSQL 쿼리 실행: " + query);
    }
}

public class MongoDBDatabase implements Database {
    @Override
    public void connect() {
        System.out.println("MongoDB에 연결");
    }
    
    @Override
    public void executeQuery(String query) {
        System.out.println("MongoDB 쿼리 실행: " + query);
    }
}

public class UserService {
    private Database database;
    
    // 생성자 주입을 통해 의존성을 외부에서 주입받음
    public UserService(Database database) {
        this.database = database;
    }
    
    public void saveUser(String userName) {
        database.connect();
        database.executeQuery("INSERT INTO users VALUES ('" + userName + "')");
    }
}

// 사용 예시
public class Application {
    public static void main(String[] args) {
        // MySQL 사용
        Database mysqlDb = new MySQLDatabase();
        UserService userService1 = new UserService(mysqlDb);
        userService1.saveUser("홍길동");
        
        // PostgreSQL로 쉽게 변경 가능
        Database postgresDb = new PostgreSQLDatabase();
        UserService userService2 = new UserService(postgresDb);
        userService2.saveUser("김철수");
        
        // MongoDB로도 쉽게 변경 가능
        Database mongoDb = new MongoDBDatabase();
        UserService userService3 = new UserService(mongoDb);
        userService3.saveUser("이영희");
    }
}

이제 UserService는 Database라는 추상화에만 의존합니다. 데이터베이스 구현체를 변경하더라도 UserService 코드는 전혀 수정할 필요가 없습니다. 또한 테스트를 위한 Mock 객체를 쉽게 만들 수 있게 됩니다.

java

// 테스트를 위한 Mock Database
public class MockDatabase implements Database {
    private boolean connected = false;
    private List executedQueries = new ArrayList<>();
    
    @Override
    public void connect() {
        connected = true;
    }
    
    @Override
    public void executeQuery(String query) {
        if (!connected) {
            throw new IllegalStateException("데이터베이스가 연결되지 않았습니다");
        }
        executedQueries.add(query);
    }
    
    public boolean isConnected() {
        return connected;
    }
    
    public List getExecutedQueries() {
        return executedQueries;
    }
}

// 테스트 코드
public class UserServiceTest {
    public void testSaveUser() {
        MockDatabase mockDb = new MockDatabase();
        UserService userService = new UserService(mockDb);
        
        userService.saveUser("테스트사용자");
        
        assert mockDb.isConnected();
        assert mockDb.getExecutedQueries().size() == 1;
        assert mockDb.getExecutedQueries().get(0).contains("테스트사용자");
    }
}

의존성 역전 원칙의 핵심은 구체적인 것이 아닌 추상적인 것에 의존하도록 만드는 것입니다. 이를 통해 코드의 유연성과 테스트 가능성이 크게 향상됩니다.

SOLID 원칙의 실전 적용: 통합 예제

지금까지 각 원칙을 개별적으로 살펴보았습니다. 이제 모든 원칙을 종합적으로 적용한 실전 예제를 통해 SOLID 원칙이 어떻게 함께 작동하는지 살펴보겠습니다. 간단한 전자상거래 주문 처리 시스템을 만들어 보겠습니다.

java

// 추상화된 인터페이스들 (의존성 역전 원칙, 인터페이스 분리 원칙)
public interface PaymentProcessor {
    boolean processPayment(double amount);
}

public interface NotificationService {
    void sendNotification(String message);
}

public interface InventoryService {
    boolean checkStock(String productId, int quantity);
    void reduceStock(String productId, int quantity);
}

// 구체적인 구현들 (개방-폐쇄 원칙 - 새로운 결제 방식을 추가할 때 기존 코드 수정 불필요)
public class CreditCardPayment implements PaymentProcessor {
    @Override
    public boolean processPayment(double amount) {
        System.out.println("신용카드로 " + amount + "원 결제 처리");
        return true;
    }
}

public class KakaoPayPayment implements PaymentProcessor {
    @Override
    public boolean processPayment(double amount) {
        System.out.println("카카오페이로 " + amount + "원 결제 처리");
        return true;
    }
}

public class EmailNotification implements NotificationService {
    @Override
    public void sendNotification(String message) {
        System.out.println("이메일 알림: " + message);
    }
}

public class SmsNotification implements NotificationService {
    @Override
    public void sendNotification(String message) {
        System.out.println("SMS 알림: " + message);
    }
}

public class DatabaseInventory implements InventoryService {
    @Override
    public boolean checkStock(String productId, int quantity) {
        System.out.println("재고 확인: " + productId);
        return true;
    }
    
    @Override
    public void reduceStock(String productId, int quantity) {
        System.out.println("재고 감소: " + productId + ", 수량: " + quantity);
    }
}

// 주문 엔티티 (단일 책임 원칙 - 주문 데이터만 관리)
public class Order {
    private String orderId;
    private String productId;
    private int quantity;
    private double totalAmount;
    
    public Order(String orderId, String productId, int quantity, double totalAmount) {
        this.orderId = orderId;
        this.productId = productId;
        this.quantity = quantity;
        this.totalAmount = totalAmount;
    }
    
    public String getOrderId() { return orderId; }
    public String getProductId() { return productId; }
    public int getQuantity() { return quantity; }
    public double getTotalAmount() { return totalAmount; }
}

// 주문 처리 서비스 (단일 책임 원칙, 의존성 역전 원칙)
public class OrderProcessor {
    private PaymentProcessor paymentProcessor;
    private NotificationService notificationService;
    private InventoryService inventoryService;
    
    // 생성자 주입으로 의존성을 외부에서 받음
    public OrderProcessor(PaymentProcessor paymentProcessor, 
                         NotificationService notificationService,
                         InventoryService inventoryService) {
        this.paymentProcessor = paymentProcessor;
        this.notificationService = notificationService;
        this.inventoryService = inventoryService;
    }
    
    public boolean processOrder(Order order) {
        // 재고 확인
        if (!inventoryService.checkStock(order.getProductId(), order.getQuantity())) {
            notificationService.sendNotification("재고 부족: " + order.getOrderId());
            return false;
        }
        
        // 결제 처리
        if (!paymentProcessor.processPayment(order.getTotalAmount())) {
            notificationService.sendNotification("결제 실패: " + order.getOrderId());
            return false;
        }
        
        // 재고 감소
        inventoryService.reduceStock(order.getProductId(), order.getQuantity());
        
        // 성공 알림
        notificationService.sendNotification("주문 완료: " + order.getOrderId());
        return true;
    }
}

// 실제 사용 예시
public class ECommerceApplication {
    public static void main(String[] args) {
        // 의존성 조립 - 필요한 구현체들을 선택하여 주입
        PaymentProcessor payment = new CreditCardPayment();
        NotificationService notification = new EmailNotification();
        InventoryService inventory = new DatabaseInventory();
        
        OrderProcessor orderProcessor = new OrderProcessor(payment, notification, inventory);
        
        // 주문 처리
        Order order1 = new Order("ORD001", "PRODUCT123", 2, 50000);
        orderProcessor.processOrder(order1);
        
        // 다른 결제 방식으로 쉽게 변경 가능
        PaymentProcessor kakaoPayment = new KakaoPayPayment();
        NotificationService smsNotification = new SmsNotification();
        
        OrderProcessor orderProcessor2 = new OrderProcessor(kakaoPayment, smsNotification, inventory);
        Order order2 = new Order("ORD002", "PRODUCT456", 1, 30000);
        orderProcessor2.processOrder(order2);
    }
}

이 예제에서 SOLID 원칙이 어떻게 적용되었는지 정리하면 다음과 같습니다.

첫째, 단일 책임 원칙이 적용되었습니다. Order 클래스는 주문 데이터만 관리하고, OrderProcessor는 주문 처리 로직만 담당하며, 각 서비스는 자신의 책임만 가집니다.

둘째, 개방-폐쇄 원칙이 적용되었습니다. 새로운 결제 방식이나 알림 방식을 추가할 때 기존 OrderProcessor 코드를 수정할 필요 없이 새로운 구현 클래스만 만들면 됩니다.

셋째, 리스코프 치환 원칙이 적용되었습니다. PaymentProcessor 인터페이스를 구현한 모든 클래스는 OrderProcessor에서 동일하게 사용될 수 있습니다.

넷째, 인터페이스 분리 원칙이 적용되었습니다. 결제 처리, 알림 발송, 재고 관리라는 서로 다른 책임을 별도의 인터페이스로 분리했습니다.

다섯째, 의존성 역전 원칙이 적용되었습니다. OrderProcessor는 구체적인 클래스가 아닌 인터페이스에 의존하며, 생성자를 통해 의존성을 주입받습니다.

SOLID 원칙 적용 시 주의사항

SOLID 원칙은 매우 강력한 설계 도구이지만, 무조건적으로 적용해야 하는 것은 아닙니다. 과도하게 적용하면 오히려 코드가 복잡해질 수 있습니다.

먼저, 프로젝트의 규모와 복잡도를 고려해야 합니다. 간단한 스크립트나 일회성 프로그램에 SOLID 원칙을 엄격하게 적용하면 오버엔지니어링이 될 수 있습니다. 반대로 장기간 유지보수될 대규모 시스템에서는 SOLID 원칙을 따르는 것이 매우 중요합니다.

둘째, 변경 가능성을 고려해야 합니다. 절대 변하지 않을 것 같은 부분까지 추상화하면 불필요한 복잡도만 증가합니다. 예를 들어 원주율 파이 값을 인터페이스로 추상화할 필요는 없습니다.

셋째, 팀의 역량을 고려해야 합니다. SOLID 원칙을 제대로 이해하지 못한 상태에서 무리하게 적용하면 오히려 혼란만 가중될 수 있습니다. 점진적으로 적용하면서 팀 전체가 이해도를 높여가는 것이 중요합니다.

넷째, 성능과의 균형을 맞춰야 합니다. 추상화 계층이 많아지면 실행 속도가 느려질 수 있습니다. 성능이 매우 중요한 부분에서는 SOLID 원칙과 성능 사이의 균형점을 찾아야 합니다.

SOLID 원칙이 가져오는 장점

SOLID 원칙을 제대로 적용하면 여러 가지 실질적인 이점을 얻을 수 있습니다.

가장 큰 장점은 유지보수성의 향상입니다. 코드의 한 부분을 수정할 때 다른 부분에 미치는 영향을 최소화할 수 있어서 버그 발생 가능성이 줄어들고 수정이 용이해집니다.

둘째, 확장성이 좋아집니다. 새로운 기능을 추가할 때 기존 코드를 거의 건드리지 않고도 확장할 수 있습니다. 이는 특히 애자일 개발 환경에서 매우 중요합니다.

셋째, 테스트 가능성이 향상됩니다. 의존성이 추상화되어 있으면 Mock 객체를 사용한 단위 테스트가 쉬워지고, 각 클래스가 단일 책임만 가지므로 테스트 케이스를 작성하기도 쉬워집니다.

넷째, 코드의 재사용성이 높아집니다. 잘 분리된 클래스들은 다른 프로젝트에서도 쉽게 재사용할 수 있습니다.

다섯째, 팀 협업이 원활해집니다. 각 클래스의 책임이 명확하면 여러 개발자가 동시에 작업할 때 충돌이 적어집니다.

마치며: SOLID 원칙 학습 로드맵

SOLID 원칙을 처음 접하는 개발자라면 다음과 같은 순서로 학습하는 것을 추천합니다.

먼저, 단일 책임 원칙부터 시작하세요. 이 원칙은 가장 이해하기 쉽고 즉시 적용할 수 있습니다. 자신이 작성하는 클래스가 여러 이유로 변경되는지 항상 확인하는 습관을 들이세요.

다음으로 개방-폐쇄 원칙을 학습하세요. 추상화의 중요성을 이해하고, 인터페이스와 추상 클래스를 효과적으로 사용하는 방법을 익히세요.

그 다음 의존성 역전 원칙을 배우세요. 의존성 주입 프레임워크를 사용하기 전에 먼저 수동으로 의존성을 주입하는 연습을 충분히 하세요.

인터페이스 분리 원칙과 리스코프 치환 원칙은 상대적으로 고급 주제이므로 어느 정도 경험이 쌓인 후에 깊이 있게 학습하세요.

실제 프로젝트에 적용할 때는 한 번에 모든 원칙을 완벽하게 지키려 하지 말고, 점진적으로 개선해 나가세요. 리팩토링을 통해 기존 코드를 조금씩 SOLID 원칙에 맞게 수정하는 것도 좋은 학습 방법입니다.

SOLID 원칙은 단순한 이론이 아니라 수십 년간의 소프트웨어 개발 경험에서 얻은 지혜입니다. 이 원칙들을 완전히 이해하고 자연스럽게 적용할 수 있게 되기까지는 시간이 걸리지만, 그만큼의 가치가 충분히 있습니다. 꾸준히 연습하고 적용하다 보면 어느새 더 나은 설계를 하는 자신을 발견하게 될 것입니다.


댓글 남기기