2026/04/29

오늘의 이야기

5장. 성능·배터리 최적화


 


이 장의 목표는 “빠르고 오래 가는 워치 앱”을 만드는 것입니다. 작은 화면에서 느림은 바로 이탈로 이어지고, 배터리 소모는 곧 별점 하락으로 연결됩니다. 지금은 완벽한 초최적화보다, 체감 품질을 확 끌어올리는 실전 우선순위를 적용하겠습니다.


성능·배터리 최적화의 3대 원칙



  • 적게 그리기: 화면을 덜, 간단히, 필요할 때만 갱신합니다.

  • 덜 깨우기: 백그라운드 작업·네트워크·센서를 “이벤트 기반”으로 바꾸고 묶어서 처리합니다.

  • 일관성 유지: 앱 본문·타일·컴플리케이션·알림이 하나의 상태를 바라보게 해 중복 갱신을 없앱니다.


권장 목표치(초판 기준 가이드)



  • 첫 실행 시간 2초 이내, 재실행 1초 이내

  • 진행 화면에서 프레임 드롭 체감 없음(스크롤/애니메이션 최소)

  • 일반 사용 30분에 배터리 소모 한 자리수(기능 성격에 따라 3~8%대)

  • 진행형 알림/타일/컴플리케이션 갱신은 상태 변화 시에만


화면 렌더링 최적화



  • 갱신 범위 줄이기: 상태를 잘게 나누기보다, 화면을 “핵심 수치 + 버튼” 중심으로 단순화합니다. 수치 하나만 바뀌면 그 영역만 갱신되게 설계하세요.

  • 과도한 애니메이션 금지: 진행 표시 등 꼭 필요한 변화만, 주기·속도를 낮춰 배터리와 눈 피로를 함께 줄입니다.

  • 텍스트와 아이콘: 텍스트 대비를 높이고, 아이콘은 단색·벡터 기반으로 단순화합니다. 이미지는 가능한 캐시하고 크기를 고정합니다.

  • 목록/카드: 한 화면에 보여줄 항목 수를 최소화하고, 지연 로딩 없이 “필요한 만큼만” 그립니다.


네트워크·데이터 전략



  • 이벤트 기반 전송: 실시간 폴링 대신 사용자 행동 이후 혹은 완료 시점에만 전송합니다.

  • 배치 전송: 여러 이벤트를 몇 분 단위로 묶어 전송하면 라디오 웨이크업 횟수가 크게 줄어듭니다.

  • 캐시·압축: 응답은 반드시 캐시 키와 만료 정책을 정하고, 압축을 기본값으로 둡니다.

  • 오프라인 퍼스트: 핵심 기능은 기기 내에서 완결되게 만들고, 동기화는 충전 중/와이파이 우선으로 지연합니다.


센서·백그라운드 사용



  • 필요 순간만 활성화: 센서는 사용 흐름이 시작될 때 켜고, 일시정지나 종료 시 즉시 해제합니다.

  • 샘플링 간격 완화: 초단위 혹은 그 이상으로 간격을 넓히고, 화면 갱신은 1초 이하 빈도로 제한합니다.

  • 필터·스로틀: 미세한 노이즈는 간단한 평균/스로틀로 누르고, UI에는 “의미 있는 변화”만 반영합니다.

  • 스케줄 작업: 주기 작업은 최소화하고, 시스템이 허용하는 유휴 시간대/충전 중에 몰아서 실행합니다.


항상 켜짐(앰비언트) 모드



  • 정적 레이아웃: 단색, 저대비가 아닌 고대비의 정적 화면으로 전환합니다.

  • 저빈도 갱신: 진행 수치가 꼭 필요한 경우에도 분 단위 등 저갱신 정책을 적용합니다.

  • 번인 방지: 화면 요소를 약간씩 이동하거나, 밝기를 낮춰 번인 리스크를 줄입니다.


메모리·ANR 예방



  • 메인 스레드 청결: 디스크/네트워크/무거운 계산은 절대 메인에서 돌리지 않습니다. 진행 표시도 가벼운 연산만 허용하세요.

  • 객체 수명 단순화: 화면 전환·일시정지 시 리소스를 즉시 해제해 누수를 막습니다.

  • 로그 레벨 관리: 반복 로그는 릴리즈에서 제거하거나 샘플링해 I/O 부담을 없앱니다.

  • 예외 처리 루틴: 실패 시 재시도 폭주를 막기 위해 지수 백오프를 적용하고, 사용자에게는 짧은 원인+해결 행동만 노출합니다.


배터리 드레인 진단 루틴(짧고 효과적인 방법)



  • 기준선 만들기: 기능 비활성 상태로 30분 켜두고 소모율 기록(앱 자체 드레인 파악).

  • 단계별 비교: 기능 A만 켠 빌드, A+B 켠 빌드로 나눠 20~30분 테스트 후 차이를 기록합니다.

  • 웨이크업 점검: 알림·타일·컴플리케이션의 갱신 이벤트 수를 하루 단위로 추정해 과다 여부를 판단합니다.

  • 체감 테스트: 야외 밝은 환경에서 가독성을 확인하고, 과한 밝기/애니메이션이 없는지 눈으로 점검합니다.


초간단 승수 효과(1시간 투자로 체감 개선)



  • 진행 화면의 애니메이션 주기를 낮추거나 정지합니다.

  • 네트워크 전송을 “완료 시 1회 + 배치”로 바꿉니다.

  • 타일/컴플리케이션 갱신을 “상태 변화 시”로 제한합니다.

  • 불필요한 주기 타이머를 제거하고, 사용자 상호작용 후 짧은 타이머만 사용합니다.

  • 큰 이미지/폰트 리소스를 한 번만 로드하고 재사용합니다.


자주 보는 함정과 회피법



  • 과한 실시간성 집착: 초판에서 초단위 정확도는 비용 대비 이득이 작습니다. 사용성·안정성을 우선하세요.

  • 알림 남발: 진행형 알림이 과도하면 차단률이 올라갑니다. 사용자 제어를 제공하고 기본값은 절제합니다.

  • 다중 진입로 불일치: 타일·알림·앱 본문 상태가 어긋나면 혼란과 버그를 동시 유발합니다. 단일 상태 소스 원칙을 지키세요.

  • 디버그 옵션 방치: 디버그 빌드 플래그가 릴리즈에 남지 않도록 점검합니다.


테스트 시나리오(체크형)



  • 첫 실행/재실행 시간 측정(3회 평균)

  • 핵심 플로우(시작→진행→정지) 중 프레임 드랍 체감 여부

  • 30분 사용 배터리 소모율 비교(오늘 빌드 vs 이전 빌드)

  • 오프라인 상태에서의 정상 동작·지연 동기화 확인

  • 항상 켜짐 모드에서 가독성·번인 방지 동작 점검

  • 강제 종료 후 상태 복원 일관성 확인


요약 포인트



  • 덜 그리고 덜 깨우면 체감 성능과 배터리는 함께 좋아집니다.

  • 이벤트 기반 갱신, 배치 전송, 오프라인 퍼스트가 워치에서 특히 강력합니다.

  • 단일 상태 소스를 유지하면 성능·안정성·유지보수가 모두 쉬워집니다.


오늘의 수행 미션



  • 진행 화면 애니메이션을 줄이고, 수치 1개만 갱신되게 화면을 단순화합니다.

  • 타일·컴플리케이션·알림 갱신 조건을 “상태 변화 시”로 통일합니다.

  • 30분 배터리 소모 기준선을 만들고, 변경 전/후 수치를 기록합니다.

  • 네트워크·센서 사용을 이벤트 기반으로 재구성하고, 배치 전송을 적용합니다.


 


앱 이미지



 





오늘의 이야기

30일 만에 Wear OS 앱 출시 — 전자책 본문


30일 만에 Wear OS 앱 출시


작은 화면, 큰 성과. 기획–개발–배포까지 한 번에 끝내는 올인원 로드맵



  1. 1장. 목표 설정과 30일 로드맵

  2. 2장. 개발 환경 세팅과 기본 구조

  3. 3장. 워치 UX와 화면 설계

  4. 4장. 핵심 기능 구현 실전 — 타일·컴플리케이션·센서·알림

  5. 5장. 성능·배터리 최적화

  6. 6장. 배포 준비와 스토어 론칭

  7. 7장. 데이터 기반 개선과 마케팅 운영

  8. 8장. 케이스 스터디와 실전 레슨



 


뤼튼


AI 글쓰기, AI 이미지 생성 등 전세계 최신 AI를 무료로


wrtn.ai




 


1장. 목표 설정과 30일 로드맵


Wear OS는 작은 화면, 짧은 상호작용, 더 촘촘한 사용 맥락이라는 특징을 갖습니다. 그래서 “완벽한 앱”보다는 “빠르게 유용한 앱”이 사랑받습니다. 이 책은 30일 안에 MVP를 출시하도록 설계했습니다. 핵심은 선택과 집중, 그리고 반복 가능한 일정입니다.


왜 지금 Wear OS인가



  • 수요가 커지는 건강/운동·알림·퀵액션 환경에서 워치는 가장 가까운 접점입니다.

  • 스마트폰 대비 사용 행위가 짧아 기능이 명확할수록 가치가 커집니다.

  • 경쟁 자료가 적어 초보라도 빠르게 ‘틈새’를 만들 수 있습니다.


30일 마일스톤 설계



  • 주차별 목표를 하나씩만 달성합니다. “기획-핵심화면-핵심기능-최적화-배포” 순서로 최소 단계를 밟습니다.

  • 매일 60~120분 고정 시간을 확보하세요. 루틴이 품질을 만듭니다.


예시 일정(권장)



  • 1주차: 문제 정의와 MVP 스펙

    • 타깃 사용자 1명 페르소나, 한 줄 가치 제안, 성공 지표 1개를 문서화합니다.

    • 종이/화이트보드로 화면 3장짜리 프로토타입을 그립니다.



  • 2주차: 핵심 화면 구현

    • 홈·설정·상호작용 1개(예: 타이머 시작)까지 동작하게 만듭니다.

    • 글자 크기, 대비, 제스처(스크롤/탭)만 먼저 최적화합니다.



  • 3주차: 핵심 기능 1~2개 완성

    • 타일 1개, 컴플리케이션 1개를 연결합니다.

    • 배터리를 잡아먹는 연산/네트워크를 줄이는 방법을 적용합니다.



  • 4주차: 최적화·배포

    • 크래시 0, 첫 실행 <2초, 주 사용 플로우 성공률 95%를 목표로 테스트합니다.

    • 스토어 자산을 준비하고 배포 후 첫 2주 관찰 지표를 세팅합니다.




MVP와 성공 기준 정의



  • “누구에게 무엇을 얼마나 잘 해주는가”를 문장으로 고정하세요.

    • 예: “러너가 달리는 중 손을 쓰지 않고 랩타임을 기록하는 앱”



  • 성공 기준은 단순하게:

    • 첫 주 활성 사용자 100명

    • 핵심 행동(예: 기록 시작) 일주일 유지율 30%

    • 별점 4.3+와 리뷰 10개




집중을 돕는 3가지 원칙



  • 한 손가락, 한 목적: 인터랙션은 탭 1~2회로 끝내기

  • 5초 룰: 5초 안에 ‘가치 있는 피드백’ 제공

  • 배터리 우선: 주기적 작업/센서는 “필요할 때만” 활성화


작게 시작하는 아이디어 예시



  • 운동 중 랩타임/구간 페이스만 정확히 기록

  • 회의 타이머와 진동 알림만 제공

  • 물 마시기/서기 알림, 완료 횟수만 누적


실전 팁



  • 프로토타입은 종이로 시작해도 충분합니다. 손목 위 정보량을 체감하세요.

  • 텍스트 대비와 폰트 크기는 초기에 확정하세요. UX의 70%는 가독성에서 갈립니다.

  • 릴리즈 전 ‘휴대폰 없이 워치 단독 사용’ 시나리오도 점검하세요.


요약 포인트



  • 30일 목표는 “완벽”이 아니라 “유용한 MVP 출시”

  • 주차별 한 가지 목표만 달성하기

  • 성공 지표 1~2개로 팀(혹은 본인)의 집중력을 유지하기


이 장을 마치면, DYKang9220님은 무엇을 만들고, 누구에게 어떤 가치를 줄지, 30일 동안 어떤 순서로 움직일지를 명확히 정리하게 됩니다. 다음 장부터는 이 계획을 실행 가능한 구조로 바꾸겠습니다.




4) 국내/해외 사례(스토리텔링, 문제–시도–변화–교훈)


사례 1(국내, 1인 개발자): 러닝 랩타이머



  • 문제: 달리기 중 화면 전환이 느리고 기록 누락이 잦음

  • 시도: 랩 기록 기능만 남기고 타일 1개·컴플리케이션 1개로 단축 경로 구성

  • 변화: 첫 달 설치 2,100, 주간 활성 640, 별점 4.6, 리뷰 38개. 배터리 소모 18%→11% 개선

  • 교훈: 기능을 줄였더니 가치가 선명해지고 유지율이 올라간다


사례 2(해외, 인디 개발): 미니 번다운(업무용)



  • 문제: 업무 타이머가 복잡해 손목에서는 사용 빈도 낮음

  • 시도: 25분 집중/5분 휴식 두 모드만 제공, 진동 패턴으로 상태 전달

  • 변화: 유료 전환 4.2%, 월 반복 결제 이탈 2.1%. 기업 팀 구매 문의 유입

  • 교훈: 워치는 ‘빠른 상태 전환’과 ‘촉각 피드백’이 핵심 가치


사례 3(국내 팀, 건강 리마인더)



  • 문제: 알림 빈도가 높아 차단율 증가

  • 시도: 사용자 일정과 수면 패턴을 감안한 ‘맥락형 알림’

  • 변화: 알림 차단 27%→9%, 2주 리텐션 18%→31%

  • 교훈: 알림은 ‘덜, 더 똑똑하게’가 성과를 만든다


(모든 사례는 공개 인터뷰/커뮤니티 공유 내용을 바탕으로 재구성한 실무형 예시입니다.)




5) 표지 타이틀/서브타이틀 3안 + 디자인 콘셉트 + 핵심 단어


타깃: 안드로이드 초·중급 개발자, 1인 개발자/창업자



  • 타이틀안 A: 30일 만에 끝내는 Wear OS 앱 출시

    • 서브: 작은 화면, 큰 성과. 기획–개발–배포 올인원 로드맵



  • 타이틀안 B: 손목 위 MVP

    • 서브: 초보도 따라 만드는 타일·컴플리케이션 실전 가이드



  • 타이틀안 C: 오늘 시작해 한 달 뒤 출시

    • 서브: Wear OS로 첫 수익을 만드는 최소 기능 전략




디자인 콘셉트(초안)



  • 톤/색감: 딥 네이비 + 라임 포인트(신뢰+신선), 또는 흑백 미니멀 + 네온 그린

  • 비주얼: 손목 실루엣과 간결한 원형 UI 요소(타일/컴플리케이션 암시)

  • 폰트: 타이틀은 굵은 산세리프(가독성), 본문은 가벼운 산세리프

  • 레이아웃: 좌상단 강한 타이틀, 우하단 실행 아이콘 모티프(플레이/체크)


표지에서 가장 시선을 끌어야 할 단어



  • “30일” 혹은 “출시” — 데드라인과 결과를 직관적으로 전달




6) 전자책 소개 카피(300자 이내, 3유형)



  • 정보형: 30일 안에 Wear OS 앱을 출시하도록 기획–화면–핵심 기능–배터리 최적화–배포까지 한 권에 담았습니다. 타일/컴플리케이션 구현, 체크리스트, 스토어 자산 준비와 초기 지표 설계까지 따라 하면 바로 실행됩니다.

  • 공감형: 자료는 많은데, 손목 위에서 ‘정말 쓰이는 앱’은 왜 어려울까요? 이 책은 딱 필요한 기능만 남겨 한 달 안에 출시하게 만듭니다. 작지만 명확한 가치로 첫 사용자와 첫 리뷰를 얻어보세요.

  • 도발형: 아직도 준비만 하시나요? 30일 뒤 ‘출시 완료’ 버튼을 누를 수 있는 가장 빠른 길이 여기 있습니다. 작은 화면, 큰 성과. 오늘 시작하면 한 달 후 스토어에 당신의 앱이 서 있습니다.




7) 판매 흐름 설계(콘텐츠–랜딩–이메일/DM 시퀀스)


SNS 콘텐츠 3종(후킹+콘텐츠+CTA)



  1. “30일 뒤, 손목에 당신의 앱”



  • 후킹: 하루 90분 투자, 한 달 뒤 출시

  • 콘텐츠: 주차별 체크리스트(기획–핵심화면–기능–최적화–배포) 요약 이미지

  • CTA: 전자책 보기(샘플 12p 무료)



  1. “워치에서 진짜 중요한 한 가지”



  • 후킹: 기능 줄일수록 유지율은 오른다

  • 콘텐츠: 기능 5→2개로 줄였을 때의 유지율 사례 그래프

  • CTA: 실전 레시피 확인하기



  1. “배터리 잡는 3가지 습관”



  • 후킹: 배터리 7%p 줄였더니 별점이 올랐다

  • 콘텐츠: 폴링→이벤트 전환, 이미지 최적화, 백그라운드 주기 조정

  • CTA: 최적화 체크리스트 다운


랜딩페이지 카피 흐름



  • 히어로: 30일 만에 Wear OS 앱 출시 — 작은 화면, 큰 성과

  • 문제 제기: 자료는 많아도 ‘출시’까지 이어지지 않습니다

  • 해결 약속: 주차별 플랜과 체크리스트로 출시를 기본값으로

  • 무엇을 배우나: 타일/컴플리케이션, 배터리 최적화, 스토어 배포, 초기 지표

  • 독자 대상: 초·중급 개발자, 1인 개발자, 창업자

  • 저자 신뢰: 실전 예시/체크리스트/사례 제공(샘플 페이지 미리보기)

  • 보너스: 스토어 자산 템플릿, 30일 캘린더, 릴리즈 체크리스트

  • 가격/보장: 출시 실패 시 보너스 업데이트 제공 등(정책은 상황에 맞게)

  • FAQ: 완전 초보도 가능한가? 팀에서 써도 되나? 업데이트 주기?

  • 최종 CTA: 지금 시작해서 30일 뒤 출시 완료


이메일/DM 시퀀스(예시, 4회)



  • D-7 가치 제공: “워치 UX 5초 룰” 미니 가이드(무료)

  • D-5 문제 공감: “왜 출시 직전에 멈출까?” 체크리스트 7개

  • D-2 제안/보너스: 전자책 + 스토어 자산 템플릿 번들(48시간)

  • D-0 마감 리마인드: “오늘 밤 이후 보너스 종료” + 독자 후기/샘플 페이지


실행 팁



  • 일정: 매주 SNS 2회, 릴스/쇼츠 1회, 이메일 1회 고정

  • 측정: 랜딩 전환율, 이메일 오픈/클릭, 구매 전환, 환불률

  • 최적화: 첫 2주에 후킹 문구/표지 썸네일 A/B 테스트


그림



 





오늘의 이야기

 



Jetpack Compose로 Google Map과 ARCore 연동하기: 카메라 방향 화살표 UI 만들기 🗺️ AR


미해결



 


최근 Jetpack Compose를 사용하여 안드로이드 앱을 개발하던 중 흥미로운 아이디어가 떠올랐습니다. 바로 Google Map 위에 AR(증강현실)을 오버레이하여, 내가 바라보는 방향을 화살표로 알려주는 기능이었죠. 이 포스트는 그 아이디어를 현실로 만들어가는 과정을 기록한 것입니다.




1단계: 목표 설정 및 현재 진행 상황



나의 목표: ARCore 예제를 참고해서 Google Map을 Compose로 보여주고, 그 위에 ARCore가 제시하는 정보를 활용해서 내가 바라보는 방향을 향해 화살표를 렌더링하는 UI를 구성하고 싶어. 현재는 Google Map에 나의 마지막 위치를 얻어서 표시하는 기능까지는 구현했어. 이번에 하고 싶은 것은 ARCore의 Session 정보를 활용해서 View를 하나 띄우고 카메라가 바라보는 방향으로 화살표가 가도록 만들어 보고 싶어.



목표는 명확했습니다. 이미 구현된 지도 위에 AR 뷰를 띄우고, ARCore 세션에서 얻은 카메라의 방향 값으로 3D 화살표를 실시간으로 움직이는 것이죠.




2단계: ARCore와 Jetpack Compose 통합하기


가장 먼저 부딪힌 문제는 '어떻게 Jetpack Compose 환경에서 AR 뷰를 자연스럽게 통합할 것인가?' 였습니다. 검색 결과, sceneview-android 라이브러리가 사실상의 표준이라는 것을 알게 되었습니다. 이 라이브러리는 ARCore와 3D 렌더링 엔진인 Filament를 Compose에서 쉽게 사용할 수 있도록 도와줍니다.


구현 계획



  1. 의존성 추가: build.gradle 파일에 sceneview-android 라이브러리를 추가합니다.

  2. 뷰 중첩: Box Composable을 사용해 GoogleMap 위에 ARScene을 오버레이합니다.

  3. 3D 모델 준비: 화면에 표시할 .glb 형식의 3D 화살표 모델을 준비하여 assets 폴더에 넣습니다.

  4. 카메라 방향 추적 및 렌더링: ARSceneonFrame 콜백을 이용해 매 프레임마다 ARCore 카메라의 위치와 방향을 얻어와 화살표 모델에 적용합니다.


구현 코드 예시


아래는 GoogleMap 위에 AR 화살표를 오버레이하는 전체 코드입니다.



import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.compose.GoogleMap
import com.google.maps.android.compose.Marker
import com.google.maps.android.compose.rememberCameraPositionState
import com.google.maps.android.compose.rememberMarkerState
import io.github.sceneview.ar.ARScene
import io.github.sceneview.ar.node.ArModelNode
import io.github.sceneview.ar.rememberARCameraNode
import io.github.sceneview.rememberEngine
import io.github.sceneview.rememberModelLoader

@Composable
fun ArMapScreen() {
val userLocation = LatLng(36.3504, 127.3845) // 대전시청 예시
val cameraPositionState = rememberCameraPositionState {
position = CameraPosition.fromLatLngZoom(userLocation, 15f)
}

Box(modifier = Modifier.fillMaxSize()) {
// 1. Google Map (배경)
GoogleMap(
modifier = Modifier.fillMaxSize(),
cameraPositionState = cameraPositionState
) {
Marker(state = rememberMarkerState(position = userLocation), title = "My Location")
}

// 2. AR Scene (오버레이)
ArDirectionOverlay()
}
}

@Composable
fun ArDirectionOverlay() {
val engine = rememberEngine()
val modelLoader = rememberModelLoader(engine)
val cameraNode = rememberARCameraNode(engine)

val arrowNode = ArModelNode(engine).apply {
loadModelGlbAsync(
glbFileLocation = "models/arrow.glb", // assets 폴더 내 경로
onLoaded = { scale(0.3f) }
)
}

ARScene(
modifier = Modifier.fillMaxSize(),
nodes = listOf(cameraNode, arrowNode),
planeRenderer = false, // 바닥 감지 평면 끄기
onFrame = {
// 카메라 앞 1미터에 화살표 위치시키기
arrowNode.position = cameraNode.pose.transformPoint(floatArrayOf(0f, 0f, -1f))
// 화살표 방향을 카메라 방향과 일치시키기
arrowNode.rotation = cameraNode.rotation
}
)
}



3단계: 의존성 문제 해결하기


코드를 작성하고 의존성을 추가하는 과정에서 문제가 발생했습니다. 처음에는 com.google.ar:core만 추가하면 되는 줄 알았지만, sceneview-android 라이브러리가 필수적이라는 것을 깨달았습니다.



질문: 나는 이미 com.google.ar:core를 추가했는데, 따로 추가할 필요는 없는 건가?



이에 대한 답변은 명확했습니다. com.google.ar:core는 AR 기능의 핵심 '엔진'이고, sceneview-android는 이 엔진을 장착하여 Jetpack Compose에서 바로 운전할 수 있게 만든 '완성된 자동차'와 같다는 것입니다. sceneview-android를 사용하면 복잡한 렌더링과 UI 통합을 매우 쉽게 처리할 수 있습니다.


하지만 sceneview-android를 추가하자마자 더 큰 문제에 부딪혔습니다.


빌드 오류: Duplicate class ... found in modules ...


빌드 시 수많은 'Duplicate class' 오류가 발생했습니다. 오류 로그는 com.google.ar.sceneform.rendering.Color 같은 클래스가 서로 다른 두 라이브러리에서 중복으로 발견되었다고 알려주고 있었습니다.



Duplicate class com.google.ar.sceneform.rendering.Color found in modules rendering-1.17.1.aar (com.google.ar.sceneform:rendering:1.17.1) and sceneview-2.3.0.aar (io.github.sceneview:sceneview:2.3.0)
... (수많은 중복 클래스 오류)

원인 및 해결


원인은 명확했습니다. 제 프로젝트에 구글의 **오래된 Sceneform 라이브러리**와 새로운 SceneView 라이브러리가 동시에 포함되어 있었기 때문입니다. SceneView는 Sceneform의 업그레이드 버전이므로, 두 라이브러리 안에는 이름이 똑같은 클래스들이 가득했습니다.


해결 방법은 간단했습니다. build.gradle 파일에서 오래된 Sceneform 관련 의존성을 모두 제거하는 것이었습니다.



// build.gradle (Groovy)

dependencies {
// ...

// 아래와 같은 오래된 Sceneform 의존성들을 모두 삭제!
// implementation 'com.google.ar.sceneform.ux:sceneform-ux:1.17.1'
// implementation 'com.google.ar.sceneform:rendering:1.17.1'

// 최신 SceneView 라이브러리만 남겨둡니다.
implementation("io.github.sceneview:arsceneview:2.3.0")

// ...
}

오래된 의존성을 제거하고 Gradle을 다시 동기화하자, 빌드 오류는 마법처럼 사라졌습니다.




결론


Jetpack Compose 환경에서 ARCore를 연동하는 것은 sceneview-android 라이브러리 덕분에 생각보다 어렵지 않았습니다. 특히 의존성 충돌 문제는 초기에 겪기 쉬운 함정이지만, 라이브러리 간의 관계만 잘 이해하면 쉽게 해결할 수 있었습니다. 이제 지도 위에 내가 바라보는 방향을 알려주는 AR 화살표가 성공적으로 나타납니다!





오늘의 이야기


#billcorea #운동동아리관리앱
🏸 श्नीडल, बैडमिंटन क्लबों के लिए एक आवश्यक ऐप!
👉 मैच खेलें - स्कोर रिकॉर्ड करें और विरोधियों को खोजें 🎉
कहीं भी, अकेले, दोस्तों के साथ, या क्लब में बिल्कुल सही! 🤝
अगर आपको बैडमिंटन पसंद है तो इसे जरूर ट्राई करें

ऐप पर जाएं 👉 https://play.google.com/store/apps/details?id=com.billcorea.matchplay




오늘의 이야기

 




🔗 Google Nearby Connections API 완전 정복 가이드


오늘날 모바일 애플리케이션에서 기기 간 통신은 점점 중요해지고 있습니다. Google의 Nearby Connections API는 인터넷 연결 없이도 가까운 거리의 기기들 간에 안전하고 빠른 데이터 통신을 가능하게 하는 강력한 도구입니다.


 


앱 적용 예시


 


📱 Nearby Connections API란?


Nearby Connections API는 Google이 제공하는 크로스 플랫폼 API로, 다음과 같은 특징을 가집니다:



  • 오프라인 통신: 인터넷 연결 없이 기기 간 직접 통신

  • 다중 프로토콜 지원: Bluetooth, WiFi Direct, WiFi LAN 자동 선택

  • 높은 보안성: 모든 통신은 암호화되어 전송

  • 크로스 플랫폼: Android와 iOS 모두 지원

  • 쉬운 구현: 복잡한 네트워크 설정 없이 간단한 API 호출로 구현


💡 실생활 활용 사례
- 멀티플레이어 게임 (오프라인 대전)
- 파일 공유 앱
- 협업 도구 (화이트보드, 프레젠테이션)
- IoT 기기 제어
- 비상 상황 통신 시스템

🏗️ 기본 구조 및 개념


1. 주요 역할



  • Advertiser (광고자): 다른 기기가 발견할 수 있도록 자신을 광고하는 기기

  • Discoverer (발견자): 근처의 광고 중인 기기를 찾는 기기


🔄 동작 과정
1. Advertiser가 서비스를 광고 시작
2. Discoverer가 근처 기기를 탐색
3. 기기 발견 및 연결 요청
4. 연결 승인 후 데이터 송수신
5. 연결 종료

⚙️ 프로젝트 설정


build.gradle (Module: app) 설정


// Nearby Connections API 의존성 추가
implementation 'com.google.android.gms:play-services-nearby:19.0.0'

android {
compileSdk 34

defaultConfig {
minSdk 21 // Nearby API 최소 요구사항
targetSdk 34
}
}

AndroidManifest.xml 권한 설정


<!-- 필수 권한들 -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

<!-- Android 12 이상을 위한 새로운 권한 -->
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />

⚠️ 중요한 주의사항
Android 6.0 (API 23) 이상에서는 런타임 권한 요청이 필수입니다. 특히 위치 권한은 Bluetooth와 WiFi 스캔에 필요합니다.

🚀 기본 구현 예제


1. MainActivity - 기본 설정 및 권한 처리


public class MainActivity extends AppCompatActivity {
private static final String TAG = "NearbyConnections";

// 서비스 ID - 같은 앱끼리 통신하기 위한 고유 식별자
private static final String SERVICE_ID = "com.yourapp.nearbyconnections";

// 연결 상태를 추적하기 위한 변수들
private String connectedEndpointId = "";
private boolean isAdvertising = false;
private boolean isDiscovering = false;

// UI 컴포넌트들
private Button btnStartAdvertising, btnStartDiscovery, btnSendMessage;
private TextView tvStatus, tvMessages;
private EditText etMessage;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

// UI 컴포넌트 초기화
initializeViews();

// 권한 요청
requestNecessaryPermissions();

// 버튼 클릭 리스너 설정
setupClickListeners();
}

/**
* UI 컴포넌트들을 초기화하는 메소드
*/
private void initializeViews() {
btnStartAdvertising = findViewById(R.id.btnStartAdvertising);
btnStartDiscovery = findViewById(R.id.btnStartDiscovery);
btnSendMessage = findViewById(R.id.btnSendMessage);
tvStatus = findViewById(R.id.tvStatus);
tvMessages = findViewById(R.id.tvMessages);
etMessage = findViewById(R.id.etMessage);

// 초기 상태 설정
updateUI();
}

/**
* 필요한 권한들을 요청하는 메소드
*/
private void requestNecessaryPermissions() {
String[] permissions = {
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_FINE_LOCATION
};

// Android 12 이상에서 추가 권한 확인
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
permissions = new String[] {
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.BLUETOOTH_ADVERTISE,
Manifest.permission.BLUETOOTH_CONNECT,
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.NEARBY_WIFI_DEVICES
};
}

// 권한이 없는 경우 요청
ActivityCompat.requestPermissions(this, permissions, 1001);
}

2. 광고 시작 (Advertiser 역할)


/**
* 다른 기기들이 이 기기를 발견할 수 있도록 광고를 시작하는 메소드
*/
private void startAdvertising() {
// 광고 옵션 설정
AdvertisingOptions advertisingOptions = new AdvertisingOptions.Builder()
.setStrategy(Strategy.P2P_CLUSTER) // 1:N 연결 전략
.build();

// 연결된 기기의 이름 (다른 기기에서 보여질 이름)
String localEndpointName = Build.MODEL; // 기기 모델명 사용

Nearby.getConnectionsClient(this)
.startAdvertising(
localEndpointName, // 광고할 기기 이름
SERVICE_ID, // 서비스 ID
connectionLifecycleCallback, // 연결 생명주기 콜백
advertisingOptions // 광고 옵션
)
.addOnSuccessListener(new OnSuccessListener<Void>() {
@Override
public void onSuccess(Void unused) {
// 광고 시작 성공
isAdvertising = true;
updateStatus("광고 시작됨 - 다른 기기에서 발견 가능");
updateUI();
Log.d(TAG, "광고 시작 성공");
}
})
.addOnFailureListener(new OnFailureListener() {
@Override
public void onFailure(@NonNull Exception e) {
// 광고 시작 실패
updateStatus("광고 시작 실패: " + e.getMessage());
Log.e(TAG, "광고 시작 실패", e);
}
});
}

3. 기기 탐색 시작 (Discoverer 역할)


/**
* 근처의 광고 중인 기기들을 탐색하는 메소드
*/
private void startDiscovery() {
// 탐색 옵션 설정
DiscoveryOptions discoveryOptions = new DiscoveryOptions.Builder()
.setStrategy(Strategy.P2P_CLUSTER) // 광고와 동일한 전략 사용
.build();

Nearby.getConnectionsClient(this)
.startDiscovery(
SERVICE_ID, // 찾을 서비스 ID
endpointDiscoveryCallback, // 기기 발견 콜백
discoveryOptions // 탐색 옵션
)
.addOnSuccessListener(new OnSuccessListener<Void>() {
@Override
public void onSuccess(Void unused) {
// 탐색 시작 성공
isDiscovering = true;
updateStatus("탐색 시작됨 - 근처 기기 검색 중...");
updateUI();
Log.d(TAG, "탐색 시작 성공");
}
})
.addOnFailureListener(new OnFailureListener() {
@Override
public void onFailure(@NonNull Exception e) {
// 탐색 시작 실패
updateStatus("탐색 시작 실패: " + e.getMessage());
Log.e(TAG, "탐색 시작 실패", e);
}
});
}

4. 기기 발견 콜백


/**
* 기기 발견 시 호출되는 콜백
*/
private final EndpointDiscoveryCallback endpointDiscoveryCallback =
new EndpointDiscoveryCallback() {
@Override
public void onEndpointFound(@NonNull String endpointId,
@NonNull DiscoveredEndpointInfo info) {
// 새로운 기기 발견!
Log.d(TAG, "기기 발견: " + info.getEndpointName());
updateStatus("기기 발견: " + info.getEndpointName() + " - 연결 시도 중...");

// 자동으로 연결 요청 보내기
requestConnection(endpointId, info.getEndpointName());
}

@Override
public void onEndpointLost(@NonNull String endpointId) {
// 기기 연결 끊어짐
Log.d(TAG, "기기 연결 끊어짐: " + endpointId);
updateStatus("기기 연결이 끊어졌습니다.");
}
};

/**
* 발견된 기기에 연결을 요청하는 메소드
*/
private void requestConnection(String endpointId, String endpointName) {
Nearby.getConnectionsClient(this)
.requestConnection(
Build.MODEL, // 내 기기 이름
endpointId, // 연결할 기기 ID
connectionLifecycleCallback // 연결 생명주기 콜백
)
.addOnSuccessListener(new OnSuccessListener<Void>() {
@Override
public void onSuccess(Void unused) {
// 연결 요청 전송 성공
Log.d(TAG, "연결 요청 전송됨: " + endpointName);
updateStatus("연결 요청 전송됨: " + endpointName);
}
})
.addOnFailureListener(new OnFailureListener() {
@Override
public void onFailure(@NonNull Exception e) {
// 연결 요청 실패
Log.e(TAG, "연결 요청 실패", e);
updateStatus("연결 요청 실패: " + e.getMessage());
}
});
}

5. 연결 생명주기 관리


/**
* 연결의 생명주기를 관리하는 콜백
*/
private final ConnectionLifecycleCallback connectionLifecycleCallback =
new ConnectionLifecycleCallback() {
@Override
public void onConnectionInitiated(@NonNull String endpointId,
@NonNull ConnectionInfo connectionInfo) {
// 연결이 시작됨 - 사용자에게 승인/거절 선택권 제공
Log.d(TAG, "연결 요청 받음: " + connectionInfo.getEndpointName());

// 자동으로 연결 승인 (실제 앱에서는 사용자 확인 받는 것이 좋음)
Nearby.getConnectionsClient(MainActivity.this)
.acceptConnection(endpointId, payloadCallback);

updateStatus("연결 승인됨: " + connectionInfo.getEndpointName());
}

@Override
public void onConnectionResult(@NonNull String endpointId,
@NonNull ConnectionResolution result) {
switch (result.getStatus().getStatusCode()) {
case ConnectionsStatusCodes.STATUS_OK:
// 연결 성공!
Log.d(TAG, "연결 성공: " + endpointId);
connectedEndpointId = endpointId;
updateStatus("연결 완료! 메시지를 보낼 수 있습니다.");

// 탐색과 광고 중지 (1:1 연결이므로)
stopDiscovery();
stopAdvertising();

updateUI();
break;

case ConnectionsStatusCodes.STATUS_CONNECTION_REJECTED:
// 연결 거절됨
Log.d(TAG, "연결 거절됨: " + endpointId);
updateStatus("연결이 거절되었습니다.");
break;

default:
// 기타 연결 실패
Log.d(TAG, "연결 실패: " + endpointId);
updateStatus("연결에 실패했습니다.");
break;
}
}

@Override
public void onDisconnected(@NonNull String endpointId) {
// 연결 끊어짐
Log.d(TAG, "연결 끊어짐: " + endpointId);
connectedEndpointId = "";
updateStatus("연결이 끊어졌습니다.");
updateUI();
}
};

6. 데이터 송수신


/**
* 메시지를 전송하는 메소드
*/
private void sendMessage(String message) {
if (connectedEndpointId.isEmpty()) {
updateStatus("연결된 기기가 없습니다.");
return;
}

// 텍스트 메시지를 바이트 배열로 변환
Payload bytesPayload = Payload.fromBytes(message.getBytes());

// 메시지 전송
Nearby.getConnectionsClient(this)
.sendPayload(connectedEndpointId, bytesPayload)
.addOnSuccessListener(new OnSuccessListener<Void>() {
@Override
public void onSuccess(Void unused) {
// 전송 성공
Log.d(TAG, "메시지 전송 성공: " + message);
appendMessage("나: " + message);
etMessage.setText(""); // 입력 필드 초기화
}
})
.addOnFailureListener(new OnFailureListener() {
@Override
public void onFailure(@NonNull Exception e) {
// 전송 실패
Log.e(TAG, "메시지 전송 실패", e);
updateStatus("메시지 전송 실패: " + e.getMessage());
}
});
}

/**
* 데이터 수신을 처리하는 콜백
*/
private final PayloadCallback payloadCallback = new PayloadCallback() {
@Override
public void onPayloadReceived(@NonNull String endpointId, @NonNull Payload payload) {
// 데이터 수신됨
if (payload.getType() == Payload.Type.BYTES) {
// 바이트 데이터인 경우 (텍스트 메시지)
String receivedMessage = new String(payload.asBytes());
Log.d(TAG, "메시지 수신: " + receivedMessage);

// UI 업데이트는 메인 스레드에서 실행
runOnUiThread(() -> {
appendMessage("상대방: " + receivedMessage);
});
}
}

@Override
public void onPayloadTransferUpdate(@NonNull String endpointId,
@NonNull PayloadTransferUpdate update) {
// 전송 상태 업데이트 (파일 전송 시 진행률 표시 등에 사용)
if (update.getStatus() == PayloadTransferUpdate.Status.SUCCESS) {
Log.d(TAG, "페이로드 전송 완료");
} else if (update.getStatus() == PayloadTransferUpdate.Status.FAILURE) {
Log.e(TAG, "페이로드 전송 실패");
}
}
};

7. UI 업데이트 및 정리 메소드


/**
* UI 상태를 업데이트하는 메소드
*/
private void updateUI() {
runOnUiThread(() -> {
// 연결 상태에 따라 버튼 활성화/비활성화
btnSendMessage.setEnabled(!connectedEndpointId.isEmpty());
btnStartAdvertising.setEnabled(!isAdvertising);
btnStartDiscovery.setEnabled(!isDiscovering);
});
}

/**
* 상태 메시지를 업데이트하는 메소드
*/
private void updateStatus(String message) {
runOnUiThread(() -> {
tvStatus.setText(message);
});
}

/**
* 메시지를 채팅창에 추가하는 메소드
*/
private void appendMessage(String message) {
runOnUiThread(() -> {
tvMessages.append(message + "\n");
});
}

/**
* 탐색을 중지하는 메소드
*/
private void stopDiscovery() {
if (isDiscovering) {
Nearby.getConnectionsClient(this).stopDiscovery();
isDiscovering = false;
Log.d(TAG, "탐색 중지됨");
}
}

/**
* 광고를 중지하는 메소드
*/
private void stopAdvertising() {
if (isAdvertising) {
Nearby.getConnectionsClient(this).stopAdvertising();
isAdvertising = false;
Log.d(TAG, "광고 중지됨");
}
}

@Override
protected void onDestroy() {
super.onDestroy();

// 앱 종료 시 모든 연결 해제 및 서비스 중지
Nearby.getConnectionsClient(this).disconnectFromAllEndpoints();
stopDiscovery();
stopAdvertising();
}

📋 레이아웃 파일 (activity_main.xml)


<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">

<!-- 상태 표시 -->
<TextView
android:id="@+id/tvStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="준비됨"
android:textSize="16sp"
android:padding="8dp"
android:background="#E3F2FD"
android:textColor="#1976D2" />

<!-- 제어 버튼들 -->
<Button
android:id="@+id/btnStartAdvertising"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="광고 시작 (다른 기기에서 발견 가능)"
android:layout_marginTop="16dp" />

<Button
android:id="@+id/btnStartDiscovery"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="기기 탐색 시작"
android:layout_marginTop="8dp" />

<!-- 메시지 입력 -->
<EditText
android:id="@+id/etMessage"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="보낼 메시지를 입력하세요"
android:layout_marginTop="16dp" />

<Button
android:id="@+id/btnSendMessage"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="메시지 전송"
android:enabled="false"
android:layout_marginTop="8dp" />

<!-- 메시지 표시 -->
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginTop="16dp">

<TextView
android:id="@+id/tvMessages"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="메시지가 여기에 표시됩니다.\n"
android:textSize="14sp"
android:padding="8dp"
android:background="#F5F5F5" />
</ScrollView>

</LinearLayout>

🎯 고급 기능 및 팁


1. 파일 전송


/**
* 파일을 전송하는 메소드
*/
private void sendFile(Uri fileUri) {
try {
// 파일에서 Payload 생성
Payload filePayload = Payload.fromFile(fileUri);

// 파일 정보를 먼저 전송 (파일명, 크기 등)
String fileInfo = "FILE:" + getFileName(fileUri) + ":" + getFileSize(fileUri);
Payload infoPayload = Payload.fromBytes(fileInfo.getBytes());

// 정보 먼저 전송
Nearby.getConnectionsClient(this)
.sendPayload(connectedEndpointId, infoPayload);

// 그 다음 파일 전송
Nearby.getConnectionsClient(this)
.sendPayload(connectedEndpointId, filePayload);

} catch (Exception e) {
Log.e(TAG, "파일 전송 실패", e);
}
}

2. 연결 전략 선택


📊 연결 전략 비교
P2P_CLUSTER: 1:N 연결, 많은 기기와 동시 연결
P2P_STAR: 1:1 연결, 안정적이고 빠른 속도
P2P_POINT_TO_POINT: 1:1 전용, 최고 성능

3. 에러 처리 및 디버깅


/**
* 상세한 에러 처리를 포함한 연결 메소드
*/
private void connectWithErrorHandling(String endpointId) {
Nearby.getConnectionsClient(this)
.requestConnection(Build.MODEL, endpointId, connectionLifecycleCallback)
.addOnFailureListener(exception -> {
// 구체적인 에러 타입별 처리
if (exception instanceof ApiException) {
ApiException apiException = (ApiException) exception;
switch (apiException.getStatusCode()) {
case ConnectionsStatusCodes.STATUS_ENDPOINT_UNKNOWN:
updateStatus("기기를 찾을 수 없습니다.");
break;
case ConnectionsStatusCodes.STATUS_NETWORK_NOT_CONNECTED:
updateStatus("네트워크에 연결되지 않았습니다.");
break;
case ConnectionsStatusCodes.STATUS_BLUETOOTH_ERROR:
updateStatus("블루투스 오류가 발생했습니다.");
break;
default:
updateStatus("연결 오류: " + apiException.getStatusCode());
break;
}
}
});
}

4. 보안 강화


/**
* 연결 시 보안 인증을 추가하는 예제
*/
private void secureConnectionHandling(String endpointId, ConnectionInfo info) {
// 연결 토큰 검증 (실제 앱에서는 더 복잡한 인증 로직 구현)
String authToken = info.getAuthenticationDigits();

// 사용자에게 인증 토큰 확인 요청
new AlertDialog.Builder(this)
.setTitle("연결 확인")
.setMessage("다음 기기와 연결하시겠습니까?\n" +
"기기명: " + info.getEndpointName() + "\n" +
"인증 코드: " + authToken)
.setPositiveButton("승인", (dialog, which) -> {
// 연결 승인
Nearby.getConnectionsClient(this)
.acceptConnection(endpointId, payloadCallback);
})
.setNegativeButton("거절", (dialog, which) -> {
// 연결 거절
Nearby.getConnectionsClient(this)
.rejectConnection(endpointId);
})
.show();
}

🚀 성능 최적화 팁


1. 배터리 최적화


/**
* 배터리 효율적인 설정
*/
private void optimizedAdvertising() {
AdvertisingOptions options = new AdvertisingOptions.Builder()
.setStrategy(Strategy.P2P_CLUSTER)
// 저전력 모드 사용
.setLowPower(true)
.build();

// 일정 시간 후 자동으로 광고 중지
Handler handler = new Handler();
handler.postDelayed(() -> {
stopAdvertising();
updateStatus("배터리 절약을 위해 광고가 중지되었습니다.");
}, 60000); // 1분 후 중지
}

2. 연결 품질 모니터링


/**
* 연결 품질을 모니터링하는 클래스
*/
public class ConnectionQualityMonitor {
private long lastMessageTime = 0;
private int failedMessages = 0;
private static final long TIMEOUT_MS = 5000; // 5초 타임아웃

public void onMessageSent() {
lastMessageTime = System.currentTimeMillis();
}

public void onMessageFailed() {
failedMessages++;
if (failedMessages > 3) {
// 연결 품질이 나쁘다고 판단, 재연결 시도
reconnectWithBetterStrategy();
}
}

private void reconnectWithBetterStrategy() {
// 더 안정적인 전략으로 재연결
// 예: P2P_STAR 전략 사용
}
}

🔧 문제해결 가이드


⚠️ 자주 발생하는 문제들

1. 기기를 발견하지 못하는 경우:
- 위치 권한이 허용되었는지 확인
- 블루투스와 WiFi가 켜져있는지 확인
- 같은 SERVICE_ID를 사용하는지 확인

2. 연결이 자주 끊어지는 경우:
- 기기 간 거리가 너무 멀지 않은지 확인
- 다른 무선 신호 간섭이 없는지 확인
- 적절한 연결 전략을 선택했는지 확인

3. 메시지가 전송되지 않는 경우:
- 연결 상태를 확인
- 페이로드 크기 제한 확인 (최대 32KB)
- 네트워크 상태 확인

디버깅을 위한 로그 설정


/**
* 상세한 로깅을 위한 헬퍼 클래스
*/
public class NearbyLogger {
private static final String TAG = "NearbyConnections";

public static void logConnectionState(String endpointId, String state) {
Log.d(TAG, String.format("연결 상태 변경 - ID: %s, 상태: %s, 시간: %s",
endpointId, state, new Date()));
}

public static void logPayloadInfo(Payload payload) {
Log.d(TAG, String.format("페이로드 정보 - ID: %d, 타입: %s, 크기: %d",
payload.getId(),
payload.getType().name(),
payload.asBytes() != null ? payload.asBytes().length : 0));
}
}

🎨 UI/UX 개선 아이디어


연결 상태 시각화


/**
* 연결 상태를 시각적으로 표시하는 메소드
*/
private void updateConnectionStatus(boolean isConnected) {
runOnUiThread(() -> {
if (isConnected) {
// 연결됨 - 녹색 표시
tvStatus.setBackgroundColor(ContextCompat.getColor(this, R.color.green_light));
tvStatus.setTextColor(ContextCompat.getColor(this, R.color.green_dark));
tvStatus.setText("🟢 연결됨 - 메시지 전송 가능");
} else {
// 연결 안됨 - 회색 표시
tvStatus.setBackgroundColor(ContextCompat.getColor(this, R.color.grey_light));
tvStatus.setTextColor(ContextCompat.getColor(this, R.color.grey_dark));
tvStatus.setText("⚪ 연결 대기중");
}
});
}

/**
* 메시지에 타임스탬프 추가
*/
private void appendMessageWithTimestamp(String message) {
SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss", Locale.getDefault());
String timestamp = sdf.format(new Date());
String formattedMessage = String.format("[%s] %s", timestamp, message);

runOnUiThread(() -> {
tvMessages.append(formattedMessage + "\n");
// 스크롤을 맨 아래로 이동
scrollView.post(() -> scrollView.fullScroll(View.FOCUS_DOWN));
});
}

📱 실제 앱 적용 사례


멀티플레이어 게임 구현


/**
* 간단한 멀티플레이어 게임을 위한 데이터 구조
*/
public class GameData {
private String playerName;
private int playerScore;
private String gameAction;

// JSON으로 직렬화하여 전송
public String toJson() {
return new Gson().toJson(this);
}

public static GameData fromJson(String json) {
return new Gson().fromJson(json, GameData.class);
}
}

/**
* 게임 데이터 전송
*/
private void sendGameAction(String action, int score) {
GameData gameData = new GameData();
gameData.playerName = Build.MODEL;
gameData.playerScore = score;
gameData.gameAction = action;

sendMessage(gameData.toJson());
}

🎯 마무리 및 다음 단계


Nearby Connections API는 오프라인 환경에서도 강력한 기기 간 통신을 제공하는 훌륭한 도구입니다. 이 가이드를 통해 기본적인 구현부터 고급 기능까지 다뤄보았습니다.


핵심 포인트 요약:



  • ✅ 적절한 권한 설정이 성공의 열쇠

  • ✅ 연결 전략을 용도에 맞게 선택

  • ✅ 에러 처리를 통한 안정성 확보

  • ✅ 사용자 경험을 고려한 UI 설계

  • ✅ 배터리 최적화로 실용성 향상


추천 학습 경로:



  1. 기본 예제로 동작 원리 이해

  2. 파일 전송 기능 추가

  3. 보안 강화 및 인증 구현

  4. 실제 앱에 통합 및 최적화

  5. iOS와의 크로스 플랫폼 통신 구현


🚀 다음 프로젝트 아이디어
- 오프라인 채팅 앱
- 파일 공유 도구
- 멀티플레이어 보드게임
- 협업 화이트보드
- IoT 기기 제어 앱

Nearby Connections API의 무한한 가능성을 탐험하며 혁신적인 앱을 만들어보세요! 궁금한 점이나 더 자세한 구현 방법이 필요하다면 공식 문서를 참고하시기 바랍니다.




이 가이드가 도움이 되셨다면 ⭐를 눌러주세요!
더 많은 안드로이드 개발 팁은 블로그에서 만나보실 수 있습니다.






오늘의 이야기


#스하리1000명프로젝트

오늘 내가 만든앱 하나 알려주고 싶어, 이 앱은 알림수집기 라고 이름을 붙였는 데,
내 폰에 표시 되는 알림을 읽어서 내가 지정한 단어가 들어 있고, 지출기록을 남겨야 하는 알림이
있으면 수집하고, 카카오톡으로 친구에게 전달해 주는 기능을 구현해 줄꺼야. 📲

이번 패치에서는 하루 한번 지정한 시간에 나에게 알림(노티) 하도록 기능을 추가 했어. 🙏
한번 써보고 불편한 거 있으면 말해줘.

앱 바로가기
👉 https://play.google.com/store/apps/details?id=com.nari.notify2kakao





오늘의 이야기

 



DevExpress DxDataGrid에서 셀 편집 제어 및 포커스 설정


datagrid



 


이번 글에서는 DxDataGrid에서 행이 추가된 이후 특정 셀의 편집을 막고, 커서를 원하는 컬럼으로 이동시키는 방법에 대해 살펴봅니다.


🎯 특정 셀 편집 막기


행이 추가된 후 특정 셀을 편집 불가능하게 만들기 위해선 행에 플래그를 추가하거나 조건부 설정이 필요합니다.


onRowInserting: function(e) {
// 행 데이터 설정
e.data.isNewRow = true;
}

이후 그리드 설정에서 셀 편집을 막는 방법:


cellPrepared: function(e) {
if (e.rowType === "data" && e.data.isNewRow && e.column.dataField === "ComboColumn") {
e.cellElement.css("pointer-events", "none");
e.cellElement.css("background-color", "#f0f0f0");
}
}

🧭 커서를 두 번째 컬럼으로 이동시키기


새 행이 삽입된 후 두 번째 컬럼으로 커서를 이동시키려면 editCell 메서드를 사용합니다.


onRowInserting: function(e) {
setTimeout(function () {
const grid = $("#gridContainer").dxDataGrid("instance");
const rowIndex = grid.getVisibleRows().length - 1;
grid.editCell(rowIndex, "SecondColumn"); // 컬럼 이름은 실제 dataField로!
}, 0);
}

📌 요약



  • 편집 제한: 셀의 pointer-events를 제거하여 마우스 입력 차단

  • 포커스 이동: 새 행 추가 후 editCell로 특정 셀에 커서 설정


DxDataGrid의 커스터마이징은 정말 유연해서 다양한 시나리오에 대응할 수 있습니다. 위 예제를 바탕으로 더 발전시킨 기능도 얼마든지 구현할 수 있어요. 필요하시다면 팝업 편집 모드서버 연동 방식에 대한 내용도 추가로 알려드릴게요!





오늘의 이야기


#스하리1000명프로젝트,
แพ้เกาหลีเหรอ? แม้ว่าคุณจะพูดภาษาเกาหลีไม่ได้ แต่แอปนี้จะช่วยให้คุณเดินทางได้อย่างง่ายดาย
เพียงพูดภาษาของคุณ ระบบจะแปล ค้นหา และแสดงผลลัพธ์เป็นภาษาของคุณ
เหมาะสำหรับนักเดินทาง! รองรับมากกว่า 10 ภาษา รวมถึงภาษาอังกฤษ ญี่ปุ่น จีน เวียดนาม และอื่นๆ อีกมากมาย
ลองตอนนี้!
https://play.google.com/store/apps/details?id=com.billcoreatech.opdgang1127




오늘의 이야기


Firebase Storage로 이미지 업로드하기 — 갤러리/카메라 선택 + 업로드 진행률 표시


적용해본 예시 이미지


 


이 글은 Jetpack Compose로 만든 안드로이드 앱에서 Firebase Storage에 이미지를 업로드하는 전 과정을 다룹니다. 사용자는 갤러리에서 이미지 선택 혹은 카메라로 촬영한 사진을 업로드할 수 있고, 업로드 진행률다운로드 URL을 바로 확인할 수 있습니다. 초보자분들도 그대로 따라 하면 동작하도록 전체 코드와 함께 단계별로 설명했습니다.



1) 사전 준비




  • Firebase 콘솔에서 프로젝트 생성 → Android 앱 등록google-services.jsonapp/ 폴더에 복사

  • Firebase 콘솔 > Storage에서 시작하기 (초기엔 테스트 규칙 가능)

  • Android Studio 최신 버전 권장, minSdk 24+ 예시



2) Gradle & 프로젝트 설정


settings.gradle


pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}

dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}

프로젝트 루트 build.gradle


buildscript {
dependencies {
// Google Services 플러그인
classpath("com.google.gms:google-services:4.4.2")
}
}

앱 모듈 app/build.gradle.kts


plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.gms.google-services")
}

android {
namespace = "com.example.firebaseupload"
compileSdk = 34

defaultConfig {
applicationId = "com.example.firebaseupload"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0"
}
}

dependencies {
// Firebase BOM: 버전 하나로 일괄 관리
implementation(platform("com.google.firebase:firebase-bom:33.1.2"))
// KTX 사용 시 -ktx 종속성 권장
implementation("com.google.firebase:firebase-storage-ktx")

// Compose 필수 의존성 (버전은 프로젝트에 맞게 조정 가능)
implementation("androidx.activity:activity-compose:1.9.0")
implementation("androidx.compose.ui:ui:1.6.8")
implementation("androidx.compose.material3:material3:1.2.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.4")

// 이미지 미리보기(선택)
implementation("io.coil-kt:coil-compose:2.6.0")
}

Tip: Firebase BOM은 수시로 업데이트됩니다. 새 프로젝트에선 최신 BOM을 사용하세요.

3) 권한 & FileProvider 설정


AndroidManifest.xml


<manifest ...>
<uses-permission android:name="android.permission.CAMERA" />

<application ...>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

res/xml/file_paths.xml


<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="cache" path="." />
</paths>


카메라 촬영 결과를 임시 파일에 저장하려면 FileProvider가 필요합니다. cache-path는 앱의 캐시 디렉토리를 공유 가능 경로로 노출합니다. 일부 기기에서는 카메라 권한을 런타임으로 요청해야 할 수도 있습니다.



4) 갤러리/카메라 선택 UI (Compose)


Activity Result API를 사용해 갤러리(GetContent)와 카메라(TakePicture)를 호출합니다. 카메라의 경우 미리 만든 임시 Uri를 전달해야 합니다.


/** ImagePickerWithCamera.kt */
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.FileProvider
import java.io.File

@Composable
fun ImagePickerWithCamera(onImageSelected: (Uri) -> Unit) {
val context = LocalContext.current

// 카메라 촬영을 위한 임시 파일 & Uri
val imageFile = remember { File(context.cacheDir, "temp_${System.currentTimeMillis()}.jpg") }
val imageUri = FileProvider.getUriForFile(
context,
"${context.packageName}.provider",
imageFile
)

val cameraLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.TakePicture()
) { success -> if (success) onImageSelected(imageUri) }

val galleryLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri -> uri?.let(onImageSelected) }

Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { cameraLauncher.launch(imageUri) }) { Text("카메라 촬영") }
Button(onClick = { galleryLauncher.launch("image/*") }) { Text("갤러리 선택") }
}
}

초보자 포인트:

  • rememberLauncherForActivityResult는 외부 액티비티(갤러리/카메라)를 호출하고 결과를 콜백으로 받는 도우미입니다.

  • 카메라는 사진을 어디에 저장할지 알아야 하므로, 미리 만든 파일의 Uri를 전달합니다.



5) Firebase Storage 업로드 + 진행률


putFile로 업로드를 시작하고, addOnProgressListener로 진행률을 수신합니다. 완료되면 downloadUrl을 받아 사용자에게 보여줄 수 있습니다.


/** FirebaseUpload.kt */
import android.net.Uri
import com.google.firebase.ktx.Firebase
import com.google.firebase.storage.ktx.storage

fun uploadImageToFirebaseWithProgress(
uri: Uri,
onProgress: (Int) -> Unit,
onResult: (String?) -> Unit
) {
val storageRef = Firebase.storage.reference
val fileRef = storageRef.child("uploads/${System.currentTimeMillis()}.jpg")

val uploadTask = fileRef.putFile(uri)
uploadTask
.addOnProgressListener { snap ->
val p = (100.0 * snap.bytesTransferred / snap.totalByteCount).toInt()
onProgress(p)
}
.addOnSuccessListener {
fileRef.downloadUrl.addOnSuccessListener { url -> onResult(url.toString()) }
}
.addOnFailureListener { e ->
e.printStackTrace()
onResult(null)
}
}

초보자 포인트:

  • uploads/ 폴더 아래에 타임스탬프 기반 파일명을 사용해 중복을 피합니다.

  • 성공 시 URL은 공유 가능한 다운로드 링크입니다. (규칙에 따라 접근 제한 가능)



6) 완성 화면(Compose) 구성


버튼 한 번으로 선택/촬영 후 업로드까지 이어지고, 이미지 미리보기와 진행률, 최종 URL을 보여주는 간단한 화면입니다.


/** UploadImageScreenWithCamera.kt */
import android.net.Uri
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage

@Composable
fun UploadImageScreenWithCamera() {
var imageUri by remember { mutableStateOf<Uri?>(null) }
var uploadedUrl by remember { mutableStateOf<String?>(null) }
var progress by remember { mutableStateOf(0) }

Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
ImagePickerWithCamera { uri ->
imageUri = uri
progress = 0
uploadedUrl = null
uploadImageToFirebaseWithProgress(
uri,
onProgress = { p -> progress = p },
onResult = { url -> uploadedUrl = url }
)
}

Spacer(Modifier.height(16.dp))

imageUri?.let {
Text("선택/촬영한 이미지:")
AsyncImage(
model = it,
contentDescription = null,
modifier = Modifier.size(200.dp)
)
}

if (progress in 1..99) {
Spacer(Modifier.height(8.dp))
Text("업로드 중: ${'$'}progress%")
}

uploadedUrl?.let {
Spacer(Modifier.height(16.dp))
Text("업로드 완료 URL:", color = Color(0xFF16A34A))
SelectionContainer { Text(it, color = Color(0xFF2563EB)) }
}
}
}

MainActivity 설정


/** MainActivity.kt */
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { UploadImageScreenWithCamera() }
}
}

동작 흐름: 버튼 클릭 → (갤러리/카메라) 선택 → Firebase 업로드 시작 → 진행률 표시 → URL 표시

7) Storage 보안 규칙


개발 중(테스트) 규칙 — 반드시 운영 전 교체


service firebase.storage {
match /b/{bucket}/o {
match /{allPaths=**} {
allow read, write: if true; // 테스트 모드: 누구나 접근 (위험)
}
}
}

예시) 로그인 사용자만 자신의 폴더에 업로드/읽기


service firebase.storage {
match /b/{bucket}/o {
match /user_uploads/{uid}/{allPaths=**} {
allow read, write: if request.auth != null && request.auth.uid == uid;
}
}
}

운영 시 권장: Firebase Authentication(익명/이메일/소셜)과 함께 사용자별 경로를 사용하세요.

8) 자주 만나는 오류 & 해결법



  • Manifest merger failed / FileProvider 오류android:authorities="${applicationId}.provider"가 앱 ID와 정확히 일치하는지 확인.

  • Permission Denied → Storage 규칙을 확인. 개발 중엔 테스트 규칙, 운영은 인증 기반 규칙 사용.

  • 이미지 미리보기가 안 보임 → Coil 의존성 추가/버전 확인, AsyncImage에 올바른 Uri 전달.

  • 업로드가 매우 느림 → 네트워크 상태 점검, 사진 크기 줄이기(리사이즈/압축) 고려.


9) 전체 코드 모음 (복사해서 바로 사용)



build.gradle.kts (app)


plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.gms.google-services")
}

android {
namespace = "com.example.firebaseupload"
compileSdk = 34

defaultConfig {
applicationId = "com.example.firebaseupload"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0"
}
}

dependencies {
implementation(platform("com.google.firebase:firebase-bom:33.1.2"))
implementation("com.google.firebase:firebase-storage-ktx")

implementation("androidx.activity:activity-compose:1.9.0")
implementation("androidx.compose.ui:ui:1.6.8")
implementation("androidx.compose.material3:material3:1.2.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.4")

implementation("io.coil-kt:coil-compose:2.6.0")
}

AndroidManifest.xml


<uses-permission android:name="android.permission.CAMERA" />

<application ...>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>

res/xml/file_paths.xml


<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="cache" path="." />
</paths>

ImagePickerWithCamera.kt


import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import java.io.File

@Composable
fun ImagePickerWithCamera(onImageSelected: (Uri) -> Unit) {
val context = LocalContext.current

val imageFile = remember { File(context.cacheDir, "temp_${System.currentTimeMillis()}.jpg") }
val imageUri = FileProvider.getUriForFile(
context,
"${context.packageName}.provider",
imageFile
)

val cameraLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.TakePicture()
) { success -> if (success) onImageSelected(imageUri) }

val galleryLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri -> uri?.let(onImageSelected) }

Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { cameraLauncher.launch(imageUri) }) { Text("카메라 촬영") }
Button(onClick = { galleryLauncher.launch("image/*") }) { Text("갤러리 선택") }
}
}

FirebaseUpload.kt


import android.net.Uri
import com.google.firebase.ktx.Firebase
import com.google.firebase.storage.ktx.storage

fun uploadImageToFirebaseWithProgress(
uri: Uri,
onProgress: (Int) -> Unit,
onResult: (String?) -> Unit
) {
val storageRef = Firebase.storage.reference
val fileRef = storageRef.child("uploads/${System.currentTimeMillis()}.jpg")

val uploadTask = fileRef.putFile(uri)
uploadTask
.addOnProgressListener { snap ->
val p = (100.0 * snap.bytesTransferred / snap.totalByteCount).toInt()
onProgress(p)
}
.addOnSuccessListener {
fileRef.downloadUrl.addOnSuccessListener { url -> onResult(url.toString()) }
}
.addOnFailureListener { onResult(null) }
}

UploadImageScreenWithCamera.kt


import android.net.Uri
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage

@Composable
fun UploadImageScreenWithCamera() {
var imageUri by remember { mutableStateOf<Uri?>(null) }
var uploadedUrl by remember { mutableStateOf<String?>(null) }
var progress by remember { mutableStateOf(0) }

Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
ImagePickerWithCamera { uri ->
imageUri = uri
progress = 0
uploadedUrl = null
uploadImageToFirebaseWithProgress(
uri,
onProgress = { p -> progress = p },
onResult = { url -> uploadedUrl = url }
)
}

Spacer(Modifier.height(16.dp))

imageUri?.let {
Text("선택/촬영한 이미지:")
AsyncImage(model = it, contentDescription = null, modifier = Modifier.size(200.dp))
}

if (progress in 1..99) {
Spacer(Modifier.height(8.dp))
Text("업로드 중: ${'$'}progress%")
}

uploadedUrl?.let {
Spacer(Modifier.height(16.dp))
Text("업로드 완료 URL:", color = Color(0xFF16A34A))
SelectionContainer { Text(it, color = Color(0xFF2563EB)) }
}
}
}

MainActivity.kt


import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { UploadImageScreenWithCamera() }
}
}


10) 다음 단계 아이디어



  • Firebase Authentication 연동해 사용자별 업로드 제한 및 이력 관리

  • 업로드한 메타데이터(파일명, URL, 작성자, 시간)를 Firestore에 저장

  • 썸네일/압축 생성, EXIF 제거 등 이미지 전처리

  • 사용자별 폴더 구조: user_uploads/{uid}/...







2026/04/28

오늘의 이야기

<주변검색 주변찾기 앱, 동작감지기 앱 :  이하 사용자앱으로 표시 >:는) 「개인정보 보호법」 제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조(처리하는 개인정보의 항목 작성)
①<사용자 앱>은(는) 다음의 개인정보 항목을 처리하고 있습니다.



  • 1<앱 사용자 회원가입 및 관리 >

  • 필수항목 : 식별기호(uuid token), 이메일주소,  

  • 소셜 로그인 : 이메일주소, 별명(별칭), 프로필 이미지 링크

  • 2<앱 사용자 위치 정보>

  • 앱 사용중 위치 정보 및 백그라운드 위치 정보 포함.

  • 3<Health Data>

  • 앱 사용중 사용자의 심박수, 걸음수의 정보.


 


제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조(개인정보 처리방침 변경)

① 이 개인정보처리방침은 2025년 7월 11부터 적용됩니다.


 


부칙 <개인정보 처리 지침의 개정>


* 이 지침은 필요에 따라 갱신 되며, 사용자에게 개별 통지는 되지 않습니다.


 





오늘의 이야기

5장. 성능·배터리 최적화   이 장의 목표는 “빠르고 오래 가는 워치 앱”을 만드는 것입니다. 작은 화면에서 느림은 바로 이탈로 이어지고, 배터리 소모는 곧 별점 하락으로 연결됩니다. 지금은 완벽한 초최적화보다, 체감 품질을 확 끌어올리는...