| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | |||||
| 3 | 4 | 5 | 6 | 7 | 8 | 9 |
| 10 | 11 | 12 | 13 | 14 | 15 | 16 |
| 17 | 18 | 19 | 20 | 21 | 22 | 23 |
| 24 | 25 | 26 | 27 | 28 | 29 | 30 |
| 31 |
- 프로토타입 패턴
- 코루틴
- 팩토리 메소드
- kmp
- 코틀린멀티플랫폼
- define
- Coroutines
- 함수형프로그래밍
- designPattern
- factory method
- material3
- kotlin multiplatform
- builderPattern
- PrototypePattern
- 추상팩토리패턴
- Kotlin
- 디자인패턴 #
- 코틀린
- android designsystem
- Abstract Factory
- 빌터패턴
- Observer Pattern
- 추상 팩토리
- ㅋㅁ
- Design Pattern
- 디자인패턴
- 옵저버 패턴
- 안드로이드 디자인시스템
- Functional Programming
- compose
- Today
- Total
오늘도 더 나은 코드를 작성하였습니까?
개발자가 알아야 하는 숫자1 - Latency Numbers (Android 개발) 본문
전력망 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를 먼저 가져갑니다. 복잡한 레이아웃은 더 많은 시간을 소요할수도 있습니다.
메인 스레드 최적화는 두 가지 방법.
- 시스템 시간을 줄인다 → 레이아웃 단순화, 뷰 계층 평탄화, ConstraintLayout 활용
- 우리 코드 시간을 줄인다 → 비동기로 빼기, 데이터 미리 준비, 캐싱
실무에서 중요한것은 무엇일까?
| 작업 | 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
}
}
// 🚨 빈 화면 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 바인딩 → 메모리 접근/단순 할당만
