티스토리 뷰

jvm언어관련/kotlin

coroutine basic

055055 2022. 6. 17. 22:02
반응형
반응형

1. Coroutine

코루틴은 비동기적으로 실행되는 코드를 간소화하기 위해 사용할 수 있는 동시 실행 설계 패턴이다.

코루틴을 사용하는 전문 개발자중 50% 이상이 생산성이 향상되었다고 보고했다.

 

코루틴(coroutine)은 루틴의 일종으로서, 협동 루틴이라 할 수 있다(코루틴의 "Co"는 with 또는 togather를 뜻한다). 상호 연계 프로그램을 일컫는다고도 표현가능하다. 루틴과 서브 루틴은 서로 비대칭적인 관계이지만, 코루틴들은 완전히 대칭적인, 즉 서로가 서로를 호출하는 관계이다. 코루틴들에서는 무엇이 무엇의 서브루틴인지를 구분하는 것이 불가능하다. 코루틴 A와 B가 있다고 할 때, A를 프로그래밍 할 때는 B를 A의 서브루틴으로 생각한다. 그러나 B를 프로그래밍할 때는 A가 B의 서브루틴이라고 생각한다. 어떠한 코루틴이 발동될 때마다 해당 코루틴은 이전에 자신의 실행이 마지막으로 중단되었던 지점 다음의 장소에서 실행을 재개한다.

 

https://www.slideshare.net/BartomiejOsmaek/kotlin-coroutines-the-new-async

 

기능

  • 경량: 코루틴을 실행 중인 스레드를 차단하지 않는 정지를 지원하므로 단일 스레드에서 많은 코루틴을 실행할 수 있다. 정지는 많은 동시 작업을 지원하면서도 차단보다 메모리를 절약한다.
  • 메모리 누수 감소: 구조화된 동시 실행을 사용하여 범위 내에서 작업을 실행한다.
  • 기본으로 제공되는 취소 지원: 실행 중인 코루틴 계층 구조를 통해 자동으로 취소가 전달된다.

 

Coroutines와 RxJava의 러닝 커브 비교

https://twitter.com/akarnokd/status/97973272315268710

 

코루틴을 한 마디로 말한다면??

- Understand Kotlin Corouines on Android(Google I/O 19)

코루틴은 비동기 코드를 동기 코드 처럼 작성 할 수 있다.

 

fun main() = runBlocking { // this: CoroutineScope
    launch { // launch a new coroutine and continue
        delay(1000L) // non-blocking delay for 1 second (default time unit is ms)
        println("World!") // print after delay
    }
    println("Hello") // main coroutine continues while a previous one is delayed
}

 

쓰레드와 코루틴의 차이점은??

Q) Difference Between thread and coroutine in Kotlin

A)

Thread

- 쓰레드는 해당 OS의 네이티브 쓰레드에 직접 연결되어 많은 자원 소모를 일으킨다.

- 쓰레드간 전환시에도 CPU 소모를 많이 일으킨다.

 

Coroutine

- 네이티브 리소스를 사용하지 않고 가장 간단한 경우 JVM Heap에서 비교적 작은 개체 하나만 사용

- 코루틴간 전환시에는 OS 커널을 포함하지 않아서 일반 함수를 호출하는 것 처럼 저렴하다. (switch시 오버헤드 없음)

코루틴을 경량 쓰레드라 표현하는 경우가 많은데, 쓰레드와 같은 용도로 사용하지만 성능이 더 좋고 가볍기 떄문에 붙여진 별명이지 쓰레드와는 다른 점들이 있다.

 

2. Coroutines Basics

코루틴 빌더

코틀린에서는 코루틴 빌더에 원하는 동작을 람다로 넘겨서 코루틴을 만들어 실행하는 방식으로 코루틴을 활용한다. 코루틴을 생성하는 함수로는 runBlocing, launch, async가 있다.

  • runBlocking

runBlocking은 최상위 함수로서 명령줄 검증 또는 테스트에 유용하다. runBlocking이라는 이름이 나타내듯이 runBlocking은 현재 스레드를 블록하고 모든 내부 코루틴이 종료될 때까지 블록한다.

 

fun <T> runBlocking(
  	block: suspend CoroutineScope.() -> T
): T
  • launch

독립된 프로세스를 실행하는 코루틴을 시작하고, 해당 코루틴에서 리턴값을 받을 필요가 없는 경우에 사용

fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job

launch 함수는 CoroutineScope의 확장 함수이기 때문에 CoroutineScope이 사용 가능한 경우에만 사용할 수 있다. launch 함수는 코루틴 취소가 필요하면 사용할 수 있는 Job 인스턴스를 리턴한다.

 

  • async

독립된 프로세스를 실행하는 코루틴을 시작하고, 해당 코루틴에서 리턴값을 받을 필요가 있는 경우에 사용한다.

fun <T> CoroutineScope.async(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> T
): Deferred<T>

async함수가 값을 리턴하면서 지연된 Deferred 인스턴스로 해당 값을 감싼다. 

지연된 인스턴스는 자바스크립트의 Promies 또는 자바의 Future와 비슷한 느낌을 준다.

Deferred는 이후 코루틴이 완료될 때 까지 await을 통해 기다릴 수 있다.



  launch async
 Return value  Job  Deferred<T>
 Suspend till finish  join(): Unit  await(): T
 Error handling  Propagate to parent  Throws when called await

 

구조화된 동시성

새로운 코루틴은 코루틴을 수행하고 있는 작업의 범위 내에서 코루틴을 생성할 수 있다.

이를 구조화된 동시성 원칙을 따른다고 하며 새로운 코루틴은 명세되어 있는 coroutineScope에서 시작될 수 있다.

CoroutineScope는 코루틴의 생명 주기를 제한하는 역할을 한다.

 

* suspend funtion

suspend function은 코루틴 실행을 중단(지연) 시킬 수 있는 기능을 할 수 있는 함수이다.

이를 통해 코루틴을 일반적인 함수와 같이 사용할 수 있다.

지연 함수는 같은 지연 함수 또는 coroutine안에서만 호출할 수 있다.

 

fun main() = runBlocking { // this: CoroutineScope
    launch { doWorld() }
    println("Hello")
}

// this is your first suspending function
suspend fun doWorld() {
    delay(1000L)
    println("World!")
}

 

 * Scope builder

코루틴 빌더 안에서 제공되는 코루틴외에도 coroutineScope를 통해서 고유한 범위를 지정할 수 있다. 이를 통해 coroutineScope를 지정하면 하위 코루틴들이 완료될 때 까지 코루틴이 종료되지 않게 할 수 있다. 하나의 코루틴이 실패하면 코루틴이 취소될 수 있게 coroutineScope 내부에서 모든 코루틴을 실행시키는 관습은 구조화된 동시성으로 알려져 있다.

runBlocking과 coroutineScope 모두 하위 코루틴이 완료될 때 까지 종료되지 않는다는 공통점이 있지만, runBlocking은 현재 스레드를 차단하고, coroutineScope는 일시 중단하는 차이점이 있다. 그렇기에 runBlokcing은 일반함수이고, coroutineScope는 지연 함수이다.

 

suspend fun <R> coroutineScope(
  	block: suspend CoroutineScope.() -> R
): R

 

An explicit job

launch가 반환하는 job을 통해 코루틴이 완료될 때 까지 명시적으로 기다릴 수 있다.

fun main() = runBlocking {
    val job = launch { // launch a new coroutine and keep a reference to its Job
        delay(1000L)
        println("World!")
    }
    println("Hello")
    job.join() // wait until child coroutine completes
    println("Done")
}

 

3. Cancellation and timeouts

코루틴이 메모리랑 리소스를 차지하고 있기 때문에 정교하게 취소하는게 중요하다.

코루틴 취소는 간단하게 반환 받은 값을 cancel 하면 된다.

fun main() = runBlocking {
    val job = launch {
        repeat(1000) { i ->
            println("job: I'm sleeping $i ...")
            delay(500L)
        }
    }
    delay(1300L) // delay a bit
    println("main: I'm tired of waiting!")
    job.cancel() // cancels the job
    job.join() // waits for job's completion
    println("main: Now I can quit.")
}

코루틴이 취소될 수 있게 하는 방법은 두가지가 있다. 첫 번째 방법은 취소를 위해서 주기적으로 지연되는 함수를 실행하는 것이다. yield 함수가 대표적으로 사용할 수 있는 지연되는 함수다. 다른 방법은 명시적으로 취소 상태를 확인하는 것이다.

Cancellation is cooperative

fun main() = runBlocking {
    val startTime = System.currentTimeMillis()
    val job = launch(Dispatchers.Default) {
        var nextPrintTime = startTime
        var i = 0
        while (i < 5) { // computation loop, just wastes CPU
            // print a message twice a second
            if (System.currentTimeMillis() >= nextPrintTime) {
                println("job: I'm sleeping ${i++} ...")
                nextPrintTime += 500L
            }
        }
    }
    delay(1300L) // delay a bit
    println("main: I'm tired of waiting!")
    job.cancelAndJoin() // cancels the job and waits for its completion
    println("main: Now I can quit.")
}

코루틴의 취소는 협조적이다. 코루틴 코드는 취소할 수 있도록 서로 협조적으로 구성되어야 한다.

kotlinx.coroutines의 모든 지연 함수는 취소할 수 있다. 함수들은 코드가 취소되면 코루틴의 취소를 확인하여 CancellationException을 throw하게 된다. 그러나 코루틴이 연속적으로 실행되는데 취소를 확인하지 않는다면 취소할 수 없다. 그래서 코루틴이 협조적이여야 취소할 수 있다고 말한다.

위의 코드를 협조적으로 취소시키려면 yield() 함수를 if문안에 추가하면 된다.

 

Making computation code cancellable

이전 예제에서 while ( i < 5 )를 while (isActive)로 변경하고 실행해보자.

fun main() = runBlocking {
    val startTime = System.currentTimeMillis()
    val job = launch(Dispatchers.Default) {
        var nextPrintTime = startTime
        var i = 0
        while (isActive) { // cancellable computation loop
            // print a message twice a second
            if (System.currentTimeMillis() >= nextPrintTime) {
                println("job: I'm sleeping ${i++} ...")
                nextPrintTime += 500L
            }
        }
    }
    delay(1300L) // delay a bit
    println("main: I'm tired of waiting!")
    job.cancelAndJoin() // cancels the job and waits for its completion
    println("main: Now I can quit.")
}

isActives는 coroutineScope의 확장 함수이다. 명시적으로 상태를 체크하는 방식은 위 지연함수를 통한 취소와 다르게 exception을 던지지 않는 차이가 있다.

Closing resources with finally

지연함수를 이용한 코루틴 취소방식은 CancellationException을 throw하므로 이를 일반적인 방법으로 Exception을 다룰 수 있다. try{...} finally{...} 식이나 코틀린의 use 함수는 코루틴이 취소될 때 정상적으로 최종 작업을 진행한다. 코루틴에서 I/O resource를 닫을 때 이와 같이 닫아주면 된다.

val job = launch {
    try {
        repeat(1000) { i ->
            println("job: I'm sleeping $i ...")
            delay(500L)
        }
    } finally {
        println("job: I'm running finally")
    }
}
delay(1300L) // delay a bit
println("main: I'm tired of waiting!")
job.cancelAndJoin() // cancels the job and waits for its completion
println("main: Now I can quit.")

Run non-cancellable block

코루틴이 취소되어 finally 블록에서 지연 함수를 사용하려고 하면 CancellationException이 발생한다. 일반적으로 닫는 작업(파일, 작업, 통신 채널 등)은 차단되지 않고 어떤 기능도 동반하지 않기 때문에 상관없으나, 가끔 취소된 코루틴에서 지연 함수를 사용해야 하는 경우는 아래와 같이

withContext(NonCancellable) {...}로 감싸서 사용할 수 있다. 이를 통해 이미 끝난 코루틴안에서 코루틴을 종료할 수 있다.

fun main() = runBlocking {
    val job = launch {
        try {
            repeat(1000) { i ->
                println("job: I'm sleeping $i ...")
                delay(500L)
            }
        } finally {
            withContext(NonCancellable) {
                println("job: I'm running finally")
                delay(1000L)
                println("job: And I've just delayed for 1 sec because I'm non-cancellable")
            }
        }
    }
    delay(1300L) // delay a bit
    println("main: I'm tired of waiting!")
    job.cancelAndJoin() // cancels the job and waits for its completion
    println("main: Now I can quit.")
}

Timeout

미리 타임아웃을 지정하는 방식 TimeoutCancellationException을 throw한다.

fun main() = runBlocking {
    withTimeout(1300L) {
        repeat(1000) { i ->
            println("I'm sleeping $i ...")
            delay(500L)
        }
    }
}

withTimeoutOrNull은 Exception을 throw하지 않고 null을 반환한다.

fun main() = runBlocking {
    val result = withTimeoutOrNull(1300L) {
        repeat(1000) { i ->
            println("I'm sleeping $i ...")
            delay(500L)
        }
        "Done" // will get cancelled before it produces this result
    }
    println("Result is $result")

4. Composing Suspending Functions

일반 코드와 마찬가지로 코루틴의 코드는 기본적으로 순차적이기 때문에 일반 순차 호출을 사용한다. 비동기로 동시에 실행하여 더 빠른 결과값을 얻기 위해서는 async를 사용한다.

개념적으로 async는 launch와 같으나 차이점은 async는 나중에 결과값을 받을 수 있는 Deferred를 반환한다. 이를 통해 await()을 호출하여 결과값을 얻을 수 있고 취소 할 수 있다.

Concurrent using async

아래와 같은 코드는 두 개의 코루틴이 동시에 실행되기 때문에 두배 더 빠르게 동작한다.

fun main() = runBlocking {
    val time = measureTimeMillis {
        val one = async { doSomethingUsefulOne() }
        val two = async { doSomethingUsefulTwo() }
        println("The answer is ${one.await() + two.await()}")
    }
    println("Completed in $time ms")
}

suspend fun doSomethingUsefulOne(): Int {
    delay(1000L)
    return 13
}

suspend fun doSomethingUsefulTwo(): Int {
    delay(1000L)
    return 29
}

Lazily started async

선택적으로 async는 staart 파라미터를 CoroutineStart.LAZY로 설정하여 늦게 시작할 수 있다.

async Lazy 호출 시 개별 코루틴에서 start()를 먼저 호출하지 않고 await()을 호출하면 await()이 코루틴 실행을 시작하고 완료될 떄까지 기다리기 때문에 순차적 동작으로 이어진다.

fun main() = runBlocking {
    val time = measureTimeMillis {
    	val one = async(start = CoroutineStart.LAZY) { doSomethingUsefulOne() }
    	val two = async(start = CoroutineStart.LAZY) { doSomethingUsefulTwo() }
    	// some computation
    	one.start() // start the first one
    	two.start() // start the second one
    	println("The answer is ${one.await() + two.await()}")
    }
    println("Completed in $time ms")
}

suspend fun doSomethingUsefulOne(): Int {
    delay(1000L) 
    return 13
}

suspend fun doSomethingUsefulTwo(): Int {
    delay(1000L) 
    return 29
}

Async-style functions

명시적인 GlobalScope에 대한 참조가 있는 async 코루틴 빌더를 사용하여 코루틴을 생성할 수 있다.

GlobalScope (전역 스코프) : 애플리케이션의 전체 수명주기에 걸쳐 실행된다.

GlobalScope에 정외된 launch와 async가 완전하게 제거될 것이 아니라면 이를 사용하지 말것을 권장한다. GlobalScope의 launch와 async의 문제점은 시작하는 코루틴이 특정 코루틴 Job에도 할당되지 않고 영구적으로 취소되지 않으면 애플리케이션의 전체 수명주기에 걸쳐 실행된다는 것이다. 따라서 반드시 사용해야 할 이유가 없다면 사용하지 않음을 권장한다.

fun main() {
    val time = measureTimeMillis {
      	// we can initiate async actions outside of a coroutine
        val one = somethingUsefulOneAsync()
        val two = somethingUsefulTwoAsync()
     	// but waiting for a result must involve either suspending or blocking.
        // here we use `runBlocking { ... }` to block the main thread while waiting for the result
        runBlocking {
            println("The answer is ${one.await() + two.await()}")
        }
    }
    println("Completed in $time ms")
}

fun somethingUsefulOneAsync() = GlobalScope.async {
    doSomethingUsefulOne()
}

fun somethingUsefulTwoAsync() = GlobalScope.async {
    doSomethingUsefulTwo()
}

suspend fun doSomethingUsefulOne(): Int {
    delay(1000L) 
    return 13
}

suspend fun doSomethingUsefulTwo(): Int {
    delay(1000L) 
    return 29
}

만약 val one = somethingOneAsync() 라인과 one.await() 사이에 논리 오류가 있고 프로그램에서 예외가 발생하고 수행하고 있던 작업이 중단될 경우를 생각해 보자.

일반적으로는 global error-handler는 이 예외를 포착하고 개발자를 위해 오류를 기록 및 보고할 수 있지만 그렇지 않다면 프로그램은 다른 작업을 계속할 수 있다. 여기서는 작업이 중단됐음에도somethingOneAsync()가 계속 수행될 수 있다. 이러한 문제는 구조화된 동시성에서는 발생하지 않는다.

Structured concurrency with async

coroutineScope로 범위를 지정해주면 코드 안에서 이상이 발생하여 예외를 발생시키면 그 범위에서 시작한 모든 코루틴이 취소된다.

suspend fun concurrentSum(): Int = coroutineScope {
    val one = async { doSomethingUsefulOne() }
    val two = async { doSomethingUsefulTwo() }
    one.await() + two.await()
}

5. Coroutine context and dispatchers

CoroutineContext

CoroutineContext는 일련의 다음 요소를 사용하여 코루틴의 동작을 정의한다.

CoroutineContext는 코루틴을 어떻게 처리할 것인지에 대한 정보의 집합이며 코루틴이 실행되는 환경을 말한다. 모든 코틀린의 코루틴은 컨텍스트를 가지고 있다.

  • Job: 코루틴의 수명 주기를 제어한다. 코루틴 하나는 하나의 job으로 볼 수 있다.
  • CoroutineDispatcher: 적절한 스레드에 작업을 전달한다
  • CoroutineName: 디버깅에 유용한 코루틴의 이름
  • CoroutineExceptionHandler: 포착되지 않은 예외를 처리한다.

범위 내에 만들어진 새 코루틴의 경우 새 Job 인스턴스가 새 코루틴에 할당되고 다른 CoroutineContext 요소는 포함 범위에서 상속된다. 새 CoroutineContextlaunch 또는 async 함수에 전달하여 상속된 요소를 재정의할 수 있다. Joblaunch 또는 async에 전달해도 아무런 효과가 없다. Job의 새 인스턴스가 항상 새 코루틴에 할당되기 때문이다.

Dispatchers and threads

coroutine context에는 해당 코루틴의 실행에 사용하는 스레드를 결정하는 coroutine dispatcher가 포함되어 있다. coroutine dispatcher는 코루틴을 특정 스레드로 제한하거나 스레드 풀에 보내거나 제한 없이 실행할 수 있도록 한다.

launch, async와 같은 코루틴 빌더는 CoroutineContext 파라미터를 허용하여 dispatcher를 명시적으로 지정할 수 있도록 한다.

fun main() = runBlocking<Unit> {
    launch { // context of the parent, main runBlocking coroutine
        println("main runBlocking      : I'm working in thread ${Thread.currentThread().name}")
    }
    launch(Dispatchers.Unconfined) { // not confined -- will work with main thread
        println("Unconfined            : I'm working in thread ${Thread.currentThread().name}")
    }
    launch(Dispatchers.Default) { // will get dispatched to DefaultDispatcher
        println("Default               : I'm working in thread ${Thread.currentThread().name}")
    }
    launch(newSingleThreadContext("MyOwnThread")) { // will get its own new thread
        println("newSingleThreadContext: I'm working in thread ${Thread.currentThread().name}")
    }
}
Unconfined            : I'm working in thread main
Default               : I'm working in thread DefaultDispatcher-worker-1
main runBlocking      : I'm working in thread main
newSingleThreadContext: I'm working in thread MyOwnThread

1. launch {} :

launch{}를 매개변수 없이 실행하면 실행중인 CoroutineScope에서 context(dispather)를 상속 받는다. 예제는 메인 스레드에서 실행되는 runBlocking coroutine의 context를 받아서 실행

 

2. Dispatchers.Unconfined:

호출한 스레드에서 코루틴을 시작하지만 첫번째 지연 함수까지만 시작한다. 이후에는 다른 스레드로 코루틴을 재개한다. CPU의 시간을 소비하지 않고 특정 스레드에 제한된 공유 데이터(ex : UI)를 업데이트 하지도 않는 코루틴에 적합

 

3. Dispatchers.Default:

GlobalScope에서 실행될 때 Dispather는 기본적으로 Dispatchers.Default 또는 백그라운드 스레드 풀에서 공유하여 사용한다. (GlobalScope.launch{}와 launch(Dispatchers.Default){}는 같다)

 

4. newSingleThreadContext:

코루틴을 실행할 새로운 스레드를 생성한다. 스레드는 매우 비싸기 때문에 실제 애플리케이션에서는 더 이상 필요하지 않을 때는 close 함수를 사용하여 해제하거나 최상위 변수에 저장 후 애플리케이션 전체에 걸쳐 사용해야 한다.)

코루틴 디버깅 옵션(JVM Option)

-Dkotlinx.coroutines.debug

 이를 통해 어떤 코루틴이 실행되었는지 알 수 있다.

6. Coroutine suspend 함수의 동작 원리?

코틀린은 CPS(Continuation Passing Style)와 State Machine을 활용해 코드를 생성해 낸다.

CPS Transfomation

프로그램의 실행 중 특정 시점 이후에 진행해야 하는 내용을 별도의 함수로 뽑고, 그 함수에게 현재 시점까지 실행한 결과를 넘겨서 처리하게 만드는 소스 코드 변환 기술이다.

CPS를 사용하는 경우 프로그램이 다음에 해야 할 일을 항상 Continuation이라는 함수 형태로 전달 되므로, 나중에 할 일을 명확히 알 수 있고 그 Continuation에 넘겨야 할 값이 무엇인지도 명확하게 알 수 있기 때문에 프로그램이 실행 중이던 특정 시점의 맥락을 잘 저장했다가 필요할 때 다시 재개할 수 있다. 어떤 면에서 CPS는 콜백 스타일 프로그래밍과도 유사하다.

간단하게 생각하면 CPS == Callback이라고 생각하면 된다.

 

Kotlin

suspend fun createPost (token: Token, item: Item): Post {...}

Java/

public static final Object createPost (Token token, Item item, Continuation<Post> cont) {...}

 

코틀린 컴파일러는 이 함수를 컴파일 하면서 뒤에 Continuation을 인자로 만들어 붙여 준다.

그리고 이 함수를 호출할 때는 함수 호출이 끝난 후 수행해야 할 작업을 cont에 Continuation으로 전달하고, 함수 내부에서는 필요한 모든 일을 수행한 다음에 결과를 cont에 넘기는 코드를 추가한다.

suspend fun postItem(item: Item) {
 	val token = requestToken()
    val post = createPost(token, item)
    processPost(post)
}

 

Label을 통해 재개해야 할 위치를 선택하고 sm(Statue Machine)을 통해 상태를 넘겨주는 역할을 해준다.

suspend fun postItem(item: Item, cont: Continuation) {
  val sm = object : CoroutineImpl {...}
  	switch (label) {
      case 0:
      	val token = requestToken(sm)
      case 1:
      val post = createPost(token, item, sm)
      case 2:
    	processPost(post)
  	}
  }

kotlin bytecode

 

 

참고:

https://kotlinlang.org/docs/coroutines-guide.html

https://medium.com/hongbeomi-dev/%EC%BD%94%ED%8B%80%EB%A6%B0%EC%9D%98-%EC%BD%94%EB%A3%A8%ED%8B%B4-3-composing-suspending-functions-8ee5127e4d49

https://developer.android.com/kotlin/coroutines?hl=ko

https://stanleykou.tistory.com/entry/Kotlin-Coroutine-Coroutine-Context-%EC%99%80-Scope

https://www.youtube.com/watch?v=0viswXto028

https://book.naver.com/bookdb/book_detail.nhn?bid=16374063

https://book.naver.com/bookdb/book_detail.naver?bid=12685155

반응형
댓글
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/05   »
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
글 보관함