요즘 소마에서 프로젝트를 해보며 개발 기술들에 대해 배우고 적용해보고 있습니다. 이러한 기술들에 대해 개념을 자세히 설명하기보다는 실제로 어떻게 썼는지, 어떤 점이 좋았는지 정리해보고자 합니다!
서버 드리븐 UI는 백엔드에서 화면에 그릴 요소를 결정해서 response를 내려주면 그것에 맞춰 프런트엔드에서 UI를 그려주는 기술입니다. 물론 서버가 모든 것을 다해주는 것은 아니고 프런트에서도 정의해줄 것들이 있긴 합니다.
아래 이미지는 ServerDriven이 뭔지 잘 나타내주는 사진이라고 생각합니다. 서버에서 저렇게 리스트 형태로 데이터를 정의해두면 프런트엔드에서 그 순서에 맞게 미리 만들어놓은 컴포넌트에 데이터를 삽입해서 보여줍니다. 사진의 출처는 이곳입니다.

상당히 특이하다고 생각할 수 있는데, 이런 기술을 쓰는 이유는 아래와 같습니다.
코드와 함께 어떻게 구현했는지 설명드리겠습니다.
우선 서버에서 내려주는 JSON Response는 아래와 같다고 가정하겠습니다.
Home이란 화면에 제목과 아이콘이 있는 AView와 제목만 있는 BView를 내려주고 있고, 순서는 BView가 먼저 나오네요.
{
"responseData" : {
"screenName":"Home",
"contents" : [
{
"viewType" : "BViewType",
"content" : {
"title": "This is B ViewType"
}
},
{
"viewType" : "AViewType",
"content" : {
"title": "This is A ViewType",
"iconUrl":"https://avatars.githubusercontent.com/u/103282546?s=200&v=4"
}
}
]
}
}
일단 내려오는 JSON Response를 파싱해서 데이터를 저장해놓을 구조체가 필요합니다만 여기서 어려움이 있을 수 있습니다.
contents를 파싱하기 위해서는 어떤 구조체 (S라고 부르겠습니다) 들의 리스트로 표현을 해야합니다. 하지만 S가 AView일지 BView일지, 또는 다른 View일지 알 수가 없기 때문에 일반적인 방법으로는 파싱하기가 어렵습니다.
그래서 저희가 사용한 방법은 이렇습니다.
struct ServerDrivenUIData: Decodable {
let responseData: ScreenData
}
struct ScreenData: Decodable {
let screenName: String
let contents: [Content]
}
struct Content: Decodable {
let viewType: String
let content: ContentTypes // Enum 타입
enum CodingKeys: String, CodingKey {
case viewType
case content
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// viewType 먼저 파싱
viewType = try container.decode(String.self, forKey: .viewType)
// viewType 문자열 값에 따라 content 파싱
content = try ContentTypes(rawValue: viewType, container: container)
}
}
enum ContentTypes {
case AViewType(AContentInfo)
case BViewType(BContentInfo)
case Unknown(DefaultContentInfo)
init(rawValue: String, container: KeyedDecodingContainer<Content.CodingKeys>) throws {
switch rawValue {
case "AViewType":
let contentInfo = try container.decode(AContentInfo.self, forKey: .content)
self = .AViewType(contentInfo)
case "BViewType":
let contentInfo = try container.decode(BContentInfo.self, forKey: .content)
self = .BViewType(contentInfo)
default:
let contentInfo = try container.decode(DefaultContentInfo.self, forKey: .content)
self = .Unknown(contentInfo)
}
}
// 서버에서 그대로 받아온 DTO를 우리가 직접 사용할 형태인 VO로 바꾸기
public func toVO() -> ViewsVO {
switch self {
case .AViewType(let aContentInfo):
return ViewsVO.AViewType(title: aContentInfo.title ?? "Unknown", iconUrl: aContentInfo.iconUrl ?? "https://i.namu.wiki/i/LFUKmPgggKnKF-I4HYMe1u7TGvG-ff_llmEbrUwX-pHHpH7iuSNO2sT0SJazFTjygTV_XR8wYbghjPh6-_vLSg.webp")
case .BViewType(let bContentInfo):
return ViewsVO.BViewType(title: bContentInfo.title ?? "Unknwon")
case .Unknown(_):
// 실제로는 예외 또는 에러에 관한 VO와 Cell도 만들어야 하지만 이는 생략
return ViewsVO.BViewType(title: "Unknown")
}
}
}
// ------- ContentInfo -------
public struct DefaultContentInfo: Decodable {}
public struct AContentInfo: Decodable {
let title: String?
let iconUrl: String?
}
public struct BContentInfo: Decodable {
let title: String?
}
서버에서 그대로 내려받은 데이터 구조체가 DTO였다면, 이를 실제로 프런트엔드 쪽에서 활용하기 위한 데이터인 VO로 변환해줘야 합니다. 당장 필요한건 AView에서는 제목과 아이콘, BView에서는 제목뿐이기 때문에 형태는 아래와 같습니다.
public enum ViewsVO {
case AViewType(title: String, iconUrl: String)
case BViewType(title: String)
}
이후에는 그저 데이터를 View까지 가져와주기만 하면 됩니다. 저희는 클린아키텍쳐를 따랐기 때문에 Moya -> DataSource -> Repository -> UseCase -> ViewModel까지 쭉쭉 내려오게 됩니다.
// Data 영역 - Moya 관련 커스텀 함수
class MoyaWrapper<Provider: TargetType>: MoyaProvider<Provider> {
func call<Value>(target: Provider) -> AnyPublisher<Value, Error> where Value: Decodable {
return self.requestPublisher(target)
.map(Value.self)
...
}
}
// Data 영역 - Datasource
public class DefaultHomeDataSource: HomeDataSource {
public init() {}
private let moyaProvider = MoyaWrapper<ServerDrivenAPI>()
public func loadViews() -> AnyPublisher<ServerDrivenUIData, Error> {
return moyaProvider.call(target: .loadViews)
}
}
// Data 영역 - Repository
final public class DefaultHomeRepository: HomeRepository {
let dataSource: HomeDataSource
public init(dataSource: HomeDataSource) {
self.dataSource = dataSource
}
public func loadViews() -> AnyPublisher<[ViewsVO], Error> {
return dataSource.loadViews()
.map{ $0.responseData.contents.map{ $0.content.toVO() } }
.eraseToAnyPublisher()
}
}
// Domain 영역 - UseCase
public final class DefaultHomeUseCase: HomeUseCase {
let repository: HomeRepository
public init(repository: HomeRepository) {
self.repository = repository
}
public func loadViews() -> AnyPublisher<[ViewsVO], Error>{
return repository.loadViews()
}
}
// Presentation 영역 - ViewModel
final public class HomeViewModel: ObservableObject {
@Published var views: [ViewsVO]
private let homeUseCase: HomeUseCase
private let cancelBag = CancelBag()
public init(homeUseCase: HomeUseCase, views: [ViewsVO] = []) {
self.views = views
self.homeUseCase = homeUseCase
}
func loadViews() {
homeUseCase.loadViews()
.receive(on: DispatchQueue.main)
.sinkToResult({ result in
switch result {
case .success(let viewsVO):
self.views = viewsVO
case .failure(_):
break
}
})
.store(in: cancelBag)
}
}
이제 View에서 보여주기만 하면 됩니다.
여기서 한가지 짚고 넘어가면 좋은게 서버 드리븐 UI는 기본적으로 TableView에 리스트 형태로 삽입되는 경우가 많고, Home뿐만 아니라 다른 화면에서도 쓰일 여지가 있기 때문에 dataSource는 공통으로 만들어 두는 것이 좋습니다.
//Presentaion 영역 - View
public class HomeViewController: UIViewController {
private var viewModel: HomeViewModel
private var dataSource: CommonTableViewDataSource?
lazy var tableView: UITableView = UITableView(frame: view.bounds, style: .plain)
...
public init(viewModel: HomeViewModel) {
self.viewModel = viewModel
...
// 값 구독하기
viewModel.$views
.sink { viewsVO in
DispatchQueue.main.async {
self.dataSource?.setViewTypeItemsAndRefresh(viewTypeItems: viewsVO)
self.tableView.reloadData()
}
}
.store(in: &cancelBag)
}
public override func viewDidLoad() {
super.viewDidLoad()
setupTableView()
loadViews()
}
// tableView 세팅하기
func setupTableView() {
dataSource = CommonTableViewDataSource()
tableView.dataSource = dataSource
view.addSubview(tableView)
}
// 서버에서 데이터 받아오기
func loadViews() {
viewModel.loadViews()
}
}
공통으로 쓰일 DataSource에서는 서버 드리븐 UI에서 쓰이는 모든 셀을 등록하도록 했습니다. 그래서 보시면 ATableViewCell, BTableViewCell을 등록해두고 인덱스에 맞게 꺼내 쓰는 모습을 볼 수 있습니다.
public class CommonTableViewDataSource: NSObject, UITableViewDataSource {
private var items: [ViewsVO] = []
private var registeredCellTypes = Set<String>()
public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return self.items.count
}
// 리스트 인덱스에 따라 어떤 Cell을 사용하고 보여줄 것인지 결정
public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let content = items[indexPath.row]
switch content {
case .AViewType(let title, let iconUrl):
registerCell(tableView: tableView, cellType: ATableViewCell.self)
let cell: ATableViewCell = useCell(tableView: tableView, indexPath: indexPath)
cell.setCell(title: title, imgUrl: iconUrl)
return cell
case .BViewType(let title):
registerCell(tableView: tableView, cellType: BTableViewCell.self)
let cell: BTableViewCell = useCell(tableView: tableView, indexPath: indexPath)
cell.setCell(title: title)
return cell
}
}
// tableView에 셀 등록하기
private func registerCell<T: CellIdentifier>(tableView: UITableView, cellType: T.Type) {
if !registeredCellTypes.contains(T.identifier) {
tableView.register(cellType.self, forCellReuseIdentifier: T.identifier)
registeredCellTypes.insert(T.identifier)
}
}
// tableView에서 셀 꺼내쓰기
private func useCell<T: CellIdentifier>(tableView: UITableView, indexPath: IndexPath) -> T {
guard let cell = tableView.dequeueReusableCell(withIdentifier: T.identifier, for: indexPath) as? T
else {
fatalError("Failed to dequeue AViewTypeCell")
}
return cell
}
// Items 설정해주기
public func setViewTypeItemsAndRefresh(viewTypeItems: [ViewsVO]) {
self.items = viewTypeItems
}
}
마지막으로 Cell의 형태만 정의해주면 됩니다. 예를 들어 ATableViewCell이라면 제목과 아이콘을 가진 셀을 만들어주면 됩니다.
특이한 점은 CellIdentifier라는 프로토콜을 따르고 있는데, 이는 필수는 아니고 그저 CommonTableViewDataSource에서 코드량을 조금이라도 줄이기 위해 셀을 등록하고 사용하는 제너릭 함수를 만들 때 사용했습니다.
// CommonTableViewDataSource에서 셀 등록 및 사용 관련 제너릭 함수를 만들기 위해 정의
protocol CellIdentifier: UITableViewCell {
static var identifier: String { get }
}
// ATableViewCell
class ATableViewCell: UITableViewCell, CellIdentifier {
static let identifier = "ATableViewCell"
let titleLabel: UILabel = {
...
}()
let iconImageView: UIImageView = {
...
}()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
addSubview(titleLabel)
addSubview(iconImageView)
NSLayoutConstraint.activate([
...
])
}
func setCell(title: String, imgUrl: String) {
titleLabel.text = title
loadImage(from: imgUrl)
}
...
}
코드를 실행해보면 결과는 아래와 같습니다.

원한다면 AView를 3개 더 추가할 수도 있고, 또는 AView와 BView의 순서를 바꿀 수 있습니다. 이렇게 컴포넌트가 추가될만하거나 순서가 영향이 큰 화면에서는 서버 드리븐 UI를 써서 앱 배포 없이도 바로 업데이트가 가능합니다.
추가로 저희가 작성한 코드가 무조건 최적의 방법은 아닐 수 있습니다. 예를 들면 파싱하는 방법이나 공통 DataSource를 만드는 방법이 더 좋은 것이 있을 수도 있습니다. 그러니 마구잡이로 따라하기보다는 한번 생각하면서 도전해보면 좋을 것 같습니다!
이상으로 제가 도전해본 ServerDrive UI를 코드와 함께 설명드렸습니다. 사실 저도 현재 배우고 있는 입장이기 때문에 틀린 개념, 부족한 개념들이 있을 수 있습니다. 혹시라도 그런 부분이 있다면 언제든지 지적해주셔도 됩니다. 긴 글 읽어주셔서 감사합니다. 😊