본문 바로가기
프로그래밍/kotlin

[kotlin] 개발자를 위한 디자인 패턴 (Design Pattern) 에 대해서 알아보자 (1/3) # 생성 패턴

by dev_gyu 2024. 9. 30.
728x90

# 디자인 패턴이란?

디자인 패턴의 기본 정의는 소프트웨어 설계에서 반복적으로 발생하는 문제들을 해결하기 위해 만들어진 일반적인 해결 방안으로, 많은 개발자들이 실무에서 이용하고 인정하는 모범 사례를 의미한다.

여기에서 설명하는 디자인 패턴은 OOP (객체 지향 프로그래밍) 기준으로 삼으며 이를 참고 바란다.

 

디자인 패턴이 만들어진 이유

디자인 패턴이 만들어진 이유는 여러 가지가 있겠지만, 가장 큰 이유로 뽑히는 것 이유들은 다음과 같다.

 

1. 코드 구조의 표준화

코드 구조를 표준화하여 협업 시 다른 개발자가 동일한 문제를 동일한 방식으로 해결할 수 있게 하여 코드의 일관성을 유지하며 코드 리뷰와 유지 보수를 쉽게 만들어준다.

 

2. 문제 해결책 재사용 가능

표준화 된 코드 구조를 통해 비슷한 문제가 발생 시 성공적으로 해결된 다른 개발자의 방법을 찾아서 해결하기가 용이하다.

 

3. 개발 속도의 향상

팀 내 개발자들이 서로 다른 코드 구조를 가진 채 개발하는 것이 아닌, 일관적인 구조로 개발을 진행하므로 새로운 개발자는 코드 구조를 빠르게 이해하고 개발할 수 있으며 이미 정의된 패턴을 사용하므로 디버깅이 쉬워지기 때문이다.

 

4. 코드의 품질 향상과 안티 패턴 방지

경험이 적은 개발자들에게서 발생되는 잘못된 설계로 인한 안티 패턴이 검증된 디자인 패턴을 사용함으로써 방지되고, 코드의 결합도와 응집도를 낮춰 코드의 모듈화와 재사용성 증가에 도움이 되게 만들어준다.

 

# 디자인 패턴의 세 가지 주요 분류

1. 생성 패턴 (Creational Patterns)

객체 생성과 관련된 패턴으로 객체 생성 과정에서 생성 방식을 캡슐화하여 코드의 복잡성 및 객체 생성 방식을 유연하게 바꾸어 준다.

 

다음은 생성 패턴을 대표하는 5가지 패턴이다.

싱글톤 패턴

객체 생성 과정을 캡슐화하여 객체 생성에 유연성을 제공하고 코드의 복잡성을 줄이는 데 중점을 둔 디자인 패턴으로 특정 클래스의 인스턴스를 애플리케이션 내에서 하나만 생성하고, 그 인스턴스에 전역적으로 접근할 수 있도록 보장한다.

 

사용 예시

object Singleton {
    var data: String = "Test"

    fun printData() {
        println(data)
    }
}

fun main() {
    Singleton.data = "Dev Gyu!"
    Singleton.printData() // 출력: Dev Gyu!
}

 

위는 Singleton Pattern 을 이용하여 object 내에 객체를 생성하고 이를 캡슐화해 다른 곳에서 사용하도록 설정한 코드이다.

위의 코드처럼 object 키워드를 사용하여 Singleton 으로 구현한 클래스는 다른 곳들에서 접근이 가능하며, 객체는 단 하나만 존재하도록 보장이 가능해진다.

 

그렇다면 전역 변수와 Singleton Pattern 으로 구현한 객체와 다른 점이 무엇일까?

 

전역 변수와 Singleton Pattern 는 비슷한 부분이 많지만 메모리 관리 측면에서 차이가 크다.

전역 변수의 경우 객체가 할당되면 프로그램이 시작되는 즉시 객체가 생성되고, Singleton 키워드를 사용해 구현된 싱글턴 객체의 경우 프로그램에서 처음 접근할 때 메모리에 할당된다

 

// Test object 의 경우 사용이 안되니 메모리에 할당 x
object Test(){
    var a = "1"
}

// Test2 object 의 경우 a() 에서 print 를 통해 Test2.b 를 호출할때 메모리에 할당됨
object Test2(){
    var b = "2"
}

fun a(){
    print(Test2.b)
}

 

빌더 패턴

복잡한 객체의 생성 과정을 단계별로 나누어 처리하는 패턴으로 생성자에 많은 매개변수가 있거나, 객체 생성 과정에서 여러 단계가 필요할 때 유용하다.

 

사용 예시

class Person(
    var firstName: String?,
    var lastName: String?,
    var age: Int?,
    var address: String?
)

class PersonBuilder {
    private var firstName: String? = null
    private var lastName: String? = null
    private var age: Int? = null
    private var address: String? = null

    fun firstName(firstName: String) = apply { this.firstName = firstName }
    fun lastName(lastName: String) = apply { this.lastName = lastName }
    fun age(age: Int) = apply { this.age = age }
    fun address(address: String) = apply { this.address = address }

    fun build() = Person(firstName, lastName, age, address)
}

fun main() {
    val person = PersonBuilder()
        .firstName("Dev")
        .lastName("Gyu")
        .age(20)
        .address("Seoul")
        .build()

    println(person)
}

 

PersonBuilder 클래스 인스턴스를 생성한 후, 메소드에 접근하여 내부 프로퍼티를 하나씩 수정하고 이를 Person class 로 종합하여 Return 한다.

 

Builder Pattern 을 사용하는 것과 Class 를 직접 생성하는 것과의 차이가 무엇일까?

 

Class 를 직접 생성하는 경우 모든 인자를 전달해줘야 하지만, Builder Pattern 의 경우 필요한 인자만 수정하여 재생성 할 수 있음.

이로 인해 Person 클래스의 인스턴스 A 가 있다고 할때, age 를 24로 수정해야하는 경우 Builder Pattern 의 경우 A.age(24).build() 로 수정할 수 있지만, Builder Pattern 이 아니라면 A = Person(A.firstname, A.lastname .. ) 과 같이 불필요한 작업이 진행될 수 있다.

 

물론 Kotlin 에서는 data class 라는 좋은 클래스가 있으니, 이 클래스를 활용하면 copy() 와 명명인자를 활용해서 Builder Pattern 을 추가하지 않고도 사용이 가능하다.

 

Data Class 를 활용한 사용 예시

data class Person(
    var firstName: String?,
    var lastName: String?,
    var age: Int?,
    var address: String?
)

fun main() {
    var person = Person(
        firstName = "Dev",
        lastName = "Gyu",
        address = "Seoul",
        age = 20
    )
    person = person.copy(age = 24)
    println(person)
}

 

프로토 타입 패턴

기존 객체를 복제하여 새로운 객체를 생성하는 패턴으로 새롭게 객체를 생성할 때 큰 비용이 드는 경우 사용한다.
data class CloneTest(val name: String): Cloneable {
    var list = mutableListOf(10, 20)

    // 기본적으로 protected 이기에 외부에서 접근하기 위해서는 public 선언해줘야한다.
    public override fun clone(): CloneTest {
        return super.clone() as CloneTest
    }
}


fun clone_test() {
    val cloneTest1 = CloneTest("test")
    var cloneTest2 = cloneTest.clone()
    println(cloneTest2) // 출력 : CloneTest(name=test)
}
Data class 에서 제공하는 Copy() 메소드와의 차이점은 무엇일까?

 

Clone 메소드는 얕은 복사라는 방식을 수행하는데, 이는 객체의 필드 값들을 복사하되 참조형 필드에 대해서는 그 참조만을 복사하는 것이다.

Copy 는 깊은 복사의 개념과 비슷하여 새로운 인스턴스를 반환하여 원본 객체와는 독립적인 새로운 객체가 생성된다.

 

또한 위의 코드에서 CloneTest Class 에 존재하는 list 의 경우 참조형 필드 (배열, 리스트, 다른 객체) 이므로 clone 시 list 주소가 복사되어 cloneTest2 의 list 에 변화를 주면 cloneTest1 의 list 에도 변화가 생기지만, Copy 의 경우 아예 다른 인스턴스이기에 변화가 생기지 않는다.

 

추상 팩토리 패턴

서로 관련된 객체들을 생성하기 위한 인터페이스를 제공하여 클래스에서 처리하도록 요구하는 패턴
interface GUIFactory {
    fun createButton(): Button
    fun createCheckbox(): Checkbox
}

class WindowsFactory : GUIFactory {
    override fun createButton(): Button = WindowsButton()
    override fun createCheckbox(): Checkbox = WindowsCheckbox()
}

class MacOSFactory : GUIFactory {
    override fun createButton(): Button = MacOSButton()
    override fun createCheckbox(): Checkbox = MacOSCheckbox()
}

 

위의 코드와 서로 관련된 제품 (객체) 를 모아놓은 제품군 Interface 를 생성한 뒤, 클래스에서 이를 상속받아 구현한다.

 

추상 팩토리 패턴을 구현하면 공통적으로 쓰이는 주제를 사용하는. 즉, Interface 를 상속받는 클래스들에 관련된 객체 생성을 강제화함으로 일관성이 증가되고, 의존성 관계 역전 원칙에 따라 시스템 결합도가 낮아지는 장점이 존재한다.

 

하지만 장점만 존재하는 것은 아니다.

제품군이 많아질수록 클래스와 인터페이스를 추가적으로 생성해줘야 하는 만큼 코드가 방대해지고, 한 제품군 내에서 개별 제품을 추가 및 수정해야하는 경우 추상 팩토리와 모든 관련 클래스들을 수정해줘야하는 단점이 존재한다.

팩토리 메소드 패턴

객체 생성의 책임을 서브클래스에 위임하여 객체 생성의 구체적인 클래스를 클라이언트 코드에서 분리하는 패턴

 

팩토리 메소드 패턴은 어떤 클래스의 인스턴스를 생성할지를 결정하는 책임 Creator 에서 분리되며, ConcreteCreator 에서 이를 재정의되어 인스턴스를 반환하는 만큼 동작을 가진다.

Creator 와 Creator 는 팩토리 메소드 패턴의 중요한 구조중 일부인데, 팩토리 메소드 패턴의 구조는 다음과 같이 나눠진다.

  • Product : 생성될 객체의 인터페이스나 추상 클래스
  • ConcreteProduct - Product Interace 를 구현하는 클래스
  • Create - 객체 생성 메소드를 정의하는 클래스, 팩토리 메소드를 이곳에서 정의한다.
  • ConcreteCreator - Create 를 확장하여 활용하는 클래스

구현 예시

이 부분은 코드가 꽤나 길어지기에 코드 블럭 -> 이미지 + 설명으로 대체하겠음

  • MeatProduct 인터페이스 정의

  • 돼지고기 및 소고기 제품 클래스를 생성하고, MeatProduct 를 상속받아 price 를 각각 구현

 

  • Creator 추상 클래스 구현. 내부에 팩토리 메소드를 구현하고, 이를 활용할 메소드를 생성한다.

  • Creator 를 상속받는 ConcreteCreator 클래스를 생성하고, createProduct 를 override 하여 재정의해준다.
  • 재정의할 때 Return 값은 이전에 생성해두었던 ConcreteProduct class 를 Return 해주도록 설정한다.

  • 최종적으로 클라이언트 코드에서는 위와 같이 사용하고 싶은 클래스 인스턴스를 생성하고 메소드를 호출하면 된다.

위와 같이 책임이 분리되므로 확장성과 캡슐화가 뛰어나 새로운 제품이 생성될 경우에는 새로운 ConcreteProduct 를 구현, 객체 생성 방식이 변경될 때는 Creator 를 변경하면 된다는 장점이 존재한다.

단점으로는 클래스의 수 증가 및 복잡성이 증가한다는 단점이 존재한다.

728x90