(Java/Kotlin) SOLID - LSP 리스코프 치환 원칙 완전 정리
개요
- SOLID 원칙 중 세 번째 LSP(Liskov Substitution Principle) 를 다룹니다.
- LSP는 “자식 클래스는 언제나 부모 클래스를 대체할 수 있어야 한다”는 원칙입니다.
- 이 글에서는 다음을 설명합니다.
- LSP가 정확히 무엇인지
- 왜 필요한지
- 위반 사례(직사각형-정사각형, 새-타조)와 개선 방법
- Android / Kotlin 실전 예제
1. 정의
서브타입(자식 클래스)은 언제나 자신의 기반 타입(부모 클래스)으로 교체할 수 있어야 한다.
— Barbara Liskov, 1987
부모 클래스를 사용하는 코드에 자식 클래스를 넣어도 → 동일하게 동작해야 한다
- 상속을 사용할 때 “자식은 부모의 계약(contract)을 반드시 지켜야 한다” 는 것이 핵심입니다.
- 문법적으로 상속이 가능해도, 의미적으로 부모를 대체할 수 없다면 LSP 위반입니다.
2. 왜 필요한가
부모 타입을 믿고 작성한 코드 → 자식 클래스로 교체 → 예상치 못한 동작 발생
- 상속은 코드 재사용보다 “행동의 일관성” 을 보장하기 위한 도구입니다.
- LSP를 어기면 다형성(Polymorphism)이 신뢰를 잃습니다.
instanceof검사, 다운캐스팅이 늘어나고, 조건 분기가 생깁니다.
3. 위반 사례 ① — 직사각형-정사각형 문제
❌ LSP 위반 — 정사각형이 직사각형을 상속
open class Rectangle {
open var width: Int = 0
open var height: Int = 0
fun area(): Int = width * height
}
class Square : Rectangle() {
// 정사각형은 width와 height가 항상 같아야 함
override var width: Int = 0
set(value) { field = value; super.height = value } // height도 같이 변경
override var height: Int = 0
set(value) { field = value; super.width = value } // width도 같이 변경
}
// 직사각형을 기대하는 코드
fun resize(rect: Rectangle) {
rect.width = 10
rect.height = 5
println("넓이: ${rect.area()}") // 기대값: 50
}
resize(Rectangle()) // 넓이: 50 ✅
resize(Square()) // 넓이: 25 ❌ — width=5, height=5 로 세팅됨
문제점:
Rectangle을 받는resize()는width와height를 독립적으로 설정할 수 있다고 기대합니다.Square를 넣으면 한쪽을 바꿀 때 다른 쪽도 바뀌어 기대한 결과(50)가 나오지 않습니다.- 부모 타입의 계약을 자식이 어겼습니다.
✅ 개선 — 상속 대신 공통 인터페이스로 분리
interface Shape {
fun area(): Int
}
class Rectangle(val width: Int, val height: Int) : Shape {
override fun area() = width * height
}
class Square(val side: Int) : Shape {
override fun area() = side * side
}
// Shape를 받는 코드 — 어떤 구현체가 와도 area()는 올바르게 동작
fun printArea(shape: Shape) {
println("넓이: ${shape.area()}")
}
printArea(Rectangle(10, 5)) // 넓이: 50
printArea(Square(5)) // 넓이: 25
개선 효과:
Rectangle과Square는 이제 각자의 불변성(invariant)을 독립적으로 유지합니다.Shape를 사용하는 코드는 어떤 구현체가 와도 안전하게 동작합니다.
4. 위반 사례 ② — 새-타조 문제
❌ LSP 위반 — 날 수 없는 새가 Bird를 상속
open class Bird {
open fun fly() {
println("날고 있습니다")
}
}
class Sparrow : Bird() {
override fun fly() {
println("참새가 날고 있습니다")
}
}
// 타조는 새이지만 날 수 없음
class Ostrich : Bird() {
override fun fly() {
throw UnsupportedOperationException("타조는 날 수 없습니다") // ❌
}
}
fun makeBirdFly(bird: Bird) {
bird.fly() // Bird라면 당연히 날 수 있다고 기대
}
makeBirdFly(Sparrow()) // 참새가 날고 있습니다 ✅
makeBirdFly(Ostrich()) // UnsupportedOperationException 💥
문제점:
Bird를 받는 코드는fly()가 항상 정상 동작한다고 기대합니다.Ostrich를 넣으면 런타임 예외가 발생합니다.is Ostrich검사 같은 방어 코드가 생겨납니다.
✅ 개선 — 능력 단위로 인터페이스 분리
interface Bird {
fun breathe()
fun eat()
}
interface FlyingBird : Bird {
fun fly()
}
class Sparrow : FlyingBird {
override fun breathe() = println("참새 호흡")
override fun eat() = println("참새 식사")
override fun fly() = println("참새가 날고 있습니다")
}
class Ostrich : Bird {
override fun breathe() = println("타조 호흡")
override fun eat() = println("타조 식사")
// fly()는 없음 — 계약에도 없음 ✅
}
fun makeBirdFly(bird: FlyingBird) {
bird.fly() // FlyingBird라면 반드시 날 수 있음
}
makeBirdFly(Sparrow()) // 참새가 날고 있습니다 ✅
// makeBirdFly(Ostrich()) — 컴파일 에러로 방지 ✅
5. 위반 사례 ③ — 예외를 추가로 던지는 경우
❌ LSP 위반 — 자식이 새로운 예외를 던짐
open class FileReader {
open fun readLine(): String {
return "파일 한 줄"
// IOException 발생 가능 (checked)
}
}
class NetworkFileReader : FileReader() {
override fun readLine(): String {
// ⚠️ 부모는 선언하지 않은 NetworkException을 던짐
throw RuntimeException("네트워크 오류")
}
}
fun process(reader: FileReader) {
// FileReader를 믿고 예외 처리를 하지 않음
val line = reader.readLine() // NetworkFileReader라면 예외 발생
println(line)
}
문제점:
- 호출자는
FileReader의 계약만 보고 처리합니다. - 자식이 예상치 못한 예외를 던지면 호출자가 터집니다.
✅ 개선 — 계약에 포함된 예외만 사용
class NetworkFileReader(private val url: String) : FileReader() {
override fun readLine(): String {
return try {
fetchLine(url)
} catch (e: Exception) {
"" // 부모 계약 범위 안에서 처리
}
}
private fun fetchLine(url: String): String = "네트워크에서 읽은 한 줄"
}
6. LSP 체크리스트 — 상속 전 확인할 것들
✔ "자식 IS-A 부모" 가 의미적으로도 맞는가?
→ 문법적으로 상속이 가능해도, 행동이 다르면 IS-A가 아님
✔ 자식 클래스가 부모의 메서드를 UnsupportedOperationException으로 막는가?
→ 명백한 LSP 위반
✔ 자식 클래스를 부모 타입 변수에 넣었을 때 기존 테스트가 그대로 통과하는가?
→ 통과해야 LSP 준수
✔ 자식이 부모보다 더 강한 사전조건(precondition)을 요구하는가?
→ 부모가 허용하는 입력을 자식이 거부하면 LSP 위반
✔ 자식이 부모보다 더 약한 사후조건(postcondition)을 보장하는가?
→ 부모가 보장하는 결과를 자식이 보장 못하면 LSP 위반
7. Android 실전 예제
❌ LSP 위반 — ReadOnlyRepository가 Repository를 상속
interface Repository<T> {
fun findById(id: Long): T?
fun save(item: T)
fun delete(id: Long)
}
// 읽기 전용인데 Repository를 구현 — save/delete를 막아야 함
class ReadOnlyUserRepository : Repository<User> {
override fun findById(id: Long): User? = User(id, "홍길동")
override fun save(item: User) {
throw UnsupportedOperationException("읽기 전용입니다") // ❌
}
override fun delete(id: Long) {
throw UnsupportedOperationException("읽기 전용입니다") // ❌
}
}
fun syncUser(repo: Repository<User>, id: Long) {
val user = repo.findById(id) ?: return
repo.save(user.copy(name = "업데이트")) // ReadOnlyUserRepository면 예외 💥
}
✅ 개선 — 읽기/쓰기 계약을 인터페이스로 분리
interface ReadableRepository<T> {
fun findById(id: Long): T?
fun findAll(): List<T>
}
interface WritableRepository<T> : ReadableRepository<T> {
fun save(item: T)
fun delete(id: Long)
}
// 읽기 전용 — 계약에 쓰기 없음
class ReadOnlyUserRepository : ReadableRepository<User> {
override fun findById(id: Long): User? = User(id, "홍길동")
override fun findAll(): List<User> = listOf(User(1L, "홍길동"))
}
// 읽기/쓰기 모두 지원
class UserRepository : WritableRepository<User> {
private val store = mutableMapOf<Long, User>()
override fun findById(id: Long) = store[id]
override fun findAll() = store.values.toList()
override fun save(item: User) { store[item.id] = item }
override fun delete(id: Long) { store.remove(id) }
}
// 읽기만 필요한 곳은 ReadableRepository를 받음
fun displayUser(repo: ReadableRepository<User>, id: Long) {
println(repo.findById(id))
}
// 쓰기까지 필요한 곳은 WritableRepository를 받음
fun syncUser(repo: WritableRepository<User>, id: Long) {
val user = repo.findById(id) ?: return
repo.save(user.copy(name = "업데이트")) // 안전 ✅
}
8. Kotlin sealed class와 LSP
sealed class를 활용하면 LSP 위반을 컴파일 타임에 방지할 수 있습니다.
sealed class ApiResult<out T> {
data class Success<T>(val data: T) : ApiResult<T>()
data class Failure(val error: Throwable) : ApiResult<Nothing>()
object Loading : ApiResult<Nothing>()
}
fun <T> handleResult(result: ApiResult<T>) {
when (result) {
is ApiResult.Success -> println("성공: ${result.data}")
is ApiResult.Failure -> println("실패: ${result.error.message}")
is ApiResult.Loading -> println("로딩 중...")
// 새 서브타입 추가 시 컴파일 에러로 강제 처리 ✅
}
}
sealed class의 모든 서브타입은 동일한when분기로 처리됩니다.- 어떤 서브타입이 와도
handleResult()가 동일하게 동작 — LSP 준수입니다.
9. LSP 위반을 감지하는 신호
✔ 자식 클래스에 UnsupportedOperationException, IllegalStateException이 있다
✔ 부모 타입 변수를 받는 코드에 instanceof / is 타입 검사가 등장한다
✔ 자식 클래스의 메서드가 아무 일도 하지 않는다 (빈 오버라이드)
✔ 부모 클래스 단위 테스트를 자식 클래스로 돌리면 실패한다
✔ 자식 클래스를 사용하는 쪽에서 "이 타입일 때는 이걸 호출하면 안 돼" 같은
주석이나 조건문이 생긴다
10. SRP / OCP / LSP 비교
| 원칙 | 핵심 질문 | 해결 도구 |
|---|---|---|
| SRP | 이 클래스가 변경되는 이유가 하나인가? | 클래스 분리 |
| OCP | 새 기능 추가 시 기존 코드를 수정하지 않는가? | 인터페이스·추상 클래스 |
| LSP | 자식 클래스가 부모 클래스를 온전히 대체할 수 있는가? | 올바른 상속 / 인터페이스 분리 |
11. 정리
| 항목 | 내용 |
|---|---|
| 정의 | 자식 클래스는 언제나 부모 클래스로 교체할 수 있어야 한다 |
| 핵심 | 상속은 “행동의 일관성”을 보장해야 한다 |
| 대표 위반 | 직사각형-정사각형, 새-타조, UnsupportedOperationException |
| 해결 방법 | 상속보다 인터페이스 분리, 합성(Composition) 활용 |
| 위반 신호 | UnsupportedOperationException, 빈 오버라이드, 다운캐스팅 |
| Kotlin 활용 | interface 분리, sealed class, 합성 |
- LSP는 “상속 = 코드 재사용” 이 아니라 “상속 = 행동 계약의 확장” 임을 알려줍니다.
- 상속으로 구현하기 어렵다면 인터페이스 + 합성 으로 해결하는 것이 올바른 방향입니다.
참고
- Clean Code — Robert C. Martin
- SOLID 전체 포스팅 보기
- SRP 포스팅 보기
- OCP 포스팅 보기