안드로이드 레거시 코드 개선 : 프로세스의 Memory Leak

김민태·2024년 11월 1일
post-thumbnail

이 글은 운영중인 앱 대상으로 실시했던 보안기관의 보안 검증을 통과하기 위한 레거시 코드 개선 및 해결 과정을 기록한 것 입니다.

1. 문제 정의: 메모리 누수의 발견

OCR 모듈의 결과값을 Kotlin 코드에서 매핑해 UI 또는 API 요청 데이터를 세팅하는 작업에서 메모리 누수가 발생하는 문제가 확인되었습니다.

프로세스 설명:

  • UI : TextView의 setText(), ImageView의 setImageBitmap() 사용
  • 이미지 전송 api : Bitmap을 jpg로 compress()하여 File형의 이미지를 생성 한 후 MultipartBody에 첨부해 전송
  • 문자열 전송 api : 필요 데이터를 최종적으로 String으로 변환해 JSONObject형식의 api 요청 데이터에 첨부해 전송

증상:

  • 데이터를 매핑하고 UI에 표시하는 과정에서 메모리 사용량이 꾸준히 증가.
  • GC 호출 후에도 일부 메모리가 해제되지 않고 남아 있음.
  • 앱의 성능이 저하되고 메모리 사용량비정상적으로 높은 상태 유지.
  • 일부 민감할 수 있는 정보가 문자열(String)이나 이미지 파일(.jpg)의 형태로 검출

실제 외부기관에서 실시한 검증 결과에서 덤프 작업으로 문자열과 이미지가 검출 된 모습
(민감 정보이므로 블러 처리)


2. 원인 분석

문제 1) String 자료형의 메모리 누수

  • 문제 코드
private var secret = "" // 변수 선언
secret = 모듈의_결과객체.멤버_변수.toString() // 변수 할당
intent.putExtra("secretInfo", secret) // 인텐트 첨부
secret.let { tvSecretInfo.setText(it) } // UI 세팅
  • 원인
    1. StringBuffer에서 String으로 변환하는 과정에서 새로운 String 인스턴스가 생성되어 메모리를 불필요하게 점유.
    2. String은 불변 객체이므로, 값이 바뀔 때마다 새로운 객체가 생성되며,기존 객체는 GC(Garbage Collector)가 수거할 때까지 힙 메모리에 남아 있게 됨.**
    3. 위 코드처럼 반복적으로 String을 생성할 경우, GC가 수거하기 전까지 힙 영역에 사용하지 않는 객체들이 계속 남게 되어 메모리 누수처럼 동작할 수 있음.

문제 2) Bitmap 참조 해제 부족

(2-1) UI 설정 시 Bitmap 참조 누수

  • 문제 코드
// Bitmap -> ByteArray 변환 함수
fun bitmapToByteArray(bitmap: Bitmap?): ByteArray {
    val stream = ByteArrayOutputStream()
    bitmap!!.compress(Bitmap.CompressFormat.JPEG, 70, stream)
    return stream.toByteArray()
} 

// 세팅
val secretByte = bitmapToByteArray(secretBitmap) 
secretImageView!!.setImageBitmap(secretBitmap)
  • 원인
    1. compress() 호출 시, 압축된 JPEG 데이터(ByteArray)ByteArrayOutputStream에 저장되지만, 사용 후 해제되지 않으면 메모리 누수 발생.
    2. 원본 Bitmap 역시 compress() 후에도 메모리에 남아 추가적인 메모리 점유 발생.
    3. setImageBitmap() 호출 시, ImageView원본 Bitmap을 참조하여 GC가 제거하지 못하는 상태 유지.

(2-2) API 요청 시 Bitmap 참조 누수

api는 이미지를 넘길 MultipartBody를 요구하고 Bitmapcompress()하여 생성된 File을 첨부 하는 구조

  • 문제 코드
val imageValue = bitmapToByteArray(secretBitmap)
val tempFile = File(cacheDir, 파일_이름)

try {
    tempFile.createNewFile()
    val out = FileOutputStream(tempFile)
    val options = BitmapFactory.Options()
    options.inMutable = true
    val bitmap = BitmapFactory.decodeByteArray(imageValue, 0, imageValue.size, options)
    bitmap.compress(Bitmap.CompressFormat.JPEG, 10, out)
    out.close()
}

// ...중간 코드 생략
// MultipartBody.Builder를 이용해 request body 생성 후 tempFile 첨부
// File의 delete() 메서드를 통해 tempFile 삭제
  • 원인
    1. decodeByteArray() 호출로 새로운 Bitmap 객체가 생성되며, 기존 Bitmap과 중복됨.
    2. bitmap.compress()참조 해제되지 않으면 메모리 점유 지속.
    3. RequestBody.create()가 내부적으로 tempFile을 참조하므로, 해제되지 않으면 메모리 점유 지속.

문제 3) 결과객체 분해 전달

  • 문제 코드
bundle.putByteArray("image", Util.bitmapToByteArray(결과객체.image))
  • 원인
    1. 결과 객체의 일부 데이터만 추출하여 Bundle에 전달하면서, 동일한 데이터가 메모리에 중복 저장됨.
    2. 추가로 생성된 Bundle 객체까지 메모리 관리 대상이 되어 비효율적인 메모리 사용 초래.

3. 해결 방안

문제 해결 1) String 대신 CharArray 활용

  • 해결책: String 대신 CharArray를 사용하여 데이터를 관리하고, String사용이 불가피한 경우 암호화된 String으로 변환하여 처리.

  • 적용 이유

    • CharArrayGC가 쉽게 수거할 수 있으며, 불필요한 객체 복사를 방지함.
    • String은 불변 객체로 메모리에 남아 있을 가능성이 높아, API 요청등 필요 시 암호화 후 변환하여 사용.
  • 변경 후 코드

// StringBuffer -> CharArray 변환 함수
fun bufferToCharArray(buffer: StringBuffer): CharArray {
    val length = buffer.length
    val charArray = CharArray(length)
    buffer.getChars(0, length, charArray, 0) // StringBuffer에서 char[]로 복사
    val resultArray = charArray.copyOf() // 원본 배열을 복사하여 반환

    java.util.Arrays.fill(charArray, '\u0000') // 데이터 덮어쓰기

    for (i in 0 until length) {
        buffer.setCharAt(i, '0') // 덮어쓰기
    }
    buffer.setLength(0) // 내용 삭제

    return resultArray
}

var secret: CharArray? = Util.bufferToCharArray(info.secret)
resultIntent.putExtra("secret", 암호화_함수(secret)) // API 요청 시 CharArray를 암호화된 String으로 변환

secret.fill('\u0000') // 값 덮어쓰기
secret = null // 참조 해제

문제 해결 2) Bitmap 참조 해제

(2-1) UI 세팅에서의 Bitmap 해제

  • 해결책: 생명주기(onDestroy())에 맞춰 ImageView의 Bitmap을 명확히 해제하여 불필요한 메모리 점유 방지.

  • 적용 이유

    • ImageView가 참조하는 Bitmap이 해제되지 않으면 GC가 제거하지 못하고 계속 유지됨.
    • drawable 내부에서 Bitmap을 직접 참조하고 있어, setImageDrawable(null)로 명확한 해제 필요.
  • 변경 후 코드

override fun onDestroy() {
    super.onDestroy()
    textImageLayout?.removeView(secretImageView) // 부모 레이아웃에서 제거
    (secretImageView?.drawable as? BitmapDrawable)?.bitmap?.recycle()
    secretImageView?.setImageDrawable(null)
    secretImageView = null
    ... 다른 참조 해제 소스들
}

(2-2) API 요청 값 세팅에서의 참조 해제

  • 해결책: Bitmap, ByteArray, File 데이터는 사용 후 즉시 정리하고, API 요청 후 MultipartBody, RequestBody 등 관련 요소 참조 해제.

  • 적용 이유

    • bitmap.recycle()을 호출하여 즉시 해제하고, 참조를 null로 설정해 GC가 빠르게 수거하도록 유도.
    • API 요청 후 ByteArrayFile 데이터도 별도의 정리 메서드로 안전하게 삭제.
  • 변경 후 코드

try {
    val options = BitmapFactory.Options()
    options.inMutable = true
    bitmap = BitmapFactory.decodeByteArray(imageValue, 0, imageValue!!.size, options)

    tempFile.createNewFile()
    out = ClearableFileOutputStream(tempFile) // 커스텀 클래스

    if (bitmap != null) {
        if (bitmap.compress(Bitmap.CompressFormat.JPEG, 10, out)) { // 압축 성공 시
            out.close() // 커스텀 클래스의 메서드
            bitmap.recycle() // 비트맵 즉시 해제
            bitmap = null
        }
    }

    // ByteArray 데이터 참조 해제
    Util.secureEraseByteArray(imageValue)
    imageValue = null
} catch (
...생략

tempFile.delete() // 삭제 메서드로 정리
파일을 닫을 때 덮어쓰기 작업을 수행하도록 커스텀 한 클래스
class ClearableFileOutputStream(file: File) : FileOutputStream(file) {
    fun clear() {
        try {
            // 파일 크기 가져오기
            val fileSize = channel.size()

            // 파일을 0으로 덮어씌워 민감 데이터 삭제
            val bytes = ByteArray(fileSize.toInt()) { 0 }
            channel.write(java.nio.ByteBuffer.wrap(bytes))

            // 채널의 위치를 파일 끝으로 설정하여 기존 데이터를 덮어씁니다
            channel.truncate(0)
        } catch (e: IOException) {
            e.printStackTrace()
        }
    }

    // close()를 오버라이드해 커스텀 메서드를 추가
    override fun close() {
        clear()
        super.close()
    }
}
ByteArray 덮어쓰기 함수
fun secureEraseByteArray(byteArray: ByteArray?) {
        if (byteArray != null) {
        	// 동기화 처리로 동시에 수정되는 것을 방지
            synchronized(byteArray) { 
                byteArray.fill(0)
                for (i in byteArray.indices) {
                    byteArray[i] = 0x00
                } // null byte로 덮어쓰기
            }
        }
    } // null 처리는 메서드 밖에서 직접 실행

문제 해결 3) 모듈 결과 객체의 싱글톤 관리

  • 해결책: 모듈의 결과 객체를 Bundle이나 Intent에 분해 전달하는 방식을 버리고 companion object에 저장하여 싱글톤으로 관리.

  • 적용 이유

    • 객체를 직접 참조함으로써 메모리 중복을 줄이고 관리 효율성을 향상시킴.
    • 개별 변수 할당 방식보다 메모리 관리 측면에서 더 효율적.
  • 변경 후 코드

// A액티비티에서 선언
companion object {
    var secretInfo: SecretResult? = null
}

// 모듈 결과 객체의 참조를 secretInfo에 할당
private fun onSuccessResult(info: SecretResult) {
   secretInfo = info
}

// 다음 단계 B엑티비티에서 참조 해제
A_Activity.secretInfo = null

4. 결과 및 성과

결과 1) 메모리 사용량 감소

동일 조건으로 3회 실행 후, Android Profiler - View Live Telemetry를 통해 최대 메모리 사용 시점(2~4초대)프로세스 종료 후 시점(11초경)의 메모리 상태를 측정함.

  • 개선 전:

    • 최대 메모리 사용량: 362 ~ 376MB
    • GC 호출 후 남은 메모리: 261 ~ 279MB
  • 개선 후:

    • 최대 메모리 사용량: 256 ~ 268MB 약 28% 감소
    • GC 호출 후 남은 메모리: 224 ~ 228MB 약 18% 감소

결과 2) 성능 개선

동일 조건으로 2회 실행 후, Android Profiler - System Trace를 통해 그래프상 제일 고점의 점유율을 측정

  • 데이터를 매핑하고 UI에 표시하는 과정의 안정성 증가:

    • GC 호출 간격이 늘어나면서 CPU 부하 감소.
    • 불필요한 메모리 점유가 제거되어 앱의 응답성이 개선.
  • 개선 전

  • 개선 후 : 약 38% 감소

결과 3) 메모리 누수(Leak) 제거

Android Profiler - Heap Dump 를 통해 프로세스 종료 후 생긴 메모리 누수 제거 확인


5. 요약

1) 민감 정보 처리 시 주의사항

  • 문자열: String 대신 CharArray로 나눠 처리하고, 불가피할 경우 암호문 변환 후 사용.
  • 이미지: BitmapFile빠르게 해제하고, 이미지를 참조 하고있는 요소도 함께 정리.
  • 뷰(View): 생명주기(onDestroy())에 맞춰 부모 제거 또는 null 처리로 참조 해제.
  • 참조 해제: null처리 뿐만 아니라 덮어쓰기 또는 자료형에 내장된 정리 메서드를 적극 활용.

2) 메모리 누수 및 보안 검증 도구

  • Heap Dump (Android Profiler): GC 후 남은 객체로 누수 여부 확인.
  • Frida3: 민감 정보 노출 여부를 메모리 덤프 분석으로 검증.

6. 결론

이번 작업을 통해 민감 정보를 다루는 과정에서 발생한 메모리 누수 문제를 효과적으로 해결했습니다. String 대신 CharArray를 활용하고, Bitmap 참조 해제 및 파일 덮어쓰기 방식을 도입하여 메모리 최적화CPU 점유율 감소를 달성했습니다.

성과:

  • 메모리 누수 제거로 GC 호출 후 불필요한 메모리 점유량을 약 18% 에서 28% 정도 감소시켰습니다.
  • CPU 부하를 약 38% 감소시켜 앱의 응답성과 안정성을 향상했습니다.
  • 민감 정보의 메모리 잔존(Leak) 문제를 해결하여 보안 검증을 통과했습니다.

교훈:

  • 민감 정보와 이미지 데이터를 다룰 때는 항상 참조 해재가 선언과 한 쌍으로 존재해야하며 작업 과정에서도 메모리 관리에 주의해야 합니다.
  • 메모리 누수는 성능 저하뿐 아니라 보안 취약점으로 이어질 수 있으므로, 다루는 데이터의 보안성에 따라 프로파일링 도구를 활용한 지속적인 모니터링이 필요합니다.

0개의 댓글