Koltin으로 알아보는 GoF 디자인패턴(1) — 생성 패턴
Singleton, Factory Method, Abstract Factory, Builder, Prototype
1995년 GoF(Gang of Four)라고 불리는 Erich Gamma, Richard Helm, Ralph Johnson, John Vissides가 모듈의 세분화된 역할이나 모듈들 간의 인터페이스 구현 방식을 설계할때 참조할 수 있는 방식을 구체화하여, 이를 GoF 디자인패턴 이라고 부릅니다. 목적에 따라 분류할 시 생성 패턴 5개, 구조 패턴 7개, 행위 패턴 11개, 총 23개의 패턴으로 구성됩니다.
GoF 디자인 패턴을 분류하는 기준 두 가지가 있습니다. 바로 목적과 범위입니다.
- 목적에 따라 분류하면 생성, 구조, 행동 3가지로 구분 할 수 있습니다.
- 생성 패턴 - 객체의 생성 과정에 관여
- 구조 패턴 - 객체의 합성에 관여
- 행동 패턴 - 객체가 상호작용하는 방법, 관심사를 분리하는 방법에 관여
2. 범위에 따라 분류하면 패턴을 주로 클래스에 적용하는지, 객체에 적용하는 지 구분할 수 있습니다.
- 클래스 패턴 — 클래스와 서브클래스 간의 관련성을 다루며, 컴파일 타임에 정적으로 결정
- 객체 패턴 — 객체 간의 관련성을 다루고, 런타임에 동적으로 결정
이번 글은 생성패턴에 대해서만 다루겠습니다.
싱글톤 패턴
특정 클래스가 하나의 객체만 생성되도록 보장합니다. 싱글톤 클래스의 객체에 대한 모든 참조는 동일한 인스턴스를 가리킵니다.
앱 전반적으로 사용하는 클래스, 의존성 주입을 하기 위한 Hilt에서의 Module등 인스턴스가 여러개 일 때 문제가 생길 수 있는 경우가 있습니다. 인스턴스를 오직 한개만 만들어 제공하는 클래스가 필요할 때 이 패턴을 사용합니다. 또, 다른 디자인 패턴(빌더, 퍼사드, 추상 팩토리 등)의 구현체 일부로 쓰이기도 합니다.
팩토리 메소드 패턴 (Factory Method Pattern)
구체적으로 어떤 객체를 생성할 것인지는 서브클래스가 정하게 하는 패턴입니다.
MLB의 LA다저스 모자를 만들어서 판매했는데 사업이 대박이 났다고 가정해봅시다. 그럼 사업을 확장하여 뉴욕 양키스, 애틀란타 브레이브스와 같은 다른 팀의 모자도 제작하겠죠? 이를 코드로 표현한다면, Creator 클래스에 분기처리가 생길 것이고 만들 모자의 종류가 많아질수록 클래스는 점점 복잡해지게 될 것입니다. 이를 해결하고자 추상화 된 팩토리를 만드는 것이 팩토리 메소드 패턴입니다.
즉, 개체 생성 프로세스를 추상화하여 인스턴스화된 개체의 유형이 런타임에 결정되게 하는 패턴입니다. 경우에 따라선 Creator과 ConcreteCreator를 하나로 구현하기도 합니다.
아래 코드를 보면
- 팩토리 역할을 할 Creator 인터페이스를 정의한다.
- Product Interface를 반환하는 메소드를 Creator 인터페이스에 정의한다.
- ConcreateCreator 즉, Creator 인터페이스 구현 클래스에서 createProduct를 구현한다.
- 사용하는 쪽에서, Creator를 통해 Product를 얻는다.
와 같은 시나리오로 제작하는 것을 알 수 있습니다.
이렇게 Factory Method Pattern을 적용했을 경우의 장점과 단점은 무엇일까요?
장점으론 SOLID의 OCP 즉, 확장에는 열려있고 변화에는 닫혀있는 원칙을 지킨 코드를 작성했다는 것입니다.
토론토 블루제이스 브랜드가 추가됐을 경우를 가정해봅시다.
브랜드에 맞는 Product를 추가해주면 되고, Factory에 토론토 모자가 추가되었다는 것만 알려주면 됩니다.
data class TCap(override val brand: String) : Cap(brand) // 추가class CapFactory : GoodsFactory {
override fun create(brand: Brand): Cap = when (brand) {
/** ..생략.. */
Brand.T_BLUEJAYS -> TCap("Atlanta") // 추가 (추가 해주지 않아도 오류가 발생하지는 않는다... 토론토 블루제이스 모자를 만들지 못 할뿐...)
else -> throw IllegalArgumentException()
}
}
기능 확장에는 열려 있고 기존 코드에 변화는 없는 것을 알 수 있습니다.
단점으로는 하나의 Concrete Class에 분기처리를 하여 구현했을 때와 비교해서 많은 클래스들이 생성되었다는 점입니다.
하지만, 이는 장점이 될 수 있습니다. 하나의 클래스에 모두 구현이 되어있다면 다양한 Product 클래스를 알고 있어야 함으로 높은 결합도를 갖게 될 것입니다. 이를 여러 클래스로 나눠야 각 클래스가 결합도는 낮고 응집도가 높아진 클래스가 될 수 있습니다.
팩토리 메소드 패턴은 “서브클래스(Factory) 에게 어떤 객체를 생성할 것인지에 대한 권한을 위임하여, 확장에는 열려있고 변화에는 닫혀있는 OCP가 지켜지는 코드를 작성할 수 있게 하는 패턴이다” 로 결론지을 수 있습니다.
추상 팩토리 패턴 (Abstract Factory Pattern)
서로 관련있는 여러 종류의 객체를 생성해주는 인터페이스 입니다. Client는 추상화된 Factory를 알고 있고 Factory에 어떤 Product(객체)를 생성하고 싶은지 알려주면 원하는 객체를 얻을 수 있는 패턴입니다.
위의 클래스다이어그램을 보면 팩토리 메소드 패턴과의 차이는 Client가 생겼다는 점입니다.
원하는 객체를 Factory를 통해 얻는다라는 점에서 팩토리 메소드 패턴과 같지만, 팩토리 메소드 패턴이 “팩토리를 구현하는 방법”에 초점을 두었다면 추상 팩토리 패턴은 “구현한 팩토리를 어떻게 사용하는지”에 대해 초점을 둡니다.
추상 팩토리 패턴의 핵심은 클라이언트 코드에서 구체적인 클래스의 의존성을 제거하는 것입니다.
Product 타입으로 원하는 팩토리를 가져올 수 있고, 팩토리 메소드 패턴으로 제작한 팩토리로 원하는 객체를 가져올 수 있습니다.
팩토리 메소드 패턴에서 봤던 예시는 Client에서 특정 팩토리에 대한 의존성이 있었다면 추상 팩토리 패턴을 이용함으로써 특정 팩토리에 대한 의존성이 없어졌다는 것 을 볼 수 있습니다.
추상팩토리 패턴을 사용함으로써 얻을 수 있는 이점은 아래와 같습니다.
SOLID의 SRP와 OCP
- SRP : 각 클래스들의 응집도가 높아져 하나의 클래스는 하나의 기능을 담당하게 됩니다.
- OCP : 각 클래스를 추상화 해줌으로써 결합도가 낮아져, 확장에는 열려있고 변화에는 닫혀있는 구조가 됩니다.
빌더 패턴 (Builder Pattern)
복잡한 객체를 만드는 프로세스를 독립적으로 분리하기 위해 사용하는 패턴입니다.
우리가 기획사 사장님이라고 가정해봅시다. 그룹을 제작하려면 그룹이름을 정해야하고 멤버들의 이름과 가수명 나이를 알고 있어야하며 그룹과 소속사 이름을 연관시켜야 합니다.
data class Group(
val name: String,
val company: Company,
val members: List<Member>
)
코드로 위와 같이 표현을 할 수 있습니다.
사용자에서 Group을 생성하려면 어떻게 해야할까요?
val member1 = Member(name = "JeonJK", alias = "JungKook", year = 18)
val member2 = Member(name = "KimTH", alias = "V", year = 18)
val member3 = Member(name = "KimNJ", alias = "RM", year = 18, leader = true)
val member4 = Member(name = "ParkJM", alias = "JiMin", year = 18)
val member5 = Member(name = "MinYG", alias = "Sugar", year = 18)
val member6 = Member(name = "KimSJ", alias = "Jin", year = 18)val company = Company(name = "HYBE")val boyGroup =
Group(name = "BTS", company = company, members = listOf(member1, member2, member3, member4, member5, member6))
이런식의 코드가 작성될 것이고, member의 필요한 정보가 더 많거나 선택적으로 입력하고 싶은 값이 늘어나게 되면 nullable하게 생성자를 세팅해줘야 함으로 생성자가 복잡해지게 될 것입니다.
그럼 Builder 패턴을 사용해서 Builder 클래스에게 이 작업을 위임해봅시다!
결과적으로 사용자는 Director를 통해 원하는 결과 값을 얻을 수 있습니다.
안드로이드에서 예를 들어보면, Dialog를 빌더패턴을 적용하여 제공해주고 있습니다. title, message, positiveButton, nagativeButton 등 사용하고자 하는 기능만 Builder에 넘겨주면 됩니다.
빌더 패턴을 사용했을 때 장점은
- 멤버 변수가 많은 경우 다양한 생성자 생성이나 복잡한 생성자 생성을 피할 수 있습니다.
- 순서를 강제해서 값을 받을 수 있고, 필수적으로 받는 값과 선택적으로 받는 값을 가독성이 좋게 분산해서 코드를 짤 수 있습니다.
- 복잡한 객체를 만드는 복잡한 과정을 뒷단에서 하고 앞단에서는 생성해줘! 라는 코드만 사용할 수 있습니다.
- 확장성이 높은 코드를 작성할 수 있습니다.
- 불완전한 객체를 생성할 수 없게 안전장치를 만들 수 있습니다. Builder를 통해 객체를 생성할 때 마지막으로 호출하는 함수에 최종 객체가 완전한지 확인해보는 코드를 작성할 수 있기 때문입니다.
단점은 클래스가 많아집니다. Builder 패턴은 기능을 확장하려 하면 Builder 클래스도 수정해 줘야하는 단점도 있어 보입니다.
결과적으로 복잡한 생성자가 생겨야 하거나 복잡한 로직이 들어가는 클래스라면 빌더패턴으로 가독성이 높은 코드를 작성할 수 있어보여, 협업을 통해 프로젝트를 제작할 경우, 빌더패턴의 사용범위를 잘 정의한다고 한다면 가독성이 좋은 클린한 코드를 작성할 수 있다고 생각됩니다.
프로토타입 패턴(Prototype Pattern)
기존 인스턴스를 복제하여 새로운 인스턴스를 만드는 패턴입니다.
Kotlin에서 프로토타입 패턴을 직접 구현할 필요는 없습니다. data class에서 제공해주는 copy 메소드를 사용하면 되기 때문이죠… (Java에서도 Clonable 인터페이스를 구현하여 clone 메소드를 사용하면 됩니다.)
Client는 Prototye 인터페이스의 clone메소드를 사용하여 구현 클래스에 구현되어있는 복사 로직을 수행하게 된다 정도만 알고 있으면 될 것 같습니다.
이보다 중요한 것은 깊은복사, 얕은복사, 방어적복사에 대해 아는 것입니다.
이 세가지 복사에 대해 잘 정리해둔 블로그가 있어 아래에 링크를 남겨놓겠습니다.
여기까지 GOF 디자인패턴의 생성패턴에 대해 알아봤습니다.
다음 글에서는 구조 관련 디자인패턴에 대해서 알아보겠습니다.