그래픽 인터체인지 포맷(영어:Graphics Interchange Format; GIF)은 비트맵그래픽 파일 포맷이다.1987년컴퓨서브가 발표하였으며,월드 와이드 웹에서 가장 널리 쓰이는 파일 포맷이기도 하다. 특별한 플러그인을 요구하지 않고 여러 환경에서 쉽게 쓸 수 있는 까닭에 다중 프레임 애니메이션을 이용한 배너 광고 등에 널리 쓰인다.
wiki 백과에서 옮겨온 글입니다. 다른 설명은 별 의미가 없고 간단한 움짤 만들기를 할 때 도움이 될 것 같습니다.
from PIL import Image import imageio import os save_dir = "C:/Users/nari4/Pictures/gif_source"
path = [f"C:/Users/nari4/Pictures/gif_source/{i}" for i in os.listdir("C:/Users/nari4/Pictures/gif_source")] imgs = [ Image.open(i) for i in path] imageio.mimsave('./boss0426_result.gif', imgs, fps=2.0)
앱을 만들고 있는 입장에서는 간단한 동작 설명을 하기 위해서 만들어 보면 좋을 것 같습니다.
그래서 하나 만들어 보았습니다.
만들어진 gif
우연히 사장이라는 앱의 동작하는 모습을 순서대로 정렬된 이미지 파일을 하나의 폴더에 담아 두고 위에서 작성한 스크립트를 실행하면... 간단하게 움짤(?)이 생성됩니다.
개발자 문서를 읽어 보면 여러 가지 방법이 기술 되어 있습니다. 이중에서 기본은 email, password 을 받아서 처리 하는 방식이고, 그외 위에서 말했던 바와 같은 google, facebook, twitter 등등 여러가지 로그인을 지원합니다.
다만, 회원 정보의 관리등은 따로 구현을 해야 하는 부분이 있다는 것은 이해를 하셨을 거라고 생각됩니다.
. signinWithCustomToken
개발된 토큰을 이용해 로그인이라고 이해를 했습니다. 그래서 카카오나 네이버에서 로그인 시도 후에 얻어지는 accessToken 만 있으면 로그인이 되지 않을까 하는 기대감(?)으로 찾아보던 중에 그 이해가 잘못되었다는 것을 알아내는 데 까지 일주일은 가버린 느낌입니다. 사이에 설 연휴가 있었기는 했지만요.
이 글에서 보는 바와 같이 인증 서버에서 하는 일이 다음과 같이 있어야 한다는 것을 알게 되었습니다. 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
# 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'
# 네이버 에서 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'
# 카카오 에서 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'
네이버나 카카오의 경우는 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 이미지 소스 파일은 아래 붙여 두도록 하겠습니다.
다음은 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 파일에 선언을 하는 것으로 해소가 되었습니다.
구글 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'
번역을 시도하는 방법에는 여러 가지가 있습니다. 그중에는 비용이 들어가는 방법도 있고, 일부 무료인 방법도 있습니다.
카카오 번역 API : 월 10,000자 까지는 무료 이후 1000자 단위로 18원 발
구글 cloud 번역 API : 월 최대 500,000자 까지는 무료 이후부터는 비용 추가 (기본 옵션 선택 시)
네이버 papago text 번역 : 1,000,000 단위 과금 20,000원 (과금 단위는 글자를 항상 올림)
등의 방법을 찾을 수 있습니다.
오늘 하고 싶은 이야기는 간단한 문구를 그냥 번역해 보는 방법입니다.
문서의 길이가 길고, 중요한 문서라고 한다면 비용이 들여서라도 번역은 정확하게 하는 것이 맞을 것 같습니다. 단지, 앱에서 사용하는 API들이 꼬부랑말(대부분 영어)로 되어 있는 것들이라서 API가 제공하는 오류 메시지를 그대로 보여 주는 것은 사용자 편의를 고려하지 않은 것이라 생각하게 되어 간편 번역을 해 보기로 했습니다.
구글에서 제공하는 ML Kit에 보면 여러 가지가 있지만 오늘은 그중에서 Translation의 사용하는 방법에 대한 예시를 적어 두고자 합니다.
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. */
/** * 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에 추가해 주었습니다.
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 }
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()
코드를 살펴보면 onCreate에서 먼저 사용할 언어를 정했습니다. 영어와 한국어로... 여기서 실수를 했던 부분은 한국어를 kr이라고 했던 건데, 무관심하게 kr 이라고 할 수 있으나, 예제 앱에서도 볼 수 있지만, ko라고 해 주어야 됩니다. (다들 아시는 것이라 생각하고 있습니다.)
그리고는 doToastMakeAppend 함수에서 인수로 받은 오류 메시지를 번역해 보여 주는 방식으로 코드를 작성했습니다. 아직 잘 알지 못하는 부분은 번역을 위해 전달한 문장이 2줄인 경우 응답이 2번으로 나누어져 오는 것 같다는 것입니다. 그래서 Toast 가 2번 발생됩니다. 그걸 해소하는 건 이 글을 보시는 분들의 몫으로 남겨 둡니다.
아무튼 이렇게 하면 어렵지 않게 영어 오류 메시지를 한글로 번역해서 보여 줄 수 있습니다. 저처럼 firebase의 기능을 활용하시는 분들께 한 가지 팁이 될지 바랍니다.
활용예시
이렇게 구현된 앱 샘플입니다. firebase에 email을 통해 로그인하기 위해서 사용자 가입을 시도해 봅니다. 그때 입력된 password의 길이가 6자리 미만의 경우 오류 처리가 됩니다.
private fun doSignupUser(emailId: MutableState<String>, password: MutableState<String>) {
오늘도 난 아무런 금전적 소득이 없다. 아직 뭐 한 달도 지나지 않았으니... 뭐 벌써 소득을 바라? 라고 말할 수 있을 것 같기는 하나... 그래도 ? 흠흠...
Gradle 7.4.0
오늘은 아침을 먹고 출근 하는 여보님을 배웅해 드리고, 난 또 앉아서 엊그제 올렸던 앱의 코드를 수정하고 있다. 어제 아침에 갑자기 android studio을 패치를 했더니, gradle 이 7.3.1에서 7.4.0으로 패치를 하게 됐다. 그래서 덩달아 gradle 파일에 implementation 선언했던 compose 버전도 1.4.0-alpha04 로 패치를 하란다.
아무 느낌 없이 그렇게 패치를 진행 했더니... 벌어지는 사태들... bottom navigation bar을 대신해 볼 요량으로 사용하기 위해서 implementation 했던 것이 오류를 내뱋기 시작했다. ㅠㅠ;;
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
비워라... 언제가 채워질 그날을 기다리며... 너무 오래 걸리지 않았으면 좋겠는 데...
corea ( 라틴어 표기?)에 bill (청구서)를 내 볼까? 하는 의미 라면 너무 건창 한 가?
아무튼 오늘 부터 billcorea라는 이름의 개인 사업자가 되어 살아 보기로 했다.
25년 11개월 내가 다녔던 어느 회사의 근무 기간
그 이전에 다녔던 회사 2곳 과 젊은 날의 패기로 했던 창업기간 은 각각이 고작 3년을 넘겨 보지 못했던 거 같은데
이제 떠나야할 시간이 되어 가고 있다는 것을 느끼기 시작하면서 준비를 시작하기는 했지만, 이런저런 이유로 그 준비기간이 끝나지 않았지만, 다가온 퇴직
그리고 한달여는 조금 가볍게 놀았다. 그 사이에 문득 가보고 싶었던 울릉도 여행도 했고, 최근 3년여 동안 느꼈던 마음의 짐(?)도 내려놓았다.
한달을 넘게 쉼을 가졌더니, 이렇게 계속 쉼을 할 수 만은 없는 일이지 않은가 ?
사업자 등록증
오늘 첫날의 일과는 재활용 쓰레기를 버리는 것으로시작하였다.ㅋ~ 혼자 하는 일이니자유로울까? 혼자 하는 일이니 뭐가다를까?
사업자 등록증 내기
세무서에 가야 하나? 어떻게 해야하는지모른다. 그래서 일단은 구글님(?)에게물어보기로했다. 네이버나, 다음 등의 검색페이지에서도검색이 되기는 하겠으나, 개발자로 살다 보니 구글이 더 편하게다가온다. 국내 메인 포탈은 국내 뉴스가 먼저 들어오기 때문에 헛 눈길을 많이 하게 된다. 아무튼...
검색정보
사업자 등록은 검색된 내용으로 국세청 홈텍스를 이용하면 어렵지 않게 등록을 할 수 있었다. 다만, 업태 / 종목을 정하는 문제가 있기는 하나 그것도 검색된 내용 등을 참고해서 종목을 결정하면 업태는 홈텍스에서 관리되는 것으로 해소가 되었다.
업태 및 종목
컴퓨터 프로그래밍 서비스업 : 이 건 용역 계약을 통해 프로그램 개발 및 유지보수 업무를 하기 위해서
응용 소프트웨어 개발 및 공급업 : 이 건 크몽, 사람인긱 등에서 앱 개발자로 살아 보기 위해서
광고 대행업 : 이 건 애드센스나 애드몹 같은 광고 플랫폼을 이용해 보기 위해서
3가지 의 종목이 선택되었고, 그대로 등록하였다.
사업장을 가져야 하는 가?
난 1인 개발자인데, 사업장이 필요한가? 우리 집 책방으로 사용하는 곳이 사업장이다. 그래서 사업자 등록할 때 주소동일 여부를 '여'로 선택하고 집 주소를 입력해 주었다.
관련 문서 제출
사업장이 있거나, 동업자가 있는 경우 제출하여야 하는 문서들이 있지만, 난 1인 사업자이니, 문서 등으로 제출해야 하는 페이지에서는 아무것도 제출하지 않고 다음으로 넘어갔다.
사업자용 계좌 개설은?
사용자용 계좌 개설은 필요한가에 대해서도 고민이 들었다. 그것도 필요는 해 보인다. 세무 신고등에 기초 자료로 사용하기 위해서는 말이다.
그리고 특히 일반과세로 사업자등록을 한 경우에는 필요할 듯해 보인다. 그래서 통장 개설을 새로 해야 하는지에 대한 고민이 생겼는 데, 일단은 홈텍스에서 신고/납부 탭에서 사업용 계좌 개설을 들어가 보았다니, 기존에 개설된 계좌를 등록할 수 있었다.
사업용 계좌 개설 관리
짠... 이제 준비는 되었다. 2023년 상반기 세무 신고를 해야 하는 시점까지 얼마 큼의 수익을 낼 수 있는 가는 모르겠지만...
이제 프리랜서 활동을 했던, 크몽 등의 사이트에 사업자로 전환 신청을 진행해야겠다.
고사상 ?
옛 어른들은 무언가를 시작할 때 고사를 지내는 관습이 있었는 데... 난 그냥 인터넷에서 펌(?) 한 사진 이미지 하나로 대신해 보겠다.
#스하리1000명프로젝트, Иногда сложно разговаривать с иностранными работниками, правда? Я сделал простое приложение, которое помогает! Вы пишете на своем языке, а другие видят это на своем. Он автоматически переводит в зависимости от настроек. Очень удобно для легкого общения. Посмотрите, когда будет возможность! https://play.google.com/store/apps/details?id=com.billcoreatech.multichat416