본문 바로가기

IT/헤드퍼스트 디자인패턴

[SOLID] 객체지향설계


안녕하세요 남갯입니다.


오늘은 객체지향설계 원칙인 SOLID에 대해서 포스팅해보려고합니다.


이번스터디에서 공부했던 내용을 정리해보고 


스터디의 과제인 SOLID를 소개하는 유튜브를 보고 


SOLID에 대해 발표하신 내용에서 개인적으로 생각하는 에러를 찾아보는 시간을 가져보려고합니다.


SOLID 유튜브 영상

https://www.youtube.com/watch?v=QXVO2NcarkQ&t=592s



SOLID란?

로버트 마틴이 2000년대 초반 명명한 객체지향 프로그래밍 및 설계의 다섯가지 기본원칙을 마이클 페더스가 두문자어 기억술로 소개한것입니다. 프로그래머가 시간이 지나도 유지보수와 확장이 쉬운 시스템을 만들고자 할때 이 원칙을 함께 적용할 수 있다. 실제 소스코드를 읽기 쉽고 확장하기 쉽게 리팩토링을 통해 코드냄새(심오한 문제점을 일으킬 수 있는 지저분한 코드를 일컫는말)를 제거하고자 사용하는 지침이다.

- 출처 : 위키백과 객체지향설계

https://ko.wikipedia.org/wiki/SOLID_(%EA%B0%9D%EC%B2%B4_%EC%A7%80%ED%96%A5_%EC%84%A4%EA%B3%84)


SOLID

S : 단일 책임의 원칙 (Single responsibility principle)

O : 개방-폐쇠의 원칙 (Open/closed principle)

L : 리스코프 치환 원칙(Liskov substitution principle)

I : 인터페이스 분리 원칙(Interface segregation principle)

D: 의존관계 역전의 원칙(Dependeny inversion principle)



- 다양한 영상과 블로그를 보면서 참조했습니다.






단일 책임의 원칙 (Single responsibility principle)


- 한 클래스는 하나의 책임만을 가져야한다.


단일 책임의 원칙에서 책임이란?  클래스내에서의 (수정해야하는 이유?,변경하는 이유?) 정도로 해석할 수 있습니다. 즉 한 클래스나 혹은 함수에서 여러가지의 책임을 갖게될경우 클래스를 수정함에 있어서 여러개의 책임 여러 이유를 통한 수정이 필요하게 되는것입니다. 따라서 단일책임의 원칙이란 하나의 수정해야하는 이유의 기능을 갖고있다라는것으로 볼 수 있습니다.




위키백과에서도 설명하는 부분은 계산기를 예로 들고있는데요?


계산클래스에 사칙연산을 하는 함수들을 넣어두었다고 가정을 해보았습니다.


계산기를 GUI가 있는 계산기로 만들라고 했을때 GUI부분을 계산클래스 안에 넣게되면 이후에 GUI를 변경하거나 하게 되었을 때

GUI와는 연관없는 계산클래스를 고치게됩니다. 이후에는 기능이 너무 모호해지게 되어 수정에 있어서 어려워지고 스파게티 코드가 된다고 합니다.


이런 상황을 막기위해 다른 예시를 통해 적용한 사례를 보았습니다.


영상에서 제시한 코드로 한번 보게되면 (영상에는 스위프트로 구현하였지만 저는 코틀린으로 구현하였습니다.)


class Handler {
fun handle() {
val data = requestDataToAPI()
val array = parse(data)
saveToDB(array)
}

private fun requestDataToAPI(): Data {
TODO()
}

private fun parse(data: Data): String {
TODO()
}

private fun saveToDB(array: String): Array<String> {
TODO()
}
}


위와 같은 코드가 존재하고 위와같은 코드는 핸들러클래스가 많은 책임을 갖게되므로 


아래와같이 핸들러를 분리하라고 말합니다.


class ModifiedHandler(val parseHandler: ParseHandler, val apiHandler: APIHandler, val dbHandler: DBHandler) {
fun handle() {
val data = apiHandler.requestDataToAPI()
val array = parseHandler.parse(data)
dbHandler.saveToDB(array)
}
}

class APIHandler() {
fun requestDataToAPI(): Data {
TODO()
}
}

class ParseHandler() {
fun parse(data: Data): String {
TODO()
}
}

class DBHandler() {
fun saveToDB(array: String): Array<String> {
TODO()
}
}



저는 영상을 보면서 많은 내용에 대해 더 찾아보게 되었고 이런 결론을 짓게 되었습니다.


https://hackernoon.com/you-dont-understand-the-single-responsibility-principle-abfdd005b137   (특히 가장 큰 영향을 준 글)


블로그의 내용을 발췌해보자면 사람들은 Single repsonsibility principle 과 같이 단일 책임 즉 무조건 하나의 클래스에는 하나만의 기능이 있어야돼!

이러면서 더욱 더 잘게 쪼갠다라고 하더라구요 그렇게 잘게 쪼개다 보면 지금 당장 위의 코드만을 봤을 때는 서비스의 일부분인 작은 서비스만을 가지고 예제코드를 표현하게 되어있는데, 예를들어 7개 이상 즉 많은 기능을 하는 서비스의 경우 7줄의 boilerPlate code(상용구 코드) 불필요한 코드들이 많이 생기게 되는것이라는 것입니다.



무조건 하나의 책임만을 가져야돼! 를 하나의 기능만을 하는 함수로 만들라는내용이 아니라 다른 레이어와 다른 모듈간에서 동작하는 것을 분리하라고 합니다. 저는 그래서 로버트마틴이 말했듯이 "같은 이유로 변하는것들을 모으고, 다른 이유로 변하는것을 분리시켜라" 처럼 이유에 맞춰 변경을 하는것이 맞다고 봤습니다.


저는 클라이언트 입장에서 API를 호출하면 결과값을 받게되는데, 성공(유/무) 일 수 도 있고 데이터일 수도 있는 부분인 결과값에 대한 파싱은 같은 레이어라고 판단했습니다.


그래서 APIHandler로 parse와 requestApi를 모아서 동작시켜도 괜찮다고 판단했습니다.


* 아닐수도 있는 내용이니 태클도 감사합니다. 개인적으로 조금 고민하고 공부하며 작성한글이니 무조건적인 비방말고 충고나 수정해야할점으로 알려주시면 감사하겠습니다.






개방-폐쇄의 원칙 (Open/closed principle)


- 소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 합니다.

open = 확장에는 열려있다

closed = 수정에는 닫혀있다.


개방-폐쇄 원칙은 객체지향언어의 핵심원칙입니다 개방-폐쇄 원칙을 통해 객체지향 프로그래밍의 가장 큰 장점인 유연성과 재사용성 유지보수성을 얻을 수 있습니다.



interface Animal {
fun moving()
fun crying()
}

class Cat() : Animal {
override fun moving() = println("cat is moving")
override fun crying() = println("meow")
}

class Dog() : Animal {
override fun moving() = println("dog is moving")
override fun crying() = println("woof woof")
}

class Duck() : Animal {
override fun moving() = println("duck is flying")
override fun crying() = println("quack quack")
}

data class ZooManage(var animal: Animal? = null) {
fun moving() = animal?.moving()
fun crying() = animal?.crying()
}

fun main() {
val myAnimal: ZooManage = ZooManage()
myAnimal.animal = Cat()
myAnimal.crying()
myAnimal.moving()
myAnimal.animal = Dog()
myAnimal.crying()
myAnimal.moving()
myAnimal.animal = Duck()
myAnimal.crying()
myAnimal.moving()
}


실제 여기서 보게되면 Animal 이라는 추상클래스를 만들고 moving과 crying이라는 함수를 만들었습니다.


실제 이 코드에서 새로운 동물이 추가된다고 하더라도


class Shark() : Animal{
override fun moving() = println("shark is swimming")
override fun crying() = println("Ddadan Ddadan")
}


Shark(상어) 라는 동물을 추가하더라도 쉽게 추가할수 있으며 기존 코드에도 변화를 않주게 됩니다.


즉 확장에는 열려있지만 수정에는 닫혀있는 코드가 되게 됩니다.






리스코프 치환 원칙(Liskov substitution principle)


리스코프 치환원칙이라는것은 SOLID 원칙중 가장 어려운 내용인것 같습니다. 이 리스코프 치환 원칙은 MIT 사이언스 교수인 리스코프가 제안한 설계원칙이라고 합니다.

즉 자식클래스는 언제든 부모클래스를 대체해서도 원래의 계획대로 잘 작동되어야 한다는 원칙입니다. 


가장 유명한 예제인 직사각형과 정사각형에 대한 예제로 알아보겠습니다.


//직사각형
open class Rectangle() {
open var width: Int = 0
open var height: Int = 0


fun getArea(): Int = (this.width * this.height)
}

class Calculator() {
lateinit var rectangle: Rectangle
fun calculateArea() {
rectangle = Rectangle()
rectangle.width = 4
rectangle.height = 5
}
fun printArea() = println(rectangle.getArea()) // 결과값 = 4 * 5 = 20
}

나는 Rectangle을 만들고 그 안에 넓이를 계산하는 함수를 만들었습니다.


넓이를 출력하게되면 정상적으로 20이 출력되는것을 확인할 수 있습니다.



fun main() {
val calculator: Calculator = Calculator()
calculator.calculateArea()
calculator.printArea()
} // 20출력


직사각형을 만든 후에 정사각형을 추가해달라는 요구가 있었습니다


그래서 저는 직사각형을 상속받아 구현하였습니다.


class Square() : Rectangle(){
override var width: Int
get() = this.width
set(value) {
width = value
height = value
}

override var height: Int
get() = this.height
set(value) {
width = value
height = value
}
}

정사각형은 직사각형의 width 와 height를 오버라이딩 받아 

동일한 값인 변이 들어가게 구현하였습니다.


class Calculator() {
lateinit var rectangle: Rectangle
fun calculateArea() {
rectangle = Square()
rectangle.width = 4
rectangle.height = 5
}
fun printArea() = println(rectangle.getArea())
}

이를 통해 아까 만든 calculator를 변경하여 Square로 변경하였습니다.


원래 예상이 되어야 하는 값은 4, 5의 값을 곱한 20이 되어야하지만


하지만 값은 25가 출력이 되게 된다. 부모에서의 의도했던 역할을 어긋나게 되는것입니다


이럴경우 lsp에 어긋나게 됩니다.


따라서 정사각형과 직사각형은 상속의 관계가 아닌 별개의 타입의 형태로 구현을 해주어야 합니다.




인터페이스 분리 원칙(Interface segregation principle)


인터페이스 분리의 원칙은 말 그대로 다른성격을 가진 인터페이스를 분리하라는 내용이다. 즉 클라이언트에서는 사용하지 않는 메서드는 사용해서 안된다는 내용이다. 따라서 인터페이스를 나누어서 만들어야한다는 내용이다. 하지만 ISP를 잘지키면 OCP를 지킬 확률도 올라간다고 합니다.


interface Action{
fun print()
fun copy()
fun eat()
fun drive()
}



예를들면 어떤 행동들이 있다고 했을때 이렇게 인터페이스로 구현할경우 


class Printer : Action{
override fun print() {
//print
}
override fun copy() {
//copy
}

override fun eat() {
//not working
}

override fun drive() {
//not working
}
}


다른성격을 가진 인터페이스는 구분지으라는 것이다.


interface PrinerAction{
fun print()
fun copy()
}


class Printer : PrinerAction{
override fun print() {
//print
}
override fun copy() {
//copy
}
}
interface PersonAction{
fun eat()
fun drive()
}
class Person : PersonAction {
override fun eat() {
//eat
}

override fun drive() {
//drivwe
}
}



이렇게 같은성격을 가지는 인터페이스를 구현함으로서 사용하지 않는 메서드는 삭제시키고 성격에 따라 인터페이스를 나누는 것이다.




의존관계 역전의 원칙(Dependeny inversion principle)


추상성이 높은 고수준 클래스는 구체적인 저수준 클래스에 의존하면 안된다는 원칙입니다.


interface NoteBook {
fun powerOn()
}
class SamsungNoteBook : NoteBook {
override fun powerOn() {
println("SamsungNoteBook powerOn")
}
}
class LGNoteBook : NoteBook {
override fun powerOn() {
println("LGNoteBook powerOn")
}
}
class MacBook : NoteBook {
override fun powerOn() {
println("MacBook powerOn")
}
}

이렇게 노트북을 추상화 시킴으로서 실제 사용하는 클래스에서


data class Developer(var noteBook: NoteBook) {
fun develop() {
noteBook.powerOn()
}
}

특정 노트북에 대한 의존성을 가지지않고 이 인터페이스를 구현한 클래스를


변경하지 않고 언제든지 교체가 가능하도록 하기 위함이다.


val developer: Developer = Developer(SamsungNoteBook())
developer.develop()
developer.noteBook = MacBook()
developer.develop()
developer.noteBook = LGNoteBook()
developer.develop()



스터디를 진행하면서 공부한 내용과 실제 블로그와 많은 영상을 보면서 정리한 자료라 맞지 않을수도 있으니


틀린부분에 대해서는 댓글을 달아주시면 달게 받고 수정하도록 하겠습니다.