BoostStudy / design-patterns

디자인 패턴의 아름다움 스터디 저장소
6 stars 0 forks source link

CHAPTER 6 생성 디자인 패턴 #6

Closed lee-ji-hoon closed 5 months ago

lee-ji-hoon commented 5 months ago

6-1. 싱글턴 패턴 (1) 생각해보기

public class Demo {
    private UserRepo userRepo;  // 생성자  또는 IoC 컨테이너를 통한 의존성 주입

    public boolean validateCachedUser(long userId) {
        User cachedUser = CacheManager.getInstance().getUser(userId);
        User actualUser = userRepo.getUser(userId);
        // 주요 로직 생략
    }
}

CacheManager가 싱글턴 패턴인데, 리팩터링 시 코드의 변경을 최소화하면서 테스트 용이성을 향상 시키는 방법이 무엇이 있을까?

6-2. 싱글턴 패턴 (2) 생각해보기

Java의 경우 엄밀히 말하면 싱글턴 패턴의 유일성의 범위는 프로세스 안이 아닌 클래스 로더 안에 있다. 왜 그런지 생각해보자.

6-3 팩터리 패턴 (1) 생각해보기

  1. 팩터리 패턴은 보편적인 디자인 패턴인데, 팩터리 패턴을 사용중인 라이브러리들을 찾아보고 그 예시를 찾아보자.
  2. 단순 팩터리 패턴은 객체를 생성하는 메서드가 정적 메서드이기 때문에 정적 팩터리 메서드 패턴이라고 한다. 이와 같이 객체를 생성하는 메서드가 정적이어야 하는 이유와 이때 코드의 테스트 용이성에 어떤 영향을 미치는가?

6-4 팩터리 패턴 (2) 생각해보기

BeansFactroy 클래스의 createBean() 함수는 재귀 함수인데, 이 함수의 매개변수가 ref 유형이면 ref 속성이 가리키는 객체를 재귀적으로 생성한다. 설정 파일에서 객체 사이의 의존성을 잘못 설정하여 의존성이 순환하게 된 경우, BeansFactory 클래스의 createBean() 함수에 스택 오버 플로가 발생할 가능성이 있는지 생각해보고, 가능성이 있다면 해결방법이 무엇일까

6-5 빌더 패턴 생각해보기

public class A {
    private boolean isRef;
    private Class type;
    private Object arg;
    // TODO 개선해보자
}

이런 상황에서 isRef가 true이면 arg를 설정해야 하지만, type은 설정할 필요가 없고, isRef가 false이면 arg와 type 모두 설정해야 하는데 이런 요구 사항에서 위 클래스를 어떻게 개선해야 할까?

6-6 프록시 패턴 생각해보기

lee-ji-hoon commented 5 months ago

싱글턴

간만에 테스트 코드까지 작성을 해보게 됐다.

책에 나와있는 Singleton 자바 코드를 그대로 Kotlin으로 바꾸기 보다는 싱글톤을 구현한다면 지금 내 지식으로는 어떻게 구현할까? 만 중점으로 두고 구현을 했다.

그 결과 object, lazy dobule-check방식 3가지로 구현을 했다.

// SingletonSample은 테코 이쁘게 짜려고 만든 interface다.
object ObjectSample : SingletonSample {
    override var count = 0
}

class LazyLoadingSingletonSampleSample private constructor() : SingletonSample {

    override var count = 0

    companion object {

        private val lazyLoadingSingletonSample by lazy { LazyLoadingSingletonSampleSample() }

        fun getInstance(): LazyLoadingSingletonSampleSample {
            return lazyLoadingSingletonSample
        }
    }
}

class DoubleCheckSingletonSample private constructor() : SingletonSample {

    override var count: Int = 0

    companion object {

        @Volatile
        private var instance: DoubleCheckSingletonSample? = null

        fun getInstance() = instance ?: synchronized(this) {
            instance ?: DoubleCheckSingletonSample().also { instance = it }
        }
    }
}

셋 다 장단점이 있는데, object는 이미 익숙할테고, double-check는 책에서 나왔으니 lazy의 내부 코드를 살짝 보면 이런 특징이 있다.

image

mode를 정하지 않으면 기본적으로 synchronized 되게 double-check을 하고 있는 모습이다.

그렇기에 우린 직접 double-check를 구현할 필요가 없다는 것을 알 수 있다.

그럼 object / by lazy 무엇을 선택해야 할까

정답은 없는거 같은데 생각해보면 이런 기준을 둘거 같다.

만약 내부에서 Hilt/Coin 같은 DI를 하게 될 경우 object는 불가능하지만 by lazy를 사용해서 만든 singleton의 경우 필드 주입이 가능하기 때문에 만약 주입이 필요한데 singleton을 만들어야 한다면 by lazy를 선택할 것 같다.

하지만 책에 나와있듯이 싱글톤 패턴의 단점은 클래스 간의 의존성을 감춘다. 내용도 있어서 지양해야 할 것 같긴한다.

싱글턴 패턴 (1) 생각해보기

CacheManager에 대한 직접적인 싱글톤 접근 대신, 이를 인터페이스로 추상화하고 생성자나 setter 등을 통해 주입받을 수 있도록 변경하는게 가장 좋아보인다.

public class Demo {

    private final UserRepo userRepo; // Hilt 혹은 Kotin 사용해서 주입
    private final CacheManager cacheManager; // Hilt 혹은 Kotin 사용해서 주입

    public boolean validateCachedUser(long userId) {
        User cachedUser = cacheManager.getUser(userId);
        User actualUser = userRepo.getUser(userId);
        // 주요 로직 생략
    }
}

싱글턴 패턴 (2) 생각해보기

클래스 로더는 런타임 중에 JVM의 메소드 영역에 동적으로 Java 클래스를 로드하는 역할을 한다

클래스 로더

https://docs.oracle.com/javase/specs/jls/se17/html/jls-12.html#jls-12.2 참고

Java 클래스 및 인터페이스 로딩 과정

Java에서 클래스 및 인터페이스 로딩은 특정 이름의 클래스나 인터페이스의 바이너리 형태를 찾고, 이를 Class 객체로 구성하는 과정이다.

클래스 로딩 과정

클래스 로더 일관성

결론

Java의 클래스 로딩 메커니즘은 시스템의 타입 안전성을 보장하며, 클래스 로더의 일관성과 정확한 로딩 과정을 통해 Java 프로그래밍 언어의 런타임 타입 시스템이 코드에 의해 침해될 수 없도록 설계가 되어 있다.

클래스로더가 진짜로 1번만 실행이 되는지 확인해보자.

image

위와 같은 경우 어떻게 로그가 찍힐지 생각해보면 2가지 경우가 있다.

  1. Single의 init > companion의 init이 10번 반복
  2. companion의 init은 1번 / Single의 init이 10번 반복

클래스 로더의 일관성 및 정확한 로딩이 보장되기 위해서는 2번으로 나와야 한다.

image

실제로 위와 같은 결과가 나왔으며, 클래스 로더는 진짜로 일관성을 보장한다는 것을 알 수 있다.

클래스에서 어느 경우에 로드가 되는지 확인

이거는 블로그 에서 너무 잘 설명해주고 있어서 이 내용을 읽어보면 좋을거 같은데 자바 기준이니까 직접 해보자.

  1. kotlinc Main.kt -include-runtime -d Main.jar / / 코틀린 소스파일을 컴파일해서 Java 바이트코드로 변환
  2. kotlin -classpath Main.jar MainKt // Jar 파일 실행
  3. java -verbose:class -classpath Main.jar MainKt // 클래스 로딩 정보 출력

image

요소 클래스 로드 여부
const val - static final 로드되지 않음
일반 val/var - static 로드됨
일반 fun - static 로드됨
@JvmStatic fun - static final 로드됨

이런 결과가 나오는데 정리해보자면 아래와 같은 경우에 로딩이 되는 것으로 보인다.

여기서 @JvmStatic은 decompile 해보면 static final 임에도 불구하고 로드가 되는데, @JvmStatic의 경우 내부적으로 싱글톤 인스턴스를 통해 접근을 하는 것으로 기억을해서 클래스 로딩을 강제로 유도하게 되는거 같다.

즉 클래스가 실제로 로드(초기화) 하는 역할을 클래스 로더가 하는 것이기 때문에 유일성의 범위는 프로세스 보다 클래스 로더 라는 말이 확실히 맞아보인다.

싱글턴 후기

팩터리 패턴

팩터리 패턴 (1) 생각해보기

class MyViewModel(
    private val myRepository: MyRepository,
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {
    // ViewModel logic

    // Define ViewModel factory in a companion object
    companion object {
        val Factory: ViewModelProvider.Factory = viewModelFactory {
            initializer {
                val savedStateHandle = createSavedStateHandle()
                val myRepository = (this[APPLICATION_KEY] as MyApplication).myRepository
                MyViewModel(
                    myRepository = myRepository,
                    savedStateHandle = savedStateHandle
                )
            }
        }
    }
}

class MyActivity : AppCompatActivity() {

    private val viewModel: MyViewModel by viewModels { MyViewModel.Factory }

}

우리가 사용하는 ViewModel을 관리해주는 ViewModelProvider 가 아주 보편적인 예시로 보인다. (이름 부터 Factory라고 아예 떡하니 명시 중)

이런 팩터리 패턴을 사용하는 이유는 우선 추상화와 유연성이 뛰어나서 선택을 하게 된거 같다. ViewModel은 여러 View에서 재사용될 수도 있는데, 팩토리를 사용해서 의존성을 주입해주기도 하며, 재사용성이 뛰어나다는 장점이 있어서 사용을 하게 되는거 같다.

팩터리 패턴 (2) 생각해보기

createBean() 함수가 재귀적으로 ref 유형의 객체를 생성하는 과정에서 순환 참조가 존재하는 경우 스택 오버플로가 발생할 가능성이 있다.

순환 의존성이란, 객체 A가 객체 B를 필요로 하고, 동시에 객체 B가 직접적이거나 간접적으로 객체 A를 필요로 하는 상황을 의미

빌더 패턴

빌더 패턴 생각해보기

public class A {
    private boolean isRef;
    private Class<?> type;
    private Object arg;

    // Ref 타입일 때 사용할 생성자
    public A(boolean isRef, Object arg) {
        if (!isRef) {
            throw new IllegalArgumentException("This constructor is for ref types only.");
        }
        this.isRef = isRef;
        this.arg = arg;
        this.type = null; // Ref 타입인 경우 type은 무시
    }

    // Non-ref 타입일 때 사용할 생성자
    public A(boolean isRef, Class<?> type, Object arg) {
        if (isRef) {
            throw new IllegalArgumentException("This constructor is for non-ref types only.");
        }
        this.isRef = isRef;
        this.type = type;
        this.arg = arg;
    }
}
class A private constructor(
    val isRef: Boolean,
    val type: Class<*>?,
    val arg: Any?
) {
    companion object {
        // Ref 타입을 위한 팩토리 메서드
        fun createRef(arg: Any) = A(isRef = true, type = null, arg = arg)

        // Non-ref 타입을 위한 팩토리 메서드
        fun createNonRef(type: Class<*>, arg: Any) = A(isRef = false, type = type, arg = arg)
    }
}

이렇게 2개로 개선이 가능할거 같다.
java 에서는 생성자를 2개로 구현 / kotlin은 기본 생성자를 private으로 막고 2개의 생성자 함수로 관리

프로토타입

프로토타입 생각해보기 - 1

키워드의 삭제도 지원해야 한다면 어떻게 구현을 해야 할까?

수정할 때 제거 하고 삽입을 하는데 제거 까지만의 동작을 하나의 함수로 만들어서 관리하면 되지 않을까?

프로토타입 생각해보기 - 2

ShoppingCart 클래스의 getItems() 메서드를 통해 items 컬렉션을 얻으며, 컬렉션 내의 ShoppingCartItem 개별 객체의 데이터를 수정할 수 있기 때문에 이 설계에 문제가 있는데 이 문제를 어떻게 해결할 수 있을까?

public final class ShoppingCartItem {
    private final String productId;
    private final double price;

    public ShoppingCartItem(String productId, double price) {
        this.productId = productId;
        this.price = price;
    }

    // Getter 메서드만 제공
    public String getProductId() {
        return productId;
    }

    public double getPrice() {
        return price;
    }

    // 변경자 메서드는 제공하지 않음
}

public class ShoppingCart {
    private List<ShoppingCartItem> items = new ArrayList<>();

    public List<ShoppingCartItem> getItems() {
        // 깊은 복사본을 만들어서 반환
        return items.stream()
                    .map(item -> new ShoppingCartItem(item.getProductId(), item.getPrice()))
                    .collect(Collectors.toList());
    }

    // ShoppingCartItem 객체를 업데이트하는 메서드
    public void updateItem(String productId, double newPrice) {
        // ...
    }
}
ldh019 commented 5 months ago

싱글턴 생각해보기 1

아마 CacheManager에 대한 이야기를 하는 것 같은데, UserRepo 처럼 의존성 주입을 하면 되지 않을까? 우리에겐 Hilt가 있으니까!

싱글턴 생각해보기 2

클래스 로더가 클래스에 있는 변수들을 초기화하고 메모리에 올리는거니까, 생성되는 객체들은 실제로는 클래스 로더의 범위 안에 있다고 말할 수 있을 것 같다.

팩토리 생각해보기 1

1) Primitive 타입의 valueOf 가 단순 팩터리 패턴으로 구현이 되어 있다. 몰랐네…

2) 해당 객체를 만들기 위해서 팩터리 클래스 객체 없이도 생성할 수 있기 때문이다. 테스트에는.. 잘 모르겠는데…

yangsooplus commented 5 months ago

팩토리 패턴 - 1

  1. -
  2. 잘 모르겟는데,,, 정적 팩토리 메서드 패턴 관련 포스팅을 읽어봐도 테스트 용이성에 대한 영향은 감이 안옴

팩토리 패턴 - 2

BeanDefinitionargsBeanDefinition에 의존성을 가지고 있는 argument가 있다면 계속 재귀가 일어나 오버플로가 발생할거여 흠 의존성 방향이 단방향이 될 수 있도록 설계해야겠지

빌더 패턴

마지막에 build() 메서드로 인스턴스를 만들 때, isRef가 false일 때 type이 설정되었는지 유효성 검사를 해서 조건에 맞는지 검사하기? 근데 애초에 이걸 같은 Class로 했어야 했는지부터 의문인 요구사항인걸

프로토타입 패턴

  1. 수정할 때에 얕은 복사 -> 수정할 데이터 삭제 -> 수정할 데이터로 삽입 -> 객체 교체 했었으니 데이터 삽입하는 과정만 빼면 된다
  2. ShoppingCartItem의 setPrice 메서드를 제거하고 가격 속성을 불변으로 수정한다. ShoppingCart에 카트 속 아이템을 수정하는 메서드를 만들고, 프로토타입 패턴을 적용하면 되겠지 ShoppingCart.editItem(index: Int, item: ShoppingCartItem) 머 이렇게 만들어서 list의 index 번째 아이템을 수정할 ShoppingCartItem 인스턴스로 교체해버려~
audxo112 commented 5 months ago

팩터리 생각해보기

Calendar

// 1. Provider 를 이용해서 객체 생성
CalendarProvider provider =
    LocaleProviderAdapter.getAdapter(CalendarProvider.class, aLocale)
                         .getCalendarProvider();
if (provider != null) {
    try {
        return provider.getInstance(zone, aLocale);
    } catch (IllegalArgumentException iae) {
        // fall back to the default instantiation
    }
}

Calendar cal = null;
// 2. Extension 을 이용한 객체 생성
if (aLocale.hasExtensions()) {
    String caltype = aLocale.getUnicodeLocaleType("ca");
    if (caltype != null) {
        cal = switch (caltype) {
            case "buddhist" -> new BuddhistCalendar(zone, aLocale);
            case "japanese" -> new JapaneseImperialCalendar(zone, aLocale);
            case "gregory"  -> new GregorianCalendar(zone, aLocale);
            default         -> null;
        };
    }
}
// 3. locale 의 language 값을 이용한 객체 생성
if (cal == null) {
    // If no known calendar type is explicitly specified,
    // perform the traditional way to create a Calendar:
    // create a BuddhistCalendar for th_TH locale,
    // a JapaneseImperialCalendar for ja_JP_JP locale, or
    // a GregorianCalendar for any other locales.
    // NOTE: The language, country and variant strings are interned.
    if (aLocale.getLanguage() == "th" && aLocale.getCountry() == "TH") {
        cal = new BuddhistCalendar(zone, aLocale);
    } else if (aLocale.getVariant() == "JP" && aLocale.getLanguage() == "ja"
               && aLocale.getCountry() == "JP") {
        cal = new JapaneseImperialCalendar(zone, aLocale);
    } else {
        cal = new GregorianCalendar(zone, aLocale);
    }
}
return cal;

DateFormat

private static NumberFormat getInstance(
    LocaleProviderAdapter adapter,
    Locale locale, Style formatStyle,
    int choice
) {
    NumberFormatProvider provider = adapter.getNumberFormatProvider();
    return switch (choice) {
        case NUMBERSTYLE   -> provider.getNumberInstance(locale);
        case PERCENTSTYLE  -> provider.getPercentInstance(locale);
        case CURRENCYSTYLE -> provider.getCurrencyInstance(locale);
        case INTEGERSTYLE  -> provider.getIntegerInstance(locale);
        case COMPACTSTYLE  -> provider.getCompactNumberInstance(locale, formatStyle);
        default            -> null;
    };
}
audxo112 commented 5 months ago

정적 팩터리 메서드

각각 상황에 따른 코드

정적이여야 하는 이유

테스트 용이성에 미치는 영향

audxo112 commented 5 months ago

직접 DI 구현 해보기

className 대신 id 를 생성key 로 하고 Container 까지 구현하면 진짜 DI 가 될 것 같다

DIFactory

main

image

실행 결과

image

ldh019 commented 5 months ago

빌더 패턴 생각해보기

true이면 애초에 생성자에 type을 입력하지 않을테니까 생성자를 두개 쓰면 되지 않을까요?

프로토타입 패턴 생각해보기

  1. 삭제한 키워드라면 getSearchWords 함수에서 해당 컴포넌트가 비어있는 SearchWord 같은걸 주지 않을까요..? 그런 경우에 put을 안한다거나 하면 될거 같아요
  2. ShoppingCartItem을 그냥 immutable로 주면 될거 같아요. setter를 private으로 막아버리자