Develop/Kotlin

[kotlin] 코틀린 Android Retrofit 활용(기상청단기예보 오픈 API 사용)

JunJangE 2022. 4. 19. 20:41

Retofit이란?

이번에는 Retofit 라이브러리를 이용하여 기상청 단기예보 오픈 API와 통신하는 방법에 대해서 알아보도록 하자.

Retofit이란 모바일에서 HTTP API 통신을 할 때 사용하는 라이브러리이다. 안드로이드 애플리케이션에서 통신 기능에 사용하는 코드를 사용하기 쉽게 만들어놓은 라이브러리로 REST 기반의 웹서비스를 통해 JSON 구조를 쉽게 가져오고 업로드할 수 있다.

다음에 나올 코드를 보다 보면 기상청 단기예보 오픈 API Key가 숨겨진 것을 확인할 수 있는데 API Key 숨기는 법을 모른다면 다음 링크를 통해서 글을 읽어보고 오면 좋을 것 같다.

 

[kotlin] 코틀린 Android local.properties에 API Key 숨기고 활용하는 방법

이번에는 local.properties에 API Key를 숨기고 활용하는 방법에 대해서 알아보자. 개발하다 보면 많은 API Key를 사용하게 되는데, 이런 많은 Key를 가지고 Git이나 오픈되어 있는 공간에서 작업을 하다

fre2-dom.tistory.com

폴더와 파일 위치

폴더와 파일에 위치는 위 이미지와 같이 구성했다.

라이브러리

Retrofit 라이브러리를 build.gradle(app)에 추가한다.

    // data binding
    buildFeatures {
        dataBinding = true
    }
}

dependencies {

	// retrofit2
	implementation 'com.squareup.retrofit2:retrofit:2.9.0'
	implementation 'com.squareup.retrofit2:converter-gson:2.9.0'

	// 위치
	implementation 'com.google.android.gms:play-services-location:19.0.1'

converter-gson 은 JSON을 object형식으로 변환하도록 도와준다.

다음으로 AndroidMainfest.xml에 들어가 인터넷 권한과 위치 권한을 추가한다.

인터넷 권한에 경우 http 통신을 위한 것이고 위치 권한은 현재 위치 기준으로 날씨 예보를 받기 위해서이다.

<!--날씨 API 가져오기-->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

<application
        android:usesCleartextTraffic="true"

android:usesCleartextTraffic="true"로 설정하는 이유로는

cleartext HTTP와 같은 cleartext 네트워크 트래픽을 사용할지 여부를 나타내는 flag로 이 플래그가 flase로 되어 있으면, 플랫폼 구성 요소 (예 : HTTP 및 FTP 스택, DownloadManager, MediaPlayer)는 일반 텍스트 트래픽 사용에 대한 앱의 요청을 거부하게 되기 때문이다. 이 flag를 설정하게 되면 모든 cleartext 트래픽은 허용 처리가 된다.

ModelWeather

import com.google.gson.annotations.SerializedName

// 날씨 정보를 담는 데이터 클래스
data class ModelWeather (
    @SerializedName("rainType") var rainType: String = "",      // 강수 형태
    @SerializedName("humidity") var humidity: String = "",      // 습도
    @SerializedName("sky") var sky: String = "",           // 하능 상태
    @SerializedName("temp") var temp: String = "",          // 기온
    @SerializedName("fcstTime") var fcstTime: String = "",      // 예보시각
)

// xml 파일 형식을 data class로 구현
data class WEATHER (val response : RESPONSE)
data class RESPONSE(val header : HEADER, val body : BODY)
data class HEADER(val resultCode : Int, val resultMsg : String)
data class BODY(val dataType : String, val items : ITEMS, val totalCount : Int)
data class ITEMS(val item : List<ITEM>)

// category : 자료 구분 코드, fcstDate : 예측 날짜, fcstTime : 예측 시간, fcstValue : 예보 값
data class ITEM(val category : String, val fcstDate : String, val fcstTime : String, val fcstValue : String)

 

WeatherInterface

import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Query

// 결과 xml 파일에 접근해서 정보 가져오기
interface WeatherInterface {
    // getUltraSrtFcst : 초단기 예보 조회 + 인증키
    @GET(BuildConfig.API_KEY)
    fun getWeather(
        @Query("numOfRows") num_of_rows: Int,   // 한 페이지 경과 수
        @Query("pageNo") page_no: Int,          // 페이지 번호
        @Query("dataType") data_type: String,   // 응답 자료 형식
        @Query("base_date") base_date: String,  // 발표 일자
        @Query("base_time") base_time: String,  // 발표 시각
        @Query("nx") nx: Int,                // 예보지점 X 좌표
        @Query("ny") ny: Int                 // 예보지점 Y 좌표
    ): Call<WEATHER>
}

레트로핏에서 Service는 API를 정의하는 인터페이스를 말한다.
어떤 형태와 방식으로 통신을 할지 어노테이션과 파라미터를 지정하면 된다.
Service는 기본적으로 Call<> 객체를 반환한다.
서버의 api가 String을 반환한다고 가정하면
클라이언트는 Retrofit을 통해 Call<String.>을 받게 된다.

WeatherObject

서비스가 인터페이스 형식 -> API를 call할때 WeatherInterface를 이용할 수 없다. 따라서, retrofit 구현체가 필요하다.

import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

object WeatherObject {
    private fun getRetrofit(): Retrofit{
        return Retrofit.Builder()
            .baseUrl(BuildConfig.URL_WEATHER)
            .addConverterFactory(GsonConverterFactory.create()) // Json데이터를 사용자가 정의한 Java 객채로 변환해주는 라이브러리
            .build()
    }
    fun getRetrofitService(): WeatherInterface{
        return  getRetrofit().create(WeatherInterface::class.java) //retrofit객체 만듦!
    }


}

getRetrofit()에서 사용할 기본 URL(local.properties에 작성했던 url) 설정과 json데이터를 받기 위해 GsonConverterFactory를 설정해 주고 반환해준다.

이제 api service를 받아서 사용할 수 있도록 getRetrofitService 함수를 다음과 같이 작성하여 반환해준다.

WeatherAdapter

import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView

class WeatherAdapter (var items : Array<ModelWeather>) : RecyclerView.Adapter<WeatherAdapter.ViewHolder>() {
    // 뷰 홀더 만들어서 반환, 뷰릐 레이아웃은 list_item_weather.xml
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WeatherAdapter.ViewHolder {
        val itemView = LayoutInflater.from(parent.context).inflate(R.layout.list_item_weather, parent, false)
        return ViewHolder(itemView)
    }

    // 전달받은 위치의 아이템 연결
    override fun onBindViewHolder(holder: WeatherAdapter.ViewHolder, position: Int) {
        val item = items[position]
        holder.setItem(item)
    }

    // 아이템 갯수 리턴
    override fun getItemCount() = items.count()

    // 뷰 홀더 설정
    inner class ViewHolder(itemView : View) : RecyclerView.ViewHolder(itemView) {
        @SuppressLint("SetTextI18n")
        fun setItem(item : ModelWeather) {
            val imgWeather = itemView.findViewById<ImageView>(R.id.imgWeather)  // 날씨 이미지
            val tvTime = itemView.findViewById<TextView>(R.id.tvTime)           // 시각
            val tvHumidity = itemView.findViewById<TextView>(R.id.tvHumidity)   // 습도
            val tvTemp = itemView.findViewById<TextView>(R.id.tvTemp)           // 온도

            
            imgWeather.setImageResource(getRainImage(item.rainType, item.sky))
            tvTime.text = getTime(item.fcstTime)
            tvHumidity.text = item.humidity +"%"
            tvTemp.text = item.temp + "°"
        }
    }

    fun getTime(factTime : String): String {
        if(factTime != "지금"){
            var hourSystem : Int = factTime.toInt()
            var hourSystemString = ""


            if(hourSystem == 0){
                return "오전 12시"
            }else if(hourSystem > 2100){
                hourSystem -= 1200
                hourSystemString = hourSystem.toString()
                return "오후 ${hourSystemString[0]}${hourSystemString[1]}시"


            }else if(hourSystem == 1200){
                return "오후 12시"
            } else if(hourSystem > 1200){
                hourSystem -= 1200
                hourSystemString = hourSystem.toString()
                return "오후 ${hourSystemString[0]}시"

            }

            else if(hourSystem >= 1000){
                hourSystemString = hourSystem.toString()

                return "오전 ${hourSystemString[0]}${hourSystemString[1]}시"
            }else{

                hourSystemString = hourSystem.toString()

                return "오전 ${hourSystemString[0]}시"

            }

        }else{
            return factTime
        }


    }
    // 강수 형태
    fun getRainImage(rainType : String, sky: String) : Int {
        return when(rainType) {
            "0" -> getWeatherImage(sky)
            "1" -> R.drawable.rainy
            "2" -> R.drawable.hail
            "3" -> R.drawable.snowy
            "4" -> R.drawable.brash
            else -> getWeatherImage(sky)
        }
    }

    fun getWeatherImage(sky : String) : Int {
        // 하늘 상태
        return when(sky) {
            "1" -> R.drawable.sun                       // 맑음
            "3" ->  R.drawable.cloudy                     // 구름 많음
            "4" -> R.drawable.blur                 // 흐림
            else -> R.drawable.ic_launcher_foreground   // 오류
        }
    }
    

}

리사이클러뷰에 들어갈 아이템 뷰를 설정한다.

Common

import android.graphics.Point

class Common {
    // baseTime 설정하기
    fun getBaseTime(h : String, m : String) : String {
        var result = ""

        // 45분 전이면
        if (m.toInt() < 45) {
            // 0시면 2330
            if (h == "00") result = "2330"
            // 아니면 1시간 전 날씨 정보 부르기
            else {
                var resultH = h.toInt() - 1
                // 1자리면 0 붙여서 2자리로 만들기
                if (resultH < 10) result = "0" + resultH + "30"
                // 2자리면 그대로
                else result = resultH.toString() + "30"
            }
        }
        // 45분 이후면 바로 정보 받아오기
        else result = h + "30"

        return result
    }

    // 위경도를 기상청에서 사용하는 격자 좌표로 변환
    fun dfsXyConv(v1: Double, v2: Double) : Point {
        val RE = 6371.00877     // 지구 반경(km)
        val GRID = 5.0          // 격자 간격(km)
        val SLAT1 = 30.0        // 투영 위도1(degree)
        val SLAT2 = 60.0        // 투영 위도2(degree)
        val OLON = 126.0        // 기준점 경도(degree)
        val OLAT = 38.0         // 기준점 위도(degree)
        val XO = 43             // 기준점 X좌표(GRID)
        val YO = 136            // 기준점 Y좌표(GRID)
        val DEGRAD = Math.PI / 180.0
        val re = RE / GRID
        val slat1 = SLAT1 * DEGRAD
        val slat2 = SLAT2 * DEGRAD
        val olon = OLON * DEGRAD
        val olat = OLAT * DEGRAD

        var sn = Math.tan(Math.PI * 0.25 + slat2 * 0.5) / Math.tan(Math.PI * 0.25 + slat1 * 0.5)
        sn = Math.log(Math.cos(slat1) / Math.cos(slat2)) / Math.log(sn)
        var sf = Math.tan(Math.PI * 0.25 + slat1 * 0.5)
        sf = Math.pow(sf, sn) * Math.cos(slat1) / sn
        var ro = Math.tan(Math.PI * 0.25 + olat * 0.5)
        ro = re * sf / Math.pow(ro, sn)

        var ra = Math.tan(Math.PI * 0.25 + (v1) * DEGRAD * 0.5)
        ra = re * sf / Math.pow(ra, sn)
        var theta = v2 * DEGRAD - olon
        if (theta > Math.PI) theta -= 2.0 * Math.PI
        if (theta < -Math.PI) theta += 2.0 * Math.PI
        theta *= sn

        val x = (ra * Math.sin(theta) + XO + 0.5).toInt()
        val y = (ro - ra * Math.cos(theta) + YO + 0.5).toInt()

        return Point(x, y)
    }
}

현재 위치를 격자 좌표로 변환해주기 위해서 위 코드를 사용한다.

MainActivity

import androidx.databinding.DataBindingUtil.setContentView
import retrofit2.Call
import retrofit2.Response
import java.text.SimpleDateFormat
import java.util.*
import android.Manifest
import android.annotation.SuppressLint
import android.os.Bundle
import android.util.Log
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import android.widget.Toast
import android.graphics.Point
import android.os.Build
import android.os.Looper
import androidx.annotation.RequiresApi
import com.google.android.gms.location.LocationCallback
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationResult
import com.google.android.gms.location.LocationServices


class MainActivity : AppCompatActivity() {

    private var baseDate = "20210510"  // 발표 일자
    private var baseTime = "1400"      // 발표 시각
    private var curPoint : Point? = null    // 현재 위치의 격자 좌표를 저장할 포인트


    lateinit var binding: ActivityMainBinding
    @SuppressLint("SetTextI18n", "MissingPermission")
    @RequiresApi(Build.VERSION_CODES.S)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = setContentView(this, R.layout.activity_main)
        binding.mainActivity = this

        // Get permission
        val permissionList = arrayOf<String>(
            // 위치 권한
            Manifest.permission.ACCESS_FINE_LOCATION,
            Manifest.permission.ACCESS_COARSE_LOCATION,
        )

        // 권한 요청
        ActivityCompat.requestPermissions(this@MainActivity, permissionList, 1)


        // 오늘 날짜 텍스트뷰 설정
        binding.tvDate.text = SimpleDateFormat("MM월 dd일", Locale.getDefault()).format(Calendar.getInstance().time) + "날씨"

        requestLocation()

        // <새로고침> 버튼 누를 때 위치 정보 & 날씨 정보 다시 가져오기
        binding.btnRefresh.setOnClickListener {
            requestLocation()
        }


    }


    // 날씨 가져와서 설정하기
    private fun setWeather(nx : Int, ny : Int) {
        // 준비 단계 : base_date(발표 일자), base_time(발표 시각)
        // 현재 날짜, 시간 정보 가져오기
        val cal = Calendar.getInstance()
        baseDate = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(cal.time) // 현재 날짜
        val timeH = SimpleDateFormat("HH", Locale.getDefault()).format(cal.time) // 현재 시각
        val timeM = SimpleDateFormat("HH", Locale.getDefault()).format(cal.time) // 현재 분
        // API 가져오기 적당하게 변환
        baseTime = Common().getBaseTime(timeH, timeM)
        // 현재 시각이 00시이고 45분 이하여서 baseTime이 2330이면 어제 정보 받아오기
        if (timeH == "00" && baseTime == "2330") {
            cal.add(Calendar.DATE, -1).toString()
            baseDate = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(cal.time)
        }

        // 날씨 정보 가져오기
        // (한 페이지 결과 수 = 60, 페이지 번호 = 1, 응답 자료 형식-"JSON", 발표 날싸, 발표 시각, 예보지점 좌표)
        val call = WeatherObject.getRetrofitService().getWeather(60, 1, "JSON", baseDate, baseTime, nx, ny)

        // 비동기적으로 실행하기
        call.enqueue(object : retrofit2.Callback<WEATHER> {
            // 응답 성공 시
            override fun onResponse(call: Call<WEATHER>, response: Response<WEATHER>) {
                if (response.isSuccessful) {
                    // 날씨 정보 가져오기
                    val it: List<ITEM> = response.body()!!.response.body.items.item

                    // 현재 시각부터 1시간 뒤의 날씨 6개를 담을 배열
                    val weatherArr = arrayOf(ModelWeather(), ModelWeather(), ModelWeather(), ModelWeather(), ModelWeather(), ModelWeather())

                    // 배열 채우기
                    var index = 0
                    val totalCount = response.body()!!.response.body.totalCount - 1
                    for (i in 0..totalCount) {
                        index %= 6
                        when(it[i].category) {
                            "PTY" -> weatherArr[index].rainType = it[i].fcstValue     // 강수 형태
                            "REH" -> weatherArr[index].humidity = it[i].fcstValue     // 습도
                            "SKY" -> weatherArr[index].sky = it[i].fcstValue          // 하늘 상태
                            "T1H" -> weatherArr[index].temp = it[i].fcstValue         // 기온
                            else -> continue
                        }
                        index++
                    }

                    weatherArr[0].fcstTime = "지금"
                    // 각 날짜 배열 시간 설정
                    for (i in 1..5) weatherArr[i].fcstTime = it[i].fcstTime

                    // 리사이클러 뷰에 데이터 연결
                    binding.weatherRecyclerView.adapter = WeatherAdapter(weatherArr)

                    // 토스트 띄우기
                    Toast.makeText(applicationContext, it[0].fcstDate + ", " + it[0].fcstTime + "의 날씨 정보입니다.", Toast.LENGTH_SHORT).show()
                }
            }

            // 응답 실패 시
            override fun onFailure(call: Call<WEATHER>, t: Throwable) {

                binding.tvError.text = "api fail : " +  t.message.toString() + "\n 다시 시도해주세요."
                binding.tvError.visibility = View.VISIBLE
                Log.d("api fail", t.message.toString())
            }
        })
    }

    // 내 현재 위치의 위경도를 격자 좌표로 변환하여 해당 위치의 날씨정보 설정하기
    @SuppressLint("MissingPermission")
    private fun requestLocation() {
        val locationClient = LocationServices.getFusedLocationProviderClient(this@MainActivity)

        try {
            // 나의 현재 위치 요청
            val locationRequest = LocationRequest.create()
            locationRequest.run {
                priority = LocationRequest.PRIORITY_HIGH_ACCURACY
                interval = 60 * 1000    // 요청 간격(1초)
            }
            val locationCallback = object : LocationCallback() {
                // 요청 결과
                override fun onLocationResult(p0: LocationResult) {
                    p0.let {
                        for (location in it.locations) {


                            // 현재 위치의 위경도를 격자 좌표로 변환
                            curPoint = Common().dfsXyConv(location.latitude, location.longitude)

                            // 오늘 날짜 텍스트뷰 설정
                            binding.tvDate.text = SimpleDateFormat("MM월 dd일", Locale.getDefault()).format(Calendar.getInstance().time) + " 날씨"
                            // nx, ny지점의 날씨 가져와서 설정하기
                            setWeather(curPoint!!.x, curPoint!!.y)
                        }
                    }
                }
            }

            // 내 위치 실시간으로 감지
            Looper.myLooper()?.let {
                locationClient.requestLocationUpdates(locationRequest, locationCallback,
                    it)
            }


        } catch (e : SecurityException) {
            e.printStackTrace()
        }
    }




}

이제 API를 사용할 곳에서 WeatherInterface 인터페이스를 통해 요청을 보내고 Callback을 통해 응답을 받아서 ModelWeather에 날씨 정보를 넣고 리사이클 러뷰 설정한 부분이다.

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>

<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">
    <data>
        <variable
            name="mainActivity"
            type="com.junjange.retrofitactivity.MainActivity" />


    </data>

<LinearLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity"
    android:gravity="center"
    android:orientation="vertical">

    <TextView
        android:id="@+id/tvDate"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="mm/dd의 날씨"
        android:textSize="30dp"
        android:textColor="@color/black"
        android:layout_margin="10dp"
        android:gravity="center"/>

    <TextView
        android:id="@+id/tvError"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="에러 : "
        android:textSize="30dp"
        android:textColor="@color/black"
        android:layout_margin="10dp"
        android:gravity="center"
        android:visibility="gone" />


    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/weatherRecyclerView"
        android:orientation="horizontal"
        android:layout_width="match_parent"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
        android:layout_height="wrap_content"/>

    <Button
        android:id="@+id/btnRefresh"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp"
        android:text="새로고침"/>

</LinearLayout>

</layout>

list_item_weather.xml

<?xml version="1.0" encoding="utf-8"?>


<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:background="@drawable/layout_background"
    android:orientation="vertical"
    android:gravity="center"
    android:padding="10dp"
    android:layout_margin="10dp">

    <TextView
        android:id="@+id/tvTime"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="00시 날씨"
        android:textColor="@color/white"
        android:textSize="14sp"
        android:gravity="center"
        />

    <ImageView
        android:id="@+id/imgWeather"
        android:layout_width="60dp"
        android:layout_height="60dp"
        android:src="@drawable/sun"
        android:layout_margin="10dp"
        />

    <TextView
        android:id="@+id/tvTemp"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="기온"
        android:textColor="@color/white"
        android:textSize="18sp" />

    <LinearLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        android:layout_margin="10dp">

        <ImageView
            android:layout_width="18dp"
            android:layout_height="18dp"
            android:src="@drawable/humidity"/>
        <TextView
            android:id="@+id/tvHumidity"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="0%"
            android:textColor="@color/white"
            android:textSize="14sp"/>


    </LinearLayout>


</LinearLayout>

 리사이클러뷰 리스트 아이템

layout_background.xml

drawable 폴더에 추가

<?xml version="1.0" encoding="utf-8"?>
<shape
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <solid android:color="#92c0f4" />
    <stroke android:color="#92c0f4" android:width="1dp" />
    <corners android:radius="7dp" />
</shape>

위 코드를 모두 작성하고 실행하면 다음 결과 화면과 같이 나오는 것을 확인할 수 있다.

<결과 영상>

전체 코드를 한 번에 보고 싶다면 아래 GitHub에서 확인할 수 있다.

참고

 

안드로이드 http 프로토콜 접속 시 예외발생 조치 (ERR CLEARTEXT NOT PERMITTED)

어제 앱을 개발 중 glide v4를 사용하여 웹에 있는 그림을 load 하였는데, 아래와 같은 예외를 주며 동작을 하지 않았습니다. com.bumptech.glide.load.engine.GlideException: Fetching data failed, class java...

developside.tistory.com

 

[ 안드로이드 - Kotlin ] retrofit을 사용한 공공데이터 포털 데이터 받기 ( 코틀린 )

안녕하세요! 다들 코로나 조심하고 계신가요? 최근 들어 백신에 대한 이슈가 많아서 공공데이터 포털에 있는 "공공데이터활용지원센터_코로나19 예방접종 통계 데이터 조회 서비스" api를 활용하

yline.tistory.com

 

[안드로이드] 최신 기상청 단기예보 API 활용하기(초단기예보, Json)

올해 1학기 때 과제로 기상청 동네 예보 API를 사용한 적이 있었는데... 7월 초에 이런 메일이 왔다. 동네 예보 API가 종료된다고... 물론 단기 예보 단위가 상세화 되는 건 좋다~ 기존에 있던 코드

min-wachya.tistory.com

GitHub

 

GitHub - junjange/Kotlin-Learning: 코틀린 학습📘

코틀린 학습📘. Contribute to junjange/Kotlin-Learning development by creating an account on GitHub.

github.com