정상에서 IT를 외치다

[디자인패턴, SOLID] 객체지향 설계 5대 원칙 본문

디자인패턴

[디자인패턴, SOLID] 객체지향 설계 5대 원칙

Black-Jin 2019. 4. 22. 14:05
반응형

객체지향 설계 5대 원칙 SOLID


컴퓨터 프로그래밍에서 SOLID란 로버트 마틴[1][2]이 2000년대 초반[3]에 명명한 객체 지향 프로그래밍 및 설계의 다섯 가지 기본 원칙을 마이클 페더스가 두문자어 기억술로 소개한 것이다. 프로그래머가 시간이 지나도 유지 보수와 확장이 쉬운 시스템을 만들고자 할 때 이 원칙들을 함께 적용할 수 있다.[3] SOLID 원칙들은 소프트웨어 작업에서 프로그래머가 소스 코드가 읽기 쉽고 확장하기 쉽게 될 때까지 소프트웨어 소스 코드를 리팩터링하여 코드 냄새를 제거하기 위해 적용할 수 있는 지침이다. 이 원칙들은 애자일 소프트웨어 개발과 적응적 소프트웨어 개발의 전반적 전략의 일부다


출처 - 위키


- SRP (Single Responsibility Principle) 단일 책임 원칙

- OCP (Open-Closed Principle) 개발 폐쇄 원칙

- LSP (Liskov Substitution Principle) 리스코프 치환 원칙

- ISP (Interface Segregation Principle) 인터페이스 분리 원칙

- DIP (Dependency Inversion Principle) 의존 역전 원칙




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


"어떤 클래스를 변경해야 하는 이유는 오직 하나뿐 이어야 한다." 


클래스와 메소드는 한가지의 역활만을 가져야 합니다. 단일 클래스는 단 한개의 책임을 가지고 변경하는 이유는 오직 한개여야 합니다.



Example

19살 이상인 유저만 이름을 변경할 수 있는 예제를 작성해 보겠습니다.


Before

data class User(var name: String, var age: Int)

class UserSettings(val user: User) {

fun changeUserName(name: String) {
if (verifyAgeUpTo19()) {
user.name = name
println("user name change : $name")
}
}

fun verifyAgeUpTo19(): Boolean {
return if (user.age > 19) {
true
} else {
println("error : user age is under 19")
false
}
}
}

UserSettings 클래스는 유저의 이름을 바꿀 수 있는 함수(changeUserName)와 나이를 인증하는 함수(verifyAgeUpTo19)를 가지고 있습니다. 이는 유저의 이름을 변경하는 책임과 유저의 나이를 인증하는 책임 두 가지를 가지고 있어 SRP 원칙에 위배됩니다.


여기서 책임은 함수의 갯수가 아닌 역활을 의미합니다. 만약 나이를 변경할 수 있는 함수(changeUserAge)가 추가 된다고 해도 유저의 값을 변경할 수 있는 기능이 늘어난거지 책임이 늘어난게 아닙니다. 

 

After

data class User(var name: String, var age: Int)

class UserSettings(val user: User) {

fun changeUserName(name: String) {
if(VerifyUser().verifyAgeUpTo19(user)) {
user.name = name
println("user name change : $name")
}
}
}

class VerifyUser {

fun verifyAgeUpTo19(user: User): Boolean {
return if(user.age > 19) {
true
} else {
println("error : user age is under 19")
false
}
}
}

유저의 값을 변경할 수 있는 UserSettings 와 나이를 인증하는 VerifyUser 라는 두 가지 클래스로 책임을 분리했습니다. 책임을 분리함으로서 우리는 변경 및 수정사항이 생겼을 때 해당 책임에 맞는 클래스만 작업할 수 있게 됩니다.




2. OCP (Open-Closed Principle) 개발 폐쇄 원칙


"소프트웨어 엔티티(패키지, 클래스, 모듈, 함수 등)는 확장에 대해서는 개방되어야 하지만, 변경에 대해서는 폐쇄되어야 한다."


자신의 확장에는 개방돼 있고, 주변의 변화에 대해서는 폐쇄돼 있어야 합니다. 즉 기능을 변경하거나 확장할 수 있으면서 그 기능을 사용하는 코드는 수정하지 않아야 합니다.



OCP 원칙이 위반되는 주요 경우


1. 다운 캐스팅이 필요한 경우

2. 비슷한 if-else 블록이 존재하는 경우


open class Animal {

fun sound() {
println("sound")
}
}

class Lion: Animal() {

fun soundLion() {
println("sound lion")
}
}

//다운 캐스팅을 한다.
fun drawCharacter(animal: Animal) {
if(animal is Lion) {
// 다운 캐스팅이 필요한 경우 OCP 위반
(animal as Lion).soundLion()
} else {
animal.sound()
}
}

다운캐스팅을 필요 하게 되면 (Animal -> Lion) 주변 변화에 폐쇄돼 있지 못하므로 OCP 위반의 대표적인 예입니다.


//비슷한 if-else 블록이 존재한다.
class Enemy(val type: Int): Animal() {

fun sounType() {

// 반복적인 if-else 블록 OCP 위반
if(type == 1) {
//...
} else if(type == 2) {
//...
} else if(type == 3) {
//...
} else {
//...
}
}
}

같은 이유로 비슷한 if-else 구문  또한 대표적인 OCP 위반의 한 예입니다. 그러면 OCP 에대해 2가지 예를 보면서 어떻게 코드를 작성해야 되는지 살펴보겠습니다.



Example1(동물 울음 소리 내기)


Before

class Animal(val name: String)

class MyAnimal(val animals: Array<Animal>) {

fun animalSound() {
for(animal in animals) {
when(animal.name) {
"lion" -> {
println("roar")
}
"mouse" -> {
println("squeak")
}
"snake" -> {
println("hiss")
}
}
}
}
}
fun main() {

val animals = arrayOf(Animal("lion"), Animal("mouse"))
MyAnimal(animals).animalSound()

}

위에서 설명 했듯이 비슷한 if-else 구문은 대표적인 OCP 위반의 예입니다.  OCP 원칙은 기능을 변경하거나 확장할 수 있으면서 그 기능을 사용하는 코드는 수정하지 않아야 한다고 했습니다. 하지만 위 코드는 기능을 확장하게 되면 그 기능을 사용하는 animalSound() 의 코드를 수정해 주어야 합니다. 즉 주변 변화에 닫혀있지 못한 구조입니다.


만약 dog 를 추가한다고 하면 이렇게 되겠죠?

fun animalSound() {
for(animal in animals) {
when(animal.name) {
"lion" -> {
println("roar")
}
"mouse" -> {
println("squeak")
}
"snake" -> {
println("hiss")
}
//TODO 기능을 확장 하니 그 기능을 사용하는 코드를 수정하게 됩니다.
"dog" -> {
println("bowwow")
}
}
}
}

이 코드를 OCP 위반되지 않게 수정해 보겠습니다.


After

abstract class Animal {

abstract fun makeSound()
}

class Lion: Animal() {

override fun makeSound() {
println("roar")
}
}

class Mouse: Animal() {

override fun makeSound() {
println("squeak")
}
}

class Snake: Animal() {

override fun makeSound() {
println("hiss")
}
}

class MyAnimal(val animals: Array<Animal>) {

fun animalSound() {
for(animal in animals) {
animal.makeSound()
}
}

}

Animal 이라는 추상 클래스를 만들어 줌으로서 OCP 를 지킬 수 있게 되었습니다. 만약 dog 기능을 추가한다고 해도 그 기능을 사용하는 animalSound() 함수는 변경하지 않아도 됩니다. 즉 확장에는 열려있고 수정에는 닫혀 있어야 합니다.



Example2 (도형 넓이 구하기)


Before

class Rectangle(val width: Double, val height: Double)

class Square(val width: Double)

class Circle(val radius: Double)

class AreaCalculator {
fun getSumArea(shapes: List<Any>): Double {
var area = 0.0

for (shape in shapes) {
when (shape) {
is Rectangle -> {
area += shape.width * shape.height
}
is Square -> {
area += shape.width * shape.width
}
is Circle -> {
area += shape.radius * shape.radius * Math.PI
}
}

}

return area
}
}
fun main() {

val shapes = listOf(Rectangle(4.0, 5.0), Square(5.0), Circle(1.0))

val areaSum = AreaCalculator().getSumArea(shapes)

println("areaSum : $areaSum")
}

각 도형의 넓이를 구해 합해주는 예제입니다. 역시 비슷한 if-else 구문을 사용했으므로 대표적인 OCP 위반 예제입니다.


After

interface Shape {
fun getArea(): Double
}

class Rectangle(val width: Double, val height: Double): Shape {

override fun getArea() = width * height
}

class Square(val width: Double): Shape {

override fun getArea() = width * width
}

class Circle(val radius: Double): Shape {

override fun getArea() = radius * radius * Math.PI
}

class AreaCalculator {

fun getSumArea(shapes: List<Shape>): Double {
var area = 0.0

for (shape in shapes) {
area += shape.getArea()
}

return area
}
}

이번에는 Shape 인터페이스를 통해 OCP 원칙을 지켰습니다. 새로운 기능이 추가 삭제 되어도 도형의 합을 수하는 getSumArea() 의 코드는 변경되지 않습니다. 즉 확장에는 열려 있고 변화에는 닫혀있는 구조입니다.




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


"서브 타입은 언제나 자신의 기반(상위) 타입으로 교체할 수 있어야 한다."


LSP를 통해 자식 클래스가 부모 클래스의 역활을 충실히 하면서 확장해나가야합니다. 즉 상위 타입의 객체를 하위 타입의 객체로 치환해도 상위 타입을 사용하는 프로그램은 정상적으로 동작해야 됩니다.


LSP 원칙은 OCP 원칙에 따라 디자인된 클래스들을 활용하는 단계에서 요구되는 원칙입니다. 추상적인 클래스를 통해서 이면에 숨어있는 구체적인 클래스를 제어하는 데 관심이 있습니다. 다시 말해 부모 클래스를 상속할 때, 부모 클래스가 사용 되는 곳은 아무 문제없이 자식 클래스도 사용할 수 있어야 합니다. 그렇지 않으면, 상속을 잘못 사용하고 있는 것입니다. LSP는 결국 상속의 룰 이며 OCP를 위반하지 않도록 하는 원칙입니다. 


LSP 설명 > 링크



Example (사각형, 정사각형 넓이 구하기)


Before

open class Rectangle(open val width: Int, open val height: Int) {

open fun getArea() = width * height
}


class Square(
override val width: Int,
override val height: Int) : Rectangle(width, height) {

//super.getArea()를 사용하지 않고 정사각형에 맞게 로직을 수정했습니다.
//이렇게 하면 상위 타입(Rectangle)의 기능을 제대로 구현하지 못하게 됩니다.
override fun getArea(): Int {
//return super.getArea()
return width * width
}

}

class DoWork {

fun work() {

val rectangle = Rectangle(5,4)

// 사각형은 20의 값이 나옵니다.
if(isChecked(rectangle)) {
println(rectangle.getArea())
} else {
throw RuntimeException()
}

val square = Square(5,4)

println("square 1 : " + square.getArea())
println("square 2 : " + (square as Rectangle).getArea())


// 정사각형은 20의 값이 아닌 5*5=25가 나옵니다.
if(isChecked(square)) {
println(square.getArea())
} else {
throw RuntimeException()
}

}

//가로 5 세로 4 인 사각형의 넓이는 20압니다.
fun isChecked(rectangle: Rectangle) = rectangle.getArea() == 20
}

fun main() {
DoWork().work()
}

사각형(Rectangle)의 가로 5와 세로 4의 값을 넣으면 넓이가 20이 나와야 합니다. 정사각형(Square) 또한 사각형의 서브타입이므로 상위 타입인 사각형의 기능을 제대로 수행해야 하지만 넓이는 20이 아닌 25(width * width)가 나옵니다. 이는 상속관계부터 잘못 정의되어 발생하는 문제입니다. 이를 해결한 코드를 보겠습니다.


* 부모인 Rectangle 객체에서 작동하는 행위가 Square 객체에 대해서 제대로 동작하지 않는다는 것은 LSP 위반하는 것입니다.


After

abstract class Shape {

abstract fun getArea(): Int
}

class Rectangle(val width: Int, val height: Int): Shape() {

override fun getArea() = width * height
}


class Square(val width: Int) : Shape() {

override fun getArea() = width * width
}

class DoWork {

fun work() {

val rectangle: Shape = Rectangle(5,4)

if(isCheckedRectangle(rectangle)) {
println(rectangle.getArea())
} else {
throw RuntimeException()
}


val square: Shape = Square(5)

if(isCheckedShape(square)) {
println(square.getArea())
} else {
throw RuntimeException()
}
}

fun isCheckedRectangle(rectangle: Shape) = rectangle.getArea() == 20

fun isCheckedShape(square: Shape) = square.getArea() == 25
}

추상 클래스인 Shape 클래스를 상속함으로서 문제를 해결했습니다. SquareRectangle와는 다르게 파라미터 1개만 받아도 동작할 수 있어야 합니다.


이렇게 LSP와 OCP는 함께 생각해야되는 주제입니다.




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


"클라이언트는 자신이 사용하지 않는 메소드에 의존 관계를 맺으면 안 된다."


Example(2019년 이상 계산기에만 곱셈 기능이 있음)


before

interface Calculator {

fun add()

fun subtract()

fun multiply()

}

계산기는 위와 같이 덧셈, 뺄셈, 곱셈 기능을 가지고 있습니다. 하지만 2019년에 나온 신 제품부터 곱셈기능을 갖는 계산기를 생산하려고 합니다.

class Calculator2019: Calculator {

override fun add() {
println("2019 add()")
}

override fun subtract() {
println("2019 subtract()")
}

override fun multiply() {
println("2019 multiply()")
}
}

위와 같이 2019년 계산기는 곱셈기능을 넣을 수 있지만

class Calculator2015: Calculator {

override fun add() {
println("2015 add()")
}

override fun subtract() {
println("2015 substract()")
}

override fun multiply() {
println("...")
}
}

2015년 계산기에는 곱셈 기능을 의미 없이 정의해 주어야 합니다. 이는 ISP 에 위반되는 상황입니다.



After

interface Calculator {

fun add()

fun subtract()

}

interface CalculatorRecent {

fun multiply()
}

class Calculator2015: Calculator {

override fun add() {
println("2015 add()")
}

override fun subtract() {
println("2015 substract()")
}

}

class Calculator2019: Calculator, CalculatorRecent {

override fun add() {
println("2019 add()")
}

override fun subtract() {
println("2019 subtract()")
}

override fun multiply() {
println("2019 multiply()")
}
}

Calculator 와 CalculatorRecent 2개로 인터페이스를 분리했습니다. 이렇게 하니 2015년 계산기에는 쓸데없이 곱셈기능을 정의하지 않아도 됩니다.




5. DIP (Dependency Inversion Principle) 의존 역전 원칙


"고차원 모듈은 저차원 모듈에 의존하면 안 된다. 이 두 모듈 모두 다른 추상화된 것에 의존해야 한다."


"추상화된 것은 구체적인 것에 의존하면 안된다. 구체정인 것이 추상화된 것에 의존해야 한다."


"자주 변경되는 구체 클래스에 의존하면 안된다."


-> 자신보다 변하기 쉬운 것에 의존하지 마라.


-> 자신보다 변하기 쉬운 것에 의존하던 것을 추상화된 인터페이스나 상위 클래스를 두어 변화기 쉬운 것에 영향받지 않게 하는 것이 의존 역전 원칙이다.


-> 상위 클래스는 하위 클래스에 의존해서는 안된다. 하위 클래스가 상위 클래스에 의존을 해야지 반대로 의존한다는것 원칙에 위반된다.



Example1(장난감을 가지고 노는 아이)


before

class Robot{

override fun toString(): String {
return "Robot"
}
}

class Kid(private val robot: Robot) {

fun play() {
println("play toy : $robot")
}
}
fun main() {

val toy = Robot()
val kid = Kid(toy)
kid.play()
}

아이는 로봇이라는 장난감을 가지고 놀 수 있습니다. 하지만 아이(Kid)의 파라미터는 Robot 으로 변하기 쉬운것에 의존하고 있습니다. 만약 다른 장난감이 생기게 되면 Kid 클래스를 새로 정의해주어야 되는 불편함이 있습니다.


open class Toy

class Robot: Toy() {

override fun toString(): String {
return "Robot"
}
}

class Dinosaur: Toy() {

override fun toString(): String {
return "Dinosaur"
}
}

class Kid(private val toy: Toy) {

fun play() {
println("play toy : $toy")
}
}

fun main() {

val robot = Robot()
val Dinosaur = Dinosaur()

//robot 과 dinosaur 를 선택해서 아이에게 줄 수 있다.
val kid = Kid(robot)
kid.play()
}

이번에는 장난감을 변하기 어려운 것(Toy 클래스)에 의존함으로서 문제를 해결한 모습입니다. 이제는 새로운 장난감이 생겨도 Kid 클래스를 새로 정의해 주지 않아도 됩니다. 위 예제 코드를 보시면 DIP 또한 추상화된 것에 의존해야 된다는 것은 결국 변화에 열려있고 수정에는 닫혀있어야 되는 OCP 원칙과 비슷한 맥락인 것 같습니다.




Example2(Logger를 보여주는 예제)


before

// 저차원 모듈
class Logger {
fun logInformation(logInfo: String) {
println(logInfo)
}
}

// 고차원 모듈
class Foo {
// 저차원 모듈을 Foo에서 생성해주어야 하므로 저차원 모듈에 의존하게 된다.
private val logger = Logger()

fun doStuff() {
logger.logInformation("Something important.")
}
}

fun main() {

DIPExample2.Foo().doStuff()

}

고차원 모듈 안에서 저차원 모듈을 생성하고 사용하게 되면 의존성이 생기게 됩니다. 즉 저차원 모듈이 변경되면 의존하고 있는 곳은 코드를 모두 수정해주어야 합니다. 이에 외부에서 객체를 생성에서 주입하는 의존성 주입을 통해 이 문제를 해결하곤 합니다. (Anroid 에서는 Dagger, Koin 과 같은 의존성 주입 프레임워크가 있습니다)


After

interface ILogger {
fun logInformation(logInfo: String)
}

class Logger : ILogger {
override fun logInformation(logInfo: String) {
System.out.println(logInfo)
}
}

class Foo {
private var logger: ILogger? = null

//Logger 생성하지 않고 외부에서 주입받습니다.
fun setLoggerImpl(loggerImpl: ILogger) {
this.logger = loggerImpl
}

fun doStuff() {
logger?.logInformation("Something important.")
}
}
fun main() {

val foo = Foo()
val logger = Logger()

foo.setLoggerImpl(logger)
foo.doStuff()
}

Foo 클래스 내부에서 doStuff() 함수의 logger을 직접 생성하지 않고 외부에서 주입받고 있는 코드입니다. 즉 외부 ILogger (변화기 어려운 것 - 추상화된 인터페이스) 에 의존 하게 둠으로서 의존 역전 원칙을 지키고 있습니다. 



정리


SOLID 원칙는 2010년대 초반에 나온 원칙입니다. 지금 까지 많은 시간이 흘렸고 개발 트렌드 또한 많은 변화를 겪어 왔습니다. 이를 어떻게 해석하고 적용하는지는 아마 개발자들 다 조금씩 다를거라고 생각합니다. 내용상 틀리거나 다른 부분이 있다면 언제든지 댓글 부탁드리겠습니다.


예제 깃 주소


<참고자료>


객체지향 개발 5대 원리: SOLID

객체지향디자인의 5원칙

Before & After 예제

반응형
Comments