알림수집기 사용자 설명서

휴대폰에 도착한 카드·은행·보험 알림과 문자를 자동으로 읽어서, 날짜별 지출 예정 금액을 오늘 할일(Todo)에 정리해 주고, 필요하면 카카오톡으로 전달해 주는 앱입니다.

※ 이 설명서의 화면은 이해를 돕기 위해 만든 가상의 예시 데이터(OO카드, OO은행 등)로 촬영했습니다. 실제 개인 정보는 포함되어 있지 않습니다.

1. 이 앱이 하는 일

카드 승인, 은행 출금 예정, 보험료 자동이체, 대출 이자 납부 안내처럼 돈이 나갈 것으로 보이는 알림은 매번 확인하기가 번거롭고 쉽게 놓칩니다. 알림수집기는 다음 순서로 이를 대신해 줍니다.

  1. 휴대폰에 알림(또는 문자)이 도착하면 앱이 내용을 읽습니다.
  2. 미리 등록해 둔 점검 문자열(예: OO카드, 월세)이 들어 있는 알림만 골라냅니다.
  3. 알림 속에서 날짜 · 금액 · 한 줄 요약을 뽑아 Todo 목록에 넣습니다.
  4. 설정에 따라 같은 알림을 카카오톡 메시지로 전달합니다.

하단 메뉴는 다음 여섯 개입니다.

메뉴 용도
Todo 선택한 날짜(기본은 오늘)의 지출 할일을 체크하며 관리합니다.
월간목록 달력으로 날짜별 지출 합계를 보고, 날짜를 누르면 그날의 Todo로 이동합니다.
수신목록 앱이 받은 알림의 이력을 보고 검색하며, 다시 분석할 수 있습니다.
매월 반복 월세·관리비처럼 매달 같은 날 나가는 지출을 미리 등록합니다.
점검문자열 어떤 알림을 수집·전달할지 단어를 등록합니다.
설정 카카오톡 전달, AI 분석, 알림 시간, 백업/복원 등을 설정합니다.

2. 처음 시작하기 (로그인과 알림 권한)

① 카카오 로그인

앱을 처음 열면 로그인 화면이 나옵니다. 카카오 로그인 버튼을 눌러 로그인하세요. 알림을 카카오톡으로 전달하는 기능(나에게 보내기 / 친구에게 보내기)을 쓰려면 로그인이 필요합니다.

카카오 로그인 화면
카카오 로그인 화면

② 알림 읽기 권한 허용

이 앱은 휴대폰에 도착하는 알림을 읽어야 동작합니다. 설정 화면의 처음 사용하시나요? 이용 안내 보기를 누르면 단계별 안내가 나오고, 첫 단계의 알림 접근 설정 버튼으로 시스템 설정 화면으로 이동할 수 있습니다.

이용 안내 1단계
이용 안내 1단계
알림 읽기 권한 화면
알림 읽기·답장하기·관리하기

시스템 화면에서 알림수집기를 찾아 스위치를 허용으로 바꿉니다. 이 권한이 없으면 알림을 전혀 수집할 수 없습니다.

알림이 표시되지 않거나 수집이 안 될 때 Android 13 이상에서는 "알림 표시" 권한도 필요합니다. 설정 화면의 알림 상태 안내(알림 수집 / 알림 표시 / 앱 알림 사용)를 확인하고, 안내에 따라 권한을 허용해 주세요. 휴대폰의 절전 기능이 앱을 종료시키지 않도록 배터리 사용 제한도 해제해 두면 안정적입니다.

3. 점검 문자열 등록하기 (가장 중요!)

앱은 점검 문자열로 등록된 단어가 들어 있는 알림만 처리합니다. 등록한 단어가 없으면 아무 알림도 Todo에 들어가지 않으니 가장 먼저 설정해 주세요. 카드사·은행·보험사 이름이나 "월세", "관리비" 같은 단어를 등록하면 됩니다.

점검문자열 목록
점검문자열 목록

목록의 각 줄은 이렇게 읽습니다.

항목 설명
추출단어 (사용여부) 알림에서 찾을 단어입니다. 체크를 끄면 그 단어는 쓰지 않습니다.
카카오톡친구 (전송여부) 체크한 단어만 카카오톡으로 전달됩니다. 체크하지 않으면 Todo에는 들어가지만 카카오톡으로는 보내지 않습니다. 친구를 지정하지 않으면 "나에게 보내기"로 전달됩니다.
휴지통 아이콘 그 단어를 삭제합니다.
  • 왼쪽 위의 + 버튼으로 새 단어를 추가하고, 줄을 눌러 수정합니다.
  • 재활용 아이콘은 전체 단어 보기와 사용 중인 단어만 보기를 전환합니다.
  • 월급처럼 들어오는 돈(입금)으로 등록한 단어는 지출 목록(Todo/월간목록)에 나타나지 않습니다.
등록 요령 알림 맨 앞에 붙는 이름([OO카드] 등)이나 "월세"처럼 그 알림에만 나오는 짧은 단어가 좋습니다. 너무 흔한 단어(예: "원", "안내")는 광고나 상관없는 알림까지 잡을 수 있으니 피하세요.

4. Todo : 오늘 할일 확인하기

앱을 열면 가장 먼저 보이는 화면입니다. 오늘 날짜에 나갈 지출이 할일 목록으로 정리됩니다.

오늘 Todo
오늘의 Todo
다른 날짜의 Todo
다른 날짜의 Todo
항목 직접 추가
항목 직접 추가
  • 남은 할일 N건 · 합계금액 : 아직 확인하지 않은 항목의 건수와 금액 합계입니다.
  • 체크박스를 누르면 "확인 완료"로 저장되어 아래쪽 확인 완료 구역으로 내려가고 줄이 그어집니다. 다시 누르면 되돌아옵니다.
  • ← → 화살표로 이전 날짜 / 다음 날짜를 볼 수 있고, 오늘이 아닌 날짜를 보고 있으면 오늘 버튼이 나타납니다.
  • 오른쪽 아래 카드 + 아이콘은 알림에 없는 지출을 직접 추가하는 버튼입니다. 거래일자, 거래금액, 적요를 입력하고 저장(디스크) 아이콘을 누르세요. 입력 화면의 출금확인을 체크하면 이미 처리한 항목으로 저장됩니다.
  • 항목을 눌러 날짜·금액·적요를 고칠 수 있습니다.

항목 이름에는 보통 OO카드 카드승인 15,800원 (10/25) 맛있는식당처럼 단어 · 종류 · 금액 · 날짜 · 가게 이름이 한 줄 요약으로 들어갑니다. 날짜가 알림에 적혀 있지 않으면 알림을 받은 날로 들어가고 "(10/25 수신일)"처럼 표시됩니다.

5. 월간목록 : 한 달 지출 한눈에 보기

달력에 날짜별 지출 합계가 표시됩니다. 오늘 날짜는 보라색으로 강조되고, 화면 맨 아래 숫자는 이번 달 합계입니다. 좌우 화살표로 달을 바꿉니다.

월간목록 달력
월간목록
날짜를 누르면 이동
날짜(27일)를 누르면 그날의 Todo로 이동

달력의 날짜를 누르면 그 날짜의 Todo 화면으로 바로 이동합니다. 하단의 Todo 메뉴를 누르면 항상 오늘로 돌아옵니다.

6. 수신목록 : 받은 알림 이력과 다시 분석

앱이 받은 알림 원문이 최신순으로 쌓입니다. 위쪽 검색창에서 단어로 찾을 수 있습니다.

수신목록
수신목록
  • 각 항목 오른쪽 위 새로고침 아이콘을 누르면 그 알림 하나를 다시 분석해서 Todo에 반영합니다. 이때는 카카오톡 메시지를 다시 보내지 않습니다.
  • 맨 아래 줄에는 매칭된 단어와 날짜가 표시됩니다.

7. 매월 반복 : 고정 지출 등록하기

월세, 관리비, 보험료처럼 매달 같은 날 나가는 지출은 알림이 오지 않아도 놓치지 않도록 미리 등록할 수 있습니다.

매월 반복 목록
매월 반복 목록
  • 목록에는 일자 · 금액 · 적요가 보입니다. 왼쪽 위 + 버튼으로 추가하고, 휴지통으로 삭제합니다.
  • 달력 아이콘을 누르면 이번 달 항목을 해당 날짜의 지출 목록에 반영할지 물어봅니다.

8. 설정 화면

설정 화면 위쪽
설정 (위쪽)
설정 화면 아래쪽
설정 (아래쪽)

상단 아이콘 : 백업과 복원

화면 맨 위의 아이콘 세 개는 데이터를 안전하게 지키는 기능입니다.

아이콘 기능
구름 + 위 화살표 백업 : 현재 데이터를 파일로 저장합니다.
시계 + 되돌림 화살표 복원 : 저장해 둔 백업으로 되돌립니다. (확인 창이 나옵니다)
디스크 저장 : 현재 설정을 저장합니다.

백업 파일은 휴대폰의 Android/data/com.nari.notify2kakao/files/Download/notify2Kakao.sqlite 에 저장됩니다. 폰을 바꾸거나 앱을 다시 설치하기 전에 꼭 백업해 두세요.

첫 번째 박스 : 안내와 계정 버튼

버튼 기능
사용자 매뉴얼 열기 이 설명서를 엽니다.
처음 사용하시나요? 이용 안내 보기 알림 권한 허용, 점검 문자열 등록 등 3단계 안내를 보여 줍니다.
카카오 계정 동의 철회 / 연결 해제하기 카카오 계정과 앱의 연결을 해제하거나, 개인정보 제공 동의를 철회하는 카카오 페이지로 이동합니다.
수신 이력 다시 분석해서 누락된 지출 채우기 최근 받은 알림 중 Todo에 빠진 지출을 찾아 채웁니다. 카카오톡은 다시 보내지 않습니다.

카카오톡 사용 / AI 사용

  • 카카오톡 사용 : 켜면 점검 문자열 중 "전송여부"에 체크된 단어가 들어간 알림을 카카오톡으로 전달합니다. 끄면 Todo 정리만 하고 카카오톡은 보내지 않습니다.
  • Groq AI 사용 : 알림 문구가 규칙만으로 분석되지 않을 때 AI에게 날짜·금액을 읽게 합니다. 사용량 제한으로 느리거나 실패할 수 있으니, 불편하면 꺼 두세요.

일일 알림과 월간 시작일

  • 일일 알림 상태 : 매일 정해진 시각에 오늘 할일이 있으면 알려 주는 알림의 예약 상태입니다. 새로고침 아이콘으로 다시 확인합니다.
  • 월간시작일자 : 매월 조회를 시작할 날짜(1~31)입니다.
  • 알람시간 : 매일 알림을 받을 시:분입니다.

9. 알림은 이렇게 분석됩니다

앱이 알림에서 날짜와 금액을 찾는 규칙을 알아 두면 결과를 이해하기 쉽습니다.

  • 금액 : "원"이 붙은 숫자만 금액으로 봅니다. 계좌번호, 승인번호, 시각, 퍼센트(%) 같은 숫자는 금액으로 착각하지 않습니다.
  • 날짜 : "10월 25일", "10/25", "2026.10.25" 같은 표기를 읽습니다. "납입일", "결제일", "출금예정일" 같은 말 옆의 날짜를 우선하고, "기준일", "대출일"처럼 참고용 날짜나 "5일전" 같은 상대 표현은 지출일로 쓰지 않습니다.
  • 날짜가 없을 때 : 알림을 받은 날짜로 넣고 항목에 "수신일"이라고 표시합니다.
  • 건너뛰는 알림 : 광고, 수신거부 안내, 혜택 내역처럼 지출과 관련 없는 알림은 처리하지 않습니다.
  • 중복 방지 : 같은 날짜·금액·단어의 알림이 여러 번 와도 한 번만 등록합니다. 같은 미납 안내가 반복돼도 아직 확인하지 않은 항목이 있으면 새로 만들지 않습니다.
  • 입금 : 입금으로 등록한 단어는 지출 목록에 넣지 않습니다.

10. 자주 묻는 질문

Q. 알림은 왔는데 Todo에 없어요.

① 그 알림에 들어 있는 단어가 점검 문자열에 등록되어 있고 "사용여부"가 켜져 있는지, ② 알림 접근 권한이 허용되어 있는지 확인하세요. 그래도 빠졌다면 설정의 수신 이력 다시 분석해서 누락된 지출 채우기를 눌러 보세요.

Q. 카카오톡으로 전달이 안 돼요.

설정에서 카카오톡 사용이 켜져 있어야 하고, 해당 단어의 전송여부가 체크되어 있어야 합니다. 카카오 로그인이 풀렸다면 다시 로그인해 주세요. 인터넷이 꺼진 상태(비행기 모드)에서는 전달되지 않습니다.

Q. 금액이나 날짜가 다르게 들어갔어요.

Todo에서 해당 항목을 눌러 직접 고칠 수 있습니다. 같은 문구가 반복해서 틀린다면 알림 문구를 개발자에게 알려 주시면 분석 규칙을 개선할 수 있습니다. (개인정보는 지우고 보내 주세요.)

Q. 폰을 바꾸거나 앱을 다시 설치하면 데이터가 사라지나요?

앱을 지우면 앱 안의 데이터도 함께 지워집니다. 삭제 전에 설정 상단의 백업을 하고, 설치 후 복원을 누르면 되돌릴 수 있습니다. 백업 파일은 별도로 보관해 두세요.

Q. 개인정보는 어디로 전송되나요?

알림 내용은 휴대폰 안에 저장됩니다. 카카오톡 전달을 켠 경우 해당 알림이 카카오톡으로, AI 사용을 켠 경우 분석에 필요한 문구가 AI 서비스로 전송됩니다. 원치 않으면 설정에서 각각 끌 수 있고, 카카오 계정 연결은 언제든 해제하거나 동의를 철회할 수 있습니다.

Q. 아무에게나 전송할 수 있나요?

카카오톡에 친구 등록이 되어 있는 경우에 한하여 전달할 수 있습니다. 다만, 카카오톡은 사용자에게 앱별 사용인증을 하도록 하고 있어서 전달 받을 친구도 이앱을 설치하고 로그인 과정을 거쳐 앱의 사용허가를 진행하여야 합니다.  그런 경우만 카카오톡 전달할 수 있는 친구 목록에 나타나고 선택할 수 있습니다.  


알림수집기 Ver.5.4.8 기준

반응형

Eclipse에서 Javadoc Export 시 한글 인코딩 오류 해결 방법

encoding

 

Eclipse에서 Javadoc을 생성하려고 할 때 소스 코드에 포함된 한글 때문에 오류가 발생하는 경우가 있습니다. 대부분은 Javadoc 생성 과정에서 소스 파일의 인코딩을 올바르게 인식하지 못해 발생하는 문제입니다. 이번 글에서는 Eclipse에서 Javadoc Export 시 발생하는 한글 인코딩 오류를 해결하는 방법을 정리해 보겠습니다.


1. Javadoc 생성 시 인코딩 옵션 지정하기

가장 많이 사용하는 방법으로, Javadoc 생성 옵션에 인코딩 정보를 직접 지정하는 것입니다.

  1. Project → Generate Javadoc 또는 Export → Java → Javadoc 선택
  2. Extra Javadoc Options 항목에 아래 옵션 추가
-encoding UTF-8 -charset UTF-8 -docencoding UTF-8

각 옵션의 의미는 다음과 같습니다.

  • -encoding : 소스 파일을 읽을 때 사용할 인코딩
  • -charset : 생성되는 HTML 문서의 문자셋
  • -docencoding : 생성되는 문서 파일의 인코딩

2. 프로젝트 인코딩 확인하기

프로젝트 자체가 UTF-8이 아닌 다른 인코딩으로 설정되어 있다면 Javadoc 생성 시 문제가 발생할 수 있습니다.

확인 경로

프로젝트 우클릭
 └ Properties
     └ Resource
         └ Text file encoding

가능하면 UTF-8로 설정하는 것을 권장합니다.


3. Eclipse 워크스페이스 인코딩 확인하기

프로젝트가 워크스페이스 설정을 따르는 경우 워크스페이스 인코딩도 확인해야 합니다.

Window
 └ Preferences
     └ General
         └ Workspace
             └ Text file encoding

여기 역시 UTF-8로 설정되어 있는지 확인합니다.


4. JDK 환경에서 직접 확인하기

Eclipse 설정 문제인지, 실제 소스 파일 문제인지 구분하기 위해 명령행에서 직접 Javadoc을 생성해 볼 수 있습니다.

javadoc -encoding UTF-8 -charset UTF-8 -docencoding UTF-8 *.java

명령행에서는 정상적으로 생성되는데 Eclipse에서만 실패한다면 Eclipse 설정 문제일 가능성이 높습니다.


5. 자주 발생하는 오류 사례

Case 1. UTF-8로 읽는데 실제 파일은 MS949인 경우

unmappable character for encoding UTF8

실제 소스 파일은 MS949(EUC-KR)인데 Javadoc이 UTF-8로 읽고 있는 상황입니다.

-encoding MS949

옵션을 적용하거나 소스 파일을 UTF-8로 변환해야 합니다.

Case 2. MS949로 읽는데 실제 파일은 UTF-8인 경우

unmappable character for encoding MS949

소스 파일은 UTF-8인데 Javadoc이 MS949로 읽고 있는 상황입니다.

-encoding UTF-8

옵션을 변경하여 해결할 수 있습니다.


정리

Eclipse에서 Javadoc 생성 시 한글 인코딩 오류가 발생한다면 다음 순서로 확인해 보세요.

  1. Javadoc 옵션에 -encoding, -charset, -docencoding 지정
  2. 프로젝트 인코딩 확인
  3. 워크스페이스 인코딩 확인
  4. JDK 명령행에서 직접 Javadoc 생성 테스트
  5. 실제 소스 파일 인코딩(UTF-8 / MS949) 확인

대부분의 경우 UTF-8로 통일하고 아래 옵션을 지정하면 문제를 해결할 수 있습니다.

-encoding UTF-8 -charset UTF-8 -docencoding UTF-8
반응형

🎲 흔들어 주사위

폰을 흔들어 주사위를 굴리고, 뱀주사위 게임으로 승부를 겨뤄 보세요!

사용자 설명서

시작하기주사위 굴리기뱀주사위 게임랭킹자주 묻는 질문개인정보처리방침

1. 시작하기

앱을 열면 두 가지를 고를 수 있습니다.

  • 🎲 주사위 굴리기 – 폰을 흔들어 1~3개의 주사위를 굴립니다.
  • 🐍 뱀주사위 게임 – 사다리를 타고 뱀을 피해 100번 칸에 먼저 도착하는 게임입니다.

첫 화면 – 오른쪽 위에 앱 아이콘과 버전이 표시됩니다.

기본 조작
  • 화면 왼쪽 위의 ← 버튼(또는 폰의 뒤로가기)으로 이전 화면으로 돌아갑니다.
  • 첫 화면에서 뒤로가기를 누르면 종료 확인 창이 뜹니다. "종료"를 눌러야 앱이 꺼집니다.
  • 폰의 언어에 맞춰 한국어, English, 日本語, ภาษาไทย, Filipino로 자동 표시됩니다.

첫 화면에서 뒤로가기를 누르면 나오는 종료 확인 창

2. 주사위 굴리기

① 주사위 개수 고르기

1개, 2개, 3개 중에서 고릅니다.

주사위 개수 선택

② 흔들어서 굴리기

  • 폰을 흔들면 주사위가 튀어 오르며 굴러가다가 서서히 멈춥니다.
  • 굴러가는 동안 진동이 느껴지고, 멈추는 순간 묵직한 진동이 한 번 더 나옵니다.
  • 흔들기 대신 "굴리기" 버튼이나 주사위 영역을 눌러도 굴릴 수 있습니다.
  • 주사위가 2개 이상이면 합계가 크게 표시되고, 아래에 최근 8번의 합계가 왼쪽부터 오른쪽으로 쌓입니다.
  • 이 화면에서는 화면이 꺼지지 않습니다.

굴린 결과 – 주사위 2개의 합계가 표시됩니다.

3. 뱀주사위 게임

① 주사위 개수와 상대 고르기

먼저 한 번에 굴릴 주사위 개수(1~3개)를 고른 뒤, 플레이어를 고릅니다.

주사위 개수 선택

플레이어 선택

  • 1인 – 컴퓨터 상대와 번갈아 굴립니다. 상대 이름은 깨비, 여우, 돌이, 순이, 로봇 중에서 이 화면에 들어올 때마다 무작위로 정해지고, 게임이 끝날 때까지 유지됩니다.
  • 2인 – 준비 중입니다.
  • 🏆 랭킹 조회 – 지난 기록을 봅니다. (아래 "랭킹" 참고)

② 게임판 보기

게임판 – 왼쪽 아래 1번에서 시작해 지그재그로 올라가 왼쪽 위 100번이 목표입니다.

  • 시작: 판 아래 START 띠에서 두 말이 출발합니다. 빨간 말이 나, 파란 말이 상대입니다.
  • 사다리(파란 막대): 아래 칸에 도착하면 위쪽 끝으로 올라갑니다. ⬆
  • 뱀(초록): 뱀의 꼬리가 있는 칸에 도착하면 머리가 있는 아래 칸으로 미끄러집니다. ⬇
  • 그림 칸과 보너스 배지: 색깔 칸의 +1, +2 배지는 도착하면 그만큼 주사위를 더 굴린다는 뜻입니다.
  • 판은 매번 바뀝니다: 사다리와 뱀(각 6~8개)과 보너스 그림 칸의 위치가 게임을 시작할 때마다 새로 정해집니다.

③ 진행 방법

  1. "굴리기" 버튼을 누르거나 폰을 흔듭니다. 주사위가 굴러가는 동안 진동이 느껴집니다.
  2. 나온 눈의 합계만큼 내 말이 한 칸씩 움직입니다.
  3. 도착한 칸이 사다리나 뱀이면 그 자리에서 올라가거나 내려갑니다. 효과음도 서로 다릅니다.
  4. 내 차례가 끝나면 상대가 자동으로 굴립니다. 화면 아래 안내 문구에서 진행 상황을 볼 수 있습니다.
  5. 100번 칸에 도착하거나 넘어가면 그 즉시 승리합니다. (남은 눈은 버립니다.)

진행 중 화면 – 보너스 칸에 도착해서 한 번 더 굴릴 수 있습니다.

④ 보너스 굴림

그림 칸에 도착하면(사다리·뱀으로 이동한 뒤의 칸 기준) 그림에 따라 주사위를 더 굴립니다. 주사위 개수에 따라 횟수가 다릅니다.

주사위 🍀 🏆 🎲 ⭐ 🚀 🍎 🐶 🎈
1개 +4번 +2번 없음
2개 +2번 +1번 없음
3개 +1번 +1번 없음

내가 받은 보너스는 직접 굴려야 하고(남은 횟수가 표시됩니다), 상대도 같은 규칙으로 연속해서 굴립니다.

⑤ 게임이 끝나면

  • 내가 이기면 먼저 폭죽이 터지고, 이어서 승리 알림창이 나옵니다.
  • 상대가 이기면 바로 알림창이 나옵니다.
  • 알림창에는 내가 던진 횟수와 상대 이름이 나오고, 기록이 자동 저장됩니다. "확인"을 누르면 플레이어 선택 화면으로 돌아갑니다.
  • 게임 도중에 나가면 기록은 저장되지 않습니다.
  • 게임 중에는 화면이 꺼지지 않습니다.

4. 랭킹

플레이어 선택 화면에서 🏆 랭킹 조회를 누르면 볼 수 있습니다.

랭킹 화면 (예시 기록)

  • 요약: 총 판수, 승·패, 승률
  • 빠른 승리 TOP 5: 주사위를 적게 던져서 이긴 순서입니다. 같은 횟수면 먼저 달성한 기록이 위에 나옵니다.
  • 최근 기록: 최근 30판의 승/패, 던진 횟수, 상대, 날짜
기록은 내 폰 안에만 저장됩니다. 앱을 삭제하거나 앱 데이터를 지우면 함께 사라집니다.

5. 자주 묻는 질문

폰을 흔들어도 주사위가 안 굴러가요.

흔드는 세기가 약하면 인식되지 않을 수 있습니다. 조금 더 힘 있게 흔들어 보세요. 한 번 굴러가는 동안(약 3초)에는 다시 흔들어도 반응하지 않습니다. 센서가 없는 기기에서는 "굴리기" 버튼을 이용하세요.

진동이 느껴지지 않아요.

폰의 진동 설정(무음·진동 모드, 터치 진동 세기)을 확인해 주세요. 진동이 없는 기기에서는 화면 애니메이션만 나옵니다.

효과음이 안 들려요.

사다리·뱀·승리 효과음은 미디어 볼륨에 따라 나옵니다. 폰이 무음이거나 미디어 볼륨이 0이면 들리지 않습니다.

글자가 작게 보여요.

언어에 따라 문장이 길면 한 줄에 맞도록 글자가 자동으로 작아집니다. 폰의 글꼴 크기를 너무 크게 설정했다면 줄여 보세요.

랭킹 기록을 지우고 싶어요.

폰의 설정 → 앱 → 흔들어 주사위 → 저장공간 → 데이터 삭제를 하면 기록이 모두 지워집니다.

광고가 나와요.

화면 맨 아래에 작은 배너 광고가 표시됩니다. 광고 처리에 대한 내용은 개인정보처리방침을 참고하세요.

반응형

DevExtreme dxDataGrid Lookup 검색 기능과 Date 컬럼 입력 설정 방법

dxDataGrid

 

DevExtreme의 dxDataGrid를 사용하다 보면 자주 마주치는 기능 중 하나가 Lookup 컬럼 검색과 Date 컬럼 입력 처리입니다. 특히 Batch 편집 모드에서 사용자 입력 편의성을 높이려면 몇 가지 옵션을 추가로 설정하는 것이 좋습니다. 이번 글에서는 Lookup(콤보박스) 검색 활성화 방법과 Date 컬럼 입력 시 하이픈(-) 처리 방법을 정리해 보겠습니다.

dxDataGrid Lookup 컬럼에서 검색 기능 사용하기

dxDataGrid의 lookup 컬럼은 내부적으로 dxSelectBox를 사용합니다. 기본적으로 목록 선택만 가능하지만 searchEnabled 옵션을 설정하면 사용자가 직접 검색할 수 있습니다.


{
    dataField: "CategoryId",
    lookup: {
        dataSource: categoryList,
        valueExpr: "id",
        displayExpr: "name"
    },
    editorOptions: {
        searchEnabled: true,
        searchMode: "contains"
    }
}

하지만 편집 모드에 따라 editorOptions가 제대로 적용되지 않는 경우가 있습니다. 이럴 때는 onEditorPreparing 이벤트에서 설정하는 방법이 가장 안정적입니다.


onEditorPreparing: function(e) {
    if (e.parentType === "dataRow"
        && e.dataField === "CategoryId") {

        e.editorOptions.searchEnabled = true;
        e.editorOptions.searchMode = "contains";
        e.editorOptions.showClearButton = true;
    }
}

여러 필드를 대상으로 검색하기

Lookup 데이터가 많은 경우 품목코드와 품목명 등 여러 필드를 동시에 검색하도록 설정할 수 있습니다.


e.editorOptions.searchExpr = [
    "itemCode",
    "itemName"
];

이 설정을 적용하면 사용자는 코드나 이름 중 어느 것으로도 검색할 수 있어 사용성이 크게 향상됩니다.

dxDataGrid Date 컬럼 입력 시 하이픈(-)을 꼭 입력해야 할까?

Date 컬럼은 일반적으로 아래와 같이 정의합니다.


{
    dataField: "ORDER_DATE",
    dataType: "date"
}

많은 개발자들이 궁금해하는 부분은 사용자가 직접 날짜를 입력할 때 20250101 형태로 입력 가능한지 여부입니다.

결론부터 말하면 브라우저 환경과 DevExtreme 버전에 따라 인식 여부가 달라질 수 있습니다. 따라서 가장 안전한 입력 방식은 아래와 같습니다.


2025-01-01

Batch 모드에서 날짜 입력 편의성 높이기

Batch Editing 모드를 사용하는 경우 날짜 입력 편의를 위해 useMaskBehavior 옵션을 사용하는 것을 추천합니다.


{
    dataField: "ORDER_DATE",
    dataType: "date",
    format: "yyyy-MM-dd"
}

onEditorPreparing: function(e) {
    if (e.dataField === "ORDER_DATE") {
        e.editorOptions.useMaskBehavior = true;
    }
}

해당 옵션을 사용하면 날짜 입력 형식이 강제되어 사용자가 보다 쉽게 날짜를 입력할 수 있습니다.

실무에서 추천하는 설정

실무에서는 다음과 같은 조합을 자주 사용합니다.


{
    dataField: "ORDER_DATE",
    dataType: "date",
    format: "yyyy-MM-dd"
}

onEditorPreparing: function(e) {

    if (e.dataField === "ORDER_DATE") {
        e.editorOptions.useMaskBehavior = true;
    }

    if (e.dataField === "CategoryId") {
        e.editorOptions.searchEnabled = true;
        e.editorOptions.searchMode = "contains";
    }
}

정리

  • Lookup 컬럼 검색은 searchEnabled 속성으로 활성화 가능
  • onEditorPreparing 이벤트를 사용하면 적용 안정성이 높음
  • searchExpr 속성을 이용하여 여러 필드를 동시에 검색 가능
  • Date 컬럼은 'yyyy-MM-dd' 형식 입력이 가장 안전함
  • Batch 모드에서는 useMaskBehavior 사용을 권장

DevExtreme dxDataGrid를 사용할 때 Lookup 검색 기능과 Date 입력 마스킹만 적용해도 사용자 경험이 크게 향상됩니다. 특히 데이터 건수가 많거나 날짜 입력이 빈번한 화면에서는 필수적으로 고려해 볼 만한 설정입니다.

반응형

Orbit Raid 사용 설명서

우주 공간에서 몰려오는 벌레 편대를 격추하는 세로형 슈팅 게임입니다.
한 손가락으로 드래그해서 움직이고, 화면을 누르고 있는 동안 자동으로 발사합니다.

항목 내용
앱 이름 Orbit Raid
지원 환경 Android 8.0(API 26) 이상, 세로 화면 고정
조작 터치 드래그 (자동 발사)
소리 효과음 + BGM (앱 안에서 켜고 끌 수 있음)

1. 시작하기

앱을 실행하면 타이틀 화면이 나옵니다.

 

타이틀

 

  • 화면 어디든 탭하면 게임이 시작됩니다.
  • 타이틀 화면에는 적 4종이 소개되고, 아래쪽에 점수표가 표시됩니다.
  • 화면 왼쪽 위의 v.x.x.x는 현재 설치된 앱 버전이고, 오른쪽 위의 작은 아이콘은 앱 로고입니다. 둘 다 조작과 상관없이 표시만 됩니다.

2. 조작 방법

게임 시작 직후 화면입니다. 아래쪽에 내 우주선이 있습니다.

조작 방법

 

동작 방법
이동 화면을 누른 채 좌우로 드래그하면 우주선이 손가락의 가로 위치를 따라갑니다.
발사 화면을 누르고 있는 동안 자동으로 총알이 나갑니다. 따로 발사 버튼은 없습니다.
멈춤 손가락을 떼면 이동과 발사가 모두 멈춥니다.

손가락이 우주선을 가리지 않도록 화면 아래쪽 빈 곳을 누르고 움직이는 것을 추천합니다.

동작

위 화면은 누르고 있는 동안 파란 총알(시안색)이 위로 나가고, 적도 주황색 총알을 쏘는 모습입니다.


3. 화면 구성 (HUD)

화면 아래쪽 막대에 현재 상태가 표시됩니다.

표시 의미
SCORE 현재 점수
HI 최고 점수. 앱을 껐다 켜도 저장됩니다.
♥ ♥ ♥ 남은 목숨 (처음 3개)
SND ON / OFF 소리 켜기/끄기. 누를 때마다 전환됩니다.

4. 적과 점수

적은 4종류이고, 위쪽 줄일수록 점수가 높습니다.

적 색 점수 체력
보스 빨강 (나비 모양) 200 2번 맞혀야 격추 (체력 막대 표시)
중간 주황 100 1번
기본 파랑 80 1번
기본 초록 50 1번
  • 적이 대형에서 내려와 돌진(다이브) 하는 중에 격추하면 점수가 2배입니다.
  • 적은 내 우주선을 향해 총알을 쏩니다. 돌진 중인 적은 더 자주 쏩니다.
  • 적 총알이나 돌진하는 적에 닿으면 우주선이 폭발하고 목숨이 1개 줄어듭니다.

5. 목숨과 부활

우주선이 맞으면 SHIP DESTROYED! 가 표시되고 잠시 뒤 화면 중앙 아래에서 다시 시작합니다.

수명 과 부활

 

  • 부활한 직후 약 2초 동안은 무적이라 우주선이 깜빡입니다. 이 시간에 자리를 잡으세요.
  • 화면이 정리되면서 날아오던 총알은 모두 사라집니다.

6. 스테이지

한 화면의 적을 모두 격추하면 LEVEL n CLEAR! 가 나오고 다음 스테이지로 넘어갑니다.

  • 스테이지가 올라갈수록 적이 더 많아지고(최대 5줄 × 12열), 더 빨라지고, 총알도 더 자주 빠르게 날아옵니다.
  • 돌진하는 적의 수와 빈도도 늘어납니다.

7. 게임 오버

목숨을 모두 잃으면 게임 오버 화면이 나옵니다.

  • 이번 판의 점수가 표시됩니다. 최고 점수를 넘겼다면 HI 가 갱신되어 저장됩니다.
  • 화면을 탭하면 처음부터 다시 시작합니다.

8. 게임 종료

게임 중에 기기의 뒤로 가기를 누르면 종료 확인 창이 나옵니다. 이 동안 게임은 일시정지됩니다.

종료 확인

 

  • 취소: 게임으로 돌아갑니다.
  • 종료: 앱을 닫습니다.

앱을 홈 버튼 등으로 백그라운드로 보내면 BGM이 멈추고, 돌아오면 이어집니다.


9. 소리

  • 효과음: 발사, 적 피격, 적 격추, 내 기체 폭발, 게임 시작, 스테이지 클리어, 게임 오버
  • BGM: 게임이 시작되면 재생되고, 게임 오버가 되면 멈춥니다.
  • 화면 아래 오른쪽 SND ON / OFF 를 눌러 효과음과 BGM을 한꺼번에 켜고 끌 수 있습니다.
  • 소리의 크기는 기기의 미디어 볼륨을 따릅니다. 소리가 안 들리면 기기 볼륨과 무음 모드를 확인하세요.

10. 업데이트와 리뷰 요청

  • 앱 업데이트: Google Play에 새 버전이 있으면 백그라운드에서 내려받고, 준비가 끝나면 새 버전이 준비되었습니다 창이 뜹니다. 지금 재시작을 누르면 새 버전으로 바뀝니다.
  • 리뷰 요청: 여러 번 실행한 뒤 Google Play 평가 창이 한 번 나올 수 있습니다. 건너뛰어도 게임에는 영향이 없습니다.

11. 공략 팁

  1. 적의 빨간 보스는 두 번 맞혀야 하니, 같은 자리에서 연속으로 쏘면 효율적입니다.
  2. 돌진 중인 적은 점수가 2배입니다. 위험하지만 노릴 가치가 있습니다.
  3. 적 총알은 내 위치를 향해 날아옵니다. 한자리에 오래 머물지 말고 좌우로 계속 움직이세요.
  4. 부활 직후의 무적 시간에는 적 대형 바로 아래까지 올라가 공격할 수 있습니다.
  5. 스테이지 후반에는 가장자리보다 화면 중앙 근처가 양쪽으로 피하기 쉽습니다.

12. 자주 묻는 질문

Q. 발사 버튼이 없어요.
A. 화면을 누르고 있는 동안 자동으로 발사됩니다. 손가락을 떼면 멈춥니다.

Q. 최고 점수가 사라졌어요.
A. 앱 데이터를 삭제하거나 앱을 지웠다가 다시 설치하면 초기화됩니다.

Q. 소리가 안 나요.
A. 화면 아래 SND 가 ON 인지, 기기 미디어 볼륨과 무음 모드가 꺼져 있는지 확인하세요.

Q. 가로 화면으로 할 수 있나요?
A. 세로 화면 전용입니다.


이 문서의 스크린샷은 Android 에뮬레이터(Pixel 9, Android 15)에서 촬영했습니다.

 

오빗 레이드 (Orbit Raid) - Google Play 앱

 

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

 

play.google.com

 

orbit raid (짭 갤러그) 슈팅 게임 #레트로 #게임

 

반응형

<오빗레이드(Orbit Raid):  이하 사용자앱으로 표시 >:는) 「개인정보 보호법」 제30조에 따라 정보주체의 개인정보를 보호하고 이와 관련한 고충을 신속하고 원활하게 처리할 수 있도록 하기 위하여 다음과 같이 개인정보 처리방침을 수립·공개합니다.
○ 이 개인정보처리방침은 2022년 4월 1부터 적용됩니다.
 
제1조(개인정보의 처리 목적)

<사용자 앱>은(는) 다음의 목적을 위하여 개인정보를 처리합니다. 처리하고 있는 개인정보는 다음의 목적 이외의 용도로는 이용되지 않으며 이용 목적이 변경되는 경우에는 「개인정보 보호법」 제18조에 따라 별도의 동의를 받는 등 필요한 조치를 이행할 예정입니다.

 
○ 제공되는 앱의 사용자 확인을 위해서 만 사용 됩니다.

제2조(개인정보의 처리 및 보유 기간)

① <사용자 앱>은(는) 법령에 따른 개인정보 보유·이용기간 또는 정보주체로부터 개인정보를 수집 시에 동의받은 개인정보 보유·이용기간 내에서 개인정보를 처리·보유합니다.

② 각각의 개인정보 처리 및 보유 기간은 다음과 같습니다.

  • 1.<앱 사용자 회원가입 및 관리>
  • <앱 사용자 회원가입 및 관리>와 관련한 개인정보는 수집.이용에 관한 동의일로부터<이 앱의 사용기간 동안>까지 위 이용목적을 위하여 보유.이용됩니다.
  • 보유근거 : 이 앱의 사용자 확인을 위해서 사용 됩니다.
  • 2 <앱 사용자 위치정보 : 백그라운드 위치 포함>
  • 백그라운드 위치정보 : 이 앱의 기본 기능인 도착 알림 발송등을 위해서 백그라운드 에서 위치 정보를 활용 합니다.  사용자의 위치 정보는 앱의 기능 활용을 위해서만 사용 되며, 앱 외부로 전송 하지 않습니다.
  • 3 <Health Data 의 사용>
  • 이 앱은 사용자의 폰이나, 워치를 통해 사용자의 Health Data 가 측정 됩니다. 측정된 Health Data 는 이 앱에서 사용자의 신체상태을 표시 하는 데만 사용 됩니다. 
  • 측정된 Health Data 을 앱 이외의 공간(온라인전송 포함)으로 복사 되거나, 제공 되지 않습니다.

제3조(개인정보의 제3자 제공)

① <사용자 앱>은(는) 개인정보를 제1조(개인정보의 처리 목적)에서 명시한 범위 내에서만 처리하며, 정보주체의 동의, 법률의 특별한 규정 등 「개인정보 보호법」 제17조 및 제18조에 해당하는 경우에만 개인정보를 제3자에게 제공합니다.
② < billcorea >은(는) 다음과 같이 개인정보를 제3자에게 제공하지 않습니다.
 
제4조(개인정보처리 위탁)

① <사용자 앱>은(는) 원활한 개인정보 업무처리를 위하여 다음과 같이 개인정보 처리업무를 위탁하지 않습니다.
②  위탁업무의 내용이나 수탁자가 변경될 경우에는 지체없이 본 개인정보 처리방침을 통하여 공개하도록 하겠습니다.

제5조(정보주체와 법정대리인의 권리·의무 및 그 행사방법)
① 정보주체는 우리집에 대해 언제든지 개인정보 열람·정정·삭제·처리정지 요구 등의 권리를 행사할 수 있습니다.
② 제1항에 따른 권리 행사는우리집에 대해 「개인정보 보호법」 시행령 제41조제1항에 따라 서면, 전자우편, 모사전송(FAX) 등을 통하여 하실 수 있으며 우리집은(는) 이에 대해 지체 없이 조치하겠습니다.
③ 제1항에 따른 권리 행사는 정보주체의 법정대리인이나 위임을 받은 자 등 대리인을 통하여 하실 수 있습니다.이 경우 “개인정보 처리 방법에 관한 고시(제2020-7호)” 별지 제11호 서식에 따른 위임장을 제출하셔야 합니다.
④ 개인정보 열람 및 처리정지 요구는 「개인정보 보호법」 제35조 제4항, 제37조 제2항에 의하여 정보주체의 권리가 제한 될 수 있습니다.
⑤ 개인정보의 정정 및 삭제 요구는 다른 법령에서 그 개인정보가 수집 대상으로 명시되어 있는 경우에는 그 삭제를 요구할 수 없습니다.
⑥ billcorea 은(는) 정보주체 권리에 따른 열람의 요구, 정정·삭제의 요구, 처리정지의 요구 시 열람 등 요구를 한 자가 본인이거나 정당한 대리인인지를 확인합니다.


제6조(처리하는 개인정보의 항목 작성)
①<사용자 앱>은(는) 다음의 개인정보 항목을 처리하고 있습니다.

  • 이 앱은 개인정보를 수집하지 않습니다.

 
제7조(개인정보의 파기)

① <사용자 앱> 은(는) 개인정보 보유기간의 경과, 처리목적 달성 등 개인정보가 불필요하게 되었을 때에는 지체없이 해당 개인정보를 파기합니다.

② 정보주체로부터 동의받은 개인정보 보유기간이 경과하거나 처리목적이 달성되었음에도 불구하고 다른 법령에 따라 개인정보를 계속 보존하여야 하는 경우에는, 해당 개인정보를 별도의 데이터베이스(DB)로 옮기거나 보관장소를 달리하여 보존합니다.
1. 법령 근거 : 관련법규 적용 사항 없음
2. 보존하는 개인정보 항목 : 없음

③ 개인정보 파기의 절차 및 방법은 다음과 같습니다.
1. 파기절차
<사용자 앱> 은(는) 파기 사유가 발생한 개인정보를 선정하고, < billcorea > 의 개인정보 보호책임자의 승인을 받아 개인정보를 파기합니다.
2. 앱의 사용자 설정에서 로그인 관련 정보 삭제 버튼을 이용하여 삭제 할 수 있습니다.

제8조(개인정보의 안전성 확보 조치)

<사용자 앱>은(는) 개인정보의 안전성 확보를 위해 다음과 같은 조치를 취하고 있습니다.

1. 내부관리계획의 수립 및 시행
개인정보의 안전한 처리를 위하여 내부관리계획을 수립하고 시행하고 있습니다.

2. 개인정보에 대한 접근 제한
개인정보를 처리하는 데이터베이스시스템에 대한 접근권한의 부여,변경,말소를 통하여 개인정보에 대한 접근통제를 위하여 필요한 조치를 하고 있으며 침입차단시스템을 이용하여 외부로부터의 무단 접근을 통제하고 있습니다.

3. 비인가자에 대한 출입 통제
개인정보를 보관하고 있는 물리적 보관 장소를 별도로 두고 이에 대해 출입통제 절차를 수립, 운영하고 있습니다.

제9조(개인정보 자동 수집 장치의 설치•운영 및 거부에 관한 사항)
<사용자 앱> 은(는) 정보주체의 이용정보를 저장하고 수시로 불러오는 ‘쿠키(cookie)’를 사용하지 않습니다.
 
제10조 (개인정보 보호책임자)
① <사용자 앱> 은(는) 개인정보 처리에 관한 업무를 총괄해서 책임지고, 개인정보 처리와 관련한 정보주체의 불만처리 및 피해구제 등을 위하여 아래와 같이 개인정보 보호책임자를 지정하고 있습니다.

  • ▶ 개인정보 보호책임자
  • 성명 :강동엽
  • 직책 : manager
  • 직급 : manager
  • 연락처 : 0504-0662-8122, help@billcorea.com

※ 개인정보 보호 담당부서로 연결됩니다.

  • ▶ 개인정보 보호 담당부서
  • 부서명 : manager
  • 담당자 : 강동엽
  • 연락처 :  0504-0662-8122, help@billcorea.com

② 정보주체께서는 우리집 의 서비스(또는 사업)을 이용하시면서 발생한 모든 개인정보 보호 관련 문의, 불만처리, 피해구제 등에 관한 사항을 개인정보 보호책임자 및 담당부서로 문의하실 수 있습니다. 우리집 은(는) 정보주체의 문의에 대해 지체 없이 답변 및 처리해드릴 것입니다.
 
제11조(개인정보 열람청구)
정보주체는 「개인정보 보호법」 제35조에 따른 개인정보의 열람 청구를 아래의 부서에 할 수 있습니다.
<사용자 앱>은(는) 정보주체의 개인정보 열람청구가 신속하게 처리되도록 노력하겠습니다.

  • ▶ 개인정보 열람청구 접수·처리 부서
  • 부서명 : manager
  • 담당자 : 강동엽
  • 연락처 :  0504-0662-8122, help@billcorea.com


제12조(권익침해 구제방법)
정보주체는 개인정보침해로 인한 구제를 받기 위하여 개인정보분쟁조정위원회, 한국인터넷진흥원 개인정보침해신고센터 등에 분쟁해결이나 상담 등을 신청할 수 있습니다. 이 밖에 기타 개인정보침해의 신고, 상담에 대하여는 아래의 기관에 문의하시기 바랍니다.

1. 개인정보분쟁조정위원회 : (국번없이) 1833-6972 (www.kopico.go.kr)
2. 개인정보침해신고센터 : (국번없이) 118 (privacy.kisa.or.kr)
3. 대검찰청 : (국번없이) 1301 (www.spo.go.kr)
4. 경찰청 : (국번없이) 182 (ecrm.cyber.go.kr)

「개인정보보호법」제35조(개인정보의 열람), 제36조(개인정보의 정정·삭제), 제37조(개인정보의 처리정지 등)의 규정에 의한 요구에 대 하여 공공기관의 장이 행한 처분 또는 부작위로 인하여 권리 또는 이익의 침해를 받은 자는 행정심판법이 정하는 바에 따라 행정심판을 청구할 수 있습니다.

※ 행정심판에 대해 자세한 사항은 중앙행정심판위원회(www.simpan.go.kr) 홈페이지를 참고하시기 바랍니다.

제13조(개인정보 처리방침 변경)

① 이 개인정보처리방침은 2026년 10월 10부터 적용됩니다.
 
부칙 <개인정보 처리 지침의 개정>
* 이 지침은 필요에 따라 갱신 되며, 사용자에게 개별 통지는 되지 않습니다.
 

반응형

PC 에서

 

🚀 오빗레이드 슈팅 게임

 

🚀 오빗레이드 슈팅 게임

 

gyeokchimjeon0913.web.app

 

모바일에서

 

🚀 오빗레이드 슈팅 게임 (모바일)

 

🚀 오빗레이드 슈팅 게임 (모바일)

 

gyeokchimjeon0913.web.app

 

반응형

DevExtreme dxDataGrid CustomStore 사용 시 자주 묻는 질문 정리

dxdatagrid

 

DevExtreme의 dxDataGrid를 사용하면서 가장 많이 접하는 질문 중 하나가 Pager의 전체 건수 설정, Loading Panel 제어, 그리고 수정된 데이터 반영 방법입니다. 이번 글에서는 실제 개발 과정에서 자주 사용하는 방법들을 정리해 보겠습니다.


1. dxDataGrid Pager의 전체 건수(totalCount) 설정하기

CustomStore를 사용할 경우 Pager는 데이터 개수를 자동으로 알 수 없습니다. 따라서 load 함수에서 반드시 totalCount 값을 함께 반환해야 합니다.


const store = new DevExpress.data.CustomStore({
    key: "id",

    load: function(loadOptions) {

        return $.ajax({
            url: "/api/users",
            data: {
                skip: loadOptions.skip,
                take: loadOptions.take
            }
        }).then(function(result) {

            return {
                data: result.items,
                totalCount: result.totalCount
            };
        });
    }
});

서버 응답 예시입니다.


{
    "items": [
        { "id": 1, "name": "홍길동" },
        { "id": 2, "name": "김철수" }
    ],
    "totalCount": 125
}

Pager는 totalCount 값을 기준으로 전체 페이지 수를 계산합니다.


2. Deferred($.Deferred) 사용하기

기존 프로젝트에서는 Promise 대신 Deferred를 사용하는 경우도 많습니다. 이 경우 resolve 시 totalCount를 함께 전달하면 됩니다.


const store = new DevExpress.data.CustomStore({

    load: function(loadOptions) {

        const d = $.Deferred();

        $.ajax({
            url: "/api/users",
            type: "GET",
            data: {
                skip: loadOptions.skip,
                take: loadOptions.take
            }
        })
        .done(function(result) {

            d.resolve(
                result.items,
                {
                    totalCount: result.totalCount
                }
            );

        })
        .fail(function() {

            d.reject("조회 실패");

        });

        return d.promise();
    }
});

핵심은 아래 코드입니다.


d.resolve(
    result.items,
    {
        totalCount: result.totalCount
    }
);

3. Loading Panel 안 보이게 하기

데이터 로딩 시 나타나는 스피너와 Loading 메시지를 숨기고 싶다면 loadPanel 설정을 사용하면 됩니다.


$("#gridContainer").dxDataGrid({
    dataSource: store,

    loadPanel: {
        enabled: false
    }
});

생성 이후에도 옵션 변경이 가능합니다.


const grid = $("#gridContainer").dxDataGrid("instance");

grid.option("loadPanel.enabled", false);

4. CustomStore에서 셀 수정 내용을 데이터에 반영하기

CustomStore를 사용하는 경우 셀 수정 후 자동으로 원본 데이터가 변경되지 않을 수 있습니다. 이때 update 함수를 구현해야 합니다.


const store = new DevExpress.data.CustomStore({
    key: "id",

    load: function() {
        return {
            data: dataList,
            totalCount: dataList.length
        };
    },

    update: function(key, values) {

        const item = dataList.find(x => x.id === key);

        if(item) {
            Object.assign(item, values);
        }

        return $.Deferred().resolve().promise();
    }
});

Grid 설정은 아래와 같습니다.


$("#gridContainer").dxDataGrid({
    dataSource: store,

    editing: {
        mode: "cell",
        allowUpdating: true
    }
});

셀 수정 후 포커스를 이동하거나 엔터를 누르면 update 함수가 호출됩니다.


5. 수정 이벤트 감지하기

행 업데이트 시점에 수정된 값을 확인하고 싶다면 onRowUpdating 이벤트를 사용할 수 있습니다.


$("#gridContainer").dxDataGrid({

    editing: {
        mode: "cell",
        allowUpdating: true
    },

    onRowUpdating: function(e) {

        console.log("기존 데이터", e.oldData);
        console.log("변경 데이터", e.newData);

        Object.assign(e.oldData, e.newData);
    }
});

마무리

CustomStore를 사용하는 dxDataGrid에서는 다음 네 가지를 기억하면 대부분의 문제를 해결할 수 있습니다.

  • Pager를 사용하려면 totalCount를 반환한다.
  • Deferred 사용 시 resolve(data, { totalCount }) 형태를 사용한다.
  • Loading Panel은 loadPanel.enabled = false 로 숨길 수 있다.
  • 셀 수정 내용을 반영하려면 update() 함수를 구현한다.

실무에서는 Server Paging, Sorting, Filtering을 함께 사용하는 경우가 많으므로 remoteOperations 옵션과 조합하여 사용하는 것을 추천합니다.

반응형

서버 없이 AdMob app-ads.txt 올리기 — Firebase Hosting 삽질기 (feat. Node 24 호환성 버그)

서버 없이

 

플레이스토어에 앱을 출시하고 AdMob으로 광고를 붙이려는데, app-ads.txt를 내 "개발자 웹사이트" 루트에 올리라는 요구사항에 걸렸다. 예전엔 Blogger 커스텀 리다이렉트로 우회했었는데, 이번엔 공식적으로 권장되는 Firebase Hosting으로 해보기로 했다. 과정에서 firebase-tools 신버전의 버그를 만나서 꽤 헤맸는데, 똑같이 겪을 사람들을 위해 정리해둔다.

TL;DR

  • app-ads.txt는 Play Console에 등록한 "웹사이트" 도메인의 루트에, 라인 그대로의 텍스트 파일로 올라가 있어야 함
  • 자체 서버 없으면 Firebase Hosting(무료)이 공식 권장 대안 — Google Sites는 크롤링 안 됨
  • firebase-tools 15.32.1 + Node v24.15.0 조합에서 firebase init이 거의 항상 실패함 (새로 생긴 "Agent Skills Setup" 기능과 설정파일 쓰기가 경쟁 조건으로 충돌)
  • 우회: init 쓰지 말고 .firebaserc / firebase.json을 손으로 만들고 바로 firebase deploy
  • deploy도 멈추는 경우가 있었는데, 원인은 프로젝트 폴더가 Documents 밑에 있었기 때문 — OneDrive 동기화든 백신 실시간 감시든, 아무튼 Documents 폴더 안에서 파일 I/O가 막히는 증상. C:\dev 같은 일반 폴더로 옮기니 바로 해결됨

1. app-ads.txt가 뭐고 왜 필요한가

AdMob/프로그래매틱 광고 생태계에서 "이 앱의 광고 인벤토리를 실제로 이 퍼블리셔가 판매하는 게 맞다"는 걸 검증하기 위한 파일이다. Google 공식 문서 기준:

  • 내용 형식 예시:
    google.com, pub-실제게시자ID, DIRECT, f08c47fec0942fa0
  • 위치: Play Console 앱 등록정보의 "웹사이트" 필드에 적힌 도메인의 루트. 웹사이트가 https://example.com이면 파일은 정확히 https://example.com/app-ads.txt에 있어야 함
  • Google Sites로는 안 됨(공식 문서에 명시), 24시간~최대 1주일 정도 크롤링/반영 시간이 걸림

자체 서버/홈페이지가 없는 1인 개발자라면 Firebase Hosting을 쓰는 게 지금 기준 가장 깔끔한 선택이다. 이미 앱에 Firebase(Firestore 등)를 쓰고 있다면 새 인프라를 더 만들 필요도 없다.

2. 설치 및 준비

npm install -g firebase-tools
firebase login

로그인은 브라우저 창이 뜨고 Firebase 콘솔에 쓰던 구글 계정으로 하면 된다.

3. 문제 발생 — firebase init이 계속 실패함

firebase init hosting

으로 Hosting 기능 선택 → public 디렉터리 지정 → SPA rewrite 여부 → GitHub Actions 연동 여부까지 정상적으로 진행되다가, 맨 마지막에:

Error: An unexpected error has occurred.

--debug 플래그를 붙여서 다시 돌려보니 원인이 드러났다:

=== Agent Skills Setup
If you are using an AI coding agent, Firebase Agent Skills make it an expert at Firebase.
√ Would you like to install agent skills for Firebase? Yes
i  Installing Agent skills in the background...
+  Agent skills installation started

[...]
Error: ENOENT: no such file or directory, open 'C:\...\firebase.json'
    at Object.writeFileSync (node:fs:2414:20)
    at Config.writeProjectFile (...\firebase-tools\lib\config.js:166:12)
    at postInitSaves (...\firebase-tools\lib\commands\init.js:243:12)

버전 정보 (이 글 기준):

  • firebase-tools 15.32.1
  • Node.js v24.15.0
  • Platform: Windows (win32)

firebase-tools 15.32.1에 새로 생긴 "Agent Skills Setup"(AI 코딩 에이전트용 스킬을 백그라운드로 설치해주는 기능)이 끝나자마자, CLI 본체가 firebase.json을 쓰려는 타이밍과 겹치면서 파일 쓰기가 깨지는 것으로 보인다. "Would you like to install agent skills?"에 No로 답해도 똑같은 자리에서 에러 종류만 바뀌어서(EBADF: bad file descriptor, write) 또 실패했다 — 즉 선택지 문제가 아니라, 경쟁 조건(race condition)성 버그로 보인다. 실제로 재시도할 때마다 성공/실패가 갈렸다(한 번은 우연히 성공하기도 했다).

Node.js가 아주 최신 버전(v24)이라 firebase-tools가 아직 완전히 검증하지 못한 조합일 가능성도 있다. 이 글을 나중에 보는 사람은 그 사이 양쪽 버전이 올라가서 이미 고쳐졌을 수도 있으니, 겪고 있는 증상이 다르면 버전부터 맞춰보는 걸 권한다.

우회 방법 — init 쓰지 말고 설정 파일을 직접 작성

init의 문제 지점(postInitSaves)을 아예 타지 않도록, deploy에 필요한 두 파일을 손으로 만들었다.

.firebaserc:

{
  "projects": {
    "default": "프로젝트ID"
  }
}

firebase.json:

{
  "hosting": {
    "public": "public",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ],
    "rewrites": [
      {
        "source": "**",
        "destination": "/index.html"
      }
    ]
  }
}

(rewrites로 모든 경로를 index.html로 보내도, public/app-ads.txt처럼 실제로 존재하는 파일은 Firebase Hosting이 rewrite보다 먼저 서빙하기 때문에 app-ads.txt 호스팅과 충돌하지 않는다.)

Windows에서 파일 만들 때 탐색기로 하면 firebase.json.txt처럼 확장자가 숨어서 꼬이는 경우가 많아서, cmd/PowerShell에서 바로:

notepad .firebaserc
notepad firebase.json

로 만드는 걸 추천한다. (메모장이 "파일이 없습니다. 새로 만드시겠습니까?"라고 물어보면 예 → 내용 붙여넣기 → 저장)

4. 두 번째 문제 — 이번엔 deploy가 멈춤

설정 파일을 수동으로 만들고 firebase deploy --only hosting을 돌렸는데, 이번엔 다음 지점에서 그냥 멈췄다(에러도 안 뜨고 진행도 안 됨):

i  hosting[프로젝트ID]: found 3 files in public
[hosting] uploading with 200 concurrency
[hosting] hash cache [...] not populated
Checked if tokens are valid: true, ...

이 뒤로 네트워크 요청 로그(>>> [apiv2])가 아예 안 찍히는 게 특징이었다 — 즉 서버 통신 전에, 로컬 파일 해시 계산 단계에서 멈춘 것. 파일 3개 해시하는 데 멈출 이유가 없으니 네트워크 문제가 아니라 파일 시스템 접근 문제로 의심했다.

프로젝트 폴더 경로가 C:\Users\...\Documents\...였던 게 걸려서, OneDrive 동기화나 Windows Defender 실시간 감시가 파일 접근을 지연/차단하고 있을 가능성을 테스트했다:

New-Item -ItemType Directory -Path C:\dev -Force
Copy-Item -Path "C:\Users\...\Documents\프로젝트폴더" -Destination "C:\dev\프로젝트폴더" -Recurse
cd C:\dev\프로젝트폴더
firebase deploy --only hosting

C:\dev 밑으로 옮기자마자 바로 성공했다.

+  hosting[프로젝트ID]: file upload complete
i  hosting[프로젝트ID]: finalizing version...
+  hosting[프로젝트ID]: version finalized
i  hosting[프로젝트ID]: releasing new version...
+  hosting[프로젝트ID]: release complete

+  Deploy complete!

Hosting URL: https://프로젝트ID.web.app

결론: Windows에서 Documents 폴더(특히 OneDrive 연동 계정)는 CLI 도구 작업 폴더로 쓰지 말 것. C:\dev 처럼 동기화/백신 감시가 덜 걸리는 평범한 경로를 쓰는 게 여러모로 안전하다.

5. app-ads.txt 올리고 마무리

notepad public\app-ads.txt
google.com, pub-실제게시자ID, DIRECT, f08c47fec0942fa0
firebase deploy --only hosting

마지막으로 Play Console → 스토어 등록정보 → 연락처 세부정보 → 웹사이트에 https://프로젝트ID.web.app를 정확히 입력(끝에 슬래시나 경로 없이, 발급된 도메인 그대로). 이게 app-ads.txt가 있는 도메인과 정확히 일치해야 AdMob 크롤러가 찾아간다.

같은 Firebase Hosting에 개인정보처리방침 페이지(public/privacy.html)도 같이 올려두면, 플레이스토어가 요구하는 "개인정보처리방침 URL"까지 한 번에 해결된다.

6. 요약 — 전체 명령어 모음

# 설치 및 로그인
npm install -g firebase-tools
firebase login

# (firebase init은 이 시점 버전 조합에서 불안정했음 — 아래로 대체)

# .firebaserc, firebase.json을 수동 작성 후,
# 반드시 OneDrive/백신 감시가 없는 일반 폴더(예: C:\dev)에서 작업

cd C:\dev\내프로젝트
firebase deploy --only hosting
증상 원인 해결
firebase init 끝에서 "Unexpected error" Agent Skills Setup과 설정파일 쓰기의 경쟁 조건 (firebase-tools 15.32.1 / Node 24 조합) init 대신 .firebaserc/firebase.json 수동 작성
firebase deploy가 해시 계산 단계에서 멈춤 작업 폴더가 Documents(OneDrive/백신 감시) 밑에 있음 C:\dev 등 평범한 경로로 폴더 이동
app-ads.txt를 AdMob이 못 찾음 Play Console 웹사이트 필드와 실제 호스팅 도메인 불일치 둘을 정확히 일치시키고 최대 1주일 대기

참고

반응형

파이썬으로 네이버 쇼핑 커넥트 자동화하기 — 블로그 포스팅부터 인스타그램 릴스 자동 게시까지

블로그게시 예시

 

네이버 쇼핑 커넥트(브랜드 커넥트 제휴)로 받은 상품 링크를 블로그 글에 자동으로 붙이고, 같은 상품으로 인스타그램 릴스 영상까지 만들어 올리는 파이썬 자동화를 만들어 본 기록입니다. 중간에 겪은 삽질(PowerShell 쉼표, URL 인코딩, 에디터가 스타일을 지우는 문제 등)도 함께 정리했습니다.

⚠️ 쇼핑 커넥트는 공개 Open API 가 없습니다. 이 글은 본인 계정으로 로그인한 브라우저가 호출하는 요청을 그대로 재현하는 방식이라, 응답 형식이 예고 없이 바뀔 수 있고 운영 정책상 제한될 수 있습니다. 호출 빈도를 낮게 유지하고, 사용 전 브랜드 커넥트 운영정책을 꼭 확인하세요.


전체 구조

[쇼핑 커넥트]
   │  (로그인 쿠키로 내부 API 호출)
   ├─▶ 상품 조회 / 검색 / 제휴 링크 발급 ─▶ [네이버 블로그 봇] 글 상단 대가성 문구 + 하단 상품 목록
   │
   └─▶ 발급된 링크 목록 ─▶ sqlite 저장
                              │
                              ├─▶ 상품 이미지 → 세로 영상(ffmpeg) + 자막
                              └─▶ 인스타그램 Graph API 로 릴스 게시 (라즈베리파이 cron)

사용한 것: Python 3.9+, requests, playwright, beautifulsoup4, ffmpeg, firebase-admin(토큰·영상 임시 저장), sqlite.


1. 공개 API 가 없을 때: Playwright codegen 으로 흐름 녹화하기

먼저 "사람이 클릭하는 순서"를 코드로 뽑아봤습니다. Playwright 의 codegen 은 브라우저에서 한 동작을 파이썬 코드로 기록하고, 로그인 세션(쿠키)도 파일로 저장해 줍니다.

python -m playwright codegen --target python --save-storage=auth.json -o recorded.py https://brandconnect.naver.com/

로그인 → 상품 찾기 → 상품 클릭 → "링크 발급" → "링크 복사" 까지 한 번 해 보면 이런 코드가 나옵니다.

page.goto("https://brandconnect.naver.com/<SPACE_ID>/affiliate/products")
page.get_by_role("link", name="상품 찾기").click()
with page.expect_popup() as info:
    page.get_by_role("link", name="수수료 5% 판매가 ...").click()
detail = info.value
detail.get_by_role("button", name="링크 발급").click()
detail.get_by_role("button", name="확인").click()

🚨 주의: codegen 은 입력한 값을 그대로 기록합니다. 로그인 과정까지 녹화하면 아이디·비밀번호가 평문으로 파일에 남습니다. 녹화 직후 로그인 부분을 지우고, 그 파일은 절대 커밋/공유하지 마세요. 저는 녹화 후 비밀번호를 바꿨습니다. auth.json(로그인 쿠키)도 .gitignore 에 꼭 넣어 두세요.

또 하나 배운 점: 네이버 로그인은 자동화하면 캡차(영수증 문제 등)가 뜹니다. 로그인은 사람이 하고, 저장된 세션만 재사용하는 구조가 가장 안정적입니다.


2. 버튼 대신 API: 네트워크 요청 캡처

"링크 복사"는 클립보드로만 들어가서 코드로 값을 읽기 애매합니다. 그래서 링크 발급 버튼을 누를 때 어떤 요청이 나가는지 기록했습니다.

log = []

def on_response(resp):
    req = resp.request
    if "gw-brandconnect" not in req.url:
        return
    log.append({
        "method": req.method, "url": req.url, "status": resp.status,
        "post": req.post_data, "body": resp.text()[:3000],
    })

context = browser.new_context(storage_state="auth.json")
context.on("response", on_response)

요청 헤더에는 쿠키가 들어 있으니, 로그로 남길 때 cookie 같은 헤더는 가리는 게 좋습니다.

캡처 결과, 화면 뒤에서는 이런 요청들이 오가고 있었습니다(도메인 gw-brandconnect.naver.com, 헤더 x-space-id: <SPACE_ID>).

용도 요청
상품 단건 GET /affiliate/query/affiliate-products/{상품ID}
추천 상품 GET /affiliate/query/affiliate-products/recommend-by-shopping-promotions?category1Id=&limit= (limit 최대 10)
상품 검색 GET /affiliate/query/affiliate-products/search-by-query?query=&limit=
링크 발급 POST /affiliate/command/affiliate-urls?affiliateProductId={상품ID} 바디 {}
발급 링크 목록 POST /affiliate/query/affiliate-urls/search

재미있던 점은 상품 응답에 이미 shortenUrl 필드가 있다는 것. 발급 전엔 null, 발급 후엔 https://naver.me/xxxx 가 들어 있어서, 굳이 클립보드를 읽을 필요가 없었습니다.

그리고 인증은 네이버 로그인 쿠키(NID_AUT, NID_SES)만 있으면 충분했습니다(쿠키가 없으면 403). 덕분에 Playwright 없이 requests 만으로도 호출할 수 있습니다.

삽질 포인트: 추천 상품 API 에 limit=20 을 보냈더니 400 ConstraintViolationException. 웹 화면이 보내는 값(10)을 넘기면 안 됩니다. 캡처한 값 그대로 쓰기가 기본입니다.


3. 클라이언트 만들기: 이미 있으면 재사용, 없을 때만 발급

Playwright 의 storage_state(JSON) 쿠키를 requests 세션에 싣는 부분입니다.

import json
import requests

def build_session(auth_path: str, space_id: str) -> requests.Session:
    state = json.loads(open(auth_path, encoding="utf-8").read())
    s = requests.Session()
    for c in state.get("cookies", []):
        s.cookies.set(c["name"], c["value"], domain=c.get("domain"), path=c.get("path", "/"))
    s.headers.update({
        "accept": "application/json, text/plain, */*",
        "x-space-id": space_id,
    })
    return s

링크 발급은 중복 발급을 막는 것이 핵심입니다. 로컬 캐시 → 상품 응답의 shortenUrl → (그래도 없으면) 발급 순서로 확인하고, 하루 발급 상한과 호출 간격도 둡니다.

GW = "https://gw-brandconnect.naver.com"

def get_or_issue_link(s, product: dict, cache: dict, today: str, daily_limit: int = 10) -> str:
    pid = str(product["id"])
    if cache["links"].get(pid):                 # 1) 로컬 캐시
        return cache["links"][pid]
    if product.get("shortenUrl"):               # 2) 이미 발급된 링크
        cache["links"][pid] = product["shortenUrl"]
        return product["shortenUrl"]
    if cache["issues"].get(today, 0) >= daily_limit:
        raise RuntimeError(f"오늘 발급 상한({daily_limit}) 도달")

    r = s.post(f"{GW}/affiliate/command/affiliate-urls",   # 3) 새로 발급
               params={"affiliateProductId": pid}, json={}, timeout=20)
    if r.status_code in (401, 403):
        raise RuntimeError("로그인 세션 만료 - 직접 다시 로그인해 쿠키 파일을 교체하세요")
    r.raise_for_status()
    url = r.json()["url"]                      # {"affiliateUrlId": ..., "url": "https://naver.me/..."}
    cache["links"][pid] = url
    cache["issues"][today] = cache["issues"].get(today, 0) + 1
    return url

테스트는 실제 발급이 일어나지 않도록 가짜 request 객체로 작성했습니다.

class FakeRequest:
    def __init__(self, products):
        self.products = {p["id"]: p for p in products}
        self.posts = 0

    def post(self, url, **kw):
        self.posts += 1
        return FakeResp(200, {"affiliateUrlId": 1, "url": f"https://naver.me/new{self.posts}"})

4. 블로그 본문에 넣기: "빨간 대가성 문구"가 사라지는 문제

쇼핑 커넥트는 게시글에 대가성 문구를 눈에 띄게 넣어야 합니다. 그래서 글 맨 위에 빨간 글씨로 넣었는데… 발행해 보니 검은 글씨가 됐습니다.

원인은 제 코드였습니다. 티스토리/AI 가 만든 HTML 을 네이버 스마트에디터에 붙여 넣기 전에 정리하는 sanitize 함수가 <span> 을 풀고 모든 style 을 지우고 있었습니다(에디터가 잘라내는 태그를 줄이려는 의도였음).

해결: 표시(data-keep-color)가 달린 span 만 색/크기를 남기는 예외를 만들었습니다.

import re
from bs4 import BeautifulSoup

KEEP_COLOR = re.compile(r"(?<![-\w])color\s*:\s*(#[0-9a-fA-F]{3,6}|rgb\([\d\s,]+\)|[a-zA-Z]+)")
KEEP_SIZE = re.compile(r"font-size\s*:\s*(\d+(?:\.\d+)?(?:px|pt|em|rem|%))")

def sanitize(html: str) -> str:
    soup = BeautifulSoup(html, "lxml")
    for tag in list(soup.find_all(["div", "span"])):
        if tag.name == "span" and tag.has_attr("data-keep-color"):
            continue                      # 표시된 span 은 남김
        tag.unwrap()
    for span in soup.find_all("span"):
        style = span.get("style", "")
        kept = []
        if m := KEEP_COLOR.search(style):
            kept.append(f"color:{m.group(1)};")
        if m := KEEP_SIZE.search(style):
            kept.append(f"font-size:{m.group(1)};")
        span.attrs = {"style": "".join(kept)} if kept else {}
    return str(soup.body.decode_contents())

disclosure = (
    '<p><span data-keep-color="1" style="color:#e03131;font-size:30px;">'
    "<strong>이 포스팅은 네이버 쇼핑 커넥트 활동의 일환으로,</strong><br>"
    "<strong>판매 발생 시 수수료를 제공받습니다.</strong></span></p>"
)

에디터가 정말 색을 유지했는지는 붙여 넣은 뒤 DOM 의 계산된 스타일로 확인하면 확실합니다.

color = frame.evaluate("""(needle) => {
    const w = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
    let n;
    while ((n = w.nextNode())) {
        if (n.nodeValue.includes(needle)) {
            const cs = getComputedStyle(n.parentElement);
            return cs.color + " / " + cs.fontSize;
        }
    }
    return null;
}""", "쇼핑 커넥트 활동의 일환으로")
print(color)   # rgb(224, 49, 49) / 30px

대표 이미지가 문구보다 위로 올라가는 문제

이미지는 커서 위치에 업로드되는데, 본문을 넣은 뒤 제목을 입력하느라 커서가 제목에 있었습니다. 그래서 이미지가 본문 맨 앞(문구 위)에 꽂혔죠. 업로드 직전에 커서를 문구 끝으로 옮겨서 해결했습니다.

def place_cursor_after_disclosure(page, last_line="판매 발생 시 수수료를 제공받습니다."):
    for fr in page.frames:
        loc = fr.get_by_text(last_line, exact=False)
        if loc.count():
            loc.first.click()
            page.keyboard.press("End")
            return True
    return False

상품 썸네일이 안 보이는 문제

상품 이미지도 같은 표시 방식(data-keep-img)으로 살렸는데, 이번엔 이미지만 들어 있는 <p> 가 통째로 비워졌습니다. "텍스트 없는 문단은 빈 줄로 만든다"는 기존 로직이 이미지를 못 본 것.

# 전: 텍스트도 <br> 도 없으면 비움 -> <p><a><img></a></p> 가 날아감
if not p.get_text(strip=True) and not p.find(["br"]):
# 후: 이미지가 있으면 건드리지 않음
if not p.get_text(strip=True) and not p.find(["br", "img"]):

5. 글 주제와 관련된 상품 고르기

처음엔 카테고리 추천 상품을 넣었더니 "전기요금 절약" 글에 냄비 세트가 붙는 참사가… 그래서 글을 쓰는 AI 에게 상품 검색어도 같이 달라고 했습니다.

- shopping_keywords 에는 이 글을 읽은 독자가 실제로 구매할 만한, 주제와 직접 관련된
  상품 검색어 3~5개를 연관도가 높은 순서로 넣습니다. 쇼핑몰 검색창에 그대로 넣을
  짧은 명사형(1~2단어, 예: "가습기", "무선 청소기")으로 쓰고, 브랜드명·수식어는 넣지 않습니다.

키워드 순서대로 검색해서 품절·중복을 빼고 모으고, 관련 상품이 부족해도 엉뚱한 상품으로 채우지 않습니다.

def search_candidates(client, keywords, per_keyword=10):
    seen, out = set(), []
    for kw in keywords:
        for p in client.search(kw, limit=per_keyword):
            if p.is_linkable and p.id not in seen:   # 판매중 + 활성 + 스토어 정상
                seen.add(p.id)
                out.append(p)
    return out

실제 검색 결과: "가습기" → 미니 가습기, "무선 청소기" → 무선청소기, "전기요금 절약" → 스마트 플러그. 훨씬 자연스러워졌습니다.

 

블로그 게시 예시

 


6. 기존 릴스 파이프라인에 방어 로직 넣기

이미 스레드 글을 릴스로 만드는 파이프라인(큐 동기화 → 영상 제작 → 게시)이 라즈베리파이 cron 으로 돌고 있었는데, 코드를 읽다 보니 위험한 곳이 보였습니다.

① 실패한 항목이 큐를 영원히 막는다. 매번 id 가 가장 작은 1건만 처리하는데, 그게 실패하면 다음 실행에도 또 그것만 시도합니다. → 실패 횟수와 재시도 시각을 기록하고, 3회면 큐에서 뺍니다.

from datetime import datetime, timedelta

MAX_ATTEMPTS = 3
BACKOFF = timedelta(minutes=30)

def record_failure(ref, item, error):
    count = int(item.get("build_fail_count") or 0) + 1
    update = {"build_fail_count": count, "last_build_error": str(error)[:500]}
    if count >= MAX_ATTEMPTS:
        update["status"] = "build_failed"          # 큐에서 제외
    else:
        update["build_retry_after"] = (datetime.now() + BACKOFF * count).isoformat(sep=" ", timespec="seconds")
    ref.update(update)

# 고를 때: 재시도 대기 중이면 건너뛰고, 실패 이력이 적은 것부터
candidates.sort(key=lambda kv: (int(kv[1].get("build_fail_count") or 0), queue_sort_key(kv[0])))

② 숫자가 아닌 키에서 int() 예외로 전체가 죽는다.

def queue_sort_key(key: str) -> tuple:
    raw = key[len("item_"):] if key.startswith("item_") else key
    try:
        return (0, int(raw), "")
    except ValueError:
        return (1, 0, key)          # 숫자 아닌 키는 뒤로

③ 토큰 만료 같은 "전체 장애"를 항목 실패로 세면 안 된다. 토큰이 만료되면 모든 항목이 3번씩 실패해서 큐가 통째로 비워집니다. Graph API 오류 코드로 구분했습니다.

GLOBAL_CODES = {190, 4, 17, 32, 613}   # 190: 토큰 무효, 나머지: 호출 제한

def raise_for_graph_error(r, what):
    if r.status_code == 200:
        return
    code = (r.json().get("error") or {}).get("code")
    if r.status_code >= 500 or code in GLOBAL_CODES:
        raise GlobalPostingError(f"{what}: {r.text[:300]}")   # 실패 횟수 X, 이번 실행만 중단
    raise Exception(f"{what}: {r.text[:300]}")                # 항목 실패로 기록

④ 동기화 경쟁 상태. "스레드에 아직 안 올라간(pending) 글만 릴스로 복사"로 바꿨더니, 스레드 봇이 먼저 게시해 버리면 영원히 복사가 안 되는 틈이 생겼습니다. 그래서 최근 1일 안에 게시된 글도 복사하도록 창을 열어 두었습니다.

⑤ 영상 파일이 계속 쌓인다. 게시가 끝난 항목의 로컬 mp4 는 지우도록 했습니다(자기가 만든 폴더 안의 파일만).


7. 상품 이미지로 세로 릴스 영상 만들기

상품 이미지는 대부분 정사각형이라 1080x1920 으로 그냥 늘리면 찌그러집니다. 흐린 배경 위에 원본 비율 그대로 얹는 ffmpeg 필터를 썼습니다.

import subprocess

def make_vertical_slide(src, dst, w=1080, h=1920):
    vf = (
        "split[a][b];"
        f"[a]scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h},boxblur=30:2[bg];"
        f"[b]scale={w}:{h}:force_original_aspect_ratio=decrease[fg];"
        "[bg][fg]overlay=(W-w)/2:(H-h)/2"
    )
    subprocess.run(["ffmpeg", "-y", "-i", str(src), "-filter_complex", vf,
                    "-frames:v", "1", "-q:v", "2", str(dst)], check=True)

def make_silence(dst, seconds):
    # 나레이션 없이 무음 트랙(업로드 호환용). 기존 조립 스크립트가 오디오 길이로 클립 길이를 정함
    subprocess.run(["ffmpeg", "-y", "-f", "lavfi", "-i", "anullsrc=r=44100:cl=stereo",
                    "-t", str(seconds), "-c:a", "libmp3lame", "-q:a", "9", str(dst)], check=True)

자막: 처음 3초는 빨간 대가성 문구, 그 뒤엔 상품명

자막은 SRT 로 만들고 ffmpeg subtitles 필터로 입힙니다. 여기서 알게 된 팁 두 가지:

  • SRT 안의 <font color="...">, <b> 는 ffmpeg 가 ASS 스타일로 변환해 줍니다.
  • <font size="16"> 도 된다! 공용 스타일(force_style='Fontsize=22')은 다른 영상과 같이 쓰고 있어서 건드리기 싫었는데, SRT 태그로 이 영상만 크기를 덮어쓸 수 있었습니다. (참고로 {\fs16} 같은 ASS 태그는 ffmpeg 의 SRT 파서가 {\an8} 류 말고는 지워버립니다.)
DISCLOSURE = ("이 포스팅은 네이버 쇼핑 커넥트 활동의 일환으로,", "판매 발생 시 수수료를 제공받습니다.")

def srt_time(sec: float) -> str:
    ms = round(sec * 1000)
    h, ms = divmod(ms, 3_600_000)
    m, ms = divmod(ms, 60_000)
    s, ms = divmod(ms, 1000)
    return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"

def build_srt(name: str, total: float, disclosure_sec=3.0, size=16) -> str:
    disclosure = "\n".join(f'<font color="#e03131" size="{size}"><b>{line}</b></font>' for line in DISCLOSURE)
    product = name.replace("<", "(").replace(">", ")")   # 상품명이 태그로 해석되지 않게
    entries = [(0, min(disclosure_sec, total), disclosure)]
    if total > disclosure_sec:
        entries.append((disclosure_sec, total, f'<font size="{size}">{product}</font>'))
    return "\n".join(f"{i}\n{srt_time(a)} --> {srt_time(b)}\n{t}\n" for i, (a, b, t) in enumerate(entries, 1))

슬라이드가 1장뿐인 상품은 문구만 나오고 끝나지 않도록, 그 장의 길이를 (문구 3초 + 3초)로 늘렸습니다.


8. 인스타그램 릴스 게시: 중복 게시만은 막자

게시는 Instagram API(Instagram 로그인) 의 3단계 흐름입니다: 컨테이너 생성 → 처리 완료 대기 → 게시. 영상은 공개 URL 이 필요해서 Firebase Storage 에 잠깐 올렸다가 게시 후 지웁니다.

GRAPH = "https://graph.instagram.com/v21.0"

def create_container(ig_user_id, video_url, caption, token):
    r = requests.post(f"{GRAPH}/{ig_user_id}/media", data={
        "media_type": "REELS", "video_url": video_url, "caption": caption, "access_token": token,
    }, timeout=60)
    raise_for_graph_error(r, "컨테이너 생성 실패")
    return r.json()["id"]

def wait_until_processed(creation_id, token, timeout=300):
    start = time.time()
    while time.time() - start < timeout:
        r = requests.get(f"{GRAPH}/{creation_id}", params={"fields": "status_code", "access_token": token}, timeout=60)
        raise_for_graph_error(r, "상태 조회 실패")
        status = r.json().get("status_code")
        if status == "FINISHED":
            return
        if status == "ERROR":
            raise Exception("영상 처리 실패")
        time.sleep(5)
    raise Exception("영상 처리 타임아웃")

가장 무서운 건 "게시는 됐는데 응답을 못 받아서 다음 실행에 또 올리는" 중복 게시입니다. 그래서 게시 직전에 상태를 publishing 으로 찍고, 응답을 못 받으면 자동 재시도하지 않고 사람이 확인하게 했습니다.

set_status(conn, pid, "publishing")
try:
    media = publish(creation_id, token)
except GlobalPostingError:
    print("게시 결과를 확인하지 못했습니다. 인스타그램에서 확인 후 상태를 직접 바꿔 주세요.")
    return 1                      # publishing 으로 남김 -> 다음 실행에서 안 집음
set_posted(conn, pid, media)

캡션에는 가격, 제휴 링크, 대가성 문구, 해시태그를 넣었습니다. 인스타그램 캡션의 링크는 클릭이 안 되기 때문에 "복사해서 브라우저에 붙여 넣어 주세요" 안내를 같이 적었습니다.

 

인스타 자동 게시 릴스 예시

 


9. Meta 개발자 설정 & 배포 삽질 모음

체크리스트

  1. 인스타그램 계정을 프로페셔널(비즈니스/크리에이터) 로 전환
  2. 앱이 개발 모드면 앱 역할 → Instagram 테스터에 계정 추가 → 인스타그램 앱에서 초대 수락
  3. 권한 및 기능에 instagram_business_content_publish 추가 (기본 세팅엔 basic/comments/messages 만 있을 수 있음!)
  4. Instagram 비즈니스 로그인 설정에 리디렉션 URL 등록
  5. 로그인 동의 → code → 단기 토큰 → 장기(60일) 토큰 교환 (저는 Cloud Functions 로 콜백을 처리)

삽질 1: 앱 ID 가 두 개다

대시보드 맨 위의 Meta 앱 ID 와, Instagram API 설정 화면의 Instagram 앱 ID 는 다릅니다. OAuth client_id 와 토큰 교환에는 Instagram 앱 ID 를 써야 합니다.

삽질 2: PowerShell 에서 --set-env-vars 가 하나로 합쳐짐

# ❌ 쉼표가 PowerShell 배열 구분자로 해석돼 "A=1 B=2 C=3" 한 덩어리로 넘어감
gcloud functions deploy my-func ... --set-env-vars=APP_ID=123,APP_SECRET=xxx,REDIRECT_URI=https://.../

# ✅ 인자 전체를 큰따옴표로 감싸기
gcloud functions deploy my-func ... "--set-env-vars=APP_ID=123,APP_SECRET=xxx,REDIRECT_URI=https://.../"

배포는 "성공"했는데 함수가 Server misconfigured 를 뱉어서 한참 헤맸습니다. 환경변수 이름 목록을 찍어보고서야 APP_ID 하나에 전부 들어간 걸 발견했어요.

삽질 3: redirect URI 인코딩은 "브라우저 주소에서만"

  • 함수 환경변수 / 대시보드 등록값: https://my-callback.example.run.app/ (원래 형태, 끝 / 까지 똑같이)
  • 브라우저로 여는 동의 URL 안에서만: redirect_uri=https%3A%2F%2Fmy-callback.example.run.app%2F

삽질 4: ID 가 두 개다 (또!)

OAuth 가 돌려주는 user_id 는 앱 범위 ID 이고, /me?fields=user_id 는 인스타그램 계정 ID(1784...) 를 줍니다. 같은 계정입니다. 토큰 점검 스크립트에서 둘 중 하나만 맞으면 통과하도록 했습니다.

me = requests.get(f"{GRAPH}/me", params={"fields": "id,user_id,username,account_type",
                                          "access_token": token}, timeout=20).json()
same_account = str(target_id) in (str(me.get("id")), str(me.get("user_id")))

토큰 자동 갱신

장기 토큰은 60일 뒤 만료됩니다. 만료 15일 전부터 cron 에서 자동 갱신하게 했습니다(발급 후 24시간이 지나야 갱신 가능).

r = requests.get("https://graph.instagram.com/refresh_access_token",
                 params={"grant_type": "ig_refresh_token", "access_token": token}, timeout=20)
new_token = r.json()["access_token"]

10. cron 등록

#!/bin/bash
cd /home/pi/myproject
# 이전 실행(영상 제작)이 길어져도 겹쳐 돌지 않게
exec 9>/tmp/reels_sc.lock
flock -n 9 || { echo "이전 실행 진행 중 - 건너뜀"; exit 0; }

python3 sync_products.py || echo "상품 동기화 실패(기존 DB 로 계속)"
python3 ig_token_check.py --refresh-if-needed
python3 build_reels.py
python3 ig_reels_posting.py
# 하루 2번, 다른 봇과 겹치지 않게 30분에
30 10,20 * * * /home/pi/myproject/reels_sc.sh >> /home/pi/myproject/reels_sc.log 2>&1

윈도우에서 .sh 를 편집해 라즈베리파이로 보낼 땐 줄바꿈(CRLF) 을 조심하세요. PowerShell Set-Content 로 고치면 CRLF 가 되어 bash 가 깨집니다. [IO.File]::ReadAllText / WriteAllText 처럼 원본 줄바꿈을 유지하는 방법을 쓰세요.


마치며

정리해 보면 이번 작업에서 가장 도움이 된 원칙은 세 가지였습니다.

  1. 로그인은 사람이, 반복은 코드가 — 캡차·2단계 인증과 싸우지 말고 세션만 재사용하기
  2. 실패는 기록하고 격리하기 — 한 항목의 실패가 큐 전체를 막지 않게, 전체 장애는 항목 탓으로 세지 않게
  3. 중복 발행은 자동 재시도보다 무섭다 — 결과를 모르면 멈추고 사람에게 확인받기

그리고 codegen 녹화 파일, 쿠키 파일, 앱 시크릿은… 정말 조심하세요. 저는 이번에 둘 다 한 번씩 노출시키고 나서 비밀번호와 시크릿을 새로 바꿨습니다. 😅

반응형

+ Recent posts