해당글은
blog.berkberber.com/solid-principles-in-kotlin-4f37e9b62dde
의 번역본 입니다. (글쓴이가 화웨이에 다니고 있어서 그런지 화웨이 예제가 상당히 많네요)
SOLID ?
객체 지향 프로그래밍에서 SOLID 원칙은 좀더 "이해가능한", "유연한", "유지보수 가능한" 소프트웨어를 만들기 위해 의도 되어진 디자인 원칙이다. SOLID 원칙은 기능 요구 조건이 변경되거나 혹은 우리가 현재 존재하는 프로그램에 추가적인 기능을 구현하고 싶을 때, 시스템의 큰 변경 없이, 지속 가능한 코드를 만들 수 있도록 도와준다.
단일 책임 원칙(Single Responsibility Principle)
단일 책임 원칙은 프로그램 안에 존재하는 모듈, 클래스, 메소드들에게는 프로그램의 단일 부분에 하나의 책임을 가지고 있다는 의미이다.
우리는 많은 행동을 하는 혹은 필요하지 않은 행동을 가지는 객체들을 만들지 않아야한다. 그래서 클래스는 하나의 일을 수행해야한다. 그러므로 클래스가 변경되어야 하는 이유는 오직 하나의 이유만을 가지고 있어야 합니다.
예를 들어 유저에 대한 정보를 담고 있는 클래스가 있다고 생각해보자, 그리고 우리는 현재 로그인, 로그아웃 기능을 만들고 싶고 각각의 기능은 Certain Authetication(어떤 인증) 를 통해 관리된다고 가정해보자.
Wrong example)
data class User(
var id: Long,
var name: String,
var password: String
){
fun signIn(){
// Authetication 을 통한 로그인...
}
fun signOut(){
// Authetication 을 통한 로그아웃...
}
}
우리는 단일 책임 원칙에 대해 배웠다. 모든 클래스는 프로그램 처리의 한 부분에만 책임을 가져야한다.
만약 우리가 Authetication Process(인증 프로세스)에 변경이 필요하다고 가정해보자. Authetication 을 현재 User 에서 다루고 있으니 결국 User 클래스에도 영향이 가게된다. (추가적으로 이미 Authetication 을 SignIn, SignOut 에 구현했기 때문에, 다른 곳에서 사용하기 어렵게 되었다) 그래서 우리는 클래스가 하나 이상의 책임을 가지게 되는 경우는 하나의 다른 클래스로 도출해야 합니다.
즉 유저 클래스는 유저에 대한 정보만 가지고 있는 것으로 구현되어야하고, 만약 로그인/로그아웃 같은 유저 인증 관련 프로세스를 다루고 싶다면 인증 관련 프로세스를 다루는 새로운 클래스는 추가해야합니다
Right example)
data class User(
var id: Long,
var name: String,
var password: String
)
class AuthenticationService(){
fun signIn(){
// 로그인 관련 구현...
}
fun signOut(){
// 로그아웃 관련 구현...
}
}
개방-폐쇄 원칙(Open-Closed Principle)
이 원칙에는 2가지의 중요한 원칙을 가지고 있습니다.
Open : 우리는 클래스에 새로운 기능을 추가 혹은 확장 할 수 있습니다. 우리가 사용하고 있는 코드에 몇가지 의존성 변경이 있을 때, 우리의 클래스는 쉽게 추가되고 변경되어야 합니다.
Closed : 클래스의 기본 기능들은 변경이 되어서는 안된다는 의미입니다. 스마트폰 그리고 스마트폰 서비스 정보를 담고 있는 MobilePhoneUser 클래스가 있다고 상상해보겠습니다. 이 클래스는 스마트폰 서비스 정보를 사용하여 스마트폰을 동작하게 구현되어 있습니다. 그리고 스마트폰 서비스에는 HMS, GMS 라고 불리는 2개의 다른 서비스가 있다고 가정합니다.
Wrong example)
class MobilePhone {
lateinit var brandName: String
}
class MobilePhoneUser {
fun runMobileDevice(mobileServices: Any, mobilePhone: MobilePhone) {
if (mobileServices is HuaweiMobileServices) {
println("This device is running with Huawei Mobile Services")
}
}
}
class HuaweiMobileServices {
fun addMobileServiceToPhone(mobilePhone: MobilePhone) {
println("Huawei Mobile Services")
}
}
위의 코드를 보면 우리는 스마트폰 서비스 타입을 if-else 문으로 체크하고 있습니다.
이 경우는 좋지 못한 사례입니다. 왜냐면 새로운 스마트폰 서비스 타입이 나오면, 그 때마다 하나하나 if-else 문을 통해 추가해야하기 때문입니다. 잊고 추가하지 않을 수도 있고, 코드량도 정량적으로 늘게 됩니다.
Open-closed 원칙을 토대로, 우리는 모든 스마트폰 서비스를 위한 하나의 인터페이스를 가지는 것을 생각해볼 수 있습니다. 그리고 각각의 스마트폰 서비스 타입은 인터페이스를 구현하고, 고유한 특성을 가지고 있게 만들 수 있습니다. 그러므로 우리는 if-else 문을 통해 스마트폰 서비스 타입을 체크할 필요가 없습니다.
Right example)
class MobilePhone {
lateinit var brandName: String
}
class MobilePhoneUser {
fun runMobileDevice(mobileServices: IMobileServices, mobilePhone: MobilePhone) {
mobileServices.addMobileServiceToPhone(mobilePhone)
}
}
interface IMobileServices {
fun addMobileServiceToPhone(mobilePhone: MobilePhone)
}
class HuaweiMobileServices: IMobileServices {
override fun addMobileServiceToPhone(mobilePhone: MobilePhone) {
mobilePhone.brandName = "Huawei"
println("Huawei Mobile Services")
}
}
class GoogleMobileServices: IMobileServices {
override fun addMobileServiceToPhone(mobilePhone: MobilePhone) {
mobilePhone.brandName = "Google"
println("Google Mobile Services")
}
}
리스코프의 치환 원칙(Liskov Substitution Priciple)
정상적으로 만들어진 프로그램이라면, 부모클래스가 아닌, 자식 클래스로 치환해도 우리의 코드의 어떠한 변경 없이 의도하는대로 동작해야 합니다. 간단하게 이야기하면, 자식 클래스는 부모클래스를 대체할 수 있어야 합니다.
부모 클래스로 부터 확장한 자식 클래스는 부모의 성격을 모두 가지고 있습니다. 만약 자식 클래스가 부모 클래스에 속하는 행동을 수행하지 않는다면, 아마도 우리는 무언가 잘못됨을 인지하고, 결국 원하는 코드를 작성할 수 없습니다. 또는 객체가 사용될 때 에러가 발생할 수 도 있습니다. 이런 행동들은 결국 코드의 오염 혹은 불필요한 코드를 작성하게 만들 수 있습니다.
Vehicle 추상 클래스가 있다고 가정해봅시다. 해당 추상 클래스는 엔진 상태에 대한 정보와 앞/뒤 이동 메서드를 가지고 있습니다.
만약 우리가 자식 클래스로 자동차, 트럭, 택시 등 만들때, Vehicle 추상 클래스만 확장하면 될 것 같이 보입니다.
Wrong example)
abstract class Vehicle {
protected var isEngineWorking = false
abstract fun startEngine()
abstract fun stopEngine()
abstract fun moveForward()
abstract fun moveBack()
}
class Car: Vehicle() {
override fun startEngine() {
println("Engine started")
isEngineWorking = true
}
override fun stopEngine() {
println("Engine stopped")
isEngineWorking = false
}
override fun moveForward() {
println("Moving forward")
}
override fun moveBack() {
println("Moving back")
}
}
class Bicycle: Vehicle() {
override fun startEngine() {
// 필요없는 메서드
}
override fun stopEngine() {
// 필요없는 메서드
}
override fun moveForward() {
println("Moving forward")
}
override fun moveBack() {
println("Moving back")
}
}
그러나 위의 코드는 보다시피 만약 우리가 Bicycle 클래스를 자식 클래스로 만들 때, Bicycle 은 엔진이 없기 때문에, startEngine, stopEngine 메서드는 필요 없어집니다. (성급한 추상화의 결과입니다)
이런 상황을 고치기 위해서는 Vehicle 을 상속받는 새로운 자식 클래스를 만들어 해결할 수 있습니다. 그리고 새롭게 추가하는 클래스는 엔진을 가지고 있는 Vehicle 들에 대한 클래스로 정의 될 것 입니다.
Right example)
interface Vehicle {
fun moveForward()
fun moveBack()
}
abstract class VehicleWithEngine: Vehicle {
private var isEngineWorking = false
open fun startEngine() {
isEngineWorking = true
}
open fun stopEngine() {
isEngineWorking = false
}
}
class Car: VehicleWithEngine() {
override fun startEngine() {
super.startEngine()
println("Engine started")
}
override fun stopEngine() {
super.stopEngine()
println("Engine stopped")
}
override fun moveForward() {
println("Moving forward")
}
override fun moveBack() {
println("Moving back")
}
}
class Bicycle: Vehicle {
override fun moveForward() {
println("Moving forward")
}
override fun moveBack() {
println("Moving back")
}
}
인터페이스 분리 원칙(Interface Segregation Principle)
객체 지향 프로그래밍에서 인터페이스는 코드를 간단하게, 의존성 결합도를 낮추는 추상화 효과를 제공합니다.(abstract 와는 다르게 Hierarchy 가 없기 때문에 더욱 이점이 많은 것은 이펙티브 자바에서 자세히 설명하고 있습니다. composition vs inheritance)
인터페이스를 사용하지 않는 클래스들은 인터페이스를 구현할 필요가 없습니다.
보통의 프로그래밍 언어는 다수의 인터페이스를 구현할 수 있도록 설계되어 있습니다. 따라서 여럿 인터페이스를 구현하다보면, 특정 클래스에서는 구현하지 않거나 필요 없는 메서드가 존재할 것 이고, 이때 우리는 다른 인터페이스로 해당 인터페이스들을 잘게 나눠야 합니다.
우리가 Animal 인터페이스를 구현했다고 가정하자. 그리고 인터페이스는 동물이 할 수 있는 행동들에 대한 메서드를 가지고 있을 것이다.
Wrong example)
interface Animal {
fun eat()
fun sleep()
fun fly() // 사실 글을 읽으시는 모두가 보자마자 엥? 여기에 이게 왜 나올까 생각할 수 있을 것 같다 정말 예시용 인 것 같다.
}
class Cat: Animal {
override fun eat() {
println("Cat is eating fish")
}
override fun sleep() {
println("Cat is sleeping on its owner's bed")
}
override fun fly() {
TODO("Not yet implemented") // Cats can't fly
}
}
class Bird: Animal {
override fun eat() {
println("Bird is eating forage")
}
override fun sleep() {
println("Bird is sleeping in the nest")
}
override fun fly() {
println("Bird is flying so high")
}
}
알다시피 Cat 은 날 수 없다. Cat 가 같은 클래스가 더 추가된다면, 이 역시도 모두 Fly 메서드는 불필요하다.
이런 이슈를 수정하기 위해서는 FlyingAnimal 같이 새로운 인터페이스를 추가하면 된다. Animal 클래스에서 fly 메서드를 제거하고, 새로운 인터페이스를 아래와 같이 추가시켰다.
Right example)
interface Animal {
fun eat()
fun sleep()
}
interface FlyingAnimal {
fun fly()
}
class Cat: Animal {
override fun eat() {
println("Cat is eating fish")
}
override fun sleep() {
println("Cat is sleeping on its owner's bed")
}
}
class Bird: Animal, FlyingAnimal {
override fun eat() {
println("Bird is eating forage")
}
override fun sleep() {
println("Bird is sleeping in the nest")
}
override fun fly() {
println("Bird is flying so high")
}
}
*그리고 필요에 따라 FlyingAnimal 인터페이스가 Animal 인터페이스를 확장하여, eat, sleep 에 대한 오버라이딩을 진행하지 않을 수도 있습니다.
의존성 역전 원칙(Dependency Inversion Principle)
의존성 역전 원칙은 서로 다른 클래스 혹은 모듈 간의 결합도와 관련이 있습니다. 상위 레벨의 클래스가 하위 레벨(세부 구현) 에 의존적이지 않아야 합니다. 인터페이스와의 의존성 제거(역전) 을 기본 전제로 두고 있습니다. 클래스 혹은 메서드를 사용하는 다른 클래스에 대한 종속성을 최소화해야 합니다. 자식 클래스에서의 번경이 부모 클래스에게 영향을 주어서는 안됩니다.
안드로이드, iOS 를 위한 어플리케이션을 만든다고 생각해봅시다. 우리는 먼저 안드로이드 개발자와 iOS 개발자가 필요합니다. 아래의 클래스들은 각각의 운영체제가 소유한 프로그래밍 언어를 사용하여 모바일 어플리케이션을 만들 때 필요한 메서드들을 가지고 있다고 가정합니다.
class AndroidDeveloper {
fun developMobileApp(){
println("Developing Android Application by using Kotlin")
}
}
class IOSDeveloper {
fun developMobileApp(){
println("Developing iOS Application by using Swift")
}
}
fun main(){
val androidDeveloper = AndroidDeveloper()
val iOSDeveloper = IOSDeveloper()
androidDeveloper.developMobileApp()
iOSDeveloper.developMobileApp()
}
위의 코드에 대해서 어떤 문제가 있는지 글쓴이는 명시하지 않았다. 아마도 위의 코드 같은 방법으로 개발하게 되면, 세세한 구현은 다르겠지만 큰 범주에서 봤을 때, Android 했던 것을 iOS 에서도 다시 하는 경우가 생길 것이고, 이를 해결하기 위한 추상화를 진행하다보면 의존성을 가지는 클래스들이 생길 것이고 이때 외부에서 받아 해결하는 방식으로 진행해야한다.
위의 문제를 해결하기 위해서 우리는 MobileDeveloper 라는 인터페이스를 생성하자. 그리고 이 클래스를 구현하는 AndroidDeveloper, IOSDeveloper 클래스를 생성하자. 만약 우리가 각각 Developer 타입에 따라서 다른 데이터를 저장하기를 원한다면, 우리는 의존성 역전 원칙을 위해 멋지게 해결할 수 있다. 또한 우리가 안드로이드에서도 더욱 세분화해서 스마트폰 타입에 따라 분리하는 것을 원한다고 가정하자.
결론적으로 Android, iOS 개발자가 같은 메서드를 통해 모바일 어플리케이션을 만들기 원하고 이때 Android 는 스마트폰 타입을 따로 받아서 처리하고싶다.
interface MobileDeveloper {
fun developMobileApp()
}
class AndroidDeveloper(var mobileService: MobileServices): MobileDeveloper {
override fun developMobileApp(){
println("Developing Android Application by using Kotlin. " +
"Application will work with ${mobileService.serviceName}")
}
enum class MobileServices(var serviceName: String) {
HMS("Huawei Mobile Services"),
GMS("Google Mobile Services"),
BOTH("Huawei Mobile Services and Google Mobile Services")
}
}
class IosDeveloper: MobileDeveloper {
override fun developMobileApp() {
println("Developing iOS Application by using Swift")
}
}
fun main(){
val developers = arrayListOf(
AndroidDeveloper(AndroidDeveloper.MobileServices.HMS),
IosDeveloper(),
AndroidDeveloper(AndroidDeveloper.MobileServices.GMS)
)
developers.forEach(MobileDeveloper::developMobileApp)
}
위와 같은 방식으로 구현하면 요구조건들을 충족할 수 있다.
위의 글을 번역하며, 설명이 적거나, 정확한 예제가 아닌 것들이 있다는 생각이 들었다.
'Android > 번역' 카테고리의 다른 글
LiveData, Coroutine, Flow 를 이용한 반응형 UI - Part 3 (0) | 2021.06.07 |
---|---|
LiveData, Coroutine, Flow 를 이용한 반응형 UI - Part 2 (0) | 2021.06.06 |
LiveData, Coroutine, Flow 를 이용한 반응형 UI - Part 1 (1) | 2021.05.25 |
코루틴을 뷰에 적용하기 (1) | 2021.03.14 |
코루틴 플로우(Flow) 읽어보기 (1) | 2021.03.08 |