관리 메뉴

나만을 위한 블로그

[Kotlin] 코루틴(Coroutine)이란? 본문

개인 공부/Kotlin

[Kotlin] 코루틴(Coroutine)이란?

참깨빵위에참깨빵_ 2022. 4. 27. 23:25
728x90
반응형

Coroutine은 Co + routine 2개가 합쳐진 단어다. 이 단어들의 뜻은 각각 아래와 같다.

 

Co : 함께, 서로
routine : 규칙적으로 하는 일의 통상적인 순서나 방법, 일반적으로 빈번히 사용할 수 있는 프로그램 또는 그 일부

 

같이 작동하는 프로그램의 일부라고 볼 수 있을 것 같은데 그러면 쓰레드랑 비슷한 건가 싶기도 하다. 위키백과에서 설명하는 코루틴은 아래와 같다.

 

https://en.wikipedia.org/wiki/Coroutine

 

Coroutine - Wikipedia

Coroutines are computer program components that generalize subroutines for non-preemptive multitasking, by allowing execution to be suspended and resumed. Coroutines are well-suited for implementing familiar program components such as cooperative tasks, ex

en.wikipedia.org

코루틴은 실행을 일시 중단하고 재개할 수 있도록 해서 비선점형 멀티태스킹을 위한 서브 루틴을 일반화하는 컴퓨터 프로그램 구성 요소다. 코루틴은 협력 작업, 예외, 이벤트 루프, 반복자, 무한 목록 및 파이프와 같은 친숙한 프로그램 구성 요소를 구현하는 데 적합하다. Donald Knuth에 따르면 Melvin Conway는 1958년 어셈블리 프로그램 구성에 코루틴이란 용어를 만들었다. 코루틴에 대한 최초의 성명은 좀 더 나중인 1963년에 나타났다
서브루틴은 코루틴의 특수한 경우다. 서브루틴이 호출되면 시작하고 종료되면 서브루틴도 종료된다. 서브루틴의 인스턴스는 한 번만 반환하고 호출 사이에 상태를 유지하지 않는다. 대조적으로 코루틴은 다른 코루틴을 호출해 종료할 수 있으며 나중에 원래 코루틴에서 호출된 지점으로 돌아갈 수 있다. 코루틴의 관점에서 보면 종료되는 게 아니라 다른 코루틴을 호출하는 것이다. 따라서 코루틴 인스턴스는 상태를 유지하고 호출마다 다르다. 한 번에 주어진 코루틴의 여러 인스턴스가 있을 수 있다. 다른 코루틴에 "양보"해서 다른 코루틴을 호출하는 것과 단순히 다른 루틴을 호출하는 것(그러면 원래 지점으로 돌아감)의 차이는 서로에게 양보하는 두 코루틴 간의 관계가 호출자의 관계가 아니란 것이다
코루틴은 쓰레드와 매우 유사하다. 그러나 코루틴은 협력적으로 멀티태스킹되는 반면 쓰레드는 일반적으로 선점형으로 멀티태스킹된다. 코루틴은 동시성을 제공하지만 병렬성은 제공하지 않는다. 쓰레드에 비해 코루틴의 장점은 실시간 컨텍스트에서 사용할 수 있다는 것이다(코루틴 간 전환에는 시스템 호출이나 차단 호출이 불필요함) 뮤텍스, 세마포어 등과 같은 동기화 기본 요소가 불필요하다. 크리티컬 섹션을 보호하기 위해 OS 지원이 필요하지 않다. 호출 코드에 투명하게 미리 예약된 쓰레드를 써서 코루틴을 구현하는 게 가능하지만 일부 이점(실시간 작업에 대한 적합성과 이들 간의 전환이 상대적으로 저렴함)이 손실된다

 

내가 이해한 핵심은 코루틴은 코틀린에만 존재하는 개념이 아니라 예전부터 있던 개념이며, 쓰레드와 다른 것이라는 점, 코루틴이 종료되면 코루틴이 호출된 지점으로 돌아간다는 것이다.

다른 코루틴 설명을 보기 전에 "(비)선점형 멀티태스킹"이라는 단어가 뭔지 궁금하다. 선점은 "남보다 앞서서 차지함"이란 뜻이 있는 단어인데 멀티태스킹에 붙으면 무슨 의미가 되는 걸까?

 

https://www.techtarget.com/whatis/definition/preemptive-multitasking

 

What is preemptive multitasking? - Definition from WhatIs.com

 

www.techtarget.com

선점형 멀티태스킹(Preemptive multitasking)은 컴퓨터 OS가 몇 가지 기준을 써서 다른 작업에 OS를 사용할 차례를 주기 전, 한 작업에 할당할 시간을 결정하는 작업이다. 한 작업에서 OS를 제어해서 다른 작업에 넘겨주는 행위를 '선점'이라고 한다. 선점을 위한 일반적인 기준은 단순히 경과 시간이다. 이런 종류의 시스템을 시간 공유 또는 시간 분할이라고도 한다. 일부 OS에선 일부 응용 프로그램에 다른 응용 프로그램보다 더 높은 우선순위를 부여할 수 있으므로, 더 높은 우선순위 프로그램이 시작되자마자 더 긴 시간 조각을 제어할 수 있다

 

https://www.techopedia.com/definition/8949/preemptive-multitasking

 

What is Preemptive Multitasking? - Definition from Techopedia

This definition explains the meaning of Preemptive Multitasking and why it matters.

www.techopedia.com

선점형 멀티태스킹은 컴퓨터 프로그램이 OS와 기본 하드웨어 리소스를 공유할 수 있도록 하는 멀티태스킹 유형이다. 프로세스 간의 전체 작동 및 컴퓨팅 시간을 분할하고, 미리 정의된 기준을 통해 서로 다른 프로세스 간의 리소스 전환이 발생한다. 선점형 멀티태스킹은 시분할 멀티태스킹(time-shared multitasking)이라고도 한다
선점형 멀티태스킹은 컴퓨터 멀티태스킹 기술의 가장 일반적인 유형 중 하나다. 각 프로세스에 컴퓨팅 리소스의 동일한 공유가 할당될 수 있는 시간 공유 기능(time sharing feature)에서 작동한다. 다만 업무의 중요도, 우선순위에 따라 추가 시간이 배정될 수 있다. OS 별 백그라운드 작업이 사용자 애플리케이션의 작업보다 더 중요하다고 간주될 수도 있다. 따라서 최전선 작업보다 더 큰 시간 조각을 받는다. 프로그램이 컴퓨팅 리소스를 제어하는 걸 막기 위해 선점형 멀티태스킹은 프로그램을 제한된 시간 조각으로 제한한다

 

https://www.geeksforgeeks.org/difference-between-preemptive-and-cooperative-multitasking/

 

Difference between Preemptive and Cooperative Multitasking - GeeksforGeeks

A Computer Science portal for geeks. It contains well written, well thought and well explained computer science and programming articles, quizzes and practice/competitive programming/company interview Questions.

www.geeksforgeeks.org

멀티태스킹은 일정 기간 동안 여러 작업, 프로세스를 동시에 실행하는 방법론이다. 선점형 멀티태스킹에서 OS는 실행 중인 프로세스에서 다른 프로세스로 컨텍스트 전환을 시작할 수 있다. 즉 OS는 현재 실행 중인 프로세스의 실행을 중지하고 CPU를 다른 프로세스에 할당할 수 있다. OS는 몇 가지 기준을 써서 다른 프로세스가 OS를 쓰도록 허용하기 전에 프로세스를 실행해야 하는 기간을 결정한다. 한 프로세스에서 OS를 제어하고 이를 다른 프로세스에 제공하는 매커니즘을 선점이라고 한다. 협동 멀티태스킹에서 OS는 실행 중인 프로세스에서 다른 프로세스로 컨텍스트 전환을 시작하지 않는다. 컨텍스트 전환은 프로세스가 주기적으로 제어권을 자발적으로 양보하거나 여러 응용 프로그램이 동시에 실행될 수 있도록 유휴 상태이거나 논리적으로 차단된 경우에만 발생한다. 또한 이런 멀티태스킹에선 스케줄링 방식이 작동하도록 모든 프로세스가 협력한다

 

아래는 코틀린 공식 홈페이지에서 말하는 코루틴이다.

 

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

 

Coroutines basics | Kotlin

 

kotlinlang.org

코루틴은 일시중단 가능한 계산(computation)의 인스턴스다. 코드의 나머지 부분과 동시에 작동하는 코드 블럭을 실행한단 점에서 개념적으로 쓰레드와 유사하다. 그러나 코루틴은 특정 쓰레드에 바인딩되지 않는다. 한 쓰레드에서 실행을 일시중지하고 다른 쓰레드에서 재시작할 수 있다. 코루틴은 경량 쓰레드로 생각할 수 있지만 실제 사용을 쓰레드와 매우 다르게 만드는 중요한 차이가 있다. 다음 코드를 실행해서 첫 번째 작업 코루틴(working coroutine)으로 이동한다
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
}
launch는 코루틴 빌더다. 독립적으로 계속 작동하는 나머지 코드와 동시에 새 코루틴을 시작한다. 그래서 Hello가 먼저 출력된다. 지연(delay)은 특별한 일시중단 기능이다. 특정 시간 동안 코루틴을 일시중단한다. 코루틴을 일시중단하면 기본 쓰레드가 차단되지 않지만 다른 코루틴이 실행되고 코드에 기본 쓰레드를 실행할 수 있다
runBlocking은 또한 일반 fun main의 비 코루틴 부분과 runBlocking { ... } 내부의 코루틴이 있는 코드를 연결하는 코루틴 빌더다. 이것은 IDE에서 강조 표시된다. 이 코드에서 runBlocking을 제거하거나 잊어버리면 시작이 CoroutineScope에서만 선언되기 때문에 시작 호출에서 오류가 발생한다
runBlocking은 runBlocking { ... } 안의 모든 코루틴이 실행을 완료할 때까지 호출하는 동안 이를 실행하는 쓰레드(이 경우 메인 쓰레드)가 차단된단 의미다. 쓰레드는 비싼 리소스고 쓰레드를 차단하는 건 비효율적이며 원하지 않는 경우가 많으므로 앱의 최상위 수준에서 runBlocking이 쓰이는 걸 종종 볼 수 있다

 

다른 곳에서 코루틴을 설명하는 글들도 확인해봤다.

 

https://proandroiddev.com/how-to-make-sense-of-kotlin-coroutines-b666c7151b93

 

How to make sense of Kotlin coroutines

Coroutines are a great way to write asynchronous code that is perfectly readable and maintainable. Kotlin provides the building block of…

proandroiddev.com

코루틴은 완벽하게 읽고 유지관리할 수 있는 비동기 코드를 작성하는 좋은 방법이다. 코틀린은 단일 언어 구성으로 비동기 프로그래밍의 빌딩 블록을 제공한다. 즉 suspend 키워드와 이를 빛나게 하는 많은 라이브러리 함수다
코틀린 팀은 코루틴을 경량 쓰레드로 정의한다. 그것들은 실제 쓰레드가 실행할 수 있는 일종의 작업이다. Kotlinlang.org의 배너가 이것의 예시다

가장 흥미로운 건 쓰레드가 특정 중단 지점(suspend points)에서 코루틴 실행을 중지하고 다른 작업을 수행할 수 있다는 것이다. 나중에 코루틴 실행을 재개하거나 다른 쓰레드가 인계받을 수도 있다. 따라서 더 정확하게 말하면 하나의 코루틴은 정확히 하나의 '작업'이 아니라 특정 보장된 순서로 실행되는 '하위 작업'의 시퀀스다. 코드가 하나의 순차적 블럭에 있는 것처럼 보여도 일시중단 함수에 대한 각 호출은 코루틴 안에서 새 '하위 작업'의 시작을 구분한다...(중략)

 

https://medium.com/android-news/android-threads-coroutines-for-beginners-f39abc90d927

 

Android Threads & Coroutines for Beginners

What is threading? Why is it important for Android? And where does Kotlin fit in?

medium.com

코루틴은 장기 실행 작업에 대한 콜백을 '순차적' 코드로 바꾸는 코틀린 기능이다. suspend 키워드는 코루틴에서 사용할 수 있는 함수를 표시하는 코틀린의 방법이다. 코루틴이 일시중단으로 표시된 함수를 호출하면 해당 함수가 반환될 때까지 차단하는 대신 결과가 준비될 때까지 실행을 일시중단한다. 그 다음 결과와 함께 중단된 위치에서 재시작된다. 결과를 기다리는 동안 일시중단되어 실행 중인 쓰레드의 차단을 해제해서 다른 실행이 발생할 수 있다...(중략)...try-catch와 같은 언어 기능을 사용할 수 있다

 

https://flexiple.com/android/using-kotlin-coroutine-builders-in-android/

 

Introduction to using Kotlin Coroutine Builders in Android

This blog post talks about why Kotlin coroutines make it super easy to develop Android apps if you know how to use them in the right way.

flexiple.com

코루틴은 메인 쓰레드를 차단하지 않으면서 메인 쓰레드에서 일시중단(suspending) 기능을 호출하는 기능을 제공하는 경량 쓰레드다. 일시중단 함수는 현재 쓰레드를 차단하지 않고 현재 코루틴 실행을 일시중지할 수 있는 기능이다. 이런 함수는 특정 시점에서 실행을 재개할 수 있다. 일시중단 기능은 동기 방식으로 비동기 코드를 작성하는 데 도움이 된다. 일시중단 함수 자체가 비동기적이지 않다는 점도 주목할 가치가 있다

 

 

그럼 코루틴은 어떻게 쓰는 건가? 안드로이드 디벨로퍼에서 의존성을 확인할 수 있다.

 

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

 

Android의 Kotlin 코루틴  |  Android 개발자  |  Android Developers

Android의 Kotlin 코루틴 코루틴은 비동기적으로 실행되는 코드를 간소화하기 위해 Android에서 사용할 수 있는 동시 실행 설계 패턴입니다. 코루틴은 Kotlin 버전 1.3에 추가되었으며 다른 언어에서 확

developer.android.com

 

여기 적힌 의존성을 프로젝트의 앱 수준 gradle 파일에 복붙해보자.

 

implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9"

 

아래는 간단한 코루틴 예제 코드다.

 

import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import com.example.kotlinprac.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

class MainActivity : AppCompatActivity() {

    val TAG = this.javaClass.simpleName

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        Log.e(TAG, "메인 쓰레드에서 코루틴 시작")
        GlobalScope.launch(Dispatchers.Default) {
            delay(500)
            Log.e(TAG, "코루틴 안에서 delay() 호출됨@@@")
        }
        Log.e(TAG, "메인 쓰레드에서 코루틴 끝남")
    }
}

 

이걸 실행하면 로그창에 로그가 아래 순서로 찍힌다.

 

 

이 코드를 통해 의문이 드는 것은 아래와 같다.

 

  • 메인 쓰레드의 로그 2개가 모두 출력된 다음에야 delay()가 호출되는 건 왜인가?
  • launch는 뭔가?
  • Dispatchers는 뭔가?
  • Default는 뭐고 다른 옵션도 있는가?

 

하나씩 확인해보자. 먼저 메인 쓰레드의 로그 2개가 먼저 호출되는 것은 launch 안의 코드들은 비동기적으로 실행되기 때문이다. 그래서 메인 쓰레드의 로그가 먼저 출력된 다음 코루틴이 작동한 것이다. 또한 launch 중괄호 안에는 리턴값도 없다.

 

위에 링크를 건 안드로이드 디벨로퍼를 보면 launch에 대해서 간단하게 설명하고 있다.

 

launch는 코루틴을 만들고 함수 본문의 실행을 해당하는 디스패처에 전달하는 함수다. launch가 새 코루틴을 만들며 I/O 작업용으로 예약된 쓰레드에서 독립적으로 네트워크 요청이 이뤄진다

 

그리고 아래는 코틀린 공식문서에 적힌 launch의 설명이다.

 

https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/launch.html

 

launch

Launches a new coroutine without blocking the current thread and returns a reference to the coroutine as a Job. The coroutine is cancelled when the resulting job is cancelled. The coroutine context is inherited from a CoroutineScope. Additional context ele

kotlin.github.io

현재 쓰레드를 차단하지 않고 새 코루틴을 시작하고 코루틴에 대한 참조를 작업으로 리턴한다. 결과 작업이 취소되면 코루틴이 취소된다. 코루틴 컨텍스트는 CoroutineScope에서 상속된다. 추가 컨텍스트 요소는 컨텍스트 인수로 지정할 수 있다. 컨텍스트에 디스패처나 다른 ContinuationInterceptor가 없으면 Dispatchers.Default가 사용된다. 상위 작업도 CoroutineScope에서 상속되지만 해당 컨텍스트 요소로 재정의될 수도 있다
기본적으로 코루틴은 즉시 실행되도록 예약된다. 다른 시작 옵션은 시작 매개변수를 통해 지정할 수 있다. 선택적 시작 매개변수를 CoroutineStart.LAZY로 설정해서 코루틴을 느리게 시작할 수 있다. 이 경우 코루틴은 새로운 상태로 생성된다
fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext, 
    start: CoroutineStart = CoroutineStart.DEFAULT, 
    block: suspend CoroutineScope.() -> Unit
): Job

 

즉 결과를 리턴하는 게 아니라 작업을 리턴한다. 그럼 async는 결과를 리턴하는 메서드일 것이다. async()의 원형은 아래와 같다.

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

 

공식문서에 첨부된 코드의 launch() 안을 보면 코루틴은 3가지 요소로 구성돼있는 걸 알 수 있다.

 

  • CoroutineContext
  • CoroutineStart
  • CoroutineScope

 

이 요소들에 대한 코틀린 공식문서의 설명은 각각 아래와 같다.

 

https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.coroutines/-coroutine-context/

 

CoroutineContext - Kotlin Programming Language

 

kotlinlang.org

코루틴에 대한 영구 컨텍스트다. Element 인스턴스의 인덱싱된 Set이다. 인덱싱된 Set은 Set과 Map을 합친 것이다. 이 Set의 모든 요소에는 고유한 키가 있다

 

https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-coroutine-start/index.html

 

CoroutineStart

Defines start options for coroutines builders. It is used in start parameter of launch, async, and other coroutine builder functions. The summary of coroutine start options is: DEFAULT -- immediately schedules coroutine for execution according to its conte

kotlin.github.io

코루틴 빌더를 위한 시작 옵션을 정의한다. 시작, 비동기 및 기타 코루틴 빌더 기능의 시작 매개변수에 쓰인다.
DEFAULT : 컨텍스트에 따라 실행할 코루틴을 즉시 예약한다
LAZY : 필요할 때만 코루틴을 느리게 시작한다
ATOMIC : 취소할 수 없는 방식으로 컨텍스트에 따라 실행하기 위해 코루틴을 예약한다
UNDISPATCHED : 현재 쓰레드에서 첫 번째 중단 지점까지 코루틴을 즉시 실행한다

 

https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-coroutine-scope/index.html

 

CoroutineScope

CoroutineScope common Defines a scope for new coroutines. Every coroutine builder (like launch, async, etc.) is an extension on CoroutineScope and inherits its coroutineContext to automatically propagate all its elements and cancellation. The best ways to

kotlin.github.io

새 코루틴의 범위를 정의한다. 모든 코루틴 빌더(launch, async 등)는 CoroutineScope의 확장이며 CoroutineContext를 상속해서 모든 요소, 취소를 자동 전파한다. scope의 독립 실행형 인스턴스를 얻는 가장 좋은 방법은 CoroutineScope() 및 MainScope() 팩토리 함수로, 필요하지 않을 때 코루틴 범위를 취소하도록 주의한다

 

 

아는 만큼 보이는 공식문서답게 무슨 말인지 전혀 모르겠다. 다른 곳에선 어떻게 설명하는지 확인해봤다.

 

https://kotlinworld.com/152

 

[Coroutine] 11. Coroutine CoroutineContext를 다루는 방법 : Coroutine Dispatcher과 ExceptionHandler을 CoroutineContext를

CoroutineContext 앞서 우리는 다음의 내용들을 배웠다. Dispatcher: 코루틴이 실행될 스레드 풀을 잡고 있는 관리자 CoroutineExceptionHandler: 코루틴에서 Exception이 생겼을 때의 처리기 그런데 이 두 가지..

kotlinworld.com

CoroutineContext는 코루틴이 실행되는 환경이라고 생각하면 된다. Dispatcher, CoroutineExceptionHandler 또한 코루틴이 실행되는 환경의 일부고, 둘 모두 CoroutineContext에 포함되서 코루틴이 실행되는 환경으로 설정될 수 있다

 

간단하게 CoroutineContext는 코루틴이 실행되는 환경이라고 이해하고 넘어간다. CoroutineStart는 코루틴의 4가지 속성을 내 상황에 맞게 쓰면 되는 것 같다. CoroutineScope는 Scope가 붙었듯 코루틴이 실행되는 범위를 말하는데 예제 코드에 사용한 GlobalScope의 경우 Application 범위에서 동작하기 때문에 Application이 살아있는 동안에는 코루틴을 제어할 수 있다.

즉 액티비티에서 코루틴을 GlobalScope에서 실행시키면 액티비티가 종료되더라도 코루틴은 작업이 끝날 때까지 동작한다. 이것 때문에 리소스 낭비가 발생할 수 있어 적절한 Scope를 쓰는 게 중요하다.

안드로이드 디벨로퍼에서도 launch, async에 대해서 간단하게 설명하고 있다.

 

그럼 Dispatcher는 뭘까? Dispatch는 "보내다"라는 뜻이 있는데, 이 뜻 그대로 쓰레드에 코루틴을 보내는 역할을 하는 것이다.

 

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

 

Kotlin 코루틴으로 앱 성능 향상  |  Android 개발자  |  Android Developers

Kotlin 코루틴으로 앱 성능 향상 Kotlin 코루틴을 사용하면 네트워크 호출이나 디스크 작업과 같은 장기 실행 작업을 관리하면서 앱의 응답성을 유지하는 깔끔하고 간소화된 비동기 코드를 작성할

developer.android.com

코루틴은 디스패처를 써서 코루틴 실행에 사용되는 쓰레드를 확인한다. 코드를 메인 쓰레드 밖에서 실행하려면 Default 또는 IO 디스패처에서 작업을 실행하도록 코루틴에 지시하면 된다. 코틀린에서 모든 코루틴은 메인 쓰레드에서 실행 중인 경우에도 디스패처에서 실행돼야 한다. 코루틴은 자체적으로 정지될 수 있으며 디스패처는 코루틴 재개를 담당한다

 

이 Dispatcher는 3가지 옵션이 있다.

 

  • Dispatchers.Main : 메인 쓰레드에서 코루틴을 실행한다. UI와 상호작용하고 빠른 작업을 실행할 때만 써야 한다. suspend 함수를 호출하고 안드로이드 UI 프레임워크 작업을 실행하며 LiveData 객체를 업데이트한다
  • Dispatchers.IO : 메인 쓰레드 밖에서 디스크 또는 네트워크 I/O를 실행할 때 사용한다. 파일에서 읽거나 파일에 쓰기, 네트워크 작업 시 사용한다
  • Dispatchers.Default : 메인 쓰레드 밖에서 CPU를 많이 사용하는 작업을 실행할 때 사용한다. 리스트 정렬, JSON 파싱 등에 사용한다.

 

Dispatcher도 상황에 맞게 적절하게 쓰면 될 것 같다.

반응형
Comments