Home 디자인 패턴
Post
Cancel

디자인 패턴

작성중…

마지막 업데이트 : 2022-03-19 18:48:01 +0900


주요 디자인 패턴

디자인 패턴이란

  • 객체 지향 설계는 변화에 유연하게 대응하기 위해 특정 상황에 맞는 패턴을 이용하게 되는데 이를 디자인 패턴이라고 한다. 여기서 특정 상황이란 클래스, 객체의 구성, 객체 간 메시지 흐름에서 알맞는 설계를 하는 것이다.

  • 디자인 패턴을 배우면서 느낄 수 있는 장점은 아래와 같다.

    1. 상황에 맞는 설계를 빠르게 적용시킬 수 있다.
    2. 각 패턴의 장단점을 통해 어떻게 설계할 것인지에 대해 도움을 받을 수 있다.
    3. 설계한 패턴에 이름을 붙임으로써 문서화, 이해, 유지보수에 도움을 얻을 수 있다.

전략 패턴 (Strategy Pattern)

  • 전략 패턴은 전략(Strategy)과 컨텍스트(Context)라는 이름으로 나뉘어지는데, 여기서 전략이란 어떤 기능을 수행하는데 필요한 알고리즘을 직접 구현한 객체가 되며 콘텍스트는 이를 사용하는 책임을 갖고 있는 것을 말한다.

  • 따라서 특정 컨텍스트에서 어떤 기능을 수행하는데 필요한 알고리즘을 따로 분리해내 컨텍스트에서는 어떤 알고리즘을 사용하는지 알 필요가 없다.

  • 전략 패턴을 사용했을때와 사용하지 않았을 때의 코드를 비교해자면 아래와 같다.

    1. 사용하지 않았을 때
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    
      data class Item(val price: Int)
    
      class Coupon {
          fun calculatePrice(item: Item, isFirst: Boolean): Int {
              if (isFirst) {
                  return item.price - (item.price * 0.8).toInt()
              } else {
                  return item.price - (item.price * 0.5).toInt()
              }
          }
      }
    
    • 첫 번째 주문일 경우 80퍼센트의 할인을 해주고 그렇지 않다면 50퍼센트를 할인해주는 코드이다.
    • 만약 첫 번째 주문이 아닌 오랜만에 주문했을 경우의 코드를 추가한다고 했을 때, if ~ else 구문과 메소드의 매개변수들은 점점 늘어나게 되고 관리가 힘들어지게 된다.
    1. 사용했을 때
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    
      interface DiscountStrategy {
          val ratio: Double
          fun getDiscountPrice(price: Int): Int
      }
    
      class FirstOrderDiscount : DiscountStrategy {
          override val ratio: Double = 0.8
          override fun getDiscountPrice(price: Int): Int {
              return price - (price * ratio).toInt()
          }
      }
    
      class OrderDiscount : DiscountStrategy {
          override val ratio: Double = 0.5
          override fun getDiscountPrice(price: Int): Int {
              return price - (price * ratio).toInt()
          }
      }
    
      data class Item(val price: Int)
    
      class Coupon(private val discountStrategy: DiscountStrategy) {
          fun calculatePrice(item: Item): Int {
              return discountStrategy.getDiscountPrice(item.price)
          }
      }
    
    • 사용하지 않았을 때와 동일한 값을 반환하지만 새로운 할인 전략이 발생했을 때 DiscountStrategy를 상속받는 클래스를 이용해 정의하고 사용할 수 있게 된다.
    • 이렇게 되면 객체 지향 설계 중 하나인 OCP원칙을 지킬 수 있게 된다.

템플릿 메서드 패턴 (Template Method)

  • 프로그램을 구현하다 보면, 완전히 동일한 절차를 가진 코드를 작성하게 될 수 있다. 즉, 일부 과정의 구현만 다를 뿐 나머지 구현은 완전히 같을 수 있는데, 그 예는 아래와 같다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    
      class DbAuthenticator {
          fun authenticate(id: String, pw: String): Auth {
              val user = userDao.selectById(id)
              auth = user.equalPassword(pw)
              if (!auth) {
                  throw Exception()
              }
              return new Auth(id, user.getName());
          }
      }
    
      class LdapAuthenticator {
          fun authenticate(id: String, pw: String): Auth {
              auth = ldapClient.authenticate(id, pw)
              if (!aut) {
                  throw Exception()
              }
              ctx = ldapClient.find(id)
              return new Auth(id, ctx.getAttribute("name))
          }
      }
    
  • 위 코드는 단순히 DB와 LDAP를 이용해 인증하는 구조인데, 몇 부분을 제외하고는 완전히 동일한 형태를 띄게 된다.
  • 지금은 DB, LDAP만 있지만 또다른 인증 방법이 나오게 된다면 코드의 중복이 많이 발생하게 됨으로써 유지보수가 어려워지게 된다.
    • 코드의 중복이 많아지면 로직 중 하나를 수정할 때 모든 관련된 코드를 수정해야하는 단점이 존재한다.
  • 템플릿 패턴은 이러한 코드의 중복을 줄여주도록 아래와 같이 구성된다.
    • 실행 과정을 구현한 상위 클래스
    • 실행 과정의 일부 단계를 구현한 하위 클래스
  • 상위 클래스는 추상 메서드를 이용해 호출하며 하위 클래스에서는 이러한 추상 메서드를 직접 구현함으로써 코드의 중복을 줄일 수 있다. 그 예는 아래와 같다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    
      abstract class Authenticator {
          fun authenticate(id: String, pw: String) {
              if (!doAuthenticate(id, pw)) {
                  throw Exception()
              }
    
              return createAuth(id)
          }
    
          protected abstract fun doAuthenticate(id: String, pw: String): Boolean
          protected abstract fun createAuth(id: String): Auth
      }
    
      class DbAuthenticator : Authenticator() {
          override fun doAuthenticate(id: String, pw: String): Boolean {
              ...
    
              return true
          }
    
          override fun createAuth(id: String): Auth {
              ...
    
              return Auth(id)
          }
      }
    
    • 위와 같이 구현하게 되면 Authenticator를 상속받는 클래스에서는 단순히 상위 클래스의 추상 메서드들을 재정의해면 된다.
  • 일반적으로 상속은 하위 클래스가 상위 클래스의 기능을 재사용할지 여부를 확인하지만 템플릿 메서드 패턴에서는 상위 클래스가 흐름을 제어하고 그 흐름에 하위 클래스가 끼어들어가게 되는 것이다.

  • 또한, 템플릿 메서드 패턴에 훅(Hook) 메서드라는 것을 이용해 하위 클래스에서 확장할 수도 있는데 그 구조는 아래와 같다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    
      abstract class Authenticator {
          fun authenticate(id: String, pw: String) {
              if (!doAuthenticate(id, pw)) {
                  throw Exception()
              }
              doSomthing()
    
              return createAuth(id)
          }
    
          protected abstract fun doAuthenticate(id: String, pw: String): Boolean
          protected abstract fun createAuth(id: String): Auth
          protected fun doSomthing() {
              ...
          }
      }
    
    • 위 코드에서 doSomething메서드를 protected로 선언함으로써 인증이 끝나고 어떤 작업을 해야할 때 그 작업을 하위 클래스에서 확장하도록 할 수 있다.

상태 패턴 (State)

  • 상태(State)에 따라 동일한 기능이 다르게 구현되어야 할 때 사용할 수 있다.

  • 상태 패턴을 이용하지 않는다면 하나의 메서드에 상태에 따른 기능들을 if문으로 다 분기시켜줘야하기 때문에 코드가 길어짐으로써 유지보수가 어려워지게 된다.

  • 상태를 인터페이스를 이용해 별도 타입으로 분리해서 각 상태 별로 알맞은 하위 타입(콘크리트 클래스)을 구현하게 된다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    
      class VendingMachine {
          private var state = NoCoinState()
          fun insertCoin(coin: Int) {
              state.increaseCoin(coin, this)
          }
          fun select(prodId: Int) { ... }
          fun changeState(state: State) { ... }
      }
        
      interface State {
          fun increaseCoin(coin: Int, vm: VendingMachine)
          fun select(prodId: Int, vm: VendingMachine)
      }
    
      class NoCoinState : State {
          override fun increaseCoin(coin: Int, vm: VendingMachine) {}
          override fun select(prodId: Int, vm: VendingMachine) {}
      }
    
      class SelectableState : State {
          override fun increaseCoin(coin: Int, vm: VendingMachine) {}
          override fun select(prodId: Int, vm: VendingMachine) {}
      }
    
  • 코인이 없을 때 select 호출 시 비프음을 내도록 할 수도 있고 선택 가능한 상태일 때 select 호출 시 제품을 제공하도록 구현할 수 있다.

  • 상태 패턴의 장점은 새로운 상태가 추가되더라도 콘텍스트(VendingMachine)가 받는 영향은 최소화 됨으로써 상태가 많아지더라도 클래스의 개수만 증가할 뿐 코드의 복잡도는 증가하지 않아 유지 보수에 유리하다. 또한, 각 상태 별로 코드가 구현되어 있기 때문에 상태에 따른 동작들을 수정하기가 쉽다.

상태 변화는 누가 해야하는가?

  • 상태 변화는 콘텍스트(VendingMachine)나 상태(NoCoinState, SelectableState 등) 둘 중 하나가 된다.
  • 콘텍스트에서 변경할 경우 아래와 같이 구현될 수 있다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    
      class VendingMachine {
          private var state = NoCoinState()
          fun insertCoin(coin: Int) { ... }
          fun select(prodId: Int) { 
              ...
              if (특정조건) {
                  changeState(NoCoinState())
              }
          }
          fun changeState(state: State) { 
              state = State
          }
      }
    
    • 콘텍스트에서 변경할 경우 비교적 상태 개수가 적고 상태 변경의 규칙이 거의 바뀌지 않는 경우에 유리한데, 상태 종류나 규칙이 자주 변경될 경우 상태 변경 처리에 대한 코드가 복잡해질 수 있다.
  • 상태에서 변경할 경우 아래와 같이 구현될 수 있다.

    1
    2
    3
    4
    5
    6
    7
    
      class NoCoinState : State {
          override fun increaseCoin(coin: Int, vm: VendingMachine) {}
          override fun select(prodId: Int, vm: VendingMachine) {
              vm.increaseCoin(coin)
              vm.changeState(SelectableState())
          }
      }
    
    • 상태에서 변경할 경우 콘텍스트(VendingMachine)에 영향을 주지 않으면서 상태나 규칙을 변경할 수 있게 된다. 그러나 상태 변경 규칙이 여러 클래스(SelectableState 등)에 분산되어 있어 구현 클래스가 많아질수록 변경 규칙을 파악하기 어려워질 수 있다.
  • 이처럼 상태 변화를 누가 하는가에 따라 장단점이 있기 때문에 주어진 상황에 알맞는 방식을 선택해야 한다.

파사드(Facade) 패턴

  • 파사드 패턴은 코드 중복직접적인 의존을 해결하는데 도움을 주는 패턴이다.
  • 예를 들어서 아래와 같은 코드가 있다고 가정해볼 때 Notebook과 Desktop은 Power, Memory, Cpu에 의존하고 있다.

    패턴 적용 전
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    
    class ModelAPower {
      fun turnOn() { }
    }
      
    class Memory {
        fun load() { }
    }
      
    class Cpu {
        fun execute() { }
    }
      
    class Notebook {
        private val modelAPower = ModelAPower()
        private val memory = Memory()
        private val cpu = Cpu()
      
        fun start() {
            modelAPower.turnOn()
            memory.load()
            cpu.execute()
        }
    }
      
    class Desktop {
        private val modelAPower = ModelAPower()
        private val memory = Memory()
        private val cpu = Cpu()
      
        fun start() {
            modelAPower.turnOn()
            memory.load()
            cpu.execute()
        }
    }
    


  • 위 코드는 몇가지 문제가 있는데 하나씩 살펴보면 아래와 같다.
    • 동일한 코드가 중복된다. (turnOn, load, execute, 변수 선언)
    • 실행의 순서가 power, memory, cpu에서 power ,cpu, memory로 바뀐다면 Notebook과 Desktop의 코드가 변경되어야 한다.
    • power가 ModelA에서 modelB로 바뀔 경우에도 Notebook과 Desktop의 코드가 변경되어야 한다.
  • 위 문제는 Notebook과 Desktop이 power, memory, cpu를 의존하고 있기 때문에 발생하는 문제인데, 파사드 패턴은 코드의 중복과 의존성을 줄여주기 위한 디자인 패턴이다.
  • 아래는 파사드 패턴을 적용했을 때의 결과이다.

    패턴 적용 후
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    
    package facade_pattern
      
    class ModelAPower {
        fun turnOn() { }
    }
      
    class Memory {
        fun load() { }
    }
      
    class Cpu {
        fun execute() { }
    }
      
    class Computer {
        private val modelAPower = ModelAPower()
        private val memory = Memory()
        private val cpu = Cpu()
      
        fun start() {
            modelAPower.turnOn()
            memory.load()
            cpu.execute()
        }
    }
      
    class Notebook {
        private val computer = Computer()
        fun start() {
            computer.start()
        }
    }
      
    class Desktop {
        private val computer = Computer()
        fun start() {
            computer.start()
        }
    }
    


    • 파사드 패턴을 적용함으로써 클라이언트 (Notebook, Desktop)와 서브 시스템 (Power, Cpu, Memory)간의 직접적인 의존을 제거했으며 코드의 중복 또한 줄어들게 되었다.

장점과 특징

  • 파사드를 정의함으로써 클라이언트 변경 없이 서브 시스템을 변경할 수 있다는 장점이 있다.
  • 단지 여러 클라이언트의 중복된 코드들을 파사드로 추상화하여 사용했을 뿐, 서브 시스템에 대한 직접적인 접근을 막는 것은 아니다.
    • 필요한 경우 클라이언트에서 서브 시스템을 바로 사용할 수 있다.
  • 퍼사드는 공통적인 작업에 대해 간편한 메소드들을 제공해준다.
  • 좋게 작성되지 않은 API의 집합을 하나의 좋게 작성된 API로 감싸준다.
  • 시스템의 복잡성을 숨기고 클라이언트에 더 간단한 인터페이스를 제공한다.
    • 예를 들어서 적용 전 코드에서 Notebook과 Desktop의 start 메소드가 훨씬 더 복잡한 로직을 가지고 있을 경우 구현 도중에 코드를 누락하거나 잘못 작성할 수 있는 가능성이 높다.
    • 따라서 파사드 패턴을 적용함으로써 클라이언트는 어떻게 구현해야 되는지에 대한것은 알 필요가 없으므로 더 간단하게 코드를 사용할 수 있게 된다.
  • 인터페이스와 같이 사용하여 서브 시스템의 교체를 쉽게 할 수도 있다.

인터페이스와 비교

  • 인터페이스는 공통된 메소드들을 정의해 내부 코드를 감춤으로써 확장과 변경에 초점이 맞춰져있지만 파사드 패턴은 내부 코드를 감추는 것은 동일하지만 단순히 공통적으로 사용되는 클래스와 메소드들을 하나의 파사드 클래스 내부 메소드 안에서 묶어주는 역할을 한다.
  • 파사드 패턴은 사용하는 클래스와 의존 관계에 있다.

어댑터 패턴과 비교

  • 어댑터 패턴은 단순히 호환되지 않는 두 개의 인터페이스를 연결해주는 패턴이다.
    • 즉, DVI to HDMI 케이블같은 것을 의미한다.
  • 그러나 파사드 패턴은 기가지니처럼 어떤 행동 하나에 티비, 전등, 가스 등을 제어하는 것과 동일하게 이해할 수 있다.
  • 참고

추상 팩토리 패턴

  • 어떤 조건별로 다른 객체들을 생성해야할 때 추상 팩토리 패턴을 사용할 수 있다.
  • 객체의 생성 책임을 분리한다.
그릴 객체 추상/콘크리트 클래스
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
abstract class DrawObject {
    abstract var point: Point
    abstract var size: Size

    abstract fun draw()
}

class Image(override var point: Point, override var size: Size) : DrawObject() {
    override fun draw() {
        println("Image -> point: ${point}, size: $size")
    }
}

class Rectangle(override var point: Point, override var size: Size) : DrawObject() {
    override fun draw() {
        println("Rectangle -> point: ${point}, size: $size")
    }
}
그릴 객체 팩토리 클래스 구현
1
2
3
4
5
6
7
8
9
10
11
12
13
abstract class DrawObjectFactory {

    companion object {
        fun getFactory(index: Int): DrawObjectFactory {
            return when (index) {
                1 -> RectangleFactory()
                else -> ImageFactory()
            }
        }
    }

    abstract fun createDrawObject(point: Point, size: Size): DrawObject
}
사각형, 이미지 팩토리 클래스 구현
1
2
3
4
5
6
7
8
9
10
11
class ImageFactory : DrawObjectFactory() {
    override fun createDrawObject(point: Point, size: Size): DrawObject {
        return Image(Point(11, 22), Size(33, 44))
    }
}

class RectangleFactory : DrawObjectFactory() {
    override fun createDrawObject(point: Point, size: Size): DrawObject {
        return Rectangle(Point(10, 20), Size(30, 40))
    }
}
메인 구현
1
2
3
4
5
6
7
8
9
10
11
fun main() {
    // 사각형
    var drawObjectFactory = DrawObjectFactory.getFactory(1)
    var drawObject = drawObjectFactory.createDrawObject(Point(10, 20), Size(30, 40))
    drawObject.draw()

    // 이미지
    drawObjectFactory = DrawObjectFactory.getFactory(2)
    drawObject = drawObjectFactory.createDrawObject(Point(11, 22), Size(33, 44))
    drawObject.draw()
}
결과
1
2
Rectangle -> point: Point(x=10, y=20), size: Size(width=30, height=40)
Image -> point: Point(x=11, y=22), size: Size(width=33, height=44)

팩토리

장점

  • 클라이언트 변경 없이 사용할 객체를 동적으로 교체할 수 있다.
  • 객체의 생성을 분리함으로써 메인에서는 생성에 대한 책임없이 사용하기만 하면 된다.

컴포지트 패턴

  • 클라이언트가 복합 객체(group)나 단일 객체를 동일하게 취급하는 것으로 전체-부분을 구성하는 클래스가 동일 인터페이스를 구현하도록 하는 디자인 패턴이다.
  • 두꺼비집으로 생각하면 쉬울 것 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
interface Device {
    fun turnOn()
    fun turnOff()
}

class Computer : Device {
    override fun turnOn() { }
    override fun turnOff() { }
}

class Light : Device {
    override fun turnOn() { }
    override fun turnOff() { }
}

class InternetCafe : Device {
    private val deviceList = mutableListOf<Device>()

    override fun turnOn() {
        deviceList.forEach {
            it.turnOn()
        }
    }

    override fun turnOff() {
        deviceList.forEach {
            it.turnOff()
        }
    }

    fun addDevice(device: Device) {
        deviceList.add(device)
    }
}

fun main() {
    val internetCafe = InternetCafe()
    internetCafe.addDevice(Computer())
    internetCafe.addDevice(Computer())

    internetCafe.addDevice(Light())
    internetCafe.addDevice(Light())

    internetCafe.turnOff()
}

장점

  • 클라이언트가 컴포지트(그룹)과 컴포넌트(단일 객체)를 구분하지 않고 컴포넌트 인터페이스만으로 프로그래밍 할 수 있다.

This post is licensed under CC BY 4.0 by the author.

안드로이드 UI 레이어

MVC 패턴

Comments powered by Disqus.