9 minute read

Android 프로젝트를 수행하면서 디버깅 또는 모니터링을 위해 간혹 Log 프린트를 사용한 경험이 있을 것입니다. 하지만 이러한 Log 프린팅 정보를 남겨둘 시 Logcat에 노출되어 보안에 대한 위험성이 생길 수도 있으며, 자신 또는 다른 개발자가 코드를 확인할 때에 불편함을 초래할 수 있습니다.

따라서 개발자들은 지나치게 사용된 Log 프린팅 코드를 없애기를 노력하지만, 대량의 소스파일에서는 이러한 것들을 하나하나 찾기에는 시간적인 비용이 들 것이고 결국 발견하지 못하는 실수로 이어지면서 원격저장소에 올라가거나 배포될 수 도 있습니다.

이런 부분을 해결하기 위해 shell 스크립트를 등을 만들어 검사하는 방식도 생각해 볼 수 있지만, Android의 빌드 툴인 Gradle Task 를 활용한다면 소스 파일 내에서 남용된 Log 라인을 빠르게 탐지하고 제거해낼 수 있습니다.

Gradle은 단순한 의존성 관리의 역할 뿐만아니라 개발 및 빌드 워크플로우를 구성할 수 있는 강력한 도구입니다. Gradle의 Task를 활용해 빌드 프로세스를 강화하거나 유틸리티를 만들 수 있으며, 이러한 Task들을 확장시켜 Plugin 으로 구성할 수 있도록 지원하고 있습니다.

이번 포스트에서는 Gradle을 바탕으로 Log 프린팅 구문을 탐색해주는 Task를 생성하고 Task 포함하는 Plguin을 구성하는 방법에 대해서 다루어보겠습니다.

배경 지식

커스텀 Gradle TaskPluguin을 구현하기 전 구현에 필요한 배경 지식들을 정리하도록 하겠습니다.

Gradle Task

Gradle Task는 Gradle이 수행하는 작업의 최소 단위입니다. 대표적으로 기본적으로 제공하는 clean, build, …와같은 Task들과 Android Application Plugin(com.android.application)이 확장해서 제공하는 build, test, assembleDebug ,… 같은 Task들이 있습니다. 이러한 빌드 과정에서 실행되는 모든 개별적인 작업 하나 하나를 Task라고 창합니다.

만약 Android Application Plugin을 등록한 모듈의 경우 Android Application Plugin이 개발에 사용될 수 있는 다양한 Task들을 제공해주기 때문에 아래와 같은 명령어를 사용할 수 있으며 해당 명령어 실행을 통해 빠르게 프로젝트 빌드 프로세스를 진행할 수 있습니다.

./gradlew build // 빌드
./gradlew test // 테스트
./gradlew assembleDebug // APK 생성 (Library Plugin에서는 AAR을 생성합니다.)

./gradlew tasks 라는 명령어를 사용할 경우 아래와 같이 현재 사용할 수 있는 Task들을 확인할 수 있습니다. 이때 tasks라는 명령어 또한 Task중 하나 입니다.

./gradlew :app:tasks와 같이 모듈단위로 gradle Task를 수행시킬 수도 있습니다.

Task는 Gradle 스크립트를 통해 추가 및 설정할 수도 있지만, Gradle 스크립트는 코틀린과 호환이 되기 때문에 실제 커스텀 클래스를 생성해 Task를 구성할 수 있습니다. 커스텀 Task 클래스를 사용하기 위해서는 DefaultTask를 상속한 서브클래스로 구성해야하며, @Input @Output 그리고 @TaskAction 등을 정의하여 입출력 그리고 동작 방식들을 정의할 수 있습니다.

아래는 gradle.kts 내에 간단하게 Hello World! 를 출력하는 커스텀 Task를 구현한 예시입니다.

import org.gradle.api.DefaultTask
import org.gradle.api.tasks.TaskAction

abstract class PrintHelloTask : DefaultTask() {
    @TaskAction
    fun printHello() {
        println("Hello World!")
    }
}

Task를 형식에 맞게 클래스로 만들어도 등록하여야만 사용할 수 있습니다. 이에 따라 아래와 같이 Task를 등록하는 구문을 추가해줍니다. 이때 printHello는 task의 이름을 의미합니다.

tasks.register<PrintHelloTask>("printHello")

이제 ./gradlew printHello 명령어를 사용하여 Hello World!를 출력 할 수 있습니다.

Task에 대한 자세한 내용은 [해당 문서]를 참고하시길 바랍니다.

Project

Project는 Gradle에서 빌드를 수행하는 하나의 단위입니다. 예를 들어 프로젝트를 초기화 할때 기본적으로 생성되는 app/build.gradle.kts는 파일은 app에 대한 Project임을 의미합니다. Project 내부에서는 Task의 집합 부터 사용할 Plugin 및 Dependency 그리고 Extension등을 정의하고 제어할 수 있습니다.

일반적으로 프로젝트를 생성하게 되면 app module 내 아래와 같은 구성정보가 만들어지게 됩니다.

plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.kotlin.compose)
}

android {
    namespace = "io.goodmidnight.sample"
    compileSdk = 35

    defaultConfig {
        applicationId = "io.goodmidnight.sample"
        minSdk = 26
        targetSdk = 35
        versionCode = 1
        versionName = "1.0"

        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            isMinifyEnabled = false
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_11
        targetCompatibility = JavaVersion.VERSION_11
    }
    kotlinOptions {
        jvmTarget = "11"
    }
    buildFeatures {
        compose = true
    }
}

dependencies {

    implementation(libs.androidx.core.ktx)
    implementation(libs.androidx.lifecycle.runtime.ktx)
    implementation(libs.androidx.activity.compose)
    implementation(platform(libs.androidx.compose.bom))
    implementation(libs.androidx.ui)
    implementation(libs.androidx.ui.graphics)
    implementation(libs.androidx.ui.tooling.preview)
    implementation(libs.androidx.material3)
    testImplementation(libs.junit)
    androidTestImplementation(libs.androidx.junit)
    androidTestImplementation(libs.androidx.espresso.core)
    androidTestImplementation(platform(libs.androidx.compose.bom))
    androidTestImplementation(libs.androidx.ui.test.junit4)
    debugImplementation(libs.androidx.ui.tooling)
    debugImplementation(libs.androidx.ui.test.manifest)
}

최상단에 보이는 plugin 블럭은 Project에 적용할 Plugin을 선언하는 영역입니다. 블럭 내부에 선언된 libs.plugins.android.application , libs.plugins.kotlin.android , libs.plugins.kotlin.compose 모두 Gradle Plugin 개발자들이 Task, Dependency, Extension 등을 정의한 하나의 집합입니다.

이어서 보이는 android 블럭은 android라는 이름으로 정의된 Extension 속성의 값을 지정하는 영역으로 Android Gradle Plugin(AGP)의 동작을 구성할 수 있습니다.

내부적으로 이 android 블럭은 ApplicationExtension이라는 클래스와 연결이되며, ApplicationExtension은 아래 사진과 같이 여러 Extension의 인터페이스를 부여받아 구성되는 것을 확인하실 수 있습니다.

내용이 조금 다른 방향으로 흘러갔지만, 궁극적으로 Extension은 Task나 Plugin등에 사용되는 설정들을 외부로 노출시켜 사용자가 직접적으로 제어할 수 있도록 도와주는 클래스입니다. android 블럭 또한 마찬가지로 android 라는 이름으로 정의된 하나의 Extension 입니다. 이 영역에서 defaultConfig, compileSdk와 같이 Extension이 제공해주는 다양한 설정값을 지정할 수 있습니다.

마지막 최하단의 블럭인 dependencies 는 외부 라이브러리나 내부에 존재하는 다른 모듈들을 추가할 수 있도록 Dependency를 정의하는 영역입니다. 물론 dependencies 블럭에서 외부 라이브러리를 불러오기 위해서는 사전에 어떤 외부저장소에서 불러올지를 정의해두어야합니다. 외부 저장소에 대한 정의는 settings.gradle.kts에 설정됩니다.

앞서 소개된 plugins 블럭과 dependencies 블럭의 차이점은 plugins은 빌드 시스템 자체를 확장시키는 도구를 설정하는 역할을 수행하고, dependencies는 내 애플리케이션 내에서 필요한 외부 라이브러리를 설정하는 역할을 담당합니다.

이외에도 Project 내에는 앞서 소개한 Task도 주입할 수 있으며, 이러한 기능들을 조합하여 요구사항에 맞는 Project를 구현할 수 있습니다.

Gradle Custom Plugin

Project는 앞서 소개한 다양한 기능들을 조합하여 빌드 시스템을 구현할 수 있지만, 또 다른 Project에서 사용할 수는 없다는 한계점이 존재합니다. 이는 즉 빌드 로직을 여러 모듈에서 쉽게 재사용하기 어렵다는 것을 의미합니다.

앞서 여러번의 힌트가 나왔겠지만, 이러한 문제점을 해결할 수 있는 방법이 존재합니다. 바로 직접적으로 Plugin을 구성하여 다른 Project 또는 Plugin에 적용하는 방식입니다.

Plugin은 특정 종류의 프로젝트를 빌드하는데 필요한 Task 설정 그리고 규칙들을 미리 정의해둔 패키지입니다. Plugin은 이러한 설정을 통해 궁극적으로 반복적인 빌드 설정을 없애고 재사용성을 늘리는 목표를 가지고 있습니다. 앞서 소개한 Android Application Plugin 처럼 Gradle 구성을 통해 Plugin을 생성할 수 있으며 해당 Plugin 내 다양한 커스텀 Task들을 추가할 수 있습니다.

아래의 예시 처럼 gradle에서 제공하는 기능을 바탕으로 Plugin 클래스를 상속하여 구성할 수 있습니다. 새롭게 만든 플러그인의 이름은 HelloPlugin 이라 칭하겠습니다.

import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies

class HelloPlugin : Plugin<Project> {
    override fun apply(project: Project) {
        project.tasks.register("printHello", PrintHelloTask::class.java)
        project.dependencies {
            add("implementation", "com.android.tools.build:gradle-api:8.4.1")
        }
    }
}

HelloPlugin 앞서 생성한 PrintHelloTask 를 포함하고 있으며, gradle-api 의존성을 추가하였습니다. 이는 Project 구성방식과 동일합니다. 차이점은 Project로 표현한 구성 정보를 Plugin으로 등록할 수 있다는 점입니다.

이제 Plugin을 Gradle에 등록해주기 위해 gradlePlugin 블럭을 사용합니다. 이때 gradlePlugin 블럭 또한 앞서 언급한 Extension 중 하나이며, 이는 kotlin-dsl이라고 불리는 플러그인에 속해져있습니다.

plugins {
    `kotlin-dsl`
}

gradlePlugin {
    plugins {
        register("HelloPlugin") {
            id = "io.goodmidnight.hello"
            implementationClass = "io.goodmidnight.HelloPlugin"
        }
    }
}

만약 플러그인이 정상적으로 등록되었을 경우 아래와 같이 모듈 내 플러그인 블럭에서 HelloPlugin 을 선언할 수 있게 됩니다.

plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
    ...
    id("io.goodmidnight.hello")
}

구현

앞서 Gradle 내에서 사용되는 핵심적인 기능들에 대한 배경들을 이해하였다면 이제는 플러그인을 구현할 준비가 되었다는 것입니다. 플러그인을 구현하기 전 가장 먼저 플러그인을 구성할 프로젝트를 세팅하는 작업이 필요합니다.

세팅

세팅을 시작하기 위해 kotlin library 타입의 독립적인 프로젝트를 만들고 프로젝트 구성에 필요한 파일들(gradle-wrapper, gitignore, versioncatalog, gradlew …등) 을 추가해줍니다. 라이브러리의 이름은 build-system으로 작성하였습니다.

gradlew는 gradle/wrapper/ 내부에 있는 gradle-wrapper.jar 파일을 실행시켜주는 스크립트입니다. gradle-wrapper.jar는 설정 파일(gradle-wrapper.properties)에 명시된 특정 버전의 Gradle 런타임을 다운로드 및 설치한 후 사용자가 요청한 명령을 런타임에 전달합니다.

Gradle 런타임이 시작되면 settings.gradle.kts와 각 모듈의 build.gradle.kts를 실행하여 전체 프로젝트의 설정을 구성한 후 요청한 명령을 수행합니다.

이어서 프로젝트의 관리를 위한 settings.gradle.kts 설정해야만 합니다. settings.gradle.kts 내에는 의존성을 어디서 어떻게 가져올지를 정의하는 dependencyResolutionManagement 블럭과 내부에 어떤 원격저장소에서 불러올지를 지정하는 repositories 블럭과 라이브러리 버전정보를 관리하는 versionCatalogs 블럭을 추가하였습니다.

settings.gradle.kts 구성을 통해 프로젝트의 의존성 및 라이브러리를 중앙에서 제어할 수 있도록 정의할 수 있게되었습니다.

dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
    }
    versionCatalogs {
        create("buildLib") {
            from(files("./gradle/libs.versions.toml"))
        }
    }
}

rootProject.name = "build-system"

라이브러리 모듈에 존재하는 build.gradle.kts 파일을 열고 프로젝트에 사용할 플러그인과 의존성 그리고 기본 설정 정보들을 추가합니다. 아래와 같이 plugins 블럭에 존재하는 kotlin-dsl 플러그인은 코틀린 플러그인과 Gradle 관련 API 의존성을 추가해주는 플러그인입니다.

plugins {
    `kotlin-dsl`
}

group = "io.goodmidnight.buildsystem"
version = "0.0.1"

Task 구성하기

플러그인을 구현하기 전 가장 먼저 Log 구문을 탐색하는 커스텀 Gradle Task 클래스를 구성해야 합니다. 커스텀 Task 클래스를 구현하기 위해서는 앞서 언급하였던데로 org.gradle.api 패키지에 존재하는 DefaultTask 를 상속한 뒤, @TaskAction 애노테이션을 부여한 함수를 생성해야 합니다. Task 클래스의 이름은 CheckLogTask로 설정하여 아래와 같이 구성하였습니다.

abstract class CheckLogTask : DefaultTask() {

    @TaskAction
    fun execute() {
		    ...
		    //TODO find Log calls
    }
}

만들고자 하는 Task의 핵심 기능인 Log 구문을 탐색하기 위해서는 가장 먼저 어떤 파일을 탐색해야 하는지를 알아야하며 파일 내에서 Log 구문이 어디에 존재하는지 파악할 수 있어야 합니다.

Gradle의 Task는 입력과 출력을 특정 파일을 대상으로 정의할 수 있도록 제공하고 있습니다. 이는 어떠한 Input 파일을 주어 Task를 수행하고 Task의 결과를 어떠한 Output 파일에 기록할지를 설정할 수 있다는 의미와 같습니다.

입력(읽기) 파일을 정의하기 위해서는 @get:Input... 을 사용해야만 하며 반대로 출력(쓰기) 파일의 경우 @get:Output... 애노테이션을 설정해야합니다. 애노테이션이 설정된 파일 프로퍼티는 Gradle이 작업을 위임하여 수행하도록 합니다.

대표적으로 사용되는 입력과 출력의 종류는 아래 표와 같이 구성되어있습니다.

@Input 단일 프로퍼티 값
@InputFile 단일 파일
@InputFiles 다수 파일
@InputDirectory 단일 디렉터리
@OutputFile 단일 파일
@OutputDirectory 단일 디렉토리
@OutputFiles 다수 파일
@OutputDirectories 다수 디렉토리

Task를 구현하기 위해서는 여러개의 파일들을 탐색해야 하며, 그 여러개의 파일들을 바탕으로 탐색한 결과를 단일 리포트에 작성하여 사용자에게 알려주어야 만 합니다. 이러한 요구사항을 바탕으로 아래와 같이 Input과 Output을 구성하였습니다.

@get:InputFiles
abstract val sourceFiles: ConfigurableFileCollection

@get:OutputFile
abstract val reportFile: RegularFileProperty

Input과 Output 애노테이션 설정와 타입에 대한 자세한 정보는 [해당 문서]를 참고하시면 많은 도움이 될 수 있을것입니다.

이제 파일 내에서 어떤 위치에 Log구문이 있는지를 판단해야만 합니다. 외부로부터 ConfigurableFileCollection 타입의 인풋 파일 집합을 받았다고 가정하고 파일 집합 순회를 시작합니다. 일반적으로 Log를 호출하는 파일들은 .java와 .kt 익스텐션을 가진 소스 코드파일일 것이니 파일 집합을 대상으로 필터링을 수행합니다.

@get:InputFiles
abstract val sourceFiles: ConfigurableFileCollection

  @TaskAction
  fun execute() {
     val logCallFindings = sourceFiles.files
            .filter { it.isFile && (it.extension.lowercase() == "java" || it.extension.lowercase() == "kt") }
	}  
          

이제 탐색할 파일들을 필터링 하였으니 java.io 패키지에 존재하는 File 메서드를 바탕으로 파일을 라인 단위로 읽고 해당 라인에서 Log를 호출하였는지 검증을 수행할 수 있습니다. 이때 라인에서 Log 호출을 하였는지 검증하는 방법은 정규식을 이용하여 해결할 수 있습니다.

private val logRegex = Regex("""(Log|Timber)\s*\.\s*\w+\s*\(.*?\);?""")

정규식은 Log와 로그 프린팅으로 가장 많이 사용되는 Timber 로 시작되고 이후 .d, .w, .e와 같은 메서드들이 포함되어 있으며 그 뒤에 괄호가 포함되어있는 즉, 일반적인 로그 호출 구문으로 제작하였습니다.

이제 라인 단위로 정규식을 검사하여 파일의 위치와 라인 번호 그리고 라인을 추출한뒤 하나의 문자열로 변환합니다. 파일의 위치는 java.io에 존재하는 기능을 통해 확인할 수 있으며, 라인 번호는 인덱스를 통해 추출해 낼 수 있습니다.

 val logCallFindings: MutableList<String> = sourceFiles.files
		 .filter { it.isFile && (it.extension.lowercase() == "java" || it.extension.lowercase() == "kt") }
		 .flatMap { file ->
				 file.readLines().mapIndexedNotNull { index, line ->
				 if (logRegex.containsMatchIn(line))
						  "Found log call in ${file.absolutePath} at line ${index + 1}: $line"
								  else null
						}
       }.toMutableList()

이제 모든 Log 구문이 호출된 위치를 문자열 리스트로 압축하였으니 해당 데이터를 바탕으로 파일 쓰기를 수행할 경우 리포트 파일을 만들어 낼 수 있습니다. 앞서 정의한 reportFile 위에 파일쓰기를 수행합니다.

val reportFile: File = reportFile.asFile.get()

// Create directory
reportFile.parentFile.mkdirs()

// Write text in the report file
reportFile.writeText(
    text = if (logCallFindings.isNotEmpty())
      "--- Log Call Report ---\nDetected ${logCallFindings.size} Log/Timber calls across your project.\n${
            logCallFindings.joinToString("\n")}"
            else "--- Log Call Report ---\nNo Log/Timber calls detected in the scanned source files."
        )

지금까지의 작성한 코드들을 하나로 병합하여 하나의 Task로 만들 수 있습니다. 최종적인 결과물은 아래와 같습니다.

import org.gradle.api.DefaultTask
import org.gradle.api.file.ConfigurableFileCollection
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.tasks.InputFiles
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.TaskAction
import java.io.File

abstract class CheckLogTask : DefaultTask() {
    private val logRegex = Regex("""(Log|Timber)\s*\.\s*\w+\s*\(.*?\);?""")

    @get:InputFiles
    abstract val sourceFiles: ConfigurableFileCollection

    @get:OutputFile
    abstract val reportFile: RegularFileProperty

    @TaskAction
    fun execute() {
        println("Starting Log call detection process")

        if (sourceFiles.files.isEmpty())
            println("No source files found for analysis. Please check your plugin configuration.")

        // Detects and collects lines of Log or Timber calls within the source files.
        val logCallFindings: MutableList<String> = sourceFiles.files
            .filter { it.isFile && (it.extension.lowercase() == "java" || it.extension.lowercase() == "kt") }
            .flatMap { file ->
                file.readLines().mapIndexedNotNull { index, line ->
                    if (logRegex.containsMatchIn(line))
                        "Found log call in ${file.absolutePath} at line ${index + 1}: $line"
                    else null
                }
            }
            .toMutableList()

        val reportFile: File = reportFile.asFile.get()

        // Create directory
        reportFile.parentFile.mkdirs()

        // Write text in the report file
        reportFile.writeText(
            text = if (logCallFindings.isNotEmpty())
                "--- Log Call Report ---\nDetected ${logCallFindings.size} Log/Timber calls across your project.\n${
                    logCallFindings.joinToString(
                        "\n"
                    )
                }"
            else "--- Log Call Report ---\nNo Log/Timber calls detected in the scanned source files."
        )

        println("Log detection complete. Report generated at: ${reportFile.absolutePath}")
    }
}

지금까지 Gradle 내 Log 구문을 탐색해주는 Task와 그를 뒷받침하는 사전지식들에 대한 내용을 다루어보았습니다. 다음 포스트에서는 이렇게 구현된 Task를 Plugin에 포함시켜 구성하는 방법에 대해 알아보겠습니다.

구현된 내용에 대한 자세한 소스는 [해당 주소] 에서 확인하실 수 있습니다.