2026/03/14

오늘의 이야기

 


손에 들고 다니는 스마트폰에 무슨 일을 그렇게 시켜 먹을라고(?) 이런 것들이 있는 가? 하는 생각이 들 무렵입니다. 


그래도 우린 이제 이런 배치(반복작업을 위한) 처리를 해야 하는 경우가 있어서 이런 것들에 대해서 알아 보고자 합니다. 


 



  • Alarm manager

  • Job Scheduler

  • Worker


반복적인 일을 시키는 방법 3가지를 살펴 보고자 합니다.


Alarm manager


알림은 지정한 시간에 어떤 이벤트가 있는 지를 알려주는 역할을 하게 됩니다.  그것들 중에서 1회성 알림만을 사용하는 경우도 있기는 하겠지만,  앱을 개발하는 경우에는 반복적인 알림을 발생시키는 것이 좋을 때가 있습니다.  그때 사용을 하게 될 것 같습니다. 


 


알림 매니저가 좋은 건 15분 미만의 경우도 반복 작업을 할 수 있다는 점 입니다.


https://developer.android.com/reference/android/app/AlarmManager



 


AlarmManager  |  Android Developers


 


developer.android.com




 


Job Scheduler


 


이름 그대로 일을 스케줄에 맞게 반복적인 작업을 실행하는 경우에 사용하게 될 것 같습니다. 다만,  안드로이드 버전이 높아지면서 배터리 효율을 관리하기 위해 최소 시간이 15분이라는 간격을 유지해야 한다는 불편함(?)이 있다는 것이 아쉽게 다가올 뿐입니다. 


 


https://developer.android.com/reference/android/app/job/JobScheduler



 


JobScheduler  |  Android Developers


 


developer.android.com




 


Worker


비동기식 작업을 설정하는 방법 중에 하나라고는 들었으나, 아직 아는 바가 없어 이 부분에 대한 기술은 훗날로 미루어 봅니다. ㅋ~


https://developer.android.com/reference/androidx/work/Worker



 


Worker  |  Android Developers


androidx.constraintlayout.core.motion.parse


developer.android.com




 


구현해 보기


이제 코드로 하나씩 만들어 보겠습니다.  


 


JobScheduler 관리를 위해서 다음과 같이 선언해 둡니다. jobId 가 있어야 하기 때문이기도 하고, 반복 간격을 지정하기 위한 상수도 선언합니다. 그리고 성공 여부를 확인 하기 위한 상수도 같이 선언 합니다.


private val JOB_ID = 123
private var PERIODIC_TIME: Long = 15 * 60 * 1000
private val SUCCESS_KEY = "SUCCESS"
private val FAILED_KEY = "FAILED"

 


알림 설정을 위해서는 다음과 같이 알림 매니저와 알림 실행 시 사용한 intent 변수 하나를 설정해 둡니다.


private var alarmMgr: AlarmManager? = null
private lateinit var alarmIntent: PendingIntent

 


JobScheduler의 시작은 다음과 같이 코드 작업을 합니다.  jobServices는 반복 작업에서 실행시킬 class 이름입니다. 



  • setPersisted : 부팅된 이후에도 반복을 하게 할지 여부를 선택합니다.

  • setPeriodic : 반복 시간을 mS 단위로 설정하게 됩니다.


다른 선택 값들도 있으니 참고해서 살펴보세요.


val componentName = ComponentName(this, jobServices::class.java)
val info = JobInfo.Builder(JOB_ID, componentName)
.setPersisted(true) // true 부팅된 이후에도 반복하게
.setPeriodic(PERIODIC_TIME) // 지연시간
.build()

val jobScheduler: JobScheduler = getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler
val resultCode = jobScheduler.schedule(info)

val isJobScheduledSuccess = resultCode == JobScheduler.RESULT_SUCCESS
Log.e(TAG, "Job Scheduled ${if (isJobScheduledSuccess) SUCCESS_KEY else FAILED_KEY}")

 


알림의 경우도 알림 상수로 선언한 알림 매니저에 알림 실행 시 사용할 AlarmReceiver을 선언하고  알림 설정을 시작합니다.  반복 작업을 할 예정이기 때문에 Repeting 함수들 중에서 골랐습니다.  반복이 아닌 1회성의 경우는 다른 함수를 사용하게 됩니다. 



  • setInexactRepeating : 알림 시간 이후 반복 시간 동안 절전모드를 해제하여 알림을 발생시킵니다.

  • set : 시정된 시간에 알림을 1회성으로 절전모드를 해제하여 알림을 발생시킵니다.


alarmMgr = getSystemService(Context.ALARM_SERVICE) as AlarmManager
alarmIntent = Intent(applicationContext, AlarmReceiver::class.java).let { intent ->
PendingIntent.getBroadcast(applicationContext, 0, intent, FLAG_MUTABLE)
}

alarmMgr?.setInexactRepeating(
AlarmManager.ELAPSED_REALTIME_WAKEUP,
SystemClock.elapsedRealtime() + sp.getFloat("repeatTerm", 1f).toLong() * 60 * 1000, // 1분 단위로
sp.getFloat("repeatTerm", 1f).toLong() * 60 * 1000,
alarmIntent
)

 


이제 코드 작업을 시작하였으니 작업 해제 하는 코드를 살펴보겠습니다. 


 


JobScheduler의 경우는 앞에서 시작할 때 지정했던 jobID을 이용하여 반복 작업을 하는 하는 반면에...


val jobScheduler: JobScheduler = getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler
jobScheduler.cancel(JOB_ID)
Log.e(TAG, "Job CANCELED")

 


알림의 경우는 알림 매니저를 그냥 cancel 하는 것으로 작업이 종료됩니다.


alarmMgr?.cancel(alarmIntent)

 


이제 각 배치 작업에서 사용할 Service의 코드를 보겠습니다. 


먼저 manifest.xml 파일에 등록한 부분을 보겠습니다. 


<receiver android:name=".receivers.AlarmReceiver"
android:exported="false"/>

<service
android:name=".services.jobServices"
android:exported="true"
android:permission="android.permission.BIND_JOB_SERVICE" />

알림을 위해서는 receiver로 등록을 하게 되고, jobscheduler을 위해서는 jobservices로 등록을 하게 됩니다.  각 class의 전체 코드는 다음과 같습니다. 


 


JobServices 


import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.job.JobParameters
import android.app.job.JobService
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.billcoreatech.smsreceiver1113.BuildConfig
import com.billcoreatech.smsreceiver1113.MainActivity
import com.billcoreatech.smsreceiver1113.R
import com.billcoreatech.smsreceiver1113.retrofit.RetrofitService
import java.text.SimpleDateFormat
import java.util.*

@SuppressLint("SpecifyJobSchedulerIdRange")
class jobServices : JobService() {

companion object {
private val TAG = "MyJobService"
lateinit var sp: SharedPreferences
}

@RequiresApi(Build.VERSION_CODES.S)
override
fun onStartJob(params: JobParameters): Boolean {
var strDate = System.currentTimeMillis()
var sdf = SimpleDateFormat("yyyy-MM-dd kk:mm:ss", Locale("ko", "KR"))
var now = sdf.format(strDate )
var context = this@jobServices

sp = getSharedPreferences(packageName, MODE_PRIVATE)

Log.e(TAG, "${now} onStartJob: ${params.jobId} ${sp.getFloat("repeatTerm", 1f).toLong()} ${sp.getBoolean("jobScheduled", false)}")
sendNotification(this@jobServices, "Activated ...")
return false
}

override
fun onStopJob(params: JobParameters): Boolean {
Log.e(TAG, "onStopJob: ${params.jobId}")
return false
}

@RequiresApi(Build.VERSION_CODES.S)
@SuppressLint("MissingPermission")
private fun sendNotification(context : Context, messageBody: String) {
val intent = Intent(context, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
val extras = Bundle()
extras.putString("MSGRCV", messageBody)
intent.putExtras(extras)
val pendingIntent = PendingIntent.getActivity(
context, 0 /* Request code */, intent,
PendingIntent.FLAG_MUTABLE
)

val channelId: String = context.getString(R.string.default_notification_channel_id)
val channelName: CharSequence = context.getString(R.string.default_notification_channel_name)
val importance = NotificationManager.IMPORTANCE_HIGH
val notificationChannel = NotificationChannel(channelId, channelName, importance)
notificationChannel.enableLights(true)
notificationChannel.lightColor = Color.RED
notificationChannel.enableVibration(true)
notificationChannel.vibrationPattern =
longArrayOf(100, 200, 300, 400, 500, 400, 300, 200, 400)

val wearNotifyManager = NotificationManagerCompat.from(context)
val wearNotifyBuilder: NotificationCompat.Builder =
NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_outline_sms_24)
.setContentTitle(context.getString(R.string.app_name))
.setContentText(messageBody)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.setVibrate(longArrayOf(100, 200, 300, 400, 500, 400, 300, 200, 400))
.setDefaults(-1)

wearNotifyManager.createNotificationChannel(notificationChannel)
wearNotifyManager.notify(0, wearNotifyBuilder.build())
}
}

 


AlarmReceiver



import android.app.AlarmManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.SystemClock
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import com.billcoreatech.smsreceiver1113.MainActivity
import com.billcoreatech.smsreceiver1113.R

class AlarmReceiver : BroadcastReceiver() {

companion object {
const val TAG = "AlarmReceiver"
const val NOTIFICATION_ID = 0
const val PRIMARY_CHANNEL_ID = "primary_notification_channel"
}

lateinit var notificationManager: NotificationManager

@RequiresApi(Build.VERSION_CODES.S)
override fun onReceive(context: Context, intent: Intent) {
Log.d(TAG, "Received intent : $intent")
notificationManager = context.getSystemService(
Context.NOTIFICATION_SERVICE) as NotificationManager

createNotificationChannel(context)
deliverNotification(context)

}

@RequiresApi(Build.VERSION_CODES.S)
private fun deliverNotification(context: Context) {
val contentIntent = Intent(context, MainActivity::class.java)
val contentPendingIntent = PendingIntent.getActivity(
context,
NOTIFICATION_ID,
contentIntent,
PendingIntent.FLAG_MUTABLE
)
var sp = context.getSharedPreferences(context.packageName, Context.MODE_PRIVATE)

val builder =
NotificationCompat.Builder(context, PRIMARY_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_outline_sms_24)
.setContentTitle(context.getString(R.string.app_name))
.setContentText("This is repeating alarm repeatTerm ${sp.getFloat("repeatTerm", 1f).toLong()} min")
.setContentIntent(contentPendingIntent)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setDefaults(NotificationCompat.DEFAULT_ALL)

Log.e("", "This is repeating alarm repeatTerm ${sp.getFloat("repeatTerm", 1f).toLong()} min")

notificationManager.notify(NOTIFICATION_ID, builder.build())
}

private fun createNotificationChannel(context: Context) {

val notificationChannel = NotificationChannel(
PRIMARY_CHANNEL_ID,
context.getString(R.string.app_name),
NotificationManager.IMPORTANCE_HIGH
)
notificationChannel.enableLights(true)
notificationChannel.lightColor = R.color.softRed
notificationChannel.enableVibration(true)
notificationChannel.description = context.getString(R.string.app_name)
notificationManager.createNotificationChannel(
notificationChannel)

}
}

 


두 가지 케이스 모두 그냥 job에서는 Notification을 발생시키는 것이 목적인 코드입니다.  이제 코드작성 방법은 다 모았으니 다른 작업으로 발전해 나갈 수 있겠지요?


 


 





오늘의 이야기

onBackPressed 가 deprecated 되었다?


이제 우리는 구글이 제안하는 안드로이드 13에 타기팅하는 앱을 제출 해야만 하는 시기에 도달하고 있습니다.  구글이 새로운 안드로이드 버전을 배포하기 시작하면서 오래된 안드로이드에 대한 게시를 제한 합니다. 


 


그래서 이번에 API 33 인 안드로이드 13에 타겟팅 하는 앱을 작성해 보았습니다. 그러다 만난 몇 가지 사용 제한이 되는 것들에 대한 정리를 해 두고자 합니다. 


 


onBackPressed는 사용자가 뒤로 가기 버튼을 클릭하는 경우 제어를 하기 위해서 사용했던 함수 입니다. MainActivity 에서 최종적으로 뒤로 가기를 클릭 하는 경우 앱을 종료시키는 기능도 사용이 되는 함수였는 데...


 


안드로이드 13에서는 더 이상 사용할 수 없는 (?)  - 사용은 가능 하나 소스 코드에 중간 줄이 생긴 모양을 보면서 코드를 지속적으로 봐야 합니다. 


 


onBackPressed



어떻게 해소를 하면 될까요?


 


CallBack을 하나 만들어 봅니다.


private val callback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
doCloseApps()
}
}

다른 건 없고 이런 모양으로 callback 함수를 하나 만들어 둡니다.  그러고 onCreate 에서 이 callback 이 호출 되도록 한 줄 넣어 주는 것으로 그 코딩은 마무리 됩니다. 


 


@RequiresApi(Build.VERSION_CODES.TIRAMISU)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

this.onBackPressedDispatcher.addCallback(this, callback)


}

그리고 나서 callback에서 처리하고자 하는 코드를 작성하는 것으로 끝입니다. 


 


간단하죠?





오늘의 이야기


#billcorea #운동동아리관리앱
🏸 Schneedle — незаменимое приложение для бадминтонных клубов!
👉 Матчевая игра: записывайте результаты и находите противников 🎉
Идеально подходит для любого места: в одиночку, с друзьями или в клубе! 🤝
Если вам нравится бадминтон, обязательно попробуйте

Зайди в приложение 👉 https://play.google.com/store/apps/details?id=com.billcorea.matchplay




오늘의 이야기

구글 클라우드 함수 설정 


서버 없는 개발자여... 이제 당신도 서버의 역할을 구성할 수 있습니다.  이 글은 아래 개발자 가이드를 참고하여 작성했습니다.


 


https://cloud.google.com/functions/docs/create-deploy-http-python?hl=ko#windows 



 


빠른 시작: Python을 사용하여 HTTP Cloud 함수 만들기 및 배포  |  Cloud Functions 문서  |  Google Cloud


의견 보내기 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 빠른 시작: Python을 사용하여 HTTP Cloud 함수 만들기 및 배포 Python을 사용하여 HTTP Cloud 함수 만들기


cloud.google.com




 


1. 프로젝트 선택


이미 구성해 놓은 여러 개의 프로젝트 중에서 사용할 프로젝트를 선택하는 과정입니다.


프로젝트 선택



 


2. API 사용 설정


다음 해당 프로젝트에서 APIs을 사용할 수 있도록 설정하는 단계입니다.  프로젝트를 확인하고 APIs을 사용하도록 설정합니다.


프로젝트 확인


사용설정



3. gCloud Client을 설치하고 설정을 초기화


설정이 완료되었으니, 이제 cloud sdk installer을 다운로드하고 나서 설치를 진행합니다. 설치하는 과정은 next 버튼을 클릭하는 것으로 완료가 됩니다. 시간은 조금 소요됩니다.


GoogleCloudSDKInstaller.exe 를 다운로드 하고 설치 합니다.

설치가 되고 나면




cloud을 위한 powershell을 찾아서 실행합니다.  (Windows 11 기준에서)


 


4. 환경을 초기화합니다.


이제 환경 설정을 해 보겠습니다. gclound init 실행하면 다음과 같이 환경 설정이 시작됩니다.  여기서 누락된 부분은 서버의 스토리지 위치인데, 가급적이면 asia로 해 주는 것이 나중에 실행 시에 도움이 됩니다. 


PS C:\workspaces\cloudhome\boss0426> gcloud init
Welcome! This command will take you through the configuration of gcloud.

Settings from your current configuration [default] are:
accessibility:
screen_reader: 'False'
core:
account: 6****@gmail.com
disable_usage_reporting: 'False'
project: bespeak-f3bff

Pick configuration to use:
[1] Re-initialize this configuration [default] with new settings
[2] Create a new configuration
Please enter your numeric choice: 2

Enter configuration name. Names start with a lower case letter and contain only lower case letters a-z, digits 0-9, and
hyphens '-': bo****ew
Your current configuration has been set to: [boss0426-new]

You can skip diagnostics next time by using the following flag:
gcloud init --skip-diagnostics

Network diagnostic detects and fixes local network connection issues.
Checking network connection...done.
Reachability Check passed.
Network diagnostic passed (1/1 checks passed).

Choose the account you would like to use to perform operations for this configuration:
[1] 6***@gmail.com
[2] Log in with a new account
Please enter your numeric choice: 1

You are logged in as: [6k2emg@gmail.com].

Pick cloud project to use:
[1] boss0426-f0490
[2] Enter a project ID
[3] Create a new project
Please enter numeric choice or text value (must exactly match list item): 1

Not setting default zone/region (this feature makes it easier to use
[gcloud compute] by setting an appropriate default value for the
--zone and --region flag).
See https://cloud.google.com/compute/docs/gcloud-compute section on how to set
default compute region and zone manually. If you would like [gcloud init] to be
able to do this for you the next time you run it, make sure the
Compute Engine API is enabled for your project on the
https://console.developers.google.com/apis page.

Your Google Cloud SDK is configured and ready to use!

* Commands that require authentication will use 6k***@gmail.com by default
* Commands will reference project `boss0426-f0490` by default
Run `gcloud help config` to learn how to change individual settings

This gcloud configuration is called [boss0426-new]. You can create additional configurations if you work with multiple accounts and/or projects.
Run `gcloud topic configurations` to learn more.

Some things to try next:

* Run `gcloud --help` to see the Cloud Platform services you can interact with. And run `gcloud help COMMAND` to get help on any gcloud command.
* Run `gcloud topic --help` to learn about advanced features of the SDK like arg files and output formatting
* Run `gcloud cheat-sheet` to see a roster of go-to `gcloud` commands.

 


5. python 설정을 시작합니다.


https://cloud.google.com/python/docs/setup?hl=ko 



 


Python 개발 환경 설정  |  Google Cloud


의견 보내기 Python 개발 환경 설정 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 이 가이드에서는 Google Cloud에서 실행되는 Python 앱 개발을 포함하여 Python 개


cloud.google.com




개발 가이드의 내용을 보고 따라 해 봅니다.


먼저 project 가 들어 있는 폴더로 이동합니다.  다음 실행 명령어 다음과 같습니다.



  • py -m pip --version : pip 버전은 항상 최신을 유지하도록 해야 합니다.  python.exe -m pip install --upgrade pip을 실행해서 최신 버전이 설치되도록 한 다음 진행 하면 좋습니다.

  • py -m venv env : 이제 개별 환경을 위한 가상 환경을 구성합니다.

  • .\env\Scripts\actvate : 구성된 가상 환경에서 스크립트를 실행해 화면을 활성화합니다.

  • pip install google-cloud-storage : 가상 환경에 cloud 함수 실행을 위한 라이브러리를 설치합니다.


PS C:\workspaces\cloudhome\boss0426> py -m pip --version
pip 22.3.1 from C:\Users\nari4\AppData\Roaming\Python\Python311\site-packages\pip (python 3.11)
PS C:\workspaces\cloudhome\boss0426> py -m venv env
PS C:\workspaces\cloudhome\boss0426> .\env\Scripts\activate
(env) PS C:\workspaces\cloudhome\boss0426>
(env) PS C:\workspaces\cloudhome\boss0426>
(env) PS C:\workspaces\cloudhome\boss0426>
(env) PS C:\workspaces\cloudhome\boss0426> pip install google-cloud-storage
Collecting google-cloud-storage
Downloading google_cloud_storage-2.7.0-py2.py3-none-any.whl (110 kB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 110.2/110.2 kB 6.7 MB/s eta 0:00:00
Collecting google-auth<3.0dev,>=1.25.0
Downloading google_auth-2.15.0-py2.py3-none-any.whl (177 kB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 177.0/177.0 kB 10.4 MB/s eta 0:00:00
Collecting google-api-core!=2.0.*,!=2.1.*,!=2.2.*,!=2.3.0,<3.0.0dev,>=1.31.5
Downloading google_api_core-2.11.0-py3-none-any.whl (120 kB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 120.3/120.3 kB 6.9 MB/s eta 0:00:00
Collecting google-cloud-core<3.0dev,>=2.3.0
Using cached google_cloud_core-2.3.2-py2.py3-none-any.whl (29 kB)
Collecting google-resumable-media>=2.3.2
Using cached google_resumable_media-2.4.0-py2.py3-none-any.whl (77 kB)
Collecting requests<3.0.0dev,>=2.18.0
Using cached requests-2.28.1-py3-none-any.whl (62 kB)
Collecting googleapis-common-protos<2.0dev,>=1.56.2
Downloading googleapis_common_protos-1.57.0-py2.py3-none-any.whl (217 kB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 218.0/218.0 kB 13.0 MB/s eta 0:00:00
Collecting protobuf!=3.20.0,!=3.20.1,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<5.0.0dev,>=3.19.5
Downloading protobuf-4.21.12-cp310-abi3-win_amd64.whl (527 kB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 527.0/527.0 kB 11.0 MB/s eta 0:00:00
Collecting cachetools<6.0,>=2.0.0
Using cached cachetools-5.2.0-py3-none-any.whl (9.3 kB)
Collecting pyasn1-modules>=0.2.1
Using cached pyasn1_modules-0.2.8-py2.py3-none-any.whl (155 kB)
Collecting six>=1.9.0
Using cached six-1.16.0-py2.py3-none-any.whl (11 kB)
Collecting rsa<5,>=3.1.4
Using cached rsa-4.9-py3-none-any.whl (34 kB)
Collecting google-crc32c<2.0dev,>=1.0
Using cached google_crc32c-1.5.0-cp311-cp311-win_amd64.whl (27 kB)
Collecting charset-normalizer<3,>=2
Using cached charset_normalizer-2.1.1-py3-none-any.whl (39 kB)
Collecting idna<4,>=2.5
Using cached idna-3.4-py3-none-any.whl (61 kB)
Collecting urllib3<1.27,>=1.21.1
Downloading urllib3-1.26.13-py2.py3-none-any.whl (140 kB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 140.6/140.6 kB 8.1 MB/s eta 0:00:00
Collecting certifi>=2017.4.17
Downloading certifi-2022.12.7-py3-none-any.whl (155 kB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 155.3/155.3 kB ? eta 0:00:00
Collecting pyasn1<0.5.0,>=0.4.6
Using cached pyasn1-0.4.8-py2.py3-none-any.whl (77 kB)
Installing collected packages: pyasn1, urllib3, six, rsa, pyasn1-modules, protobuf, idna, google-crc32c, charset-normalizer, certifi, cachetools, requests, googleapis-common-protos, google-resumable-media, google-auth, google-api-core, google-cloud-core, google-cloud-storage
Successfully installed cachetools-5.2.0 certifi-2022.12.7 charset-normalizer-2.1.1 google-api-core-2.11.0 google-auth-2.15.0 google-cloud-core-2.3.2 google-cloud-storage-2.7.0 google-crc32c-1.5.0 google-resumable-media-2.4.0 googleapis-common-protos-1.57.0 idna-3.4 protobuf-4.21.12 pyasn1-0.4.8 pyasn1-modules-0.2.8 requests-2.28.1 rsa-4.9 six-1.16.0 urllib3-1.26.13

[notice] A new release of pip available: 22.3 -> 22.3.1
[notice] To update, run: python.exe -m pip install --upgrade pip

 


클라우드 함수 만들기 


이제 다시 처음 가이드로 돌아와서 cloud에서 사용할 함수를 만들어 보겠습니다. 저는 python으로 구동하는 함수를 만들기 위해서 환경 설정도 했고 해서 python 으로 돌아가는 함수를 만들 예정입니다. 


 


1. 먼저 python project 폴더를 하나 생성 합니다. (이글에서는 boss0426으로 할 예정입니다.)


2. main.py 코드를 만들고 그 안에 필요한 함수를 구성합니다.  해당 함수의 이름은 deploy 할 때 함수 이름으로 사용되므로 작성 시에 참고하세요. https 호출 시 끝단 URL 이 됩니다.


3. requirements.txt 파일을 하나 작성 해서 import 되어야 하는 항목을 나열해 줍니다. pc에서 python 환경으로 구동할 때는 그냥 source code에 import 하면 실행이 되기는 하지만, cloud 환경에서는 그것이 안 되는 것으로 보입니다. 그래서 text 파일에 모두 기록을 해 주면 실행 시에 load 되어 같이 실행이 되는 것으로 보입니다.


4. 이제 deploy을 해 보겠습니다.


 


    gcloud functions deploy 'function_name' --runtime python310 --trigger-http --allow-unauthenticated


(env) PS C:\workspaces\cloudhome\boss0426> gcloud functions deploy boss0426_request --runtime python310 --trigger-http --allow-unauthenticated
Created .gcloudignore file. See `gcloud topic gcloudignore` for details.
Deploying function (may take a while - up to 2 minutes)...⠛
For Cloud Build Logs, visit: https://console.cloud.google.com/cloud-build/builds;region=us-central1/0d6b660e-85d2-4e6d-86c9-eb09bfaac827?project=319191239543
Deploying function (may take a while - up to 2 minutes)...done.
availableMemoryMb: 256
buildId: 0d6b660e-85d2-4e6d-86c9-eb09bfaac827
buildName: projects/319191239543/locations/us-central1/builds/0d6b660e-85d2-4e6d-86c9-eb09bfaac827
dockerRegistry: CONTAINER_REGISTRY
entryPoint: boss0426_request
httpsTrigger:
securityLevel: SECURE_ALWAYS
url: https://us-c**********0.cloudfunctions.net/boss0426_request
ingressSettings: ALLOW_ALL
labels:
deployment-tool: cli-gcloud
name: projects/boss******0/locations/us-central1/functions/boss0426_request
runtime: python310
serviceAccountEmail: bo*******ppspot.gserviceaccount.com
sourceUploadUrl: https://storage.googleapis.com/uploads-103870777877.us-central1.cloudfunctions.appspot.com/6f47b5ef-c5ef-4163-843d-08c8a48fa10a.zip
status: ACTIVE
timeout: 60s
updateTime: '2023-01-04T05:30:31.359Z'
versionId: '1'
(env) PS C:\workspaces\cloudhome\boss0426>

이제 설정이 완료되었습니다.


 


5. cloud console에서 로그 활동을 확인해 보겠습니다.


콘솔 에서 로그 보기



 


설정도 되었고 호출해보니 호출도 되는 것 같습니다.  이제 실제 앱에서 구동을 해서 확인해 보아야 할 차례입니다.


그 뒷 이야기는 다음에...


 


 





오늘의 이야기

인앱 결제 버전이 올라갔어요



알림: 2022년 8월 2일부터 모든 신규 앱은 결제 라이브러리 버전 4 이상을 사용해야 합니다. 2022년 11월 1일부터는 기존 앱의 모든 업데이트에도 결제 라이브러리 버전 4 이상이 요구됩니다.

구글에서 가이드하는 내용입니다. 이제 구글 인앱 결제로 라이브러리를 업데이트해야 할 것 같아요. 수수료도 30%나 떼어 가면서 요구하는 것도 많습니다. 그래도 어쩌겠어요. 장터(?)가 그것 밖에는 없으니 말이죠.

https://developer.android.com/google/play/billing/migrate-gpblv5?hl=ko







Google Play 결제 라이브러리 4에서 5로의 이전 가이드  |  Google Play 결제 시스템  |  Android Developers


알림: 2022년 8월 2일부터 모든 신규 앱은 결제 라이브러리 버전 4 이상을 사용해야 합니다. 2022년 11월 1일부터는 기존 앱의 모든 업데이트에도 결제 라이브러리 버전 4 이상이 요구됩니다. 자세히


developer.android.com





https://qonversion.io/blog/google-play-billing-library-5-0/







Google Play Billing Library 5.0 overview: new subscription model


Google introduced its new major version of the Google Play Billing Library on recent I/O conference, which include vast information about the new architecture of subscriptions. Let's explore these updates in this article.


qonversion.io





이전 버전에서 사용되던 함수가 더 이상 사용할 수 없게 되면서 이전이 필요해졌습니다. 이전 가이드에 표시되어 있는 것처럼 정리가 일부 필요해 보입니다. 아무튼 이런 정보들을 이용해서 다음 버전으로 이전을 시작해 봅니다.


정기결제 정책도 새로 추가 ?


개발 가이드를 읽어 보면 새로운 API을 활용하기 위해서는 새로운 정기결제 항목이 필요해 보입니다. 새로 등록을 해 보도록 하겠습니다.

정기결제 만들기


콘솔에서 수익 창출 -> 정기 결제에 들어가서 새로운 구독 만들기를 눌러서 새롭게 하나 만들어 봅니다.
다만, 정기결제를 위해서는 앱은 무료로 등록 해야 합니다.

새로 추가한 정기 결제


여기 까지는 쉽게 따라오실 수 있습니다. 그런데 이제는 기본 요금제 및 혜택을 등록하도록 하고 있습니다. 그럼 새로운 기본 요금제를 설정해 보겠습니다.

기본 요금제 선택



  • 자동갱신

  • 선불


2가지의 선택이 있습니다. 자동 갱신은 고객이 해지하기 전까지 자동으로 갱신을 시도한다는 의미이고, 선불은 고객이 필요할 때마다 결제를 진행하여야 하는 경우입니다.

여기서 주의할 점은 선불의 경우는 혜택을 추가할 수 없었습니다. 그래서 추가적인 혜택을 부여하고자 하면 자동 갱신을 만들어 주어야 할 것 같습니다.

** 그리고 저장을 하려고 하면 문제는 출시하려는 국가별로 가격을 정해 주어야 한다는 것입니다. 그래서 처음에는 출시 국가를 줄이는 방법을 선택했다가, 찾아보니 일괄 설정이 가능했습니다.

방법은 화면에 있는 Set prices 클릭하는 것입니다.

전체 일괄 선택후 가격 설정 하기


그러면 국가 리스트가 나오고 일괄 선택도 가능 합니다. 이제 우리나라 통화인 KRW 기준으로 금액을 입력하고 update을 해 보겠습니다.

기준 금액 입력


각 나라별 금액 일괄 입력 후


이제 필요에 따라서 혜택을 추가해 볼 수 있겠습니다. 하지 않아도 되기는 하겠지만 그래도 한번 해 보겠습니다.

정기결제 설정후 상세



혜택추가1단계


id을 입력하고 자격 기준을 선택해 봅니다. 3가지의 선택 사항이 존재하는 데, 이번에는 신규 고객 획득의 경우만 선택해 보겠습니다.



  • 신규고객획득 : 새로 앱을 설치한 사용자에게만 적용됩니다.

  • 업그레이드 : 이전 정기결제를 하고 있는 사용자가 다른 선택을 하게 되는 경우 적용됩니다.

  • 개발자선택 : 개발자 가 선택 사항을 조합해서 적용할 수 있습니다.


단계 추가를 해야 합니다. 신규 고객에게 어떤 조건으로 혜택을 줄 것 인가를 정하게 되는 데, 무료 체험판 제공을 해 보겠습니다.



  • 무료 체험판 : 기간을 정해 놓고 무료 혜택을 제공할 수 있습니다.

  • 1회 결제 : 1회만 혜택을 제공할 수 있습니다.

  • 할인된 반복 결제 : 반복적으로 정해진 기간 동안 할인된 금액으로 결제할 수 있는 혜택을 제공할 수 있습니다.


이제 조건 입력이 되었으니 활성화를 클릭하면 시행됩니다.


앱에서 확인해 보기


이제 앱에서 정상적으로 동작을 하는지 확인해 볼 차례입니다. 음... 등록한 지 얼마 되지 않아서 일까요? 아직 목록이 나오지 않고 있습니다.

동작이 원활하게 되는지 보도록 하겠습니다.





인앱결제 실행 화면


이글과 관련해서 수정된 코드는 아래 github을 참고하세요.

https://github.com/nari4169/daycnt415_kotlin/blob/master/app/src/main/java/com/billcoreatech/daycnt415/billing/BillingManager.kt







GitHub - nari4169/daycnt415_kotlin


Contribute to nari4169/daycnt415_kotlin development by creating an account on GitHub.


github.com





연말이 되었습니다. 2023년에도 열공하는 여러분이 되시길 바랄게요.

이 글이 도움이 되셨다면... 아래 광고도 클릭 한번 부탁드려요. ^^;;





오늘의 이야기


#스하리1000명프로젝트,
迷失在韩国?即使您不会说韩语,这个应用程序也可以帮助您轻松出行。
只需说出您的语言即可 - 它会翻译、搜索并以您的语言显示结果。
非常适合旅行者!支持英语、日语、中文、越南语等10多种语言。
现在就试试吧!
https://play.google.com/store/apps/details?id=com.billcoreatech.opdgang1127




2026/03/13

오늘의 이야기

인앱결제 코드 이전 이야기


이전 버전에서는 java 코드로 구현된 소스 코드를 공유해 보았습니다.


 


https://billcorea.tistory.com/165



 


안드로이드 앱 만들기 : 구글 인앱 결제 쉽게 따라하기 (정기결제, 소스공유)


이전 포스팅 이전에 작성했던 포스팅을 참고하여 인앱 결제를 구현했던 기억을 되살펴 보겠습니다. https://billcorea.tistory.com/27 안드로이드 앱 만들기 구글 인앱결제 쉽게 따라 하기... 인앱 결제를


billcorea.tistory.com




 


오늘은 이 코드를 그대로 kotlin  코드로 변환을 해 보았습니다. 


Java File to Kotlin File


Android Studio 에서는 java 코드를 kotlin으로 변환해 줍니다. 


메뉴에서 Code 제일 아래에 보면 Convert Java File to Kotlin File 이 보입니다.  물로 이 메뉴는 Java 코드일 때만 보입니다.


android studio 메뉴



 


변환을 시행해 보겠습니다. 변환은 내 앱의 상위 package 이름이 나와 있는 위치에서 오른쪽 마우스를 클릭해서 하는 방법도 있습니다.  개발 java 파일을 선택해서 오른쪽 마우스 클릭해서 하게 되는 경우는 개별 파일만 처리해 주지만, 최상위 package을 선택하고 하는 경우 하위 경로에 있는 모든 파일을 한 번에 변환해 줍니다. 


 


주의 사항  


일괄 변환된 후에 해야할 일들이 생깁니다. java 코드에서는 global 변수로 사용하고자 하는 경우 그냥 변수 이름만 선언해 주면 되었던 부분들이 kotlin을 변환하게 되면 그 값을 정해 주는 것에 대해서 설정을 해 주어야 하는 부분들이 생기며  해당 변수를 일괄적으로 null 대입하는 코드로 변환을 해 주시기 때문에 아래 예시들처럼 수정을 해 주어야 하는 부분들이 생깁니다.


 


변환 전 / 후



위 예시는 kotlin  으로 변환 후에 코드를 정리한 후의 코드이니 변환된 직후의 코드와는 다르다는 것을 염두에 두고 보시길 바랍니다. 


 


조금더 자세한 주의 사항을 보시려면 google  에서 제공하는 codelab 을 살펴 보세요.


https://codelabs.developers.google.com/codelabs/java-to-kotlin?hl=ko#1 



 


Kotlin으로 변환  |  Google Codelabs


이 Codelab에서는 자바 코드를 Kotlin으로 변환하는 방법을 알아봅니다.


codelabs.developers.google.com




 


Gradle 설정 추가


 source code 는 변환을 해 주지만, gradle 파일을 자동 변환을 해 주지 않기 때문에 설정을 일부 추가해 주어야 합니다. 


 


먼저 project 의 gradle 파일에는 아래처럼 2곳에 추가를 해 주었습니다.


buildscript {
ext.kotlin_version = '1.7.20' // kotlin 추가
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.3.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" // kotlin 추가
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}

 


module의 gradle 파일은 다음과 같이 추가해 주었습니다.


plugins {
id 'com.android.application'
id 'kotlin-android-extensions' // kotlin 추가
id 'kotlin-android' // kotlin 추가
id 'kotlin-kapt' // kotlin 추가
}

 


이제 빌드를 진행해 봅니다. 


 


앱 실행 화면



 


이런 게 변환된 코드는 정상적으로 실행이 되는 것을 확인했습니다. 


 


구글 인앱 결제 


인앱 정기결제 코드는 어떻게 변환이 되었을까요 ?


 



import android.app.Activity
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import android.widget.Toast
import com.android.billingclient.api.*
import com.billcoreatech.daycnt415.R
import com.billcoreatech.daycnt415.util.KakaoToast
import java.text.SimpleDateFormat
import java.util.*

class BillingManager(var mActivity: Activity) : PurchasesUpdatedListener, ConsumeResponseListener {
var TAG = "BillingManager"
lateinit var mBillingClient: BillingClient
lateinit var mSkuDetails: List<SkuDetails>

enum class connectStatusTypes {
waiting, connected, fail, disconnected
}

var connectStatus = connectStatusTypes.waiting

/**
* 구글에 설정한 구독 상품 아이디와 일치 하지 않으면 오류를 발생 시킴.
* 21.04.20 이번에는 1회성 구매로 변경 210414_monthly_bill_999, 210420_monthly_bill
*/
var punchName = "220302_bill_1month_999"
var punchNameInapp = "210420_monthly_bill"
var payType = BillingClient.SkuType.SUBS
var option: SharedPreferences
var editor: SharedPreferences.Editor

init {
option = mActivity.getSharedPreferences("option", Context.MODE_PRIVATE)
editor = option.edit()
mBillingClient = BillingClient.newBuilder(mActivity)
.setListener(this)
.enablePendingPurchases()
.build()
mBillingClient.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(billingResult: BillingResult) {
Log.e(TAG, "respCode=" + billingResult.responseCode)
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
connectStatus = connectStatusTypes.connected
Log.e(TAG, "connected...")
purchaseAsync()
} else {
connectStatus = connectStatusTypes.fail
Log.i(TAG, "connected... fail ")
}
}

override fun onBillingServiceDisconnected() {
connectStatus = connectStatusTypes.disconnected
Log.i(TAG, "disconnected ")
}
})
}

/**
* 정기 결재 소모 여부를 수신 : 21.04.20 1회성 구매의 경우는 결재하면 끝임.
* @param billingResult
* @param purchaseToken
*/
override fun onConsumeResponse(billingResult: BillingResult, purchaseToken: String) {
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
Log.i(TAG, "사용끝 + $purchaseToken")
return
} else {
Log.i(TAG, "소모에 실패 " + billingResult.responseCode + " 대상 상품 " + purchaseToken)
return
}
}

fun purchase(skuDetails: SkuDetails?): Int {
val flowParams = BillingFlowParams.newBuilder()
.setSkuDetails(skuDetails!!)
.build()
return mBillingClient.launchBillingFlow(mActivity, flowParams).responseCode
}

fun purchaseAsync() {
Log.e(TAG, "--------------------------------------------------------------")
mBillingClient.queryPurchasesAsync(payType) { billingResult, list ->
Log.e(TAG, "onQueryPurchasesResponse=" + billingResult.responseCode)
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
if (list.size < 1) {
editor = option.edit()
editor.putBoolean("isBill", false)
editor.commit()
Log.e(TAG, "getData=" + list.size)
} else {
for (purchase in list) {
Log.e(TAG, "getPurchaseToken=" + purchase.purchaseToken)
for (str in purchase.skus) {
Log.e(TAG, "getSkus=$str")
}
val now = Date()
now.time = purchase.purchaseTime
Log.e(TAG, "getPurchaseTime=" + sdf.format(now))
Log.e(TAG, "getQuantity=" + purchase.quantity)
Log.e(TAG, "getSignature=" + purchase.signature)
Log.e(TAG, "isAutoRenewing=" + purchase.isAutoRenewing)
Log.e(TAG, "getPurchaseState=" + purchase.purchaseState)
editor = option.edit()
editor.putBoolean("isBill", purchase.isAutoRenewing)
editor.commit()
}
}
Log.e(TAG, "--------------------------------------------------------------")
}
}

val skuDetailList: Unit
get() {
val skuIdList: MutableList<String> = ArrayList()
skuIdList.add(punchName)
val params = SkuDetailsParams.newBuilder()
params.setSkusList(skuIdList).setType(payType)
mBillingClient.querySkuDetailsAsync(
params.build(),
SkuDetailsResponseListener { billingResult, skuDetailsList ->
if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
Log.i(TAG, "detail respCode=" + billingResult.responseCode)
return@SkuDetailsResponseListener
}
if (skuDetailsList == null) {
KakaoToast.makeToast(
mActivity,
mActivity.getString(R.string.msgNotInfo),
Toast.LENGTH_LONG
).show()
return@SkuDetailsResponseListener
}
Log.i(TAG, "listCount=" + skuDetailsList.size)
for (skuDetails in skuDetailsList) {
Log.i(TAG, """
${skuDetails.sku}
${skuDetails.title}
${skuDetails.price}
${skuDetails.description}
${skuDetails.freeTrialPeriod}
${skuDetails.iconUrl}
${skuDetails.introductoryPrice}
${skuDetails.introductoryPriceAmountMicros}
${skuDetails.originalPrice}
${skuDetails.priceCurrencyCode}
""".trimIndent()
)
}
purchase(skuDetailsList[0])
})
}

/**
* @param billingResult
* @param purchases
*/
override fun onPurchasesUpdated(billingResult: BillingResult, purchases: List<Purchase>?) {
if (billingResult == null) {
Log.wtf(TAG, "onPurchasesUpdated: null BillingResult")
return
}
val responseCode = billingResult.responseCode
val debugMessage = billingResult.debugMessage
Log.d(TAG, "onPurchasesUpdated: ${responseCode} ${debugMessage}")
when (responseCode) {
BillingClient.BillingResponseCode.OK -> if (purchases == null) {
Log.d(TAG, "onPurchasesUpdated: null purchase list")
processPurchases(null)
} else {
processPurchases(purchases)
}
BillingClient.BillingResponseCode.USER_CANCELED -> Log.i(
TAG,
"onPurchasesUpdated: User canceled the purchase"
)
BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> Log.i(
TAG,
"onPurchasesUpdated: The user already owns this item"
)
BillingClient.BillingResponseCode.DEVELOPER_ERROR -> Log.e(
TAG, "onPurchasesUpdated: Developer error means that Google Play " +
"does not recognize the configuration. If you are just getting started, " +
"make sure you have configured the application correctly in the " +
"Google Play Console. The SKU product ID must match and the APK you " +
"are using must be signed with release keys."
)
}
}

private fun processPurchases(purchasesList: List<Purchase>?) {
if (purchasesList != null) {
Log.d(TAG, "processPurchases: " + purchasesList.size + " purchase(s)")
} else {
Log.d(TAG, "processPurchases: with no purchases")
}
if (isUnchangedPurchaseList(purchasesList)) {
Log.d(TAG, "processPurchases: Purchase list has not changed")
return
}
}

/**
* subs 의 경우는 아래와 같이 구매확인을 해 주어야 됨.
* @param purchase
*/
fun confirmPerchase(purchase: Purchase) {
//PURCHASED
if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
if (!purchase.isAcknowledged) {
val acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchase.purchaseToken)
.build()
mBillingClient.acknowledgePurchase(acknowledgePurchaseParams) { billingResult ->
Log.e(TAG, "getResponseCode=" + billingResult.responseCode)
editor.putBoolean("isBill", true)
editor.commit()
}
}
} else if (purchase.purchaseState == Purchase.PurchaseState.PENDING) {
//구매 유예
Log.e(TAG, "//구매 유예")
} else {
//구매확정 취소됨(기타 다양한 사유...)
Log.e(TAG, "//구매확정 취소됨(기타 다양한 사유...)")
}
}

private fun isUnchangedPurchaseList(purchasesList: List<Purchase>?): Boolean {
for (purchase in purchasesList!!) {
confirmPerchase(purchase)
}
return false
}
}

이전에 포스팅했던 java 코드와 비교를 해 보면 코드가 많이 간소화되었다는 것을 알 수 있습니다.  호출해서 사용하는 코드는 github의 코드를 참고해 보세요. 


 


결제 테스트


결제 진행에 대한 테스트는 꼭 playstore에 게시한 이후에 진행하여야 합니다.  저는 내부 테스트로 게시한 이후 진행 하고 있습니다. 그리고  정기결제 항목을 처음 등록한 경우에는 해당 결제 항목이 사용이 될 수 있으려면 24시간 이상 걸리는 경우가 있으므로 미리 정기결제 항목을 등록해 두고 앱을 만들어 가는 것이 시간 활용에 도움이 됩니다. 


 


결제 테스트



 


이상으로 Java 코드의 소스를 Kotlin으로 변환을 해 보았습니다.


 


전체 코드 보기


전체소스코드는 아래 링크를 참고하세요.


https://github.com/nari4169/daycnt415_kotlin



 


GitHub - nari4169/daycnt415_kotlin


Contribute to nari4169/daycnt415_kotlin development by creating an account on GitHub.


github.com




 





오늘의 이야기

정기결제


앱에 결제 기능을 다는 이야기는 이전 포스팅에 있습니다. 이번에는 혜택을 주는 방법에 대한 이야기를 적어 봅니다. 


먼저 이전에 등록해서 운영하던 경우를 기준으로 작성하고 있음을 밝혀 둡니다.  이전에 만들었던 앱에 매월 정기 결제를 통해 광고를 제거하는 옵션을 달았던 적이 있습니다. 


 


정기 결제(구독)이 등록된 정보



 


그중에서 현재 운영 중인 구독 보기를 선택합니다.


 


혜택 추가 하기



 


혜택 추가


이제 혜택 추가를 해 보겠습니다.


 


혜택 추가



혜택 추가 하기에는 신규 고객을 선택하는 경우와 이전 사용자를 선택 하는 경우, 그 외 개발자의 임의 지정을 선택할 수 있을 듯합니다.  기존 고객을 위한 프로모션을 하는 경우도 있겠지만, 제가 배포한 앱은 아직 사용자가 없기 때문에 신규 고객을 대상으로 한 혜택 추가를 해 보겠습니다.


자격기준



저 선택 사항 아래 탱크를 달도록 되어 있는 데, 일단은 무시해 보겠습니다.


저장해 보기



 


그냥 저장 버튼을 눌렀더니 아래와 같이 단계를 추가하도록 가이드를 하고 있습니다.  단계는 2개까지 등록이 될 것 같습니다.


 


단계 추가



 


신규 고객에게 혜택을 등록하는 것으로 정했으니, 단계 추가에서는 무료 체험판이라고 선택을 하는 것이 맞을 듯합니다. 


 



  • 무료체험판  : 지정하는 기간 동안 무료 체험을 제공합니다.

  • 1회 결제      : 지정하는 기간 동안 1회 결제에 한하여 정액, 할인율, 일정금액 등으로 가격을 조정해 줄 수 있습니다. 

  • 할인된 반복 결제 : 지정하는 결제 기간 동안 정액, 할인율, 일정금액 등으로 가격을 조정해 줄 수 있습니다.


단계 옵션



저는 신규 고객에게 3개월 동안 무료 체험을 할 수 있도록 하고자 합니다. 그랬더니 판매가 되는 국가별로 가격표가 노출이 됩니다. 그리고 적용을 눌러보겠습니다.


무료체험 단계



적용하고 활성화를 시켜 봅니다. 


 


혜택 활성화 예시



결제 진행


내부 테스트 계정으로 앱을 설치하고 테스트를 진행해 봅니다. 


 


인앱 결제 혜택 추가 후 결제 처리 화면



 


이상으로 정기 구독자를 위한 혜택 추가 하는 방법에 대한 이야기를 추가해 봅니다.


 





오늘의 이야기

소셜 로그인 firebase에서 지원하고 있는 소셜 로그인(?)은 Google, Facebook, Apple, Microsoft, Twitter 등 대부분 외국계(?)입니다. firebase 의 소셜 로그인 지원 우리나라에서 대다수가 사용하는 nave...