왜 당신의 Java 코드에 Liskov Substitution Principle가 필요한가
Source: Dev.to
왜 당신의 Java 코드에 리스코프 치환 원칙(Liskov Substitution Principle)이 필요한가?
소프트웨어 설계 원칙 중 하나인 LSP(Liskov Substitution Principle)는 객체 지향 프로그래밍에서 **“서브타입은 언제나 그들의 기반 타입으로 교체될 수 있어야 한다”**는 규칙을 말합니다. 이 원칙을 무시하면 코드가 예상치 못한 방식으로 동작하고, 유지보수가 어려워집니다. 이번 글에서는 Java 예제를 통해 LSP를 위반했을 때 발생하는 문제와, 이를 어떻게 해결할 수 있는지 살펴보겠습니다.
1️⃣ LSP를 위반한 예시
아래 코드는 Shape이라는 추상 클래스를 상속받아 Rectangle과 Square를 구현한 예시입니다.
public abstract class Shape {
public abstract double area();
}
public class Rectangle extends Shape {
protected double width;
protected double height;
public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
@Override
public double area() {
return width * height;
}
public void setWidth(double width) {
this.width = width;
}
public void setHeight(double height) {
this.height = height;
}
}
public class Square extends Rectangle {
public Square(double side) {
super(side, side);
}
@Override
public void setWidth(double width) {
super.setWidth(width);
super.setHeight(width); // 정사각형이므로 높이도 같이 바꿔야 함
}
@Override
public void setHeight(double height) {
super.setHeight(height);
super.setWidth(height); // 정사각형이므로 너비도 같이 바꿔야 함
}
}
위 코드는 LSP를 위반합니다. Square는 Rectangle을 상속했지만, setWidth와 setHeight 메서드의 동작이 Rectangle과 다르게 동작합니다. 이 때문에 Rectangle 타입으로 선언된 변수에 Square 객체를 넣고 사용하면 의도와 다른 결과가 나올 수 있습니다.
LSP 위반을 보여주는 테스트
public static void main(String[] args) {
Rectangle rect = new Square(5);
rect.setWidth(10);
System.out.println("Width: " + rect.width); // 기대: 10
System.out.println("Height: " + rect.height); // 실제: 10 (정사각형이므로 높이도 10)
}
위 예제에서 rect는 Rectangle 타입이지만 실제 객체는 Square입니다. setWidth(10)을 호출했을 때 height도 자동으로 10으로 바뀌어 예상치 못한 부작용이 발생합니다. 이는 LSP를 위반한 전형적인 사례이며, 클라이언트 코드는 Rectangle이 순수하게 동작할 것이라고 가정했기 때문에 버그가 생깁니다.
2️⃣ 왜 LSP가 중요한가?
-
예측 가능한 행동
서브클래스가 기반 클래스와 동일한 계약을 유지하면, 클라이언트 코드는 타입만 보고도 동작을 예측할 수 있습니다. -
유연한 확장
새로운 서브클래스를 추가할 때 기존 코드를 수정할 필요가 없습니다. 이는 **OCP(Open/Closed Principle)**와도 연결됩니다. -
테스트 용이성
LSP를 만족하면 모의 객체(Mock)를 사용해 테스트하기가 쉬워집니다. 서브클래스가 기본 계약을 깨지 않기 때문에 테스트가 깨지지 않습니다.
3️⃣ LSP를 만족하도록 리팩터링하기
위 예제에서 가장 간단한 해결책은 상속 대신 컴포지션(Composition)을 사용하는 것입니다. Square와 Rectangle을 별도의 클래스로 두고, 공통 인터페이스 Shape만 구현하도록 변경합니다.
public interface Shape {
double area();
}
public class Rectangle implements Shape {
private double width;
private double height;
public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
@Override
public double area() {
return width * height;
}
public void setWidth(double width) {
this.width = width;
}
public void setHeight(double height) {
this.height = height;
}
}
public class Square implements Shape {
private double side;
public Square(double side) {
this.side = side;
}
@Override
public double area() {
return side * side;
}
public void setSide(double side) {
this.side = side;
}
}
리팩터링 후 테스트
public static void main(String[] args) {
Shape shape1 = new Rectangle(4, 5);
Shape shape2 = new Square(5);
System.out.println("Rectangle area: " + shape1.area()); // 20
System.out.println("Square area: " + shape2.area()); // 25
}
이제 Rectangle과 Square는 서로 독립적인 구현을 가지며, LSP를 위반하지 않습니다. Shape 인터페이스만 알면 어떤 구체적인 형태든 사용할 수 있기 때문에 클라이언트 코드는 안전합니다.
4️⃣ 실무에서 LSP를 적용하는 팁
| 상황 | 적용 방법 |
|---|---|
| 상속을 사용하고 싶지만 행동이 다를 때 | 상속 대신 인터페이스와 컴포지션을 고려한다. |
| 메서드 시그니처는 동일하지만 예외를 다르게 던질 때 | 상위 클래스가 선언한 예외보다 넓은 범위의 예외만 허용한다. |
| 서브클래스가 전처리/후처리를 추가하고 싶을 때 | 템플릿 메서드 패턴을 사용해 기본 흐름을 유지하면서 확장한다. |
| 다형성을 활용한 컬렉션 처리 | 컬렉션에 넣을 객체들은 공통 인터페이스를 구현하도록 설계한다. |
5️⃣ 결론
Liskov Substitution Principle은 코드의 신뢰성, 유지보수성, 확장성을 크게 향상시킵니다. 위 예시처럼 상속 구조를 무분별하게 사용하면 LSP를 쉽게 위반하게 되고, 이는 버그와 설계 부실로 이어집니다.
- 상속보다 컴포지션을 우선 고려한다.
- 인터페이스를 통해 계약을 명확히 정의한다.
- 서브클래스가 기반 클래스의 행동을 변경하지 않도록 설계한다.
이 원칙을 팀 차원에서 코드 리뷰 체크리스트에 포함시키면, 장기적으로 더 깨끗하고 견고한 Java 애플리케이션을 만들 수 있습니다.
Tip: LSP를 검증할 때는 단위 테스트를 활용해 “서브타입을 기반 타입으로 교체했을 때 동일한 결과가 나오는가?”를 확인해 보세요.
Happy coding!
Introduction
장난감 가게에 가서 TV 리모컨용 교체 배터리를 사는 상황을 상상해 보세요. AA 배터리 한 팩을 봅니다. Duracell이든, Energizer이든, 혹은 일반 매장 브랜드이든 상관없이 어떤 브랜드의 AA 배터리라도 리모컨에 딱 맞고 완벽하게 전원을 공급할 것이라고 기대하죠.
만약 실제로는 삼각형 모양이라 슬롯에 맞지 않는 “AA 배터리”를 샀다면, 실망하겠죠? 그 배터리는 AA 배터리가 가져야 할 “계약”을 어긴 것입니다.
Java 프로그래밍에서는 코드에서 이런 실망을 방지하기 위한 규칙이 있습니다. 바로 Liskov Substitution Principle(LSP)이라고 합니다.
핵심 개념: LSP란 무엇인가?
Liskov Substitution Principle는 SOLID 설계 원칙 중 “L”에 해당합니다. 간단히 말하면, 다음과 같습니다:
슈퍼클래스의 객체는 애플리케이션을 깨뜨리지 않으면서 서브클래스의 객체로 교체 가능해야 합니다.
왜 신경 써야 할까요?
당신이 Java를 배울 때, 상속(“is‑a” 관계)에 대해 배우게 됩니다. 하지만 정사각형이 수학적으로 “is‑a” 직사각형이라고 해서 코드에서 Rectangle 클래스를 상속받아야 한다는 의미는 아닙니다! 코드가 Rectangle을 기대하고 있는데 Square를 전달하고, 갑자기 너비와 높이가 예상치 못하게 바뀐다면, LSP를 위반한 것입니다.
주요 이점
- 코드 재사용성: 기본 클래스에 로직을 작성하고 향후 모든 서브클래스에서도 작동할 것이라고 신뢰합니다.
- 유지보수성: 새로운 서브클래스가 시스템의 기존 부분을 “깨뜨리지” 않습니다.
- 신뢰성: 개발자들을 밤새도록 괴롭히는 성가신 “예상치 못한 동작” 버그를 줄여줍니다.
코드 예제 (Java 21)
1. 올바른 방법: 대체 가능성을 위한 설계
// The Base Contract
abstract class PaymentProcessor {
// Every payment must be able to process an amount
public abstract void process(double amount);
}
// Subclass 1: Credit Card
class CreditCardProcessor extends PaymentProcessor {
@Override
public void process(double amount) {
System.out.println("Processing credit card payment of $" + amount);
// Logic for merchant gateway
}
}
// Subclass 2: PayPal
class PayPalProcessor extends PaymentProcessor {
@Override
public void process(double amount) {
System.out.println("Redirecting to PayPal for payment of $" + amount);
// Logic for digital wallet
}
}
public class PaymentApp {
// This method follows LSP: It accepts ANY PaymentProcessor
public static void executeTransaction(PaymentProcessor processor, double amount) {
processor.process(amount);
}
public static void main(String[] args) {
// We can swap CreditCard for PayPal seamlessly!
executeTransaction(new CreditCardProcessor(), 100.00);
executeTransaction(new PayPalProcessor(), 50.00);
}
}
2. 잘못된 방법: 계약 위반
class Bird {
public void fly() {
System.out.println("I am flying!");
}
}
class Ostrich extends Bird {
@Override
public void fly() {
// VIOLATION: An Ostrich is a Bird, but it CANNOT fly.
// This breaks any code that expects a Bird to fly.
throw new UnsupportedOperationException("Ostriches can't fly!");
}
}
팁: 위 문제를 해결하려면 FlyingBird와 NonFlyingBird 클래스를 만들어야 합니다!
LSP 모범 사례
- “빈” 오버라이드 피하기: 메서드를 오버라이드해서
UnsupportedOperationException만 던진다면, 상속 구조가 잘못된 것입니다. - 메서드 일관성 유지: 서브클래스는 부모와 동일한 입력 타입을 받아들이고 동일하거나 더 구체적인 반환 타입을 반환해야 합니다.
- “행동”으로 생각하기, “분류”만이 아니라: 펭귄이 생물학적으로 새라 하더라도 코드에
.fly()메서드를 상속시켜서는 안 됩니다. - 인터페이스 사용:
Swimmable이나Flyable같은interface가 깊은 클래스 계층보다 나을 때가 있습니다. - 문서 참고: 메서드 시그니처에 대한 공식 규칙을 이해하려면 Oracle Java Documentation on Inheritance 를 확인하세요.
결론
Liskov Substitution Principle은 예측 가능성에 관한 것입니다. 서브클래스가 부모 클래스가 약속한 것을 충실히 이행하도록 함으로써, 모듈화되고 테스트하기 쉬우며 성장에 대비된 코드베이스를 만들 수 있습니다. 이는 Java programming에서 초보자에서 전문가로 성장하는 가장 중요한 단계 중 하나입니다.