관리 메뉴

오늘도 더 나은 코드를 작성하였습니까?

개발자가 알아야 하는 숫자1 - Latency Numbers (Android 개발) 본문

카테고리 없음

개발자가 알아야 하는 숫자1 - Latency Numbers (Android 개발)

hik14 2026. 5. 18. 18:46

전력망 60Hz에서 시작

  • 미국/한국 전력은 60Hz, 유럽은 50Hz
  • 1930년대 아날로그 TV가 화면 새로 고침을 전력 주파수에 맞춤 

* 60Hz => 1초에 60번 진동

인간 시각과 우연히 맞음

프레임률 인식
24 fps 영화 (모션 블러로 보정)
30 fps 가끔 끊김
60 fps 대부분 부드럽다고 인식
120 fps+ 한계효용 급감

인간 시각 처리 한계가 60~80Hz 근처라, 60Hz가 "부드러움의 임계선"과 자연스럽게 맞았음.

1개의 프레임이 렌더링 되는 시간 

1초 = 1,000 ms 이고 프레임은 1개의 화면

1,000 ms / 60 frames 

=> 16.666... ms

안드로이드의 16.6ms — Project Butter (2012)

안드로이드에서 16.6ms가 공식적인 성능 목표가 된 건 2012년 Google I/O의 "Project Butter" 발표부터입니다. Android 4.1 Jelly Bean. 이전 Android는 프레임률이 들쭉날쭉했는데, Project Butter가 도입한 핵심 개념이:

(1) VSync 동기화

화면 새로 고침(60Hz)에 맞춰 정확히 16.6ms마다 한 프레임씩 그리도록 강제. 이전엔 GPU가 빨리 그리면 빨리 보내고, 늦으면 늦게 보내서 tearing(찢김) 발생했음.

(2) Triple Buffering

CPU, GPU, 디스플레이가 동시에 일할 수 있게 버퍼 3개를 돌림. 이걸로 16.6ms 예산을 빈틈없이 활용.

(3) Choreographer

// Android 내부에서 16.6ms 타이밍을 관리하는 클래스
Choreographer.getInstance().postFrameCallback { frameTimeNanos ->
    // 매 프레임 호출되는 콜백
}

Choreographer가 16.6ms마다 신호를 보내고, 그 안에 모든 그리기 작업이 끝나야 한 프레임이 정상적으로 송출됩니다. 못 끝내면 그 프레임은 버려지고(=프레임 드롭), => 사용자는 끊김으로 인식.

이제는 60fps만이 표준이 아님

요즘 폰들은 더 빠른 디스플레이를 사용

주사율 한 프레임(렌더링 시간) 사용 기기
60Hz 16.6 ms 일반 폰, 대부분의 디스플레이
90Hz 11.1 ms OnePlus 7 Pro 이후 일부
120Hz 8.3 ms iPhone Pro, Galaxy S 시리즈, ProMotion
144Hz 6.9 ms 게이밍 폰

120Hz가 점점 표준이 되고 있어서 예산이 절반으로 줄어드는 추세:

  • 같은 코드가 60Hz 폰에선 부드럽고 120Hz 폰에선 끊겨 보일 수 있음
  • 고급 폰일수록 성능 요구사항이 더 빡빡해짐

출발점: 60fps = 한 프레임 16.6ms

안드로이드 UI가 부드럽게 보이려면 메인 스레드가 16.6ms마다 한 프레임씩 그려내야 합니다. 이 시간을 넘기면 사용자에겐 즉시 jank(끊김)로 보일수 있다. (120fps 기기는 더 빡빡해서 8.3ms)

 

시스템이 7~14ms를 먼저 가져갑니다. 복잡한 레이아웃은 더 많은 시간을 소요할수도 있습니다.

 

메인 스레드 최적화는 두 가지 방법.

  1. 시스템 시간을 줄인다 → 레이아웃 단순화, 뷰 계층 평탄화, ConstraintLayout 활용
  2. 우리 코드 시간을 줄인다 → 비동기로 빼기, 데이터 미리 준비, 캐싱

실무에서 중요한것은 무엇일까?

작업 1번 비용 예산(3~8ms) 안 가능 횟수
메모리 접근 (HashMap get) 100 ns 30,000~80,000번
StateFlow value 읽기 ~200 ns 15,000~40,000번
Room 단순 쿼리 (suspend) ~50-100 μs 30~160번
Room JOIN/@Relation ~300 μs 약 10~25번
SharedPreferences apply() ~수십 μs (메모리만) 수백 번
SharedPreferences commit() 10~50 ms 0번
DataStore 읽기 (캐시 hit, Flow collect) ~수십 μs 수백 번(DataStore는 한번 읽으면 메모리에 캐싱)
DataStore 읽기 (첫 호출, 디스크에서 로드) ~5-20 ms 0~1번 (첫 호출만 )
DataStore 쓰기 (edit { }) ~5-20 ms 0번 
이미지 디코딩 (큰 이미지) 10~100 ms 0번
큰 JSON 파싱 5~50 ms 0번
서버 API 호출 50ms+ 0번
디스크 동기 I/O (큰 파일) 수십 ms 0번

 

"0번"으로 표시된 작업은 메인 스레드 금지

1. 서버 API 호출

표에서 가능 횟수가 0번인 작업들이 메인 스레드 절대 금지 목록입니다. Coroutine 또는 다른 Thread로 작업한다.

1번 비용이 우리 예산(3~8ms)을 이미 넘기거나 거의 다 소모해버려서, 한 번만 실행해도 프레임이 깨집니다.

// 🚨 NetworkOnMainThreadException
val response = okHttpClient.newCall(request).execute()

// ✅ 코루틴 + Dispatchers.IO
viewModelScope.launch {
    val response = withContext(Dispatchers.IO) {
        okHttpClient.newCall(request).execute()
    }
}

 

2. 이미지 디코딩 (10~100ms) → 우리 예산의 1~30배

Coil 또는 Glide 와 같은 라이브러리 사용하여 비동기 처리. 절대로 메인 스레드에서 처리하면 안됨.

View든 Compose든 라이브러리를 사용하여 안전하게 처리한다. 

// 🚨 직접 디코딩 - 큰 이미지면 100ms+
val bitmap = BitmapFactory.decodeFile(path)
imageView.setImageBitmap(bitmap)

// ✅ Coil
AsyncImage(
    model = file,
    contentDescription = null
)

 

3. 큰 JSON 파싱 (5~50ms) → 우리 예산의 1~10배

// ✅ Retrofit + suspend (자동으로 IO에서 실행)
@GET("/posts")
suspend fun getPosts(): List<Post>
 

4. SharedPreferences.commit() (10~50ms) → 우리 예산의 1~10배

// 🚨 메인 스레드 블로킹
prefs.edit().putString("key", value).commit()

// ✅ 비동기 디스크 쓰기 (메모리만 반영, 디스크는 백그라운드)
prefs.edit().putString("key", value).apply()

 

*commit()은 디스크 동기 쓰기라 10~50ms 걸리지만, apply()는 메모리만 건드리니까 수십μs(예산 안).

5. DataStore 쓰기 (5~20ms) → 우리 예산을 거의 다 먹음

// 🚨 DataStore는 컴파일 단에서 막혀있어서 메인 호출 자체가 불가
// suspend 함수만 제공
viewModelScope.launch {
    dataStore.edit { it[KEY] = value }
}

 

DataStore는 안전성을 위해 매 쓰기마다 디스크 sync 보장하니까 5~20ms => suspend로만 노출돼서 강제로 비동기

"30번 이상 가능"한 작업은 메인 스레드 OK

표에서 가능 횟수가 수백~수만 번인 작업들은 무조건 비동기로 뺄 필요 없어요. 모든 걸 코루틴으로 감싸는 게 능사가 아닙니다.

 

작업 가능 횟수 메인 스레드 판단
StateFlow value 읽기 15,000~40,000번
HashMap/List get 30,000~80,000번
SharedPreferences apply() 수백 번
DataStore 읽기 (캐시 hit) 수백 번 ✅ (다만 API는 suspend)
Room 단순 쿼리 30~160번 ⚠️ 1~2번은 OK, 반복은 위험

ViewModel에서 StateFlow 값 읽어서 UI에 반영하는 정도는 메인 스레드 동기 처리로 충분합니다.

짧은 String 포맷팅, 작은 List map/filter도 마찬가지.

Room 단순 쿼리는 애매한 영역. 1번이면 50μs라 예산에 여유 있지만, 반복문에서 호출하면 금세 한도 넘어요. 그래서 Room은 무조건 suspend로 선언해서 컴파일러가 IO Dispatcher로 빼주게 하는 게 표준입니다.

RecyclerView 바인딩

onBindViewHolder는 한 프레임 안에 여러 번 호출됩니다. 그래서 표의 "3~8ms 예산"을 다시 바인딩 한 번당 1~2ms로 더 쪼개서 봐야 해요.

override fun onBindViewHolder(holder: ViewHolder, position: Int) {
    val item = items[position]
    
    // 🚨 동기 DB 조회 (50~100μs × 반복 = 위험)
    val detail = db.getDetail(item.id)
    
    // 🚨 이미지 메인 스레드 디코딩 (10~100ms = 즉사)
    holder.image.setImageBitmap(BitmapFactory.decodeFile(item.path))
    
    // 🚨 매번 SimpleDateFormat 생성 (각 ~수십 μs × N개)
    holder.date.text = SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(item.date)
}

 

private val dateFormatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")  // 재사용

override fun onBindViewHolder(holder: ViewHolder, position: Int) {
    val item = items[position]  // 메모리 접근만 (100ns)
    
    holder.image.load(item.path)        // Coil이 백그라운드 처리
    holder.date.text = dateFormatter.format(item.date)  // 포맷터 재사용
    holder.title.text = item.title       // 단순 할당
}

콜드 스타트 — 16.6ms의 100~300배 예산

앱 시작은 1.5~2초 안에 첫 프레임이 그려져야 합니다. 

1.5초면 약 90배, 2초면 약 120배, 5초(ANR 경고선)면 약 300배

표의 작업들이 누적되면, 한두 개는 OK지만 여러 개 쌓이면 1초 넘기는 경우가 많기 때문에 조심해야된다.  

// 🚨 합치면 500ms+, 사용자는 빈 화면만 봄
class MyApp : Application() {
    override fun onCreate() {
        initFirebase()      // 100ms
        initAnalytics()     // 50ms
        initImageCache()    // 200ms (디스크 캐시 로드)
        initDatabase()      // 100ms (DB 파일 열기 ~ Room이라면 표의 "디스크 동기 I/O" 항목)
        initNetworking()    // 50ms
    }
}
 
해결: Jetpack App Startup으로 지연 초기화, Hilt @Singleton lazy 활용. "진짜 시작 시 필요한 것"과 "나중에 필요한 것"을 분리.
// 🚨 빈 화면 50~500ms (표에서 "서버 API 호출 50ms+")
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        viewModel.posts.observe(this) { /* API 응답 기다림 */ }
    }
}

 

Room 캐시(50~100μs)에서 즉시 보여주고, API는 백그라운드. 표 기준으로 보면 API 50ms 대신 Room 100μs로 500배 빠르게 첫 화면 표시.

// Repository
fun observePosts(): Flow<List<Post>> = flow {
    emitAll(postDao.observeAll())  // 즉시 emit (μs 단위)
}.onStart {
    refreshFromServer()  // 백그라운드 (ms 단위, 사용자는 모름)
}

한 줄 요약

메인 스레드 = 16.6ms 예산 = 우리 코드 3~8ms = 표의 "수십 번 이상" 작업만 자유롭게.

표 기준 0번 작업은 무조건 코루틴으로:

  • 네트워크 → suspend + Retrofit
  • Room → suspend 또는 Flow
  • DataStore → suspend (API 자체가 강제)
  • 이미지 디코딩 → Coil/Glide
  • JSON 파싱 → Retrofit이 IO에서
  • SharedPreferences → commit() 금지, apply() 사용
  • RecyclerView 바인딩 → 메모리 접근/단순 할당만