BAEKJH BE Developer

좋은 객체지향 설계를 위한 원칙 SOLID

2021-01-13
BAEKJungHo
OOP

logo

서론

우리는 지금까지 객체지향 3대 요소 캡슐화, 상속, 다형성에 대해서 배웠고 그 중 다형성은 객체지향의 꽃이라고 배웠습니다. 그리고 다형성을 배우면서 실무에서 적용된 SRP, OCP, DIP 예제를 맛 보았습니다.

이번에는 객체지향 설계 원칙인 SOLID 의 특징에 대해서 정리해보는 시간을 가져보겠습니다.

이번 포스트는 다형성 아티클을 공부하고 왔다는 전제하에 설명하는 부분이 상당히 많습니다.

예를 들어 SRP 를 이해하기 위해서는 책임이라는 단어를 이해해야하며, LSP, ISP, OCP 와 DIP 는 다형성과 관련이 있는데 다형성을 이해하지 않고 해당 포스트를 본다면 공부 효율이 떨어질 것입니다.

따라서 다형성에 관한 포스트를 통해서 SRP, OCP, DIP 를 적용한 예제를 공부하지 않으신 분들은 꼭 한번 봐주시길 바랍니다.

객체지향 3대 요소 다형성(Polymorphism) 공부하기

SOLID

SOLID 는 SRP, OCP, LSP, ISP, DIP 의 앞글자를 따서 만든 단어입니다.

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

위 원칙에서 가장 중요한 두 원칙을 고르자면 OCP 와 DIP입니다.

SOLID 를 적용하면 장점

SOLID 는 개발자가 모듈 수준에서 작업할 때 이 원칙들을 적용할 수 있습니다. 다형성 예제에서 우리는 더보기 모듈 개발을 통해서 SRP, DIP, OCP 가 적용된 것을 배웠습니다.

즉, 코드 수준 보다는 조금 상위에 적용되며 소프트웨어 구조를 잡는데에 도움을 줍니다.

또한 SOLID 는 다형성과 관련된 원칙들이 많기 때문에 SOLID 를 적용하면 유지보수성과 확장성이 좋아집니다.

SRP(Single Resposibility Principle)

단일 책임 원칙(SRP)은 말 그대로 하나의 클래스는 하나의 책임만 가져야 한다는 것입니다.

하나의 책임이라는 말이 모호하긴한데 문맥에 따라 적절하게 판단해야합니다.

그 판단이 되는 기준은 변경입니다.

즉, 단일 책임 원칙은 단일 모듈의 변경의 이유가 오직 하나 뿐이어야한다. 라고 정의할 수 있습니다.

예를 하나 들어보겠습니다.

인사팀과 회계팀이 있고 두 팀은 calculateTaxesEndOfYear 라는 연말정산 메서드를 같이 사용하고 있습니다. 즉 현재까지는 둘다 연말정산 로직이 동일한 상태입니다.

그런데 내년이 되서 회계팀의 연말 정산 로직이 바뀌어서 회계팀 개발자가 calculateTaxesEndOfYear 메서드를 변경하게되면 인사팀은 내년에 아무것도 모른채 잘못된 연말 정산 로직으로 연말 정산을 수행할 것입니다.

즉, 위 메서드는 인사팀, 회계팀에 의해서 변경될 수 있으면 변경의 이유가 하나가 아니게 됩니다.

이렇듯 단일 책임 원칙은 클래스와 메서드 수준에 적용되는 원칙입니다.

OCP(Open/Closed Principle)

  • 소프트웨어 요소는 확장(새로운 기능 추가)에는 열려 있어야 하며, 변경(기존 코드의 변경)에는 닫혀 있어야 한다.
    • 즉, 기존 코드의 변경 없이 새로운 기능을 추가할 수 있어야 한다.
    • 인터페이스를 구현한 새로운 클래스를 만들어 새로운 기능을 구현
  • 다형성 활용

개방 폐쇄 원칙은 다형성과 밀접한 관련이 있습니다.

OCP 를 활용한 예시로는 예를들어 비지니스로직반복로직이 결합되어 있는 경우 함수형 인터페이스(Functional Interface)를 사용하여 기존 코드를 변경하지 않고 리팩터링 할 수 있습니다.

마일리지 거래 내역을 예를 들어 보겠습니다.

@FunctionalInterface
public interface MileageTransactionFilter {
    boolean test(MileageTransaction mileageTransaction);
}
public class MileageTransactionIsInFebruaryAndExpensive implements MileageTransactionFilter {
    @Override
    public boolean test(MileageTransaction mileageTransaction) {
        return mileageTransaction.getDate().getMonth() == month 
            && Product.GIFTCARD.getName().equals(mileageTransaction.getProduct());
    }
}
/**
* 특정 월이나 상품으로 마일리지 내역 검색하기
* 이 방식의 한계
* - 거래 내역의 여러 속성을 조합할수록 코드가 점점 복잡해진다.
* - 반복 로직과 비지니스 로직이 결합되어 분리하기가 어려워진다.
* - 코드를 반복한다.
* @param month
* @param amount
* @return
*/
@Deprecated
public List<MileageTransaction> findTransactions(final Month month, final Product product) {
    final List<MileageTransaction> result = new ArrayList<>();
    for(final MileageTransaction mileageTransaction : mileageTransactions) { // 반복 로직
        if(mileageTransaction.getDate().getMonth() == month 
            && Product.GIFTCARD.getName().equals(mileageTransaction.getProduct())) { // 비지니스 로직
            result.add(mileageTransaction);
        }
    }
    return result;
}

/**
* 위 메서드에서 반복 로직과 비지니스 로직을 함수형 인터페이스를 구현함으로써 분리
* @param mileageTransactionFilter
* @return
*/
public List<MileageTransaction> findTransactions(final MileageTransactionFilter mileageTransactionFilter) {
    final List<MileageTransaction> result = new ArrayList<>();
    for(final MileageTransaction mileageTransaction : mileageTransactions) {
        if(mileageTransactionFilter.test(mileageTransaction)) {
            result.add(mileageTransaction);
        }
    }
    return result;
}
  • 장점
    • 기존 코드를 바꾸지 않으므로 기존 코드가 잘못될 가능성이 줄어든다.
    • 코드가 중복되지 않으므로 재사용성이 증가한다.
    • 결합도가 낮아지므로 코드의 유지보수성이 증가한다.

LSP(Liskov Substitution Principle)

리스코프 치환 원칙은 다음과 같습니다.

  • 프로그램 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다.
  • 다형성에서 하위 클래스는 인터페이스 규약을 다 지켜야한다. 다형성을 지원하기 위한 원칙.
  • 인터페이스를 구현한 구현체를 믿고 사용하려면 이 원칙이 필요하다.

예를들어 자동차 악셀을 밟는데 뒤로가면 이 원칙을 위반하게 되는 것입니다.

public interface Car {

    void pressAccelerator();

}

자동자라는 역할을 인터페이스로 만들었습니다. 그리고 구현체는 다음과 같습니다.

public class Lamborghini implements Car {

    @Override
    public void pressAccelerator() {
        // LSP 원칙 위반
        System.out.println("후진");
    }

}

다형성에 우리는 역할은 책임을 암시한다고 배웠습니다. 즉, 자동차(Car) 라는 역할은 악셀을 밟으면 앞으로 가야하고 브레이크를 하면 멈춰야합니다. 하지만 위 하위 클래스는 상위 클래스의 인터페이스 규약을 제대로 지키지 못했습니다. 따라서 LSP 원칙을 위반하게 된 것입니다.

ISP(Interface Segregation Principle)

인터페이스 분리 원칙은 다음과 같습니다.

  • 특정 클라이언트를 위한 여러개의 인터페이스가 하나의 범용 인터페이스보다 낫다.
  • 자동차 인터페이스 -> 운전 인터페이스, 정비 인터페이스로 분리
  • 사용자 인터페이스 -> 운전자 클라이언트, 정비사 클라이언트로 분리
  • 인터페이스를 분리하면 정비 인터페이스가 변경되어도 운전자에게 영향을 미치지 않음
  • 인터페이스가 명확해지고 대체 가능성이 높아진다.

OOP 다형성에서 배운 것처럼 역할은 곧 인터페이스를 나타내며 역할은 대체 가능성을 의미한다고 했습니다. 따라서 역할을 잘 분리하고 적절한 책임을 가지게 하면 각 역할(인터페이스)가 명확해지고 대체 가능성이 높아집니다.

DIP(Dependency Inversion Principle)

의존 관계 역전 원칙은 다음과 같습니다.

  • 개발자는 추상화에 의존해야지 구현에 의존하면 안된다.
  • 역할에 의존해야지 구현에 의존하면 안된다.
  • 인터페이스에 의존해야지 구현체에 의존하면 안된다.
    • 즉, 역할과 구현을 명확하게 분리해야한다.
public class UserService {
  
  // 구현체에 의존하고 있다. 다른 구현체로 바꾸려면 클라이언트(UserService)의 코드를 변경해야한다.
  // DIP 위반
  // private UserRepository userRepository = new UserJoinRepository();
  private UserRepository userRepository = new UserFindRepository();
  
}

다형성만으로는 OCP 와 DIP를 지키기 어려우며 이를 쉽게 지킬 수 있게 도와주는 프레임워크가 바로 스프링 프레임워크입니다.

웹 개발에 정형화된 패턴

보통 웹 개발을 하다보면 인터페이스와 구현체를 1:1로 두고 사용하는 경우가 많습니다.

사실 이러한 패턴이 되게 정형화 되어있고 아무렇지 않게 아무 의심없이 무작정 사용하는 경우가 많은데, 인터페이스는 구현체가 나중에 2개 3개 점점 늘어나며, 확장 가능성이 있을 경우에만 인터페이스를 두는게 좋고, 기능을 확장할 가능성이 거의 없다고 판단되면 클래스로 사용하다가 나중에 리팩터링 하는 방법이 좋습니다.

무작정 인터페이스만 만들어서 사용하다보면 추상화 비용이 발생하는데, 런타임시에 구체클래스가 선택되는데 클릭해서 들어가면 인터페이스만 있기 때문에 한번 더 들어가야 구체클래스가 뭔지 알 수 있습니다.

참고

Real-world Software Development

인프런. 스프링 핵심 원리

클린 아키텍처


Comments

Index