🍊

면접에서 ‘OOP를 하면서 지켜야 할 원칙이나 OOP의 특징에 대해서 알고 있는 대로 설명해주세요'라는 질문을 받았다. 질문을 받자마자 생각한 건 캡슐화(encapsulation)뿐이었다. 캡슐화도 제대로 설명하지 못하다가 질문자가 SOLID 원칙을 알려주면서 이에 대해 대답을 원했다. 그러나 아무것도 말할 수 없었다.

객체지향 프로그래밍을 한다면 반드시는 아니어도 한 번쯤 듣게 되는 원칙이다. 나도 신입으로 입사 교육을 들었을 때, SOLID 원칙을 알았다. 사실 그렇다. 교육 당시에만 한 번 읽고 넘어갔다. 이 원칙에 대해서 주의 깊게 고민하지도 않았고 이 원칙을 지키면서 일을 하지도 않았다. 그래서였을까, 머리에는 SOLID의 S도 기억하지도 못했고 SOLID라는 단어를 들었을 때는 아차 싶었다. 그래서 이번에 한번 부셔서 내 관점에서 이해하고 이를 남기려 한다.

SOLID, 마치 진짜 solid 단어인 것처럼 보이나 사실 5개 원칙의 앞 글자만 따와서 합친 말이다. SRP, OCP, LSP, ISP, DIP를 일컫는다. 맨 처음에 나오는 SRP부터 이해해보자.

SRP(Single Responsibility Principle)

단일 책임 원칙(Single Responsibility Principle)은 하나의 객체는 반드시 하나의 책임을 갖는 것이다. 그렇다면 하나의 책임은 무엇일까? 아래 임의로 생각한 OrderService 를 보자.

1234567891011121314151617
public class OrderService {
public long order(Product product) {
if (product.isSoldOut()) {
throw new SoldOutException();
}
long discountAmount = getDiscountAmount(product);
return product.getPrice() - discountAmount;
}
private long getDiscountAmount(Product product) {
if (product.getPrice() > 20_000L) {
return 1000L;
}
return 0L;
}
}

OrderService 는 Product를 입력받아 상품의 재고를 확인하고 할인 가격을 계산해서 상품의 결제 금액을 반환하고 있다. 주문 과정에서 재고 확인과 상품의 금액을 계산하는 것은 필요하다. OrderService 의 책임이 주문이라고 하면 앞서 말한 재고 확인과 상품 금액 계산은 적절한 책임이다. 그러나 다른 관점에서 보면 OrderService 이 상품의 할인 가격을 계산하는 것은 과도한 책임일 수 있다. 할인 가격을 계산하는 객체로 따로 분리한다면 책임을 더 적절하게 나눌 수 있지 않을까.

123456789101112
public class OrderService {
private final DiscountCalculator discountCalculator = new DiscountCalculator();
public long order(Product product) {
if (product.isSoldOut()) {
throw new SoldOutException();
}
long discountAmount = discountCalculator.getDiscountAmount(product);
return product.getPrice() - discountAmount;
}
}

OrderService 는 전반적인 주문을 담당하고  DiscountCalculator는 상품의 할인 가격을 계산하는 담당 하도록 리팩토링을 했다. OrderServiceDiscountCalculator 객체에 할인 가격을 요청하고, 요청받은 DiscountCalculator 객체는 할인 가격을 계산해서 반환한다. 이후에 OrderService는 주문을 계속 진행한다. 

단일 책임 원칙으로 객체의 책임을 바라본다면 한 객체가 어디까지 담당할까를 적절하게 정의해야 한다. 한 객체에서 과도한 책임을 지지 않도록 제한함으로써 다수의 책임이 아닌 단일 책임을 갖도록 할 수 있다. 하나의 과정일 수 있지만 작은 단위의 책임으로 나눠 각각 객체에 할당한다. 그리고 전반적인 하나의 과정은 객체 사이에서의 커뮤니케이션을 통해 완성할 수 있다. 그렇다고 해서 책임을 계속 작은 단위로 나누는 것은 지양해야 한다. 지나치게 책임을 나눠 적절한 수준보다 더 많은 객체를 생성하면 그만큼 관리해야 하는 객체가 많아진다. 유지보수 하는 데에 있어서 오히려 더 많은 리소스가 소요될 수 있다. 여기서 말하는 적절한 수준은 정확하게 정해진 답은 없다. 경험/직감으로 정하거나 협업하는 동료와 소통해서 정해야 한다.

OCP, Open Closed Principle

개방 폐쇄 원칙(Open Closed Principle)은 확장에 대해서는 열려 있고, 수정에 대해서는 닫혀있음을 말한다. 여기서 확장과 수정은 무엇일까, 확장은 객체가 가지는 기능 외에 필요한 기능을 추가하는 것이다. 확장에 열려 있다는 것은 새로운 기능을 추가에서 손쉽게 추가를 할 수 있도록 객체를 구성하는 것이다. 수정에 닫혀있다는 것은 기존에 구성된 객체가 수정 사항이 있음에도 이전에 지원했던 기능을 그대로 지원하는 것이다. 수정 사항이 기존의 기능에 영향을 주지 않는다. 간단하게 객체의 기능을 유지하면서 새로운 기능을 추가하는 데 어려움이 없어야 한다.

123456789
public class DiscountCalculator {
public long getDiscountAmount(Product product) {
if (product.getPrice() > 20_000L) {
return 1000L;
}
return 0L;
}
}

DiscountCalculator 를 살펴보자. 간단하게 상품의 가격이 20,000원이 초과할 때, 1,000원을 할인해주고 있다. 만약 새로운 요구 사항으로 특정 상품에 대해서는 10%를 할인해주도록 기능을 추가한다고 하자. 어떻게 하면 기존의 기능을 변경하지 않으면서 쉽게 새로운 기능을 추가할까.

1234567891011
public class DiscountCalculator {
public long getDiscountAmount(Product product) {
if ("AMOUNT_DISCOUNT".equals(product.getDiscountType()) && product.getPrice() > 20_000L) {
return 1000L;
} else if ("PERCENTAGE_DISCOUNT".equals(product.getDiscountType()) && product.getPrice() > 20_000L) {
return product.getPrice() * 0.1;
}
return 0L;
}
}

리팩토링한 DiscountCalculator 를 다시 살펴보자. 우선 할인 유형을 나타내기 위해 getDiscountType() 함수를 추가했다. 그리고 각각 할인 유형에 따라 할인 가격을 계산하도록 조건문을 활용했다. 간단한 리팩토링이다. 

위의 리팩토링을 한 코드는 개방 폐쇄 원칙을 지키고 있다고 말할 수 있을까, 확실히 말할 수는 없지만 그렇지 않다. 왜냐하면, 만약에 또 다른 할인 유형이 나타난다면 할인 가격을 계산하는 함수인 getDiscountAmount() 를 수정해야 하기 때문이다. 예제의 코드는 간단하지만, 실제 해당 함수를 수정하다가 다른 할인 유형의 계산 로직을 실수로 수정할 수 있다. 

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
public class DiscountCalculator {
private final Map<String, DiscountResolver> discountTypeToResolverMap;
public DiscountCalculator(List<DiscountResolver> discountResolvers) {
discountTypeToResolverMap = discountResolvers.stream()
.collect(Collectors.toMap(DiscountResolver::getDiscountType, it -> it));
}
public long getDiscountAmount(Product product) {
DiscountResolver discountResolver = discountTypeToResolverMap.getOrDefault(product.getDiscountType(), product -> 0);
return discountResolver.resolve(product);
}
public interface DiscountResolver {
String getDiscountType();
long resolve(Product product);
}
public static class AmountDiscountResolver implements DiscountResolver {
@Override
public String getDiscountType(
) {
return "AMOUNT_DISCOUNT";
}
@Override
public long resolve(Product product) {
if (product.getPrice() > 20_000L) {
return 1000L;
}
return 0L;
}
}
public static class PercentageDiscountResolver implements DiscountResolver {
@Override
public String getDiscountType(
) {
return "PERCENTAGE_DISCOUNT";
}
@Override
public long resolve(Product product) {
if (product.getPrice() > 20_000L) {
return product.getPrice() * 0.1;
}
return 0L;
}
}
}

개방 폐쇄 원칙을 지키도록 DiscountCalculator를 리팩토링을 해보았다. 할인 유형에 따라 가격을 계산하는 인터페이스(DiscountResolver )를 두고 이를 구현하는 구체 클래스(AmountDiscountResolver , PercentageDiscountResolver)를 만들었다.

새로운 요구 사항이 추가되었다고 가정해보자. 아까처럼 기존의 함수를 수정해야 할까, 아니다. 이제는 새로운 할인 유형에 대한 구체 클래스만 추가로 생성하면 된다. DiscountResolver 를 구현하는 구체 클래스를 만들어 DiscountCalculator 를 생성할 때, 생성자의 매개 변수로 새로 만든 구체 클래스를 추가하면 된다. 추가할 때, 실수가 발생한다면 새로 추가한 클래스에 대해서만 찾아보면 된다. 다시 말하면 기존의 할인 가격을 계산하는 함수에는 전혀 영향이 없다. 확장에는 열려 있지만, 수정에는 닫혀 있음은 이러한 구성이 아닐까. 

추가로 DiscountCalculator 의 책임을 더 작게 나눈 것으로도 볼 수 있다. 기존의 하나의 함수에서 모든 할인 가격을 계산했다면 이제는 각각 유형마다 구체 클래스로 책임을 세분화했다.

LSP, Liskov Substitution Principle

리스코프 치환 원칙(Liskov Substitution Principle)은 자식 객체가 부모 객체로 치환되어도 올바르게 동작함을 말한다. 사실 이 원칙에 대해서는 이해도가 낮다. 그래서 흔히 말하는 직사각형과 정사각형 사이의 관계를 예를 들어보자.

우리는 흔히 정사각형은 직사각형임을 알고 있다. 그 이유는 정사각형은 직사각형의 구성 조건에 부합하기 때문이다. 직사각형은 사각형 중 네 각이 모든 같은 사각형을 말한다. 그리고 정사각형은 직사각형처럼 네 각이 모두 같고 네 변의 길이가 모든 같은 사각형을 말한다. 그래서 얼핏 보면 ‘정사각형은 직사각형이다’라고 생각한다. 그래서 코드로 나타낼 때도 정사각형을 직사각형의 자식 객체, 즉 상속을 통해 구현하고 있다.

사실 ‘정사각형은 직사각형이다'라는 것이 ‘정사각형이 직사각형을 상속한다'와 같지 않다. 왜냐하면, 직사각형은 네 변의 길이가 같지 않기 때문이다. 정사각형 객체는 네 변의 길이가 같음을 확인하고 이를 바탕으로 다른 기능을 구현하기 때문이다. 상속 관계에서 부모와 자식 객체의 특징이 다르다면 리스코프 치환 원칙을 지키지 못한다.

ISP, Interface Segregation Principle

인터페이스 분리 원칙(Interface Segregation Principle)은 객체의 필요한 부부만 구현함을 뜻한다. 다시 말하면 인터페이스를 구현한 구체 클래스가 필요하지 않은 함수도 구현을 해야 한다면 이는 인터페이스 분리 원칙을 지키지 못한 것이다. 앞서 얘기했던  DiscountCalculator 를 살펴보자. 기존 요구 사항에 새로운 요구 사항이 추가되었다. 이제는 Product 뿐만 아니라 Item 객체도 할인 가격을 계산해야 한다. 여기서 Item  은 Product 처럼 판매할 수 있는 새로운 객체로 생각하자. 

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
public class DiscountCalculator {
private final Map<String, DiscountResolver> discountTypeToResolverMap;
public DiscountCalculator(List<DiscountResolver> discountResolvers) {
discountTypeToResolverMap = discountResolvers.stream()
.collect(Collectors.toMap(DiscountResolver::getDiscountType, it -> it));
}
public long getDiscountAmount(Product product) {
DiscountResolver discountResolver = discountTypeToResolverMap.getOrDefault(product.getDiscountType(), product -> 0);
return discountResolver.resolve(product);
}
public interface DiscountResolver {
String getDiscountType();
long resolve(Product product);
long resolve(Item item);
}
public static class AmountDiscountResolver implements DiscountResolver {
@Override
public String getDiscountType() {
return "AMOUNT_DISCOUNT";
}
@Override
public long resolve(Product product) {
if (product.getPrice() > 20_000L) {
return 1000L;
}
return 0L;
}
@Override
public long resolve(Item item) {
return return 1000L;
}
}
public static class PercentageDiscountResolver implements DiscountResolver {
@Override
public String getDiscountType() {
return "PERCENTAGE_DISCOUNT";
}
@Override
public long resolve(Product product) {
if (product.getPrice() > 20_000L) {
return product.getPrice() * 0.05;
}
return 0L;
}
@Override
public long resolve(Item item) {
return 0;
}
}
}

DiscountResolver 에 Item 를 지원하도록 long resolve(Item item)함수를 추가했다. 그렇다면 이 인터페이스를 구현하는 모든 구체 클래스도 해당 함수를 구현해야 한다. 그리고 이때 Item 에 적용하는 할인 유형은 ‘AMOUNT_DISCOUNT’ 밖에 없다. 그러면 ‘PERCENTAGE_DISCOUNT’ 유형의 구체 클래스는 Item 에 대한 할인 가격을 구할 필요가 없다. 인터페이스 분리 원칙으로 보면 PercentageDiscountResolver 는 불필요한 함수를 구현하고 있고 이는 원칙을 위배하고 있다. DiscountResolver를 수정해서 사용할 것이 아니라 ItemDiscountResolver를 도입해서 구현하는 것이 더 인터페이스 분리 원칙에 맞지 않을까.

DIP, Dependency Inversion Principle

의존성 역전 원칙(Dependency Inversion Principle)은 객체는 저수준 모듈보다 고수준 모듈에 의존해야 함을 말한다. 여기서 고수준의 모듈은 인터페이스나 추상 객체를 말하고 저수준의 모듈은 구체 클래스를 말한다. 위의 예제를 보면 OrderService 는 할인 가격을 계산하는 DiscountCalculator 에 의존하고 있다. 고수준의 모듈이 아니라 저수준의 모듈에 의존하는 것이다. 그렇다면 왜 저수준 모듈인 구체 클래스에 의존하면 안 될까?

객체 지향 프로그래밍에서 의존 관계는 발생할 수밖에 없다. 왜냐하면, 객체는 책임과 역할이 있고 객체 간의 커뮤니케이션을 통해 시스템을 구현하기 때문이다. 이때 한 객체가 다른 구체 클래스에 의존하고 있다면 그 구체 클래스가 변경될 때마다 영향이 있을 수 있다. 그러나 두 객체 사이의 하나의 고수준 모듈인 인터페이스가 있다면 인터페이스가 변경되지 않는 이상 구체 클래스의 변경이 다른 객체에 전달되지 않는다. 이 의존성 역전 원칙은 두 객체 사이의 의존성을 약하게 만들어 수정이 있을 때, 쉽게 가능하도록 만들어준다.

실제 갑자기 실험적인 기능을 개발할 때, 데이터를 저장하기 위해서 외부 저장소에 파일로 다룬다. 이후에 정식 기능이 되면 데이터베이스로 저장소를 변경한다. 구체 클래스로 파일을 다루다가 데이터베이스로 다루도록 직접 변경해도 괜찮지만, 인터페이스를 중간에 두고 이 인터페이슬 구현하는 구체 클래스로 파일, 데이터베이스 구체 클래스로 구현하면 편하다.

References