SOLID란 무엇일까?

WWWKR·2024년 3월 19일
0

Clean Code의 저자 로버트 C. 마틴 (Uncle Bob) 은 객체 지향 프로그래밍 설계의 다섯 가지 원칙인 SOLID 를 제시했다.

SRP: 단일 책임 원칙 (Single Responsibility Principle)
OCP: 개방-페쇄 원칙 (Open/Closed Principle)
LSP: 리스코프 치환 원칙 (Liskov Substitution Principle)
ISP: 인터페이스 분리 원칙 (Interface Segregation Principle)
DIP: 의존성 역전 원칙 (Dependency Inversion Principle)

SOLID 의 목표는 변경에 유연하고, 코드에 대한 이해 및 재사용이 쉬워야 한다.

1. S: 단일 책임 원칙 (Single Responsibility Principle, SRP)

한 클래스는 하나의 책임만 가져야 한다. 즉, 클래스가 변경해야 하는 이유가 하나만 있어야 함.


class User(
	val id: String
) {
	fun login() {
    	// ...login
    }
    
    fun saveToken() {
    	// ...saveToken
    }
}

위 코드처럼 클래스 안에 여러 책임이 있는 경우 하나의 로직이 변경됨으로 의도치 않게 다른 로직에도 영향을 미칠 수 있다. 또한 login 과 saveToken 뿐 아니라 그 외에 다른 함수가 더 추가된다면 가독성이 떨어지게 되고 코드가 점점 복잡해진다. 이러한 상황은 SRP(단일 책임 원칙)를 위배한다고 볼 수 있고 코드를 분리하여 적절하게 수정할 수 있다.

class User(val id: String)

class AuthManager() {
	fun login(user: User) {
    	// ...login
    }
}

class TokenManager() {
	fun saveToken(user: User) {
    	// ...saveToken	
    }
}

이렇게 각 기능 별로 코드를 분리하면 클래스는 각각 하나의 책임만 가지고 있기 때문에 코드가 수정 되어도 변경 및 문제가 생길 여지를 최소화 할 수 있다.

2. O: 개방-폐쇄 원칙 (Open-Closed Principle, OCP)

소프트웨어 개체는 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다.
즉, 개체가 수행 하는 기능은 확장이 가능하지만 개체 자체는 변경이 안 돼야 함.
개체와 개체의 기능 구현을 분리하고 개체가 기능을 직접 참조하지 않도록 추상화 함.


class UseCase(val databaseRepository: DatabaseRepository) {
	fun getUserAll() {
    	databaseRepository.getUserAll()
    }
}

class DatabaseRepository() {
	fun getUserAll() {}
}

class NetworkRepository() {
	fun getUserAll() {}
}

만약 UseCase에서 DatabaseRepository 를 직접 사용하면 Local 에서 가져오던 데이터를 Network 에서 가져오도록 변경할 때 UseCase 의 코드도 변경 돼야 함.


class UseCase(val repository: Repository) {
	fun getUserAll() {
    	repository.getUserAll()
    }
}

interface Repository {
	fun getUserAll()
}

class DatabaseRepository: Repository {
	override fun getUserAll() {}
}

class NetworkRepository: Repository {
	override fun getUserAll() {}
}

이렇게 코드가 변경된다면 로컬에서 가져오던 데이터를 서버에서 가져오도록 변경하더라도(Repository 의 구현체를 DatabaseRepository 에서 NetworkRepository로 변경) 구현체만 변경 하면 되기 때문에 UseCase 는 수정하지 않아도 됨.

변경을 최소화 하기 위해서는 구현체에 대한 직접적인 참조를 피해야 함.

MVVM 패턴과 함께 자주 사용하는 Repository 패턴을 구현할 때, Repository Interface 와 Repository Class(구현체)를 나눠서 개발 하는 방식이 OCP 를 따르는 방식이라고 할 수 있다.

3. L: 리스코프 치환 원칙 (Liskov Substitution Principle, LSP)

하위 클래스 객체는 상위 클래스 객체의 역할을 그대로 수행할 수 있어야 한다.

open class Bird {
    open fun fly() {
        println("Flying...")
    }
}

class Penguin : Bird() {
    override fun fly() {
    	throw Exception("Can't fly!")
    }
}

모든 새들은 날 수 있다는 전제하게 fly 라는 함수를 구현했지만 펭귄은 fly가 불가능하기 때문에 에러를 던짐, 즉 하위 클래스인 Penguin 은 상위 클래스인 Bird 를 대체하여 같은 행위를 제공할 수 없음.

open class Bird {}

open class FlyingBird : Bird() {
    open fun fly() {
        println("Flying...")
    }
}

open class NonFlyingBird : Bird() {
    // Non-flying birds may have other behaviors, but they don't include flying
}

class Penguin : NonFlyingBird() {
    // Penguins don't fly
}

이렇게 NonFlyingBird 클래스를 만들어 분리하게 되면 날 수 있는 새와 날 수 없는 새 모두 상위 클래스를 대체 가능함.

4. I: 인터페이스 분리 원칙 (Interface Segregation Principle, ISP)

클래스에 필요하지 않은 인터페이스를 구현하도록 강요해서는 안 된다는 것을 명시한다. 하나의 비대한 인터페이스보다 세부적으로 나눠진 인터페이스를 구현하는게 좋은 방법이며, 이를 통해 불필요한 종속성을 최소화하고 의도하지 않은 결합이 발생할 위험을 줄일 수 있다.

interface NetworkClient {
	fun fetchUser()
    fun fetchProduct()
}

class ViewModel(val networkClient: NetworkClient) {
	fun fetchUser() {
    	networkClient.fetchUser()
    }
}

ViewModel에서 fetchUser 를 사용하고 있지만 NetworkClient 구현체의 fetchProduct 가 변경 되었다면 ViewModel에 영향이 없는지 확인을 해야 함.

interface UserFetcher {
	fun fetchUser()
}

interface ProductFetcher {
    fun fetchProduct()
}


class ViewModel(val userFetcher: UserFetcher) {
	fun fetchUser() {
    	userFetcher.fetchUser()
    }
}

fetchUser, fetchProduct 를 나눠서 인터페이스를 설계하고 ViewModel은 필요한 UserFetcher 만 의존해서 사용하므로 fetchProduct 구현이 변경돼도 ViewModel을 확인할 필요가 없어짐.

어차피 사용을 안 하니까 문제가 없다고 생각할 수 있지만, 문제가 발생할 가능성이 있는거 자체가 코스트. 코스트가 있다면 실수의 여지도 있기 때문에 애초에 의존 범위를 줄여 서로 연관이 없음을 분명이 하는 것이 중요.

5. D: 의존성 역전 원칙 (Dependency Inversion Principle, DIP)

상위 계층이 하위 계층에 의존하는 전통적인 의존관계를 반전시켜 하위 계층이 상위 계층을 의존하게 하는 방법

의존성이란 결국 내가 해당 대상의 변경에 영향을 받겠다는 의미. 의존성이 역전되면 변경에 영향을 받는 방향이 반대로 됨. 이를 이용해서 변경이 많은 부분이 변경이 적은 부분을 의존하도록 반전시키는 방법이다.

위의 2. 개방-폐쇄 원칙의 예제와 같이 UseCase 가 DatabaseRepository 구현체를 직접 참조하고 있는 경우 DatabaseRepository 의 구현이 변경될 때마다 UseCase 의 코드가 문제 없는지 확인 해야 함. 또, 통신 방법이 변경되어 NetworkRepository 를 사용 해야 되는 경우에도 직접적인 UseCase 코드가 변경 되어야 함.

의존 방향

  • UseCase -> Repository 구현체

제어 방향

  • UseCase -> Repository 구현체

하지만 UseCase가 Repository 라는 인터페이스를 참조하게 되면 구현체에 코드가 변경 되는 경우 UseCase 는 신경 쓰지 않아도 되고, 통신 방식이 변경 되어 Repository 의 구현체가 변경 되어도 UseCase 는 변경되지 않아도 됨.

의존 방향

  • UseCase -> Repository Interface
  • Repository 구현체 -> Repository Interface

제어 방향

  • UseCase -> Repository 구현체

참고
Modern Android 훑어보기

profile
안드로이드 개발자

0개의 댓글