이 글은 운영중인 앱 대상으로 실시했던 보안기관의 보안 검증을 통과하기 위한 레거시 코드 개선 및 해결 과정을 기록한 것 입니다.
OCR 모듈의 결과값을 Kotlin 코드에서 매핑해 UI 또는 API 요청 데이터를 세팅하는 작업에서 메모리 누수가 발생하는 문제가 확인되었습니다.
프로세스 설명:
setText(), ImageView의 setImageBitmap() 사용Bitmap을 jpg로 compress()하여 File형의 이미지를 생성 한 후 MultipartBody에 첨부해 전송JSONObject형식의 api 요청 데이터에 첨부해 전송증상:
실제 외부기관에서 실시한 검증 결과에서 덤프 작업으로 문자열과 이미지가 검출 된 모습
(민감 정보이므로 블러 처리)

private var secret = "" // 변수 선언
secret = 모듈의_결과객체.멤버_변수.toString() // 변수 할당
intent.putExtra("secretInfo", secret) // 인텐트 첨부
secret.let { tvSecretInfo.setText(it) } // UI 세팅
StringBuffer에서 String으로 변환하는 과정에서 새로운 String 인스턴스가 생성되어 메모리를 불필요하게 점유. String은 불변 객체이므로, 값이 바뀔 때마다 새로운 객체가 생성되며,기존 객체는 GC(Garbage Collector)가 수거할 때까지 힙 메모리에 남아 있게 됨.**String을 생성할 경우, GC가 수거하기 전까지 힙 영역에 사용하지 않는 객체들이 계속 남게 되어 메모리 누수처럼 동작할 수 있음.// 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)
compress() 호출 시, 압축된 JPEG 데이터(ByteArray)가 ByteArrayOutputStream에 저장되지만, 사용 후 해제되지 않으면 메모리 누수 발생. Bitmap 역시 compress() 후에도 메모리에 남아 추가적인 메모리 점유 발생. setImageBitmap() 호출 시, ImageView가 원본 Bitmap을 참조하여 GC가 제거하지 못하는 상태 유지. api는 이미지를 넘길
MultipartBody를 요구하고Bitmap을compress()하여 생성된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 삭제
decodeByteArray() 호출로 새로운 Bitmap 객체가 생성되며, 기존 Bitmap과 중복됨. bitmap.compress() 후 참조 해제되지 않으면 메모리 점유 지속. RequestBody.create()가 내부적으로 tempFile을 참조하므로, 해제되지 않으면 메모리 점유 지속. bundle.putByteArray("image", Util.bitmapToByteArray(결과객체.image))
Bundle에 전달하면서, 동일한 데이터가 메모리에 중복 저장됨. Bundle 객체까지 메모리 관리 대상이 되어 비효율적인 메모리 사용 초래.해결책: String 대신 CharArray를 사용하여 데이터를 관리하고, String사용이 불가피한 경우 암호화된 String으로 변환하여 처리.
적용 이유
CharArray는 GC가 쉽게 수거할 수 있으며, 불필요한 객체 복사를 방지함. 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 // 참조 해제
해결책: 생명주기(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
... 다른 참조 해제 소스들
}
해결책: Bitmap, ByteArray, File 데이터는 사용 후 즉시 정리하고, API 요청 후 MultipartBody, RequestBody 등 관련 요소 참조 해제.
적용 이유
bitmap.recycle()을 호출하여 즉시 해제하고, 참조를 null로 설정해 GC가 빠르게 수거하도록 유도. ByteArray와 File 데이터도 별도의 정리 메서드로 안전하게 삭제. 변경 후 코드
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()
}
}
fun secureEraseByteArray(byteArray: ByteArray?) {
if (byteArray != null) {
// 동기화 처리로 동시에 수정되는 것을 방지
synchronized(byteArray) {
byteArray.fill(0)
for (i in byteArray.indices) {
byteArray[i] = 0x00
} // null byte로 덮어쓰기
}
}
} // null 처리는 메서드 밖에서 직접 실행
해결책: 모듈의 결과 객체를 Bundle이나 Intent에 분해 전달하는 방식을 버리고 companion object에 저장하여 싱글톤으로 관리.
적용 이유
변경 후 코드
// A액티비티에서 선언
companion object {
var secretInfo: SecretResult? = null
}
// 모듈 결과 객체의 참조를 secretInfo에 할당
private fun onSuccessResult(info: SecretResult) {
secretInfo = info
}
// 다음 단계 B엑티비티에서 참조 해제
A_Activity.secretInfo = null
동일 조건으로 3회 실행 후,
Android Profiler - View Live Telemetry를 통해 최대 메모리 사용 시점(2~4초대)과 프로세스 종료 후 시점(11초경)의 메모리 상태를 측정함.
개선 전:
362 ~ 376MB
261 ~ 279MB
개선 후:
256 ~ 268MB 약 28% 감소
224 ~ 228MB 약 18% 감소
동일 조건으로 2회 실행 후,
Android Profiler - System Trace를 통해 그래프상 제일 고점의 점유율을 측정
데이터를 매핑하고 UI에 표시하는 과정의 안정성 증가:
개선 전

개선 후 : 약 38% 감소

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

String 대신 CharArray로 나눠 처리하고, 불가피할 경우 암호문 변환 후 사용. Bitmap과 File은 빠르게 해제하고, 이미지를 참조 하고있는 요소도 함께 정리. onDestroy())에 맞춰 부모 제거 또는 null 처리로 참조 해제.null처리 뿐만 아니라 덮어쓰기 또는 자료형에 내장된 정리 메서드를 적극 활용.이번 작업을 통해 민감 정보를 다루는 과정에서 발생한 메모리 누수 문제를 효과적으로 해결했습니다. String 대신 CharArray를 활용하고, Bitmap 참조 해제 및 파일 덮어쓰기 방식을 도입하여 메모리 최적화와 CPU 점유율 감소를 달성했습니다.
성과:

교훈: