객체지향 프로그래밍이 어렵고, 기준도 잘모르겠어서 우선, SOLID 원칙을 제대로 알아가보려 한다.
객체지향 프로그래밍에서 SOLID 원칙을 잘 지키면 깨끗하고, 확장성이 높으며 유지보수성이 높은 코드를 작성할 수 있다.
또한, 테스트에도 용이한 코드를 작성할 수 있다.
몇주간 객체지향 프로그래밍에 대해 탐구한 결과, 객체지향 프로그래밍의 장점은 재사용할 수 있는 코드, 클래스 간 분명한 역할, 객체 간의 협력으로 구현하는 코드라고 생각한다.
객체를 재사용하면서 객체 간의 협력을 구현하면 일관성을 챙길 수 있다. 여기서 말하는 일관성이란, 공통의 목적을 공유할 때, 그것의 구현 방식도 같은 것이다. 일관성없는 코드를 작성했을 때는 하나의 목적을 가지는 여러 정책, 여러 방식에 대해 여러 구현 방식이 나오는 것이다. 이 경우에, 같은 목적의 새로운 정책이나 방식을 구현해야 한다면, 그 여러 구현 방식 중 어떤 방식으로 구현해야 할지에 대한 고민을 해야 한다. 또한, 정책 하나의 구현 방식을 봐도, 다른 정책의 코드가 이해하기 쉽지 않을 것이다.
SRP: 단일 책임 원칙
하나의 모듈, 클래스, 메서드는 하나의 책임만 가져야 한다는 것이다. 여기서 책임이라는 것이 애매하다.
책임을 결정하는 데에는 변경이 미치는 영향이 가장 중요하다. 변경의 이유와 변경의 주기가 같은 것끼리 분리하다보면 하나의 책임만 가지게 된다고 한다.
예시
class Pay {
...
public void discount(Product product) {
if(product.getPromotion() == Promotion.ADDTIONAL_PRODUCT) {
...
}
if(product.getPromotion() == Promotion.VIP) {
...
}
...
}
...
}
위 코드처럼 할인이라는 하나의 목적에 대해 여러 가지 프로모션 정책을 if문으로 분기처리를 한다면, 책임이 많은 것이다. 왜냐하면, 각 방식(정책)이 각각 다른 변경 이유와 변경 주기를 가질 수 있기 때문이다. 할인 정책이 추가될 수 있고, 하나의 할인 정책이 수정될 수 있는데, 이러한 변경으로 인해 서로에게 영향을 미칠 것이다.
이것을 메서드로 분리하면 어느정도 개선할 수 있지만, 더 개선할 수 있는 방법이 있다.
if문을 객체화하여 추상화에 의존하도록 하고, 구현체에 따라 그 방식(정책)이 달라지게 하는 것이다.
interface PromotionPolicy {
void discount(Product product);
}
class VipPromotionPolicy extends PromotionPolicy {
@Override
void discount(Product product) {
...
}
}
class AdditionalProductPromotionPolicy {
@Override
void dicount(Product product) {
...
}
}
이렇게 객체지향 프로그래밍에서는 if 문으로 조건을 판단하지 않고, 객체 사이의 연결로 그것을 대신할 수 있다.
또한, 각각의 조건을 분리함으로써, 재사용성도 챙길 수 있다.
변경 이유와 주기에 따라 객체를 분리하여 추상화에 의존하도록하면 SRP를 지킬 수 있다.
OCP: 개방-폐쇄 원칙
확장에는 열려있고, 변경에는 닫혀있어야 한다는 원칙이다.
다형성을 활용한 방식으로는 역할과 구현을 분리하여 인터페이스를 구현한 새로운 클래스를 하나 만들억서 새로운 기능을 구현하는 것이다.
SRP를 설명할 때의 예시처럼 PromotionPolicy라는 인터페이스를 만들어, 구현체를 여러개 둔다면, 새로운 프로모션 정책을 만들어도 다른 프로모션 정책에게 변경의 영향이 미치지 않을 것이다.
이렇게 확장에는 열려있지만, 변경에는 닫혀있는 구조를 만들 수 있다.
예시 코드
class PaymentService {
private final PromotionPolicy promotionPolicy
...
public void processDiscount(Product product) {
promotionPolicy.discount(product);
...
}
SRP에서의 예시 코드에 이어서, 위 코드처럼 PromotionPolicy를 사용하면, 새로운 할인 정책이 추가되어도, PaymentService를 수정할 필요없이 기능을 확장할 수 있다.
LSP: 리스코프 치환 원칙
프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서, 하위 타입의 인스턴스로 바꿀 수 있어야 한다는 원칙이다.
이를 지키려면, 클라이언트가 외부에서 슈퍼 타입을 사용하며 가지는 기대와, 예상 결과를 하위 타입이 깨면 안된다. 하위 타입은 행동에 대한 기대와 예상 결과에 벗어나는 행동을 하면 안된다.
클라이언트는 외부에서 하위 타입에 대한 차이를 인식하지 못한 채로 사용할 수 있어야 한다!
이를 지키려면, 하위 타입은 상위 타입보다 더 강한 사전 조건을 가지면 안된다. 상위 타입에 대해 기대하는 행동을 할 수 없을 지도 모르기 때문에!
또한, 하위 타입은 상위 타입보다 더 약한 사후 조건을 가지면 안된다. 상위 타입에 대해 기대하는 행동의 결과를 얻을 수 없을 지도 모르기 때문에!
이렇게, LSP는 하위 타입이 상위 타입에 대한 약속, 규약을 다 지켜야 한다는 것이다.
이것을 지켜야, 인터페이스를 믿고 사용할 수 있다!
더 강한 사전 조건의 예시
Rectangle과 Rectangle을 상속 받는 Square가 있을 때, 클라이언트가 Rectangle에 대해 높이와 너비가 다를 수 있다는 기대를 가지고 있다.
이때, Square가 높이와 너비가 같아야 한다는 조건이 있다면, Rectangle에 대한 클라이언트의 기대를 충족시키지 못할 수도 있다.
이외에도, 하위 클래스가 예외를 던지거나, return null;로 클라이언트의 예상에 벗어나는 결과를 내면 안된다.
ISP: 인터페이스 분리 원칙
특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다는 것이다.
역할과 책임이 많은 인터페이스를 구체적인 인터페이스로 나누어 클라이언트가 관심있는 것만 알도록 해야 한다.
이를 통해, 작은 인터페이스는 각 인터페이스에 대한 테스트를 단순하게 유지할 수 있다.
예시 코드
interface Worker {
void work();
void eat();
}
class Robot implements Worker {
public void work() { System.out.println("일한다!"); }
public void eat() { throw new UnsupportedOperationException("로봇은 밥 안 먹음!"); }
}
이처럼, Robot을 사용하는 클라이언트에게는 eat()라는 메서드를 몰라도 된다. 이것은 Movable, Eatable로 인터페이스를 나누어 해결할 수 있다.
DIP: 의존 관계 역전 원칙
구체화에 의존하면 안되고, 추상화에 의존해야 한다는 원칙이다.
상위 모듈(고수준 모듈)이 하위 모듈(저수준 모듈)의 구현에 직접 의존하지 않도록하여 결합도를 낮추고 유연성을 높이기 위함이다.
여기서 상위 모듈이란, 시스템의 비즈니스 로직을 정의하는 모듈이다. 프로그램에서 무엇을 해야하는지 결정하는 부분으로, 변경이 잦은 구체적인 구현보다는 개념적이고 일반적인 개념을 제공한다.
하위 모듈이란, 실제로 어떤 방식으로 동작할 지를 정의하는 모듈이다. 구체적이고, 기술적인 구현을 포함하며, 일반적으로 외부 시스템과 상호 작용하는 계층이다.
상위 모듈이 하위 모듈에 직접적으로 의존하면, 결합도가 높아져 하위 모듈에서 발생하는 변경이 상위 모듈로 영향을 미칠 가능성이 커진다.
SRP와 OCP에서 들은 예시 코드에서 PaymentService가 PromotionPolicy라는 추상화에 의존하는 것이 아니라, VipPromotionPolicy에 의존하게 되면, DIP를 위반하는 것이다.
VipPromotionPolicy가 변경되면 PaymentService에게 영향이 미칠 수 있다.
SOLID를 공부한 결과, 변경의 이유와 주기에 따라 객체를 나누고, 추상화를 적절히 활용하는 것이 객체지향 프로그래밍의 핵심이라는 생각이 들었다.
SRP를 지켜 단일 책임을 부여하면 변경의 영향을 최소화할 수 있다.
OCP를 지켜 새로운 기능을 추가할 때 기존 코드를 수정할 필요가 줄어든다.
LSP를 지켜 하위 타입이 상위 타입에 대한 기대를 충족하도록 보장하면 인터페이스를 믿고 사용할 수 있어, 객체 간의 협력이 원할해진다.
ISP를 지켜 각 클라이언트가 필요한 기능만 제공하는 인터페이스를 설계하면, 불필요한 의존성을 줄일 수 있고, 수정의 영향이 줄어든다.
DIP를 지켜 상위 모듈이 하위 모듈의 구체적인 구현에 의존하지 않도록 하면, 결합도를 낮추고 유지보수성을 높일 수 있다.
'공부' 카테고리의 다른 글
템플릿 메서드 패턴 vs 전략 패턴 (2) | 2025.03.16 |
---|---|
상속과 합성: 코드 재사용과 확장의 방법 (0) | 2025.03.09 |
equals()와 hashCode()의 개념과 관계 (0) | 2025.03.01 |
단위 테스트 (0) | 2025.03.01 |
객체와 자료 구조의 차이 (0) | 2025.03.01 |