티스토리 뷰

Backend

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

지마켓 김윤제 2024. 3. 14. 20:43

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

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

 

저는 현재 기존의 Gmarket Mobile App VIP API 시스템과 Mobile Web VIP API 시스템을

통합 & 개편하는 VIP 모듈화 프로젝트를 진행하고 있으며, 그중 모듈 비즈니스를 담당하고 있습니다.

 

이번 편에서는 제가 셀 수 없이 많은 시간 동안 고민한 비즈니스 설계를 소개하려고 합니다.

(하도 고민을 많이 해서 탈모가 생겼다는 썰이..)

자세한 내용은 아래에서 설명하도록 하겠습니다.

 


Hexagonal Architecture

우선 프로젝트 구조를 먼저 설명드리려고 합니다.

모듈 비즈니스는 Multi Module 프로젝트입니다.

itembiz라는 프로젝트 내에 api, core, infra 모듈은 각각의 역할이 있습니다.

  • api : front에 요청을 받아 core 모듈에 비즈니스 로직을 수행할 객체를 찾아서 요청 후 결과 front에 응답
  • core : 순수 비즈니스 로직을 처리
  • infra : DB, API 등 외부와의 연동을 처리

여기에 팀원 분께서 헥사고날 아키텍쳐 (Hexgonal Architecture)를 제안 주셔서 적용을 하였습니다.

헥사고날 아키텍쳐의 핵심은 Core 비즈니스 로직을 보호하자입니다.

 

이 블로그에서는 헥사고날 아키텍쳐를 설명하기 위함이 아니니

자세한 설명은 패스하겠습니다.

 

궁금하신 내용은 제가 참고했던 자료들을 보시면 이해에 도움이 될 것입니다.

참고

https://blog.allegro.tech/2020/05/hexagonal-architecture-by-example.html

https://engineering.linecorp.com/ko/blog/port-and-adapter-architecture\


Mobile App, Web Response Model

본격적으로 제가 고민해야만 했던 내용에 대해서 설명하려고 합니다.

그 첫 번째 문제는 App과 Web에 대한 API를 통합하게 되면서 생기는 Response Model 문제였습니다.

 

기본적으로 App과 Web은 화면을 그리는 원리가 다릅니다.

예를 들면 웹에서 icon 영역을 그리는 원리가 앱과 달라 아래와 "title" 같은 구조에서는 웹에서 그릴 수 없습니다.

"title": {
    "imageUrl": "https://image.gmarket.co.kr/hanbando/202306/icn_box_fast_g.png",
    "text": "Gmarket"
}

저도 백엔드 엔지니어로 자리 잡기 전까진 프론트 개발도 해봤었기 때문에 처음엔 이해를 못 했는데요.

될 것 같은데? imageUrl 받으면 그릴 수 있지 않나? 왜 자꾸 안 된다고 하지? 내가 맘에 안 드나?라는 생각까지 해봤었습니다. (결론적으로 제가 지식이 짧았습니다.)

 

추후에 프론트엔드 개발자분들께서 자세히 설명해 주신 내용은 문제는 그릴 수는 있으나 기존의 마크업 구조 때문에 유지보수가 힘들다는 것이었습니다.

그러면 웹은 아래와 같이 데이터를 내려줘야 화면을 편하게 그릴 수 있다는 것입니다.

"title": {
    "text": "Gmarket"
},
"tag": {
    "tagType": "OVER_SEA",
    "text": "해외배송 상품"
}

비슷하지만 다른 구조입니다.

여기서부터 문제가 발생합니다. 그러면 어떻게 필드를 관리하지?
  • 하나의 클래스에서 Prefix 또는 Suffix로 앱과 웹의 필드 관리해 볼까?
    data class Tab(
        private val reviewTabApp,
        private val reviewTabMWeb,
    )​
    그럴듯할 수도 있습니다.
    하지만 데이터(필드)가 많아진다면 어떨까요?

data class Tab(
    private val reviewTabApp,
    private val reviewTabMWeb,
    private val qnaTabApp,
    private val qnaTabMWeb,
    private val claimTabApp,
    private val claimTabMWeb,
    private val refundTabMweb,
)

필드가 굉장히 많아졌습니다.

심지어 앱에는 필요 없고 Mweb에만 필요한 refund 필드도 생겼습니다.

가장 중요한 문제는 App과 MWeb에서는 서로 필요 없는 데이터가 내려온다는 것입니다.

(ex App에서는 MWeb 필드가 필요가 없음, 특히 refund 필드는 최악)

 

  • 하나의 클래스에서 주석을 잘 ~ 달아서 써보는 것은 어떨까?

제 경험상 주석이라는 것에 도움을 받아본 적이 극히 드뭅니다. (주석 당시 개발 a시점, 현시점 f, 맞는 게 하나도 없음)

Clean Code라는 책에서는 정말 좋은 시스템이라면 주석이 최대한 없는 시스템이고

정말 필요한 내용이 아니면 오히려 주석을 다는 것을 지양하라고 나와있습니다.

(물론 제 경험이기 때문에 오만한 생각 일 수도 있습니다.)

 

 

  • 앱과 웹의 필드를 분리하자 (Prefix or Suffix로 클래스 파일 분리)
class TabsApp()

class TabsMweb()

이렇게 하기엔 클래스 파일의 양이 너무 많아질 것 같고 Prefix, Suffix를 붙이기에는 이름이 너무 마음에 안 들었습니다.

 

 

  • 앱과 웹의 필드를 분리하여 관리하자 (중첩 클래스 활용)

저는 이 방법을 택했으며, 필드는 최대한 동일한 구조로 가져가되 추가/삭제되도록만 한다는 규칙을 가져갔습니다.

class Tabs {
    data class App(
        private val review,
        private val qna,
        private val claim,
    )

    data class Mweb(
        private val review,
        private val qna,
        private val claim,
        private val refund,
    )

    data class Pc(
        private val review,
        private val qna,
        private val claim,
   )
}

 


API EndPoint 통합? 분리?

다음으로는 API EndPoint를 통합할 것인지, 분리할 것인지에 대한 문제가 있었습니다.

  1. (통합) get 또는 Post로 PlatFormType을 받아서 처리
    저는 이 방 안에서 post를 택했습니다.
@RestController
@RequestMapping("/api")
class ItemController(
    private val itemService: ItemService
) {
    @GetMapping("/v1/getItem")
    fun getItem(@RequestParam platFormType: String) {
        ~
    }

    @PostMapping("v1/getItem")
    fun getItem(@RequestBody data: Data) {
        val platformType = data.context.platFormType
    }

}
  1. (분리)
    어떠신가요? 이렇게 한다면 모든 API의 Endpoint마다 app/mweb을 구분해야 합니다.
@RestController
@RequestMapping("/api")
class ItemController(
    private val itemService: ItemService
) {
    @GetMapping("/v1/getItem/app")
    fun getItemApp() {
        ~
    }

    @GetMapping("/v1/getItem/Mweb")
    fun getItemMweb() {
    }

}

 


EndPoint를 통합하는데 Response Model이 2개라고?

이게 무슨 말이야?라고 생각 하실 수 있습니다.

추상화를 이용한다면 가능합니다.

 

AbstractData라는 추상화 클래스를 만들고

App/Mweb 클래스가 추상화를 상속받도록 하는 것입니다.

당연히 AbstractData를 반환해야겠죠.

@RestController
@RequestMapping("/api")
class ItemController(
    private val itemService: ItemService
) {

    @PostMapping("v1/getItem")
    fun getItem(@RequestBody data: Data): AbstractData {
        val platformType = data.context.platFormType
    }

}
abstract class AbstractData {}

class Tabs {
    data class App(
        private val review,
        private val qna,
        private val claim,
    ): AbstractData()

    data class Mweb(
        private val review,
        private val qna,
        private val claim,
        private val refund,
    ): AbstractData()

    data class Pc(
        private val review,
        private val qna,
        private val claim,
   ): AbstractData()
}

 


비즈니스 로직 분리는 어떻게?

이제는 비즈니스 로직을 어떻게 분리를 해야 할까 고민을 해야 했습니다.

App과 Mweb의 Response 최대한 비슷하지만 모델이 다르기 때문에

비즈니스 로직이 달라지기도 하고, 심지어는 각 플랫폼마다 로직이 다른 경우도 있습니다.

또다시 아래와 같이 고민해 봤습니다.

  • 비즈니스 로직을 처리하는 Class 이름을 Prefix, Suffix로 두어 각기 다른 파일로 분리
@Service
class TabDomainServiceImplApp {

}

@Service
class TabDomainServiceImplMWeb {

}

역시 모든 파일을 이렇게 만든다는 것은 상당히 마음에 안 듭니다.

  • 하나의 클래스에서 모든 로직마다 무한 if문 펼쳐보기
if (platFormType == "MWeb") {

}
else if (platFormType == "APP") {

}

if (platFormType == "MWeb") {

}
else if (platFormType == "APP") {

}

지옥이 펼쳐질 것입니다.

  • 중첩클래스 활용

위에 Response 모델에 중첩 클래스를 활용했던 것처럼, 비즈니스 클래스도 가능하지 않을까?라는 생각으로 해봤더니 정상 동작을 했고

꽤나 마음에 들었습니다.

class TabDomainServiceImpl {

    @Service
    class App {

    }

    @Service
    class MWeb {

    }
}

 


그래서 어떻게 실행시킬 거야?

위에서 설명드린 내용이 부실하여 이해가 안 되실 수 있을 것 같아

결론적으로 앱/웹용으로 분리한 비즈니스 로직을 어떻게 실행시킬 것인지

자세히 코드로 풀어 설명드리겠습니다.

API 모듈에서는 Front에서 요청을 받고 아래와 같이 Core 비즈니스 모듈에서 해당 요청을 처리할 담당 클래스를 찾습니다.

그 이후에 Core 비즈니스에서 내가 담당자야!!!라고 하며 나타난 녀석에게 그럼 이거 처리해 줘!!라고 하는 거죠.

[API 모듈]

@RestController
@RequestMapping("/api")
class ItemController(
    private val itemService: ItemService
) {

    @PostMapping("v1/getItem")
    fun getItem(@RequestBody data: Data): mono {
       itemService.getItem(data)
    }

}

[API 모듈]

@Service
class ItemService(
    //ItemDomainService를 구현하고 있는 모든 클래스의 의존성이 주입된다.
    private val itemDomainServices: List<ItemDomainService>,
) {

    suspend fun getItem(data: Data): AbstractData {
        //비즈니스 로직을 처리할 담당 클래스 찾기
        val platformType = data.context.platFormType
        val itemDomainService = itemDomainServices.find { itemDomainService ->
                itemDomainService.isMatchPlatformType(platformType = platformType)
            }?: throw NoSuchElementException("적합한 ItemDomainService 구현체를 찾을 수 없습니다. PlatformType: $platformType")

        //담당 클래스에게 비즈니스 로직 요청 하기
        return itemDomainService.getItem(data)
    }
}

[Core 모듈]

interface ItemDomainService {
    suspend fun isMatchPlatformType(platFormType: PlatFormType): Boolean

    suspend fun getItem(data: Data): AbstractData
}

[Core 모듈]

class ItemDomainServiceImpl {

    @Service
    class App: ItemDomainService {
        suspend fun isMatchPlatformType(platFormType: PlatFormType): Boolean {
            return platFormType == PlatFormType.APP
        }
        suspend fun getItem(data: Data): AbstractData {

        }
    }
    	
    @Service
    class Mweb: ItemDomainService {
        suspend fun isMatchPlatformType(platFormType: PlatFormType): Boolean {
            return platFormType == PlatFormType.MWeb
        }
        suspend fun getItem(data: Data): AbstractData {

        }
    }
}

여기서 핵심은 인터페이스를 활용한다는 것입니다.

인터페이스를 각 클래스 앱/웹이 각자에 맞게 구현하게 하는 것입니다.

 


끝으로

지마켓에 온 지 벌써 2년이 다되어가며 만 5년이 되어가고 있는 개발자입니다.

시간이 빠르게 흘러가고 있어서 저는 제 연차에 맞는 실력을 보이고 있는지 항상 불안에 떨고 있습니다.

저와 같은 고민을 하는 분들이 많을 것 같습니다.

 

위에서 설명드린 방법이 정답은 아닙니다.

제가 많이 부족하기에 이 순간엔 저 설계가 최선의 방법일 수도 있고

다른 분이 생각한 설계가 더 좋을 수 있습니다.

 

정답은 없는 것 같습니다.

 

하지만 끊임없이 생각하는 것을 멈추면 안 된다고 생각합니다.

기존의 시스템이 그래왔다고 복사 붙여 넣기 하기보다는 조금 더 좋은 시스템을 만들려고 고민해 보는 것은 어떨까 합니다.

 

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

댓글