본문 바로가기

웹 개발

객체지향 SOLID 원칙

SOLID

소프트웨어 설계 원칙에는 SOLID라는 것이 있다. 이는 객체지향 소프트웨어 설계에 사용되지만 절차적 프로그래밍 기법에도 적용할 수 있다. SOLID는 다음과 같다.

  • 단일 책임 원칙(Single Reponsibility Principle, SRP)
  • 개방 폐쇄 원칙(Open Closed Principle, OCP)
  • 리스코프 치환 원칙(Liskov Substitution Principle, LSP)
  • 의존 역전 원칙(Dependency Inversion Principle, DIP)
  • 인터페이스 분리 원칙(Interface Segregation Principle, ISP)

단일 책임 원칙(Single Reponsibility Principle, SRP)

책임

객체는 단 하나의 책임만 가져야한다. 여기서 말하는 책임이란 다음과 같다.

  • 해야 하는 것
  • 할 수 있는 것
  • 해야 하는 것을 잘 할 수 있는 것

만일 학생 클래스에 학생 정보뿐만 아니라 성적확인, 성적표 출력 ,DB 저장 등의 역할을 한다하면 학생 개개인이 너무 많은 책임을 지게된다. 학생이 '해야 하는 것을 잘 할 수 있는 것'은 수강 과목을 추가하고 조회하는 일정도일 것이다. 성적표 출력이나 DB저장등의 역할은 다른 클래스가 더 잘할 수 있을 것이다.

변경

실효성 있는 설계가 되려면 책임을 더 현실적인 개념으로 파악해야한다. 좋은 설계란 새로운 요구사항이 있을 때 영향 받는 부분을 줄여야 한다. 이는 변경이 쉬워야 한다는 이야기가 된다.

클래스를 변경하려면 변경 이유부터 찾아야한다. 앞서 얘기한 Student 클래스의 경우엔 책임을 어디까지 지는지를 따져봐야한다. 책임을 많이 지게되면 그만큼 각각의 클래스 혹은 메소드에 결합도는 올라간다. 수강과목을 추가하는 addCoursesaveDb가 코드 내부 어딘가에 연결될 수도 있고, 마찬가지로 getCourseloadDb가 연결될 수도 있다. 이러한 경우 스키마 변화가 getCourse 메서드와 addCourse메서드의 변화를 일으킬 수 있다.

학생 클래스

책임 분리

Student 클래스는 책임이 많음으로 여러 클래스의 도움으로 책임을 분산 시켜야한다. Student에게 단일 책임을 부여하는 것으로 단 하나의 책임만 수행하도록 하여 변경 사유가 될 수 있는 것을 하나로 해야한다. Student 클래스의 변경 사유가 될 수 있는 것은 학생 고유 정보, 데이터베이스 스키마, 출력 형식의 변화등이 있다. 학생 클래스를 저장하는 DAO(Data Access Object)가 필요할 것이고 성적표를 출력하는 성적표 클래스, 출석을 관리하는 출석부 클래스로 분리하는 것이 좋다.

책임 분리한 클래스 다이어그램

산탄총 수술

만일 한 책임이 여러개의 클래스로 분산되어 있는 경우에도 단일 책임 원칙을 적용해서 변경해야 한다. 이럴경우엔 여러 클래스를 모두 변경해야하는데 이를 산탄총맞은 동물을 치료하는 것과 같다해 산탄총 수술이라고 한다. 이와 같은 경우는 로깅, 보안, 트랜잭션과 같은 횡단 관심(cross-cutting concern)으로 분류할 수 있는 기능이 대표적이다.

가령 특정 메서드들의 실행에서 로그를 DB에 저장한다고 가정한다. 사용하는 모든 메서드들에 로깅 코드가 있을 것이다. 그러다 스펙이 변경되어 로그가 DB가 아닌 파일로 저장한다고 하면 로그를 기록하는 모든 메서드를 확인해서 코드를 변경할 것이다. 이는 시간과 비용이 많이 들게된다. 해결법은 로깅을 하는 별도의 클래스에게 책임을 담당하게 하면 흩어진 코드들을 한곳에 모이게해 응집도를 높일 수 있다.

관심지향 프로그래밍(AOP)와 횡단 관심 문제

산탄총 수술에서 이야기한 특정 메소드를 호출할때마다 로그를 쌓는 방식의 횡단 관심 문제는 OOP가 아닌 AOP에서 해결이 가능하다. AOP는 관점지향 프로그래밍으로 메소드가 호출될 때, 객체가 생성될 때 등 어떠한 상황에서 기능이 수행되게 하는 프로그래밍 기법이다. 산탄총 수술의 해결은 한곳에 책임을 몰아넣으면 해결이 가능하지만 호출하고 사용하는 곳에서는 해당 기능들이 어딘가 포함되어 있을 것이다. AOP로 코드를 변경시킨다면 '메소드가 호출되었을 때'의 관점으로 로깅 코드를 호출시킬 수 있어 각 메소드들에 로깅 코드를 넣는것보다 효율적이고 각 메소드에 코드를 집어넣지 않을 수 있다.

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

개방 폐쇄 원칙은 기존의 코드를 변경하지 않으면서 기능을 추가할 수 있도록 설계가 되어야 한다는 것을 말한다. SomeClient가 이 기능을 성적표나 출석부 기능을 이용하고 도서관 대여 명부와 같은 새로운 매체에 학생의 대여 기록을 출력하는 기능을 추가하낟고 가정하자. 도서관 대여 명부 클래스를 만들고 SomeClient 클래스에 이 기능을 사용하도록 한다. 그러나 이 방법은 개방 폐쇄 원칙을 위반한다. 학생의 대여 기록을 출력하는 기능을 추가하려고 SomeClient 클래스를 수정해야 하기 때문이다. 이 경우엔 학생의 대여 기록을 출력하는 매체가 변해야한다.

이 경우 SomeClient가 각 개별 클래스를 처리 하도록 하는 것이 아닌 인터페이스로 구체적인 출력을 캡슐화 시켜서 처리해야 한다.

개방 폐쇄 원칙의 또 하나의 관점은 클래스를 변경하지 않고도 해당 클래스의 환경을 변경할 수 있는 설계가 되어야한다. 특히 테스트 환경에서 중요한데 단위 테스트에서 DB연결이나 웹 서버 설치 등은 테스트를 회피하게 하는 요인이다. 그러므로 실제 외부 서비스를 흉내내는 가짜 객체로 테스트의 효율성을 높일 필요가 있다.

테스트를 할때 실제로 DB내용을 변경하는 위험성도 있고, 외부 서비스와 연동을 할 경우 외부 서비스의 상태에 따라 테스트 결과가 실패할 수도 있다. 이럴 경우에는 모의 객체를 사용하여 의존성을 갖는 객체를 가짜 객체로 만들어 사용할 수 있다.

리스코프 치환 원칙(Listkov Substitution Principle, LSP)

리스코프 치환 원칙은 MIT 컴퓨터 공학과 리스코프 교수가 1987년에 제안한 원칙이다. 자식 클래스는 최소한 자신의 부모 클래스에서 가능한 행위는 수행할 수 있어야 한다는 뜻이다. LSP를 만족하면 부모 클래스의 인스턴스 대신에 자식 클래스의 인스턴스로 대체해도 프로그램의 의미가 변환되지 않는다. 따라서 LSP를 만족하려면 부모 클래스의 인스턴스를 자식 클래스의 인스턴스로 대신할 수 있어야한다.

자식 클래스가 부모 클래스 인스턴스의 행위를 일관성 있게 실행하려면 부모 클래스의 행위를 명확하게 정의할 수 있는 수단이 필요하다.

public class Product {
    private int price;

    public void setPrice(int price) {
        this.price = price;
    }

    public int getPrice() {
        return price;
    }
}

상품 클래스는 가격을 set하는 setter와 get하는 getter를 갖는 클래스다. 여기에 자식 클래스로 DiscountedProduct를 만든다고 하자

public class DiscountedProduct extends Product {
    private double discountedRate = 0;

    public void setDiscounted(double discountedRate) {
        this.discountedRate = discountedRate;
    }

    public void applyDiscount(int price) {
        super.setPrice(price - (int)(discountedRate * price));
    }
}

DiscountedProduct는 할인률을 set하고 상품가격에 할인률을 적용시킨다. 기존 Product 클래스에 있는 getter, setter의 기능 변경 없이 그대로 상속받았다.

Product p1 = new Product();
Product p2 = new Product();
p1.setPrice(10000);
System.out.println(p1.getPrice());
p2.setPrice(p1.getPrice());
System.out.println(p2.getPrice());

DiscountedProduct d1 = new DiscountedProduct();
DiscountedProduct d2 = new DiscountedProduct();
d1.setPrice(10000);
System.out.println(d1.getPrice());
d2.setPrice(d1.getPrice());
System.out.println(d2.getPrice());

부모 클래스인 Product를 사용한 코드와 그를 똑같이 대체한 DiscountedProduct 클래스를 사용한 코드이다. Product를 사용했을 때와 DiscountedProduct를 사용했을 때의 결과가 동일 하므로 이는 상속관계가 LSP를 위반하지 않았다는 것을 말한다.
반면 DiscountedProduct클래스에 할인률이 적용된 가격을 만들기 위해 setPrice를 오버라이드하여 할인률을 적용시킨다면 결과는 달라질 것이다. 이러한 행위는 부모 클래스의 행위와 일관되지 않으므로 LSP를 만족하지 않는다.

피터코드의 상속 규칙에서 "서브 클래스가 슈퍼 클래스의 책임을 무시하거나 재정의하지 않고 확장만 수행한다"라는 규칙은 슈퍼 클래스의 메소드를 오버라이드 하지 않는 것과 같은 의미다. 피터 코드의 상속 규칙을 지키는 것은 lsp를 만족시키는 하나의 방법이다.

의존 역전 원칙(Dependency Inversion Principle, DIP)

객체 사이에 서로 도움을 주고 받으면 의존 관계가 발생한다. 의존 역전 원칙은 그러한 의존 관계를 맺을 때의 가이드라인에 해당한다. 의존 관계를 맺을 때 변화하기 쉬운 것 또는 자주 변화하는 것보다는 변화가 거의 없는 것에 의존하라는 원칙이다.

변하기 어려운 것은 정책, 전략과 같은 큰 흐름이나 개념 같은 추상적인 것으로 생각하고, 변하기 쉬운 것은 구체적인 방식, 사물 등과 같은 것은 변하기 쉬운 것으로 생각하면 구분하기 편하다.

아이가 자동차 장난감, 로봇 장난감, 레고 장난감을 가지고 논다고하면 우리는 자동차, 로봇, 레고를 변하기 쉬운 사실로 보고 장난감이라는 추상적인 표현을 변하기 어려운 것으로 볼 수 있다. DIP를 만족하려면 어떤 클래스를 사용할 때 구체적인 클래스 보다는 인터페이스나 추상 클래스와 의존 관계를 맺도록 설계 해야한다. DIP를 만족하면 변화에 유연한 시스템이 된다.

public class Kid {
    private Toy toy;

    public void setToy(Toy toy) {
        this.toy = toy;
    }

    public void play() {
        System.out.println(toy.toString());
    }
}

public class Robot extends Toy {
    public String toString() {
        return "Robot";
    }
}

public class Lego extends Toy {
    public String toString() {
        return "Lego";
    }
}
public class Main {
    public static void main(String[] args) {
        Toy t = new Robot();
        Kid k = new Kid();
        k.setToy(t);
        k.play();
    }
}

위는 아이가 로봇을 가지고 노는 코드다. 만일 아이가 로봇이 아니라 레고를 가지고 놀도록 변경한다면 다음과 같이 간단하게 다른 코드에 영향없이 고칠 수가 있다

public class Main {
    public static void main(String[] args) {
        Toy t = new Lego();
        Kid k = new Kid();
        k.setToy(t);
        k.play();
    }
}

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

ISP는 인터페이스를 클라이언트에 특화되도록 분리시키라는 설계 원칙이다. 클라이언트는 자신이 이용하지 않는 기능이 수정이 일어나더라도 영향을 받지 않아야한다는 것이다.

 

복합기 클래스를 예로 들면 복합기를 사용하는 복사 클라이언트, 프린터 클라이언트, 팩스 클라이언트가 있을때 복사 클라이언트는 프린터의 기능이 변경되었을 때 발생하는 문제에 대해 영향이 없어야한다. 그러므로 범용적인 인터페이스보다는 특화 인터페이스를 사용해야한다.

 

위와 같이 각 기능에 따라 인터페이스 특화를 하여 기능 변경에 대한 영향을 받지 않게 할 수 있다. 이는 단일 책임 원칙과도 이어지는 내용인데 기존 복합기에 모여있던 책임을 흩어지게 함으로써 단일 책임 원칙도 해결하고 인터페이스 특화 원칙도 해결할 수 있다.

참고

- 정인상 채홍석, 『JAVA 객체 지향 디자인 패턴』

'웹 개발' 카테고리의 다른 글

DNS(Domain Name System)  (0) 2021.10.05
Post와 Put의 차이  (0) 2021.10.05
객체지향 원리  (0) 2021.08.26
자바 가상 머신(Java Virtual Machine, JVM)  (0) 2021.07.25
오라클 병렬처리  (0) 2021.04.18