티스토리 뷰

1. 안드로이드 google map api를 활용한 어플 만들어보기

이번 프로젝트는 구글 맵, sk (t map) pai 를 활용해서 위치 검색 및 현위치를 지도상에서 마커로 나타내주는 안드로이드 앱 입니다. 상호명이나 건물명 등으로 검색을 한 결과를 리스트로 보여주고 유저가 이를 클릭하면 지도 화면으로 이동해서 해당 위치를 마커로 표시합니다.

해당 포스팅은 구현 코드 전부를 설명하지는 않고 🙅‍♂️ 핵심 코드를 정리합니다. 구현 코드 전부가 궁금하신 분들은 글 말미에 첨부드리는 저의 깃허브 저장소를 참고하시기 바랍니다.

주요 기능

  • 위치 (건물, 상호 명 등) 검색 기능
  • 검색한 위치 클릭 시 지도 상에 핀(마커)으로 표시해줌
  • 현재 위치 버튼을 클릭하면 현재 자신의 위치를 보여줌
  • + 무한 스크롤 기능 추가
  • + 위치 권한 거절 시 교육용 팝업 보여줌
  • + 키보드 엔터 시 바로 검색 등.
반응형

사용 기술

  • 코루틴(Coroutines)
  • okhttp3, retrofit2
  • google map
  • intent
  • POI Geo Reverse (현재 내위치 받아오기)
  • Tmap 라이브러리
  • 등.

결과 화면

앱 런치시 간단 텍스트뷰와 검색 버튼이 있습니다. 여기에 주소를 입력하고 검색해주면 검색 목록이 아래 뜨고 이를 클릭하면 클릭한 주소지를 구글 맵에서 보여주게 됩니다.

현재 유저의 위치는 민감정보로 이용자 동의가 반드시 필요하며 동의할 경우 현재 위치로 이동하여 현위치를 보여줍니다. 앱 실행 및 테스트는 AVD에서 진행하였으므로 위치를 임의로 지정해서 테스트 해볼 수 있었습니다.

2. 기본 레이아웃 구성

기본 레이아웃은 activity_main.xml 와 activity_map.xml 으로 구성되어 있습니다.

 

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
	...
    tools:context=".MainActivity">

    <EditText
        android:id="@+id/search_bar_input_view"
        android:inputType="text"
        android:maxLines="1"
        .../>

    <Button
        android:id="@+id/search_button"
        .../>

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler_view"
      	...
        android:scrollbars="vertical"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
        />

    <TextView
        android:id="@+id/empty_result_text_view"
        ... />

    <ProgressBar
        android:id="@+id/progress_circular"
        ... />

</androidx.constraintlayout.widget.ConstraintLayout>

메인 엑티비티는 검색어 입력 텍스트, 검색 버튼, 리사이클러 뷰 등으로 이루어져 있습니다. EditText 의 경우에는 인풋 타입을 텍스트, 최대 라인을 1로 잡아주었고 리사이클러뷰 레이아웃 매니저를 xml 상에서 지정해주었습니다.

activity_map.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
	...>

    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/map_fragment"
        android:name="com.google.android.gms.maps.SupportMapFragment"
        ... />

    <com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/current_location_button"
        ...
        android:src="@drawable/ic_baseline_my_location_24"
        ... />

    <ProgressBar
        android:id="@+id/progress_circular"
        ... />

</androidx.constraintlayout.widget.ConstraintLayout>

맵 엑티비티 에서는 구글맵을 담아줄 FragmentContainerView 를 추가했고 name으로는 구글맵을 지원하는 프레그먼트 명을 지정해주어야합니다. 현재 위치를 나타내는 버튼은 drawable 자원을 하나 추가해 아이콘을 지정해서 넣어주었습니다.

각각의 액티비티 모두 로딩 상태를 표시할 프로그레스 바를 추가해주었습니다.

반응형

3. 리사이클러 뷰 사용 하기 

리사이클러뷰 에서는 검색한 위치 내역이 리스트로 표시되게 할겁니다.

3.1. 뷰 홀더 아이템 레이아웃 정의

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
	...
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <TextView
        android:id="@+id/title_text_view"
        ...
        tools:text="제목" />

    <TextView
        android:id="@+id/subtitle_text_view"
        ...
        tools:text="부제목" />

    <View
        android:id="@+id/divider_view"
        android:layout_width="0dp"
        android:layout_height="1dp"
        android:background="@color/black"
        ... />

</androidx.constraintlayout.widget.ConstraintLayout>

아이템 레이아웃의 경우에는 제목, 부제목 형식으로 위치 명칭, 상세 주소를 담아서 보여줄 것입니다.

3.2. 데이터 클래스 정의 (Entitiy)

@Parcelize
data class SearchResultEntity(
    val fullAddress: String,
    val name: String,
    val locationLatLng: LocationLatLngEntity
) : Parcelable

각각 아이템은 데이터 클래스인 SearchResultEntity 를 바탕으로 구성되게 할 것이며 이는 주소, 주소 명칭 그리고 위도 경도를 Float 형식으로 갖고 있는 LocationLatLngEntity 데이터 클래스를 포함합니다.

3.3. RecyclerView Adapter 정의 하기

다음으로 중요한 어뎁터를 구현해줍니다.

class SearchRecyclerAdapter : RecyclerView.Adapter<SearchRecyclerAdapter.SearchResultViewHolder>() {

    private var searchResultList: List<SearchResultEntity> = listOf()
    var currentPage = 1
    var currentSearchString = ""

    private lateinit var searchResultClickListener: (SearchResultEntity) -> Unit

    inner class SearchResultViewHolder(
        private val binding: ViewholderSearchResultItemBinding,
        private val searchResultClickListener: (SearchResultEntity) -> Unit
    ) : RecyclerView.ViewHolder(binding.root) {
        fun bindData(data: SearchResultEntity) = with(binding) {
            titleTextView.text = data.name
            subtitleTextView.text = data.fullAddress
        }

        fun bindViews(data: SearchResultEntity) {
            binding.root.setOnClickListener {
                searchResultClickListener(data)
            }
        }
    }
 ...

SearchResultEntity 를 리스트로 갖는 searchResultList를 정의해주고 내부에 inner 클래스로 뷰홀더를 정의해주었습니다.

뷰 바인딩을 사용해서 뷰홀더를 생성하며 메서드로 뷰와 데이터를 바인딩 시키는 bindData 와 아이템 클릭 시 발생하는 이벤트 리스너를 지정해줍니다.(bindViews)

searchResultClickListener 를 어뎁터 생성시 지정해 줄 것이며 이를 사용하여 아이템 클릭 이벤트를 처리합니다.

...
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SearchResultViewHolder {
        val binding = ViewholderSearchResultItemBinding.inflate(
            LayoutInflater.from(parent.context),
            parent,
            false
        )
        return SearchResultViewHolder(binding, searchResultClickListener)
    }

    override fun onBindViewHolder(holder: SearchResultViewHolder, position: Int) {
        holder.bindData(searchResultList[position])
        holder.bindViews(searchResultList[position])
    }

    override fun getItemCount(): Int {
        return searchResultList.size
    }
 ...

onCreateViewHolder 에서 뷰 홀더를 생성하는 매커니즘을 정의하고 리스너도 전달해서 넘겨주도록 합니다. searchResultClickListener 를 지정하는 부분은 잠시후 아래에서 설명드리겠습니다.

그리고 재정의 해주어야하는 onBindViewHolder와 getItemCount 메서드도 정의해줍니다.

...
    @SuppressLint("NotifyDataSetChanged")
    fun setSearchResultList(
        searchResultList: List<SearchResultEntity>,
        searchResultClickListener: (SearchResultEntity) -> Unit
    ) {
        this.searchResultList = this.searchResultList + searchResultList
        this.searchResultClickListener = searchResultClickListener
        notifyDataSetChanged()
    }

    fun clearList(){
        searchResultList = listOf()
    }
 ...

검색 결과를 담을 리스트는 setSearchResultList 를 통해 지정하며 이때 searchResultClickListener (리스너) 또한 받아와 지정해줍니다. 리스트의 경우에는 무한 스크롤 기능을 위해서 기존 리스트에 더해주도록 설정하였고 clearList 메서드를 따로 두어 리스트 초기화에 사용하겠습니다.

데이터 변경 시 notifyDataSetChanged 으로 데이터 변경을 알려 뷰를 업데이트 할 수 있도록 해줍시다.

4. Retrofit 사용하기 (T-map api 사용하기)

4.1. service interface 정의하기

interface ApiService {

    companion object {
        const val MAX_PAGE_CONTENT_SIZE = 30
        const val VERSION = 1
        const val START_PAGE = 1
    }

    @GET(Url.GET_TMAP_LOCATION)
    suspend fun getSearchLocation(
        @Header("appKey") appKey: String = Key.TMAP_API,
        @Query("version") version: Int = VERSION,
        @Query("callback") callback: String? = null,
        @Query("page") page: Int = START_PAGE,
        @Query("count") count: Int = MAX_PAGE_CONTENT_SIZE, // 한페이지에 얼마나 나타낼 지
        @Query("searchKeyword") keyword: String, // 검색어
        ...
    ): Response<SearchResponse>

    @GET(Url.GET_TMAP_REVERSE_GEO_CODE)
    suspend fun getReverseGeoCode(
        @Header("appKey") appKey: String = Key.TMAP_API,
        @Query("version") version: Int = VERSION,
        @Query("callback") callback: String? = null,
        @Query("lat") lat: Double,
        @Query("lon") lon: Double,
        ...
    ): Response<AddressInfoResponse>
}
object Url {
    const val TMAP_URL = "https://apis.openapi.sk.com"

    const val GET_TMAP_LOCATION = "/tmap/pois"

    const val GET_TMAP_REVERSE_GEO_CODE = "/tmap/geo/reversegeocoding"
}

레트로핏에 사용할 API service 인터페이스를 정의합니다. base 주소로 부터 요청할 주소를 @GET 키워드로 정의해서 각각 필요한 헤더와 쿼리를 넣어 서버에 요청하게됩니다. 이때 API 키, 검색어 등이 필요합니다. 저는 요청 페이지 컨텐츠를 30개로 제한해서 요청했습니다.

이때 함수를 suspend 함수로 지정해서 코루틴에서 비동기로 처리할 수 있도록 해줍니다. (비동기 처리는 enqueue 를 통해서도 가능함)

각 응답에 해당하는 데이터를 받아올 data class 또한 따로 정의 해줍니다.

4.2. Retrofit 유틸 정의하기

반응형
object RetrofitUtil {

    val apiService: ApiService by lazy { getRetrofit().create(ApiService::class.java) }

    private fun getRetrofit(): Retrofit {

        return Retrofit.Builder()
            .baseUrl(Url.TMAP_URL)
            .addConverterFactory(GsonConverterFactory.create()) // gson으로 파싱
            .client(buildOkHttpClient()) // OkHttp 사용
            .build()
    }
...

object 로 RetrofitUtil 을 정의해줍니다. getRetrofit 에서는 (인스턴스 생성 코드 별도로 필요 없이 바로 호출 사용 가능) Tmap 베이스 주소, gson 컨버터, OkHttp 를 사용해서 레트로핏을 빌드해줍니다. 

apiService 를 사용할때는 아까 위에서 정의해준 서비스 인터페이스인 ApiService를 사용해서 API 구현체를 사용하게 됩니다.

    private fun buildOkHttpClient(): OkHttpClient {
        val interceptor = HttpLoggingInterceptor() // 매번 api 호출 시 마다 로그 확인 할것
        if (BuildConfig.DEBUG) {
            interceptor.level = HttpLoggingInterceptor.Level.BODY
        } else {
            interceptor.level = HttpLoggingInterceptor.Level.NONE
        }
        return OkHttpClient.Builder()
            .connectTimeout(5, TimeUnit.SECONDS) // 5초 동안 응답 없으면 에러
            .addInterceptor(interceptor)
            .build()
    }
}
...

OkHttp 를 사용해서 api 호출 결과를 매번 로그를 통해 확인할 수 있도록 해줍니다. 이때 5초 동안 응답이 없으면 에러가 발생하도록 했습니다.

4.3. 코루틴을 사용한 비동기 요청 처리

class MainActivity : AppCompatActivity(), CoroutineScope {
    ...
    override val coroutineContext: CoroutineContext
        get() = Dispatchers.Main + job
    ...

메인 엑티비티에서 CoroutineScope 구현 코루틴 컨텍스트 프로퍼티를 재정의

    private fun searchWithPage(keywordString: String, page: Int) {
        launch(coroutineContext) {
            try {
                ...
                // IO 스레드 사용
                withContext(Dispatchers.IO) {
                    val response = RetrofitUtil.apiService.getSearchLocation(
                        keyword = keywordString,
                        page = page
                    )
                    if (response.isSuccessful) {
                        val body = response.body()
                        // Main (UI) 스레드 사용
                        withContext(Dispatchers.Main) {
                            ...
                            body?.let { searchResponse ->
                                setData(searchResponse.searchPoiInfo, keywordString)
                            }
                        }
                    }
                }
            } catch (e: Exception) {
                ...
            } finally {
                ...
            }
        }
    }

검색 버튼이 눌리면 입력한 검색어를 바탕으로 검색을 실시합니다.

미리 정의 해준 coroutineContext 로 launch 해서 코루틴을 시작합니다. 네트워크 작업을 할때는 withContext(Dispatchers.IO) 를 통해 IO 스레드를 사용해서 처리해줍니다.

반응형

레트로핏을 사용해서 티맵 api 를 통해 지도 검색을 합니다. 요청 body가 응답 성공이라면 이를 통해서 데이터를 파싱하여 리사이클러 뷰의 어뎁터에 데이터를 등록하게됩니다. 이때는 UI 스레드 즉, 메인 스레드를 사용합니다.

어뎁터에 데이터 리스트 갱신

    private fun setData(searchInfo: SearchPoiInfo, keywordString: String) {

        ...
        adapter.setSearchResultList(dataList) {
            ...

            // map 액티비티 시작
            startActivity(Intent(this, MapActivity::class.java).apply {
                putExtra(SEARCH_RESULT_EXTRA_KEY, it)
            })
        }
        ...
    }

어뎁터에 데이터 리스트를 갱신(등록) 해줄 때는 해당 아이템의 클릭 리스너를 같이 지정하는데 이때 아이템 클릭 시 해당 데이터에 맞는 지도가 Map Activity 에서 보여지도록 해당 위치 데이터 entitiy 를 인텐트의 Extra에 넣어서 실행합니다.

5. 구글 맵 사용하기

메인 엑티비티에서 검색 결과 아이템을 클릭하면 해당 데이터를 바탕으로 맵 엑티비티에서 구글맵을 표시합니다. 검색 위치를 표시하는 과정은 아래와 같이 이루어집니다.

맵 엑티비티 실행 -> getParcelableExtra 로 SearchResultEntity 를 받아옴 -> OnMapReadyCallback 을 구현한 맵 엑티비티에서 onMapReady 를 처리 -> 이때 마커를 만들고 지도에서 이를 표시

검색 결과 entitiy 가져오기

    override fun onCreate(savedInstanceState: Bundle?) {
        ...

        if (::searchResult.isInitialized.not()) {
            intent?.let {
                searchResult = it.getParcelableExtra<SearchResultEntity>(SEARCH_RESULT_EXTRA_KEY)
                    ?: throw Exception("데이터가 존재하지 않습니다.")
                setupGoogleMap()
            }
        }

        ...
    }

SupportMapFragment 가져와서 callback 전달

    private fun setupGoogleMap() {
        val mapFragment =
            supportFragmentManager.findFragmentById(binding.mapFragment.id) as SupportMapFragment
        
        mapFragment.getMapAsync(this) // callback 구현 (onMapReady)
    }

onMapReady 콜백을 재정의

    override fun onMapReady(map: GoogleMap) {
        this.map = map
        currentSelectMarker = setupMarker(searchResult)

        currentSelectMarker?.showInfoWindow()
    }

이때 searchResult 를 사용해서 마커를 설정하고 이를 보여줌

구글맵 마커 만들기

    private fun setupMarker(searchResult: SearchResultEntity): Marker {

        // 구글맵 전용 위도/경도 객체
        val positionLatLng = LatLng(
            searchResult.locationLatLng.latitude.toDouble(),
            searchResult.locationLatLng.longitude.toDouble()
        )

        // 구글맵 마커 객체 설정
        val markerOptions = MarkerOptions().apply {
            position(positionLatLng)
            title(searchResult.name)
            snippet(searchResult.fullAddress)
        }

        // 카메라 줌 설정
        map.moveCamera(CameraUpdateFactory.newLatLngZoom(positionLatLng, CAMERA_ZOOM_LEVEL))

        return map.addMarker(markerOptions)
    }

위도와 경도 정보를 가지고 LatLng 객체를 하나 만들어주고 마커 객체 옵션을 MarkerOptions() 를 통해 생성해줍니다. 위치, 제목, 스니펫 등을 설정하고 맵의 moveCamera 를 통해 줌을 설정, 위치를 지정해준뒤 마커를 추가해서 반환해줍니다.

반응형

6. LocationManager 를 사용한 내위치 현위치 가져오기

private lateinit var locationManager: LocationManager

위치 매니저 프로퍼티를 하나 정의합니다. 위치 매니저는 앱 프레임워크에 해당하는데 앱이 위치 변경 정보를 수신할 수 있게 해준다. (수신할 수 있는 서비스를 사용할 수 있게 한다.)

    private fun getMyLocation() {
        // 위치 매니저 초기화
        if (::locationManager.isInitialized.not()) {
            locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager
        }

        // GPS 이용 가능한지
        val isGpsEnable = locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)
    ...

getMyLocation 메서드를 정의한다. 이 메서드는 현위치 버튼의 onClick 시 호출되게 된다.

먼저 위치 매니저의 초기화가 안되었다면 (lateinit 이라 초기화 필요) 초기화를 해준다.

그 다음 GPS 이용이 가능한지 확인하고 GPS 이용이 불가능하다면 권한을 얻어야한다.

        // 권한 얻기
        if (isGpsEnable) {
            when {
                shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION) && shouldShowRequestPermissionRationale(
                    Manifest.permission.ACCESS_COARSE_LOCATION
                ) -> {
                    showPermissionContextPop()
                }

                ContextCompat.checkSelfPermission(
                    this,
                    Manifest.permission.ACCESS_FINE_LOCATION
                ) != PackageManager.PERMISSION_GRANTED && ContextCompat.checkSelfPermission(
                    this,
                    Manifest.permission.ACCESS_COARSE_LOCATION
                ) != PackageManager.PERMISSION_GRANTED -> {
                    makeRequestAsync()
                }

                else -> {
                    setMyLocationListener()
                }
            }
        }
    }

권한이 전에 한번 거절 되었다면 권한이 왜 필요한지 설명할 팝업을 띄우고 권한이 부여되지 않은 경우에는 퍼미션 요청 작업을 진행합니다.

    private fun makeRequestAsync() {
        // 퍼미션 요청 작업. 아래 작업은 비동기로 이루어짐
        ActivityCompat.requestPermissions(
            this,
            arrayOf(
                Manifest.permission.ACCESS_FINE_LOCATION,
                Manifest.permission.ACCESS_COARSE_LOCATION
            ),
            PERMISSION_REQUEST_CODE
        )
    }

권한이 모두 주어졌다면 이제 위치 정보를 받아옵시다.

먼저 현재 위치가 변경되는 이벤트를 받아올 리스너 클래스를 하나 정의합니다.

    inner class MyLocationListener : LocationListener {
        override fun onLocationChanged(location: Location) {
            // 현재 위치 콜백
            val locationLatLngEntity = LocationLatLngEntity(
                location.latitude.toFloat(),
                location.longitude.toFloat()
            )

            onCurrentLocationChanged(locationLatLngEntity)
        }

    }

여기서는 onLocationChanged 를 재정의 해주어 위치가 변경되는 경우에 처리를 구현합니다.

 

그리고 현재 위치를 요청하도록 합니다.

    private fun setMyLocationListener() {
        val minTime = 3000L // 현재 위치를 불러오는데 기다릴 최소 시간
        val minDistance = 100f // 최소 거리 허용

        // 로케이션 리스너 초기화
        if (::myLocationListener.isInitialized.not()) {
            myLocationListener = MyLocationListener()
        }

        // 현재 위치 업데이트 요청
        with(locationManager) {
            requestLocationUpdates(
                LocationManager.GPS_PROVIDER,
                minTime,
                minDistance,
                myLocationListener
            )
            requestLocationUpdates(
                LocationManager.NETWORK_PROVIDER,
                minTime,
                minDistance,
                myLocationListener
            )
        }
    }

현재 위치를 요청할때 myLocationListener 를 리스너로 전달해서 현재 위치를 요청하고 현위치가 변경되는 경우 onCurrentLocationChanged 가 호출되서 위치를 업데이트 할 수 있습니다.

    private fun onCurrentLocationChanged(locationLatLngEntity: LocationLatLngEntity) {
        map.moveCamera(
            CameraUpdateFactory.newLatLngZoom(
                LatLng(
                    locationLatLngEntity.latitude.toDouble(),
                    locationLatLngEntity.longitude.toDouble()
                ), CAMERA_ZOOM_LEVEL
            )
        )

        loadReverseGeoInformation(locationLatLngEntity)
        removeLocationListener() // 위치 불러온 경우 더이상 리스너가 필요 없으므로 제거
    }

onCurrentLocationChanged 에서는 위치를 현재 위치로 이동시키고 loadReverseGeoInformation 으로 위도 경도 정보로 해당 위치의 지역명을 T-map 서버에서 가져오도록 요청해서 지도에 마커로 표시합니다.

    private fun removeLocationListener() {
        if (::locationManager.isInitialized && ::myLocationListener.isInitialized) {
            locationManager.removeUpdates(myLocationListener)
        }
    }

현재 위치를 불러온 경우에는 myLocationListener 를 업데이트 대상에서 지워줍니다.

기타. 리사이클러뷰 무한 스크롤 구현하기

처음 부터 검색 결과를 모두 나타내기에는 과부하가 걸릴 수 있으니 30개만 보여주고 유저가 스크롤할 경우에 계속해서 +30 개씩 무한 로드해서 보여줄 수 있도록 구현했습니다.

    private fun initViews() = with(binding) {
        ...

        // 무한 스크롤 기능 구현
        recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                super.onScrolled(recyclerView, dx, dy)
                recyclerView.adapter ?: return

                val lastVisibleItemPosition =
                    (recyclerView.layoutManager as LinearLayoutManager).findLastVisibleItemPosition()
                val totalItemCount = recyclerView.adapter!!.itemCount - 1

                // 페이지 끝에 도달한 경우
                if (!recyclerView.canScrollVertically(1) && lastVisibleItemPosition == totalItemCount) {
                    loadNext()

                }
            }
        })
    }

방법은 리사이클러뷰에 스크롤 리스너를 달아서 페이지 끝에 도달한 경우에 다음 페이지를 로딩하도록 구현해주었습니다.

위 코드는 리사이클러뷰 스크롤 영역이 끝에 도달한 경우 loadNext()를 호출합니다.

반응형

2021.08.02 - [Android/App] - [Android] 뮤직 플레이어 앱 만들기 with ExoPlayer, Kotlin

2021.07.13 - [Android/App] - [Android] Youtube 앱 만들어보기 (ExoPlayer, MotionLayout)

2021.06.30 - [Android/App] - [Android] 에어비앤비 앱 만들어 보기 (네이버 지도 api, mocky 등)

해당 앱 프로젝트 전체 코드는 저의 깃허브 저장소에서 확인하실 수 있습니다.

댓글
최근에 올라온 글
최근에 달린 댓글
네이버 이웃추가
«   2024/12   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30 31
글 보관함