티스토리 뷰

Backend

설계란 고민의 연속이다 2편

지마켓 김윤제 2024. 4. 4. 23:34

안녕하세요 VI Engineering 팀 김윤제입니다.

Gmarket Mobile Web Vip(View Item Page = 상품 상세)를 담당하고 있는 Backend Engineer 입니다.

 

예전부터 쓰고자 한 내용들이 많았는데 귀차니즘이 심해서 이렇게 한 번에 몰아서 쓰게 되네요.

이번 편은 지난 편 설계란 고민의 연속이다 1편에 이어 2편입니다.

 

https://dev.gmarket.com/104

1편을 보신 후에 2편을 보시는 게 많은 이해가 될 것입니다. (안 보시면 이해가 안 갈 수도 있어요)

자세한 이야기는 아래에서 상세히 다루도록 하겠습니다.

 


최선은 무엇인가!

어떤 설계가 좋은 설계인지 모듈 설계를 AS-IS와 비교하며 하나씩 살펴보도록 하겠습니다.

  • 배송 모듈
    지마켓에는 배송 타입이 스마일 배송, 스마일 프레시, 당일 배송, 일반 통계 배송, 설치 예약이 있습니다.
    각 배송 정보를 구하는 로직은 다르지만 유일한 공통점은 배송비 로직이 같다는 것입니다.
    AS-IS의 방식 v1, v2와 신규 플랫폼 모듈에서 나아갈 방향 TO-BE 버전을 살펴보겠습니다.

[AS-IS V1]

[C#]

public Ship getShipping(String shippingType) {
    if (shippingType == ShippingType.SMILE) {

    }
    else if(shippingType == ShippingType.SMILE_FRESH) {

    }
    else if(shippingType == ShippingType.EXPRESS) {

    }
    else if(shippingType == ShippingType.GENERAL_STATISTICS) {

    }
    else if(shippingType == ShippingType.INSTALL) {

    }
}

if문의 향연이 펼쳐집니다.

이런 상황에서 배송 타입이 지속적으로 더 추가된다고 해보겠습니다.

제트기 배송, 위성 배송이라고 가정해보겠습니다.

저 밑에 추가적으로 else if 가 추가 될 것입니다.

또한 그 else if 문 안에 비즈니스 로직이 들어갈 것입니다.

하지만 이게 전부가 아닙니다

이런 문제도 있습니다.


Rider의 한계?

Rider는 인텔리제이와 동일한 회사 제트브레인에서 만든 개발 툴입니다.

Rider의 한계를 경험하신 분이 계실지 궁금합니다.

저런 식으로 하나의 클래스 안에서 if문의 향연을 펼칠 시 코드 라인이 매우 길어져

Rider가 코드 컴파일을 못한다는 점입니다.

(물론 이런 케이스를 발견한 분은 얼마 안 계실 겁니다..)

오류 나도 못 잡습니다. 자동완성도 안됩니다. 색깔도 인식을 못합니다.

 

왼쪽[문제]-오른쪽[정상]

 

이게 끝이 아닙니다.

최악은 하나의 파일 안에서 저렇게 무한 if문을 태운다면 코드 라인도 엄청나게 길어질 뿐만 아니라

비슷한 기능에 대한 협업이라도 할 시에 Commit Merge 전쟁이 발발합니다.

매 기능 수정 할 때마다 Merge시 30분은 소요됩니다.

즉, 유지보수가 굉장히 어려워집니다.

 

[AS-IS V2]

현재 저는 이것을 개선했습니다. 아래에 블로그는 제가 개선했을 당시의 내용입니다. (전부 적혀있지는 않습니다.)

https://dev.gmarket.com/97

그렇다면 개선 버전은 어떨까요?

[C#]

public class Main() {
    var expressShipping = Task.Run(() =>
    {                  
        var expressShipping = expressShipping.getShipping(goodsData);
        return expressShipping;
    });
    var smileShipping = Task.Run(() =>
    {                  
        var smileShipping = smileShipping.getShipping(goodsData);
        return smileShipping;
    });
     var smileFreshShipping = Task.Run(() =>
    {                  
        var smileFreshShipping = smileFreshShipping.getShipping(goodsData);
        return smileFreshShipping;
    });
    ~~

}

[C#]

public class ExpressShipping() {

    public Ship getShipping(Data data) {
        if (data.Type != Type.Express) return null;
        ~
    }
}

[C#]

public class SmileShipping() {

    public Ship getShipping(Data data) {
        if (data.Type != Type.Smile) return null;
        ~
    }
}

[C#]

public class SmileFreshShipping() {

    public Ship getShipping(Data data) {
        if (data.Type != Type.SmileFresh) return null;

           ~
    }
}

각각의 배송 타입의 클래스에서는 자신이 맡은 역할만 하는 것입니다.
AS-IS V1보다는 낫다고 말할 수 있지만 나름대로 단일 책임 원칙을 지켜보려고 했지만
이게 최선이냐고 물어본다면 고개를 숙일 것입니다.


그 이유는 하나의 Class파일에서 if/else문으로 처리하던 로직을
각각의 클래스 파일을 만들어 각자의 기능을 갖게 하는 것이 목적이었지만


여전히 Task.Run을 실행하는 부분은 Conflict의 위험성이 남아있으며
필요하지 않은 기능을 null을 반환받더라도 전부 수행해야 하기 때문입니다.

 

이제 To-Be를 살펴보도록 하겠습니다.

앞서 말씀드린 것처럼 배송 타입별로 로직은 다르지만 유일하게 공통점이 배송비 로직이 같다는 점을 생각했을 때

딱 떠오른 게 있습니다. 아마도 여기까지 읽으셨을 때 아 ~? 하며 눈치채셨을 수도 있습니다.

 

맞습니다. 바로 템플릿 메소드 패턴을 적용하는 것입니다.

간단하게 템플릿 메소드 패턴을 설명 드리자면 부모 클래스에서 공통된 로직을 수행하고

자식 클래스에서 각자 필요한 구현을 하는 것입니다.

 

이를 배송 모듈에 적용했을 때 부모 클래스에서 공통 로직인 배송비 로직을 구현하고,

자식 클래스에서 배송 타입별로 배송 정보를 구하는 것입니다!

템플릿 메소드 패턴에 대해서 자세한 설명을 원하신다면 아래 페이지를 참조하시면 이해에 도움이 될 것입니다.

https://engineering.linecorp.com/ko/blog/templete-method-pattern

 

템플릿 메서드 패턴으로 모순 없는 상태 보장하기

시작하기 전에 안녕하세요. LINE Pay의 iOS 개발을 맡고 있는 정지인입니다. LINE Pay iOS의 결제 기능을 리팩토링하는 데에 적용했던 템플릿 메서드 패턴을 이용한 계약 기반 프로그래밍 기법에 대해

engineering.linecorp.com

 

[TO-BE]

1편을 건너뛰셨다면 왜 중첩클래스에서 App, Mweb이 존재하는지 의문 일 수 있습니다.

반드시 1편을 읽어주셔야 이해가 되실 거예요!!

class CommonShippingDomainService {
    class App: ShippingDomainService {
        suspend fun getShipping(data: Data): Result {
            val fee = getShippingFee(data); //공통 배송비 로직 호출
            val shipping = getConcreteShipping(data) //각자 자식 클래스에서 구현
            }

        suspend fun getShippingFee(data: Data): Fee {
            공통 배송비 로직   
        }
    }

    class MWeb: ShippingDomainService {
        suspend fun getShipping(data: Data): Result {
            val fee = getShippingFee(data);
            val shipping = getConcreteShipping(data)
            }

        suspend fun getShippingFee(data: Data): Fee {
            공통 배송비 로직   
        }
    }
}
interface ShippingDomainService {
    suspend fun isMatchItemType(itemType: TypeEnum.ItemType): Boolean
    suspend fun isMatchPlatformType(platformType: PlatformType): Boolean
    suspend fun getConcreteShipping(data: Data): Result
}
class SmileShippingDomainServiceImpl {
    class App: CommonShippingDomainService.App() {
        suspend fun isMatchItemType(itemType: TypeEnum.ItemType): Boolean {
            return itemType == ItemType.Smile_DELIVERY
        }
        suspend fun isMatchPlatformType(platformType: PlatformType): Boolean {
            return platFormType == PlatformType.APP
        }
        suspend fun getConcreteShipping(data: Data): Result {
            ~
        }
    }

    class MWeb: CommonShippingDomainService.MWEB() {
        suspend fun isMatchItemType(itemType: TypeEnum.ItemType): Boolean {
            return itemType == ItemType.Smile_DELIVERY
        }
           suspend fun isMatchPlatformType(platformType: PlatformType): Boolean {
            return platFormType == PlatformType.MWEB
        }
        suspend fun getConcreteShipping(data: Data): Result {
            ~
        }
    }
}
class SmileFreshShippingDomainServiceImpl {
    class App: CommonShippingDomainService.APP() {
        suspend fun isMatchItemType(itemType: TypeEnum.ItemType): Boolean {
            return itemType == ItemType.Smile_FRESH
        }
        suspend fun isMatchPlatformType(platformType: PlatformType): Boolean {
            return platFormType == PlatformType.APP
        }
        suspend fun getConcreteShipping(data: Data): Result {
            ~
        }
    }

    class MWeb: CommonShippingDomainService.MWEB() {
        suspend fun isMatchItemType(itemType: TypeEnum.ItemType): Boolean {
            return itemType == ItemType.Smile_FRESH
        }
           suspend fun isMatchPlatformType(platformType: PlatformType): Boolean {
            return platFormType == PlatformType.MWEB
        }
        suspend fun getConcreteShipping(data: Data): Result {
            ~
        }
    }
}
class ExpressShippingDomainServiceImpl {
    class App: CommonShippingDomainService.APP() {
        suspend fun isMatchItemType(itemType: TypeEnum.ItemType): Boolean {
            return itemType == ItemType.EXPRESS
        }
        suspend fun isMatchPlatformType(platformType: PlatformType): Boolean {
            return platFormType == PlatformType.APP
        }
        suspend fun getConcreteShipping(data: Data): Result {
            ~
        }
    }

    class MWeb: CommonShippingDomainService.MWEB() {
        suspend fun isMatchItemType(itemType: TypeEnum.ItemType): Boolean {
            return itemType == ItemType.EXPRESS
        }
           suspend fun isMatchPlatformType(platformType: PlatformType): Boolean {
            return platFormType == PlatformType.MWEB
        }
        suspend fun getConcreteShipping(data: Data): Result {
            ~
        }
    }
}

이렇게 개발했을 때 다음 요청으로 제트기 배송, 위성 레이저 배송, 드론 배송이 추가된다고 해보겠습니다.

어떨까요? 네 맞습니다

 

각 타입에 맞는 클래스를 만들어서 PlatFormType에 따라

CommonShippingDomainService.App 또는 CommonShippingDomainService.MWeb을 상속받고

자식 클래스의 구현만 해주면 됩니다.

 

이렇게 된다면 동료와 협업을 할 때 같은 파일을 수정하지 않아도 되기 때문에 형상 충돌의 위험성도 적습니다.

또한 타입 별로 클래스가 나뉘어져 있기 때문에 비즈니스 로직 관리에 용이합니다.


끝으로

하나의 블로그에서 많은 내용을 담으려고 했는데 쓰다 보니 한 가지 예시만 드는데도 엄청나게 길어지네요.

아무래도 내용이 길어지다 보면 안 보는 게 사람 심리다 보니 적당히 끊어가며 다음 편에서 이어가려고 합니다.

 

오늘 보신 내용은 어떠셨을까요?

AS-IS V1, AS-IS V2을 보며 혀를 둘렀을 수도, TO-BE를 보며 이게 최선이야? 라고 하실 수도 있습니다.

V2를 저렇게 밖에 못했던 것은 상황이 여의치 않았기 때문입니다.

TO-BE 또한 한정된 시간 속에서 밤 낮 없이 고민하며 만든 결과이지만 최선일지는 모르겠습니다.

 

부족하지만 항상 최선의 결과를 도출하기 위해 끊임없이 노력하겠습니다.

긴 글 읽어주셔서 감사합니다.

감사합니다.

댓글