ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 2021 앱 Dev-Matching | K-MOOC 강좌정보 서비스
    취업 이야기 2021. 7. 20. 18:06

    프로그래머스에서는 지난 6월 19일 '2021 Dev-Matching: 앱 개발자'의 과제 테스트가 진행되었습니다. 다들 과제 테스트는 어떠셨나요? 과제 테스트에는 완벽한 정답은 없지만, 분명 어떻게 하면 더 잘할 수 있을까 고민하신 분들이 많을 거로 생각해요. 과제 출제자가 작성한 해설을 보고 나의 코드를 발전시켜보세요 :)


    K-MOOK 과제 해설

    문제해설

    이 과제는 주어진 베이스 코드를 기반으로 기능을 완성하는 과제 입니다.

    데이터 소스

    이 과제에서 사용하는 데이터는 국가평생교육진흥원에서 제공하는 K-MOOK 강좌 목록과 상세 정보 입니다.

    공공데이터 포털인 data.go.kr 에서 제공되고 있습니다. 이 외에고 공공데이터 포털에서는 여러 공공데이터를 api 형태로 또는 data 형태로 제공하고 있어, 토이프로젝트나 개인앱을 만들 때 참고하면 아주 유용한 사이트 입니다.

    참고로 서울시에서 운영하는 서울 열린데이터 광장 https://data.seoul.go.kr/ 도 있으니 함께 참고하면 좋습니다.

    출제의도

    아주 기본적인 목록-상세 구조의 서비스 입니다.

    목록화면에서는 pull to refresh 라던가 무한스크롤같은 당연히 있어야할 기본기능만을 요구하고 있고,

    상세화면에는 복잡한 화면구성의 수고를 덜어드리기 위해 웹뷰로 보여주도록 친절하게 출제하였습니다.

    의도적인 부분은 외부라이브러리를 사용하지 않도록 하는 부분이었습니다. 플랫폼에서 제공되는 기본 기능에 대한 이해도와 이를 활용한 개발 능력을 확인할 수 있도록 하였습니다.

    베이스 코드 설명

    • Android 베이스 코드 설명

      목록(KmookListActivity) 과 상세 (KmookDetailActivity) 의 두 개 화면으로만 구성된 단촐한 구성입니다.

      코드 구성은 구글에서 제시하는 앱 아키텍쳐 가이드를 따르고 있습니다.

      앱 아키텍쳐 구성에 따라 ViewModel 이나 Repository 를 모두 기본 코드로 제공하고 있고, LiveData를 사용하는 부분은 구현자의 몫으로 남겨두었습니다.

      외부 라이브러리를 사용하지 않고 플랫폼의 기본 기능만을 사용해서 http 통신과 json 파싱을 모두 구현할 수 있도록 베이스 코드가 제공되고 있습니다.

      규모가 작은 서비스이다 보니 DI 모듈은 적용하지 않았습니다. 역시 이 부분은 구현자의 재량으로 남겨두었습니다.

      KmoocListActivity 에서 fetching된 데이터를 adapter를 통해 표시하는 부분, progressBar 표시, pull to refresh, 무한로딩에 대한 구현이 필요합니다.

      KmoocDetailActivity 에서 상세 데이터를 조회하여 기본정보 적용 및 webview를 사용한 표시 기능이 구현되어야 합니다.

      ImageLoader 를 통해 이미지를 로딩처리하는 부분에서 async 한 처리 구현과 cache를 기대하였습니다. 이미지 라이브러리를 사용해본 분들이라면 cache 기능이 기본으로 제공되기 때문에 자칫 빠뜨릴 수 있는 부분입니다. cache의 역할과 원리를 이해하고 있다면 쉽게 구현할 수 있는 부분입니다.

      KmoocRepository 에서 json 을 파싱하여 Model 객체를 만들어내는 부분이 구현되어야 합니다.

    • iOS 베이스 코드 설명

      iOS 는 UI 구성을 간편하게 제공하기 위해서 Storyboard 기반으로 제공하고 있습니다.

      SwiftUI 로 제공하는 것도 고려했었지만, UI를 위한 코드가 길어져 어렵게 느껴지게 될 까봐 선택하지 않았습니다. 하지만 구현자가 Storyboard를 무시하고 SwiftUI로 재 작성하고자 한다면 충분히 인정할 수 있습니다.

      KmoocListViewControllerKmoocDetailViewController 의 두 개 ViewController 로 구성된 단순한 서비스 입니다. List 에서 Detail 로의 이동은 segue를 사용하고 id를 전달하여 Detail 화면에서 fetching하게 됩니다.

      ViewModel 을 사용하는 MVVM 구조를 사용하고 있으며, reactive 한 부분에 대한 처리는 없습니다. 이 부분은 구현자에게 맡기고 있으며 MVVM에 대한 이해도를 확인할 수 있는 부분입니다.

      KmoocRepository 에서 json을 fetching 한 후 parsing에 대한 구현은 없습니다. Model이 Codable을 구현하고 있으므로 쉽게 처리할 수 있을 것 같지만, 서버에서 전달되는 json과 Model객체가 달라 바로 변환이 되지 않습니다. 이 부분에서 Codable 또는 Json 을 다루는 기본 기술에 대한 이해도를 확인할 수 있는 부분입니다.

      ImageLoader 에서 이미지를 로딩처리하는 부분에서 async 한 처리 구현과 cache를 기대하였습니다. 이미지 라이브러리를 사용해본 분들이라면 cache 기능이 기본으로 제공되기 때문에 자칫 빠뜨릴 수 있는 부분입니다. cache의 역할과 원리를 이해하고 있다면 쉽게 구현할 수 있는 부분입니다.

      KmoocListViewController 에서 viewModel을 사용하고 있습니다. 데이터 로딩 간 UIActivityIndicatorView 표시를 제어하는 부분과 데이터 로딩이 완료된 시점에 tableView를 reload 하는 처리, 무한로딩에 대한 처리가 필요합니다.

      KmoocDetailViewController 에서 상세 정보에 대해 기본 표시하고 webview를 사용해서 컨텐츠를 표시해야 합니다. 자연스러운 컨텐츠 화면을 제공하기 위해서는 webview 자체가 스크롤되는 것이 아닌, 화면 전체가 스크롤 될 수 있도록 특별한 처리가 필요합니다.

    구현

    구현예시

    💡구현에는 정답이 없습니다. 아래의 코드는 참고만 하시기 바랍니다.

    android.zip
    167.2 kB

    ios.zip
    72.3 kB

    구현이 필요한 항목들 중에서 몇 가지만 함께 살펴보겠습니다.

    이미지 다운로드 및 캐싱

    이미지를 보여주는 것은 모바일 서비스에서 매우 빈번하게 사용하는 기능입니다. 보통 이런 기능을 제공하는 외부 라이브러리를 사용해서 맡겨버리기 때문에 매우 기본적인 기능임에도 어떻게 동작하는지에 대해 생각하지 않고 넘어아게 되기 일수입니다. 직접 구현하면서 비동기 처리, 그리고 캐싱에 대해서 상기해 보시기 바랍니다.

    비동기 처리

    비동기 처리를 해야하는 이유는 이미지를 다운로드하는데 시간이 걸리기 때문입니다. 만약 UI Thread에서 다운로드를 하게 된다면 다운로드하는 시간 만큼 UI가 멈추게 될 것입니다. 그래서 UI Thread가 아닌 다른 Thread로 다운로드를 처리하고 비동기적으로 결과를 처리하는 구현이 필요합니다.

    • Android 구현

      안드로이드에서는 Network을 사용하는 동작을 UI Thread에서 아예 처리하지 못하도록 막고 있습니다.

      다른 Thread에서 처리하는 방법으로는 직접 Thread를 생성하거나 AsyncTask 등을 사용하는 방법이 있습니다. kotlin 에서는 코루틴을 제공하면서 매우 쉽게 사용할 수 있도록 기능을 제공해주고 있습니다.

        GlobalScope.launch(Dispatchers.IO) {
          // IO Thread 에서 처리할 일
        }

      이미지를 다운로드 하는 것은 Network을 사용하는 IO 작업이기 때문에 IO Thread에서 처리시키는게 적당할 것 같습니다. 이미지 다운로드가 완료되고 나면 UI에 이미지를 적용할게 될 텐데, UI의 변경이 일어나는 것은 반드시 UI Thread (== Main Thread) 에서 처리해야 합니다. 따라서 UI 측에 이미지를 전달할 때는 UI Thread로 이동한 후에 전달해 줘야 합니다. 역시 코루틴을 사용하면 이것을 매우 쉽게 처리할 수 있습니다.

        GlobalScope.launch(Dispatchers.IO) {
                // IO Thread 에서 처리할 일
            withContext(Dispatchers.Main) {
                        // Main Thread 에서 처리할 일
            }
        }

      url 로부터 이미지를 다운로드할 때는 BitmapFactory를 통해서 처리합니다.

        val bitmap = BitmapFactory.decodeStream(URL(url).openStream())

      url 스트링을 URL 객체로 만들고, stream을 열러서 BitmapFactory로 전달해주면, 나머지는 알아서 해줍니다.

      완성된 코드는 이렇게 됩니다.

        fun loadImage(url: String, completed: (Bitmap?) -> Unit) {
            if (url.isEmpty()) {
                completed(null)
                return
            }
      
            GlobalScope.launch(Dispatchers.IO) {
                try {
                    val bitmap = BitmapFactory.decodeStream(URL(url).openStream())
                    withContext(Dispatchers.Main) {
                        completed(bitmap)
                    }
                } catch (e: Exception) {
                    withContext(Dispatchers.Main) {
                        completed(null)
                    }
                }
            }
        }

      url이 잘못되었을 경우에 대한 처리와 다운로드 중 오류에 대해 null을 반환하도록 하였습니다. 반환은 callback으로 처리했습니다.

      이미지 캐시

      동일한 이미지를 매번 다운로드 하는 것은 비효율적입니다. 한 번 다운로드했던 이미지는 임시로 저장했다가 재사용할 수 있도록 캐시기능을 추가해 보겠습니다.

      이 구현에서는 캐시를 메모리에만 하도록 하겠습니다. 캐시는 메모리에 저장했다가 다시 꺼내주기만 하면 되기 때문에 구현은 매우 간단합니다. url에 해당하는 이미지를 요청하게 되니까 url을 key로 하는 dictionary 데이터 구조가 적당해 보입니다.

        val imageCache = mutableMapOf<String, Bitmap>()

      요청된 이미지가 이미 캐시되어 있는지 확인하는 방법은 요청된 url이 key로 등록되어있는지 확인하는 방법이면 충분합니다.

        if (imageCache.containsKey(url)) {
            completed(imageCache[url])
            return
        }

      이미지 다운로드 전체 코드는 이렇게 됩니다.

        object ImageLoader {
            private val imageCache = mutableMapOf<String, Bitmap>()
      
            fun loadImage(url: String, completed: (Bitmap?) -> Unit) {
                if (url.isEmpty()) {
                    completed(null)
                    return
                }
      
                if (imageCache.containsKey(url)) {
                    completed(imageCache[url])
                    return
                }
      
                GlobalScope.launch(Dispatchers.IO) {
                    try {
                        val bitmap = BitmapFactory.decodeStream(URL(url).openStream())
                        imageCache[url] = bitmap
      
                        withContext(Dispatchers.Main) {
                            completed(bitmap)
                        }
                    } catch (e: Exception) {
                        withContext(Dispatchers.Main) {
                            completed(null)
                        }
                    }
                }
            }
        }
    • iOS 구현

      iOS 에서 멀티쓰레드를 수행하는 방법은 NSOperation을 사용할 수도 있지만, GCD를 사용하면 간결하게 처리할 수 있습니다.

        DispatchQueue.global(qos: .background).async {
            // 다른 Thread 에서 처리할 일
        }

      이미지 다운로드가 완료되고 나면 UI에 이미지를 적용할게 될 텐데, UI의 변경이 일어나는 것은 반드시 UI Thread (== Main Thread) 에서 처리해야 합니다. 따라서 UI 측에 이미지를 전달할 때는 UI Thread로 이동한 후에 전달해 줘야 합니다. 역시 GCD를 사용하면 이것을 매우 쉽게 처리할 수 있습니다.

        DispatchQueue.global(qos: .background).async {
                // 다른 Thread 에서 처리할 일
            DispatchQueue.main.async {
                // Main Thread 에서 처리할 일
            }
        }

      url 로부터 이미지를 다운로드할 때는 Data를 통해서 처리합니다.

        let data = try? Data(contentsOf: URL(string: url)!)
        let image = UIImage(data: data)

      url 스트링을 URL 객체로 만들고, 바이너리를 다운로드 하여 얻은 Data 를 UIImage에 넣고 생성합니다.

      완성된 코드는 이렇게 됩니다.

        func loadImage(url: String, completed: @escaping (UIImage?) -> Void) {
            if url.isEmpty {
                completed(nil)
                return
            }
      
            DispatchQueue.global(qos: .background).async {
                if let data = try? Data(contentsOf: URL(string: url)!) {
                    let image = UIImage(data: data)
                    DispatchQueue.main.async {
                        completed(image)
                    }
                } else {
                    DispatchQueue.main.async {
                        completed(nil)
                    }
                }
            }
        }

      url이 잘못되었을 경우에 대한 처리와 다운로드 중 오류에 대해 null을 반환하도록 하였습니다. 반환은 callback으로 처리했습니다.

      이미지 캐시

      동일한 이미지를 매번 다운로드 하는 것은 비효율적입니다. 한 번 다운로드했던 이미지는 임시로 저장했다가 재사용할 수 있도록 캐시기능을 추가해 보겠습니다.

      이 구현에서는 캐시를 메모리에만 하도록 하겠습니다. 캐시는 메모리에 저장했다가 다시 꺼내주기만 하면 되기 때문에 구현은 매우 간단합니다. url에 해당하는 이미지를 요청하게 되니까 url을 key로 하는 dictionary 데이터 구조가 적당해 보입니다.

        var imageCache = [String: UIImage]()

      요청된 이미지가 이미 캐시되어 있는지 확인하는 방법은 요청된 url이 key로 등록되어있는지 확인하는 방법이면 충분합니다.

        if let image = imageCache[url] {
            completed(image)
            return
        }

      이미지 다운로드 전체 코드는 이렇게 됩니다.

      
      class ImageLoader {
            private static var imageCache = [String: UIImage]()
      
            static func loadImage(url: String, completed: @escaping (UIImage?) -> Void) {
                if url.isEmpty {
                    completed(nil)
                    return
                }
      
                if let image = imageCache[url] {
                    completed(image)
                    return
                }
      
                DispatchQueue.global(qos: .background).async {
                    if let data = try? Data(contentsOf: URL(string: url)!) {
                        let image = UIImage(data: data)
                        imageCache[url] = image
                        DispatchQueue.main.async {
                            completed(image)
                        }
                    } else {
                        DispatchQueue.main.async {
                            completed(nil)
                        }
                    }
                }
            }
        }

    무한 스크롤

    보통 리스트 형태의 데이터를 서버에서 제공할 때 전체 데이터를 한꺼번에 모두 전달하기 보다는 page단위로 끊어서 조금씩 전달해 주게 됩니다. 스크롤을 할 때마다 스크롤이 바닥에 닿을 때 쯤 다음페이지를 자동으로 로딩해오는 방식을 무한스크롤 방식이라고 합니다.

    무한스크롤을 구현하는 방식은 이렇습니다.

    • 스크롤 이벤트를 통해 어느정도 스크롤되고 있는지를 감시하다가

    • 남아있는 스크롤 양이 어느정도인지 확인하고 다음페이지를 로딩할 지 결정하게 됩니다.

    • 로딩 시 주의할 점은, 로딩요청이 일어난 경우 계속되는 이벤트에 의해서 중복요청 되지 않도록 처리해야하는 부분 입니다.

    • Andriod 구현

      리스트를 RecyclerView를 사용하고 있으므로 RecyclerView의 OnScrollListerner를 통해서 스크롤되고 있는 상태를 감시합니다.

        binding.lectureList.addOnScrollListener(object : RecyclerView.OnScrollListener() {
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                super.onScrolled(recyclerView, dx, dy)
            }
        })

      스크롤 양이 얼마나 남았을 때 다음페이지 로딩을 할 것인가를 결정해야 합니다. 보통 한 화면만큼의 크기가 남은경우 로딩을 수행하거나, 보여질 아이템 개수가 몇 개 남았는지를 가지고 결정하기도 합니다. 이 구현예제는 후자의 방식으로 처리했습니다.

        binding.lectureList.addOnScrollListener(object : RecyclerView.OnScrollListener() {
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                super.onScrolled(recyclerView, dx, dy)
      
                val layoutManager = binding.lectureList.layoutManager
                        val lastVisibleItem = (layoutManager as LinearLayoutManager)
                        .findLastCompletelyVisibleItemPosition()
      
                // 마지막으로 보여진 아이템 position 이
                // 전체 아이템 개수보다 5개 모자란 경우, 데이터를 loadMore 한다
                if (layoutManager.itemCount <= lastVisibleItem + 5) {
                    viewModel.next()
                }
            }
        })

      마지막으로 주의할 부분이 있습니다. 다음페이지가 없거나, 이미 요청된 경우는 중복해서 요청되지 않도록 해야 한다는 것입니다. 이 예에서는 로딩중인것을 progressBar가 보여지는지를 활용했습니다.

      이것을 모두 적용하면 다음 같습니다.

        binding.lectureList.addOnScrollListener(object : RecyclerView.OnScrollListener() {
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                super.onScrolled(recyclerView, dx, dy)
      
                        // 다음페이지 존재 여부 확인
                        if(viewModel.hasNextPage() == false) return;
      
                val layoutManager = binding.lectureList.layoutManager
                        // 이지 로딩중인지 확인
                if (viewModel.progressVisible.value != true) {
                    val lastVisibleItem = (layoutManager as LinearLayoutManager)
                        .findLastCompletelyVisibleItemPosition()
      
                    // 마지막으로 보여진 아이템 position 이
                    // 전체 아이템 개수보다 5개 모자란 경우, 데이터를 loadMore 한다
                    if (layoutManager.itemCount <= lastVisibleItem + 5) {
                        viewModel.next()
                    }
                }
            }
        })
    • iOS 구현

      리스트를 TableView를 사용하고 있으므로 delegate의 scrollViewDidScroll를 통해서 스크롤되고 있는 상태를 감시합니다.

        override func scrollViewDidScroll(_ scrollView: UIScrollView) {
        }

      스크롤 양이 얼마나 남았을 때 다음페이지 로딩을 할 것인가를 결정해야 합니다. 보통 한 화면만큼의 크기가 남은경우 로딩을 수행하거나, 보여질 아이템 개수가 몇 개 남았는지를 가지고 결정하기도 합니다. 이 구현예제는 전자의 방식으로 처리했습니다.

        override func scrollViewDidScroll(_ scrollView: UIScrollView) {
            let offsetY = scrollView.contentOffset.y
            let contentHeight = scrollView.contentSize.height
      
            if offsetY > contentHeight - scrollView.frame.height {
                viewModel.next()
            }
        }

      마지막으로 주의할 부분이 있습니다. 다음페이지가 없거나, 이미 요청된 경우는 중복해서 요청되지 않도록 해야 한다는 것입니다. 이 예에서는 ViewModel 내에서 loading 플래그를 두고 활용합니다.

      이것을 적용하면 다음 같습니다.

        class KmoocListViewModel: NSObject {
                ...
                private var loading = false
      
            func next() {
                if loading { return }
                loading = true
      
                repository.next(currentPage: lectureList) {
                                ...
                    self.loading = false
                }
            }
        }

    json 파싱하기

    서버에서 전달되는 json 정보를 서비스에서 사용하는 Model 객체로 만드는 과정을 파싱한다라고 합니다. json의 형태가 Model과 동일하다면 바로 변환해서 사용할 수도 있겠지만, 서로 구성과 필드명이 다르게 되면 파싱을 통해서 Model 객체를 만드는 과정이 필요합니다.

    json으로부터 Model을 만드는 과정은 보통 2가지 방식있습니다.

    (1) Entity 중간객체를 통해서 만들기

    json 이 전달되면 이것을 1:1로 변환할 수 있도록 모든 필드 구성이 동일한 중간객체인 Entity 를 만듭니다. 그리고 그 Entity를 가지고 Model을 변환하는 과정입니다.

    예를 들어 json이 이렇게 구성되어 있다면,

    {
        "status": 200,
      "data": [
            {
                "hello": "world",
                "answer": 42
            },
            {
                "hello": "world2",
                "answer": 43
            }
        ],
        "createdAt": "2020-01-01T00:00:00Z"
    }

    이것을 그대로 표현할 수 있는 Entity 객체를 정의합니다.

    • Android의 경우

      Entity 객체를 이렇게 정의할 수 있을 겁니다.

        @Serializable
        data class Entity (
            val status: Int,
            val data: List<EntityListItem>,
            val createdAt: String
        )
      
        @Serializable
        data class EntityListItem (
            val hello: String,
            val answer: Int
        )

      이렇게 정의된 entity는 Json Serializer를 통해서

        val entity: Entity = Json.decodeFromString(json)

      이렇게 간단하게 파싱을 완료할 수 있습니다.

      만약 필요한 모델이

        data class Model(
            val answers: List<Int>,
            val createdAt: Date
        )

      이렇게 json의 일부만 차용해서 표현한다고 할 때

        fun entityToModel(entity: Entity): Model {
            return Model(
                                entity.data.map { d -> d.answer },
                                Date.parse(entity.createdAt)
                            )
        }

      과 같은 과정으로 만들어 낼 수 있게 됩니다.

    • iOS의 경우

      Entity 객체를 이렇게 정의할 수 있을 겁니다.

        struct Entity : Codable {
            let status: Int
            let data: [EntityListItem]
            let createdAt: Date
        }
      
        struct EntityListItem : Codable {
            let hello: String
            let answer: Int
        }

      이렇게 정의된 entity는 JSONDecoder를 통해서

        let entity = try! JSONDecoder().decode(Entity.self, from: json)

      이렇게 간단하게 파싱을 완료할 수 있습니다.

      만약 필요한 모델이

        struct Model(
            let answers: [Int],
            let createdAt: Date
        )

      이렇게 json의 일부만 차용해서 표현한다고 할 때

        func entityToModel(entity: Entity): Model {
            return Model(
                                entity.data.map { $0.answer },
                                Date.parse(entity.createdAt)
                            )
        }

      과 같은 과정으로 만들어 낼 수 있게 됩니다.

    이렇게 처리하게 되면 Model과 json과의 결합성을 낮출 수 있게 되고, 서버의 규격 변경에 대응할 때 Model 자체를 변경되지 않거나 분리된 관리로 영향을 최소화 할 수 있게 되는 장점이 있습니다.

    하지만 객체 수가 많아지게 되고 관리포인트도 증가하게 된다는 단점도 있습니다.

    (2) Json 직접 파싱해서 만들기

    두 번째 방식으로는 json 객체를 직접 파싱해서 필요한 정보만을 추출하여 Model 객체를 만드는 방법이 있습니다.

    • Android의 경우

      android 에서 json을 다루기 위해서는 org.json.JSONObject 객체를 사용합니다.

        val jsonObject = JSONObject(json)
        val status = jsonObject.getInt("status")
        val createdAt = Date.parse( jsonObject.getInt("createdAt") )

      이처럼 jsonObject 로부터 필요로하는 값의 key 값으로부터 데이터를 직접 꺼내서 사용하는 방식입니다.

    • iOS의 경우

      iOS 에서는 json 을 Dictionary 로 만들어 처리할 수 있습니다.

        let data = json.data(using: .utf8)!
        let jsonObject = try! JSONSerialization.jsonObject(with: data, options: []) as! [String: Any] 
        let status = jsonObject["status"] as Int
        let createdAt = Date.parse( jsonObject["createdAt"] as String )

      이처럼 JSONSerialization 을 통해 만들어낸 dictionary 로부터 필요로하는 값의 key 값으로부터 데이터를 직접 꺼내서 사용하는 방식입니다.

    이렇게 처리하면 데이터를 직접 사용하게 되니까 직관적이고 짧고 간결한 코드를 유지할 수 있게 되는 장점이 있습니다.

    하지만 json의 모든 타입과 규격을 개발할 때 모두 알고 있어야 하고, 변화에 대응하기 어렵다는 단점도 있습니다.

    위 두 가지 방식 모두 많이 사용되는 방식으로 필요에 따라 적절한 것을 선택하여 사용하면 됩니다.

    이 예에서는 후자의 방식을 사용하였습니다.

    • Android 코드

        private fun parseLectureList(jsonObject: JSONObject): LectureList {
            return jsonObject.getJSONObject("pagination")
                .run {
                    LectureList(
                        getInt("count"),
                        getInt("num_pages"),
                        getString("previous"),
                        getString("next"),
                        jsonObject.getJSONArray("results")
                            .run {
                                mutableListOf<Lecture>()
                                    .apply {
                                        for (i in 0 until length()) {
                                            add(parseLecture(getJSONObject(i)))
                                        }
                                    }
                            }
                    )
                }
        }
      
        private fun parseLecture(jsonObject: JSONObject): Lecture {
            return jsonObject.run {
                Lecture(
                    getString("id"),
                    getString("number"),
                    getString("name"),
                    getString("classfy_name"),
                    getString("middle_classfy_name"),
                    getJSONObject("media").getJSONObject("image").getString("small"),
                    getJSONObject("media").getJSONObject("image").getString("large"),
                    getString("short_description"),
                    getString("org_name"),
                    DateUtil.parseDate(getString("start")),
                    DateUtil.parseDate(getString("end")),
                    if (has("teachers")) getString("teachers") else null,
                    if (has("overview")) getString("overview") else null,
                )
            }
        }
    • iOS 코드

        private func parseLectureList(jsonObject: [String: Any]) -> LectureList {
            return LectureList(count: (jsonObject["pagination"] as! [String: Any])["count"] as! Int,
                               numPages: (jsonObject["pagination"] as! [String: Any])["num_pages"] as! Int,
                               previous: (jsonObject["pagination"] as! [String: Any])["previous"] as? String ?? "",
                               next: (jsonObject["pagination"] as! [String: Any])["next"] as? String ?? "",
                               lectures: (jsonObject["results"] as! [[String: Any]]).map(parseLecture))
        }
      
        private func parseLecture(jsonObject: [String: Any]) -> Lecture {
            return Lecture(id: jsonObject["id"] as! String,
                           number: jsonObject["number"] as! String,
                           name: jsonObject["name"] as! String,
                           classfyName: jsonObject["classfy_name"] as! String,
                           middleClassfyName: jsonObject["middle_classfy"] as! String,
                           courseImage: ((jsonObject["media"] as! [String: Any])["image"] as! [String: Any])["small"] as! String,
                           courseImageLarge: ((jsonObject["media"] as! [String: Any])["image"] as! [String: Any])["large"] as! String,
                           shortDescription: jsonObject["short_description"] as! String,
                           orgName: jsonObject["org_name"] as! String,
                           start: DateUtil.parseDate(jsonObject["start"] as! String),
                           end: DateUtil.parseDate(jsonObject["end"] as! String),
                           teachers: jsonObject["teachers"] as? String,
                           overview: jsonObject["overview"] as? String)
        }

    MVVM 방식

    Model 로 구성된 데이터를 UI에 표현하기 위해서 요즘 많이 사용하는 방식으로 MVVM 방식이 있습니다. MVVM은 View와 Model 사이에 ViewModel 이 위치하게 되면서 화면에 표시할 데이터를 관리하게 됩니다.

    여기서 MVVM의 특징적인 부분은 의존관계가 단방향으로 이루어져 있다는 것입니다. View는 ViewModel을 의존하고만 있습니다. ViewModel은 View를 알지 못하기 때문에 데이터의 변경이 일어나게 되었다는 것을 View측에 전달하기 위해서 DataBinding 방식이 도입됩니다.

    • Android의 경우

      Android 에서 layout.xml 을 view 로 보게 되면서 xml과의 data binding이 이뤄질 수 있습니다.

      그래서 xml을 이렇게 구성하고 사용할 수 있습니다.

      (자세한 사용법은 이 곳을 참조하시면 됩니다.)

        <layout xmlns:android="http://schemas.android.com/apk/res/android"
                xmlns:app="http://schemas.android.com/apk/res-auto">
            <data>
                <variable
                    name="viewmodel"
                    type="com.myapp.data.ViewModel" />
            </data>
            <ConstraintLayout... /> <!-- UI layout's root element -->
        </layout>

      이 때 viewmodel 의 데이터 변경이 일어나는 것을 view 측에서 감지할 수 있도록 LiveData를 사용하도록 권장하고 있습니다.

      이 예에서는 LiveData를 사용해서 progressVisible 과 lecture 리스트의 변경상태를 전달하고 처리하도록 구성하였습니다.

        // KmoocListViewModel.kt
      
        class KmoocListViewModel(private val repository: KmoocRepository) : ViewModel() {
      
            var progressVisible = MutableLiveData<Boolean>()
            var lectureList = MutableLiveData<LectureList>()
      
            fun list() {
                progressVisible.postValue(true)
                repository.list {
                    this.lectureList.postValue(it)
                    progressVisible.postValue(false)
                }
            }
        }
        // KmoocListActivity.kt
      
        class KmoocListActivity : AppCompatActivity() {
                override fun onCreate(savedInstanceState: Bundle?) {
                        ...
                        viewModel.lectureList.observe(this) { lectureList ->
                    adapter.updateLectures(lectureList.lectures)
                    binding.pullToRefresh.isRefreshing = false
                }
                        viewModel.progressVisible.observe(this) { visible ->
                    binding.progressBar.visibility = visible.toVisibility()
                }
                        ...
                }
        }
    • iOS의 경우

      iOS 에서 플랫폼 차원에서 제공되는 data binding은 따로 없습니다. SwiftUI 를 사용하게 되면 Combine을 통해서 가능하지만, Combine을 사용하지 않고 ViewModel 내의 데이터의 변경을 View측에 전달하기 위해서는 클로져를 사용하게 됩니다.

      이 예에서는 Closure를 사용해서 progressVisible 과 lecture 리스트의 변경상태를 전달하고 처리하도록 구성하였습니다.

        // KmoocListViewModel.swift
      
        class KmoocListViewModel: NSObject {
                ...
            var loadingStarted: () -> Void = { }
            var loadingEnded: () -> Void = { }
            var lectureListUpdated: () -> Void = { }
      
            func list() {
                loading = true
                loadingStarted()
                repository.list {
                    self.lectureList = $0
                    self.lectureListUpdated()
                    self.loadingEnded()
                    self.loading = false
                }
            }
        }
        // KmoocListViewController.swift
      
        class KmoocListViewController: UITableViewController {
            override func viewDidLoad() {
                super.viewDidLoad()
      
                        ...
                viewModel.loadingStarted = { [weak activity] in
                    activity?.isHidden = false
                    activity?.startAnimating()
                }
                viewModel.loadingEnded = { [weak activity] in
                    activity?.stopAnimating()
                }
      
                viewModel.lectureListUpdated = { [weak self] in
                    self?.tableView.reloadData()
                    self?.tableView.refreshControl?.endRefreshing()
                }
                viewModel.list()
            }
        }

    정리

    "바퀴를 다시 발명하지 않는다." 라고 합니다. 요즘의 개발 트렌드는 잘 만들어진 라이브러리들을 조합해서 원하는 서비스를 만드는 것이 맞습니다만, 그 라이브러리들은 모두 기본기능들을 조합하여 만들어진 것들입니다. 실제 개발에서는 여러 라이브러리들을 자유롭게 조합하여 사용할 수 있지만, 그 근본이 되는 기본 기능들의 동작을 놓친다면 주객이 전도되는 결과와 같을 것입니다.

    시간제한이 주어진 상황에서 아무리 작은 서비스라도 완성도를 갖추도록 만드는것은 어려운 일입니다. 그래서 베이스코드가 주어진 것이기도 합니다. 베이스코드가 기존에 자신이 작성하던 습관과 맞지 않아 적응하지 못하거나 어렵게 느껴지실 수도 있습니다. 하지만 실제 개발 업무에서는 모든 코드를 처음부터 만들어가는 기회는 쉽게 주어지지 않습니다. 즉, 기존의 코드를 읽고 이해하고 기능을 이어나가는 방식으로 개발하는 경우가 더 많다는 것입니다.

    이러한 여러 상황을 고려해서 의도를 담아 출제했던 과제 입니다. 결과에 안주하거나 실망하기 보다는 과정에서 개발의 즐거움을 찾을 수 있다면 계속 발전하는 개발자가 될 수 있다고 생각합니다. 스티브 잡스가 말했던 것 처럼요.


    앱 데브매칭 문제 다시 풀기

    프로그래머스 사이트 내에'실력 체크' > '과제관'>[앱] K-MOOC 강좌정보 서비스를 클릭하면 2021 Dev-Matching: 앱 개발자에 출제된 과제를 풀 수 있습니다. 앱 과제 이외에도 '과제관'에는 내 실력을 체크할 수 있는 다양한 문제가 준비되어있어요!

    >> 과제관으로 바로 가기 <<

    2021 앱 데브매칭이 궁금하다면?

    Dev-Mathing은 개발자들과 유수의 기업을 이어주는 프로그래머스의 채용 프로그램입니다. 지원자는 하나의 이력서로 최대 5개의 관심 있는 포지션을 선택하여 지원할 수 있으며, 이력서와 테스트 점수가 함께 기업에 전달되어 지원자를 더욱 주목받게 도와주는 프로그램입니다. 자세한 내용이 궁금하시다면 지난 2021 Dev-Matching 앱(상반기) 내용을 한번 확인해보세요!
    >> 지난 2021 Dev-Matching 앱 개발자(상반기) 내용 보러가기 <<

    댓글 0

Programmers