안드로이드에서의 thread(스레드)/MVVM/RecyclerView(리사이클러뷰)

Updated:

목차

  1. 안드로이드에서의 스레드(Thread)
    • 구성
    • 스레드의 3가지 유형
    • 코루틴(Coroutine)이란?
    • 코루틴 키워드
    • 활용
  2. 앱 개발을 대표하는 디자인 패턴: MVVM
    • MVVM 용어의 이해
    • 구현요소
    • 활용
  3. 리사이클러뷰(Recycler View)
    • 개념과 사용 목적
    • 작동원리
    • 활용
  4. 참고

1.스레드(Thread) for Android

구성

  • 스레드: 프로그램 내에서 실행되는 흐름의 단위
    • 단일 작업만 필요하다면? ▶️ 스레드 1개 동작
    • 다중 작업이 필요하다면? ▶️ 스레드 2개 이상 동작
  • 핸들러: 다른 스레드가 보낸 메시지를 받고 처리하는 객체. 스레드 메시지큐와 함께 사용됨
    • 스레드와 핸들러는 1대1 대응
    • 루퍼(Looper)와 연결되어 생성된다.

그림을 보면 핸들러는 스레드에서 발생한 메시지를 받고 해당 메시지를 메시지 큐에 정리합니다. 그리고 루퍼가 메시지큐의 메시지가 순차적으로 처리될 수 있도록 합니다.

예를 들어, 위의 그림 왼쪽에 있는 첫 번째 스레드에서 1부터 50까지 더하는 연산을 하는 메시지가 발생하면 핸들러가 그것을 받아서 메시지 큐에 집어넣겠죠. 그리고 루퍼가 메시지 큐의 상태를 체크하며 순차적으로 큐 안에 들어있는 메시지를 처리시켜줍니다. 그리고 메인스레드에는 단 하나의 스레드만 접근할 수 있기 때문에 루퍼를 통해 큐에 여러 메시지가 들어있어도 하나의 스레드에서 나온 메시지만 동작할 수 있습니다.

그렇다면 왜 메인 스레드를 UI스레드라고 할까요? 그 이유는 말그대로 UI를 제어하는 스레드이기 때문입니다. 우리가 어플을 사용할 때 제일 먼저 UI를 보며 어플의 상태를 인식하게 되죠? 따라서 만약 UI스레드에서 다른 스레드 2개가 동시에 동작하면서 동시에 UI를 바꾸는 작업을 수행하게 되면 사용자에게 보여지는 UI 화면에 혼란이 오게 되니까 이것을 미연에 방지하기 위해 UI스레드 즉, Main스레드에서는 단 하나의 스레드만 접근할 수 있도록 합니다.

💻 활용

val handler = Handler(Looper.getMainLooper()) 
val runnable = Runnable {
  val intent = Intent(this, MainActivity::class.java) startActivity(intent) 
  finish() 
} 
handler.postDelayed(runnable, 3000)
animationView.setOnClickListener{
  handler.removeCallbacks(runnable) 
  val intent = Intent(this, MainActivity::class.java) 
  startActivity(intent) 
  finish() 
}
  • 핸들러는 루퍼와 연결되어 생성되기 때문에 생성 인자로 루퍼를 넘겨준다.

  • getMainLooper(): Main스레드의 Looper를 가져온다는 뜻. 여기서 잠깐! MainLooper를 생성하는 코드는 없는데 어떻게 가져올 수 있을까? ▶️ MainLooper는 안드로이드 자체에서 만들어두기 때문이다. 따라서 Main스레드가 아니라 다른 스레드에서 동작하는 작업이라면 따로 루퍼를 만들어야한다!

  • Runnable: 스레드에서 동작할 수 있는 객체를 구현할 수 있는 인터페이스

  • postDelayed(runnable, long): long의 밀리세컨드만큼 기다린 뒤에 runnable 객체를 수행시켜라 추가적으로 post(바로 실행)와 postAtTime(시간 지정)함수도 있음

  • Handler.removeCallbacks: 메시지큐에 들어있는 메시지를 삭제한다.

스레드의 3가지 유형

  • Main: UI와 상호작용하는 작업을 실행하는데 최적화되어 있다.
  • IO: 디스크 또는 네트워크 I/O 작업을 실행하는데 최적화되어 있다.
  • Default: CPU를 많이 사용하는 작업을 기본 스레드 외부에서 실행하도록 최적화되어 있다. 정렬 작업이나 JSON 파싱 작업 등에 사용된다.

그래서 저는 방학 중 개인 프로젝트할 때 IO쪽은 사용해보지 못하고 UI 작업 들어갈 땐 Main 스레드 유형을 사용하고 데이터베이스에 접근해서 처리하는 작업을 해야할 땐 Default 스레드 유형을 사용했습니다.

여기까지 스레드의 동작 원리와 유형까지 살펴봤는데요. 사실 직접 코딩을 하다보면 이러한 핸들러를 이용한 스레드로 비동기 코드를 짜기 보단 주로 코루틴이라는 것을 이용하게 됩니다. 그럴만한 이유가 있으니까 그렇게들 하는 거겠죠? 그렇다면 핸들러를 사용한 스레드보다 효율적인 코루틴에 대해 알아봅시다.

코루틴이란?

▶️ 스레드/비동기 작업/백그라운드 작업을 할 수 있는 경량스레드이다.

코루틴의 개념을 한 줄로 표현했을 때 이렇게 말할 수 있는데 문장에 강조표시한 것처럼 기존의 스레드와 코루틴의 가장 큰 차이점은 경량인지 아닌지에 있습니다.

제가 스레드와 코루틴의 차이점에 대해서 찾아보다가 본 글에서 가장 인상깊었던 문장이. ‘너는 스레드를 10만개 만들 수는 없지만, 코루틴은 10만개를 만들 수 있다. ‘였는데요. 코루틴이 스레드에 비해서 이렇게 가벼워질 수 있었던 이유에 대해서 알아봅시다.

  • 코루틴은 JVM heap 안의 작은 객체에 연결되어있다. (반면, 스레드는 OS에 해당하는 native스레드와 연결되어있고 자원의 상당부분을 소비해서 CPU에 부담을 주기 쉽다.)
  • 코루틴 전환시 Context Switch가 일어나지 않는다. 애초에 코루틴은 OS 커널에 연결되어있지 않기 때문이다. (반면, 스레드는 OS 커널과 연결되어 있기 때문에 전환시 CPU 사이클을 꽤 소비한다.)
  • 실행과 중단을 지정할 수 있다.

코루틴 키워드

  • Scope
    • Coroutine Scope: 코루틴 블록 묶음, 코루틴으로 제어할 수 있는 단위
    • Global Scope: 컴포넌트의 Lifecycle과 무관하게 싱글톤으로 어플리케이션 생명주기에 따라 동작하는 코루틴

여기서 Global Scope는 되도록 사용을 권장하지 않는 추세인데, 안드로이드 프로그래밍 특성상 라이프사이클과 무관하게 동작한다면 예상치 못한 오류를 발생시키기 쉽기 때문입니다.

예를 들어 앱 화면 하나가 종료됐을 때 그 화면과 관련하여 동작되던 스레드가 모두 종료되어야하는데 라이프 사이클에 상관없이 계속 동작될 수 있는 스레드라면 메모리 누수를 발생시킬 수 있다는 것입니다. 따라서 소개되는 개념에는 Global Scope라는 것이 있지만 최대한 사용하지 않는게 좋겠습니다.

  • Coroutine Context: 실행상태 정보의 집합
    • Job: 1개 이상의 코루틴 동작을 제어함
    • Dispatcher: 코루틴 실행을 특정 스레드로 한정 시킴, 스레드의 제한 없이 실행되도록 함(다양한 스레드에서 실행시킬 수 있다.), 어떤 스레드를 어떻게 동작시킬 것인가 정의

두 가지 다 코루틴 상태 즉, 코루틴 콘텍스트의 정보를 상속 받아서 실행상태를 알고있기 때문에 동작할 수 있다고 볼 수 있습니다. 지금 딱 봐도 Dispatcher가 Job보다 더 중요한 일을 많이 하고 있는 것처럼 보이죠? 맞아요. Dispatcher에 대해서 더 자세하게 알아보겠습니다.


  1. 우선, 유저가 코루틴을 생성해서 Dispatcher에 전송시킵니다.


  2. 코루틴을 받은 디스패처는 자신이 접근할 수 있는 Thread Pool에서 놀고있는 스레드가 어떤 스레드인지 확인한 후 놀고있는 스레드 즉, 현재 작업하지 않는 스레드에 코루틴을 전송합니다.


  3. 분배받은 스레드는 받은 코루틴을 수행합니다.

이런 식으로 디스패처가 코루틴의 동작되는 곳을 정의한다고 이해하면 됩니다. 위에서 스레드를 어떻게 동작시킬 것인가 또한 정의한다고 적어놨는데 이렇게 적어놨다는 건 동작시키는 종류가 나눠져있다는 거겠죠? 동작 종류는 위에서 스레드의 유형으로 한 번 언급했는데 Main/IO/Default 이 3가지 방식으로 나눠서 동작 방식을 지정할 수 있습니다.

  • launch()/async(): 코루틴을 만들고 실행하는 코루틴 빌더
    • launch(): 객체로 Job()을 반환하고 반환 값은 없다.
    • async(): 객체로 Deferred를 반환하고 반환 값이 존재한다.

여기서 job은 위에 서술했던 코루틴 상태값이고 밑의 Deferred는 결과 값을 수신받는 객체입니다. 비동기 작업에 사용되기 때문에 결과값 역시 언제 반환될지 정확한 시점을 알 수 없기 때문에 코루틴이 연산 결과를 도출할 때까지 Deferred 객체는 그 값을 기다리고 해당 값을 받아오는 역할을 합니다.

💻 활용

suspend fun main(){
  val deferred: Deferred<String> = 
  	CoroutineScope(Dispatchers.IO).async {
      "Deferred Result"
    }
  val defferedResult = deferred.await()	// 코루틴 반환 값을 받을 때까지 일시중단
  Log.d("결과 값", deferredResult)
}

위에서부터 찬찬히 봐볼게요.
우선 suspend라는 예약어가 먼저 보이는데 이건 함수가 실행 중단될 수도 있는 함수라는 것을 알려주는 역할을 합니다. 함수 내부에 실행 중단 구문이 들어갔는데 suspend를 붙여주지 않으면 에러가 납니다. 그래서 일단 중단될 수도 있는 함수인 main함수를 정의하려고 블록을 열었습니다.

다음으로 Deferred 객체를 생성했는데 앞에서 말했다시피 async 메서드가 Deferred를 반환하기 때문에 타입을 맞춰준 겁니다. 반환 타입이 문자열인 String이기 때문에 Deferred 객체가 받아들이는 타입도 String으로 생성했습니다.

그 다음 await 메서드를 사용해 코루틴 반환 값을 받을 때까지 함수 실행을 중단시켜주고 defferedResult에 값이 들어온 다음 Log 내용으로 결과 값을 찍는 코드입니다.

우선 여기까지 코틀린 기반 안드로이드의 스레드와 코루틴 주요기능을 살펴봤습니다.
다음으로는 안드로이드에서 가장 중요한 디자인패턴인 MVVM에 대해서 소개하겠습니다.

2.앱 개발 대표 디자인 패턴: MVVM

MVVM 용어의 이해

  • Model-View-Viewmodel의 약어
  • View와 Model을 Viewmodel로 분리하여 뷰가 어느 특정한 모델 플랫폼에 종속되지 않도록 해준다.
  • View와 Viewmodel이 데이터바인딩으로 연결되어 Viewmodel의 속성이 가지는 데이터가 변경될 때 마다 View를 업데이트시켜줄 수 있다.

여기서 View가 일컫는 것은 우리가 어플을 사용하다보면 UI로 표현되는 것들이 있는데 그 중에서 대표적으로 버튼, 텍스트 박스, 이미지 등이 뷰로 다 표현된다고 보시면 됩니다.

이러한 뷰는 Model이라는 벡엔드 로직을 통해서 데이터를 받거나 하는 식으로 동작처리를 하게 되는데 이 과정에서 뷰가 어떤 특정 모델에 종속되지 않도록 Viewmodel이라는 추상화를 통해 뷰와 모델의 상호 의존성을 낮춥니다. 여기까지가 MVVM대로 구현해야하는 의의고, 3번 째에 적혀있는 ‘ Viewmodel의 속성이 가지는 데이터가 변경될 때 마다 View를 업데이트시켜줄 수 있다.’에서 Viewmodel의 속성 데이터가 변경되는 것을 어떻게 View가 알 수 있게하는지에 대하여 알아보겠습니다.

구현요소

  • LiveData: 관찰 가능한 데이터 홀더 클래스. 활성 관찰자에게 업데이트 정보를 알린다.
  • Observer: LiveData 객체가 보유한 데이터 변경 시 발생하는 작업을 제어하는 인터페이스. 코틀린에서는 listener라고도 함.
  • observe: LiveData 클래스의 메서드. LiveData와 Observer를 연결해준다.

데이터가 변경되는 것을 감지하려면 감지 대상인 데이터와 감지 도구가 있어야겠죠? 감지 대상은 LiveData라는 데이터 홀더 클래스를 이용해 지정합니다. 여기서 데이터 홀더 클래스는 말그대로 데이터를 보유하고있는 클래스예요. 따라서 LiveData의 변경을 감지하는 것이고 이것을 감지할 수 있게 해주는 도구가 옵저버 인터페이스입니다. LiveData와 Observer를 연결시켜주는게 observe 메서드고요. 순서대로 말해보자면 다음과 같습니다.

  1. LiveData로 변경될 수 있는 데이터를 가지는 데이터 홀더 클래스를 만든다.
  2. LiveData가 변경되었을 때 동작해야하는 코드를 Observer 인터페이스를 이용해 정의한다.
  3. LiveData의 메서드인 observe로 정의한 Observer 객체를 연결한다.
  4. LiveData가 변경되었을 때 연결된 Observer객체가 변경사항에 관한 알림을 받아 정의된 코드를 실행한다.

구현코드를 보기에 앞서 구현요소들이 View, Viewmodel, Model에서 어떻게 상호작용되는지 그림으로 보겠습니다. 우선 모델은 ViewModel을 통해 업데이트 정보를 받기 때문에 자신의 데이터 공급이 어디서 일어나는지 모르고, 몰라야합니다. 그리고 Model은 자신이 가지고 있는 현재 데이터를 ViewModel에 공급합니다. 그리고 ViewModel은 그것을 자신의 LiveData에 저장합니다. 그런 다음 View에서는 ViewModel의 LiveData가 변경되었을 때 액션을 취해줄 코드를 Observer와 Observe를 이용해 작성합니다.

💻 활용

  • ViewModel
class HomeViewModel(application: Application): AndroidViewModel(application){
    private var listenerRgst:ListenerRegistration? = null
    private var _userSnapshot:MutableLiveData<DocumentSnapshot> = MutableLiveData<DocumentSnapshot>()
    val userSnapshot: LiveData<DocumentSnapshot> = _userSnapshot	// View에는 LiveData만 노출(MutableLiveData X)
    val mapplication = application

    fun getUserSnapshot(){
        if (listenerRgst == null) {
            val firebaseRepo = FirebaseRepository(mapplication)
            listenerRgst = firebaseRepo.getUserSnapshot(_userSnapshot)	// 모델로부터 데이터 읽음
        }
    }

    fun setUserSnapshot(snapshot: DocumentSnapshot){
      	val firebaseRepo = FirebaseRepository(mapplication)
        _userSnapshot.value = snapshot	// LiveData에 변경된 데이터 삽입
      	firebaseRepo.setUserSnapshot(_userSnapshot)	// 모델에 데이터 업데이트
    }

   ...
}
  • View
class HomeFragment : Fragment() {
  ...
  override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        // LiveDate의 value가 it으로 들어감
        homeViewModel.userSnapshot.observe(viewLifecycleOwner){
            initUserView(it)	// userSnapshot이 변경될 때 이 메서드가 동작
        }
    ...
  }
  
  private fun initUserView(snapshot: DocumentSnapshot){...}
  ...
}

제가 개인 플젝으로 짠 코드의 일부라서 전체 코드는 제 깃헙에서 보실 수 있습니다(아래의 참고 링크 참조).
View쪽에서 보시면 지금 observe는 있는데 Observer 인터페이스가 안 보이죠? 이게 어떻게 된 것이냐면 viewLifecycleOwner 뒤에 중괄호치고 코드를 작성한 부분이 Observer인터페이스를 구현해서 바로 넘겨준겁니다. 코틀린에서는 고차함수라는 개념이 있는데 이 고차함수를 사용하면 람다함수를 인자로 받을 수 있어서 지금 Observer 가 들어가야하는 인자자리에 중괄호를 바로 열어서 람다함수를 구현해준 것이라고 이해하시면 되겠습니다.

3.리사이클러뷰(RecyclerView)

개념과 사용목적

  • 스크롤 목록 구현
  • ListView를 개선한 View ▶️ 더 효율적이고 유연하다.
  • 목록을 구성하는 항목들을 재활용(Recycler)한다.
  • 자유롭게 커스터마이징할 수 있다.
  • 집필 시점에 안드로이드 공식 사이트에서 목록을 구현할 때 ListView보다 RecyclerView를 사용하라고 권장하고있다.

안드로이드에는 많은 종류의 View가 있지만 그 중에서 중요한 View 세가지를 뽑으라한다면 RecyclerView가 빠질 수 없다고 생각합니다.

지금부터 설명할 RecyclerView란 이름에서 보여지듯 자신이 가지는 데이터들을 재활용할 수 있는 View입니다. 여기서 RecyclerView가 가지는 데이터란, 목록을 구성하는 항목에 들어가는 데이터입니다.

가장 쉽게 생각할 수 있는 전화번호 목록을 생각해봅시다. 우리가 스크롤하는대로 목록의 각 항목이 보여지게 되죠? 이때 ListView는 일일이 보여지게 되는 항목을 구성했습니다. 올렸다 내렸다를 반복한다면 ListView에서는 항목을 없애고 구성하는 짓을 똑같이 반복하게됩니다. 이것은 성능저하와 커스터마이징의 한계를 야기합니다. 따라서 이 문제들을 해결하기 위해 등장한 것이 RecyclerView라고 보시면 되겠습니다.

RecyclerView는 화면에 보여졌던 항목이 다시 보여질 때 항목 데이터를 다시 구성하지 않습니다. 말그대로 처음에 보여졌던 항목 데이터를 저장하고 재활용합니다. 그렇다면 RecyclerView 안에서 어떻게 데이터들의 재활용이 일어날 수 있을지에 대해 알아보겠습니다.

작동원리

  • LayoutManager: 수직/수평으로 지정된 형식대로 RecyclerView의 데이터를 배치시켜준다. 수직 방향이 디폴트다.
  • Adapter(RecyclerView.Adapter)
    • RecyclerView를 상속받는 클래스
    • 데이터를 아이템(항목)에 제공하는 역할
    • 내부에 RecyclerView.ViewHolder 클래스와 onCreateViewHolder, onBindViewHolder, getItemCount 함수가 포함되어야한다.
    • RecyclerView.ViewHolder ▶️ RecyclerView 내부의 View의 정보를 가지고있는 추상 클래스
    • onCreateViewHolder ▶️ RecyclerView.ViewHolder 객체를 생성하고 반환한다. RecyclerView에서 새 항목이 필요할 때(화면에 새 항목이 보여질 때) 호출된다.
    • onBindViewHolder ▶️ 항목에 포함되어 있는 View의 데이터를 업데이트 해준다. 각 항목의 position을 인자로 받는다. 항목이 재사용될 수 있도록하는 메서드이다.
    • getItemCount ▶️ RecyclerView의 항목 전체 개수를 반환한다.

RecyclerView는 Adapter과 상호작용하며 데이터를 주고받게 됩니다. 따라서 RecyclerView를 구성하기 위해서는 Adapter의 역할이 가장 중요한데요. Adapter 클래스는 RecyclerView.Adapter를 상속 받아 만들어지며 위에서 서술한 클래스와 메서드들이 반드시 들어가야합니다.

Adapter 클래스를 구성하는 것들의 기능을 살펴보면 RecyclerView 항목의 생성, 업데이트 전담하는 것을 확인할 수 있습니다. RecyclerView의 대표적인 구성요소들의 기능을 확인했으니 이제 코드를 보며 활용법에 대해 알아봅시다.

💻 활용

class SendAdapter(private var postLi: ArrayList<Note>)
    : RecyclerView.Adapter<SendAdapter.ViewHolder>(){

    inner class ViewHolder(view: ItemSendRecyclerBinding):
      RecyclerView.ViewHolder(view.root){	// 리사이클러뷰의 뷰홀더를 상속받아 정의
        // 항목 하나를 구성하는(속해있는) View를 받는다.
        val txtFromName = view.txtFromName
        val txtPreview = view.txtPreview
        val circleImg = view.circleImageView
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int):
      ViewHolder {	
        val view =
      ItemSendRecyclerBinding.inflate(LayoutInflater.from(parent.context), 					parent, false)	// 항목 레이아웃 xml의 내용을 가져온다.
        return ViewHolder(view)		// 내부 클래스인 ViewHolder 생성
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
      // 각 항목의 순번 인덱스가 position 인자로 들어간다.
        postLi!!.get(position).let { item ->
            with(holder){
                txtFromName.text = item.name	// 텍스트뷰1 세팅
                val arr = item.text.replace("\n","")
                txtPreview.text = arr	// 텍스트뷰2 세팅
     						circleImg.setImageResource(	// 이미지뷰 세팅
       					MainActivity.profileImgLi[item.profileImg])
                itemView.setOnClickListener {	// 항목 자체가 클릭됐을 때
                    itemClickListener.onItemClick(it, position)
                }
            }
        }
    }

    override fun getItemCount(): Int {	// 항목 전체 개수
        return postLi.size
    }
    
    ...
}

여기서 가장 눈여겨 봐야할 부분은 뷰의 재사용을 위해 호출되는 onBindViewHolder 메서드입니다. onCreateView 메서드는 Adapter 클래스 생성시 딱 한 번만 호출되기 때문에 뷰의 재사용을 위해 호출되지 않습니다.

저는 여기에서 Adapter 클래스를 RecyclerView가 존재하는 클래스 외부에 정의했지만 구현하고자하는 RecyclerView가 간단해서 코드가 길지 않다면 내부 클래스(inner class)로 정의해도 됩니다.

제 깃헙에 내부 클래스로 구현한 코드도 있으니 궁금하시면 확인해보세요(아래 참고 링크에 들어가셔서 깃헙 검색창에 inner class 검색).

4.참고

  • 안드로이드 공식, Thread: https://developer.android.com/reference/java/lang/Thread
  • 안드로이드 공식, Handler: https://developer.android.com/reference/android/os/Handler
  • 안드로이드 공식, Looper: https://developer.android.com/reference/android/os/Looper?hl=nl-BE#getMainLooper()
  • Kotlin Coroutines의 GlobalScope을 어떻게 사용할 수 있을까?: https://thdev.tech/kotlin/2020/12/22/kotlin_effective_16/
  • 코루틴 공식 가이드 자세히 읽기: https://myungpyo.medium.com/%EC%BD%94%EB%A3%A8%ED%8B%B4-%EA%B3%B5%EC%8B%9D-%EA%B0%80%EC%9D%B4%EB%93%9C-%EC%9E%90%EC%84%B8%ED%9E%88-%EC%9D%BD%EA%B8%B0-part-5-62e886f7862d
  • Coroutine의 Dispatcher 란 무엇인가?: https://myungpyo.medium.com/%EC%BD%94%EB%A3%A8%ED%8B%B4-%EA%B3%B5%EC%8B%9D-%EA%B0%80%EC%9D%B4%EB%93%9C-%EC%9E%90%EC%84%B8%ED%9E%88-%EC%9D%BD%EA%B8%B0-part-5-62e886f7862d
  • 안드로이드 공식, Observer: https://developer.android.com/reference/androidx/lifecycle/Observer
  • 작성자 깃헙(코드참고): https://github.com/YeeunLee8245/MyWriting-AndroidApp
  • 위키백과, MVVM: https://ko.wikipedia.org/wiki/%EB%AA%A8%EB%8D%B8-%EB%B7%B0-%EB%B7%B0%EB%AA%A8%EB%8D%B8
  • MVVM 패턴, Observer 패턴: https://jhy156456.tistory.com/entry/MVVM-%ED%8C%A8%ED%84%B4
  • 안드로이드 공식, RecyclerView: https://developer.android.com/reference/androidx/recyclerview/widget/RecyclerView.html
  • 안드로이드 리사이클러뷰 기본 사용법: https://recipes4dev.tistory.com/154

Leave a comment