2026/02/14
오늘의 이야기
#billcorea #운동동아리관리앱
🏸 Schneedle — незаменимое приложение для бадминтонных клубов!
👉 Матчевая игра: записывайте результаты и находите противников 🎉
Идеально подходит для любого места: в одиночку, с друзьями или в клубе! 🤝
Если вам нравится бадминтон, обязательно попробуйте
Зайди в приложение 👉 https://play.google.com/store/apps/details?id=com.billcorea.matchplay
오늘의 이야기
python 이나, java 에서 소스를 이용해서 jsoup 파싱을 해 보기는 했으나, android 폰에서 구현하는 파싱은 이번이 처음이다.
예전에 lotto 당첨 결과를 얻어오는 앱을 만들어 본 적이 있는데,
그때는 정말 날코딩으로 html 페이지를 분석해서 쪼개여 왔는데, 이제 그 보다 쉬운 방법이 있다는 것이다.

동행복권 페이지에서 회차별 당첨 결과를 조회하는 페이지를 열어서 F12(개발자도구)을 이용해서 페이지에서 얻어와야 하는 html tag 을 찾아보자.
다음은 android studio 에서 새 프로젝트를 하나 만들고... 제일 먼저 그래드 설정에 다음과 같이 jsoup 사용을 위해 추가해 본다. (2021.06.30 기준 버전은 1.13.1)
dependencies {
implementation 'androidx.appcompat:appcompat:1.3.0'
implementation 'com.google.android.material:material:1.3.0'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
implementation 'org.jsoup:jsoup:1.13.1'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
}그 다음은 manifast 파일을 열어서 인터넷 사용을 위한 권한 설정을 한다.
<uses-permission android:name="android.permission.INTERNET" />그리고 다음은 화면을 그릴껀데, 이건 예제 이므로 단순하게 버튼 하나와 textView 하나만 담았다.
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:id="@+id/textView"
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:layout_above="@+id/button"
android:scrollbars="vertical"
android:text="" />
<Button
android:id="@+id/button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_alignParentBottom="true"
android:layout_marginEnd="22dp"
android:layout_marginBottom="43dp"
android:text="HTML 가져오기" />
</RelativeLayout>
그리고 이번에는 MainActivity 구현을 해 본다.
소스에서 살펴볼 부분은 AsyncTask 을 이용해서 비동기 통신을 해야 한다는 것과 어떻게 하면 결과 숫자값만 뽑아올 수 있는 가 하는 부분이다. 위에 html 결과에서 span tag 을 값을 받아와야 하는 데 class 만 표시 되어 있어서 그 class 의 공통적인 종류를 이용해서 필요한 부분이 결과만 추출해 오면 된다.
package com........;
import androidx.appcompat.app.AppCompatActivity;
import android.os.AsyncTask;
import android.os.Bundle;
import android.text.method.ScrollingMovementMethod;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import java.io.IOException;
import java.util.Arrays;
public class MainActivity extends AppCompatActivity {
private String drwNoUrl = "https://dhlottery.co.kr/gameResult.do?method=byWin";
private TextView textviewHtmlDocument;
private String htmlContentInStringFormat = "";
private String nowCnt = "" ;
String TAG = "Lotto" ;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
textviewHtmlDocument = (TextView) findViewById(R.id.textView);
textviewHtmlDocument.setMovementMethod(new ScrollingMovementMethod());
Button htmlTitleButton = (Button) findViewById(R.id.button);
htmlTitleButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
nowCnt = "969"; // 2021.06.30 기준 마지막 회차
JsoupAsyncTask jsoupAsyncTask = new JsoupAsyncTask();
jsoupAsyncTask.execute(drwNoUrl, nowCnt);
}
});
}
// 백그라운드 호출을 해야 하는 것은 실시간으로 답을 받아올 수 없기 때문에
private class JsoupAsyncTask extends AsyncTask<String, Void, Void> {
@Override
protected void onPreExecute() {
super.onPreExecute();
}
@Override
protected Void doInBackground(String... params) {
Log.i(TAG, Arrays.toString(params));
try {
// 파라미터로 넘어온 값을 이용해서 url 와 회차를 설정한다.
String callUrl = params[0] + "&drwNo=" + params[1];
Document doc = Jsoup.connect(callUrl).get();
// 위의 html tag에서 결과숫자를 싸고 있는 span tag 을 class명을 이용함.
Elements links = doc.select(".ball_645");
Log.e(TAG, "links=" + links.size());
htmlContentInStringFormat += "회차=" + nowCnt ;
for(Element el : links) {
Log.e(TAG, el.text()) ;
htmlContentInStringFormat += " " + el.text() ;
}
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
@Override
protected void onPostExecute(Void result) {
textviewHtmlDocument.setText(htmlContentInStringFormat);
}
}
}AsyncTask를 사용하면 UI 스레드를 적절하고 쉽게 사용할 수 있다. 이 클래스를 사용하면 스레드 및 / 또는 핸들러를 조작하지 않고도 백그라운드 작업을 수행하고 UI 스레드에 결과를 게시 할 수 있다.
AsyncTask는 Thread and Handler에 대한 도우미 클래스로 설계되었으며 일반적인 스레딩 프레임 워크를 구성하지 않는다. AsyncTasks는 짧은 작업 (최대 몇 초)에 이상적으로 사용되어야 한다.
화면의 결과가 초라해(?) 보이기는 해도 쉽게 그 결과를 얻어올 수 있었다.

살펴보고 궁금한 것이 있으시다면. 댓글을 달아 주세요.
오늘의 이야기
#스하리1000명프로젝트,
迷失在韩国?即使您不会说韩语,这个应用程序也可以帮助您轻松出行。
只需说出您的语言即可 - 它会翻译、搜索并以您的语言显示结果。
非常适合旅行者!支持英语、日语、中文、越南语等10多种语言。
现在就试试吧!
https://play.google.com/store/apps/details?id=com.billcoreatech.opdgang1127
2026/02/13
오늘의 이야기
---
**최종 추천 조합:**
추천 [01,06,07,16,30,38]
추천 [05,10,12,27,34,40]
추천 [03,06,11,16,21,29]
추천 [02,04,17,20,31,39]
추천 [01,05,21,33,37,41]
---
**마지막 회차 (1210) 당첨 번호: 01, 07, 09, 17, 27, 38**
- 짝수/홀수 비율: 짝수 1개, 홀수 5개
- 총 합계: 99, 평균: 16.50
- 간격 패턴: [6, 2, 8, 10, 11]
**새로운 추천 조합과의 비교:**
추천 조합 1 (01, 06, 07, 16, 30, 38):
- 짝수/홀수 비율: 짝수 4개, 홀수 2개 (마지막 회차: 짝수 1개, 홀수 5개)
- 총 합계: 98, 평균: 16.33 (마지막 회차: 합계 99, 평균 16.50)
- 간격 패턴: [5, 1, 9, 14, 8] (마지막 회차: [6, 2, 8, 10, 11])
- 마지막 회차와의 유사성 점수 (4점 만점): 2점
추천 조합 2 (05, 10, 12, 27, 34, 40):
- 짝수/홀수 비율: 짝수 4개, 홀수 2개 (마지막 회차: 짝수 1개, 홀수 5개)
- 총 합계: 128, 평균: 21.33 (마지막 회차: 합계 99, 평균 16.50)
- 간격 패턴: [5, 2, 15, 7, 6] (마지막 회차: [6, 2, 8, 10, 11])
- 마지막 회차와의 유사성 점수 (4점 만점): 2점
추천 조합 3 (03, 06, 11, 16, 21, 29):
- 짝수/홀수 비율: 짝수 2개, 홀수 4개 (마지막 회차: 짝수 1개, 홀수 5개)
- 총 합계: 86, 평균: 14.33 (마지막 회차: 합계 99, 평균 16.50)
- 간격 패턴: [3, 5, 5, 5, 8] (마지막 회차: [6, 2, 8, 10, 11])
- 마지막 회차와의 유사성 점수 (4점 만점): 1점
추천 조합 4 (02, 04, 17, 20, 31, 39):
- 짝수/홀수 비율: 2개, 홀수 4개 (마지막 회차: 짝수 1개, 홀수 5개)
- 총 합계: 113, 평균: 18.83 (마지막 회차: 합계 99, 평균 16.50)
- 간격 패턴: [2, 13, 3, 11, 8] (마지막 회차: [6, 2, 8, 10, 11])
- 마지막 회차와의 유사성 점수 (4점 만점): 2점
추천 조합 5 (01, 05, 21, 33, 37, 41):
- 짝수/홀수 비율: 짝수 0개, 홀수 6개 (마지막 회차: 짝수 1개, 홀수 5개)
- 총 합계: 138, 평균: 23.00 (마지막 회차: 합계 99, 평균 16.50)
- 간격 패턴: [4, 16, 12, 4, 4] (마지막 회차: [6, 2, 8, 10, 11])
- 마지막 회차와의 유사성 점수 (4점 만점): 0점
---
### 추천 번호 생성 추론
주어진 20회차 당첨 번호 데이터를 기반으로 다음 회차를 위한 5가지 조합을 분석하고 추천했습니다. 각 조합은 다음과 같은 분석 및 전략을 통해 도출되었습니다.
1. **가장 자주 출현한 번호 기반 조합:**
- 지난 20회차 동안 가장 빈번하게 출현한 상위 6개 번호(01,16,27,30,38,39)를 직접 선별하여 조합을 구성했습니다. 이는 과거 데이터에서 가장 높은 빈도를 보인 번호들이 다시 출현할 가능성이 높다는 가정에 기반합니다. (추천 조합 1: 01,06,07,16,30,38)
2. **가장 흔한 짝수/홀수 비율 조합:**
- 지난 데이터에서 가장 많이 나타난 짝수/홀수 비율(4개 짝수, 2개 홀수)을 따르면서, 전체 번호의 평균 합계(109.95)와 가까운 조합을 생성했습니다. 이 조합은 최근 회차의 극단적인 짝수/홀수 비율(1210회차: 짝수 1개, 홀수 5개)에서 벗어나, 데이터 상의 일반적인 경향을 따르도록 설계되었습니다. (추천 조합 2: 05,10,12,27,34,40)
3. **가장 흔한 간격 패턴 기반 조합:**
- 지난 회차들에서 가장 자주 나타난 간격 패턴(3,5,5,5,8) 중 하나를 선택하고, 자주 등장하는 작은 시작 번호(예: 1, 3, 5 등)로부터 해당 패턴을 적용하여 조합을 만들었습니다. 이는 번호 간의 특정 '흐름'이 반복될 수 있다는 가설에 기반합니다. (추천 조합 3: 03,06,11,16,21,29)
4. **최근 미출현 고빈도 번호(Due Numbers) 혼합 조합:**
- 최근 5회차 동안 출현하지 않았지만, 전체 데이터에서 높은 출현 빈도를 보이는 '나올 때가 된' 번호들을 선별하여, 가장 자주 출현한 번호들과 섞어 조합을 구성했습니다. 이 조합은 장기적인 출현 경향과 단기적인 미출현 경향을 모두 고려하여 균형을 맞추려 했습니다. 또한, 총 합계가 전체 평균 합계(109.95)에 가깝도록 조정했습니다. (추천 조합 4: 02,04,17,20,31,39)
5. **높은 패턴 일치율 회차 참조 변형 조합:**
- 과거 데이터 중 분석 항목(간격, 짝수/홀수 비율, 합계, 평균)에서 가장 높은 일치율(Matching Ratio)을 보였던 회차의 당첨 번호를 참조하여 구성했습니다. 해당 회차의 번호들을 기반으로 1~2개 숫자를 변경하여, 과거의 강력한 패턴을 유지하면서도 완전히 동일하지 않은 새로운 조합을 도출했습니다. (추천 조합 5: 01,05,21,33,37,41)
모든 추천 조합은 지난 10회차 당첨 번호와 중복되지 않도록 검증되었습니다.

사용하는 예시 영상 보기
이 앱이 궁금 하다면, 아래 링크에서 설치할 수 있습니다.
로또 645
오늘의 이야기
윌라오디오북을 듣고 나서 ... 어떻게 이런 생각을 하고 사는 걸까 ?
조금은 일찍 알았다면, 좋았을까 하는 생각이 드는건 왜인가 ? 그래도 시작이 반이라는 말 처럼
이제라도 시작해 보면 언제가 좋은 날이 올 수 있는 것인가 ?
세상에 공짜는 없다는 말이 맞는 것 같다. 그래서 이책 저자의 말이 맞는 말이기도 하지만,
사실 절대적으로 맞는 말은 아니다. 그 또한 노력의 결과로 받아 드려져야 하는 것이라고 생각이 든다.
그래도 배운건 있지. 어떻게 하면 광고판을 얻을 수 있는 가 하는 것...
구글 애드센스 도전을 하고 있는데, 그쪽에서 받은 메일은 다음과 같다.
광고 게재가 준비되지 않은 사이트
사이트에서 정책 위반이 발견되어 아직 광고를 게재할 수 없습니다.
가치 있는 인벤토리: 콘텐츠 없음
Google에서는 콘텐츠가 없는 경우 수익 창출을 허용하지 않습니다.
제작 중인 사이트 또는 앱에 대한 자리표시자 콘텐츠를 포함하는 콘텐츠가 없습니다.
자세한 내용은 다음 자료를 참조하세요.
사이트에서 고유 콘텐츠와 우수한 사용자 환경을 제공하는지 확인하기
고품질 사이트를 만들기 위한 정책 도움말 2부
웹마스터 품질 가이드라인
콘텐츠 정책을 확인하거나 고객센터를 방문하여 자세한 정보를 확인하세요. 위반사항을 수정했으면 사이트 검토를 요청할 수 있습니다.콘텐츠가 없음... 이게 무슨말인가 ? 내 딴에는 나름 이런 저런 것들도 열댓개의 글을 게시했는데, 말이지... 물론 내용이 써 많은 것은 아니지만... 그리고 더 자세한 가이드를 해 주지 않는다. (설령 내가 찾지 못한 것일 수 도 있지만)
이책의 내용을 듣다 보면 최소 1500자 이상의 글을 적은 페이지가 30개 이상은 되어야 할 것 같은 생각이 들었다. 그래서 그동안 적어 두었던 페이지를 살펴 보니 나름 이해가 안되는 것인 아니였다. 그런다고 해서 내가 뭘 더 채울 수 있는 것인가 하는 부분에 대해서는 고민을 해 보지 않을 수 없었다. 그래서 아직도 난 애드센서의 허가(?)을 얻지 못하고 있는 중이고, 어느날은 얻을 수 있을 지 모르겠지만 서도...
또 하나 잊혀지지 않아야 하는 주제는 파이프라인 을 만들어야 한다는 말... 이런 저런 것들을 차곡 차곡 쌓아두다 보면 나중에는 도움이 되는 길이 생길까 하는 것? 아직은 그것에 대해서 알 수는 없으나... 어느 날에는 아는 날이 올까 하는 궁금함이 생긴다. 이글은 이책의 저자가 말한 편법(?)을 누리는 중이다. 혹시 이해가 되지 않는 다면... 이 책을 꼭 읽어 보시길... ㅋㅋㅋ
이책의 목차는 아래와 같다.
추천의 글
리남의 강의를 수강한 분들의 실제 후기
프롤로그
지극히 평범했던 나도 했기 때문에
당신도 할 수 있습니다
제1부 원하는 것을 이루는 부의 마인드셋
01 돈을 벌기 위해 취업을 포기했다
나는 N포세대다
탈선도 필요할 때가 있다
바뀌고자 한다면 행동이 우선이다
나 자신을 알기로 했다
02 나는 자면서도 돈을 벌기로 결심했다
의심을 확신으로 전환하라
온라인은 모든 게 돈이다
당신도 같은 실수를 하고 있을지 모른다
도움 없이 스스로 시작하는 방법
03 무자본으로 파이프라인을 구축하라
월급은 노후를 보장하지 않는다
죽는 순간까지 물을 길을 수는 없다
*부업을 하는 이유: 파이프라인
무자본, 최소 시간, 공간 제약이 없는 가장 현실적인 부업
*취미로 돈을 버는 가장 현실적인 방법
파이프라인 구축을 위해 필요한 것
04 거창하지 않아야 시작할 수 있다
블루오션을 찾지 않기로 했다
블로그는 이제 끝났다는 모두의 거짓말
*블로그로 돈 버는 시대는 끝났다?
사실 거창한 이유는 필요 없다
05 실수를 반복하지 않기로 다짐했다
실패를 통해서도 배울 게 있다
처음에 돈을 벌지 못했던 이유
*블로그로 돈을 벌기 위해 필요한 것
그때는 몰랐고, 지금은 알게 된 것
여우와 두루미가 내게 준 교훈
제2부 자면서도 돈이 들어오는 파이프라인 구축 공식
01 성공의 핵심은 극비의 노하우가 아니다
근본적으로 필요한 것
온라인 마케팅으로 돈을 버는 방법
*인터넷 광고 종류 정리
반드시 알아야 할 마케팅 플랫폼
02 딱, 아는 만큼 보인다
블로그 서비스의 종류와 특징
*수익형 블로그 어디서 만들까?
*워드프레스 정말 저품질이 없을까?
애드센스를 선택한 이유
아는 것과 모르는 것의 차이
*블로그 월 1000만 원 수익 공개
03 첫 단추를 제대로 끼우는 게 중요하다
시작이 반이다
티스토리 블로그 사용법
*수익형 블로그 최적화 세팅
*티스토리 신규 에디터 사용법
검색되는 블로그를 만들자
*블로그 검색 등록 하는 방법
04 시작은 구글 애드센스로 하라
구글 애드센스 승인 TIP: 정공법과 편법
구들 애드센스 신청하기
*구글 애드센스 승인받기
구글 애드센스 사용법
*애드센스 광고 넣는 방법
05 어떻게 나만의 무기를 갖는가
나만의 효과적인 무기를 갖는 방법
*블로그 주제 선정 TIP
*키워드 추출 방법 https://www.welaaa.com/audio/detail/7680
나는 자는 동안에도 돈을 번다
저자 소개 빠듯한 가정에서 태어나 그 누구보다 평범하고 현실적인 삶을 추구하던 취준생이었지만, 대학 졸업 후 돌연 취업 포기를 선언했다. 내 집 마련, 결혼과 육아 등 월급의 한계가 가져다
www.welaaa.com
p.s (2021.07.06)
바라면 이루어 지는 건가... 드디어 오늘 메일 받았다.. adsense 로 부터... 드디오 광고를 허 하노라...

오늘의 이야기
#스치니1000프로젝트 #재미 #행운기원 #Compose #Firebase
🎯 야 너 토요일마다 로또 확인하냐?
나도 맨날 "혹시나~" 하면서 봤거든 ㅋㅋ
근데 이제는 그냥 안 해
AI한테 맡겼어 🤖✨
그것도 구글 Gemini로다가!
그래서 앱 하나 만들었지
👉 "로또 예상번호 by Gemini" 🎱
AI가 분석해서 번호 딱! 뽑아줌
그냥 보고 참고만 하면 됨
재미로 해도 좋고…
혹시 모르는 거잖아? 😏
https://play.google.com/store/apps/details?id=com.billcorea.gptlotto1127
오늘의 이야기
android studio 을 이용해서 개발을 하다 보면 간혹 만나는 오류들이 여러가지가 있다면
그중에 황당한 경우라면 아마도 RuntimeException 으로 오류가 나는 경우일 것 같다.
1. 구글링을 해보자... 안드로이드를 책으로 배우는 입장에서는 제일 난감한 것이 오류를 만났을 때 인데,
나보다 먼저 해 본 사람들이 남겨놓은 글들이 제일이다.
Process: com.tistory.webnautes.example, PID: 12963
java.lang.RuntimeException: Unable to instantiate activity ComponentInfo{com.tistory.webnautes.example/com.tistory.webnautes.example.MainActivity}:
java.lang.ClassNotFoundException: Didn't find class
2. 나오는 내용에 나의 메시지와 비슷한 글이 들어있는 것은 무조건 다 읽어본다.
3. 크롬 브라우저를 이용해서 검색을 하고 있다면. 번역기능을 이용해서 영문site 의 내용도 읽어보는 것이 도움이 된다.
4. 참고 사이트
https://github.com/AdevintaSpain/Leku/issues/155
Unable to instantiate application on startup · Issue #155 · AdevintaSpain/Leku
Description Really looking forward to use this, thanks for sharing. But I'm having trouble getting an application set-up. Once i run my app i get an exception: E/AndroidRuntime: FATAL EXCEPTION...
github.com
내용을 살펴 보면 JDK 버전 떄문에 문제가 되는 것으로 보인다.
5. 이제 gradle 파일을 수정해 보자
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}위 예시는 java8 을 사용하는 경우의 gradle 파일에 대한 예시이니 나의 JDK 버전에 맞게 찾아서 설정을 해야 한다.
이래도 에러가 해소 되지 않는 다면... 다시 구글링을 해 보는 것으로 ...
오늘의 이야기



들판에 핀
이름은 뭐지???
날 좋은 휴일 오후... 기약없이 흐르는 시간이 아쉽다.
집을 나섰다.
남들은 햇빛이 뜨거워 움직이지 않는 다는 오후 1시.
작은 물병하나 들고 나서는 길이 시작 부터 덥다
이길을 걷다 보면 어디서 다시 돌아와야 하는 지 알 수는 없지만,
그런데로 나름의 여유를 느낄 수 있기는 했다.
이길로 다시 돌아오지 않는다 해도 전혀 이상하지 않을 것 같은
시간들이 흘러 지나간다.
여태 보내온 시간들이 날 위해 무엇을 하였는 지 ?
오늘의 이야기
#스하리1000명프로젝트
스치니들!
내가 만든 이 앱은, 내 폰에 오는 알림 중에서 중요한 키워드가 있는 경우
등록해둔 친구에게 자동으로 전달해주는 앱이야 📲
예를 들어, 카드 결제 알림을 와이프나 자녀에게 보내주거나
이번 달 지출을 달력처럼 확인할 수도 있어!
앱을 함께 쓰려면 친구도 설치 & 로그인해줘야 해.
그래야 친구 목록에서 서로 선택할 수 있으니까~
서로 써보고 불편한 점 있으면 알려줘 🙏
👉 https://play.google.com/store/apps/details?id=com.nari.notify2kakao
오늘의 이야기
인앱 결제를 하기 위해서 오늘도 구글링을 하시는 분들께... 기본적은 헤맴을 줄여보기 위해서 정리를 해 둡니다.
인앱 결제를 하려면 일단, 할 일은 앱을 하나 만들어서 구글 플레이에 등록을 하는 것이다. 등록하는 가이드는 구글링을 통해 많이 나와 있으니 생략한다.
여기서 등록한다고 해서 꼭 출시상태를 만들 필요는 없다. 알파테스트(비공개 테스트) 단계까지만 등록해도 된다.
그리고 해야할 것은 인앱 상품을 만들어서 등록하는 것이다.

이처럼 등록을 하고 나면 금방이 승인이 나지 않는 다. 등록하고 다음날 확인해 보는 게 마음 편한 방법이다.
승인이 확인되면, manifest 부터 설정을 해 봐야 한다.
<uses-permission android:name="com.android.vending.BILLING"/>
<uses-permission android:name="android.permission.INTERNET"/>권한은 꼭 등록이 되어야 한다. internet 사용 권한도 등록해 두어야 한다.
그다음은 build.gradle의 내용 중에서 다음은 선언 있어야 한다.
dependencies {
implementation 'com.android.billingclient:billing:4.1.0'
implementation 'com.google.code.gson:gson:2.8.8'
}이런저런 다른 것들도 필요하면 넣어야 한다. 저 예시는 빌링을 위한 필수 항목이다. 2021.8.2. 부터는 모든 앱이 Ver 3.0을 사용해야 한다고 권고하고 있다. 빌링은 아래 예시 처럼 전체 소스를 확인해야 쉽게 이해가 쉽게 될 것 같다.
아래 예시 처럼 하나 만들어 두고 사용하는 것도 좋은 방법이 될 것 같다. 이 예제에서 수정할 부분은
billcode는 구글스토어에서 등록해 놓은 인앱 상품의 상품 ID가 되며, 아래 예시에는 firebase의 realtime database와 연동을 통해서 구매한 사용자의 목록을 관리하기 위한 billingUserBean 항목이 있으나, 이건 개발자의 선택에 따라 필요 없을 수 도 있다. 그런 부분을 제거해 버리면 그냥 그대로 사용할 수 있다.
import android.app.Activity;
import android.util.Log;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.android.billingclient.api.BillingClient;
import com.android.billingclient.api.BillingClientStateListener;
import com.android.billingclient.api.BillingFlowParams;
import com.android.billingclient.api.BillingResult;
import com.android.billingclient.api.ConsumeParams;
import com.android.billingclient.api.ConsumeResponseListener;
import com.android.billingclient.api.Purchase;
import com.android.billingclient.api.PurchasesUpdatedListener;
import com.android.billingclient.api.SkuDetails;
import com.android.billingclient.api.SkuDetailsParams;
import com.android.billingclient.api.SkuDetailsResponseListener;
import com.billcoreatech.dream314.R;
import com.billcoreatech.dream314.util.StringUtil;
import com.google.gson.JsonObject;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.List;
public class BillingManager<mConsumResListnere> implements PurchasesUpdatedListener {
String TAG = "BillingManager" ;
BillingClient mBillingClient ;
Activity mActivity ;
public List<SkuDetails> mSkuDetails ;
public enum connectStatusTypes { waiting, connected, fail, disconnected }
public connectStatusTypes connectStatus = connectStatusTypes.waiting ;
private ConsumeResponseListener mConsumResListnere ;
String billCode = "210420_onetime_pay" ; // 인앱결제 상품ID는 그때 그때 달라요
public BillingManager (Activity _activity) {
mActivity = _activity ;
mBillingClient = BillingClient.newBuilder(mActivity)
.setListener(this)
.enablePendingPurchases()
.build() ;
mBillingClient.startConnection(new BillingClientStateListener() {
@Override
public void onBillingSetupFinished(@NonNull BillingResult billingResult) {
Log.i(TAG, "respCode=" + billingResult.getResponseCode() ) ;
if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) {
connectStatus = connectStatusTypes.connected ;
Log.i(TAG, "connected...") ;
Log.i(TAG, "resp=" + mBillingClient.queryPurchases(billCode).getBillingResult()
+ "=" + mBillingClient.queryPurchases(billCode).getResponseCode());
getSkuDetailList() ;
} else {
connectStatus = connectStatusTypes.fail ;
Log.i(TAG, "connected... fail ") ;
}
}
@Override
public void onBillingServiceDisconnected() {
connectStatus = connectStatusTypes.disconnected ;
Log.i(TAG, "disconnected ") ;
}
});
mConsumResListnere = new ConsumeResponseListener() {
@Override
public void onConsumeResponse(@NonNull BillingResult billingResult, @NonNull String purchaseToken) {
if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) {
Log.i(TAG, "사용끝 + " + purchaseToken) ;
return ;
} else {
Log.i(TAG, "소모에 실패 " + billingResult.getResponseCode() + " 대상 상품 " + purchaseToken) ;
return ;
}
}
};
}
public int purchase(SkuDetails skuDetails) {
BillingFlowParams flowParams = BillingFlowParams.newBuilder()
.setSkuDetails(skuDetails)
.build();
return mBillingClient.launchBillingFlow(mActivity, flowParams).getResponseCode();
}
public void getSkuDetailList() {
List<String> skuIdList = new ArrayList<>() ;
skuIdList.add(billCode);
SkuDetailsParams.Builder params = SkuDetailsParams.newBuilder();
params.setSkusList(skuIdList).setType(BillingClient.SkuType.INAPP); // INAPP 가 인앱결제라는 구분임.
mBillingClient.querySkuDetailsAsync(params.build(), new SkuDetailsResponseListener() {
@Override
public void onSkuDetailsResponse(@NonNull BillingResult billingResult, @Nullable List<SkuDetails> skuDetailsList) {
if (billingResult.getResponseCode() != BillingClient.BillingResponseCode.OK) {
Log.i(TAG, "detail respCode=" + billingResult.getResponseCode()) ;
return ;
}
if (skuDetailsList == null) {
Toast.makeText(mActivity, mActivity.getString(R.string.msgNotInfo), Toast.LENGTH_LONG).show();
return ;
}
Log.i(TAG, "listCount=" + skuDetailsList.size());
for(SkuDetails skuDetails : skuDetailsList) {
Log.i(TAG, "\n" + skuDetails.getSku()
+ "\n" + skuDetails.getTitle()
+ "\n" + skuDetails.getPrice()
+ "\n" + skuDetails.getDescription()
+ "\n" + skuDetails.getFreeTrialPeriod()
+ "\n" + skuDetails.getIconUrl()
+ "\n" + skuDetails.getIntroductoryPrice()
+ "\n" + skuDetails.getIntroductoryPriceAmountMicros()
+ "\n" + skuDetails.getOriginalPrice()
+ "\n" + skuDetails.getPriceCurrencyCode()) ;
}
mSkuDetails = skuDetailsList ;
}
});
}
/**
* @param billingResult
* @param purchases
*/
@Override
public void onPurchasesUpdated(@NonNull BillingResult billingResult, @Nullable List<Purchase> purchases) {
userinfoDB = FirebaseDatabase.getInstance().getReference("UserInfoDB");
if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK && purchases != null) {
Log.i(TAG, "구매 성공>>>" + billingResult.getDebugMessage());
JSONObject object = null ;
String pID = "" ;
String pDate = "" ;
for(Purchase purchase : purchases) {
Log.i(TAG, "성공값=" + purchase.getPurchaseToken()) ;
Log.i(TAG, "성공값=" + purchase.getOriginalJson());
try {
object = new JSONObject(purchase.getOriginalJson());
pID = object.getString("purchaseToken");
pDate = StringUtil.getDate(object.getLong("purchaseTime")); // 날자를 구하기 위해서
} catch (JSONException e) {
e.printStackTrace();
}
Log.i(TAG, "token=" + pID + "" + pDate) ;
ConsumeParams params = ConsumeParams.newBuilder()
.setPurchaseToken(purchase.getPurchaseToken())
.build() ;
mBillingClient.consumeAsync(params, mConsumResListnere);
}
} else if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.USER_CANCELED) {
Log.i(TAG, "결제 취소");
} else {
Log.i(TAG, "오류 코드=" + billingResult.getResponseCode()) ;
}
}
}
이제 위에서 작성한 billingManager 을 호출하는 MainActivity의 예시를 보자
onCreate 등에서 미리 billingManger 에 대한 선언을 해 두고, 화면에서 버튼을 클릭했거나, 결제 메뉴를 선택했을 떄 아래 부분만 추가해 주면 결제 흐름은 끝이다.
BillingManager billingManager = new BillingManager(getActivity());
...
if(billingManager.connectStatus == BillingManager.connectStatusTypes.connected) {
Log.i(TAG, "connected ..") ;
SkuDetails skuDetails = (SkuDetails) billingManager.mSkuDetails.get(0);
int iResp = billingManager.purchase(skuDetails) ;
Log.i(TAG, "iResp=" + iResp) ;
}물론 이 예시는 1회성 인앱 결제의 흐름을 따라가는 예시 이므로 정기 결제 흐름에 대한 부분은 추가로 더 확인을 해야 할 필요가 있다. (정기결제 흐름을 만들다가 포기(?)한 이유는 정기결제 후에 결제 소모 구현을 해야 하는데, 잘 되지 않아서... ㅠㅠ;;)
이 처럼해 정리해 두면 차후에 라도 다른 거 인앱 결제를 구현할 때 복사해서 쓸 기본 소스는 준비하는 것이 될 것 같다.
p.s 2021.11.14 : 테스트는 어떻게 할 것 인가 ?
이번에 수정을 하면서 느낀건... 자주 해 봐야 한다는 것이다. 이것도 오랜만에 한번 해 볼려 했더니... 기억 가물거린다.
아무튼... 추가해서 적어 두어야 하는 것은 테스트 하려면...
1. 먼저 playstore 에 앱을 게시하자... 단, 내부테스트 까지만, 게시하면 가능 하다. 물론 테스트 해야 하는 이메일 계정은 등록을 해 두어야 한다.
2. 다음은 인앱 상품을 만들어 두어야 한다. 그것이 등록하면 바로 테스트를 할 수 있는 게 아니라서, 등록하고 나서 하루쯤 지나서 테스트 한다고 생각하는 것이 마음편하다. 그래서 인앱 상품을 개발할 꺼라면, 미리 테스트 앱을 게시하고, 인앱상품도 만들어 등록해 두어야 한다.
3. 테스트는 어떻게 할 것인가 ? AVD 에서 테스트를 해도 되기는 하지만, 이번에 하다 보니, AVD에 계정을 2개 등록했다고 하면, 테스트 계정으로 2개다 등록을 해야 하는지 확인이 필요해 보인다. 그래서 깔끔하게 AVD 을 밀어버리고 계정은 한개만 등록된 상태에서 테스트를 진행 했다.
이런 정도의 주의 사항을 상기 시키기 위해 추가 글을 적는다. 이만...
p.s 2022.02.27 구매 소모에 대한 추가 글.
구매 소모를 설정할 때 Token 을 전달해 주어야 하는데, 아래 처럼 전달해 주어야 1회성 구매에 대한 소모가 정상적으로 처리 되는 것을 확인할 수 있었다.
ConsumeParams params = ConsumeParams.newBuilder()
.setPurchaseToken(purchase.getPurchaseToken())
.build() ;
mBillingClient.consumeAsync(params, mConsumResListnere);
끝.
오늘의 이야기
Kotlin Multiplatform 프로젝트를 위한 최신 업데이트 그아이와 친구들 ? Android Studio Meerkat | 2024.3.1 Patch 1 Build #AI-243.2497...
-
이전 글에서 정리할 것처럼 java에서 kotlin으로 이전을 했습니다. 그러고 나서 보기 시작했는 데, DefaultSharedPrefernces의 사용할 수 없는 환경으로 변경이 된 것을 알게 되었습니다. 이전 prefs = Prefere...

