킴의 레포지토리

좋은 설계를 위한 OOP와 SOLID 원칙 적용 본문

study/java

좋은 설계를 위한 OOP와 SOLID 원칙 적용

킴벌리- 2024. 4. 8. 20:17

프로그램을 몇개 개발해보면서, 단순히 동작하는 프로그램을 넘어 변경에 유연하게 대응할 수 있는 설계의 중요성을 깨달았다. 계속해서 변경되고 추가되는 요구사항을 수용하려면 종종 프로그램 전체를 수정해야 하고, 예상치 못한 곳에서 발생하는 버그로 인해 프로그램 수정이 어려워지면, 결국 기존 코드의 재사용을 포기하고, 처음부터 새롭게 코드를 작성하는 상황에 직면할 수도 있다. 혼자 개발하는 상황에서도 변경된 요구사항을 반영하는게 부담되는데, 많은 컴포넌트가 상호작용하는 실제 서비스에서는 변경에 취약한 코드가 더욱 심각한 비효율을 초래한다.

이에, 진정한 의미의 좋은 설계가 무엇인지에 대해 다시 한번 깊이 고민해보기로 했다. 이 글에서는 첫째, 좋은 설계란 무엇인지 둘째, 왜 객체지향 설계가 좋은 설계를 위한 선택인지 셋째, 객체지향 프로그래밍을 성공적으로 수행하기 위해 준수해야 할 5가지 SOLID 원칙의 중요성에 대해 설명하고자 한다.

 

1. 좋은 설계란 무엇인가

좋은 설계란 오늘 요구하는 기능을 온전히 수행하면서 내일의 변경을 매끄럽게 수용할 수 있는 설계다. 
- <오브젝트, 조영호>

요구사항은 불가피하게 변화하기 마련이다. 수정되어야 할 부분이 분명하고 그 외 부분에는 영향을 미치지 않는다면 내일의 변경을 매끄럽게 수용할 수 있게 된다. 이를 실현하기 위해서는 코드의 적절한 배치의존성 관리가 필요하다. 즉, 관련된 관심들을 하나의 모듈로 관리하고, 관련 없는 관심들 사이의 의존성을 최소한으로만 유지해야한다.

 

2. 객체지향 프로그래밍

2-1 .객체지향 프로그래밍이란

객체지향 프로그래밍은 세상의 사물이나 개념을 객체로 표현하고 객체와 객체의 상호작용(협력)에 따라서 프로그래밍하는 방식을 말한다. 각 객체는 상태행위를 가지며, 이는 관련 있는 상태와 행위가 하나의 클래스 내에 존재함으로써 캡슐화된다. 객체들은 다른 객체의 구현방식에 대해서는 알지 못하며, 단지 인터페이스를 통해 상호작용한다. 이것은 다른 객체가 약속한 대로 동작할 것이라는 신뢰를 바탕으로 한다.

2-2 .절차지향 프로그래밍과의 비교

절차지향 프로그래밍과의 비교를 통해 객체지향 프로그래밍이 무엇인지에 대해 명확히 알수 있다.

  객체지향 프로그래밍 절차지향 프로그래밍
프로그래밍 관점 객체들과 객체 사이의 상호작용으로 프로그래밍 순차적인 함수의 실행으로 프로그래밍
데이터와 프로세스위치 관련있는 데이터와 프로세스가 하나의 클래스에 존재 데이터와 프로세스가 별도의 모듈에 위치
데이터의 변경이
일어나는 과정
인터페이스를 통해 외부로부터 요청을 받아 내부 상태를 변경 프로세스를 통해 전달된 데이터를 직접 조작하고 결과를 반환
코드 수정의 용이성 쉬움. 변경해야 하는 코드가 클래스 내부로 한정되고 인터페이스만 변경이 없다면 코드 외부는 수정될 필요가 없다 어려움. 데이터가 수정되면 데이터를 인자로 전달받는 모든 프로세스가 수정되어야함
대표적인 프로그래밍 언어 JAVA C

 

2-3. 객체지향 프로그래밍이 왜 좋은 설계를 위한 선택인가

객체지향은 요구 사항의 변경이 코드에 미치는 영향을 최소화해 좋은 설계를 돕는다. 클래스는 관련있는 상태와 행위를 캡슐화하기 때문에, 요구사항의 변경을 수용하기 위해 하나의 클래스만 수정하면 된다. 또한, 객체 간 의존성은 인터페이스로 한정되기 때문에, 인터페이스를 사용하는 다른 클래스들은 변경 없이 유지될 수 있습니다. 이로 인해 객체간 의존성이 최소한으로 유지되며, 시스템 전체의 유연성과 유지보수성이 향상된다.

3. 성공적인 객체지향 프로그래밍을 위해 지켜야할 5가지 원칙(SOLID)

단순히 객체지향 언어를 사용한다고 객체지향 프로그래밍을 하게되는 것은 아니다. 객체지향 언어는 객체지향 프로그래밍을 위한 캡슐화, 정보은닉, 상속, 다형성, 추상화 기능을 지원하기만 한다. 이러한 기능을 효과적으로 활용하여 객체지향의 장점을 극대화 할 수 있는 방식으로 설계하는 것을 개발자의 몫이다. SOLID는 이러한 설계를 위한 핵심 가이드라인이다.

각 SOLID 원칙의 의미를 파악하고, 원칙을 위반하면 발생하게 되는 문제점, 이를 준수하도록 개선하는 방안을 탐색해본다. 또, 각 원칙이 변경을 수용할 수 있는 설계에 어떻게 도움이 되는지 살펴본다.

3-1 SRP(Single Responsibility Principle, 단일 책임 원칙)

클래스가 오직 하나의 책임을 가져야하고, 이 책임과 관련된 모든 것이 하나의 클래스 안에 모여있어야 한다는 원칙이다. 객체지향 프로그래밍에서는 상태와 행위를 하나의 클래스로 묶는데, 이때 하나의 책임에 관련된 것들만 하나의 클래스로 묶어야 한다. 따라서 SRP는 높은 응집도와 관련된 원칙이다.

한 클래스에 여러 책임을 부여하는 경우 다음과 같은 문제점이 발생한다.

  1. 변경과 아무 상관없는 코드들이 영향을 받게 된다. 어떤 코드를 수정한 후에 아무런 상관도 없던 코드에 문제가 발생한다.
  2. 기능 변경 시, 클래스 내 다른 책임에 대한 로직도 검토하고 테스트해야하는 부담이 증가한다.
  3. 각 책임에 관련된 의존성이 추가되다보면 다른 클래스와의 결합도가 증가한다.

다음의 객체는 단일 책임 원칙을 위반한 객체이다. Employee 클래스가 직원 정보를 관리하면서 동시에 직원 데이터를 데이터베이스에 저장하고, 이메일 알림을 보내는 책임을 가지기 때문이다.

public class Employee {
    private String name;
    private String email;
    private String department;
    private String databaseURL;
    private EmailSystem emailSystem;

    public Employee(String name, String email, String department) {
        this.name = name;
        this.email = email;
        this.department = department;
    }

    public void saveEmployee() {
        // 데이터베이스 저장 로직
    }

    public void sendEmail() {
        // 이메일 전송 로직
    }
}

  1. 변경과 아무 상관 없는 코드들이 영향을 받게 됨: 예를 들어, 이메일 전송 로직을 변경했을때, 이 변경이 데이터베이스 저장 로직에 영향을 줄 수 있다. 이메일 전송 방식의 변경이 아무런 상관도 없어야할 데이터베이스 저장 코드를 깨뜨릴 수 있다.
  2. 기능 변경 시 테스트 부담 증가: 이메일 전송 방식을 변경하고 싶을때, 데이터베이스 저장 로직도 함께 검토하고 테스트해야 하는 부담이 생김
  3. 의존성과 결합도 증가: Employee 클래스가 데이터베이스 연결 시스템과, 이메일 시스템에 동시에 의존하게 되면서 결합도가 증가합니다. 이로 인해 데이터베이스나 이메일 시스템을 변경하려고 할때 Employee 클래스도 영향을 받게 된다.

이러한 문제를 해결하기 위해 SRP에 따라 책임을 분리할 수 있다.

public class Employee {
    private String name;
    private String email;
    private String department;

    public Employee(String name, String email, String department) {
        this.name = name;
        this.email = email;
        this.department = department;
    }

    // Getter 메서드 등...
}

public class EmployeePersistence {
    public void saveEmployee(Employee employee) {
        // 데이터베이스 저장 로직
    }
}

public class EmailService {
    public void sendEmail(Employee employee) {
        // 이메일 전송 로직
    }
}

 

이렇게 책임을 분리함으로써 한 클래스의 변경이 다른 클래스에 영향을 미치지 않고, 각 기능을 독립적으로 검토하고 테스트할 수 있으며, 클래스가 과도한 의존성을 가지는 것을 방지할 수 있다. 결과적으로 하나의 책임만을 가지는 클래스는 변경에 유연해진다.

3-2 OCP(Open-Closed Principle, 개방-폐쇄 원칙)

클래스는 확장에는 열려있어야하고, 수정에는 닫혀있어야 한다는 원칙이다. 기존의 코드를 변경하지 않고도 시스템의 기능을 확장할 수 있어야한다는 의미이다. 이를 위해 변하기 쉬운 부분을 인터페이스로 추상화하고, 외부에서는 구현 클래스 대신 인터페이스를 통해 상호작용하게 한다. 새로운 인터페이스 구현 클래스를 추가함으로써, 기존의 구현체들을 수정하지 않고 시스템의 기능을 확장할 수 있다.

OCP를 가장 잘 반영하는 디자인 패턴은 Strategy Pattern이다. 변경될 가능성이 있는 Strategy를 인터페이스화하고 클라이언트는 인터페이스에 의존하도록 함으로써, ConcreteStrategy4와 같은새로운 구현체를 추가해도 기존 구현에 영향을 주지 않는다.

만약 OCP를 위반하면 다음과 같은 문제점이 발생할 수 있다.

  1. 기존 코드 안정성 저하: 기존 코드를 자주 수정해야해 각 변경사항이 다른 부분에 예기치 않은 버그를 발생시킬 수 있습니다.
  2. 변경에 대한 복잡성이 증가: 시스템을 확장하기 위해 기존 코드를 지속적으로 수정하면, 코드가 점점 복잡해지고 기존 기능의 이해 및 수정이 어려워진다.

다음과 같은 Logger 클래스가 있다고 가정해 보자. Logger는 로그를 콘솔에 출력하는 기존 기능에 파일로 저장하는 기능을 추가하기 위해서 기존 코드 log()를 수정하기 때문에 OCP를 위반한다. 기능이 추가될수록 예기치않은 버그가 발생할 위험이 커지고, log() 메소드의 복잡성이 증가하여 어떤 기능을 하는지 이해하고 수정하는 것이 어려워진다.

public class Logger {
		private File logFile;
    public void log(String message, String type) {
		    if(type.equals("console")){
	        System.out.println("Log to console: " + message);
        }else if(type.equals("file"){
          file.write(message);
        }
    }
}

 

다음과 같이 전략 패턴을 사용하여 로그 기록방식을 추상화화면 OCP를 준수하게 되어 확장 가능한 설계가 된다.

public interface LogStrategy {
    void log(String message);
}

public class ConsoleLogStrategy implements LogStrategy {
    @Override
    public void log(String message) {
        System.out.println("Log to console: " + message);
    }
}

public class FileLogStrategy implements LogStrategy {
    @Override
    public void log(String message) {
        // 파일에 로그 기록하는 코드
    }
}

public class Logger {
    private LogStrategy logStrategy;

    public Logger(LogStrategy logStrategy) {
        this.logStrategy = logStrategy;
    }

    public void log(String message) {
        logStrategy.log(message);
    }
}

 

OCP를 준수하면 새로운 기능을 추가할 때 기존 코드에 영향을 미치지 않아, 변경에 유연해진다.

3-3 LSP(Liscov Substitution Principle, 리스코프 치환 원칙)

슈퍼타입을 상속받는 모든 하위 타입이 슈퍼타입과 동일한 방식으로 동작해야한다는 원칙이다. 슈퍼타입의 인스턴스를 하위 타입의 인스턴스로 대체해도, 클라이언트 입장에서는 아무런 차이를 느끼지 않아야 한다. 즉, 클라이언트가 객체의 실제 타입에 대해 알 필요가 없이 상위 타입의 인터페이스만을 통해 그 객체를 사용하며 일관된 동작을 기대할 수 있어야한다는 의미다.

 

OCP구조에 대한 원칙이라면 LSP동작에 대한 원칙이다. OCP가 시스템의 확장성을 위해 구조적인 변경 없이 기능을 추가할 수 있도록 한다면, LSP는 확장된 부분이 기존 시스템과 잘 통합될 수 있도록 보장하는 동작적인 측면을 강조한다. 즉, LSP는 OCP를 보완하는 원칙으로 볼 수 있다. 하위 타입이 상위 타입의 기능을 적절하게 대체할 수 있어야만, 시스템은 구조적인 변경 없이 기능 확장이 가능해진다.

downcasting이나 instance of는 LSP를 위반한 징후일 수 있다. 이러한 방식은 객체의 실제 타입에 따라 다른 동작을 수행하기 때문에 클라이언트가 실제 객체 타입을 직접 확인해야하는 경우일 수 있다. 이는, 클라이언트가 사용하는 클래스의 구체적인 구현에 의존하게 만들어 캡슐화를 약화시킨다.

 

또한, 슈퍼타입에 정의된 기능을 하위타입이 기능을 수행하지 않는 방식으로 오버라이드하거나, 예외를 던지도록 하는 것은 LSP 위반이다. 이러한 방식은 하위 타입이 상위 타입의 역할을 적절히 대체할 수 없음을 의미하고, 상속 구조의 잘못된 사용을 시사한다.

대표적인 LSP 위반 사례는 직사각형/정사각형 문제이다. SqaureRectangle을 상속받지만, setHeight(), setWidth()는 다르게 동작한다. 따라서 클라이언트는 Rectangle 타입의 변수가 실제로 Rectangle 타입인지, Square 타입인지 확인 한 후, Square 타입일 경우 setWidth()만 호출하도록 주의를 기울여야 한다. 그렇지 않을 경우, 예외가 발생할 수 있다.

public class Rectangle {
    private int width;
    private int height;

    public int area() {
        return width * height;
    }

    public void setWidth(int width) {
        this.width = width;
    }

    public void setHeight(int height) {
        this.height = height;
    }
}

public class Square extends Rectangle {
    @Override
    public void setWidth(int width) {
        this.width = width;
        this.height = width;
    }

    @Override
    public void setHeight() {
        throw new RuntimeException("정사각형은 너비만 설정할 수 있습니다");
    }

}

}

 

LSP를 준수하기 위해, RectangleSquare를 별도의 클래스로 구분하는 것이 필요하다. 실제 세계에서 Square IS - A Rectangle 관계이지만, 객체지향 관점에서는 SquareRectangle이 일관된 방식으로 동작하지 않기 때문에 두 클래스 사이의 상속관계가 적절하지 않다.

LSP를 준수함으로써, 클라이언트는 상위 타입의 인터페이스를 통해 일관된 방식으로 객체를 사용할 수 있도록 한다. 이는 시스템의 다양한 부분을 손쉽게 교체하거나 확장할 수 있도록 해 변경에 유연한 설계가 가능하게 한다.

3-4 ISP(Interface Segregation Principle, 인터페이스 분리 원칙)

클라이언트가 자신이 사용하지 않는 메서드에 의존하지 않아야 한다는 원칙이다. 일부 클라이언트에서만 사용되는 메서드가 있는 경우, 이를 별도의 인터페이스로 분리해 의존성을 최소화해야 한다. ISP를 위반하면 클라이언트가 자신이 사용하지 않는 메서드까지 의존하게 되어, 시스템 결합도를 불필요하게 증가시킨다.

 

다음 MultiFunctionDevice는 ISP를 위반한다. scan() 이나 fax()를 지원하지 않는 SimplePrinter도 해당 메서드를 구현해야하기 때문이다.

public interface MultiFunctionDevice {
    void print();
    void scan();
    void fax();
}

public class SimplePrinter implements MultiFunctionDevice {
    @Override
    public void print() {
        // 인쇄 기능 구현
    }

    @Override
    public void scan() {
        // SimplePrinter는 스캔 기능을 지원하지 않지만, 인터페이스 때문에 구현해야 함
        throw new UnsupportedOperationException("Scan not supported.");
    }

    @Override
    public void fax() {
        // SimplePrinter는 팩스 기능을 지원하지 않지만, 인터페이스 때문에 구현해야 함
        throw new UnsupportedOperationException("Fax not supported.");
    }
}

다음처럼 기능별로 인터페이스를 분리함으로써 각 클라이언트가 필요한 인터페이스만 구현할 수 있게 할 수 있다. 이는 ISP 원칙에 따라 클라이언트가 실제로 사용하는 기능에만 의존하도록 하여, 불필요한 의존성을 제거한다.

public interface Printer {
    void print();
}

public interface Scanner {
    void scan();
}

public interface Fax {
    void fax();
}

public class SimplePrinter implements Printer {
    @Override
    public void print() {
        // 인쇄 기능만 구현
    }
}

 

ISP는 큰 인터페이스를 더 작고, 구체적인 역할을 수행하는 여러 인터페이스로 분리함으로써, 클라이언트가 실제로 필요한 기능에만 의존하도록 합니다. 이렇게 함으로써 객체 간 결합도가 감소하며, 한 부분의 변경이 시스템의 다른 부분에 미치는 영향을 최소화할 수 있습니다.

3-5 DIP(Dependency Inversion Principle, 의존성 역전 원칙)

상위 레벨의 모듈는 하위 레벨에 의존하지 않도록 하는 원칙이다. 의존성이 상위 모듈에서 하위 모듈로 향하던 의존성을 역전시켜, 상위 모듈과 하위 모듈 모두 추상화에 의존하도록 한다. 즉, 객체 간 의존성을 인터페이스를 통해서 느슨하게 설정함으로써 구현체를 쉽게 변경할 수 있도록 한다.

이 과정에서, 컴파일 타임의 의존성(코드 의존성)과 런타임 의존성이 달라진다. 컴파일 타임에는 상위 모듈이 인터페이스에 의존하지만, 런타임에는 해당 추상화를 구현한 구체적인 클래스에 의존하게 된다. 어떤 구현체를 사용할지는 클래스 외부에서 결정되며 의존성 주입, 팩토리 패턴등을 통해 런타임 의존성이 설정된다.

DIP는 클라이언트 코드가 객체 생성 로직과 구체적인 타입에 의존하지 않게 함으로써, 하위 모듈의 변경이 상위 모듈에 영향을 미치지 않게 보호한다.

DIP를 준수하면 자연스럽게 OCP도 지키게 된다. DIP는 고수쥴 모듈과 저수준 모듈 사이의 인터페이스를 두기 때문에, 인터페이스를 구현한 클래스만 추가하면 기존 코드의 수정 없이 기능의 확장이 가능하게 한다.

 

다음의 코드는 DIP를 위반한 예시이다. 상위 모듈이 하위 모듈의 객체를 직접 생성하고 의존하기 때문이다.

public class Logger {
    private LogStrategy logStrategy;

    public Logger() {
        this.logStrategy = new ConsoleLogStrategy();
    }

    public void log(String message) {
        logStrategy.log(message);
    }
}

 

다음과 같이 생성자의 매개변수를 통해 외부에서 LogStrategy 인터페이스 타입을 주입받음으로써 의존성의 방향을 역전시킬 수 있다.

public class Logger {
    private LogStrategy logStrategy;

    public Logger(LogStrategy strategy) {
        this.logStrategy = strategy;
    }

    public void log(String message) {
        logStrategy.log(message);
    }
}

 

DIP는 저수준 모듈의 구현체가 변경되거나, 구현이 변경되어도 고수준 모듈은 아무런 수정이 필요 없도록 함으로써 시스템을 쉽게 수정하고 확장할 수 있게 한다.

정리

  • 좋은 설계란 지금의 요구사항을 만족하면서도 미래의 요구사항 변경에 유연하게 대처할 수 있는 설계이다. 변경에 유연하게 대처한다는 것은 변경이 미치는 영향을 최소화하는 것이다. 이를 위해서는 코드를 클래스에 적절하게 배치하고, 클래스 간 의존성을 최소한으로 유지하는 것이 필요하다.
  • 객체지향 프로그래밍이란 세상을 객체의 관점에서 바라보고, 객체 간의 상호작용으로 프로그래밍하는 것을 말한다. 객체는 상태와 행위를 가지고, 객체 간의 인터페이스를 통해 상호작용한다. 객체지향 프로그래밍은 요구사항 변경을 반영하기 위해 하나의 클래스만 수정하게 한다. 또한, 인터페이스만 외부로 공개함으로써, 클래스 내부의 변경이 클래스 외부에 미치는 영향을 최소화해 좋은 설계가 가능하게 한다.
  • SOLID는 성공적인 객체지향 프로그래밍을 위한 가이드라인이다. 객체지향이 지원하는 캡슐화, 정보은닉, 추상화, 상속, 다형성을 활용하여 변경에 유연한 설계를 돕는다. SRP는 응집도 높은 클래스를 만들도록 하고, OCP와 LSP는 기존 시스템 변경 없이 확장을 가능하게 하고, ISP는 불필요한 의존성을 제거하고, DIP는 상위 레벨을 하위 레벨의 변경으로부터 보호한다.

결국 디자인패턴도 객체지향 설계원칙을 상황에 맞게 적용한 것이다. 어떤 상황에 어떤 디자인패턴을 사용하는게 좋을지 결정하기 위해서는 객체지향 원칙에 대한 이해를 바탕으로 해당 디자인 패턴이 미래의 변경에 유연하게 대처할 수 있도록 하는지 고민해보아야한다.