나미래 Android 개발자

Foreground Service Short Service로 게시글 업로드 구현하기 본문

안드로이드

Foreground Service Short Service로 게시글 업로드 구현하기

Moimeme Futur 2025. 12. 24. 23:25

안드로이드에서 백그라운드 작업을 안정적으로 처리하면서도,

사용자에게 현재 작업이 진행 중임을 명확하게 알려야 하는 경우 어떤 선택이 적절할까?

 

단순한 네트워크 요청이라면 WorkManager가 좋은 선택이 될 수 있다.
하지만 다음 조건을 모두 만족하는 작업이라면 이야기가 달라진다.

  • 사용자가 명확히 트리거한 작업이고
  • 즉시 실행되어야 하며
  • 앱이 백그라운드로 전환되더라도 중단되면 안 되고
  • 수행 시간이 길지 않은 작업

이 글에서는 이러한 조건을 만족하는 게시글 업로드(이미지 + 텍스트) 시나리를 예시로, Foreground Service 중에서 Short Service를 어떻게 사용하면 좋을지 정리해본다.

 

참고). Android 14(API 34)에서 강화된 정책까지 함께 다룬다.

 

샘플 전체 코드가 궁금한 경우, 제일 하단에 위치한 예제 코드부터 확인하면 된다.

 

Foreground Service란?

Foreground Service사용자가 인지해야 하는 작업을 수행하기 위해 사용하는 Service다.

핵심 특징은 다음과 같다.

  • 반드시 Notification을 통해 사용자에게 작업 수행 중임을 알려야 한다
  • 백그라운드 상태에서도 비교적 높은 실행 우선순위를 가진다
  • 시스템 리소스를 지속적으로 사용하므로 남용하면 안 된다

즉, Foreground Service는 단훈시 "백그라운드에서 오래 돌리기 위한 도구"가 아니라,
사용자가 지금 이 작업이 실행 중이라는 사실을 알아야 하는 경우에만 사용하는 것이 전제다.

 

언제 Foreground Service를 사용해야 할까?

다음 질문에 모두 Yes라면 Foreground Service를 고려해볼 수 있다.

  • 이 작업은 사용자 액션으로 직접 시작되었는가?
  • 작업이 진행 중임을 사용자가 인지해야 하는가?
  • 앱이 백그라운드로 가도 작업이 중단되면 안 되는가?

게시글 업로드는 보통 이 조건을 만족한다.
사용자가 업로드 버튼을 눌렀고, 업로드 중이라는 사실을 알고 싶어 하며,
업로드 도중 앱을 나가더라도 작업이 계속되기를 기대한다.

이런 맥락에서 Foreground Service는 자연스러운 선택이다.

 

Foreground Service, short Service란?

Short Service짧은 시간 안에 반드시 종료되는 작업을 위한 Foreground Service 타입이다.

공식 문서 기준 핵심 제약은 다음과 같다.

  • 3분 이내에 작업이 종료되어야 한다
  • STICKY 재시작을 지원하지 않는다
  • Android 14(API 34)부터는 타임아웃이 강제되며, 무시 시 ANR이 발생한다

즉, Short Service는 "지금 당장 끝내야 하는 작업을 빠르게 처리하고 즉시 정리하는 Service를 의미한다.

 

Foreground Service 사용 방법

1. Foreground Service 선언 및 권한

Foreground Service 권한 및 Service 선언

Short Service를 사용하기 위해서는 FOREGROUND_SERVICE 권한과 사용할 Service를 Manifest에 선언해 한다.

<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />

<application>
    <service
        android:name=".service.PostService"
        android:foregroundServiceType="shortService" />
</application>

 

Notification 권한에 대한 이해

공식 문서에서는 Foreground Service 실행 자체에는 POST_NOTIFICATION 권한이 필수는 아니라고 명시한다.

Apps don't need to request the POST_NOTIFICATIONS permission in order to launch a foreground service.

 

다만 Foreground Service의 목적은 사용자에게 작업 수행 사실을 알리는 것이므로, 실제 사용자 경험 관점에서는 Notification을 정상적으로 노출하기 위해 POST_NOTIFICATIONS 권한을 함께 사용하는 것이 바람직하다.

<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
    <application>
        <service
            android:name=".service.PostService"
            android:foregroundServiceType="shortService" />
    </application>
</manifest>

 

2. Foreground Service 실행

Foreground Service는 onStartCommand() 내부에서 startForeground()를 호출함으로써 시작된다.

Foreground Service의 Short Service를 사용할 때 반드시 기억해야 할 두 가지 포인트가 있다.

1. START_NOT_STICKY 사용

  • Short Service는 재시작을 전제로 하지 않는다.
  • 따라서 onStartCommand()의 반환값으로는 START_NOT_STICKY를 사용하는 것이 적절하다.

2. startForegound() 호출 시 OS 버전 분기

  • ForegroundServiceType 개념은 Android 10(API 29)에서 도입되었다.
  • 따라서 Short Service를 의도대로 동작시키기 위해서는 OS 버전 분기가 필요하다.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
    startForeground(id, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE)
} else {
    startForeground(id, notification)
}

 

3. Foreground Service 종료 (중요)

Android 14부터 Foreground Service의 Short Service에는 3분 타임아웃이 강제된다.

타임아웃이 발생하면 Service.onTimeout() 콜백이 호출되며, 이를 처리하지 않으면 *ANR이 발생한다.

따라서 반드시 다음 처리가 필요하다.

  • stopForeground(): Foreground 상태 해제
  • stopSelf(): Service 종료
override fun onTimeout(startId: Int) {
    stopForeground(STOP_FOREGROUND_REMOVE)
    stopSelf(startId)
}

 

게시글 업로드를 Foreground Service Short Service로 구현하기

게시글 업로드 예제에서는 다음과 같은 설계 기준을 사용했다.

  • 업로드 성공 여부는 서버 응답 기준으로 판단한다
  • Short Service 타임아웃은 실패로 간주하지 않는다
  • 비즈니스 실패에 대해서만 실패 Notification을 노출한다

이는 Short Service의 타임아웃이 네트워크 통신 실패가 아니라 Service 수명 정책 이벤트라는 점을 고려한 선택이다.

아래는 전체 예제 코드다.

@AndroidEntryPoint
class PostService : LifecycleService() {

    @Inject
    lateinit var postBoardUseCase: PostBoardUseCase

    @Inject
    lateinit var notificationHelper: NotificationHelper

    @Inject
    lateinit var postEventBus: PostEventBus

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        super.onStartCommand(intent, flags, startId)
        val boardParcel = intent?.getParcelableExtra(EXTRA_BOARD, BoardParcel::class.java)
        if (boardParcel == null) {
            stopSelf(startId)
            return START_NOT_STICKY
        }
        startForegroundService()
        lifecycleScope.launch(Dispatchers.IO) {
            postBoard(startId, boardParcel)
        }
        return START_NOT_STICKY
    }

    private fun startForegroundService() {
        val notification = notificationHelper.createPostBoardUploadNotification()

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            startForeground(
                NotificationHelper.POST_UPLOAD_NOTIFICATION_ID,
                notification,
                ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE
            )
        } else {
            startForeground(
                NotificationHelper.POST_UPLOAD_NOTIFICATION_ID,
                notification
            )
        }
    }

    private suspend fun postBoard(startId: Int, boardParcel: BoardParcel) {
        try {
            postBoardUseCase(
                title = boardParcel.title,
                content = boardParcel.content,
                images = boardParcel.images
            ).fold(
                onSuccess = { boardId ->
                    postEventBus.emit(PostEvent.Success(boardId))
                },
                onFailure = { exception ->
                    val errorMessage = when (exception) {
                        is PostException -> exception.userMessage
                        else -> exception.message ?: "업로드에 실패했습니다."
                    }
                    postEventBus.emit(PostEvent.Failure(errorMessage))
                    throw exception
                }
            )
        } catch (e: Exception) {
            showFailedNotification()
        } finally {
            stopForeground(STOP_FOREGROUND_REMOVE)
            stopSelf(startId)
        }
    }

    private fun showFailedNotification() = with(notificationHelper) {
        val notification = createPostBoardUploadFailedNotification()
        notify(NotificationHelper.POST_UPLOAD_NOTIFICATION_ID, notification)
    }

    override fun onTimeout(startId: Int) {
        super.onTimeout(startId)
        stopForeground(STOP_FOREGROUND_REMOVE)
        stopSelf(startId)
    }

    companion object {
        const val EXTRA_BOARD = "extra_board"
    }
}
Comments