Table of contents
LSP — 자식은 부모를 대체할 수 있어야 한다
LSP(Liskov Substitution Principle)는 Barbara Liskov가 1987년에 제안한 원칙이다.
If S is a subtype of T, then objects of type T may be replaced with objects of type S without altering the correctness of the program. — Barbara Liskov
말이 어렵게 느껴질 수 있는데, 핵심은 단순하다. 부모 타입을 사용하는 코드에 자식 타입을 넣어도 프로그램이 올바르게 동작해야 한다. 컴파일이 되느냐가 아니라, 의미적으로 정확하게 동작하느냐의 문제다.
고전적 위반 사례 — 직사각형과 정사각형
이 문제는 LSP를 설명할 때 가장 자주 등장하는 예제다. 수학적으로 정사각형은 직사각형의 특수한 형태다. 그러니까 상속으로 표현하면 자연스러워 보인다.
public 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;
}
}
정사각형은 가로와 세로가 항상 같아야 한다. 그래서 setter를 오버라이드한다.
public class Square extends Rectangle {
@Override
public void setWidth(int width) {
this.width = width;
this.height = width; // 가로를 바꾸면 세로도 바뀜
}
@Override
public void setHeight(int height) {
this.width = height; // 세로를 바꾸면 가로도 바뀜
this.height = height;
}
}
컴파일은 문제없다. 하지만 Rectangle을 사용하는 코드에 Square를 넣으면 어떻게 될까?
public void resize(Rectangle rect) {
rect.setWidth(5);
rect.setHeight(4);
assert rect.getArea() == 20; // 5 x 4 = 20이어야 한다
}
Rectangle을 넘기면 넓이는 20이 된다. 예상대로다. 하지만 Square를 넘기면? setHeight(4)가 호출되면서 width도 4로 바뀌어 넓이가 16이 된다. 단언(assert)이 실패한다.
flowchart TB
subgraph violation["LSP 위반 — Square가 Rectangle을 대체하지 못함"]
Client["resize 메서드"] -->|"Rectangle 기대"| Rect["Rectangle<br/>setWidth → width만 변경<br/>setHeight → height만 변경"]
Client -->|"Square 전달"| Sq["Square<br/>setWidth → width + height 변경<br/>setHeight → width + height 변경"]
Rect -->|"5 x 4 = 20"| OK["정상 동작"]
Sq -->|"4 x 4 = 16"| FAIL["동작 깨짐!"]
style FAIL fill:#ff6b6b,color:#fff
style OK fill:#51cf66,color:#fff
end
타입 시스템은 Square가 Rectangle의 자식이라고 보장해 준다. 하지만 행동적 호환성이 없다. 부모의 계약을 자식이 깨뜨린 것이다. 이것이 LSP 위반이다.
계약에 의한 설계
LSP를 더 정확하게 이해하려면 Design by Contract(계약에 의한 설계)라는 개념을 알아야 한다. Bertrand Meyer가 Eiffel 언어와 함께 제안한 개념으로, 메서드는 다음 세 가지 계약을 갖는다.
- 사전 조건(Precondition): 메서드 호출 전에 만족해야 하는 조건. 자식은 이를 약화(더 느슨하게)할 수 있지만 강화해서는 안 된다
- 사후 조건(Postcondition): 메서드 실행 후에 보장되는 조건. 자식은 이를 강화(더 엄격하게)할 수 있지만 약화해서는 안 된다
- 불변식(Invariant): 객체가 항상 만족해야 하는 조건. 자식도 반드시 유지해야 한다
Rectangle.setWidth()의 사후 조건은 width가 주어진 값으로 바뀌고, height는 변하지 않는다이다. Square는 setWidth()를 호출하면 height도 바뀌므로 사후 조건을 약화시켰다. 계약 위반이다.
올바른 설계 — 공통 추상화
직사각형-정사각형 문제의 해결책은 상속 대신 공통 추상화를 사용하는 것이다.
public interface Shape {
int getArea();
}
public class Rectangle implements Shape {
private final int width;
private final int height;
public Rectangle(int width, int height) {
this.width = width;
this.height = height;
}
@Override
public int getArea() {
return width * height;
}
}
public class Square implements Shape {
private final int side;
public Square(int side) {
this.side = side;
}
@Override
public int getArea() {
return side * side;
}
}
Shape 인터페이스를 통해 두 클래스 모두 getArea()를 제공한다. 서로 상속 관계가 아니므로 setWidth()/setHeight() 같은 계약 충돌이 발생할 여지가 없다. 불변 객체로 만들면 더 안전하다.
LSP 위반을 감지하는 신호
실무에서 LSP 위반을 빠르게 알아채는 몇 가지 신호가 있다.
- 자식 클래스에서 부모의 메서드를 오버라이드하면서 예외를 던진다 —
UnsupportedOperationException을 던지는 순간 부모의 계약을 깨뜨리고 있을 가능성이 높다 instanceof검사가 자꾸 늘어난다 — 다형성으로 해결해야 할 부분을 타입 체크로 우회하고 있다는 뜻이다- 자식 클래스를 넣으면 기존 테스트가 실패한다 — 부모 타입으로 작성한 테스트가 자식을 넣었을 때 깨지면 LSP 위반의 직접적인 증거다
// LSP 위반의 전형적 신호 — 자식이 부모의 동작을 거부한다
open class Bird {
open fun fly(): String = "날아간다"
}
class Penguin : Bird() {
override fun fly(): String {
throw UnsupportedOperationException("펭귄은 날 수 없다")
}
}
fun makeBirdFly(bird: Bird) {
println(bird.fly()) // Penguin이 들어오면 예외 발생
}
펭귄은 새다. 하지만 나는 새로서의 계약을 지킬 수 없다. 이럴 때는 FlyableBird와 NonFlyableBird로 계층을 분리하거나, fly() 메서드를 별도 인터페이스로 빼야 한다. 이 지점에서 자연스럽게 ISP로 넘어간다.
ISP — 클라이언트가 쓰지 않는 메서드에 의존하지 않는다
ISP(Interface Segregation Principle)는 Robert C. Martin이 제안한 원칙이다.
No client should be forced to depend on methods it does not use.
클라이언트가 자기에게 필요 없는 메서드까지 들고 있는 인터페이스에 의존하면, 그 필요 없는 메서드가 변경될 때 영향을 받게 된다. 인터페이스를 작고 구체적인 단위로 분리해야 한다.
위반 사례 — 뚱뚱한 인터페이스
복합기(All-in-One Printer)를 인터페이스로 정의한다고 해보자.
public interface MultiFunctionDevice {
void print(Document doc);
void scan(Document doc);
void fax(Document doc);
void staple(Document doc);
}
이 인터페이스를 구현하는 고급 복합기는 문제가 없다.
public class OfficeDevice implements MultiFunctionDevice {
@Override
public void print(Document doc) { /* 인쇄 */ }
@Override
public void scan(Document doc) { /* 스캔 */ }
@Override
public void fax(Document doc) { /* 팩스 */ }
@Override
public void staple(Document doc) { /* 스테이플 */ }
}
문제는 단순한 프린터를 구현할 때 생긴다.
public class SimplePrinter implements MultiFunctionDevice {
@Override
public void print(Document doc) { /* 인쇄 */ }
@Override
public void scan(Document doc) {
throw new UnsupportedOperationException("스캔 미지원");
}
@Override
public void fax(Document doc) {
throw new UnsupportedOperationException("팩스 미지원");
}
@Override
public void staple(Document doc) {
throw new UnsupportedOperationException("스테이플 미지원");
}
}
구현할 수 없는 메서드에 예외를 던지고 있다. 이건 앞서 본 LSP 위반의 신호이기도 하다. SimplePrinter는 MultiFunctionDevice의 계약을 온전히 이행하지 못한다.
flowchart TB
subgraph fat["ISP 위반 — 뚱뚱한 인터페이스"]
MFD["MultiFunctionDevice<br/>print + scan + fax + staple"]
MFD --> Office["OfficeDevice<br/>모두 구현 가능"]
MFD --> Simple["SimplePrinter<br/>print만 가능<br/>나머지는 UnsupportedOperationException"]
style Simple fill:#ff6b6b,color:#fff
end
인터페이스 분리
해법은 뚱뚱한 인터페이스를 역할별로 나누는 것이다.
public interface Printable {
void print(Document doc);
}
public interface Scannable {
void scan(Document doc);
}
public interface Faxable {
void fax(Document doc);
}
public interface Stapleable {
void staple(Document doc);
}
이제 각 클래스는 자신이 지원하는 기능만 구현한다.
public class SimplePrinter implements Printable {
@Override
public void print(Document doc) { /* 인쇄 */ }
}
public class OfficeDevice implements Printable, Scannable, Faxable, Stapleable {
@Override
public void print(Document doc) { /* 인쇄 */ }
@Override
public void scan(Document doc) { /* 스캔 */ }
@Override
public void fax(Document doc) { /* 팩스 */ }
@Override
public void staple(Document doc) { /* 스테이플 */ }
}
SimplePrinter는 Printable만 구현한다. UnsupportedOperationException을 던질 이유가 사라졌다. Faxable 인터페이스에 변경이 생겨도 SimplePrinter에는 영향이 없다.
flowchart TB
subgraph segregated["ISP 준수 — 분리된 인터페이스"]
P["Printable"]
S["Scannable"]
F["Faxable"]
ST["Stapleable"]
P --> Simple2["SimplePrinter"]
P --> Office2["OfficeDevice"]
S --> Office2
F --> Office2
ST --> Office2
style Simple2 fill:#51cf66,color:#fff
style Office2 fill:#51cf66,color:#fff
end
실무 예제 — 사용자 서비스 분리
복합기 예제는 원칙을 설명하기 좋지만 실무와는 거리가 있다. 좀 더 현실적인 예제를 보자.
사용자 관련 기능을 하나의 인터페이스에 모아둔 경우다.
public interface UserService {
User findById(Long id);
List<User> findAll();
User create(CreateUserRequest request);
User update(Long id, UpdateUserRequest request);
void delete(Long id);
void changePassword(Long id, String oldPassword, String newPassword);
void resetPassword(Long id);
LoginResult login(String email, String password);
void logout(Long userId);
boolean validateToken(String token);
}
이 인터페이스를 사용하는 클라이언트가 여럿 있다고 가정해 보면, 각각이 필요로 하는 메서드가 다르다.
- 관리자 화면:
findById,findAll,create,update,delete - 인증 모듈:
login,logout,validateToken - 비밀번호 관리:
changePassword,resetPassword
인증 모듈은 findAll()이나 delete()에 전혀 관심이 없다. 그런데도 UserService에 의존하기 때문에, CRUD 관련 메서드의 시그니처가 바뀌면 인증 모듈도 재컴파일이 필요하다.
ISP를 적용해 인터페이스를 분리한다.
public interface UserQueryService {
User findById(Long id);
List<User> findAll();
}
public interface UserCommandService {
User create(CreateUserRequest request);
User update(Long id, UpdateUserRequest request);
void delete(Long id);
}
public interface AuthService {
LoginResult login(String email, String password);
void logout(Long userId);
boolean validateToken(String token);
}
public interface PasswordService {
void changePassword(Long id, String oldPassword, String newPassword);
void resetPassword(Long id);
}
구현 클래스는 필요한 인터페이스만 골라서 구현한다. 하나의 클래스가 여러 인터페이스를 구현할 수도 있고, 별도 클래스로 나눌 수도 있다.
public class UserServiceImpl implements UserQueryService, UserCommandService {
@Override
public User findById(Long id) { /* ... */ }
@Override
public List<User> findAll() { /* ... */ }
@Override
public User create(CreateUserRequest request) { /* ... */ }
@Override
public User update(Long id, UpdateUserRequest request) { /* ... */ }
@Override
public void delete(Long id) { /* ... */ }
}
public class AuthServiceImpl implements AuthService {
@Override
public LoginResult login(String email, String password) { /* ... */ }
@Override
public void logout(Long userId) { /* ... */ }
@Override
public boolean validateToken(String token) { /* ... */ }
}
인증 모듈은 AuthService에만 의존한다. UserCommandService에 메서드가 추가되거나 시그니처가 바뀌어도 인증 모듈은 무관하다. 각 클라이언트가 자기에게 필요한 인터페이스에만 의존하게 된 것이다.
ISP와 SRP의 관계
ISP와 SRP는 닮아 있다. SRP가 구현 클래스의 책임을 하나로 제한하라는 원칙이라면, ISP는 인터페이스의 책임을 작게 나누라는 원칙이다. 방향이 같다. 다만 보는 관점이 다르다. SRP는 이 클래스를 왜 바꿔야 하는가를 묻고, ISP는 이 인터페이스의 클라이언트가 누구인가를 묻는다.
실무에서 ISP를 적용할 때 가장 좋은 출발점은 클라이언트를 먼저 보는 것이다. “이 인터페이스를 누가 사용하는가?” 그 사용자가 쓰지 않는 메서드가 인터페이스에 포함되어 있다면, 분리할 시점이다.
LSP와 ISP가 만나는 지점
LSP 파트에서 본 펭귄 예제를 다시 떠올려 보자. Bird 클래스에 fly() 메서드가 있고, Penguin이 이를 상속받아 UnsupportedOperationException을 던졌다. 이건 LSP 위반인 동시에 ISP 위반이기도 하다. Penguin이 날 수 있는 새라는 인터페이스에 억지로 끼워진 것이기 때문이다.
ISP를 적용해 인터페이스를 분리하면 LSP 위반도 함께 해결된다.
interface Bird {
fun eat(): String
fun sleep(): String
}
interface Flyable {
fun fly(): String
}
class Eagle : Bird, Flyable {
override fun eat() = "사냥해서 먹는다"
override fun sleep() = "높은 곳에서 잔다"
override fun fly() = "하늘을 난다"
}
class Penguin : Bird {
override fun eat() = "물고기를 잡아 먹는다"
override fun sleep() = "서서 잔다"
// fly()를 구현할 필요가 없다
}
Penguin은 Flyable을 구현하지 않으므로 fly()를 던지거나 무시할 필요가 없다. Bird 인터페이스의 계약 — eat()과 sleep() — 은 온전히 이행할 수 있다. LSP를 지키면서 ISP도 만족한다.
flowchart TB
BirdI["Bird 인터페이스<br/>eat + sleep"]
FlyI["Flyable 인터페이스<br/>fly"]
BirdI --> Eagle["Eagle<br/>Bird + Flyable"]
FlyI --> Eagle
BirdI --> PenguinC["Penguin<br/>Bird만 구현"]
style Eagle fill:#51cf66,color:#fff
style PenguinC fill:#51cf66,color:#fff
핵심 정리
| 원칙 | 핵심 질문 | 위반 신호 |
|---|---|---|
| LSP | ”자식을 부모 자리에 넣으면 프로그램이 정확하게 동작하는가?” | instanceof 남발, UnsupportedOperationException, 자식 넣으면 테스트 실패 |
| ISP | ”이 인터페이스의 클라이언트가 모든 메서드를 쓰는가?” | 빈 구현, 예외 던지기, 클라이언트마다 쓰는 메서드가 다름 |
LSP가 다형성의 정확성을 보장한다면, ISP는 의존의 범위를 최소화한다. 둘을 함께 적용하면 클래스 계층이 깔끔해지고, 변경의 파급 효과가 줄어든다.
다음 편에서는 SOLID의 마지막 원칙인 DIP를 다룬다. 고수준 모듈이 저수준 모듈에 의존하는 것이 왜 문제인지, 의존성 방향을 뒤집는다는 것이 구체적으로 무엇을 의미하는지를 코드와 다이어그램으로 풀어본다.
→ 3편: DIP




Loading comments...