1단계 Docker 설치 및 실행

단계별로 접근할 때, 문의를 해본다.

"1단계 부터 해보자."

좋습니다! 1단계: Docker 기본 이해 및 Synology에서 사용법 익히기 단계부터 차근히 시작해보죠.

1단계: Synology NAS에서 Docker 기본 사용법 익히기

1-1. Docker 패키지 설치

  1. DSM(웹관리페이지)에 로그인
  2. 패키지 센터 > 검색창에 Docker 입력
  3. Docker 패키지를 설치
  4. 설치 후 "열기"를 눌러 Docker GUI 실행

1-2. 첫 Docker 이미지 실행 (hello-world or nginx)

(1) Docker에서 이미지 검색 및 다운로드

  • Docker GUI > 레지스트리 탭 클릭
  • nginx 검색 > nginx:latest 선택 후 다운로드

(2) 이미지 실행 (포트 매핑 포함)

  • 이미지 탭 > nginx:latest 선택 > 실행
  • 컨테이너 이름: test-nginx
  • 포트 설정: 로컬 포트: 8080컨테이너 포트: 80
    => 로컬 포트는 설정을 할 수가 없어서 이 부분 분석하는데 시간이 소요되었다.

(3) 결과 확인

  • 브라우저에서 http://NAS_IP:로컬포트 접속
  • nginx Welcome Page가 나오면 성공!

(4) 결과 확인

CLI에서 하면, 로컬 포트도 설정 가능함을 몇 번의 시도로 알 수 있었다.

sudo docker run --name test-nginx2 -p 8081:80 -d nginx

1-3. 컨테이너 관리 실습

  • 컨테이너 탭에서 test-nginx 확인
    • 시작 / 중지 / 재시작 / 로그 보기
    • 중지 후 삭제도 해보세요
  • 볼륨 탭 확인: 아직은 없음 (2단계에서 실습 예정)

1-4. 명령어로도 실행해보기 (선택사항, SSH 접속 필요)

Synology NAS에 SSH 접속이 가능하다면:

docker run --name hello-container hello-world
  • 메시지에 "Hello from Docker!"가 나오면 성공

다음 단계로 넘어가기 전 체크리스트

  • Docker 패키지를 설치했다
  • Docker GUI에서 이미지(nginx)를 다운로드하고 실행해 봤다
  • 브라우저에서 nginx 페이지를 확인했다
  • 컨테이너 시작/중지/삭제를 해봤다

2단계

"단계 2를 해보자."라는 Prompting으로 다음 사항이 제공된다.

✅ 2단계 목표 요약

  1. Python Docker 이미지 다운로드 (python:3.10 권장)
  2. NAS의 공유 폴더를 Docker 컨테이너에 마운트
  3. Python 컨테이너에서 간단한 웹 크롤링 코드 실행 (requests, beautifulsoup4)
  4. 코드 실행 결과를 NAS 폴더에 저장

🔧 준비: NAS에 공유 폴더 하나 만들기 (예: docker-scripts)

Synology DSM > 제어판 > 공유 폴더에서 다음처럼 하나 생성:

  • 이름: docker-scripts
  • 내부 경로 예: /volume1/docker-scripts -> /volume2/backups/scripts를 사용하기로 함.
  • 이후 Python 코드 파일을 여기에 넣고 Docker 컨테이너에서 마운트해서 사용할 예정

✅ Step-by-Step: Docker CLI로 Python 컨테이너 실행

📥 1. Python 이미지 다운로드

sudo docker pull python:3.10

최신 안정 버전으로 3.10을 권장하지만, 필요 시 다른 버전도 가능 (3.11, 3.9 등)

🗂 2. 예제 코드 파일 생성

/volume1/docker-scripts/test.py로 아래 코드를 저장하세요:
-> /volume2/backups/scripts에 저장

# test.py
import requests
from bs4 import BeautifulSoup

res = requests.get("https://example.com") # examples.com으로 변경해서 테스트
soup = BeautifulSoup(res.text, "html.parser")
print("Page title:", soup.title.string)

``

🚀 3. Docker 컨테이너 실행 (NAS 폴더 마운트)

sudo docker run -it --rm \
  --dns=1.1.1.1 \
  -v /volume2/backups/scripts:/app \
  python:3.10 bash

이 명령은:

  • -it: 인터랙티브 터미널
  • --rm: 실행 후 컨테이너 자동 삭제
  • -v: NAS 공유 폴더 /volume2/backups/scripts 를 컨테이너 내부 /app으로 마운트

🧪 4. 컨테이너 내부에서 실행

cd /app
pip install requests beautifulsoup4
python test.py

결과:

Page title: 403

✅ 잘 되었는지 확인 체크리스트

  • python:3.10 이미지 다운로드 성공
  • /volume1/docker-scripts/test.py 파일 생성
  • Docker 컨테이너에서 /app/test.py 실행
  • Page title: 출력 확인

성공했다면 이제 Python 코드도 NAS에서 Docker로 돌릴 수 있게 된 거예요!

문제 정의

BeautifulSoup을 이용한 Web Scrapping의 경우는 Dynamic Web의 경우, 동작하지 않는 경우가 많다. 이럴 경우, 실재 Web Browser를 이용한 것과 동일하게 Web Scrapping을 하는 방법이 있다. Selenium이다.

하지만, (1)에서 살펴 본것과 같이 Synology NAS에서는 X11 GUI가 없다. 그래서, Selenium을 쓰기 어렵다. 필자의 경우, Docker를 써보지 못해서 난이도가 높았다. 그래서, 다음과 같이 Prompting을 하고 필요한 절차를 작게 나누어 도전해보고자 한다. 이를 통해서, 여러 학습이 이루어 질 것으로 기대한다.

"Synology NAS에 Docker를 적용하여 나중에는 Selenium을 사용해서 Web Scrapping을 하려고해. 내가 Docker에 대해서는 아주 초보자여서 몇가지 단계를 나눠서 실행해 보고 이해도를 높이고 싶어 어떤 절차로 나눠서 하는 것이 좋을지 제안해줘."

Docker 학습 단계 제안

Synology NAS에서 Docker를 이용해 Selenium 기반 웹 스크래핑 환경을 구성하고자 한다면, 아래와 같이 단계를 나눠서 진행하는 것이 좋습니다. 각 단계는 개별적으로 실습하며 Docker에 대한 이해를 점차 높이는 구조로 구성했습니다:

1단계: Docker 기본 이해 및 Synology에서 사용법 익히기

  • Synology DSM에서 Docker 패키지 설치
  • Docker GUI로 간단한 이미지 다운로드 및 컨테이너 실행 (예: hello-world, nginx)
  • 컨테이너의 시작, 중지, 삭제 실습
  • 볼륨과 포트 매핑 개념 익히기

실습 예시:

docker run -d -p 8080:80 nginx

브라우저에서 NAS_IP:8080으로 접속해 nginx 테스트

2단계: Python 실행 환경 구성

  • Python 실행 가능한 Docker 이미지(Python 3.x) 다운로드
  • 로컬 폴더를 컨테이너에 마운트해서 코드 실행
  • 간단한 requests, beautifulsoup4 기반 웹 크롤링 스크립트 실행해 보기

실습 예시:

docker run -it -v /volume1/docker/scraper:/app python:3.10 bash cd /app && python simple_scraper.py

3단계: Selenium 및 Headless 브라우저 환경 구성

  • Selenium + Chromium (또는 Firefox) 기반의 Docker 이미지 사용
  • 웹 페이지의 JavaScript 렌더링 후 데이터 추출 테스트
  • Headless 모드 동작 확인 (selenium/standalone-chrome 등 사용)

실습 예시:

docker run -d -p 4444:4444 --shm-size="2g" selenium/standalone-chrome
  • 로컬에서 Python 코드로 Selenium Remote WebDriver로 접속하여 테스트

4단계: NAS 내부에서 Python 코드와 Selenium 연동

  • NAS에 설치된 Python 환경에서 Selenium 서버(Docker 컨테이너)로 연결
  • 컨테이너 간 네트워크 이해 (bridge, host, custom network)
  • 내부적으로 자동 크롤링 스케줄링 구성 (예: cron, watchtower, supervisord)

5단계: 결과 저장 및 자동화

  • 크롤링 결과를 NAS의 공유 폴더에 저장 (CSV/Markdown)
  • 로그 파일 관리 및 실패 시 재시도 처리 추가
  • 필요 시 이메일/텔레그램/카카오톡으로 알림 전송 기능 추가

6단계: 보안 및 관리 최적화

  • Docker Compose 사용해 환경 설정 자동화
  • selenium-chrome에 인증서 문제 해결 및 User-Agent 변경 등 고급 설정
  • Docker 컨테이너 백업 및 업데이트 전략 이해 (watchtower 등 활용)

참고 문헌

(1) https://technical-leader.tistory.com/181

문제 정의

운전과 같이 사용자가 휴대폰을 사용하지 말아야 하는 상황에서 사용자가 휴대폰을 사용하는 내역을 파악하는 기능을 개발하고자 하는 필요가 있었다. 그래서, 다음과 같은 Prompt로 가능한 방법에 대해서 제안을 해달라고 요청했다.

"너는 pytorch 및 tensorflow의 전문가이고, 동영상 처리에 대한 전문가야. Android에 대해서도 잘 알고 있는 전문가야. 휴대폰의 사용자가 휴대폰 사용하는 동안 어떤 어플리케이션을 사용하고 있는지 알아 내는 모듈을 개발하려고해. 이를 알아내는 방법을 여러가지 제안해줘"

이에 대해서 다음 사항들을 제안했다.

  1. Accessibility Services 활용
  2. UsageStatsManager 활용
  3. Foreground Service 및 Activity Recognition API
  4. Logcat 활용 (루팅된 기기)
  5. Screen Overlay 탐지 (Android 10 이하)
  6. Custom System-level API 개발 (OEM 권한 필요)
  7. ML 기반 비디오/스크린 분석 (실험적)

특히, 다음 제안과 권장 사항도 제공받았다.

  • 보안 및 개인정보 보호 우선: 민감한 정보를 다루는 작업이므로 사용자 동의를 확실히 받는 것이 중요합니다.
  • 기술적 가능성 검토: Android 버전, 권한 정책, 배터리 소모 등을 면밀히 분석하세요.
  • 효율적인 구현: UsageStatsManager와 Accessibility Service의 조합을 1차적으로 추천합니다.

초기 Prompting

제안 받은 데로, UsageStatsManager를 사용하려고 했고, 조금 더 쉽게 접근하기 위해서 아래 Prompting으로 시작했다. 특히, 우리 협력 팀 중 하나가 유사한 기능을 UsageStatsManager를 사용하여 구현하였다.

 

"너는 android application 개발자야. 다음 요구 사항을 만족하는 application을 만들어줘. 추가적으로 필요한 정보가 있다면, 확인하고 제안해줘. 프로그램을 작성하는 순서를 상세히 설명해줘.

  1. MainActivity를 가지고 있어
  2. LifecycleService를 가지고 있어. MainActivity에서 이 Service를 구동해
  3. MainActivity에는 Start 버튼을 가지고 있어. 이 버튼을 클릭하면, Service 동작 시간을 측정 시작해. 그리고, Start 버튼이 Stop 버튼으로 변경돼.
  4. MainActivity의 Stop 버튼을 클릭하면, 휴대폰에서 사용자가 다른 어플리케이션을 사용한 내용을 정리해서 알려주는 기능을 가지고 있어. 이 부분은 UsageStatsManager에서 사용 이력을 확인할 수 있어."

제공받은 코드로 테스트를 했지만, Service로 구동시키는 것이 쉽지 않아, MainActivity에서 버튼으로 시작하고 Stop으로 마무리 하도록 했다.

추가 진행

"UsageStatsHelper의 getUsageStats의 기능을 개선해줘. MainActivity에서 Start 버턴을 누른 시간 부터 Stop 버튼 누른 시간 사이에 사용된 앱에 대해서 정보를 출력해줘. 사용시간이 0 이상인 package 정보만 출력해줘. 특정 package의 경우는 제외할 수 있도록 해줘."

UsageStatsHelper.kt 을 다음과 같이 받았다.

import android.app.usage.UsageStats
import android.app.usage.UsageStatsManager
import android.content.Context
import android.content.pm.PackageManager
import java.text.SimpleDateFormat
import java.util.*

object UsageStatsHelper {

    // 특정 패키지를 제외할 수 있도록 리스트를 설정
    private val excludedPackages = setOf(
       "com.android.systemui" // 시스템 UI는 보통 제외하는 것이 좋음
    )

    fun getUsageStats(context: Context, startTime: Long, endTime: Long): String {
        val usageStatsManager =
            context.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager

        val usageStatsList: List<UsageStats> = usageStatsManager.queryUsageStats(
            UsageStatsManager.INTERVAL_BEST, startTime, endTime
        )

        if (usageStatsList.isEmpty()) {
            return "사용 기록 없음. 권한이 필요할 수 있습니다."
        }

        val packageManager = context.packageManager
        val sdf = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
        val result = StringBuilder()

        // 사용 시간이 0보다 크고 제외 대상이 아닌 앱만 필터링
        val filteredStats = usageStatsList
            .filter { it.totalTimeInForeground > 0 && it.packageName !in excludedPackages }
            .sortedByDescending { it.totalTimeInForeground } // 가장 많이 사용한 앱 순으로 정렬

        if (filteredStats.isEmpty()) {
            return "해당 시간 동안 사용된 앱이 없습니다."
        }

        filteredStats.forEach { stat ->
            val packageName = stat.packageName
            val appName = getAppName(packageManager, packageName)
            val lastUsed = sdf.format(Date(stat.lastTimeUsed))
            val totalTime = formatTime(stat.totalTimeInForeground)

            result.append("$appName ($packageName) 사용 시간: $totalTime (마지막 사용: $lastUsed)\n")
        }

        return result.toString()
    }

    // 패키지명으로부터 앱 이름을 가져오는 함수
    private fun getAppName(packageManager: PackageManager, packageName: String): String {
        return try {
            val applicationInfo = packageManager.getApplicationInfo(packageName, 0)
            packageManager.getApplicationLabel(applicationInfo).toString()
        } catch (e: PackageManager.NameNotFoundException) {
            packageName // 앱 이름을 가져오지 못하면 패키지명 반환
        }
    }

    // 밀리초 단위를 "시간:분:초" 형식으로 변환하는 함수
    private fun formatTime(milliseconds: Long): String {
        val seconds = milliseconds / 1000
        val hours = seconds / 3600
        val minutes = (seconds % 3600) / 60
        val remainingSeconds = seconds % 60

        return String.format("%02d:%02d:%02d", hours, minutes, remainingSeconds)
    }
}

실재로 App 사용된 시간 파악

여러가지 테스트를 하고 알게된 것도 많았다. 가장 중요한 이슈는 우리가 파악하고자 하는 기간 동안에 실재로 app이 사용된 duration보다 더 길게 나오는 이슈가 있었다.

"startTime과 endTime의 duration보다 실재 사용한 duration이 더 길게 나오는 것으로 보여. 위의 queryUsageStats의 lastUsedTime을 이용해서 startTime과 endTime 사이 사용된 앱을 찾아내줘. 그리고, 다른 방법으로 startTime과 endTime 사이에서 실재로 app이 사용된 시간을 파악해서 출력할 수 있도록 getUsageStats 함수를 업데이트 해줘."

import android.app.usage.UsageStats
import android.app.usage.UsageStatsManager
import android.content.Context
import android.content.pm.PackageManager
import java.text.SimpleDateFormat
import java.util.*

object UsageStatsHelper {

    // 특정 패키지를 제외할 수 있도록 리스트 설정
    private val excludedPackages = setOf(
       "com.android.systemui" // 시스템 UI 제외
    )

    fun getUsageStats(context: Context, startTime: Long, endTime: Long): String {
        val usageStatsManager =
            context.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager

        val usageStatsList: List<UsageStats> = usageStatsManager.queryUsageStats(
            UsageStatsManager.INTERVAL_BEST, startTime, endTime
        )

        if (usageStatsList.isEmpty()) {
            return "사용 기록 없음. (데이터가 업데이트되지 않았을 수 있음)"
        }

        val packageManager = context.packageManager
        val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
        val result = StringBuilder()

        // ✅ `lastTimeUsed`를 기반으로 `startTime ~ endTime` 사이에 실제 사용된 앱만 필터링
        val filteredStats = usageStatsList.filter { 
            it.totalTimeInForeground > 0 &&
            it.lastTimeUsed in startTime..endTime &&  // 마지막 사용 시간이 startTime ~ endTime 사이에 있어야 함
            it.packageName !in excludedPackages
        }.sortedByDescending { it.totalTimeInForeground } // 가장 많이 사용한 앱 순으로 정렬

        if (filteredStats.isEmpty()) {
            return "해당 시간 동안 사용된 앱이 없습니다."
        }

        filteredStats.forEach { stat ->
            val packageName = stat.packageName
            val appName = getAppName(packageManager, packageName)
            val lastUsed = sdf.format(Date(stat.lastTimeUsed))

            // ✅ 실재로 `startTime ~ endTime` 사이에서 사용된 시간을 계산
            val actualUsageTime = calculateActualUsageTime(stat, startTime, endTime)
            val formattedTime = formatTime(actualUsageTime)

            result.append("$appName ($packageName) 사용 시간: $formattedTime (마지막 사용: $lastUsed)\n")
        }

        return result.toString()
    }

    // ✅ 앱이 `startTime ~ endTime` 사이에서 실재로 사용된 시간을 계산하는 함수
    private fun calculateActualUsageTime(stat: UsageStats, startTime: Long, endTime: Long): Long {
        val lastUsed = stat.lastTimeUsed
        val totalTime = stat.totalTimeInForeground

        // ✅ `lastTimeUsed`가 `startTime ~ endTime`에 포함된 경우
        return if (lastUsed in startTime..endTime) {
            // 앱이 `startTime` 이전부터 실행 중이었다면 `startTime`부터 사용 시간 계산
            val adjustedStartTime = maxOf(startTime, lastUsed - totalTime)
            val adjustedEndTime = minOf(endTime, lastUsed)
            adjustedEndTime - adjustedStartTime
        } else {
            0L // 사용되지 않은 경우 0 반환
        }
    }

    // 패키지명으로부터 앱 이름을 가져오는 함수
    private fun getAppName(packageManager: PackageManager, packageName: String): String {
        return try {
            val applicationInfo = packageManager.getApplicationInfo(packageName, 0)
            packageManager.getApplicationLabel(applicationInfo).toString()
        } catch (e: PackageManager.NameNotFoundException) {
            packageName // 앱 이름을 가져오지 못하면 패키지명 반환
        }
    }

    // 밀리초 단위를 "시간:분:초" 형식으로 변환하는 함수
    private fun formatTime(milliseconds: Long): String {
        val seconds = milliseconds / 1000
        val hours = seconds / 3600
        val minutes = (seconds % 3600) / 60
        val remainingSeconds = seconds % 60

        return String.format("%02d:%02d:%02d", hours, minutes, remainingSeconds)
    }
}

두 번째 개선

"calculateActualUsageTime을 UsageStatsManager.queryEvents(startTime, endTime)를 사용하여 앱의 실행 이벤트(ACTIVITY_RESUMED, ACTIVITY_PAUSED)를 추적해서 계산해줘."

import android.app.usage.UsageEvents
import android.app.usage.UsageStats
import android.app.usage.UsageStatsManager
import android.content.Context
import android.content.pm.PackageManager
import java.text.SimpleDateFormat
import java.util.*

object UsageStatsHelper {

    // 특정 패키지를 제외할 수 있도록 리스트 설정
    private val excludedPackages = setOf(
       "com.android.systemui" // 시스템 UI 제외
    )

    fun getUsageStats(context: Context, startTime: Long, endTime: Long): String {
        val usageStatsManager =
            context.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager

        val usageStatsList: List<UsageStats> = usageStatsManager.queryUsageStats(
            UsageStatsManager.INTERVAL_BEST, startTime, endTime
        )

        if (usageStatsList.isEmpty()) {
            return "사용 기록 없음. (데이터가 업데이트되지 않았을 수 있음)"
        }

        val packageManager = context.packageManager
        val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
        val result = StringBuilder()

        // ✅ `lastTimeUsed`를 기반으로 `startTime ~ endTime` 사이에 실제 사용된 앱만 필터링
        val filteredStats = usageStatsList.filter { 
            it.totalTimeInForeground > 0 &&
            it.lastTimeUsed in startTime..endTime &&  // 마지막 사용 시간이 startTime ~ endTime 사이에 있어야 함
            it.packageName !in excludedPackages
        }.sortedByDescending { it.totalTimeInForeground } // 가장 많이 사용한 앱 순으로 정렬

        if (filteredStats.isEmpty()) {
            return "해당 시간 동안 사용된 앱이 없습니다."
        }

        filteredStats.forEach { stat ->
            val packageName = stat.packageName
            val appName = getAppName(packageManager, packageName)
            val lastUsed = sdf.format(Date(stat.lastTimeUsed))

            // ✅ `queryEvents()`를 사용하여 실제 사용된 시간을 계산
            val actualUsageTime = calculateActualUsageTime(context, packageName, startTime, endTime)
            val formattedTime = formatTime(actualUsageTime)

            result.append("$appName ($packageName) 사용 시간: $formattedTime (마지막 사용: $lastUsed)\n")
        }

        return result.toString()
    }

    // ✅ `queryEvents()`를 사용하여 앱의 실행 이벤트 추적
    private fun calculateActualUsageTime(context: Context, packageName: String, startTime: Long, endTime: Long): Long {
        val usageStatsManager =
            context.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager

        val events = usageStatsManager.queryEvents(startTime, endTime)
        val event = UsageEvents.Event()

        var totalUsageTime = 0L
        var lastResumedTime: Long? = null

        // 이벤트 스트림을 읽어가면서 ACTIVITY_RESUMED 및 ACTIVITY_PAUSED를 분석
        while (events.hasNextEvent()) {
            events.getNextEvent(event)

            if (event.packageName == packageName) {
                when (event.eventType) {
                    UsageEvents.Event.ACTIVITY_RESUMED -> {
                        lastResumedTime = event.timeStamp
                    }
                    UsageEvents.Event.ACTIVITY_PAUSED -> {
                        if (lastResumedTime != null) {
                            val usageDuration = event.timeStamp - lastResumedTime
                            if (usageDuration > 0) {
                                totalUsageTime += usageDuration
                            }
                        }
                        lastResumedTime = null
                    }
                }
            }
        }

        return totalUsageTime
    }

    // 패키지명으로부터 앱 이름을 가져오는 함수
    private fun getAppName(packageManager: PackageManager, packageName: String): String {
        return try {
            val applicationInfo = packageManager.getApplicationInfo(packageName, 0)
            packageManager.getApplicationLabel(applicationInfo).toString()
        } catch (e: PackageManager.NameNotFoundException) {
            packageName // 앱 이름을 가져오지 못하면 패키지명 반환
        }
    }

    // 밀리초 단위를 "시간:분:초" 형식으로 변환하는 함수
    private fun formatTime(milliseconds: Long): String {
        val seconds = milliseconds / 1000
        val hours = seconds / 3600
        val minutes = (seconds % 3600) / 60
        val remainingSeconds = seconds % 60

        return String.format("%02d:%02d:%02d", hours, minutes, remainingSeconds)
    }
}

결과의 포맷 정리

사용한 앱들의 정리를 위해서 Map형태로 정리하기로 하였다.

"Application 사용시간을 확인할 때, getUsageStats의 결과 물로 application 이름, package 이름 그리고 각 앱의 사용 시작, 사용 끝 시간을 포함하는 Map형태로 반환되도록 구성해줘."

import android.app.usage.UsageEvents
import android.app.usage.UsageStatsManager
import android.content.Context
import android.content.pm.PackageManager
import java.text.SimpleDateFormat
import java.util.*

object UsageStatsHelper {

    // 특정 패키지를 제외할 수 있도록 리스트 설정
    private val excludedPackages = setOf(
       "com.android.systemui" // 시스템 UI 제외
    )

    /**
     * 📌 사용된 애플리케이션 정보를 반환하는 함수
     * @return Map<String, List<Pair<Long, Long>>>
     * - Key: 앱 이름 (패키지 이름 포함)
     * - Value: (사용 시작 시간, 사용 종료 시간) 리스트
     */
    fun getUsageStats(context: Context, startTime: Long, endTime: Long): Map<String, List<Pair<Long, Long>>> {
        val usageStatsManager =
            context.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager

        val packageManager = context.packageManager
        val result = mutableMapOf<String, MutableList<Pair<Long, Long>>>()

        // ✅ `queryEvents()`를 사용하여 ACTIVITY_RESUMED 및 ACTIVITY_PAUSED 추적
        val usageEvents = usageStatsManager.queryEvents(startTime, endTime)
        val event = UsageEvents.Event()

        val activeApps = mutableMapOf<String, Long>() // 현재 실행 중인 앱 저장

        while (usageEvents.hasNextEvent()) {
            usageEvents.getNextEvent(event)

            val packageName = event.packageName
            if (packageName in excludedPackages) continue // 제외할 패키지 필터링

            val appName = getAppName(packageManager, packageName)

            when (event.eventType) {
                UsageEvents.Event.ACTIVITY_RESUMED -> {
                    activeApps[packageName] = event.timeStamp // 사용 시작 시간 저장
                }
                UsageEvents.Event.ACTIVITY_PAUSED -> {
                    val startTimestamp = activeApps.remove(packageName) ?: continue // 시작 시간이 없으면 패스
                    val endTimestamp = event.timeStamp
                    result.getOrPut("$appName ($packageName)") { mutableListOf() }
                        .add(Pair(startTimestamp, endTimestamp)) // 시작, 종료 시간 저장
                }
            }
        }

        return result
    }

    // 패키지명으로부터 앱 이름을 가져오는 함수
    private fun getAppName(packageManager: PackageManager, packageName: String): String {
        return try {
            val applicationInfo = packageManager.getApplicationInfo(packageName, 0)
            packageManager.getApplicationLabel(applicationInfo).toString()
        } catch (e: PackageManager.NameNotFoundException) {
            packageName // 앱 이름을 가져오지 못하면 패키지명 반환
        }
    }
}

마무리 하며...

이전에도 설명했듯이, 진행하면서 학습이 발생하기 때문에 여러 iteration을 돌리는 것이 중요하다는 것을 다시금 깨닫게 된다. 중간에 다른 인터페이스도 사용해보고, 테스트하면서 알게된 부분을 가지고 계속 개선하면서 현재까지 결과를 얻었다. 이 코드를 가지고 팀원들에게 피드백을 받으면서 진행하는 것이 필요한 부분이다.

들어가며

최근 ChatGPT와 같은 대규모 언어 모델(LLM) 도구들이 보편화되면서, 이를 학습에 활용하는 것에 대한 우려의 목소리도 함께 커지고 있다. “AI가 대신 글을 써주면, 학생들이 스스로 사고하고 표현할 기회를 잃는 것 아니냐”는 걱정이다. 일예로, LLM 분야의 대가인 제프리 힌튼은, 앞으로는 많은 지식 노동이 대체될 수 있으며 배관공(Plumber)과 같은 수작업 직업이 더 나은 선택이 될 수 있다고 경고하기도 했다.

 

하지만 나는 이 우려가, 마치 자동차와 비행기가 등장했을 때 “사람들이 걷지 않아 건강을 해칠 것”이라고 걱정하던 상황과 닮아 있다고 생각한다. 그 이후에도 우리는 여전히 걷고, 달리고, 운동하며 몸을 단련해왔다. 중요한 것은 도구의 유무가 아니라, 도구를 어떻게 사용하는가이다.

나의 활용 방식

AI를 활용하는 방식에는 여러 스펙트럼이 존재한다. 그 중 두 가지 대표적인 활용 방식이 자주 떠오른다. 하나는 아이언맨과 자비스, 다른 하나는 프로도와 간달프 의 관계이다.

 

아이언맨은 자신이 잘 아는 기술과 전투의 세계에서 자비스의 도움을 받아 더 빠르고, 더 효율적으로 문제를 해결한다. 자비스는 아이언맨을 대신해 사고하지 않는다. 대신 반복적인 작업을 덜어주고, 그의 아이디어를 구현하는 데 필요한 실현 가능성과 속도를 높여준다. 이 과정 속에서도 아이언맨은 계속해서 학습하고 성장한다. AI는 내 사고력을 대체하는 것이 아니라, 내 역량을 증폭하는 파트너다. 이와 관련해 박태웅 한빛미디어 의장도 최근 LLM은 ‘증폭기(amplifier)’와 같은 존재라고 언급한 바 있다. 이 말에 깊이 공감한다.

 

반면, 프로도의 여정은 더 탐험에 가깝다. 그는 낯선 세계로 떠나며, 어떤 위험과 교훈이 기다리는지 알지 못한다. 이때 간달프는 모든 것을 대신해주는 존재가 아니라, 방향을 제시하고 조언을 건네며 프로도가 스스로 성장할 수 있도록 돕는다.
이는 내가 아직 익숙하지 않은 도메인에서 AI의 도움을 받으며 시행착오를 겪고 배워가는 모습과 닮아 있다.

개발 영역에서는 이러한 두 가지 접근이 “Vibe Coding”이라는 흐름 속에 잘 드러난다. 익숙한 도메인에서는 LLM과 협업하여 더 빠르게 프로토타입을 만들고, 반복 실험과 피드백을 통해 인사이트를 얻는다. 낯선 기술을 다룰 때는, AI가 멘토이자 실험 파트너가 되어 나의 몰입과 탐험을 도와준다.

 

이 모든 과정에서 가장 중요한 것은 “Vibe”, 즉 몰입과 호기심이다. 일이 너무 쉬우면 흥미를 잃고, 너무 어렵다면 지치고 포기하게 된다. 그래서 탐험할 때는 단계별(iterative)로 도전 과제를 나누고, 적절한 난이도를 유지하는 것이 핵심이다. 또한, AI가 생성한 결과를 그저 수동적으로 받아들이는 데 그쳐선 안 된다. 예를 들어, Cursor 같은 도구를 활용해 코드를 실행해보고 에러를 분석하거나, 내가 이해한 방식대로 코드를 수정해 다시 피드백을 주는 과정이 중요하다. 피드백을 주고받는 상호작용 속에서 비로소 진짜 학습이 일어난다.

맺으며

AI 시대의 학습은 ‘무기력하게 도움을 받는 것’이 아니다. 강력한 도구와 협업하면서 몰입하는 경험이다. 자비스처럼 효율을 높이거나, 간달프처럼 새로운 세계로 이끌려 가며 우리는 더 나은 배움을 할 수 있다. 중요한 것은, 그 안에 나의 호기심, 열정, 주도권이 살아 있느냐이다.

 

변화는 빠르다. 이러한 때에 불안함을 느끼지 않는 사람이 얼마나 될까? 하지만 AI를 두려워하기보다, 내 성장의 증폭기로 삼을 수 있다면 그것은 결코 위협이 아닌 기회가 될 것이다. 머리 속으로 되내어 보자. "이거 흥미로운데?"

문제 정의

내가 가지고 있는 Synology NAS에서 서버 처럼 news를 요약해서 정리하는 것을 만들고 싶었다. Discord와 연동도 좋지만, Obsidian을 개인 지식 관리로 쓰고 있고 이 Obsidian의 저장소를 OneDrive로 하고 있어서 이 부분을 활용하고 싶었다.

"Synology NAS에서 매일 아침에 news crawling을 해서, markdown file로 특정 폴더 파일에 요약 정보 및 링크를 요약해서 저장하는 기능을 넣고 싶어. 어떻게 구성할지 몇가지 방법을 제안해줘."

우선 방안에 대해서 알고 싶어서 위와 같이 Prompting을 하였다. 그리고, 제안한 방법들의 요약을 정리해 보면 다음과 같다.

  1. Synology Task Scheduler + Python 스크립트
  2. Docker 컨테이너 활용
  3. Crawlab 활용 (Docker 기반 크롤링 플랫폼)
  4. Obsidian 플러그인 활용

2번 3번은 알고 있는 지식이 거의 없어서 난이도가 높았다. 4번은 자세히 읽었을 때에는 Synology로 하는 방법이 아니었다. 1번이 requests, BeautifulSoup, markdownify와 같은 들어 본적이 있는 python package를 사용하는 방법이지 시도해보기로 했다.

처음 코드 생성

다음과 같은 Prompting으로 첫 코드를 얻었다.

"방법 1에서 Ptyno 스크립트 실행하는 방법을 detail하게 설명해줘. 어떤 python script를 사용해야 하는지 알려주고, 실재로 Synology에서 python script 테스트 하는 방법도 알려줘. 그리고, Task Scheduler에 해당 script 등록하고 테스트해 보는 방법도 상세하게 알려줘."

생성해준 코드를 적용하기 위한 환경 설정도 물어 보면서, pip라든과 관련된 package를 설치하고 테스트를 했다. 한번에 동작하지 않았다. 처음 제공한 파일들은 관련된 method만 제공하고 실제로 그 method를 호출하는 main 코드가 없어서 추가 요청을 했다.

디버깅

추가적인 동작확인을 위해서 다음과 같이 Prompting을 추가로 진행했다.

"위에서 이야기 한데로 스크립트를 실행했지만, 결과물은 나오지 않았어. 예를 들어 naver의 경우에도 링크를 만들어서 browser에서 열어 보면 결과물도 잘 나왔어. 하지만, 추출된 뉴스는 하나도 없는데 어떻게 디버깅을 할지 알려줘."

관련해서 중간 생성하는 debugging용 html 파일을 저장하게 했다. 이 html을 넣으면서 naver, daum, google의 분석을 하는 것을 디버깅했다. daum은 뉴스를 추출하지 못했고, naver는 링크를 잘 가져오지만 역시 dynamic이어서 요약까지 잘 하지 못했다. 그래도, google은 잘 처리해 주어서 최종 코드를 얻을 수 있었다.

최종 코드

지금까지 정리된 코드를 다음과 같이 얻었다.

# news_summary.py

import requests
from bs4 import BeautifulSoup
from openai import OpenAI
from datetime import datetime
import os

# 전역 디버그 설정
DEBUG_MODE = False
DEBUG_PATH = "/volume2/backups/scripts/debug/"

headers = {'User-Agent': 'Mozilla/5.0'}

def get_naver_news(query, max_results=3):
    url = f"https://search.naver.com/search.naver?where=news&query={query}"
    res = requests.get(url, headers=headers)

    # 디버깅 HTML 저장
    if DEBUG_MODE:
        with open(f"{DEBUG_PATH}naver_debug.html", "w", encoding="utf-8") as f:
            f.write(res.text)
    soup = BeautifulSoup(res.text, 'html.parser')
    items = soup.select('a[href^="https://n.news.naver.com/"]')[:max_results]
    
    # 디버깅 뉴스 갯수 출력
    if DEBUG_MODE:
        print(f"[NAVER] 뉴스 개수: {len(items)}")
    return [(a.text.strip(), a['href']) for a in items]

def get_google_news(query, max_results=3):
    url = f"https://news.google.com/search?q={query}&hl=ko&gl=KR&ceid=KR:ko"
    headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                      "AppleWebKit/537.36 (KHTML, like Gecko) "
                      "Chrome/113.0.0.0 Safari/537.36"
    }
    res = requests.get(url, headers=headers)

    # DEBUG 모드 설정
    if DEBUG_MODE:
        with open(os.path.join(DEBUG_PATH, "google_debug.html"), "w", encoding="utf-8") as f:
            f.write(res.text)
    soup = BeautifulSoup(res.text, "html.parser")
    items = soup.select("a.JtKRv")[:max_results]
    news_list = []
    for a in items:
        title = a.text.strip()
        href = a.get("href", "")
        # 상대경로를 절대경로로 변경
        if href.startswith("./"):
            href = "https://news.google.com" + href[1:]
        news_list.append((title, href))
    if DEBUG_MODE:
        print(f"[Google] 뉴스 개수: {len(news_list)}")
    return news_list

def fetch_article_content(url):
    try:
        res = requests.get(url, headers=headers, timeout=5)
        soup = BeautifulSoup(res.text, 'html.parser')
        paragraphs = soup.select('article p')
        text = '\n'.join([p.text.strip() for p in paragraphs if p.text.strip()])
        return text[:1000]
    except Exception as e:
        return f"(본문 수집 실패: {e})"

def fetch_naver_article_content(url):
    try:
        headers = {
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
            'Referer': 'https://www.naver.com'
        }
        res = requests.get(url, headers=headers, timeout=5)
        soup = BeautifulSoup(res.text, 'html.parser')
        article = soup.select_one('article')
        if article:
            paragraphs = article.find_all(['p', 'div'])
            text = '\n'.join([p.get_text(strip=True) for p in paragraphs if p.get_text(strip=True)])
            return text[:2000]
        else:
            return "(기사 본문을 찾을 수 없음)"
    except Exception as e:
        return f"(본문 수집 실패: {e})"

def summarize_with_gpt(title, url):
    client = OpenAI(api_key=os.getenv("OPENAI_API_KEY") or "YOUR KEY")
    if "n.news.naver.com" in url:
        content = fetch_naver_article_content(url)
        prompt = f"다음은 뉴스 기사입니다. 기사의 제목을 첫 줄에 포함해주세요. 그리고, 핵심 내용을 3줄 이내로 요약해 주세요.\n\n제목: {title}\n본문:\n{content}"
    else:
        content = fetch_article_content(url)
        prompt = f"다음은 뉴스 기사입니다. 핵심 내용을 3줄 이내로 요약해 주세요:\n\n제목: {title}\n내용: {content}"
    try:
        response = client.chat.completions.create(
            model="gpt-3.5-turbo",
            messages=[{"role": "user", "content": prompt}]
        )
        return response.choices[0].message.content.strip()
    except Exception as e:
        return f"(요약 실패: {e})"

def run_news_summary(query):
    result = {"네이버": [], "구글": []}
    for portal, func in [("네이버", get_naver_news), ("구글", get_google_news)]:
        try:
            for title, url in func(query):
                summary = summarize_with_gpt(title, url)
                result[portal].append((title, url, summary))
        except Exception as e:
            result[portal].append(("수집 실패", "#", str(e)))
    return result

def save_all_queries_to_markdown(all_results, all_queries, output_dir="/volume2/backups/Obsidian/Newbie/0. Inbox"):
    today = datetime.now().strftime('%Y-%m-%d')
    filename = os.path.join(output_dir, f"multi_news_ALL_{today}.md")
    with open(filename, 'w', encoding='utf-8') as f:
        f.write(f"# 📰 {today} - 전체 키워드 뉴스 요약\n\n")
        for query in all_queries:
            f.write(f"\n---\n\n## 🔍 키워드: {query}\n\n")
            result = all_results[query]
            for portal, items in result.items():
                f.write(f"### 🔎 {portal} 뉴스\n")
                for title, url, summary in items:
                    f.write(f"- [{title}]({url})\n  > {summary}\n\n")
    return filename

if __name__ == "__main__":
    query_file = "/volume2/backups/Obsidian/Newbie/news_query_temrs.md"
    if not os.path.exists(query_file):
        print(f"❌ 쿼리 파일 없음: {query_file}")
    else:
        with open(query_file, 'r', encoding='utf-8') as f:
            content = f.read()
        queries = [q.strip() for q in content.split(',') if q.strip()]

        all_results = {}
        for q in queries:
            print(f"▶️ 수집 시작: {q}")
            result = run_news_summary(q)
            all_results[q] = result

        final_path = save_all_queries_to_markdown(all_results, queries)
        print(f"✅ 전체 결과 저장 완료: {final_path}")

마무리

위의 코드를 Task Scheduler에 등록하고 매일 아침에 관련된 폴더에 위치하게 하고 이 폴더의 파일을 One Drive와 싱크해서 Mobile의 Obidian에서 접근하도록 했다. 여전히 naver는 링크가 잘 못되기도 하고, 제목도 제데로 뽑지 못하기도 하다. 결국은 Selenium을 쓰라고 하는데, Synology는 X11 GUI를 미지원 하기 때문에 Docker 사용을 제안 받았다. 이 부분은 현재로는 허들이 높아서 단계별로 시도해보기로 한다.

ChatGPT를 학습 측면으로 많이 쓰다가 요즘은 Vibe Coding, 즉 개발자가 직접 코드를 작성하기보다는 자연어로 문제를 설명하면 대형 언어 모델(LLM)이 이를 기반으로 코드를 생성해서 활용하는 방식으로 활용도가 하는 증가하고 있다. 이에 대해서 몇 가지 경험들을 공유하기 전에 '들어가기' 측면에서 돌아보며 생각 났던 것을 적어 보려고 한다.

언어로서의 프로그래밍 랭귀지

얼마 전(그래도 2025년 초)에 ChatGPT에게 물었을 때, Tier 1 언어에 다양한 언어가 있다는 것을 본 적 있다. 영어 (English, 가장 자신 있다고 한다), 중국어 (Mandarin), 스페인어 (Spanish), 프랑스어 (French), 독일어 (German), 일본어 (Japanese), 한국어 (Korean)가 포함되어 있다. 특히, 예전에는 한국어로 Prompting을 하면 영어로 번역해서 결과를 가져오고 그 결과를 한글로 번역(심지어 구글 Translator를 쓰는 것도 봤었다)해서 결과를 내었었다. 그래서 그런지, 요즘은 한글로 Prompting을 해도 결과가 아주 좋다. 이 정도라면 언어 학습에 ChatGPT를 안쓰는 것은 바보 같은 일이 되었다고 생각된다. 요즘 영어 학습 시 내가 쓴 글에 대해서 Correct하는데 자주 사용한다.

그렇다면, Computer Language 에서도 Tier 1이 있지 않을까 궁금해서 다음과 같이 물어봤었다. "너는 소프트웨어 개발 전문 Agent야. Vibe coding을 하려는 사용자에게 도움을 줄 때, 가장 자신이 있는 컴퓨터 언어에는 어떤 것들이 있는지 알려줘." ChatGPT는 Python, JavaScript (Node.js & Browser), Processing / p5.js, Rust, Kotlin까지 5개를 이야기 했었다. Gemini는 Python (파이썬), JavaScript (자바스크립트), Java (자바), C++ (씨쁠쁠), C# (씨샵)을 언급했다. 추가로 Swift, Kotlin, PHP, Ruby, Go, TypeScript 등 다양한 현대 프로그래밍 언어에 대해서도 능숙하게 이해하고 코드를 생성하거나 설명할 수 있다고 하긴 했다. 일반 언어 학습도 마찬가지이지만, 이제 프로그래밍 랭귀지 학습도 LLM을 사용하지 않는 것은 좋은 피드백을 놓치는 일인 것이라는게 내 생각이다. 기존 코드 분석이나 내가 작성한 코드의 리뷰도 LLM으로 하면 좋다.

동어 반복이지만, Coding도 어찌 보면 글을 쓰는 일이다. 앞에서 말했듯이 ChatGPT가 좋은 언어 선생인데 활용하지 않는 것은 이상한 일이 되고 있다.

나의 개발 환경

개발을 해보려면, 환경은 중요하다. 사실 많은 개발자들이 Web 기반으로 하고 있어서 Cursor가 유용한 도구이고 Vibe Coding을 할 수 있다. 하지만, 나 같이 Mobile에서 개발을 하고 있거나, 업무 자동화와 같은 것이 유용한 사람도 있다. 이와 같이 개발 환경이 다양할 수 있다.

우선 내가 가지고 있는 환경들을 정리해 보자. 물론, 자신의 환경도 정리해 보고 추가로 확장해 나갈 수도 있겠다. 즉, 개인 프로젝트로 Cloud에 개발해 볼 수도 있는 일이다.

  • 집의 HW: Synology NAS, Laptop, 개인용으로 쓰는 Android Smart Phone이 있다. 뒤에 이야기 하겠지만, NAS가 서버 처럼 동작할 수 있어서 자동화에 활용을 할 수 있다.
  • 회사의 HW에서: Phone/Tablet등이 많고, 개인용 Windows PC도 있다.

SW적 개발 환경은 다음과 같이 이야기 할 수 있다.

  • 언어적으로는 예전에 C/C++을 했지만, 요즘은 살펴 볼 기회가 점점 적어 지고 있다.도리어 Python이 업무 자동화(Web Scrapping)와 AI 관련해서 교육 및 업무 하면서 더 많이 쓰고 있다.
  • Android를 사용해야 하기 때문에 Java/Kotlin도 보게 되는 상황이다.
  • IDE의 측면에서는 Python을 위해서 PyCharm과 Android 개발을 위해서 Android Studio를 사용한다. 일반적인 text editor인 Notepadd++도 사용한다. Linux 환경에 정말 쓸 editor가 없다면 vi를 쓰기도 한다.

이렇게 해야 한다는 것이 아니고 나의 예이다. 내가 가지고 있는 것으로 시작하면 된다. 이런 것을 잘 모르겠다? 그럼 뒤에도 설명하겠지만, 아주 간단한 사용법 부터 익히면 좋겠다.

가장 먼저 할일은? 바로 문제 정의

내가 요즘 담당하고 있는 업무 TPM이고, 개발자가 아니어서 프로그래밍은 눈으로만 보거나 관련된 글을 읽는 것이 다라고 생각하고 살았던 것 같다. Vibe Coding은 생각만 하고 있던 것들을 LLM이 Coding Agent로서 바로 내 옆에 나 대신 코딩을 해주는 전문가가 생긴 것과 마찬가지다.

이렇게 하고 싶은, 하고 싶었던 것들 떠올려 LLM에게 물어 보면 된다.

  • Android 폰의 특정 시간 (시작 시간에서 끝나는 시간 사이) 동안, 사용자가 사용한 Application과 사용한 시간을 측정하고 싶다.
  • 특정 시간에 그날 날씨 예보를 파악해서 사용자에게 Activity를 제안하고 싶다.
  • 매일 뉴스를 찾아서 내가 사용하는 메모 앱(Obsidian)에 요약해서 저장하고 싶다.
  • 관심있는 주식 전망에 대해서 현재 날짜 기준으로 최신 뉴스나 정보를 검색 예상해 준다.
  • Android 폰에서 비디오 재생하는 간단한 Sample 앱을 만들어 보고 싶다.

이러한 것들이 간단하지만 문제 정의의 시작이 될 수 있다. 위의 내용들을 그냥 ChatGPT에 먼저 적고 순차적으로 답변들을 검토하면서 진행해도 큰 문제가 없었다.

Prompting

사실 ChatGPT 같은 경우는 특별히 prompting을 예전처럼 아주 구체적으로 적지 않고, 어설프게 해도 상당히 좋은 결과들이 나온다. 하지만, 몇 번의 시도에서 느낀 것을 적어 본다.

  • 문제 해결의 여러 방법을 제안하고 비교 요청. 이 부분은 Architecturing, Designing 단계로도 볼 수 있다. 즉, 우리가 해결하고자 하는 문제에 대해서 어떤 구조, 어떤 접근 방법이 가능한지 알 수 있다.
  • 작고 쉽게 접근이 가능해야 한다. 여러 제안 중에서 너무 새롭거나 허들이 높다면, 더 세밀하게 나눠봐야 한다. Android Programming을 전혀 해보지 않았다면 Hello World라도 짜보고 접근한다거나, Docker를 전혀 해보지 않은 상태에서 이 방법을 선택하는 것은 너무 어렵다.
  • 이터레이션을 해야 한다. 제안 된 것을 ChatGPT가 실행해 볼 수도 있지만, 그렇지 않은 경우도 많다. 또한, Oudated된 정보를 가지고 있는 경우도 많다. 그렇기 때문에, 제공 받은 코드는 실행을 해봐야 한다. Cursor와 같이 IDE 환경이라면 바로 Deploy도 하고 실행도 해볼 수 있지만, ChatGPT는 그렇지지 않은 환경이라 실행하지 않으면 알기 어렵다.
  • Debugging도 도와 달라고 하면 잘 도와 준다. 분석을 위한 중간 결과물이나 실행 시 중요 정보를 출력하는 Logging이나 Print를 삽입해주기도 한다. 웹 페이지에 접근해서 내용을 가져오는 동작을 의미하는 Web Scrapping이나 Web Crawling의 경우 중간 결과물로 html을 저장하기도 하고 이 저장된 html을 제공하면 디버깅 포인트를 찾기도 한다.

이와 같이 다양한 접근 방법에 대해서 고려하고 작은 스텝으로 진행하면서 실행하여 LLM에게 Feedback하면서 진행하는 것이 유용하다.

마무리 하며

몇 번의 Vibe Coding을 하고 돌아 본 것이라 부족한 부분이 많고 나중에 생각이 바뀔 수도 있겠다. 이후에는 실재 사례들을 정리해 보겠다. 다른 하고 싶은 아이디어들이 떠오른다면, 추가적으로 실행해 볼 수 있겠다. 혹시, 같이 해보고 싶은 아이디어를 제공해 준다면, 함께 시도해 볼 수도...

들어가며..

최근에 Vibe Coding [1,2] 이라는 이야기가 많이 회자되고 있다. 개발자는 구조나 Product에 집중하고 실재 코딩 작업은 LLM에 맞기는 것이라고 한다. VSCode에 통합되어 있는 AI도구인 Cursor에서는 Agent[3]라는 제품도 나와서 이러한 트렌드를 더 가속화 하는 것으로 보인다. Cursor가 이미 IDE와 결함되어 있고, Agent 그리고 Ask 기능을 이미 제공하고 있기 때문에 여기서 설명하는 내용이 이미 필요 없을 수 있다. 하지만, Cursor를 사용하지 않고도 가능하다고 하면 Cursor를 사용하지 못하기 때문에 AI 도구를 이용해서 효과를 내지 못하는 것은 아니라는 것의 반증이기도 할 것 같다.

나의 사례

여기서 다루려는 내용은 아래 내용으로 나누어 볼 수 있다.

  1. 기존에 존재하는 코드를 이해하고 
  2. 우리가 원하는 기능을 어떻게 구현할지 결정하고
  3. 구현하는 코드를 만들어 적용

 

1번은 우리가 이미 구현하고 있는 Android App기준으로 이 부분은 다른 사례에 적용한다면 개발 환경에 따라 다를 수 있을 것 같다. 이 Android App에 대해 분석하는 예는 따로 설명한다[4]. 2번 부터 3번까지의 경우, Android App 기준으로 하나의 예를 가지고 따라가 보자. 물론 상황에 따라 다를 수 있음을 이해하자.

원하는 기능의 구현

이 부분은 필자의 사례이므로 상황에 따라 많이 다를 수 있다. 우리는 사용자가 특정 기간 동안 다른 동작을 해서 주의가 산만해 지는 것을 확인하기 위해 휴대폰에서 특정 앱을 사용하는 것을 모니터링 하는 것을 구현하려고 했다. 이와 관련해서 ChatGPT에 Prompting해 본 결과 중 일부를 살펴 보자.

Prompt: 너는 pytorch 및 tensorflow의 전문가이고, 동영상 처리에 대한 전문가야. Android에 대해서도 잘 알고 있는 전문가야. 휴대폰의 사용자가 휴대폰 사용하는 동안 어떤 어플리케이션을 사용하고 있는지 알아 내는 모듈을 개발하려고해. 이를 알아내는 방법을 여러가지 제안해줘

ChatGPT said:  (7가지를 이야기 했지만, 아래 3가지만 추출)
휴대폰에서 사용자가 어떤 애플리케이션을 사용하는지 알아내는 모듈을 개발하려면 아래와 같은 여러 가지 접근 방법을 고려할 수 있습니다. 각 접근법은 기술적인 제약, 성능, 그리고 보안 문제를 고려해야 합니다.

 

1. Accessibility Services 활용
- 방법: Android의 접근성 서비스(Accessibility Service)를 이용하면 사용자가 현재 활성화된 앱과 상호작용하는 내용을 감지할 수 있습니다.
- 장점: 사용자가 상호작용하는 앱에 대한 정보를 비교적 간단하게 가져올 수 있습니다.
- 단점: 사용자 동의가 필요하며, 민감한 정보를 처리하기 때문에 오용 시 보안 문제가 발생할 수 있습니다.
- 구현:
    1. `AccessibilityService` 클래스 구현.
    2. `onAccessibilityEvent` 메서드에서 앱 사용 데이터를 수집.

 

2. UsageStatsManager 활용
- 방법: Android의 `UsageStatsManager` API를 사용하여 최근에 사용된 애플리케이션 목록 및 사용 시간을 가져올 수 있습니다.
- 장점: 시스템 API를 사용하므로 안정적이고 공식적인 접근 방식.
- 단점: 사용자에게 Usage Access 권한을 별도로 요청해야 합니다.
- 구현:
    1. `UsageStatsManager` 인스턴스 생성.
    2. `queryUsageStats` 메서드로 주어진 기간 동안 사용된 앱의 데이터를 수집.

 

3. Foreground Service 및 Activity Recognition API
- 방법: 포어그라운드 서비스와 Activity Recognition API를 조합하여 현재 활성화된 앱 상태를 모니터링.
- 장점: 백그라운드에서 안정적으로 작동.
- 단점: 높은 배터리 소모 가능성과 앱 승인 절차.
- 구현:
    1. `ActivityManager`로 현재 실행 중인 태스크 정보 확인.
    2. Activity Recognition API로 사용자 동작(예: 걷기, 정지)도 함께 분석.

여기서 도출된 것들 중 유사한 기능을 다른 유관 부서에서 구현한 것을 보아서 문의 시, UsageStatsManager를 사용하는 것을 알았다. 여기서도 동일한 방법으로 접근해 보기로 결정하였다.

구현하는 코드를 만들어 적용

이를 기반으로 Prompting을 하고 관련된 코드를 입수 하여 독립된 Android App 으로 구현 테스트를 진행한다. 다음과 같은 Prompt를 사용하였다.

너는 android application 개발자야. 다음 요구 사항을 만족하는 application을 만들어줘. 추가적으로 필요한 정보가 있다면, 확인하고 제안해줘. 프로그램을 작성하는 순서를 상세히 설명해줘. 
1. MainActivity를 가지고 있어 
2. MainActivity에는 Start 버튼을 가지고 있어. 이 버튼을 클릭하면, 동작 시간을 측정 시작해. 그리고, Start 버튼이 Stop 버튼으로 변경돼. 
3. MainActivity의 Stop 버턴을 클릭하면, 휴대폰에서 사용자가 다른 어플리케이션을 사용한 내용을 정리해서 알려주는 기능을 가지고 있어. 이 부분은 UsageStatsManager에서 사용 이력을 확인할 수 있어.

이 것 기반으로 Android에서 동작하는 Kotlin 코드를 제공한다. 실제로 Android Studio에 코드를 적용하고, 테스트를 한다. 여기서 획득한 Helper 코드의 기본적인 테스트가 완료되어서, 실재 우리 App과 연동해 보고 동작하는 것을 확인하였다. 

마무리하며

간단한 사례이지만, 여기서 의미 있는 부분은 나의 문제를 명확하게 하고 어떻게 해결할지 고민하면서 필요한 요구 사항을 LLM에게 제공하여 동작 가능한 코드를 제공받았다. 개발자의 학습면에서도 좋다고 생각되는 부분은 제공 받은 코드를 통해서 Kotlin에 대해서 학습할 기회를 얻게 된 부분도 부수적인 장점으로 생각된다.


참고 문헌

[1] https://youtu.be/mzomjCHEPpg?si=TJRR-4Er7arf5m-j
[2] https://youtu.be/IACHfKmZMr8?si=rGTS_SlI96HS16no
[3] https://youtu.be/gKCklJOWB6o?si=N95cuv_Nf23Ar8S8
[4] Modern Anroid Application의 구조 이해, https://technical-leader.tistory.com/174

+ Recent posts