오늘은 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의 장점 🌟
코드의 유연성 향상
- 상위 타입으로 프로그래밍 가능
- 런타임에 하위 타입 객체로 자유롭게 교체 가능
테스트 용이성
- 상위 타입의 테스트케이스로 모든 하위 타입 검증 가능
유지보수성 향상
- 예측 가능한 동작으로 버그 발생 감소
- 코드 이해도 향상
재사용성 향상
- 상위 타입을 사용하는 코드를 수정 없이 재사용 가능
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