카테고리 없음

객체지향 심화 학습 3편: 리스코프 치환 원칙 (LSP) 완전정복 🎯

블로글러 2024. 11. 3. 21:10

오늘은 SOLID의 세 번째 원칙인 리스코프 치환 원칙(LSP)을 자세히 알아볼게요!

1. 리스코프 치환 원칙이란? 💡

핵심: "상위 타입의 객체를 하위 타입의 객체로 치환해도 프로그램의 정확성이 깨지지 않아야 한다"

Bad Case: LSP 위반 사례

// ❌ 이렇게 하면 안돼요!
class Rectangle {
    protected int width;
    protected int height;

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

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

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

class Square extends Rectangle {
    // 정사각형은 가로 세로가 같아야 하므로 오버라이드
    @Override
    public void setWidth(int width) {
        this.width = width;
        this.height = width;  // LSP 위반!
    }

    @Override
    public void setHeight(int height) {
        this.width = height;  // LSP 위반!
        this.height = height;
    }
}

// 이런 문제가 발생해요
void processRectangle(Rectangle rectangle) {
    rectangle.setWidth(5);
    rectangle.setHeight(4);
    // 직사각형이면 20을 기대하지만
    // 정사각형이 들어오면 16이 됨! (예상치 못한 결과)
    assert rectangle.getArea() == 20;
}

Good Case: LSP 준수 사례

// ✅ 이렇게 인터페이스로 분리하면 좋아요!
interface Shape {
    int getArea();
}

class Rectangle implements Shape {
    private int width;
    private int height;

    public Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }

    @Override
    public int getArea() {
        return width * height;
    }
}

class Square implements Shape {
    private int side;

    public Square(int side) {
        this.side = side;
    }

    @Override
    public int getArea() {
        return side * side;
    }
}

2. 실전 적용 예시: 결제 시스템 💳

2-1. 기본 결제 인터페이스 설계

public interface PaymentProcessor {
    /**
     * @throws PaymentFailedException 결제 실패시
     * @return 결제 결과
     */
    PaymentResult process(Payment payment);

    /**
     * @throws RefundFailedException 환불 실패시
     * @return 환불 결과
     */
    RefundResult refund(Payment payment);
}

public class Payment {
    private final Money amount;
    private final String orderId;
    private PaymentStatus status;

    // ... 생성자, getter 등
}

2-2. 신용카드 결제 구현

@Component
public class CreditCardProcessor implements PaymentProcessor {
    private final CardCompanyGateway gateway;

    @Override
    public PaymentResult process(Payment payment) {
        validatePayment(payment);

        try {
            CardPaymentResult result = gateway.pay(
                payment.getAmount(),
                payment.getCardInfo()
            );

            return new PaymentResult(
                result.isSuccess(),
                result.getTransactionId()
            );
        } catch (GatewayException e) {
            throw new PaymentFailedException("카드 결제 실패", e);
        }
    }

    @Override
    public RefundResult refund(Payment payment) {
        validateRefund(payment);

        try {
            CardRefundResult result = gateway.refund(
                payment.getTransactionId()
            );

            return new RefundResult(
                result.isSuccess(),
                result.getRefundId()
            );
        } catch (GatewayException e) {
            throw new RefundFailedException("카드 환불 실패", e);
        }
    }
}

2-3. 가상계좌 결제 구현

@Component
public class VirtualAccountProcessor implements PaymentProcessor {
    private final BankingGateway bankingGateway;

    @Override
    public PaymentResult process(Payment payment) {
        validatePayment(payment);

        // 가상계좌는 발급만 하고 실제 입금은 나중에!
        try {
            VirtualAccountInfo accountInfo = bankingGateway.createVirtualAccount(
                payment.getAmount(),
                payment.getOrderId()
            );

            return new PaymentResult(
                true,
                accountInfo.getAccountNumber()
            );
        } catch (BankingException e) {
            throw new PaymentFailedException("가상계좌 발급 실패", e);
        }
    }

    @Override
    public RefundResult refund(Payment payment) {
        // 가상계좌는 입금 전에는 환불이 필요 없음
        if (!payment.isDeposited()) {
            return new RefundResult(true, null);
        }

        // 입금된 경우에만 환불 처리
        try {
            RefundInfo refundInfo = bankingGateway.refund(
                payment.getAccountInfo(),
                payment.getAmount()
            );

            return new RefundResult(
                true,
                refundInfo.getRefundId()
            );
        } catch (BankingException e) {
            throw new RefundFailedException("가상계좌 환불 실패", e);
        }
    }
}

3. LSP 위반 사례와 해결 방법 🔨

3-1. 예외 규약 위반

// ❌ 상위 타입에 없는 예외를 throws
class Parent {
    public void process() {
        // 정상 처리
    }
}

class Child extends Parent {
    @Override
    public void process() throws SQLException {  // LSP 위반!
        // DB 처리 중 예외 발생
    }
}

// ✅ 상위 타입의 예외로 감싸서 던지기
class Child extends Parent {
    @Override
    public void process() {
        try {
            // DB 처리
        } catch (SQLException e) {
            throw new RuntimeException("처리 실패", e);
        }
    }
}

3-2. 사전조건 강화 위반

// ❌ 하위 타입에서 더 엄격한 검증
class Parent {
    public void setValue(int value) {
        // 모든 정수 허용
    }
}

class Child extends Parent {
    @Override
    public void setValue(int value) {
        if (value <= 0) {  // LSP 위반! 더 엄격한 조건
            throw new IllegalArgumentException("양수만 허용");
        }
    }
}

// ✅ 별도의 메서드로 분리
class Parent {
    public void setValue(int value) {
        // 모든 정수 허용
    }
}

class Child extends Parent {
    public void setPositiveValue(int value) {  // 새로운 메서드
        if (value <= 0) {
            throw new IllegalArgumentException("양수만 허용");
        }
        setValue(value);
    }
}

4. LSP를 지키는 방법 📝

4-1. 계약에 의한 설계 (Design by Contract)

/**
 * 결제 처리 인터페이스
 *
 * 계약 사항:
 * 1. 사전 조건: payment는 null이 아니어야 함
 * 2. 사후 조건: 결제 성공시 반드시 거래 ID가 존재해야 함
 * 3. 불변식: 결제 금액은 항상 0보다 커야 함
 */
public interface PaymentProcessor {
    PaymentResult process(Payment payment);
}

@Component
public class SafePaymentProcessor implements PaymentProcessor {
    @Override
    public PaymentResult process(Payment payment) {
        // 사전 조건 검증
        Objects.requireNonNull(payment, "결제 정보는 필수입니다");

        // 불변식 검증
        if (payment.getAmount().isLessThanOrEqual(Money.ZERO)) {
            throw new IllegalArgumentException("결제 금액은 0보다 커야 합니다");
        }

        // 결제 처리
        PaymentResult result = processPayment(payment);

        // 사후 조건 검증
        if (result.isSuccess() && result.getTransactionId() == null) {
            throw new IllegalStateException("거래 ID가 없습니다");
        }

        return result;
    }
}

4-2. 테스트를 통한 검증

@Test
void 모든_결제처리기는_LSP를_만족해야_한다() {
    // Given
    List<PaymentProcessor> processors = Arrays.asList(
        new CreditCardProcessor(),
        new VirtualAccountProcessor(),
        new KakaoPayProcessor()
    );

    Payment payment = new Payment(Money.wons(10000), "ORDER-001");

    // When & Then
    processors.forEach(processor -> {
        PaymentResult result = processor.process(payment);

        // 공통 계약 검증
        assertThat(result).isNotNull();
        if (result.isSuccess()) {
            assertThat(result.getTransactionId()).isNotNull();
        }
    });
}

5. LSP의 장점 🌟

  1. 코드의 유연성 향상

    • 상위 타입으로 프로그래밍 가능
    • 런타임에 하위 타입 객체로 자유롭게 교체 가능
  2. 테스트 용이성

    • 상위 타입의 테스트케이스로 모든 하위 타입 검증 가능
  3. 유지보수성 향상

    • 예측 가능한 동작으로 버그 발생 감소
    • 코드 이해도 향상
  4. 재사용성 향상

    • 상위 타입을 사용하는 코드를 수정 없이 재사용 가능

6. 실무 적용 전략 💪

6-1. 상속 관계 설계시 체크리스트

  • 하위 클래스가 상위 클래스의 동작을 변경하지 않는가?
  • 하위 클래스의 사전조건이 더 강화되지 않았는가?
  • 하위 클래스의 사후조건이 더 약화되지 않았는가?
  • 하위 클래스가 상위 클래스의 불변식을 지키는가?

6-2. 코드 리뷰시 체크포인트

// 1. 메서드 시그니처 검사
@Override  // 반드시 @Override 사용
public ReturnType methodName(Parameters... params) {
    // 상위 타입과 동일한 시그니처
}

// 2. 예외 처리 검사
try {
    // 로직
} catch (Exception e) {
    // 상위 타입에 선언된 예외만 throws
}

// 3. 검증 로직 검사
if (value < 0) {  // 상위 타입보다 더 강한 검증은 피하기
    throw new IllegalArgumentException();
}

6-3. 테스트 전략

public abstract class PaymentProcessorTest {
    // 모든 결제처리기가 만족해야 하는 테스트케이스
    @Test
    void 기본_결제_테스트() {
        // Given
        PaymentProcessor processor = createProcessor();
        Payment payment = createValidPayment();

        // When
        PaymentResult result = processor.process(payment);

        // Then
        assertThat(result.isSuccess()).isTrue();
        assertThat(result.getTransactionId()).isNotNull();
    }

    // 하위 클래스에서 구체적인 처리기 생성
    protected abstract PaymentProcessor createProcessor();
}

class CreditCardProcessorTest extends PaymentProcessorTest {
    @Override
    protected PaymentProcessor createProcessor() {
        return new CreditCardProcessor();
    }

    // 신용카드 결제 고유의 테스트 추가
}

마치며 🎁

LSP는 상속과 다형성을 올바르게 사용하기 위한 핵심 원칙입니다. 이 원칙을 잘 지키면 코드의 재사용성과 유지보수성이 크게 향상됩니다. 상속을 사용할 때는 항상 LSP를 고려하면서 설계해주세요!


다음 시간에는 인터페이스 분리 원칙(ISP)에 대해 알아볼게요!
궁금한 점이 있다면 댓글로 남겨주세요! 😊

728x90