공통화

Dophi·2023년 8월 15일

개발 기술

목록 보기
7/12

소개글

요즘 소마에서 프로젝트를 해보며 개발 기술들에 대해 배우고 적용해보고 있습니다. 이러한 기술들에 대해 개념을 자세히 설명하기보다는 실제로 어떻게 썼는지, 어떤 점이 좋았는지 정리해보고자 합니다!

공통화란

공통화는 캡슐화, DI 같이 뭔가 거창한 것이라기보다는 말 그대로 컴포넌트를 공통화, 또는 일반화하는 것입니다. 예를 들자면 똑같은 뷰를 공통화해서 A화면에서도 사용하고, B화면에서도 사용하는 것이 될 수 있습니다. 이 개념을 제 나름대로 한번 정리를 해보자면 아래와 같습니다.

개념

  • 같은 컴포넌트가 여러 군데에서 쓰일 경우 일반화 해서 재사용하는 것

쓰는 이유

  • 똑같은 코드를 여러번 반복해서 쓰는 것을 방지할 수 있음
  • 덕분에 기능이 변해도 수정할 범위가 굉장히 작아짐

특징

  • Generic, Protocol 등을 사용하여 일반화 가능

코드

코드와 함께 어떻게 구현했는지 설명드리겠습니다. 참고로 개발환경은 아래와 같습니다.

클린아키텍쳐, MVVM, Swinject, SwiftUI, Combine

ProblemCellView

우선은 저희가 어떤 공통뷰를 쓰고 있는지부터 설명드리겠습니다.

위에 사진을 보면 세 화면 (A, B, C 라고 하겠습니다) 에 모두 "가나다라마바사"가 써져 있는 셀들이 똑같이 존재합니다. 이 셀의 코드를 모든 화면에서 일일이 다 정의하기에는 당연히 매우 비효율적이기 때문에 보통은 공통화를 생각할 것입니다. 여기까지는 쉽게 할 수 있습니다.

다만 문제가 있을 수 있는데 크게는 총 두가지 입니다.

첫번째는, 예를 들어 하트 버튼을 누르면 찜하기 관련 API를 서버에게 날려야하는데, 이 조그만 ProblemCellView의 한정된 서버 동작을 위해 ProblemCellViewModel, ProblemCellUseCase, ProblemCellRepository, ProblemCellDataSource를 일일이 다 만들어야하는가 입니다. 정답부터 말하자면 그럴 필요 없고, Protocol을 사용하면 해결할 수 있고 자세한 것은 아래에서 설명드리겠습니다.

클린아키텍처에 대해 좀 더 공부를 하고 돌아왔습니다!

알아보니 위의 컴포넌트들이 1대1 매칭될 필요는 없으며, ViewModel이 여러 Usecase를 가지고, Usecase가 여러 Repository를 가지는 등 1대N 매칭이 사실 더 맞다고 할 수 있습니다.

그래서 결론은 ProbelmCellViewModel 정도는 만들어질 수 있지만, 나머지는 1대1 매칭을 고집할 필요는 없습니다.

두번째는, 사실 모든 셀들이 같아 보이지만 저희 앱의 동작 상 셀에 쓰이는 VO는 각 화면마다 모두 다릅니다. 그렇다면 타입이 모두 다른, 하지만 비슷하거나 똑같은 속성도 가지고 있는 VO를 어떻게 공통으로 인자로 받을 수 있는가에 대해 고민이 필요한데, 이는 Generic, Protocol로 해결 가능합니다.

일단 정답 코드부터 알려드리겠습니다.

struct ProblemCellView<T: ProblemCell>: View {
    
    @Binding private var problemCellVO: T
    private let problemCellHandling: ProblemCellHandling
    
    init(problemCellVO: Binding<T>, problemCellHandling: ProblemCellHandling) {
        self._problemCellVO = problemCellVO
        self.problemCellHandling = problemCellHandling
    }
    
    var body: some View {
        ZStack(alignment: .trailing) {
        	// 셀 누르면 해당 문제로 이동
            Button {
                problemCellHandling.moveToProblemView(id: problemCellVO.problemId)
            } label: {
                HStack() { ... }
            }
            ...
            // 하트 누르면 찜하기 or 찜해제하기
            Button {
                problemCellHandling.changeFavoriteStatus(id: problemCellVO.problemId)
            } label: {
                Image(systemName: problemCellVO.favorite.symbolName)
            }
            ...
        }
    }
}

첫번째 문제 해결

첫번째 문제는 Protocol로 해결했다고 했는데 바로 이 부분입니다.

// ProblemCellView
struct ProblemCellView<T: ProblemCell>: View {
    private let problemCellHandling: ProblemCellHandling

    init(..., problemCellHandling: ProblemCellHandling) {
        ...
        self.problemCellHandling = problemCellHandling
    }

    var body: some View {
    	ZStack(alignment: .trailing) {
            Button {
                problemCellHandling.moveToProblemView()
            } label: ...
            Button {
                problemCellHandling.changeFavoriteStatus()
            } label: ...
        }
    }
}

그리고 숨겨져있는 나머지 코드는 아래와 같습니다.

// ProblemCellHandling - 셀에서 ViewModel의 기능이 필요한 동작을 나타낸 Protocol
protocol ProblemCellHandling {
    func moveToProblemView(id: Int)
    func changeFavoriteStatus(id: Int)
}

// AView - 실제 View에서 사용하는 모습
ProblemCellView(problemCellVO: problem, problemCellHandling: aViewModel)

// AViewModel - ViewModel은 Protocol을 따라서 구현
extension AViewModel: ProblemCellHandling {
    public func moveToProblemView(id: Int) {
        coordinator.push(.problemDetailScene(id: id))
    }
    public func changeFavoriteStatus(id: Int) {
        useCase.toggleProblemFavorite(id: id)
        ...
    }
}

설명을 해드리자면, 우선 ProblemCellView는 자신만의 ViewModel을 가지고 있지 않기 때문에 ViewModel에서 처리해야하는 동작을 실행하기 위해서는 책임을 위임해야합니다. 그래서 ProblemCellHandling Protocol을 정의를 했고, ProblemCellView는 해당 동작이 실행되는 시점만 결정했습니다. 실제로 어떤 동작을 할지는 Protocol을 따르는 ViewModel이 정하게 하고 해당 ViewModel을 주입 받는 방식으로 동작합니다.

간단한 코드지만 여기서 중요한 개념이 2개나 쓰였습니다. 바로 DI와 IoC 입니다. 간단하게 말하지만 DI를 통해 ViewModel을 주입받고, IoC를 준수해서 실행 시점과 구현의 책임이 분리된 것입니다.

이 코드가 뭔가 익숙한 느낌이 드실 수도 있는데, 우리가 자주 사용하는 delegate가 바로 이런 형식으로 구현한 것입니다.

두번째 문제 해결

앞서 각 화면마다 VO가 조금씩 다르다고 했는데 이를 코드로 보여드리겠습니다.

public struct AProblemCellVO {
    public let problemId: Int
    public var favorite: ProblemFavoriteStatus
    ...
}

public struct BProblemCellVO {
    public let favoriteId: Int
    public let problemId: Int
    public var favorite: ProblemFavoriteStatus
    ...
}

public struct CProblemCellVO {
    public let problemUserId: Int
    public let problemId: Int
    public var favorite: ProblemFavoriteStatus
    ...
}

전체적으로 problemId, favorite 등의 값을 가진다는 점은 똑같지만, 설계상 변수가 하나씩 달라서 VO를 따로따로 만들어야 했습니다.

하지만 ProblemCellView를 공통으로 쓰기 위해서는 모든 VO를 받아서 그 안의 프로퍼티 값을 쓸 수 있어야 했는데, 그러기 위해 사용한 것이 특정 Protocol을 만족하는 Generic 입니다.

// ProblemCellView
struct ProblemCellView<T: ProblemCell>: View {
    
    @Binding private var problemCellVO: T
    
    init(problemCellVO: Binding<T>, ...) {
        self._problemCellVO = problemCellVO
        ...
    }
    
    var body: some View {
        ZStack(alignment: .trailing) {
            ...
            Button {
                problemCellHandling.changeFavoriteStatus(id: problemCellVO.problemId)
            } label: {
                Image(systemName: problemCellVO.favorite.symbolName)
            }
            ...
        }
    }
}

그냥 Generic만 써도 되지 않을까 생각하실 수 있는데, 위의 코드를 보면 problemCellVO의 problemId, favorite 프로퍼티에 접근을 하고 있습니다. 따라서 ProblemCell Protocol은 아래와 같이 해당 프로퍼티들을 가지고 있어야 하며 Generic은 이 Protocol을 준수한다고 명시해줘야 합니다.

public protocol ProblemCell {
    var problemId: Int { get }
    var favorite: ProblemFavoriteStatus { get set }
    ...
}

이후 AProblemCellVO, BProblemCellVO, CProblemCellVO가 모두 이 Protocol을 따르게 했습니다. 덕분에 오직 한가지 타입이 아니더라도 해당 Protocol 을 따르는 여러 타입의 VO들을 넘겨줄 수 있게 되었습니다.

// AView
ProblemCellView(problemCellVO: aProbelmCellVO, problemCellHandling: aViewModel)

// BView
ProblemCellView(problemCellVO: bProbelmCellVO, problemCellHandling: bViewModel)

// CView
ProblemCellView(problemCellVO: cProbelmCellVO, problemCellHandling: cViewModel)

정리

단순해보였던 뷰의 공통화 작업이었지만, 생각할 거리가 꽤나 많았던 작업이었습니다. Protocol, Generic 의 개념 자체는 잘 알고 있었지만 어디에 실제로 쓰일까 의문이 들기도 했는데, 이렇게 직접 고민하면서 써보니 용도를 좀 더 잘 파악할 수 있어서 좋은 경험이 됐습니다.

마무리

이상으로 제가 구현해본 공통화 작업을 코드와 함께 설명드렸습니다. 사실 저도 현재 배우고 있는 입장이기 때문에 틀린 개념, 부족한 개념들이 있을 수 있습니다. 혹시라도 그런 부분이 있다면 언제든지 지적해주셔도 됩니다. 긴 글 읽어주셔서 감사합니다. 😊

profile
개발을 하며 경험한 것들을 이것저것 작성해보고 있습니다!

1개의 댓글

comment-user-thumbnail
2023년 8월 15일

좋은 글 감사합니다.

답글 달기