Android 4대 컴포넌트 (Service)

SSY·2025년 1월 29일
0

AndroidFramework

목록 보기
2/3
post-thumbnail

시작하며

안드로이드 서비스는 Background Service/Foreground Service/Bind Service로 3가지가 존재한다. 이번 포스팅엔 이 3가지 서비스들의 특징에 대해 서술하고 어떤 상황 때 위 서비스들을 선택하면 좋을 지 알려주고자 한다.

Background Service

UI와는 완전 무관하게 백그라운드 작업을 위한 서비스이다. 이 서비스를 사용 시, 사용자의 인지 밖에서 백그라운드 작업을 실행시킬 수 있다. 하지만 여기서 필자는 아래와 같은 의문이 들었다.

Background Service만으로 그 외 2가지 서비스들을 대체하여 쓰면 되는것 아닌가?

하지만 Android API 26버전부터 백그라운드 제한 정책으로 인해 Background Service를 무한정으로 실행시킬 수 없다. 왜냐하면, 앱 내에서 무분별하게 실행중인 Background Service들로 인해, 모바일 기기 의 메모리 사용량 증가로, 이로 인해 안드로이드 시스템은 앱을 무작위로 kill할 수 있기 때문이다. 이때 만약 kill당한 앱이 사용자가 심취해 듣고 있던 음악 앱일 경우, 사용자의 경험은 떨어질 것이다.


참고 : https://developer.android.com/about/versions/oreo/background?hl=ko#overview

이런 이유로, Android는 Background Service를 실행시키는 데, 제약사항을 추가한다. 첫 번째로 Foreground Service를 대신해 사용하라는 것. 두 번째로 JobScheduler등과 같이 지속적 실행이 아닌 '예약'을 통해 간헐적인 백그라운드 작업을 실행시키는 것이다.

하지만 그렇다고 Background Service가 무조건 나쁘다는 것은 아니. 적절히만 사용한다면 오히려 사용자 경험을 증가시키는데 기여할 수 있으며, 이런 예외 사항으로 4가지가 있다.

첫 번째로, FCM을 통해 사용하는 MyFirebaseMessageService이다. 이를 통해 알림 메시지를 수신받았을 경우, 안드로이드 시스템은 Background Service를 실행시킬 수 있는 약간의 유휴시간을 부여한다. 두 번째로, PendingIntentNotificationManager를 통해 실행되는 경우도 허용한다. 세 번째로, BroadcastReceiver를 통해 MMS/SMS메시지를 수신받았을 때도 허용하며, 마지막으로 VPNService를 활용한 네트워크 처리를 할 때도 Background Service사용이 허용된다. 즉, 안드로이드는 아래 4가지 사항일 때엔 Background Service를 사용할 수 있다고 말하며 이를 활용하는 것은 사용자 경험을 해치지 않으며 오히려 증가시킬 수 있음을 인정하고 있는 셈이다.


참고 : https://developer.android.com/about/versions/oreo/background?hl=ko#services

아래는 필자가 진행하고 있는 프로젝트의 코드로, SMS/MMS메시지를 수신받아 Background Service를 실행하고 있는 코드이다.

<service
    android:name=".util.SMSHandlerService"
    android:exported="false" />
<receiver
    android:name=".util.MySMSReceiver"
    android:enabled="true"
    android:exported="true"
    android:permission="android.permission.BROADCAST_SMS">
    <intent-filter>
        <action android:name="android.provider.Telephony.SMS_RECEIVED" />
    </intent-filter>
</receiver>

우선 첫 번째로, 매니페스트에서 MySMSReceiver를 등록했다. 이로 인해 기기 내 발생하는 SMS/MMS 브로드캐스트는 위 클래스로 라우팅되며 앱이 실행된다. 이렇게 되었을 경우, 안드로이드 시스템은 Background Service실행을 위한 유휴시간을 제공한다. 이에 맞게, BroadcastReceiver클래스의 onReceive콜백으로 받는 메서드 내부에서 SMSHadlerServiceBackground Service로 실행시켜주고 있다.

// MySMSReceiver 내부
override fun onReceive(context: Context, intent: Intent) {
	    CoroutineScope(Dispatchers.IO).launch {
    	try {
        	if (getPrefsBooleanUseCase(Pair(SP_MAIN_SMS_AGREE, false)).successOr(false)) {
            	context.startService(Intent(context, SMSHandlerService::class.java).apply {
                	putExtra(SMSHandlerService.USER_ID, userId)
                	putExtra(SMSHandlerService.SENDER, sender)
            	})
        	}
	    } finally {
    	    cancel()
    	}
     }
}

이 글을 읽으시는 분들도 위의 사례처럼 내가 구현할 Service가 위 4가지 사항에 부합한지 체크해보고, 만약 그렇다면 Background Service를 사용할 수 있을 것이다.

만약, Background Service를 AOS 14기기에서 실행시킨다면 어떤 결과가 발생할까? 크게 3가지로 분류해 서비스의 실행 여부를 체크할 수 있다.

[Service Test Case]
1. 화면을 나갔을 때, 실행되는가?
2. 홈 버튼을 눌러 앱을 나갔을 때, 실행되는가?
3. Task Stack에서 앱을 지웠을 때, 실행되는가?

간단하게 아래의 샘플코드를 작성해보았고 그에 대한 결과이다.

Manifest 내부 코드
<service
    android:name=".MyService"
    android:enabled="true"
    android:exported="true"
    />
// Activity 에서 실행
Intent(this, MyService::class.java).apply {
      startService(this)
}

// Service 내부 코드
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
  CoroutineScope(Dispatchers.Default).launch {
    repeat(100000000) {
      delay(1000L)
      println("serviceLog, [start], onStartCommand, count : $it, startId : $startId")
    }
  }
  return START_STICKY
}
override fun onTaskRemoved(rootIntent: Intent?) {
  super.onTaskRemoved(rootIntent)
  println("serviceLog, onTaskRemoved")
}
override fun onDestroy() {
  super.onDestroy()
  println("serviceLog, onDestroy")
}

[Service Test Case]
1. 화면을 나갔을 때, 실행되는가? : True
2. 홈 버튼을 눌러 앱을 나갔을 때, 실행되는가? -> True
3. Task Stack에서 앱을 지웠을 때, 실행되는가? -> False

onTaskRemovedonDestroy생명주기 흐름도 함께 말하자면, 백그라운드 서비스가 지장이 없을 경우, 이 두가지 생명주기는 호출되지 않지만 그렇지 않을 경우는 호출되는걸 확인할 수 있다.

Foreground Service

이 서비스는 Background Service와 약간의 차이를 가진다. 첫 번째는 해당 서비스 실행을 위해 매니페스트 상 권한을 설정해줘야 한다는 점이다. 두 번째로, startForegroundService(...)메서드 실행 후, onStartCommand()내부에서 startForeground()호출을 통해 해당 서비스를 Foreground Service로 승격시키는 추가 작업이 포합된다는 것이다. 그 후, 5초 이내에 NotificationManager를 활용하여 알림을 반드시 띄워줘야한다. (만약 그러지 않을 시, ANR에러가 발생하게 된다.)

[Background Service vs Foreground Service]

  • Foreground Service의 매니페스트 권한 설정
  • Foreground Service시작 메서드 상이
  • Foreground Service 추가 승격 필요
  • 승격 직후, 알림 추가 필요

Background Service와 마찬가지로 아래 3가지 Test Case를 실행해봤을 때 결과이다.

Manifest 내부 코드
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<service
    android:name=".MyService"
    android:enabled="true"
    android:exported="true"
    android:foregroundServiceType="dataSync"
    />
// Activity 에서 실행
Intent(this, MyService::class.java).apply {
      startForegroundService(this)
}

// Service 내부 코드
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
  val notification = Notification.Builder(this, "my_channel_id")
    .setContentTitle("Foreground Service")
    .setContentText("Service is running...")
    .setSmallIcon(android.R.drawable.ic_dialog_info)
    .build()
    
  startForeground(1, notification)
  CoroutineScope(Dispatchers.Default).launch {
    repeat(100000000) {
      delay(1000L)
	  println("serviceLog, [start], onStartCommand, count : $it, startId : $startId")
    }
  }
  return START_STICKY
}

[Service Test Case]
1. 화면을 나갔을 때, 실행되는가? -> True
2. 홈 버튼을 눌러 앱을 나갔을 때, 실행되는가? -> True
3. Task Stack에서 앱을 지웠을 때, 실행되는가? -> True

권한을 부여받은 Foreground Service는 앱을 Task Stack에서 지웠을 때도 실행이 잘된다. 하지만 차이가 있다면 디바이스 상단에 Notification이 계속 떠있다는 점이며, 이로 인해 사용자에게 현재 앱이 백그라운드 작업을 수행하고 있음을 알린다는 점이다.

하지만 해당 위 서비스 실행 시, 메모리 누수를 조심해야한다. 만약 아래와 같은 코드를 실행시켰을 때, 문제가 무엇일까?

button.setOnClickListener {
	Intent(this, MyService::class.java).apply {
      startForegroundService(this)
    }
}

버튼 클릭으로 서비스가 실행되기도 하지만 버튼 한 번의 클릭으로 위 Intent객체가 변수에 저장되지 않았음으로 추후 종료할 수 없다. 따라서 위 객체를 변수나 프로퍼티에 할당해두고 추후, stopService(Intent))호출을 잊지 말아야 한다.

Bind Service

실행할 백그라운드 작업이 Activity/Service 수명주기와 합이 맞아야할 때 사용한다. 좀 더 풀어서 말하자면, Background/Foreground Service는 서비스 종료를 위해 stopService()를 무조건 호출해야 하지만, Bind ServicebindService()호출 뿐만 아니라 이를 호출한 컴포넌트 수명주기가 onDestroy되었을 때에도 자동 종료될 수 있다는 의미다. 즉, Bind ServiceBackground/Foreground Service보다, 자신을 호출한 컴포넌트와 밀접하게 연관되는데, 이는 통신 또한 가능하다. 이러한 방법엔 Binder/Messenger/AIDL로 3가지가 존재한다.

차이에 대해 논의하기 전, 공통점을 먼저 간단히 짚고자 한다. Bind Service는 통신을 위한 필요조건이 있는데, 그건은 Service.onBind()IBind타입의 객체를 반환하면 이를 클라이언트의 ServiceConnection.onServiceConnectected()에서 IBinder를 수신받아 통신을 진행한다는 점이다.

// 서비스에서 3가지 방식의 IBinder를 반환
override fun onBind(intent: Intent): IBinder {
  // 1. IBinder 클래스를 직접 정의하여
  // 2. Messenger클래스를 사용해 IBinder타입을 반환하거나
  // 3. AIDL 정의 및 빌드 완료한 Stub파일의 IBinder타입을 반환하거나
  return binder
}
// 클라이언트에서 IBinder타입 수신 및 타입 캐스팅하여 사용
private val connection = object : ServiceConnection {
  override fun onServiceConnected(className: ComponentName, service: IBinder) {
    val binder = service as LocalService.LocalBinder
    mService = binder.getService()
  }
  override fun onServiceDisconnected(arg0: ComponentName) {  }
}

Binder Class

클라이언트와 서비스 간 통신은 큰 범주로 2가지로 우선 나눌 수 있다.

  • 하나의 앱 프로세스에서 클라이언트와 서비스 간 통신
  • 여럿의 앱 프로세스에서 클라이언트와 서비스 간 통신

첫 번째로 다룰 Bind Class는 '하나의 앱 프로세스에서 클라이언트와 서비스 간 통신'수행 시 사용하며, Messenger Class/AIDL은 '여럿의 앱 프로세스에서 클라이언트와 서비스 간 통신'수행 시 사용한다.

만약 내가 구현해야 할 Bind Service가 나의 앱 프로세스 내에서만 통신해야한다면? Bind Class구현체를 사용함이 현명하다. 또한 이는 꽤 괜찮은 장점도 존재하는데, Bind Class는 다른 2가지보다 구현도 용이할 뿐만 아니라, 직렬화/역직렬화 없이 참조 타입 객체를 전달할 수도 있다. 또한 추가적인 스레드를 사용하지 않아 동기화 이슈가 발생할 가능성도 없다. 아래는 안드로이드 공식 홈페이지에서 추출한 Binder Class를 직접 구현한 사례이다.

class LocalService : Service() {
    // Binder given to clients.
    private val binder = LocalBinder()

    // Random number generator.
    private val mGenerator = Random()

    /** Method for clients.  */
    val randomNumber: Int
        get() = mGenerator.nextInt(100)

    /**
     * Class used for the client Binder. Because we know this service always
     * runs in the same process as its clients, we don't need to deal with IPC.
     */
    inner class LocalBinder : Binder() {
        // Return this instance of LocalService so clients can call public methods.
        fun getService(): LocalService = this@LocalService
    }

    override fun onBind(intent: Intent): IBinder {
        return binder
    }
}
class BindingActivity : Activity() {
    private lateinit var mService: LocalService
    private var mBound: Boolean = false

    /** Defines callbacks for service binding, passed to bindService().  */
    private val connection = object : ServiceConnection {

        override fun onServiceConnected(className: ComponentName, service: IBinder) {
            // We've bound to LocalService, cast the IBinder and get LocalService instance.
            val binder = service as LocalService.LocalBinder
            mService = binder.getService()
            mBound = true
        }

        override fun onServiceDisconnected(arg0: ComponentName) {
            mBound = false
        }
    }

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

    override fun onStart() {
        super.onStart()
        // Bind to LocalService.
        Intent(this, LocalService::class.java).also { intent ->
            bindService(intent, connection, Context.BIND_AUTO_CREATE)
        }
    }

    override fun onStop() {
        super.onStop()
        unbindService(connection)
        mBound = false
    }

    /** Called when a button is clicked (the button in the layout file attaches to
     * this method with the android:onClick attribute).  */
    fun onButtonClick(v: View) {
        if (mBound) {
            // Call a method from the LocalService.
            // However, if this call is something that might hang, then put this request
            // in a separate thread to avoid slowing down the activity performance.
            val num: Int = mService.randomNumber
            Toast.makeText(this, "number: $num", Toast.LENGTH_SHORT).show()
        }
    }
}

Messenger Class

'여럿의 앱 프로세스에서 클라이언트와 서비스 간 통신'을 수행할 때, 첫 번째로 고려하면 좋은 솔루션이 Messenger Class의 사용이다. Messenger Class는 여럿 앱 프로세스가 보내는 요청을 단일 스레드 큐에 담고 이를 순차적 처리한다. 반면, AIDL는 멀티스레딩을 고려해야할 경우 적합하다.


참고 : https://developer.android.com/develop/background-work/services/bound-services?hl=ko#Creating

아래는 Messenger Class를 사용한 안드로이드 공식 홈페이지 샘플 코드이다.

/** Command to the service to display a message.  */
private const val MSG_SAY_HELLO = 1

class MessengerService : Service() {

    /**
     * Target we publish for clients to send messages to IncomingHandler.
     */
    private lateinit var mMessenger: Messenger

    /**
     * Handler of incoming messages from clients.
     */
    internal class IncomingHandler(
            context: Context,
            private val applicationContext: Context = context.applicationContext
    ) : Handler() {
        override fun handleMessage(msg: Message) {
            when (msg.what) {
                MSG_SAY_HELLO ->
                    Toast.makeText(applicationContext, "hello!", Toast.LENGTH_SHORT).show()
                else -> super.handleMessage(msg)
            }
        }
    }

    /**
     * When binding to the service, we return an interface to our messenger
     * for sending messages to the service.
     */
    override fun onBind(intent: Intent): IBinder? {
        Toast.makeText(applicationContext, "binding", Toast.LENGTH_SHORT).show()
        mMessenger = Messenger(IncomingHandler(this))
        return mMessenger.binder
    }
}
class ActivityMessenger : Activity() {
    /** Messenger for communicating with the service.  */
    private var mService: Messenger? = null

    /** Flag indicating whether we have called bind on the service.  */
    private var bound: Boolean = false

    /**
     * Class for interacting with the main interface of the service.
     */
    private val mConnection = object : ServiceConnection {

        override fun onServiceConnected(className: ComponentName, service: IBinder) {
            // This is called when the connection with the service has been
            // established, giving us the object we can use to
            // interact with the service.  We are communicating with the
            // service using a Messenger, so here we get a client-side
            // representation of that from the raw IBinder object.
            mService = Messenger(service)
            bound = true
        }

        override fun onServiceDisconnected(className: ComponentName) {
            // This is called when the connection with the service has been
            // unexpectedly disconnected&mdash;that is, its process crashed.
            mService = null
            bound = false
        }
    }

    fun sayHello(v: View) {
        if (!bound) return
        // Create and send a message to the service, using a supported 'what' value.
        val msg: Message = Message.obtain(null, MSG_SAY_HELLO, 0, 0)
        try {
            mService?.send(msg)
        } catch (e: RemoteException) {
            e.printStackTrace()
        }

    }

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

    override fun onStart() {
        super.onStart()
        // Bind to the service.
        Intent(this, MessengerService::class.java).also { intent ->
            bindService(intent, mConnection, Context.BIND_AUTO_CREATE)
        }
    }

    override fun onStop() {
        super.onStop()
        // Unbind from the service.
        if (bound) {
            unbindService(mConnection)
            bound = false
        }
    }
}

AIDL

여럿 앱 프로세스들이 요청을 보낼 경우, 안드로이드 플랫폼은 자체적으로 관리하는 스레드 풀 내에서 해당 요청들에 적절한 스레드를 할당하고 해당 요청들이 병럴적으로 들어온다. 즉, 멀티스레드적으로 요청 처리 시, AIDL방식을 고려한다. 하지만 이 방식은 Race Condition등의 문제를 추가적으로 고려해야하며, .aidl파일에 인터페이스를 정의해야하기에 좀 더 수고가 드는 방식이기도 하다. 아래 또한 공식 홈페이지 코드이다.


참고 : https://developer.android.com/develop/background-work/services/aidl?hl=ko

class RemoteService : Service() {

    override fun onCreate() {
        super.onCreate()
    }

    override fun onBind(intent: Intent): IBinder {
        // Return the interface.
        return binder
    }


    private val binder = object : IRemoteService.Stub() {
        override fun getPid(): Int {
            return Process.myPid()
        }

        override fun basicTypes(
                anInt: Int,
                aLong: Long,
                aBoolean: Boolean,
                aFloat: Float,
                aDouble: Double,
                aString: String
        ) {
            // Does nothing.
        }
    }
}
private const val BUMP_MSG = 1

class Binding : Activity() {

    /** The primary interface you call on the service.  */
    private var mService: IRemoteService? = null

    /** Another interface you use on the service.  */
    internal var secondaryService: ISecondary? = null

    private lateinit var killButton: Button
    private lateinit var callbackText: TextView
    private lateinit var handler: InternalHandler

    private var isBound: Boolean = false

    /**
     * Class for interacting with the main interface of the service.
     */
    private val mConnection = object : ServiceConnection {

        override fun onServiceConnected(className: ComponentName, service: IBinder) {
            // This is called when the connection with the service is
            // established, giving us the service object we can use to
            // interact with the service.  We are communicating with our
            // service through an IDL interface, so get a client-side
            // representation of that from the raw service object.
            mService = IRemoteService.Stub.asInterface(service)
            killButton.isEnabled = true
            callbackText.text = "Attached."

            // We want to monitor the service for as long as we are
            // connected to it.
            try {
                mService?.registerCallback(mCallback)
            } catch (e: RemoteException) {
                // In this case, the service crashes before we can
                // do anything with it. We can count on soon being
                // disconnected (and then reconnected if it can be restarted)
                // so there is no need to do anything here.
            }

            // As part of the sample, tell the user what happened.
            Toast.makeText(
                    this@Binding,
                    R.string.remote_service_connected,
                    Toast.LENGTH_SHORT
            ).show()
        }

        override fun onServiceDisconnected(className: ComponentName) {
            // This is called when the connection with the service is
            // unexpectedly disconnected&mdash;that is, its process crashed.
            mService = null
            killButton.isEnabled = false
            callbackText.text = "Disconnected."

            // As part of the sample, tell the user what happened.
            Toast.makeText(
                    this@Binding,
                    R.string.remote_service_disconnected,
                    Toast.LENGTH_SHORT
            ).show()
        }
    }

    /**
     * Class for interacting with the secondary interface of the service.
     */
    private val secondaryConnection = object : ServiceConnection {

        override fun onServiceConnected(className: ComponentName, service: IBinder) {
            // Connecting to a secondary interface is the same as any
            // other interface.
            secondaryService = ISecondary.Stub.asInterface(service)
            killButton.isEnabled = true
        }

        override fun onServiceDisconnected(className: ComponentName) {
            secondaryService = null
            killButton.isEnabled = false
        }
    }

    private val mBindListener = View.OnClickListener {
        // Establish a couple connections with the service, binding
        // by interface names. This lets other applications be
        // installed that replace the remote service by implementing
        // the same interface.
        val intent = Intent(this@Binding, RemoteService::class.java)
        intent.action = IRemoteService::class.java.name
        bindService(intent, mConnection, Context.BIND_AUTO_CREATE)
        intent.action = ISecondary::class.java.name
        bindService(intent, secondaryConnection, Context.BIND_AUTO_CREATE)
        isBound = true
        callbackText.text = "Binding."
    }

    private val unbindListener = View.OnClickListener {
        if (isBound) {
            // If we have received the service, and hence registered with
            // it, then now is the time to unregister.
            try {
                mService?.unregisterCallback(mCallback)
            } catch (e: RemoteException) {
                // There is nothing special we need to do if the service
                // crashes.
            }

            // Detach our existing connection.
            unbindService(mConnection)
            unbindService(secondaryConnection)
            killButton.isEnabled = false
            isBound = false
            callbackText.text = "Unbinding."
        }
    }

    private val killListener = View.OnClickListener {
        // To kill the process hosting the service, we need to know its
        // PID.  Conveniently, the service has a call that returns
        // that information.
        try {
            secondaryService?.pid?.also { pid ->
                // Note that, though this API lets us request to
                // kill any process based on its PID, the kernel
                // still imposes standard restrictions on which PIDs you
                // can actually kill. Typically this means only
                // the process running your application and any additional
                // processes created by that app, as shown here. Packages
                // sharing a common UID are also able to kill each
                // other's processes.
                Process.killProcess(pid)
                callbackText.text = "Killed service process."
            }
        } catch (ex: RemoteException) {
            // Recover gracefully from the process hosting the
            // server dying.
            // For purposes of this sample, put up a notification.
            Toast.makeText(this@Binding, R.string.remote_call_failed, Toast.LENGTH_SHORT).show()
        }
    }

    // ----------------------------------------------------------------------
    // Code showing how to deal with callbacks.
    // ----------------------------------------------------------------------

    /**
     * This implementation is used to receive callbacks from the remote
     * service.
     */
    private val mCallback = object : IRemoteServiceCallback.Stub() {
        /**
         * This is called by the remote service regularly to tell us about
         * new values.  Note that IPC calls are dispatched through a thread
         * pool running in each process, so the code executing here is
         * NOT running in our main thread like most other things. So,
         * to update the UI, we need to use a Handler to hop over there.
         */
        override fun valueChanged(value: Int) {
            handler.sendMessage(handler.obtainMessage(BUMP_MSG, value, 0))
        }
    }

    /**
     * Standard initialization of this activity.  Set up the UI, then wait
     * for the user to interact with it before doing anything.
     */
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContentView(R.layout.remote_service_binding)

        // Watch for button taps.
        var button: Button = findViewById(R.id.bind)
        button.setOnClickListener(mBindListener)
        button = findViewById(R.id.unbind)
        button.setOnClickListener(unbindListener)
        killButton = findViewById(R.id.kill)
        killButton.setOnClickListener(killListener)
        killButton.isEnabled = false

        callbackText = findViewById(R.id.callback)
        callbackText.text = "Not attached."
        handler = InternalHandler(callbackText)
    }

    private class InternalHandler(
            textView: TextView,
            private val weakTextView: WeakReference<TextView> = WeakReference(textView)
    ) : Handler() {
        override fun handleMessage(msg: Message) {
            when (msg.what) {
                BUMP_MSG -> weakTextView.get()?.text = "Received from service: ${msg.arg1}"
                else -> super.handleMessage(msg)
            }
        }
    }
}

참고

profile
불가능보다 가능함에 몰입할 수 있는 개발자가 되기 위해 노력합니다.

0개의 댓글