2026/03/14

오늘의 이야기

소셜 로그인


firebase에서 지원하고 있는 소셜 로그인(?)은 Google, Facebook, Apple, Microsoft, Twitter 등 대부분 외국계(?)입니다.


firebase 의 소셜 로그인 지원



우리나라에서 대다수가 사용하는 naver, kakao 등은 없습니다. 그래서 지난 이야기에서 Naver, Kakao와 Google 계정으로 로그인하는 이야기를 해 보았습니다.


 


https://billcorea.tistory.com/308



 


안드로이드 앱 만들기 : 소셜 로그인 ( 네이버, 카카오톡, 구글) 구현해 보기


소셜로그인 요새는 대부분의 사용자들이 이런저런 SNS 등에 가입이 되어 있기 때문에 또 다른 개인정보를 제공해 가면서 로그인을 하려고 하지 않습니다. 또한 각각에 등록된 비밀번호를 기억하


billcorea.tistory.com




 


소셜 로그인 은 개인정보가 털린다?


이전 포스팅을 하고 나서 다른 글을 보다가 그런 이야기들이 많이 있더라고요. 소셜 로그인은 쉽게 접근이 가능하기 때문에 여기저기 등록하게 되면 개인정보가 어디서 털리는지 모르게 털린다는 이야기 많습니다. 


 


그 이야기도 이해가 안 되는 부분은 아닙니다. 가입이라는 과정이 없다 보니 쉽게 남발(?) 하고 있지 않는 건가 하는 생각이 드는 건 동의가 되는 부분입니다.


 


카카오의 개인정보 동의 항목



 


네이버 개인정보 동의 항목



 


카카오나 네이버에서는 API을 제공하면서 제공이 되는 범위를 지정하게 되어 있습니다.  해서 개발자 또는 해당 사이트의 개발 범위에서 기본 정보만 확인하고 있다고 한다면, 개인정보 누출에 대해서는 고민이 필요 없지 않을까 하는 생각이 듭니다. 


 


다만, 대부분의 사이트에서는 회원 관리를 위해서 다른 정보를 따로 수집하게 됩니다. 그때는 이제 카카오나 네이버의 고객 정보가 아닌 해당 사이트 또는 앱의 고객정보가 되기 때문에 다른 이야기가 됩니다. 


 


고객정보가 털린다 에는 일부는 맞고 일부는 틀린 이야기가 되는 것 갔습니다.  이제 다시 하던 이야기로 돌아와서...


 


Firebase Auth


https://firebase.google.com/docs/auth/android/start?hl=ko 



 


Android에서 Firebase 인증 시작하기


Firebase Summit에서 발표된 모든 내용을 살펴보고 Firebase로 앱을 빠르게 개발하고 안심하고 앱을 실행하는 방법을 알아보세요. 자세히 알아보기 이 페이지는 Cloud Translation API를 통해 번역되었습니


firebase.google.com




개발자 문서를 읽어 보면 여러 가지 방법이 기술 되어 있습니다. 이중에서 기본은 email, password 을 받아서 처리 하는 방식이고, 그외 위에서 말했던 바와 같은 google, facebook, twitter 등등 여러가지 로그인을 지원합니다. 


 


다만, 회원 정보의 관리등은 따로 구현을 해야 하는 부분이 있다는 것은 이해를 하셨을 거라고 생각됩니다. 


 


. signinWithCustomToken


개발된 토큰을 이용해 로그인이라고 이해를 했습니다. 그래서 카카오나 네이버에서 로그인 시도 후에 얻어지는 accessToken 만 있으면 로그인이 되지 않을까 하는 기대감(?)으로 찾아보던 중에 그 이해가 잘못되었다는 것을 알아내는 데 까지 일주일은 가버린 느낌입니다.  사이에 설 연휴가 있었기는 했지만요.


 


https://firebase.google.com/docs/auth/android/custom-auth?hl=ko 



 


Android에서 커스텀 인증 시스템을 사용하여 Firebase에 인증  |  Firebase 인증


Firebase Summit에서 발표된 모든 내용을 살펴보고 Firebase로 앱을 빠르게 개발하고 안심하고 앱을 실행하는 방법을 알아보세요. 자세히 알아보기 의견 보내기 Android에서 커스텀 인증 시스템을 사용


firebase.google.com




개발자 문서에서 말하는 이야기 중에서 


 


3. 사용자가 앱에 로그인하면 사용자의 로그인 인증 정보(예: 사용자 이름과 비밀번호)를 인증 서버로 전송하세요. 서버가 사용자 인증 정보를 확인하여 정보가 유효하면 커스텀 토큰을 반환합니다.


 


이 부분에 있는 인증 서버가 발목을 잡을 거라는 걸 알아내는 데 일주일이 가버린 것입니다. 


 


인증서버


그래서 이제 인증 서버에 대한 사례들을 찾아보았습니다.  


https://blog.naver.com/PostView.naver?blogId=chltmddus23&logNo=221784299552 



 


custom auth token을 활용한 firebase auth- kakao 로그인 연동 방법


Sign In Firebase with additional auth providers (ex. kakao ) using Custom auth tokens Use Kaka...


blog.naver.com




이 글에서 보는 바와 같이 인증 서버에서 하는 일이 다음과 같이 있어야 한다는 것을 알게 되었습니다.  custom token을 만들어 내려면 말이죠.


 


custom flow



 


이제 그 구현을 해 보아야 하는 데, 찾을 수 있던 예제등은 node.js로 되어 있는 코드들 이더라고요.  앞전 포스팅에서 이야기했던 것처럼 python으로 구현했던 server less 말고는 아는 게 없는 데 말입니다.  그래서 이제 그 구현을 python 으로 해 보겠습니다. 


 


 


Google Cloud Functions 코드 만들어 보기


서버가 없는 개발자는 이렇게 구현을 해 보았어요. google cloud functions을 만들어 사용해 보니 테스트 설정등에 노력이 들어가기는 하지만,  유용하게 사용을 해 볼 수 있다는 점은 좋은 것 같습니다.  물론 자잘한 비용이 발생됩니다.  구글도 그냥 free로 해 주지는 않으니까요.


from flask import escape
import functions_framework

import json

import firebase_admin
from firebase_admin import credentials
from firebase_admin import db
from firebase_admin import auth
import requests

cred = credentials.Certificate('./boss0426-firebase.json')
firebase_admin.initialize_app(cred, {
'databaseURL': 'https://boss*************************************tabase.app/'
})


# kakao 에서 개인 정보 받아 오기
def requestMe(token):
kakaoRequestMeUrl = 'https://kapi.kakao.com/v2/user/me?secure_resource=true'
header = {'Authorization': 'Bearer ' + token}
resp = requests.get(kakaoRequestMeUrl, headers=header)
return resp.content

# 네이버 에서 개인 정보 받아 오기
def naverRequestMe(token):
naverRequestMeUrl = 'https://openapi.naver.com/v1/nid/me'
header = {'Authorization': 'Bearer ' + token, 'X-Naver-Client-Id': 'oXV*********W', 'X-Naver-Client-Secret': 'Ue*********x'}
resp = requests.get(naverRequestMeUrl, headers=header)
return resp.content


# auth 에 등록된 경우는 update 아니면 create 하는 함수
def updateOrCreateUser(email, nickname, profileImage):
try:
user = auth.get_user_by_email(email)
print('user={0}'.format(user))
user = auth.update_user(uid=user.uid, email=email, email_verified=True, display_name=nickname, photo_url=profileImage)
except:
user = auth.create_user(email=email, email_verified=True, display_name=nickname, photo_url=profileImage)

print('Successfully fetched user data: {0}'.format(user.uid))
return user.uid

# google 로그인 을 위한 함수
@functions_framework.http
def boss0426_google_token(request):
request_json = request.get_json(silent=True)
request_args = request.args

profileImage = 'https://billcorea.tistory.com'
if request_json and 'email' in request_json:
email = request_json['email']
nickName = request_json['nickName']
elif request_args and 'email' in request_args:
email = request_args['email']
nickName = request_args['nickName']
else:
email = 'email'
nickName = 'nickName'

uid = updateOrCreateUser(email, nickName, profileImage)
customToken = auth.create_custom_token(uid)
resp = json.dumps({"customToken": '{}'.format(customToken)})
print(resp)
return resp, 200

# 네이버 에서 access token 을 받아온 경우
@functions_framework.http
def boss0426_naver_token(request):
request_json = request.get_json(silent=True)
request_args = request.args

if request_json and 'accessToken' in request_json:
accessToken = request_json['accessToken']
elif request_args and 'accessToken' in request_args:
accessToken = request_args['accessToken']
else:
accessToken = 'accessToken'

textResp = naverRequestMe(accessToken)
dict = json.loads(textResp)
print('dict={}'.format(dict))
userId = accessToken
email = dict['response']['email']
nickname = dict['response']['nickname']
profileImage = dict['response']['profile_image']

print('userId {}'.format(userId))
print('email {}'.format(email))
print('nickname {}'.format(nickname))
print('profileImage {}'.format(profileImage))

if userId == "":
return "ERROR", 400

uid = updateOrCreateUser(email, nickname, profileImage)
customToken = auth.create_custom_token(uid)
resp = json.dumps({"customToken": '{}'.format(customToken)})
print(resp)
return resp, 200

# 카카오 에서 access token 을 받아온 경우
@functions_framework.http
def boss0426_kakao_token(request):
request_json = request.get_json(silent=True)
request_args = request.args

if request_json and 'accessToken' in request_json:
accessToken = request_json['accessToken']
elif request_args and 'accessToken' in request_args:
accessToken = request_args['accessToken']
else:
accessToken = 'accessToken'

textResp = requestMe(accessToken)
dict = json.loads(textResp)
print('dict={}'.format(dict))
userId = accessToken
email = dict['kakao_account']['email']
nickname = dict['properties']['nickname']
profileImage = dict['properties']['profile_image']

print('userId {}'.format(userId))
print('email {}'.format(email))
print('nickname {}'.format(nickname))
print('profileImage {}'.format(profileImage))

if userId == "":
return "ERROR", 400

uid = updateOrCreateUser(email, nickname, profileImage)
customToken = auth.create_custom_token(uid)
resp = json.dumps({"customToken": '{}'.format(customToken)})
print(resp)
return resp, 200

코드는 위에서 기술했던 flow에 준해서 작성을 했습니다. 



  • 네이버나 카카오의 경우는 access Token을 받아와서 이 코드 안에서 필요한 정보(email, nickname, profile_image)를 취한 다음, auth 정보에 보유 유무를 체크하고 없을 때는 create 하고 있으면 update 하는 방식으로 하고, 

  • google onetap 로그인의 경우는 access token 이 아니라 email 정보를 주기 때문에 그냥 auth 정보를 확인한 뒤 create 나 update을 하는 방식으로 구현이 되었습니다.


카카오나 네이버의 정보 취득은 url을 get 방식으로 호출하면 json type으로 정보를 전달해 주기 때문에 그렇게 사용이 가능합니다.   카카오와 네이버의 다른 점은 호출 시 사용하는 header 기술에 차이가 있습니다.  코드에서 잘 찾아보세요.


 


한 가지 아쉬운 점은 customToken으로 나가는 값을 json으로 만들어 내보는 과정에서 b' 이런 것들이 붙어 간다는 부분인데, 일단 현재는 android code에서 split 처리를 해서 사용을 하고 있기는 합니다.  이 부분은 나중에 알게 되면 다시 수정을 해 보겠습니다.  혹여나 알게 되시면 댓글  부탁 드립니다.


 


이제 코드는 작성이 되었습니다.  Google Cloud Functions 에는 어떻게 적용을 할까요? 그건 이전 포스팅을 참고해 주세요.


 


오늘도 좋은 하루가 되시길 바라 봅니다.


 





오늘의 이야기

소셜로그인


요새는 대부분의 사용자들이 이런저런 SNS 등에 가입이 되어 있기 때문에 또 다른 개인정보를 제공해 가면서 로그인을 하려고 하지 않습니다. 또한 각각에 등록된 비밀번호를 기억하는 것이 이제 한계에 도달하기도 합니다.

그래서 사용 하는 것이 기존에 등록된 SNS계정을 이용해서 로그인하는 것을 선호(?)하는 경향이 있어 보입니다. 그래서 이번에는 우리가 만드는 앱에도 소셜 로그인을 구현해 볼까 합니다.

또한, firebase auth 을 통과하는 것까지 구현 보는 것이 이번 앱을 만들면서 고민했던 부분입니다.


로그인 화면




일반적으로 만들어지는 로그인 화면 예시 입니다. 이 화면에서는 기존과 같이 이메일과 비밀번호를 받아서 로그인하는 방식을 지원합니다. (사전에 등록을 해야 하기 때문에 등록하는 화면도 따로 구현이 되어야 합니다.)

다음은 소셜 로그인을 지원하는 버튼을 그려 보겠습니다. 아이콘은 여기 저기서 이미지는 구해지기는 하나 이걸 또 변환을 해서 사용해야 하기 때문에 android studio의 메뉴에서 File - New - Vector Asset에서 사용할 vector 이미지 소스 파일은 아래 붙여 두도록 하겠습니다.




Google-08.svg

0.00MB





kakao.svg

0.00MB





naver.svg

0.00MB




※ 해당 이미지 들은 구글링을 통해서 얻어진 파일들 입니다. 여기저기서 구현 이미지 파일을 svg로 변환해서 사용했습니다.


Kakao 로그인


사전에 kakao 개발자 계정을 통해서 앱을 등록하고 네이티브 Key는 얻어 왔다고 가정하겠습니다. 그 과정은 이번 이야기의 중심이 아니기 때문입니다.

제가 하는 경우는 settings.gradle 파일에 추가한 부분인데, kakao sdk 등을 내려받을 경로를 지정해 주었습니다.


repositories {
google()
mavenCentral()
gradlePluginPortal()
maven { url 'https://devrepo.kakao.com/nexus/content/groups/public/' }
}


다음은 앱 수준의 gradle 파일에 추가할 부분입니다.
이 글을 쓰고 있는 2023.01.30 기준으로는 2.12.1 이 최신인 듯합니다.


dependencies {

...
// kakao login
implementation "com.kakao.sdk:v2-user:2.12.1"

}


다음은 activity에 기술된 부분입니다. callback을 지정하고 작업하는 부분은 kakao의 경우 대부분의 사용자들이 카카오톡을 사용하고 있으며, 로그인도 하고 있다고 생각이 되지만 혹여나 카카오톡이 로그인되지 않을 경우 카카오 계정으로 로그인을 하기 위해서 구현하였습니다.



  • isKakaoTalkLoginAvailable : 카카오톡 로그인 가능 한가?

  • loginWithKakaoTalk : 카카오톡으로 로그인

  • loginWithKakaoAccount : 카카오 계정으로 로그인


결국 로그인은 카카오톡에 계정이 있어야만 가능하겠지만, 2가지 방법을 기술해 두고 선택적으로 로그인을 할 수 있도록 지원하는 것이 AVD (애물레이터) 등에서 테스트할 때도 도움이 됩니다.


private val kakaoCallback: (OAuthToken?, Throwable?) -> Unit = { token, error ->
if (error != null) {
Log.e(TAG, "카카오계정으로 로그인 실패", error)
} else if (token != null) {
Log.e(TAG, "카카오계정으로 로그인 성공 ${token.accessToken} ${token.idToken}")
doSigninKakaoToken("${token.accessToken}")
}
}

private fun doKakaoLogin() {

// 카카오톡이 설치되어 있으면 카카오톡으로 로그인, 아니면 카카오계정으로 로그인
if (UserApiClient.instance.isKakaoTalkLoginAvailable(this@MainActivity)) {
UserApiClient.instance.loginWithKakaoTalk(this@MainActivity) { token, error ->
if (error != null) {
Log.e(TAG, "카카오톡으로 로그인 실패", error)

// 사용자가 카카오톡 설치 후 디바이스 권한 요청 화면에서 로그인을 취소한 경우,
// 의도적인 로그인 취소로 보고 카카오계정으로 로그인 시도 없이 로그인 취소로 처리 (예: 뒤로 가기)
if (error is ClientError && error.reason == ClientErrorCause.Cancelled) {
return@loginWithKakaoTalk
}

// 카카오톡에 연결된 카카오계정이 없는 경우, 카카오계정으로 로그인 시도
UserApiClient.instance.loginWithKakaoAccount(this@MainActivity, callback = kakaoCallback)
} else if (token != null) {
Log.e(TAG, "카카오톡으로 로그인 성공 ${token.accessToken} ${token.idToken}")
doSigninKakaoToken("${token.accessToken}")
}
}
} else {
UserApiClient.instance.loginWithKakaoAccount(this@MainActivity, callback = kakaoCallback)
}

}

 


Naver 로그인


이 또한 네이버 개발자 계정으로 로그인을 하고 앱을 생성한 다음 naver login api 사용을 하겠다는 부분과 앱을 등록하는 과정이 들어갑니다. 이 부분은 검색을 통해서 많이 찾을 수 있고, 이번 포스팅의 주제는 아니기 때문에 넘어갑니다.
네이버에서는 등록한 앱의 Client ID와 Client Secret을 얻어와야 합니다.

네이버의 경우는 카카오와 다르게 repogitories을 선언하지 않아도 되었습니다. 네이버 개발자 가이드의 내용에는
oauth-5.3.0.aar 파일을 받아서 libs에 넣고 선언을 하라는 부분이 있기도 하지만, 실제로는 아래와 같이 gradle 파일에 선언을 하는 것으로 해소가 되었습니다.


dependencies {

...
// naver login
implementation("com.navercorp.nid:oauth:5.3.0") // jdk 11

}

다음은 activity에서 구현되어야 하는 부분입니다. naverLauncher을 먼저 선언하고 호출된 결과를 받아올 준비를 합니다.
그리고 아래와 같이 초기화 및 호출을 해 봅니다.



  • initialize : 초기화

  • authenticate : 네이버 로그인을 호출


    private val naverLauncher = registerForActivityResult<Intent, ActivityResult>(ActivityResultContracts.StartActivityForResult()) { result ->
when(result.resultCode) {
RESULT_OK -> {
// 네이버 로그인 인증이 성공했을 때 수행할 코드 추가
// binding.tvAccessToken.text = NaverIdLoginSDK.getAccessToken()
// binding.tvRefreshToken.text = NaverIdLoginSDK.getRefreshToken()
// binding.tvExpires.text = NaverIdLoginSDK.getExpiresAt().toString()
// binding.tvType.text = NaverIdLoginSDK.getTokenType()
// binding.tvState.text = NaverIdLoginSDK.getState().toString()
Log.e("", "accessToken=${NaverIdLoginSDK.getAccessToken()}")
doSigninNaverToken(NaverIdLoginSDK.getAccessToken().toString())
}
RESULT_CANCELED -> {
// 실패 or 에러
val errorCode = NaverIdLoginSDK.getLastErrorCode().code
val errorDescription = NaverIdLoginSDK.getLastErrorDescription()
doToastMakeAppend(R.string.titleFailure, "$errorDescription")
}
}
}

private fun doNaverLogin() {
NaverIdLoginSDK.initialize(this@MainActivity, BuildConfig.NAVER_CLIENT, BuildConfig.NAVER_SECERT, BuildConfig.APPLICATION_ID)
NaverIdLoginSDK.authenticate(this@MainActivity, naverLauncher)
}

 


구글 로그인


구글 oneTap 로그인의 경우도 다음과 같이 구현이 될 듯합니다. 앱을 등록하거나 하는 부분등은 구글 검색을 통해서 찾아보실 수 있을 듯하고, 이번 포스팅의 주된 이야기가 아니라 생략 합니다. 다만 여기서는 web_client_id 가 필요 합니다.
이건 firebase 개발자 콘솔에서 앱을 등록하고 google_service.json 파일을 가져오면 그 안에서 가져올 수 있습니다.

앱 수준의 gradle 파일에는 다음과 같이 추가했습니다.


plugins {
...
id 'com.google.gms.google-services'
...
}

...

dependencies {

...
// 구글 로그인
implementation 'com.google.android.gms:play-services-auth:20.4.1'

}

프로젝트 수준의 gradle 에는 다음과 같이 추가했습니다.


dependencies {
...
classpath 'com.google.gms:google-services:4.3.15'

}

이제 activity에서의 구현을 보도록 하겠습니다.


 private lateinit var oneTapClient: SignInClient

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

...

oneTapClient = Identity.getSignInClient(this@MainActivity)

}

private val intentSenderRequestActivityResultLauncher =
registerForActivityResult(
ActivityResultContracts.StartIntentSenderForResult()
) { result ->
try {
if (result.resultCode == Activity.RESULT_OK) {
result.data?.let { intent ->
oneTapClient.getSignInCredentialFromIntent(intent)
}?.also { signInCredential ->

val username = signInCredential.id
val displayName = signInCredential.displayName

Log.e("", "loginOk $username, $displayName")

doSigninGoogleToken(username, displayName.toString())

}
}
} catch (e: ApiException) {
when (e.statusCode) {
CommonStatusCodes.CANCELED -> {
Log.d(TAG, "One-tap dialog was closed.: ${e.message.toString()}")
}
CommonStatusCodes.NETWORK_ERROR -> {
Log.d(TAG, "One-tap encountered a network error.: ${e.message.toString()}")
}
else -> {
Log.d(TAG, "Couldn't get credential from result.: ${e.localizedMessage}")
}
}
}
}

/**
* google oneTap 로그인
*/
private fun doGoogleLogin() {

Log.e(TAG, "google login start ...")

GetSignInIntentRequest.builder()
.setServerClientId(getString(R.string.default_web_client_id))
.build().also { getSignInIntentRequest ->

Identity.getSignInClient(this@MainActivity)
.getSignInIntent(getSignInIntentRequest)
.addOnSuccessListener { pendingIntent ->
IntentSenderRequest.Builder(pendingIntent.intentSender).build()
.also { intentSenderRequest ->
try {
intentSenderRequestActivityResultLauncher.launch(
intentSenderRequest
)
} catch (e: ActivityNotFoundException) {
Log.d(
TAG,
"addOnSuccessListener:Google Sign-in failed:$e "
)
}
}

}
.addOnFailureListener { exception ->
Log.d(TAG, "addOnFailureListener:Google Sign-in failed:$exception ")
}
}
}

 


Firebase Auth


위의 방법 등으로 해서 구글, 네이버, 카카오 로그인을 구현을 해 보았습니다. 이제 여기서 구해지는 accessToken을 이용해서 firebase 의 auth 구현 방법 중에서 custom Token 을 이용해 로그인하는 방법으로 연결해 보겠습니다.


auth.signInWithCustomToken


먼저 보고 와야 하는 포스팅이 하나 있습니다.

https://billcorea.tistory.com/279



 


개발일기 # 번외편2 : 난 서버가 없는데(Serverless) cloud function 사용해 보기


Cloud Function 서버를 보유 하지(Serverless) 않고 서버가 있는 것처럼 업무 구현을 하고 싶습니다. 개발을 하면서 데이터 베이스와 스토리지를 firebase을 활용하고 있다면 cloud function 도 배워서 준비를


billcorea.tistory.com




이 글을 보고 이해를 하셨다고 보고 다음 이야기를 진행해 보겠습니다. 쉬었다 오겠습니다. 다음 글에서 뵈어요.

지나가기 전에,

보다보니 소셜로그인으로 인한 개인정보가 분실에 대한 걱정이 생길 수도 있겠네요.  개발자센터에 앱을 등록 하면서 인증시 받는 정보는 최소화 해야 할 듯 하네요.

현재는 닉네임, 이메일주소, 프로파일이미지 정도만 받고 있는 데... 더 줄여야 할까요???


 


 


전체 소스는 아래 github 에서 참고 하세요.


https://github.com/nari4169/RemotePayment0119


 





오늘의 이야기



#스치니1000프로젝트 #재미 #행운기원 #Compose #Firebase

🎯 야 너 토요일마다 로또 확인하냐?
나도 맨날 "혹시나~" 하면서 봤거든 ㅋㅋ

근데 이제는 그냥 안 해
AI한테 맡겼어 🤖✨

그것도 구글 Gemini로다가!

그래서 앱 하나 만들었지
👉 "로또 예상번호 by Gemini" 🎱

AI가 분석해서 번호 딱! 뽑아줌
그냥 보고 참고만 하면 됨

재미로 해도 좋고…
혹시 모르는 거잖아? 😏


https://play.google.com/store/apps/details?id=com.billcorea.gptlotto1127




오늘의 이야기

Translation


번역을 시도하는 방법에는 여러 가지가 있습니다.  그중에는 비용이 들어가는 방법도 있고, 일부 무료인 방법도 있습니다.



  • 카카오 번역  API : 월 10,000자 까지는 무료 이후 1000자 단위로 18원 발

  • 구글 cloud 번역 API : 월 최대 500,000자 까지는 무료 이후부터는 비용 추가 (기본 옵션 선택 시)

  • 네이버 papago text 번역 : 1,000,000 단위 과금  20,000원 (과금 단위는 글자를 항상 올림)


등의 방법을 찾을 수 있습니다. 


 


오늘 하고 싶은 이야기는 간단한 문구를 그냥 번역해 보는 방법입니다. 


 


문서의 길이가 길고, 중요한 문서라고 한다면 비용이 들여서라도 번역은 정확하게 하는 것이 맞을 것 같습니다.  단지,  앱에서 사용하는 API들이 꼬부랑말(대부분 영어)로 되어 있는 것들이라서 API가 제공하는 오류 메시지를 그대로 보여 주는 것은 사용자 편의를 고려하지 않은 것이라 생각하게 되어 간편 번역을 해 보기로 했습니다. 


 


구글에서 제공하는 ML Kit에 보면 여러 가지가 있지만 오늘은 그중에서 Translation의 사용하는 방법에 대한 예시를 적어 두고자 합니다. 


 


https://developers.google.com/ml-kit/language/translation/android



 


Android에서 ML Kit를 사용하여 텍스트 번역  |  Google Developers


이 페이지는 Cloud Translation API를 통해 번역되었습니다. Switch to English Android에서 ML Kit를 사용하여 텍스트 번역 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요.


developers.google.com




 


IOS 개발은 아직 해 보지 않기 때문에... 안드로이드로 개발하는 부분만 말해 볼까 합니다.  이 글은 위에 있는 개발 가이드의 내용을 참조하여 작성하였습니다.


Gradle


앱 수준의 gradle 파일에 한 줄 넣어 주세요.


// ML Kit translate
implementation 'com.google.mlkit:translate:17.0.1'

 


Viewmodel


다음은 가이드에 나와 있는 내용들을 다 정리하기가 너무 힘들어서 가이드의 예시용 sourcecode에서 viewModel 코드 파일을 그냥 복사했습니다.


 


/*
* Copyright 2019 Google Inc. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import android.app.Application
import android.util.LruCache
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import com.billcoreatech.remotepayment0119.R
import com.google.android.gms.tasks.OnCompleteListener
import com.google.android.gms.tasks.Task
import com.google.android.gms.tasks.Tasks
import com.google.mlkit.common.model.DownloadConditions
import com.google.mlkit.common.model.RemoteModelManager
import com.google.mlkit.nl.translate.TranslateLanguage
import com.google.mlkit.nl.translate.TranslateRemoteModel
import com.google.mlkit.nl.translate.Translation
import com.google.mlkit.nl.translate.Translator
import com.google.mlkit.nl.translate.TranslatorOptions

import java.util.Locale

/**
* Model class for tracking available models and performing live translations
*/
class TranslateViewModel(application: Application) : AndroidViewModel(application) {

companion object {
// This specifies the number of translators instance we want to keep in our LRU cache.
// Each instance of the translator is built with different options based on the source
// language and the target language, and since we want to be able to manage the number of
// translator instances to keep around, an LRU cache is an easy way to achieve this.
private const val NUM_TRANSLATORS = 3
}

private val modelManager: RemoteModelManager = RemoteModelManager.getInstance()
private val pendingDownloads: HashMap<String, Task<Void>> = hashMapOf()
private val translators =
object : LruCache<TranslatorOptions, Translator>(NUM_TRANSLATORS) {
override fun create(options: TranslatorOptions): Translator {
return Translation.getClient(options)
}
override fun entryRemoved(
evicted: Boolean,
key: TranslatorOptions,
oldValue: Translator,
newValue: Translator?,
) {
oldValue.close()
}
}
val sourceLang = MutableLiveData<Language>()
val targetLang = MutableLiveData<Language>()
val sourceText = MutableLiveData<String>()
val translatedText = MediatorLiveData<ResultOrError>()
val availableModels = MutableLiveData<List<String>>()

// Gets a list of all available translation languages.
val availableLanguages: List<Language> = TranslateLanguage.getAllLanguages().map { Language(it) }

init {
// Create a translation result or error object.
val processTranslation =
OnCompleteListener<String> { task ->
if (task.isSuccessful) {
translatedText.value = ResultOrError(task.result, null)
} else {
translatedText.value = ResultOrError(null, task.exception)
}
// Update the list of downloaded models as more may have been
// automatically downloaded due to requested translation.
fetchDownloadedModels()
}
// Start translation if any of the following change: input text, source lang, target lang.
translatedText.addSource(sourceText) { translate().addOnCompleteListener(processTranslation) }
val languageObserver =
Observer<Language> { translate().addOnCompleteListener(processTranslation) }
translatedText.addSource(sourceLang, languageObserver)
translatedText.addSource(targetLang, languageObserver)

// Update the list of downloaded models.
fetchDownloadedModels()
}

private fun getModel(languageCode: String): TranslateRemoteModel {
return TranslateRemoteModel.Builder(languageCode).build()
}

// Updates the list of downloaded models available for local translation.
private fun fetchDownloadedModels() {
modelManager.getDownloadedModels(TranslateRemoteModel::class.java).addOnSuccessListener {
remoteModels ->
availableModels.value = remoteModels.sortedBy { it.language }.map { it.language }
}
}

// Starts downloading a remote model for local translation.
internal fun downloadLanguage(language: Language) {
val model = getModel(TranslateLanguage.fromLanguageTag(language.code)!!)
var downloadTask: Task<Void>?
if (pendingDownloads.containsKey(language.code)) {
downloadTask = pendingDownloads[language.code]
// found existing task. exiting
if (downloadTask != null && !downloadTask.isCanceled) {
return
}
}
downloadTask =
modelManager.download(model, DownloadConditions.Builder().build()).addOnCompleteListener {
pendingDownloads.remove(language.code)
fetchDownloadedModels()
}
pendingDownloads[language.code] = downloadTask
}

// Returns if a new model download task should be started.
fun requiresModelDownload(
lang: Language,
downloadedModels: List<String?>?,
): Boolean {
return if (downloadedModels == null) {
true
} else !downloadedModels.contains(lang.code) && !pendingDownloads.containsKey(lang.code)
}

// Deletes a locally stored translation model.
internal fun deleteLanguage(language: Language) {
val model = getModel(TranslateLanguage.fromLanguageTag(language.code)!!)
modelManager.deleteDownloadedModel(model).addOnCompleteListener { fetchDownloadedModels() }
pendingDownloads.remove(language.code)
}

fun translate(): Task<String> {
val text = sourceText.value
val source = sourceLang.value
val target = targetLang.value
if (source == null || target == null || text == null || text.isEmpty()) {
return Tasks.forResult("")
}
val sourceLangCode = TranslateLanguage.fromLanguageTag(source.code)!!
val targetLangCode = TranslateLanguage.fromLanguageTag(target.code)!!
val options =
TranslatorOptions.Builder()
.setSourceLanguage(sourceLangCode)
.setTargetLanguage(targetLangCode)
.build()
return translators[options].downloadModelIfNeeded().continueWithTask { task ->
if (task.isSuccessful) {
translators[options].translate(text)
} else {
Tasks.forException<String>(
task.exception
?: Exception(getApplication<Application>().getString(R.string.unknown_error))
)
}
}
}

/** Holds the result of the translation or any error. */
inner class ResultOrError(var result: String?, var error: Exception?)

/**
* Holds the language code (i.e. "en") and the corresponding localized full language name (i.e.
* "English")
*/
class Language(val code: String) : Comparable<Language> {

private val displayName: String
get() = Locale(code).displayName

override fun equals(other: Any?): Boolean {
if (other === this) {
return true
}

if (other !is Language) {
return false
}

val otherLang = other as Language?
return otherLang!!.code == code
}

override fun toString(): String {
return "$code - $displayName"
}

override fun compareTo(other: Language): Int {
return this.displayName.compareTo(other.displayName)
}

override fun hashCode(): Int {
return code.hashCode()
}
}

override fun onCleared() {
super.onCleared()
// Each new instance of a translator needs to be closed appropriately. Here we utilize the
// ViewModel's onCleared() to clear our LruCache and close each Translator instance when
// this ViewModel is no longer used and destroyed.
translators.evictAll()
}
}

 


 


MainActivity


예시에 앱에서는 콤보 박스를 이용해서 source 언어와 번역 후 사용할 target 언어를 선택하도록 하였으나, 만들고 있는 앱에서는 영어(en)를 한국어(ko)로 번역하는 것만 사용할 생각 이기 때문에 다음과 같은 부분들을 MainActivity에 추가해 주었습니다. 


 


import android.app.Activity
.....
import java.security.MessageDigest

class MainActivity : ComponentActivity() {

private val translateView : TranslateViewModel by viewModels()

.....

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

// 간단한 문서 번역 ML
translateView.sourceLang.value = TranslateViewModel.Language("en")
translateView.targetLang.value = TranslateViewModel.Language("ko")
if (!sp.getBoolean("isDownloadKR", false)) {
translateView.downloadLanguage(TranslateViewModel.Language("ko"))
}
// 한국어 모델은 download 을 한 번 받아야 해서
translateView.availableModels.observe(
this@MainActivity
) {result ->
for (lang in result) {
Log.e("", "lang=${lang}")
if (lang == "kr") {
... // 다운로드 받은 걸 표시 해 두었다가 앱을 실행 할 때 마다 받는 건 방지 하도록 구현
}
}
}

...

setContent {

val scrollableState = rememberScrollState()

RemotePayment0119Theme {
// A surface container using the 'background' color from the theme
Column(
modifier = Modifier
.fillMaxSize()
.padding(30.dp)
.verticalScroll(scrollableState),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
DestinationsNavHost(navGraph = NavGraphs.root) {
composable(JoinUserScreenDestination) {
JoinUserScreen(
navigator = destinationsNavigator,
doSignup = { emailId, password ->
doSignupUser(emailId, password)
}
)
}
composable(LoginOptionsDestination) {
LoginOptions(
doLogin = { email, password ->
doEmailLogin(email, password)
},
doForGotPassword = { email ->
doForgotPassword(email)
},
doGoogleLogin = {
doGoogleLogin()
},
doFacebookLogin = {
doFacebookLogin()
},
doRegisterUser = {
destinationsNavigator.navigate(JoinUserScreenDestination)
},
doTranslateDownload = {
// 화면에서 버튼 클릭을 통해 일부로 받을 수 있도록 구현 할 수 도 있음.
translateView.downloadLanguage(TranslateViewModel.Language("ko"))
doToastMake(R.string.msgBeginDownload)
},
doEmailLoginWithLink = { emailid ->
doEmailLoginWithLink(emailid)
}
)
}
}
}
}
}
}

private fun doEmailLoginWithLink(emailId: MutableState<String>) {

}

private fun doSignupUser(emailId: MutableState<String>, password: MutableState<String>) {

}

private fun doFacebookLogin() {

}

private fun doGoogleLogin() {

}

private fun doForgotPassword(email: MutableState<String>) {

}

private fun doEmailLogin(email: MutableState<String>, password: MutableState<String>) {

if (email.value == "") {
doToastMake(R.string.EnterEmail)
return
}
if (password.value == "") {
doToastMake(R.string.EnterPassword)
return
}

auth.signInWithEmailAndPassword(email.value, password.value)
.addOnSuccessListener {
doToastMake(R.string.msgSigninCompleted)
}
.addOnFailureListener {
doToastMakeAppend(R.string.msgUserIdOrPasswordError, it.localizedMessage)
}
}

private fun doToastMake(msgResource: Int) {
Toast.makeText(this@MainActivity, getString(msgResource), Toast.LENGTH_SHORT).show()
}

private fun doToastMakeAppend(msgResource: Int, localizedMessage: String?) {

// 에러 메시지가 영어로 오기 때문에 그것을 번역해 본다.
// 번역이 된 이후에 toast 가 나오도록 구현
translateView.sourceText.value = "$localizedMessage"
translateView.translatedText.observe(
this@MainActivity
) { result ->
Log.e("", "result = ${result.error} ${result.result}")
resultString = result.result.toString()

Toast.makeText(this@MainActivity, "${getString(msgResource)}:${resultString}", Toast.LENGTH_SHORT).show()
}

}

}

@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
RemotePayment0119Theme {
DestinationsNavHost(navGraph = NavGraphs.root)
}
}

코드를 살펴보면 onCreate에서 먼저 사용할 언어를 정했습니다. 영어와 한국어로... 여기서 실수를 했던 부분은 한국어를 kr이라고 했던 건데,  무관심하게 kr 이라고 할 수 있으나, 예제 앱에서도 볼 수 있지만, ko라고 해 주어야 됩니다. (다들 아시는 것이라 생각하고 있습니다.)


 


그리고는 doToastMakeAppend 함수에서 인수로 받은 오류 메시지를 번역해 보여 주는 방식으로 코드를 작성했습니다. 아직 잘 알지 못하는 부분은 번역을 위해 전달한 문장이 2줄인 경우 응답이 2번으로 나누어져 오는 것 같다는 것입니다.  그래서 Toast 가 2번 발생됩니다.  그걸 해소하는 건 이 글을 보시는 분들의 몫으로 남겨 둡니다. 


 


아무튼 이렇게 하면 어렵지 않게 영어 오류 메시지를 한글로 번역해서 보여 줄 수 있습니다. 저처럼 firebase의 기능을 활용하시는 분들께 한 가지 팁이 될지 바랍니다.


 


 


활용예시


이렇게 구현된 앱 샘플입니다.  firebase에 email을 통해 로그인하기 위해서 사용자 가입을 시도해 봅니다.  그때 입력된 password의 길이가 6자리 미만의 경우 오류 처리가 됩니다. 


private fun doSignupUser(emailId: MutableState<String>, password: MutableState<String>) {

if (emailId.value == "") {
doToastMake(R.string.EnterEmail)
return
}
if (password.value == "") {
doToastMake(R.string.EnterPassword)
return
}
auth.createUserWithEmailAndPassword(emailId.value, password.value)
.addOnSuccessListener {
doToastMake(R.string.msgCreateUserCompleted)
}
.addOnFailureListener {
doToastMakeAppend(R.string.msgCreateUserFailure, it.localizedMessage)
}
}

이때 나오는 오류 메시지를 doToastMakeAppend 함수 호출을 통해 번역을 시도해 보는 것입니다. 


 



간편 번역 샘플


 


이상으로 ML Translation에 대해 알아보았습니다.


 


읽어 주셔서 감사합니다.


 


 





오늘의 이야기


#스하리1000명프로젝트

스치니들!
내가 만든 이 앱은, 내 폰에 오는 알림 중에서 중요한 키워드가 있는 경우
등록해둔 친구에게 자동으로 전달해주는 앱이야 📲

예를 들어, 카드 결제 알림을 와이프나 자녀에게 보내주거나
이번 달 지출을 달력처럼 확인할 수도 있어!

앱을 함께 쓰려면 친구도 설치 & 로그인해줘야 해.
그래야 친구 목록에서 서로 선택할 수 있으니까~
서로 써보고 불편한 점 있으면 알려줘 🙏

👉 https://play.google.com/store/apps/details?id=com.nari.notify2kakao





오늘의 이야기

오늘 일과


어제 playstore에 올렸던 앱이 버벅 거린다... 


우연히 사장 앱 수정중에...



 


오늘도 난 아무런 금전적 소득이 없다.  아직 뭐 한 달도 지나지 않았으니...  뭐 벌써 소득을 바라? 라고 말할 수 있을 것 같기는 하나... 그래도 ? 흠흠...


 


Gradle 7.4.0


오늘은 아침을 먹고 출근 하는 여보님을 배웅해 드리고, 난 또 앉아서 엊그제 올렸던 앱의 코드를 수정하고 있다.  어제 아침에 갑자기 android studio을 패치를 했더니, gradle 이 7.3.1에서 7.4.0으로 패치를 하게 됐다. 그래서 덩달아 gradle 파일에 implementation 선언했던 compose 버전도 1.4.0-alpha04 로 패치를 하란다.


 


아무 느낌 없이 그렇게 패치를 진행 했더니... 벌어지는 사태들...  bottom navigation bar을 대신해 볼 요량으로 사용하기 위해서 implementation 했던 것이 오류를 내뱋기 시작했다. ㅠㅠ;;


 


https://github.com/raamcosta/compose-destinations/issues/336



 


io.github.raamcosta.compose-destinations:core:1.8.32-beta upgrade refences ... · Issue #336 · raamcosta/compose-destinations


An error occurs while doing ./gradlew clean build. Why? Stack traces ./gradlew : e: C:\workspaces\Boss0426\app\src\main\java\com\billcoreatech\boss0426\CalcComposeActivity.kt: (34, 31): Unresolved ...


github.com




 


원작자님에게 질문을 해 본다. 꼬부랑 말로 다가... 요즘은 구글 번역이 글을 읽거나 번역을 하는 데 많은 도움을 주기 때문에 그렇게 어렵지 않게 꼬부랑 말을 해 볼 수 있다.  물론 나의 입으로 하는 것은 아니지만...


 


질문과 답하기



You cannot use kotlin 1.7.22


https://developer.android.com/jetpack/androidx/releases/compose-kotlin



 


Compose와 Kotlin의 호환성 지도  |  Android 개발자  |  Android Developers


Compose와 Kotlin의 호환성 지도 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 종속 항목 선언 Compose 컴파일러에 관한 종속 항목을 추가하려면 프로젝트에 Googl


developer.android.com




Latest allowed version is 1.7.21 compatible with compose compiler 1.4.0-alpha02.


 


원작자님의 한 마디... 윽~~~  병아리 코더는 이런 글을 볼 때 가슴이 찌져진다... 이 한 가지를 눈치채지 못해 하루 반나절이나 시간을 버렸으니 말이다. 


 


patch 가 나온다고 해서 무작정 덤비는 (?) 것이 좋지  않다는 것을 배웠다. ㅋ~   아무튼...


 


집을 나서다.


1인 개발자가 되어 좋은 건 아무때나, 아무 곳이나, 나의 사무실(?)이 되어 준다는 것이다. 오늘은 집을 나섰다. 앞에 말한 것처럼 여보님은 출근을 했고, 아들놈은 방학이라 아직 주무신다. 푸하하... 탈출을 해 보자... 집을 나서 길을 따라 오늘은 삼시로 정할 곳을 찾아본다. 


 


지도보기



오늘은 너(?) 정했다.  코딩 작업은 잘 될 곳인가 ?


 


토닥토닥.... (키보드 두드리는 소리...)  왁자지껄 ( 주변 사람들 말소리...) 


 


나만 이곳에 둥지(?)를 틀은 것은 아니고, 옆에 있는 고등학생(?)은 학원 가기 전에 자리를 잡고 있는지 연신 뭔가를 들여다보고 있고, 그 옆 대학생(?) 둘은 잡담이 길다.


 


어제 적용했던 패치를 복원했다... 다시 gradle 7.3.1로 돌리고 나머지 들만 patch을 적용해 테스트를 해 본다... 우쒸~~~ 


이제야 이전처럼 앱이 build 되고 실행도 된다. 음... 엊그제 올렸던 앱을 수정하고 다시 게시를 진행해 본다.


 


저녁 여섯 시 삼분 전... 나도 이제 퇴근(?)이라는 걸 해야겠다.  


 


오늘 한 일


1. 앱 수정해서 다시 올렸다.


2. 별다방에 앉아서 별그램에 글도 하나 게시했고


3. 이 글도 적어 보고 있다.


 


 


Empty 


비워라... 언제가 채워질 그날을 기다리며... 너무 오래 걸리지 않았으면 좋겠는 데...





오늘의 이야기

빌코리아 ( billcorea )


corea ( 라틴어 표기?)에 bill (청구서)를 내 볼까? 하는 의미 라면 너무 건창 한 가? 


아무튼 오늘 부터 billcorea라는 이름의 개인 사업자가 되어 살아 보기로 했다.


 


25년 11개월 내가 다녔던 어느 회사의 근무 기간


그 이전에 다녔던 회사 2곳 과 젊은 날의 패기로 했던 창업기간 은  각각이 고작 3년을 넘겨 보지 못했던 거 같은데


 


이제 떠나야할 시간이 되어 가고 있다는 것을 느끼기 시작하면서  준비를 시작하기는 했지만, 이런저런 이유로 그 준비기간이 끝나지 않았지만, 다가온 퇴직


 


그리고 한달여는 조금 가볍게 놀았다. 그 사이에 문득 가보고 싶었던 울릉도 여행도 했고, 최근 3년여 동안 느꼈던 마음의 짐(?)도 내려놓았다.


 


한달을 넘게 쉼을 가졌더니, 이렇게 계속 쉼을 할 수 만은 없는 일이지 않은가 ?


 


사업자 등록증



 


오늘 첫날의 일과는 재활용 쓰레기를 버리는 것으로 시작하였다. ㅋ~  혼자 하는 일이니 자유로울까?  혼자 하는 일이니 뭐가 다를까?


 


사업자 등록증 내기


세무서에 가야 하나? 어떻게 해야 하는지 모른다. 그래서 일단은 구글님(?)에게 물어보기로 했다. 네이버나, 다음 등의 검색 페이지에서도 검색이 되기는 하겠으나, 개발자로 살다 보니 구글이 더 편하게 다가온다.  국내 메인 포탈은 국내 뉴스가 먼저 들어오기 때문에 헛 눈길을 많이 하게 된다. 아무튼...


검색정보



 


사업자 등록은 검색된 내용으로 국세청 홈텍스를 이용하면 어렵지 않게 등록을 할 수 있었다.  다만, 업태 / 종목을 정하는 문제가 있기는 하나 그것도 검색된 내용 등을 참고해서 종목을 결정하면 업태는 홈텍스에서 관리되는 것으로 해소가 되었다.


 


업태 및 종목



  • 컴퓨터 프로그래밍 서비스업 : 이 건 용역 계약을 통해 프로그램 개발 및 유지보수 업무를 하기 위해서

  • 응용 소프트웨어 개발 및 공급업 : 이 건 크몽, 사람인긱 등에서 앱 개발자로 살아 보기 위해서

  • 광고 대행업 : 이 건 애드센스나 애드몹 같은 광고 플랫폼을 이용해 보기 위해서


3가지 의 종목이 선택되었고, 그대로 등록하였다.


 


사업장을 가져야 하는 가?


난 1인 개발자인데, 사업장이 필요한가?  우리 집 책방으로 사용하는 곳이 사업장이다. 그래서 사업자 등록할 때  주소동일 여부를 '여'로 선택하고 집 주소를 입력해 주었다.


 


관련 문서 제출


사업장이 있거나, 동업자가 있는 경우 제출하여야 하는 문서들이 있지만, 난 1인 사업자이니, 문서 등으로 제출해야 하는 페이지에서는 아무것도 제출하지 않고 다음으로 넘어갔다.


 


사업자용 계좌 개설은?


사용자용 계좌 개설은 필요한가에 대해서도 고민이 들었다.  그것도 필요는 해 보인다. 세무 신고등에 기초 자료로 사용하기 위해서는 말이다.


 


그리고 특히 일반과세로 사업자등록을 한 경우에는 필요할 듯해 보인다.  그래서 통장 개설을 새로 해야 하는지에 대한 고민이 생겼는 데, 일단은 홈텍스에서 신고/납부 탭에서 사업용 계좌 개설을 들어가 보았다니, 기존에 개설된 계좌를 등록할 수 있었다.


 


사업용 계좌 개설 관리



 


짠... 이제 준비는 되었다. 2023년 상반기 세무 신고를 해야 하는 시점까지 얼마 큼의 수익을 낼 수 있는 가는 모르겠지만...


 


이제 프리랜서 활동을 했던, 크몽 등의 사이트에 사업자로 전환 신청을 진행해야겠다.


 


고사상 ?


옛 어른들은 무언가를 시작할 때 고사를 지내는 관습이 있었는 데... 난 그냥 인터넷에서 펌(?) 한 사진 이미지 하나로 대신해 보겠다.


고사상 차림 인터넷 펌



 





오늘의 이야기


#스하리1000명프로젝트,
Иногда сложно разговаривать с иностранными работниками, правда?
Я сделал простое приложение, которое помогает! Вы пишете на своем языке, а другие видят это на своем.
Он автоматически переводит в зависимости от настроек.
Очень удобно для легкого общения. Посмотрите, когда будет возможность!
https://play.google.com/store/apps/details?id=com.billcoreatech.multichat416




오늘의 이야기

 


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


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


 



  • 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을 발생시키는 것이 목적인 코드입니다.  이제 코드작성 방법은 다 모았으니 다른 작업으로 발전해 나갈 수 있겠지요?


 


 





오늘의 이야기

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