
상속에서 오버라이드하면 LSP 위반 아닌가?
상속 구조에서 메서드를 오버라이드하면 LSP 위반 아닌가? 이런 의문을 가져본 적 있으신가요?
저같은 경우는 Angular 프로젝트를 리팩토링하다가 이 질문에 부딪혔습니다. API 호출 코드가 모든 Feature 서비스에서 반복되고 있어서, 공통 BaseService를 만들고 상속받는 구조를 설계하고 있었습니다.
코드는 대략 이런 형태였습니다.
// 공통 Base
abstract class ApiBaseService {
protected post<T>(path: string, condition: any): Observable<T> {
// 공통 로직
}
protected handleError(error: any): Observable<never> {
return throwError(() => new ApiError(error));
}
}
// Feature 서비스
class UserApiService extends ApiBaseService {
loadUser(id: string) {
return this.post<User>('/user', { id });
}
}
그런데 코드를 작성하다 보니 문득 의문이 생겼습니다.
"만약 자식 클래스에서
handleError를 오버라이드하면, 그건 LSP 위반 아닌가?"
LSP는 "자식 클래스가 부모 클래스를 대체할 수 있어야 한다"는 원칙입니다. 오버라이드를 하면 동작이 달라지니까, 대체가 안 되는 거 아닐까요?
찾아보니 오버라이드 자체는 LSP 위반이 아니었습니다. LSP가 금지하는 건 오버라이드가 아니라, "기대하는 동작을 깨는 것"이었습니다. 이 기회에 LSP가 정확히 뭔지 제대로 정리해보기로 했습니다.
파이로 이해하는 LSP
LSP를 처음 접했을 때 정의만 봐서는 감이 안 왔는데, 파이 비유를 보고 나서야 이해가 됐습니다.
내가 사과 파이를 달라고 했는데 블랙베리 파이를 가져오면 당연히 화가 납니다. 하지만 과일 파이를 달라고 했는데 블랙베리 파이를 가져오면? 괜찮습니다. 사과든 딸기든 복숭아든 상관없으니까요.
이게 LSP의 핵심입니다. 가능한 한 추상적인 것에 의존하면, 구체적인 구현체를 바꿔도 내 코드는 안 깨집니다.
피자 사건
여기서 문제가 생깁니다. 누군가 "토마토도 과일이니까 피자도 과일 파이 아님?" 이라고 우기기 시작합니다.
interface FruitPie {
slice(): PieSlice;
getCalories(): number;
}
class ApplePie implements FruitPie { /* ... */ }
class StrawberryPie implements FruitPie { /* ... */ }
class Pizza implements FruitPie { /* 토마토도 과일이니까...? */ }
이제 과일 파이로 뭔가를 하려고 했던 코드가 전부 망가집니다.
function serveDessert(pie: FruitPie) {
const slice = pie.slice();
addWhippedCream(slice); // 피자에 휘핑크림?
serve(slice);
}
serveDessert(new Pizza()); // 디저트 서빙 로직이 깨집니다
인터페이스의 모든 구현체는 서로 교환 가능해야 합니다. 피자는 과일 파이를 대체할 수 없습니다. 이게 LSP 위반의 본질입니다.
실무에서 흔히 보이는 LSP 위반
파이 이야기는 재밌지만, 실제로 코드를 짜다 보면 LSP 위반은 좀 더 미묘한 형태로 나타납니다.
갑자기 null을 반환하는 경우
interface UserRepository {
findById(id: string): User;
}
// 기존 구현: 항상 User 반환, 없으면 예외
class DatabaseUserRepository implements UserRepository {
findById(id: string): User {
const user = this.db.find(id);
if (!user) throw new UserNotFoundError(id);
return user;
}
}
// 새로운 구현: null 반환
class CacheUserRepository implements UserRepository {
findById(id: string): User {
return this.cache.get(id) || null; // 캐시에 없으면 null
}
}
기존 코드는 findById가 항상 User를 반환한다고 믿고 있었습니다.
function greetUser(repo: UserRepository, id: string) {
const user = repo.findById(id);
console.log(`Hello, ${user.name}!`); // null이면 터집니다
}
한 번도 null을 반환하지 않던 메서드가 갑자기 null을 반환하면, 그걸 쓰던 모든 코드가 깨집니다. 이런 일은 생각보다 자주 발생합니다. 특히 캐시 레이어를 추가하거나 새로운 구현체를 만들 때 주의해야 합니다.
새로운 예외를 던지는 경우
interface FileStorage {
save(file: File): void;
}
// 기존: 실패해도 조용히 처리
class LocalStorage implements FileStorage {
save(file: File): void {
try {
fs.writeFileSync(file.path, file.content);
} catch {
this.logger.error('저장 실패');
}
}
}
// 새로운 구현: 예외를 던짐
class CloudStorage implements FileStorage {
save(file: File): void {
const result = this.cloud.upload(file);
if (!result.success) {
throw new UploadFailedError(result.message);
}
}
}
기존 코드는 save가 예외를 안 던진다고 믿고 있었습니다.
function backupFiles(storage: FileStorage, files: File[]) {
files.forEach((file) => storage.save(file));
console.log('백업 완료!');
}
CloudStorage로 바꾸는 순간 앱이 크래시합니다. 인터페이스에 예외에 대한 명세가 없으니, 구현체마다 다르게 동작하는 겁니다.
사전조건을 강화하는 경우
interface PaymentProcessor {
process(amount: number): Receipt;
}
// 기존: 모든 금액 처리
class CardProcessor implements PaymentProcessor {
process(amount: number): Receipt {
return this.chargeCard(amount);
}
}
// 새로운 구현: 제한 추가
class CryptoProcessor implements PaymentProcessor {
process(amount: number): Receipt {
if (amount < 100) {
throw new Error('최소 100 이상만 가능');
}
return this.sendCrypto(amount);
}
}
기존에 process(50)을 호출하던 코드가 CryptoProcessor에서는 깨집니다. 부모보다 더 까다로운 조건을 요구하면 안 됩니다.
결국 계약을 지키는 것
이런 사례들을 정리하다 보니 깨달은 게 있습니다. LSP는 결국 계약에 관한 이야기였습니다.
인터페이스나 부모 클래스는 암묵적인 계약을 정의합니다. 이 메서드는 뭘 받고, 뭘 반환하고, 어떤 예외를 던지고, 어떤 상태를 바꾸는가. 모든 구현체는 이 계약을 지켜야 합니다.
| 허용 | 금지 |
|---|---|
| 더 관대하게 받기 | 더 까다롭게 받기 |
| 더 구체적으로 반환 | 갑자기 null 반환 |
| 예외 줄이기 | 새로운 예외 추가 |
| 기능 확장 | 기대 동작 변경 |
다시 원래 질문으로
그래서 처음 의문으로 돌아가면, 오버라이드하면 LSP 위반일까요?
아닙니다. 계약을 지키면서 오버라이드하면 LSP를 준수합니다.
class UserApiService extends ApiBaseService {
protected override handleError(error: any): Observable<never> {
this.logger.error('User API Error', error); // 로깅 추가
return super.handleError(error); // 기존 동작 유지
}
}
사전조건도 동일하고, 사후조건도 동일하고, 예외도 동일합니다. 단지 로깅이라는 기능만 추가했을 뿐입니다. 이건 LSP를 준수합니다.
하지만 이렇게 하면 문제가 됩니다.
class BadApiService extends ApiBaseService {
protected override handleError(error: any): Observable<never> {
console.log('에러 무시');
return of(null) as any; // 에러를 삼켜버림
}
}
에러를 던진다고 기대하는 코드가 전부 깨집니다. 이건 LSP 위반입니다.
마무리하며
처음에는 단순히 "오버라이드하면 LSP 위반 아닌가?"라는 의문에서 시작했는데, 파고들다 보니 계약이라는 개념까지 오게 됐습니다.
결국 오버라이드가 문제가 아니라 계약을 깨는 게 문제였습니다. 반환값이 null일 수 있는지, 어떤 예외를 던지는지, 문서화하거나 타입으로 명시하는 게 좋습니다.
"상속보다 합성을 선호하라"는 말도 결국 같은 맥락입니다. 합성을 쓰면 계약이 public 인터페이스로 드러나서 명확해지니까요.
타입 에러가 "내가 뭔가 착각하고 있는 건 아닐까?"를 돌아보게 하는 질문이라면, LSP는 "이 구현체가 정말 부모를 대체할 수 있는가?"를 돌아보게 하는 질문인 것 같습니다.