🧐 Swift 로 여러가지 디자인 패턴들에 대해 공부해봤습니다.
디자인 패턴은 공통의 소프트웨어 개발 문제를 확인하고 이를 다루기 위한 전략을 제공합니다. 다음은 디자인 패턴을 사용했을 때의 장점입니다.
디자인 패턴에 담긴 두 가지 주된 철학은 코드 재사용과 유연성입니다. 소프트웨어 아키텍트로서 재사용이 가능하면서 유연한 코드를 작성하는 것은 필수적입니다.
디자인 패턴은 세 가지의 범주로 분류되고, 다음과 같습니다.
생성 패턴은 객체를 어떻게 생성하는지 다루는 디자인 패턴입니다. 생성 패턴에는 두 가지 기본 개념이 있습니다.
생성 패턴 범주의 한 부분으로 널리 알려진 패턴으로는 다섯 가지가 있습니다.
싱글턴 패턴은 개발 커뮤니티에서 많은 논란을 불러일으키는 주제입니다. 싱글턴 패턴이 자주 남용되고 오용되는 패턴이기 때문일 것입니다. 싱글턴 패턴이 논란이 많은 또 다른 이유는 싱글턴 패턴이 애플리케이션에서 정적 상태를 제공하기 때문입니다. 정적 상태는 애플리케이션의 어느 지점에서나 객체를 변경시킬 수 있는 능력을 제공합니다. 싱글턴 패턴은 의존성과 강한 결합을 가져옵니다. 때문에 싱글턴 패턴을 오용하지 않도록 주의를 기울여야 합니다.
싱글턴 패턴은 앱 전체 주기동안 어떠한 타입에 대해 유일한 인스턴스가 있어야 하는 문제를 해결하기 위해 설계되었습니다. 이 패턴은 앱에서 하나의 인스턴스만 필요로 하는 경우에 매우 유용합니다. 예를들어, 앱이 블루투스로 원격장치와 통신하면서 앱 곳곳에서 연결을 유지하고 싶을 경우 좋은 예시가 될 수 있습니다. 싱글턴 패턴은 다른 페이지로 이동하는 경우 재연결을 할 필요 없이 연결을 유지하게 해줍니다.
// 싱글턴 구현
class MySingleton {
static let sharedInstance = MySingleton()
var number = 0
private init() {}
}
// 싱글턴 사용
var singleA = MySingleton.sharedInstance
var singleB = MySingleton.sharedInstance
클래스의 타입 프로퍼티는 클래스를 인스턴스화하지 않아도 호출할 수 있습니다. sharedInstance 를 정적 상수로 정의했기 때문에 MySingleton.sharedInstance 로 접근을 하면 앱 주기 전체에서 오직 하나의 인스턴스에만 접근하게 됩니다. 그리고 private init() 으로 외부에서는 객체를 생성할 수 없도록 막아버림으로써 오직 하나라는 보장의 싱글턴 패턴이 구현됩니다. 이는 클래스가 참조타입이기 때문에 생기는 보장이기도 합니다.
빌더 패턴은 복잡한 객체의 생성을 도우면서, 객체 생성 프로세스를 강제합니다. 빌더 패턴은 타입의 서로 다른 결과물을 생성하는 데 동일한 프로세스를 사용하게 해줍니다.
빌더 패턴은 인스턴스를 생성함에 있어서 여러가지 경우의 옵션을 챙겨줘야 할 때, 복잡함을 해결하기 위해 설계되었습니다. 빌더 패턴은 builder 라는 중개자를 이용해서 이 문제를 해결합니다.
먼저, 빌더 패턴을 사용하지 않고 복잡한 구조체를 생성해보겠습니다. 다음은 맛있는 햄버거 구조체입니다.
// 햄버거 구조체
struct Burger {
var name: String
var patties: Int
var bacon: Bool
var cheese: Bool
var pickles: Bool
var ketchup: Bool
var mustard: Bool
var tomato: Bool
}
// 커스텀 버거 생성
var myBurger = Burger(name: "MyBurger",
patties: 1,
bacon: false,
cheese: false,
pickles: true,
ketchup: true,
mustard: false,
tomato: true)
// 치즈 버거 생성
var cheeseBurger = Burger(name: "CheeseBurger",
patties: 1,
bacon: true,
cheese: true,
pickles: false,
ketchup: false,
mustard: false,
tomato: false)
위와 같은 방식으로 Burger 인스턴스를 생성하는 데에는 많은 코드가 필요하게 됩니다. 이번에는 빌더 패턴을 사용해서 인스턴스를 생성해보겠습니다.
struct BurgerBuilder {
var name = "Burger"
var patties = 1
var bacon = false
var cheese = false
var pickles = false
var ketchup = false
var mustard = false
var tomato = false
mutating func setPatties(_ choice: Int) { self.patties = choice }
mutating func setBacon(_ choice: Bool) { self.bacon = choice }
mutating func setCheese(_ choice: Bool) { self.cheese = choice }
mutating func setPickles(_ choice: Bool) { self.pickles = choice }
mutating func setKetchup(_ choice: Bool) { self.ketchup = choice }
mutating func setMustard(_ choice: Bool) { self.mustard = choice }
mutating func setTomato(_ choice: Bool) { self.tomato = choice }
func build() -> Burger {
return Burger(name: name,
patties: patties,
bacon: bacon,
cheese: cheese,
pickles: pickles,
ketchup: ketchup,
mustard: mustard,
tomato: tomato)
}
}
// builder로 인스턴스 생성하기
var burgerBuilder = BurgerBuilder()
burgerBuilder.setBacon(true)
// 커스텀 버거 생성
var myBurger2 = burgerBuilder.build()
// 치즈 버거 생성
burgerBuilder.setCheese(true)
var cheeseBurger2 = burgerBuilder.build()
위 코드에서 볼 수 있듯이, 빌더 패턴을 사용하면 인스턴스를 생성하는 과정이 간단해집니다.
팩토리 메소드 패턴은 생성할 정확한 타입을 명시하지 않으면서 객체의 인스턴스를 생성하는 메소드를 사용합니다. 팩토리 메소드는 생성할 타입을 런타임에 선택하게 해줍니다.
팩토리 패턴은 하나의 프로토콜을 따르는 여러 타입이 있고, 인스턴스화하기 위해 적절한 타입을 런타임에서 선택해야 하는 문제를 해결하기 위해 설계 되었습니다.
팩토리 메소드 패턴은 하나의 메소드 내에서 인스턴스화 유형을 선택하는 데 사용되는 로직을 캡슐화합니다. 이 메소드는 프로토콜을 코드로만 노출시킵니다. 코드는 메소드를 호출하며, 특정 타입을 어떻게 선택하는지에 대한 자세한 사항은 드러내지 않습니다.
예를들어, Flyable 이라는 프로토콜이 있고, 이를 만족하는 Pigeon, Eagle, sparrow 가 있다고 했을 때 다음과 같이 일반적인 생성 코드를 짤 수 있습니다.
protocol Flyable {
func fly()
}
// 약속된 id: 0
class Pigeon: Flyable {
func fly() {
print("Pigeon fly")
}
}
// id: 1
class Eagle: Flyable {
func fly() {
print("Eagle fly")
}
}
// id: 2
class Sparrow: Flyable {
func fly() {
print("Sparrow fly")
}
}
// 팩토리
class FlyableFactory {
public static func makeFlyableThing(id: Int) -> Flyable? {
switch id {
case 0:
return Pigeon()
case 1:
return Eagle()
case 2:
return Sparrow()
default:
return nil
}
}
}
// eagle 생성
var flyable = FlyableFactory.makeFlyableThing(id: 1)
https://velog.io/@heyksw/Server-Driven-UI-Multi-Cell-Type-Custom-Table-View#common-cell-common-cell-factory
이 글에서 팩토리 패턴에 대한 더 자세한 예시를 다뤘었습니다.
구조 디자인 패턴은 어떻게 타입을 더 큰 구조체로 결합할 수 있는가를 서술합니다. 일반적으로 이러한 더 큰 구조체는 작업하기가 더 쉽고 개별적인 타입이 가진 수많은 복잡도를 감추기에도 더 쉽습니다. 구조 패턴 범주에 있는 대부분 패턴들은 객체 간의 연결을 수반합니다.
다음은 대표적인 구조 패턴들입니다.
앱 개발을 하다보면, 새로운 요구 사항과 기능이 들어옴에 따라 기존 타입을 수정해야할 일들이 많습니다. A 라는 타입을 수정해야 하는데, B 라는 타입과 결합도가 높다면, 그리고 B 는 C 라는 타입과 결합도가 높다면.. 수정하는데에 많은 노력을 쏟아야 할 것입니다. 브리지 패턴은 상호작용하는 기능을 갖고 기능 간에 공유하는 능력으로부터 각 기능에 특화된 능력을 분리시킴으로써 이런 문제를 해결합니다.
메시지를 주고 받는 기능을 설계해보겠습니다.
Message 라는 메시지 프로토콜을 정의합니다. Message 프로토콜은 어떤 string 을 주고 받을 것인지에 대한 messageString, 메시지를 보내기 전 준비단계에 필요한 메서드 prepareMessage() 를 갖고 있습니다.
Sender 라는 프로토콜을 정의합니다. Sender 는 메시지를 보내는 메서드 sendMessage(message: Message) 를 갖습니다. 여기서 Message 프로토콜과 Sender 프로토콜은 서로 연관성이 생깁니다.
// 프로토콜 정의
protocol Message {
var messageString: String {get set}
init(messageString: String)
func prepareMessage()
}
protocol Sender {
// Message 프로토콜과 연관성이 생김
func sendMessage(message: Message)
}
// 메시지 클래스 생성
class PlainTextMessage: Message {
var messageString: String
required init(messageString: String) {
self.messageString = messageString
}
func prepareMessage() {
// 메시지 준비 코드
}
}
// 이메일 센더 클래스 생성
class EmailSender: Sender {
func sendMessage(message: Message) {
print("send email message")
}
}
// SNS 센더 클래스 생성
class SMSSensder: Sender {
func sendMessage(message: Message) {
print("send sms message")
}
}
이제, 갑자기 Sender 프로토콜에 Message 를 보내기전에 검증하는 새로운 기능을 추가해달라는 요구 사항을 받았다고 가정해보겠습니다. 그리고 sendMessage 의 매개변수는 없애고 Message 프로퍼티를 생성해야 하는 상황이 생겼다고 가정하겠습니다.
그렇다면 Sender 프로토콜을 다음과 같이 변경해야 합니다.
protocol Sender {
// message 프로퍼티 추가
var message: Message? {get set}
// 매개변수 수정
func sendMessage()
// 메시지 검증 기능
func verifyMessage()
}
브리지 패턴을 사용하지 않았을 경우에는 EmailSender, SMSSender 클래스에 전부 찾아가서 sendMessage() 를 수정해줘야 합니다. 위의 경우에는 Sender 프로토콜을 채택한 클래스가 2개 뿐이지만, 이 숫자가 많아질수록 유지 보수하기 더욱 어려워질 것입니다.
브리지 패턴을 사용해, 다음과 같이 Message 와 Sender 사이에 Bridge 를 놓는다면 이를 해소할 수 있습니다.
// 브리지
struct MessagingBridge {
static func sendMessage(message: Message, sender: Sender) {
var sender = sender
message.prepareMessage()
sender.message = message
sender.verifyMessage()
sender.sendMessage()
}
}
Message 와 Sender 가 어떻게 상호작용하는지에 대한 로직은 MessagingBridge 구조체 내부로 캡슐화 되었습니다. 그러므로, 로직을 변경하는 경우가 생긴다면 전체 코드를 리팩토링 하는게 아니라 이 타입 내부에서만 수정을 하면 됩니다.
퍼사드 패턴은 더 크고 복잡한 코드에 간소화된 인터페이스를 제공합니다. 퍼사드 패턴은 복잡한 것들을 숨김으로써 라이브러리를 사용하기 더 쉽고 이해하기 더 쉽게 만듭니다. 퍼사드 패턴은 여러 API를 하나의 더 사용하기 쉬운 API로 결합하게 해줍니다.
퍼사드 패턴은 대게 서로 함께 작동하게 설계된 수많은 독립적인 API를 가진 복잡한 시스템을 갖고 있는 경우에 사용됩니다.
다음 예시는 호텔, 항공기, 렌터카를 예약하는 API에 대한 코드입니다.
import Foundation
struct Hotel {
// 호텔 객실 정보
}
struct HotelBooking {
static func getHotelNameForDates(to: NSDate, from: NSDate) -> [Hotel]? {
let hotels = [Hotel]()
// 호텔을 가져오는 로직
return hotels
}
static func bookHotel(hotel: Hotel) {
// 호텔 객실을 예약하는 로직
}
}
struct Flight {
// 항공기에 대한 정보
}
struct FlightBooking {
static func getFlightNameForDates(to: NSDate, from: NSDate) -> [Flight]? {
let flight = [Flight]()
// 항공기를 가져오는 로직
return flight
}
static func bookFlight(flight: Flight) {
// 항공기를 예약하는 로직
}
}
struct RentalCar {
// 렌터카에 대한 정보
}
struct RentalCarBooking {
static func getRentalCarNameForDates(to: NSDate, from: NSDate) -> [RentalCar]? {
let rentalCar = [RentalCar]()
// 렌터카를 가져오는 로직
return rentalCar
}
static func bookRentalCar(rentalCar: RentalCar) {
// 렌터카를 예약하는 로직
}
}
// 퍼사드 구조체
struct TravelFacade {
var hotels: [Hotel]?
var flights: [Flight]?
var cars: [RentalCar]?
init(to: NSDate, from: NSDate) {
hotels = HotelBooking.getHotelNameForDates(to: to, from: from)
flights = FlightBooking.getFlightNameForDates(to: to, from: from)
cars = RentalCarBooking.getRentalCarNameForDates(to: to, from: from)
}
func bookTrip(hotel: Hotel, flight: Flight, rentalCar: RentalCar) {
HotelBooking.bookHotel(hotel: hotel)
FlightBooking.bookFlight(flight: flight)
RentalCarBooking.bookRentalCar(rentalCar: rentalCar)
}
}
TravelFacade 구조체를 만듬으로써, 퍼사드 타입만 변경해서 전체 API 코드들을 리팩토링할 수 있게 됩니다.
행위 디자인 패턴은 타입 간에 상호 작용이 어떻게 이뤄지는지를 설명합니다. 이러한 패턴은 어떤 일을 발생시키기 위해 어떻게 서로 다른 타입의 인스턴스간에 메세지를 보내는지 설명합니다.
다음은 행위 디자인 패턴으로 알려진 것들입니다.
커맨드 패턴은 사용자에게 나중에 실행할 수 있는 행동을 정의하도록 요구합니다. 일반적으로 커맨드 패턴은 나중에 호출하거나 행동을 해야 하는 모든 정보를 캡슐화합니다.
커맨드 패턴은 사용자에게 다양한 행동에 관한 로직을 커맨드 프로토콜을 따르는 분리된 타입으로 캡슐화하라고 이야기합니다. 그러면 이를 사용하는 호출자에게 커맨드 타입의 인스턴스를 제공할 수 있습니다. 호출자는 필요한 행동을 실행하기 위해 프로토콜에서 제공하는 인터페이스를 사용할 것 입니다.
다음은 계산기에 대한 로직을 커맨드 패턴으로 구현한 것입니다.
protocol MathCommand {
func excute(num1: Double, num2: Double) -> Double
}
struct AdditionCommand: MathCommand {
func excute(num1: Double, num2: Double) -> Double {
return num1 + num2
}
}
struct SubtractionCommand: MathCommand {
func excute(num1: Double, num2: Double) -> Double {
return num1 - num2
}
}
struct MultiplicationCommand: MathCommand {
func excute(num1: Double, num2: Double) -> Double {
return num1 * num2
}
}
struct DivisionCommand: MathCommand {
func excute(num1: Double, num2: Double) -> Double {
return num1 / num2
}
}
struct Calculator {
func performCalculation(num1: Double, num2: Double, command: MathCommand) -> Double {
return command.excute(num1: num1, num2: num2)
}
}
var calc = Calculator()
var startValue = calc.performCalculation(num1: 25, num2: 10, command: SubtractionCommand())
커맨드 패턴을 사용함으로써 얻을 수 있는 장점들은 다음과 같습니다.
스트래티지 패턴은 호출하는 타입으로부터 자세한 구현 사항을 분리하고 런타임에서 구현체를 교체시킬수 있게 해준다는 점에서 커맨드 패턴과 매우 유사합니다. 스트래티지 패턴은 알고리즘을 캡슐화하는 경향이 잇다는 점에서 커맨드 패턴과 큰 차이를 보입니다.
스트래티지 패턴은 알고리즘을 바꿈으로써 객체가 같은 기능을 다른 방법으로 수행하기를 기대하는 반면, 커맨드 패턴에서는 커맨드를 바꾸면 객체가 객체의 기능을 바꾸기를 기대합니다.