(Java/Kotlin) SOLID - LSP 리스코프 치환 원칙 완전 정리

개요


1. 정의

서브타입(자식 클래스)은 언제나 자신의 기반 타입(부모 클래스)으로 교체할 수 있어야 한다.
— Barbara Liskov, 1987

부모 클래스를 사용하는 코드에 자식 클래스를 넣어도 → 동일하게 동작해야 한다

2. 왜 필요한가

부모 타입을 믿고 작성한 코드 → 자식 클래스로 교체 → 예상치 못한 동작 발생

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 로 세팅됨

문제점:


✅ 개선 — 상속 대신 공통 인터페이스로 분리

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

개선 효과:


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 💥

문제점:


✅ 개선 — 능력 단위로 인터페이스 분리

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)
}

문제점:


✅ 개선 — 계약에 포함된 예외만 사용

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("로딩 중...")
        // 새 서브타입 추가 시 컴파일 에러로 강제 처리 ✅
    }
}

9. LSP 위반을 감지하는 신호

✔ 자식 클래스에 UnsupportedOperationException, IllegalStateException이 있다

✔ 부모 타입 변수를 받는 코드에 instanceof / is 타입 검사가 등장한다

✔ 자식 클래스의 메서드가 아무 일도 하지 않는다 (빈 오버라이드)

✔ 부모 클래스 단위 테스트를 자식 클래스로 돌리면 실패한다

✔ 자식 클래스를 사용하는 쪽에서 "이 타입일 때는 이걸 호출하면 안 돼" 같은
   주석이나 조건문이 생긴다

10. SRP / OCP / LSP 비교

원칙 핵심 질문 해결 도구
SRP 이 클래스가 변경되는 이유가 하나인가? 클래스 분리
OCP 새 기능 추가 시 기존 코드를 수정하지 않는가? 인터페이스·추상 클래스
LSP 자식 클래스가 부모 클래스를 온전히 대체할 수 있는가? 올바른 상속 / 인터페이스 분리

11. 정리

항목 내용
정의 자식 클래스는 언제나 부모 클래스로 교체할 수 있어야 한다
핵심 상속은 “행동의 일관성”을 보장해야 한다
대표 위반 직사각형-정사각형, 새-타조, UnsupportedOperationException
해결 방법 상속보다 인터페이스 분리, 합성(Composition) 활용
위반 신호 UnsupportedOperationException, 빈 오버라이드, 다운캐스팅
Kotlin 활용 interface 분리, sealed class, 합성

참고



Related Posts