[ Android Essential ] 라이브러리로 보는 비동기 시리즈 : 자물쇠 시대의 종말

malcongmalcom·2025년 7월 9일

Android Essential

목록 보기
3/5
post-thumbnail

바쁘다 바빠 현대 사회

안드로이드에서 비동기라는 키워드를 접할 때마다 떠오르는 생각이 하나 있다. 바로 “현대 사회”다. 현대 사회는 철저히 분업화되어 있고, 쉬지 않고 효율적으로 일하는 구조를 추구한다. “잘 쉬는 것도 일의 연장”이라는 말이 있을 정도로, 우리는 한정된 시간과 자원 안에서 최대의 성과를 내기 위해 최적화된 시스템 속에 살고 있다.

스마트폰 내부도 마찬가지다. 제한된 리소스 속에서 수많은 작업을 완벽하게 처리하기 위해서는, 맡겨진 일을 효율적으로 분배하고 실행하는 방식이 필요하다. 그 핵심이 바로 비동기다.

비동기는 결국 CPU가 작업을 처리하는 단위인 스레드와 직결된다. 코루틴(Coroutine), Flow, WorkManager, Room, suspend 함수, AsyncTask, Thread, Handler 등은 이러한 비동기 처리를 다양한 차원에서 구현하기 위한 기술적 도구일 뿐이다. 본질은 모두 멀티코어 CPU와 스레드의 협력을 어떻게 효과적으로 orchestrate(조율)할 것인가에 있다.

오늘은 안드로이드 앱에서 간단한 정보를 저장하기 위해 널리 쓰이는 두 가지 기술을 살펴본다. SharedPreferences와 DataStore다. 두 기술이 어떤 교차점을 가지는지, 내부 구조는 어떻게 동작하는지 파헤치면서, 왜 비동기가 안드로이드에서 이렇게 중요한 키워드가 되었는지를 함께 이해해보자.

Why DataStore?

요즘 안드로이드 개발자들이 가장 많이 이야기하는 로컬 데이터 저장 방식은 단연 Jetpack DataStore다. 구글이 직접 권장하고 있고, 이제는 SharedPreferences보다 DataStore를 쓰는 게 당연한 흐름이 됐다. 이유는 단순하다. 비동기 처리, 스레드 안전성, 그리고 타입 안정성. 이 세 가지를 한 번에 해결할 수 있는 솔루션이기 때문이다.

사실 우리가 저장하는 데이터는 대부분 아주 작다. 로그인 유지 여부, 유저 역할, 앱 테마 설정 같은 값들. 결국 이건 OS 차원에서 보면 작은 파일을 읽고 쓰는 작업일 뿐이다. SharedPreferences가 XML 파일을 다뤘던 것처럼, DataStore는 프로토콜 버퍼(Protobuf) 파일을 다룬다. 그러니까 본질은 “파일 저장”이지만, 그걸 처리하는 방식이 완전히 달라졌다.

예전 방식인 SharedPreferences는 commit()을 사용할 경우 UI 스레드에서 동기적으로 파일 I/O를 처리했기 때문에 ANR(Application Not Responding)의 주요 원인 중 하나였다. 이를 개선하기 위해 등장한 apply()는 메모리 반영 후 디스크 기록을 ExecutorService의 백그라운드 스레드에서 비동기로 처리한다. 따라서 실제 파일 쓰기 작업은 UI 스레드에서 실행되지 않는다. 다만, Binder 트랜잭션 직전에 QueuedWork.waitToFinish()가 호출될 경우, 아직 끝나지 않은 디스크 쓰기를 기다리느라 UI 스레드가 잠시 블로킹될 수 있다. 반면 DataStore는 모든 작업을 코루틴 기반으로 완전한 비동기 처리 방식으로 설계했다. 파일 쓰기는 항상 별도의 디스패처에서 실행되고, UI 스레드는 단 한 번도 블로킹되지 않는다. 개발자가 스레드 처리를 직접 관리할 필요도 없다.

또 하나 중요한 차이는 타입 안정성이다. Preferences DataStore는 기존 SharedPreferences처럼 Key-Value 기반이지만, 여전히 "user_id" 같은 문자열 키를 직접 다뤄야 한다. 반대로 Proto DataStore는 Protobuf를 사용해서 데이터 구조를 정의한다. 예를 들어 Tebah 앱에서는 이런 방식으로 유저 정보를 정의한다.

syntax = "proto3";

enum UserRoleProto {
  GUEST = 0;
  MEMBER = 1;
  ADMIN = 2;
}

message UserPreferences {
  string user_id = 1;
  bool is_auto_login = 2;
  UserRoleProto user_role = 3;
}

이렇게 해두면, user_id를 오타 낼 걱정도 없고, 잘못된 타입을 넣는 실수도 사라진다. 실제 Tebah 앱의 DataStore 구현 코드는 다음과 같다.

dataStore.updateData { current ->
    current.toBuilder()
        .setUserId(id)
        .setIsAutoLogin(isAutoLogin)
        .setUserRole(role)
        .build()
}

updateData는 suspend 함수라서 내부적으로 코루틴에서 실행된다. DataStore는 이 과정을 Mutex로 감싸서 동시성을 제어하고, 파일 쓰기는 IO 스레드에서 안전하게 처리한다. 그리고 데이터가 변경되면 Flow를 통해 바로 UI에 반영할 수도 있다. Jetpack Compose를 쓰는 경우라면 collectAsState()만 붙이면 끝이다. 이런 구조 덕분에 UI가 자연스럽게 반응하는 앱을 만들 수 있다.

설정도 간단하다. Gradle에 DataStore 의존성을 추가하고, Proto DataStore를 쓰려면 Protobuf 플러그인을 넣으면 된다. id "com.google.protobuf" 플러그인과 .proto 파일만 준비하면 끝. 최신 프로젝트에서는 거의 표준처럼 자리 잡았다.

결국 DataStore는 단순히 SharedPreferences를 대체하는 라이브러리가 아니다. 비동기 처리와 스레드 안전성을 확보하고, 타입 안정성까지 보장하는 현대적인 설계다. 하지만 여기서 이런 생각이 든다.

SharedPreferences는 왜 이렇게까지 문제가 되었을까? apply()는 왜 완전한 비동기가 아니었을까? 그리고 synchronized 기반의 락은 어떤 한계를 가지고 있었을까?

이 질문에 답할 수 있다면, 안드로이드에서 비동기 처리가 얼마나 중요한 개념인지, 그리고 동시성 문제가 왜 개발자들에게 큰 과제인지 제대로 체감하게 될 것이다. 그럼 이제 SharedPreferences의 내부 구조를 하나씩 살펴보자.

SharedPreferences

SharedPreferences는 안드로이드에서 가장 널리 사용되는 Key-Value 기반 로컬 저장 방식 중 하나다. 외부에서 보기에는 단순하지만, 내부 구조를 살펴보면 몇 가지 중요한 특징이 있다. 인터페이스 형태로 제공되는 API와, XML 파일 기반의 실제 저장 방식이 그 특징 중 하나다.

SharedPreferences는 이름 그대로 인터페이스이고, 실제 구현체는 SharedPreferencesImpl이다. 이 구현체는 앱에서 Context.getSharedPreferences()를 호출할 때 생성된다. 사용 코드를 보면:

val prefs = context.getSharedPreferences("user_prefs", Context.MODE_PRIVATE)

이 메서드를 호출하면 내부적으로 다음과 같이 동작한다:

public SharedPreferences getSharedPreferences(String name, int mode) {
    File file = getSharedPrefsFile(name);
    return new SharedPreferencesImpl(file, mode);
}

여기서 주목할 부분은 File file = getSharedPrefsFile(name);이다. SharedPreferences는 결국 XML 파일을 기반으로 동작하는 저장소다. 예를 들어 "user_prefs"라는 이름은 실제로 다음 경로에 있는 XML 파일과 연결된다:

/data/data/<패키지명>/shared_prefs/user_prefs.xml

데이터는 XML 형태로 직렬화되어 저장된다. 예를 들어:

val editor = prefs.edit()
editor.putString("username", "malcong")
editor.putInt("age", 28)
editor.apply()

이 코드를 실행하면 내부적으로는 이런 XML이 만들어진다:

<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
    <string name="username">malcong</string>
    <int name="age" value="28" />
</map>

SharedPreferences는 처음 getSharedPreferences()를 호출하는 순간, 내부에서 SharedPreferencesImpl을 생성하며 XML을 메모리에 로드한다. 이때 Map<String, Object> 형태로 캐싱되기 때문에 이후 읽기 작업은 매우 빠르다. 파싱 로직은 다음과 같이 처리된다:

mMap = (Map<String, Object>) XmlUtils.readMapXml(str);

즉, 한 번 로드된 이후에는 디스크를 다시 읽지 않고 메모리에서 즉시 값을 반환한다. 하지만 문제는 쓰기 작업이다. 데이터를 저장하려면 단순히 메모리를 갱신하는 것에서 끝나지 않는다. Map을 업데이트한 뒤 → XML로 직렬화 → 디스크에 기록하는 과정이 필요하다. 이 과정에서 디스크 I/O가 발생하고, 이 작업이 어떤 방식으로 처리되느냐에 따라 앱의 성능과 안정성이 크게 달라진다.

안드로이드에서는 이 과정을 처리하기 위해 commit()과 apply()라는 두 가지 메서드를 제공한다. 두 메서드는 결국 동일한 최종 목표(변경 내용을 디스크에 기록)를 가지지만, 동작 방식에서 큰 차이를 보인다.

commit() vs apply()

commit()은 동기 방식으로 동작한다. 호출한 스레드에서 모든 작업—메모리 갱신, XML 직렬화, 디스크 쓰기—가 완료될 때까지 대기한다. 즉, 메서드를 호출한 스레드가 블로킹된다. 대부분의 경우, commit()은 UI 스레드에서 호출되기 때문에 ANR(Application Not Responding) 문제를 유발할 수 있다.

내부 구현을 보면:

public boolean commit() {
    MemoryCommitResult mcr = commitToMemory();
    SharedPreferencesImpl.this.enqueueDiskWrite(mcr, null);
    try {
        mcr.writtenToDiskLatch.await();
    } catch (InterruptedException e) {
        return false;
    }
    return mcr.writeToDiskResult;
}

핵심은 mcr.writtenToDiskLatch.await() 부분이다. 이 코드는 디스크 쓰기가 완료될 때까지 현재 스레드를 블로킹한다. 따라서 UI 스레드에서 commit()을 호출하면 ANR 위험이 매우 높다.

apply()는 비동기 방식이다. 메모리 갱신은 즉시 완료되지만, 디스크 쓰기는 별도의 쓰레드에서 처리된다. 즉, 호출한 스레드는 블로킹되지 않고 즉시 반환된다. 내부 동작은 다음과 같다:

public void apply() {
    final MemoryCommitResult mcr = commitToMemory();
    final Runnable awaitCommit = new Runnable() {
        public void run() {
            try {
                mcr.writtenToDiskLatch.await();
            } catch (InterruptedException ignored) {}
        }
    };
    QueuedWork.add(awaitCommit);
    SharedPreferencesImpl.this.enqueueDiskWrite(mcr, new Runnable() {
        public void run() {
            QueuedWork.remove(awaitCommit);
        }
    });
}

여기서 중요한 부분은 QueuedWork다. 이 큐는 apply()가 비동기적으로 동작할 때, 앱이 백그라운드로 내려가거나 Activity가 종료되는 시점에서 아직 완료되지 않은 쓰기 작업을 마무리하는 데 사용된다. 디스크 기록 자체는 내부에서 ExecutorService를 이용한 스레드 풀에서 실행된다.

결국 차이는 명확하다:

  • commit() → 호출한 스레드에서 디스크 쓰기까지 동기 실행 (UI 스레드 블로킹)
  • apply() → 메모리 갱신 후 즉시 반환, 디스크 쓰기는 백그라운드에서 비동기 처리

이 차이 때문에 대부분의 경우 apply()가 권장된다. 하지만 apply()는 디스크 기록이 언제 끝나는지 알 수 없으므로, 앱이 강제 종료되거나 전원이 꺼지는 시점에는 데이터 유실 가능성이 있다. 반대로 commit()은 반환 시점에 디스크 기록까지 보장되지만, UI 스레드에서 호출할 경우 성능 저하와 ANR 위험이 크다.

동시성?

이쯤에서 동시성에 대한 얘기로 넘어가자. 동시성, 개발할 때 자주 듣는 말인데 정확히 뭘 의미하는 걸까? 간단히 말해, 여러 작업이 동시에 실행돼도 프로그램의 상태가 꼬이지 않고 우리가 기대하는 결과가 나오는 것, 그게 동시성이다.

예를 들어 은행 앱에서 동시에 송금을 요청했다고 해보자. 두 트랜잭션이 동시에 실행되는데, 하나는 정상 처리되고 다른 하나는 잔고 부족으로 실패해야 한다. 그런데 동시성 제어가 안 되면? 둘 다 성공해버려서 잔고가 마이너스가 된다. 이런 문제는 서버뿐만 아니라, 안드로이드 앱에서도 충분히 발생한다. 앱 내부에서 여러 스레드가 공용 데이터에 접근하는 순간부터 동시성은 핵심 이슈다.

SharedPreferences도 예외가 아니다. 앱에서 설정값을 저장할 때 자주 쓰이는 이 클래스는 내부적으로 HashMap을 사용해 데이터를 메모리에 유지하고, 필요할 때 XML 파일로 기록한다. HashMap은 계속 나온다. 한 번 직접 만들어보면 많은 것들을 알 수 있다. 이쯤에서 아메리카노 한 잔 마시고 오라. 산타클로스 이야기도 파일 시스템을 이해하는 데에 많은 도움이 된다. 중요한 건, SharedPreferences는 앱 전역에서 누구나 접근할 수 있다는 점이다. 즉, 메인 스레드(UI)와 백그라운드 스레드가 동시에 값 저장을 시도할 수 있다. 이 상황에서 데이터를 안전하게 관리하려면 락(lock)이 필요하다.

synchronized

안드로이드의 SharedPreferences는 이 문제를 해결하기 위해 synchronized 키워드를 사용한다. 그런데, synchronized는 정확히 뭘까?

synchronized는 JVM에서 제공하는 모니터 락(Monitor Lock) 기반의 동기화 방식이다. 왜 이름이 모니터일까? 자바의 모든 객체는 내부적으로 모니터라는 구조를 가진다. 모니터는 스레드가 해당 객체에 대해 하나씩 차례로만 접근하도록 보장하는 메커니즘이다. 한 스레드가 모니터를 획득하면, 다른 스레드는 그 락을 얻을 때까지 대기한다.

하지만 synchronized는 단순히 “줄 서서 기다려” 수준이 아니다. 이 키워드가 하는 일은 훨씬 더 깊다. 메모리 배리어를 삽입해 CPU 캐시를 메모리와 동기화하고, 명령어 재정렬을 막고, 스레드 간 데이터 일관성을 보장한다. 이제 그 내부를 실제 예시를 통해 살펴보자.

상황을 가정해 보자. Tebah 앱에서 이런 코드가 동시에 실행된다:

// UI 스레드 
prefs.edit().putBoolean("theme_dark", true).apply()

// 백그라운드 스레드 
prefs.edit().putBoolean("location_enabled", true).apply()

두 스레드가 동시에 apply()를 호출한다. 여기서 “동시에”라는 말은 멀티코어 CPU에서 두 스레드가 서로 다른 코어에서 실제로 병렬 실행된다는 의미다.

  • 스레드 A(Core 0): "theme_dark"=true를 HashMap에 기록
  • 스레드 B(Core 1): "location_enabled"=true를 HashMap에 기록

여기서 락이 없다면 어떤 일이 벌어질까? HashMap은 스레드 안전하지 않다. 왜냐하면 내부 동작이 이렇게 복잡하기 때문이다.

HashMap.put():
1. 키 해시 계산
2. 인덱스 찾기
3. 기존 엔트리 탐색
4. 새 엔트리 생성 및 연결
5. size++

이 중간에 다른 스레드가 끼어들면? 예를 들어, 스레드 A가 bucket[index]를 갱신하기 직전에 스레드 B가 동일한 버킷을 수정하면, 연결 리스트 포인터가 꼬인다.

if (bucket[i] == null) {
    bucket[i] = newEntry;
} else {
    newEntry.next = bucket[i];  // A가 여기까지 실행했는데,
    bucket[i] = newEntry;       // B가 개입해서 구조가 깨진다
}

결과는 참혹하다. 연결 리스트의 포인터가 꼬여서 해시 구조가 무너지고, 그 다음 탐색에서 NullPointerException이 발생하거나 심하면 무한 루프에 빠질 수도 있다. 이런 문제가 발생하면 앱은 곧바로 크래시나 ANR을 맞게 된다.

JMM

이 상황을 더 깊게 보자. 두 스레드가 동시에 HashMap을 수정한다는 건, 결국 JVM 힙(Heap)에 존재하는 동일한 객체를 멀티코어 CPU가 병렬로 건드리는 것이다. HashMap에 값을 넣는 과정은 단순히 한 줄의 자바 코드가 아니다. 내부적으로는 수십 개의 JVM 바이트코드, 그리고 JIT 컴파일 후 수십 개의 CPU 명령어로 변환된다.

CPU 레벨에서 벌어지는 흐름은 이렇다:

  • 스레드 A(Core 0): MOV 명령으로 엔트리 주소를 레지스터에 로드 → 조건 분기(JNE) → 기존 엔트리의 next 포인터 연결
  • 스레드 B(Core 1): 동일한 HashMap 버킷에 접근 → 값 비교(CMP) → 포인터 덮어쓰기(MOV)

각 스레드는 자신이 쓰는 값을 L1 캐시에 보관하고, 성능을 위해 메인 메모리(RAM)에는 나중에 반영한다. 문제는 여기서 발생한다. Core 0은 "theme_dark" 값을 자신의 캐시에만 반영하고, Core 1은 "location_enabled" 값을 다른 캐시에만 반영한다. 메모리는 여전히 옛날 값이다. 즉, 두 스레드가 같은 객체를 바라보지만, 실제로는 서로 다른 세계를 보고 있는 셈이다. 이게 바로 메모리 가시성 문제다.

그렇다면 어떻게 해결할까? 바로 여기서 synchronized가 진가를 발휘한다. synchronized는 단순히 “한 스레드씩 들어오세요”가 아니라, 두 가지를 보장한다.

첫째는 원자성(Atomicity)이다. 락을 획득한 스레드만 HashMap을 수정할 수 있고, 그 작업이 끝날 때까지 다른 스레드는 진입할 수 없다. HashMap.put()의 모든 단계—버킷 인덱스 계산, 엔트리 생성, 연결 리스트 조정, size 증가—이 하나의 불가분한 작업으로 취급된다.

둘째는 메모리 가시성(Visibility)이다. 락을 해제할 때 JVM은 CPU에게 다음을 명령한다.

  • 캐시 내용을 메인 메모리에 플러시
  • 명령어 재정렬 금지
  • 쓰기 버퍼 비우기

이 명령은 메모리 배리어(memory barrier)를 통해 구현된다. 메모리 배리어는 CPU에게 “지금까지 진행 중이거나 예정된 메모리 관련 연산을 반드시 모두 완료하고, 순서를 보장하라”는 강제 신호다. 왜 이런 게 필요할까? 바로 Java Memory Model(JMM) 때문이다.

JMM은 자바 멀티스레드 프로그램이 동일한 결과를 보장하도록 정의한 규칙이다. 한 스레드에서 변경한 값이 다른 스레드에도 동일하게 보이도록, 언제 메모리 동기화를 강제해야 하는지 명확히 규정한다. JMM에 따라 synchronized 블록을 빠져나올 때는 StoreStore, StoreLoad 배리어, 진입할 때는 LoadLoad, LoadStore 배리어가 삽입된다.

CPU는 이를 구현하기 위해 특수 명령어를 사용한다.

  • x86: LOCK CMPXCHG, MFENCE
  • ARM: DMB

이 명령들은 단순히 락을 거는 것이 아니다. 내부적으로는 다음과 같은 강력한 동작을 수행한다.

  • 버스 잠금(Bus Lock)
  • 캐시 무효화(Cache Invalidation)
  • 파이프라인 플러시(Pipeline Flush)

모든 CPU 코어는 메모리를 공유하는 데이터 통로(버스)를 통해 읽고 쓴다. 버스 잠금은 이 통로를 잠시 독점해 다른 코어의 접근을 막는 것이다. 비유하면, 고속도로에 차들이 달리고 있는데 한 트럭이 모든 차선을 막고 “내 작업 끝날 때까지 아무도 지나가지 마!”라고 하는 상황이다.

멀티코어 CPU는 각 코어가 L1/L2 캐시에 데이터를 저장해 속도를 높인다. 문제는 캐시가 최신 값을 보장하지 않는다는 것이다. 캐시 무효화는 다른 코어가 가진 동일 데이터의 사본을 모두 “무효”로 만들고, 다음 접근 시 반드시 메인 메모리(RAM)에서 다시 가져오도록 강제한다. 비유하자면, 집집마다 복사본으로 쓰던 장부를 전부 찢고 본점에서 원본만 보도록 만드는 것이다.

CPU는 성능을 높이기 위해 파이프라인(Pipeline) 방식을 사용한다. 파이프라인은 명령어를 단계별로 분리해 동시에 처리하는 방식이다.
예:

  1. Fetch (명령어 가져오기)
  2. Decode (명령어 해석)
  3. Execute (실행)
  4. Write Back (결과 저장)

이 덕분에 CPU는 첫 번째 명령을 실행하는 동안, 두 번째 명령을 해석하고, 세 번째 명령을 가져올 수 있어 매우 빠르다. 하지만 이 과정에서 CPU는 명령어 재정렬(Out-of-Order Execution)이라는 최적화까지 한다. 즉, “앞에 있는 연산이 메모리 쓰기라서 느리네? 그럼 뒤에 있는 연산부터 처리해야겠다.” 이런 식이다.

문제는 메모리 배리어가 오면 이런 최적화가 금지된다는 것이다. 배리어는 “이 시점 이후의 명령어는 절대 먼저 실행하면 안 된다. 파이프라인 안에 이미 쌓인 것도 다 멈춰!”라는 신호를 CPU에 준다. CPU는 파이프라인에 있던 명령을 전부 비우고(flush), 다시 순서대로 재시작한다.

비유로 말하자면, 공장에서 조립 라인에 부품들이 줄줄이 올라가 있는데, 갑자기 “순서가 틀렸으니 전부 치워!”라는 명령이 떨어진 상황과 같다. 이미 조립 중이던 부품을 치우고, 라인을 다시 세팅하는 데 드는 비용은 엄청나다. 파이프라인 단계가 깊을수록(최신 CPU는 14~20단계 이상) 이 비용은 기하급수적으로 커진다.

이 모든 작업은 CPU 성능 최적화의 정반대다. 특히 캐시 플러시는 매우 느리다. 캐시는 CPU 바로 옆에 있는 초고속 메모리(L1 몇 ns, L2 수십 ns)인데, 메인 메모리(RAM)는 수백 ns 이상이 걸린다. 캐시를 비우고 다시 메모리에서 가져오면 접근 시간이 수십 배 이상 늘어난다.

여기에 컨텍스트 스위칭과 락 경합이 추가된다.

  • 컨텍스트 스위칭: OS가 스레드를 바꾸면서 레지스터, 스택 정보를 저장하고 복원하는 과정. 수백~수천 CPU 사이클이 소요된다.
  • 락 경합: 여러 스레드가 synchronized 구역 앞에서 대기하는 상황. 스레드 수가 많아질수록 대기 시간이 폭발적으로 증가한다.

결과적으로 synchronized는 단순한 자바 키워드가 아니다. CPU 캐시 정책, 파이프라인, 심지어 메모리 버스까지 제어하는 강력한 동기화 장치다. 하지만 그 대가로 성능 비용이 매우 크며, 락이 자주 발생하면 멀티코어 환경에서 처리량이 급격히 떨어진다.

메모리가 아니라 디스크라면?

여기까지는 메모리 레벨에서 벌어지는 동시성 문제와 synchronized가 이를 어떻게 해결하는지 살펴봤다. 하지만 SharedPreferences는 메모리에만 데이터를 저장하지 않는다. 최종적으로는 XML로 직렬화해 디스크에 기록해야 한다. 이 디스크 쓰기 과정에서도 중요한 문제가 발생한다.

먼저 apply()의 내부 코드를 살펴보자.

public void apply() {
    final MemoryCommitResult mcr = commitToMemory();
    final Runnable awaitCommit = new Runnable() {
        public void run() {
            try {
                mcr.writtenToDiskLatch.await();
            } catch (InterruptedException ignored) {}
        }
    };
    QueuedWork.add(awaitCommit);
    SharedPreferencesImpl.this.enqueueDiskWrite(mcr, new Runnable() {
        public void run() {
            QueuedWork.remove(awaitCommit);
        }
    });
}

이제 이 코드를 단계별로 뜯어보면, 왜 메모리 정합성은 보장되지만 디스크 정합성은 깨질 수 있는지 이해할 수 있다.

  1. commitToMemory() – 메모리 반영

apply()는 먼저 commitToMemory()를 호출해 메모리에 변경 사항을 반영한다. 이 메서드는 synchronized 블록 안에서 실행되기 때문에 여러 스레드가 동시에 apply()를 호출해도 메모리 내 HashMap 업데이트는 직렬화된다. 즉, 메모리 레벨에서는 정합성이 보장된다. 모든 변경 사항은 하나의 글로벌 락을 통해 원자적으로 적용되기 때문이다.

하지만 여기서 중요한 점은, commitToMemory()가 끝나는 순간 apply()는 UI 스레드로 즉시 반환된다는 것이다. 이 시점에서 사용자 입장에서는 "저장 완료"처럼 보인다. 그러나 실제로 디스크에 데이터가 기록되기까지는 아직 갈 길이 멀다.

  1. awaitCommit과 QueuedWork.add()

다음으로, apply()는 awaitCommit이라는 Runnable을 만든다. 내부에서 mcr.writtenToDiskLatch.await()를 호출하는데, 이는 CountDownLatch를 사용해 디스크 쓰기 완료 여부를 기다리는 코드다. 이 Runnable은 바로 실행되지 않는다. 대신 QueuedWork.add(awaitCommit)로 시스템 큐에 등록된다.

왜 이런 작업을 할까? 앱이 백그라운드로 내려가거나 Activity가 종료될 때, 안드로이드는 QueuedWork.waitToFinish()를 호출해 남은 작업을 기다린다. 이때 awaitCommit이 실행되어 디스크 쓰기가 끝날 때까지 대기한다. 즉, 앱 종료 직전까지도 쓰기 작업이 완료되지 않을 가능성이 있기 때문에, 시스템이 안전 장치를 추가한 것이다.

  1. enqueueDiskWrite() – 디스크 쓰기 예약

이제 핵심이다. 실제로 XML을 파일에 기록하는 작업은 enqueueDiskWrite()를 통해 ExecutorService(스레드 풀)에 제출된다.

private void enqueueDiskWrite(final MemoryCommitResult mcr, final Runnable postWriteRunnable) {
    Runnable writeToDiskRunnable = new Runnable() {
        public void run() {
            writeToFile(mcr, false); // 여기서 XML 생성 및 파일 기록
            if (postWriteRunnable != null) {
                postWriteRunnable.run();
            }
        }
    };
    QueuedWork.queue(writeToDiskRunnable, true);
}

QueuedWork.queue()는 내부적으로 ExecutorService를 사용해 디스크 쓰기를 백그라운드 스레드에서 처리한다. 즉, Disk I/O는 앱 프로세스 내의 ExecutorService 스레드에서 실행된다. 별도의 프로세스나 Binder IPC는 관여하지 않는다.

  1. 왜 디스크 정합성이 깨질까?

apply()는 commitToMemory()를 호출한 뒤 UI 스레드에서 즉시 반환되지만, 실제 디스크 기록 작업은 writeToDiskRunnable로 큐에 등록된다. 그렇다면 두 개 이상의 스레드가 거의 동시에 apply()를 호출하면 어떤 일이 발생할까?

여기서 "거의 동시에"란, 스레드 A가 commitToMemory()를 마치고 XML 스냅샷을 생성한 직후, 스레드 B가 이어서 commitToMemory()를 실행해 또 다른 스냅샷을 만드는 상황을 의미한다. 두 스레드 모두 메모리 갱신은 synchronized로 보호되므로, 최종 메모리 상태에는 두 값(theme_dark, location_enabled)이 모두 반영된다. 하지만 XML 스냅샷은 각 스레드가 commitToMemory()를 실행한 시점의 상태를 기반으로 하므로, 디스크에 기록되는 순서에 따라 결과가 달라진다.

코드 레벨에서 보면, commitToMemory()는 MemoryCommitResult 객체를 생성하고, 이후 writeToFile()에서 이 객체를 직렬화해 XML에 기록한다. 즉:

스레드 A:

MemoryCommitResult mcrA = commitToMemory(); // theme_dark=true 반영
enqueueDiskWrite(mcrA);

스레드 B:

MemoryCommitResult mcrB = commitToMemory(); // location_enabled=true 반영
enqueueDiskWrite(mcrB);

문제는 이 Runnable들이 QueuedWork를 통해 SingleThreadExecutor 큐에 쌓인다는 점이다. 큐는 FIFO로 동작하지만, 실행 타이밍은 다른 태스크나 flush 동작에 따라 예측하기 어렵다. A가 먼저 실행된 후 B가 실행되면 마지막 XML에는 location_enabled만 남고, theme_dark는 덮어씌워진다. 반대로 B가 먼저 실행되면 그 반대다. 결국 메모리는 최신 상태지만, 디스크는 마지막 Runnable의 스냅샷만 반영하는 구조적 한계가 있다.

apply() 실행 플로우는 다음과 같다:

UI Thread (apply 호출)
    ↓
[commitToMemory()]  -- synchronized --
    ↓
MemoryCommitResult 생성
    ↓
enqueueDiskWrite()
    ↓
QueuedWork.queue(writeToDiskRunnable)
    ↓
SingleThreadExecutor
    ↓
[writeToFile(mcr)] ← 실행 순서는 보장되지 않음

핵심 포인트는 각 apply() 호출마다 독립된 Runnable이 큐에 들어가고, 마지막으로 실행된 Runnable의 스냅샷만 디스크에 남는다는 점이다. 이것이 apply()가 완전한 원자성을 보장하지 못하는 이유다.

  1. Partial Write – 더 최악의 시나리오

더 심각한 문제는 Partial Write다. 디스크 쓰기는 단일 연산이 아니다. 내부적으로는 XML 문자열 생성 후 FileOutputStream.write()를 호출하고, 이 작업은 JNI를 거쳐 시스템 콜(write())로 리눅스 커널에 전달된다. 커널은 페이지 캐시를 거쳐 I/O 스케줄러를 통해 NAND 플래시에 기록한다. 이 복잡한 과정 도중 앱이 강제 종료되거나 전원이 꺼진다면?

예를 들어:

<map>
    <boolean name="theme_dark" value="true"/>
    <bool  ← 여기서 중단

이 상태에서 SharedPreferences가 이 파일을 읽으려 하면 XML 파싱이 실패하고 앱은 크래시한다. 실제로 안드로이드 초기 버전에서는 이런 문제가 자주 보고됐다.

안드로이드는 이를 방지하기 위해 백업 파일(.bak)을 사용한다. 새로운 내용을 .bak에 먼저 쓰고, 쓰기가 끝나면 원본으로 교체한다. 하지만 이 방식도 완벽하지 않다. 두 개의 쓰기 작업이 동시에 실행되면, 어떤 .bak 파일이 마지막에 적용될지 알 수 없기 때문이다.

여기서 synchronized가 디스크 쓰기를 보호하지 못하는 이유를 다시 보자. commitToMemory()까지는 모니터 락이 걸려 있어 원자성이 유지되지만 enqueueDiskWrite() 이후는 락 해제 상태다. 이유는 명확하다. Disk I/O는 매우 느리고, UI 스레드에서 락을 유지하면 ANR이 발생하기 때문이다. 따라서 ExecutorService에서 비동기로 처리하는 방식이 선택됐다. 이 방식은 UI 응답성을 확보하지만, 그 대가로 디스크 내용은 마지막으로 실행된 작업의 스냅샷만 반영된다. 즉, 순서가 꼬이면 중간 변경사항은 쉽게 사라질 수 있다.

락 경합

락 경합이 발생하면 어떤 일이 벌어질까? synchronized는 JVM 모니터 락을 사용한다. 여러 스레드가 동시에 apply()를 호출하면 commitToMemory()에서 락 경합이 발생한다. JVM은 우선 스핀 락을 사용한다. 스핀 락은 CAS 기반으로 락을 반복 시도하며 루프를 돈다. 락이 곧 풀릴 거라고 가정하고 바쁘게 대기하는 방식이다. 이 경우 OS 개입이 없으므로 컨텍스트 스위칭 비용은 없다. 하지만 락이 오래 걸리면 CPU를 계속 낭비한다. 스핀으로도 안 풀리면 JVM은 OS 블로킹으로 넘어간다. 이때는 시스템 콜을 통해 스레드를 BLOCKED 상태로 전환하고 CPU에서 내린다. OS는 스케줄러 개입으로 다른 스레드를 실행하고, 나중에 해당 스레드를 READY 상태로 올려 다시 실행시킨다. 이 과정에서 컨텍스트 스위칭 비용이 발생한다. 레지스터를 저장하고 복원하며, CPU 캐시가 무효화되고 파이프라인이 플러시된다.

이를 다이어그램으로 표현하면 다음과 같다.


┌──────────────────────────────────────────────┐
│ 락 경합 상황 (Lock Contention)                  │
├──────────────────────────────────────────────┤
 스레드 A: 락 획득 → 실행 중
 스레드 B: 락 요청 → 실패 → 대기 상태
 스레드 C: 락 요청 → 실패 → 대기 상태
└──────────────────────────────────────────────┘

처리 단계:
1. 경량 락 (스핀 락)
   - CAS 기반 루프 → 락이 곧 풀릴 거라 가정
   - 장점: 짧은 대기에서 빠름
   - 단점: CPU 바쁘게 소비

2. OS 블로킹
   - 스레드를 BLOCKED로 전환 → 컨텍스트 스위칭 발생
   - 비용: 레지스터 저장/복원 + CPU 캐시 플러시

비용 비교:
───────────────────────────────────────
 스핀 락   → 빠름 (짧은 대기) / CPU 낭비
 OS 블로킹 → 느림 / CPU는 절약 / 스위칭 비용 ↑
───────────────────────────────────────

스핀 락은 같은 코어에서 계속 락을 시도하므로 캐시 지역성이 유지된다. 락 관련 데이터가 L1/L2 캐시에 남아 있어 빠르게 접근할 수 있다. 반대로 OS 블로킹은 스레드를 CPU에서 내리고 나중에 다시 올릴 때 캐시가 무효화된다. 심지어 다른 코어에서 실행될 수도 있어, 이전 코어의 캐시에 있던 데이터는 완전히 쓸모없어진다. 결과적으로 RAM에서 데이터를 다시 읽어야 하고, 파이프라인도 리셋된다. 이 오버헤드는 수십에서 수백 나노초 차이가 나므로, 짧은 대기에서는 스핀 락이 유리하다. 그러나 락 대기가 길어지면 CPU를 태워 기다리는 스핀 락은 오히려 성능을 떨어뜨린다. 그래서 JVM은 하이브리드 전략을 쓴다. 우선 스핀 락을 시도하고, 실패하면 OS 블로킹으로 전환하는 방식이다.

apply()는 commitToMemory() 이후 디스크 쓰기를 ExecutorService에서 비동기로 처리하기 때문에, 작은 데이터와 낮은 업데이트 빈도에서는 문제가 드러나지 않는다. 그러나 업데이트가 빈번하거나, 프로세스가 디스크 쓰기 전에 종료되면 데이터 유실 가능성이 생긴다. commit()은 동기적이라 안정적이지만, UI 스레드에서 디스크 I/O를 수행하므로 ANR 위험이 크다. 락 경합이 심하면 스핀 락과 OS 블로킹으로 이어져 컨텍스트 스위칭과 캐시 무효화 비용까지 발생할 수 있다.

이런 이유로 구글은 DataStore를 권장한다. DataStore는 코루틴 기반으로 동작하며, 내부적으로 Mutex와 단일 스레드 디스패처를 사용해 동시성을 제어하고 원자성을 보장한다. 또한 Flow를 통해 UI 반응성을 확보하면서 SharedPreferences의 정합성 문제를 해결한다.

DataStore!

suspend

안드로이드에서 DataStore를 사용할 때 가장 먼저 눈에 띄는 것은 suspend 키워드다. 겉으로 보면 “스레드를 블로킹하지 않고 비동기로 처리한다” 정도로 생각할 수 있지만, 그 내부에서 일어나는 일은 훨씬 복잡하고 정교하다. suspend는 단순한 문법적 장치가 아니라, JVM과 코루틴 런타임에서 코드를 상태 머신으로 변환하고, 다양한 객체들이 협력해 실행을 제어하는 메커니즘이다. 이 과정에는 JVM이라는 프로세스, 메모리 구조(스택과 힙, 메타스페이스), OS 스레드, 그리고 CPU 명령어 수준까지 모든 레이어가 관여한다.

우리가 작성하는 애플리케이션 코드는 독립적인 OS 프로세스로 존재하지 않는다. 앱은 JVM이라는 프로그램에 의해 실행되는 입력이며, OS에서 보면 java 명령으로 실행된 JVM이 하나의 프로세스로 존재한다. 그 내부에서 애플리케이션 로직이 동작한다. JVM은 OS API를 통해 네이티브 스레드를 생성하며, 이 스레드들은 OS 스케줄러에 의해 CPU 코어에 배치된다. 결국 애플리케이션 로직은 JVM 스레드에서 실행되고, 코루틴은 이 JVM 스레드 위에서 논리적인 실행 단위로 동작한다.

JVM 메모리는 크게 세 가지 영역으로 나눌 수 있다. 힙은 객체가 동적으로 할당되는 공간으로, Continuation 인스턴스나 코루틴 상태 정보가 저장된다. 스택은 각 JVM 스레드마다 별도로 존재하며, 메서드 호출 시 스택 프레임이 생성된다. 마지막으로 메타스페이스는 클래스 메타데이터를 관리하는 영역이다. 이 영역은 런타임에 클래스를 로드하고 구조를 유지하기 위해 필요하다. JVM은 클래스를 로드할 때 이 정보를 메타스페이스에 보관하고, 이를 기반으로 객체를 생성하고 코드를 실행한다.

일반 함수는 호출되면 인자를 스택에 푸시하고 새로운 스택 프레임을 생성하며, 실행이 끝나면 프레임을 해제한다. 이 방식은 단순하고 효율적이지만, 실행 중간에 멈출 수 없다. 멈추려면 스레드 자체를 블로킹해야 한다. 스레드 블로킹이란 해당 스레드가 더 이상 작업을 수행하지 못하고 대기 상태로 전환되는 것을 의미한다. 이 상태에서는 스레드가 점유하고 있는 스택 메모리와 리소스가 그대로 유지되므로 메모리 효율성 측면에서 매우 불리하며, 동시에 다른 작업도 수행할 수 없기 때문에 자원 낭비가 발생한다. 이러한 이유로 I/O 대기나 네트워크 요청을 기다리기 위해 스레드를 블로킹하는 방식은 현대 애플리케이션에서 심각한 병목을 만든다.

suspend 함수는 이 문제를 해결한다. 실행 도중 멈췄다가 다시 이어야 하기 때문에 JVM의 스택 기반 모델만으로는 불가능하다. 코틀린 컴파일러는 suspend 함수를 Continuation-Passing Style로 변환한다. 예를 들어 다음 함수를 보자.

override suspend fun updateUserInfo(id: String, isAutoLogin: Boolean, role: UserRoleProto) {
    dataStore.updateData { current ->
        current.toBuilder()
            .setUserId(id)
            .setIsAutoLogin(isAutoLogin)
            .setUserRole(role)
            .build()
    }
}

이 코드는 컴파일 후 다음과 같은 형태로 변환된다.

public final Object updateUserInfo(String id, boolean isAutoLogin, UserRoleProto role, Continuation<? super Unit> completion)

결국 코루틴의 핵심은 Continuation이다. Continuation은 인터페이스이며, 실제 동작은 이를 상속한 ContinuationImpl 클래스에서 수행된다. 이 객체는 상태 머신의 구조를 가지며, 현재 실행 위치를 나타내는 label, 중단 시점의 로컬 변수, 마지막 실행 결과 등을 저장한다. 핵심 메서드는 invokeSuspend로, 코루틴이 중단 후 재개될 때 호출되며, label 값에 따라 실행 지점을 분기한다. 이때 label은 suspend 포인트마다 증가하지만, 각 지점은 일반적으로 suspend 직전과 resume 이후의 흐름을 나눠 처리해야 하므로, 복잡한 경우 label 값은 suspend 지점 수의 두 배 이상이 될 수도 있다.

invokeSuspend는 단순한 switch 문처럼 보이지만, 코루틴의 제어 흐름을 담당하는 실질적인 실행 루틴이다. 코루틴은 launch, async 같은 빌더 함수에 의해 생성되며, 이때 생성된 Continuation의 invokeSuspend가 호출되면서 본격적인 실행이 시작된다. 이 과정에서 Dispatcher는 해당 코루틴을 Runnable로 큐에 등록하고, OS 스케줄러가 스레드를 할당하면 CPU는 invokeSuspend 내부의 코드를 수행한다.

suspend는 단순히 멈춘다는 뜻이 아니라, 멈추는 동시에 다시 이어질 지점을 설정하는 동작이다. 이 과정은 다음 네 단계로 이루어진다. 첫째, 현재 실행 상태와 지역 변수는 Continuation 객체에 저장된다. 둘째, COROUTINE_SUSPENDED라는 특별한 값을 반환하여 호출자에게 이 코루틴이 중단되었음을 알린다. 셋째, 현재 스레드의 스택 프레임을 해제해 스레드를 반납한다. 넷째, 코루틴을 다시 재개시킬 resume 트리거를 등록한다. 이 resume 트리거는 delay, 네트워크 요청, 디스크 I/O, 채널, Flow, Mutex, Job 등 다양한 suspend 함수의 내부에서 정의되며, 언제 코루틴을 다시 깨울지 결정하는 이벤트를 기반으로 작동한다. 이 모든 흐름은 결국 외부 이벤트에 의해 이어지며, 이벤트는 OS나 JVM 내부에서 발생할 수 있다.

예를 들어 소켓을 사용하는 네트워크 요청의 경우, 데이터가 도착하면 OS 커널이 epoll을 통해 이벤트를 발생시키고, JVM의 Selector가 이를 감지해 준비된 채널을 실행한다. Selector는 해당 콜백을 Executor에 전달하고, Executor는 continuation.resumeWith를 호출해 코루틴을 재개한다. 디스크 I/O도 커널이 완료 이벤트를 알려주면 비슷한 방식으로 동작한다. delay는 JVM 타이머 스레드를 이용해 구현되며, 내부적으로는 System.nanoTime() 기반으로 OS의 시계 인터럽트를 활용한다. 예약된 시간이 되면 JVM이 콜백을 실행하고 코루틴을 다시 깨운다.

결국 resume는 DispatchedContinuation을 통해 Dispatcher에 전달되고, Dispatcher는 적절한 스레드에서 invokeSuspend를 다시 호출한다. OS는 스레드를 스케줄링하고, CPU는 해당 스레드의 명령어를 실행한다. suspend는 CPU를 멈추는 것이 아니라 실행 상태를 힙에 저장하고 스레드 점유를 해제해 자원을 효율적으로 사용하는 기술이다.

결국 코루틴은 스레드가 아니며, 스레드 위에서 동작하는 경량 실행 단위다. suspend는 스레드의 실행을 강제로 멈추는 것이 아니라, 실행 상태를 Continuation에 저장하고 힙에 옮김으로써 스레드 점유를 해제한다. 이렇게 하면 스레드는 즉시 반환되어 다른 작업을 수행할 수 있고, CPU와 메모리 사용 효율이 극대화된다. 재개는 외부 이벤트를 통해 트리거되며, OS 커널에서 발생하는 네트워크 이벤트나 I/O 완료 신호, JVM 타이머 스레드의 콜백 등이 그 역할을 한다. 이 구조 덕분에 개발자는 복잡한 스레드 제어나 시스템 콜을 직접 다루지 않고도, 단순히 suspend 키워드로 논리적 흐름을 표현할 수 있다. 코루틴의 철학은 명확하다. 스레드를 직접 관리하는 부담에서 벗어나, suspend로 선언한 비동기 작업을 안전하고 효율적으로 이어가도록 하고, Dispatcher가 운영하는 스레드 풀 위에서 블로킹 없이 최적의 성능을 제공하는 것이다.

Coroutine

코루틴은 단순히 suspend 함수로 끝나지 않는다. 어디서, 어떤 스레드에서 실행될지는 CoroutineScope와 Dispatcher가 결정한다. DataStore를 사용할 때 대부분의 호출은 viewModelScope.launch { ... } 같은 코드에서 시작된다. 이때 생성되는 코루틴은 특정한 컨텍스트를 가진다. 그 컨텍스트에는 다음 세 가지 핵심 요소가 들어 있다.

첫 번째는 Job이다. Job은 코루틴의 생명주기를 관리하는 컨트롤러 역할을 한다. 모든 코루틴은 하나의 Job을 갖고, 이 Job은 부모-자식 관계를 형성한다. 덕분에 구조적 동시성(Structured Concurrency)이 가능해진다. 예를 들어 ViewModel이 사라지면, 그 안에서 실행 중이던 모든 코루틴이 한 번에 취소된다. Job의 내부는 비교적 간단하지만, 매우 중요한 개념이다. 상태는 Active → Cancelling → Cancelled로 흐르며, 이를 통해 취소가 안전하게 전파된다.

두 번째는 CoroutineScope다. CoroutineScope는 코루틴이 실행될 “환경”을 정의한다. 스코프는 Job과 Dispatcher를 묶어 놓은 컨테이너라고 생각하면 된다. 우리가 viewModelScope.launch를 호출하면, 내부적으로 이 스코프에 연결된 Job과 Dispatcher가 함께 전달된다.

세 번째는 Dispatcher다. 이게 가장 중요한 요소다. Dispatcher는 코루틴이 실행될 스레드를 결정한다. 안드로이드에서는 크게 세 가지 Dispatcher가 자주 쓰인다. Dispatchers.Main은 UI 스레드에서 동작하며, Compose나 LiveData가 여기서 돌아간다. Dispatchers.Default는 CPU 연산에 최적화된 스레드 풀을, Dispatchers.IO는 디스크 I/O나 네트워크 같은 블로킹 연산에 최적화된 스레드 풀을 사용한다. DataStore는 기본적으로 Dispatchers.IO를 이용한다. 이유는 명확하다. 데이터 읽기와 쓰기 모두 디스크 접근을 동반하기 때문이다.

그렇다면 Dispatchers.IO는 내부적으로 어떻게 구현되어 있을까? 많은 사람들이 단순히 “스레드 풀”이라고 생각하지만, 실제로는 훨씬 정교한 구조를 가진다. Kotlin의 코루틴 디스패처는 JVM의 ForkJoinPool 기반으로 동작하며, 여기서 사용되는 핵심 알고리즘이 바로 Work-Stealing이다.

Work-Stealing은 현대 멀티코어 CPU에서 가장 많이 쓰이는 스케줄링 기법 중 하나다. 기본 아이디어는 간단하다. 각 스레드는 자신만의 작업 큐를 가지고 있고, 자신의 큐가 비면 다른 스레드의 큐에서 작업을 “훔친다.” 이렇게 하면 특정 스레드에 일이 몰려 있는 상황을 줄이고, 전체 CPU 코어를 최대한 활용할 수 있다. Kotlin은 이 전략을 적극적으로 활용한다. 예를 들어 코어가 8개인 기기라면, 스레드 풀은 8개의 워커 스레드를 띄우고, 각 스레드는 자신만의 Deque(양방향 큐)를 관리한다. 코루틴이 launch되면, 기본적으로 현재 스레드의 Deque에 푸시된다. 하지만 다른 스레드가 한가하면, Deque의 끝에서 작업을 훔쳐 와서 실행한다.

이 방식은 단순히 “병렬성을 높인다”는 차원을 넘어, CPU 캐시 친화적이다. 스레드가 자기 큐에서 작업을 처리하면, 이전 작업과 같은 코어에서 실행되기 때문에 L1/L2 캐시에 이미 올라온 데이터가 재활용된다. 반면 Fixed Thread Pool은 중앙 집중형 큐를 사용하기 때문에, 캐시 효율이 떨어지고 잠금 경합(lock contention)이 심해진다.

Dispatchers.IO는 여기에 한 가지 특징을 더한다. 디스크 I/O는 CPU 연산과 달리 대부분 시간이 커널에서 블로킹 대기 상태로 소모된다. 따라서 Kotlin은 IO 디스패처에서 스레드의 수를 기본적으로 64개까지 늘려 준다. 이렇게 하면 한 스레드가 I/O 대기 중이어도 다른 스레드가 CPU 작업을 진행할 수 있다. 이 구조는 DataStore가 안정적으로 동작하는 기반이 된다. updateData는 결국 디스크에 Protobuf로 직렬화된 데이터를 쓰는 작업이므로, 이 작업은 ForkJoinPool 기반 IO 디스패처의 워커 스레드에서 실행된다.

하지만 여기서 끝이 아니다. 스레드가 결정되면, 이제 JVM 레벨에서 OS로 넘어가는 과정이 시작된다. 그리고 그 순간, Linux 커널의 스케줄러가 본격적으로 개입한다. 다음 단계에서는 코루틴을 담고 있는 스레드가 OS에서 어떻게 관리되는지, 그리고 왜 Linux의 CFS(Completely Fair Scheduler)가 DataStore와 잘 맞는지, 커널 코드와 함께 살펴보자.

코루틴은 스레드가 아니다

코루틴은 스레드가 아니다. 그러나 코루틴은 결국 스레드 위에서 동작한다. updateData가 Dispatchers.IO에서 실행되면, 이 코루틴은 JVM의 워커 스레드 중 하나에서 수행된다. 여기서 중요한 점은, JVM 스레드 자체는 OS가 관리하는 커널 스레드(Native Thread)라는 것이다. 즉, 코루틴의 실행 여부는 JVM 디스패처가 결정하지만, 스레드가 실제 어느 CPU 코어에서 돌아갈지는 커널의 스케줄러가 담당한다.

안드로이드는 리눅스 커널을 기반으로 한다. 리눅스의 기본 스케줄러는 CFS(Completely Fair Scheduler)다. 이름에서 알 수 있듯, 핵심 목표는 “공정성”이다. 공정성이라는 말은 단순히 순서를 지킨다는 뜻이 아니라, 각 태스크가 CPU 시간을 공평하게 할당받도록 보장한다는 의미다. CFS는 이를 위해 vruntime(Virtual Runtime)이라는 개념을 사용한다.

각 스레드에는 vruntime이라는 값이 있다. 이 값은 스레드가 CPU를 점유한 시간에 따라 증가한다. 중요한 점은, 모든 스레드가 동일하게 증가하는 것이 아니라 우선순위에 따라 가중치가 적용된다. 우선순위가 높은 스레드는 vruntime이 천천히 증가하므로 더 오래 CPU를 사용할 수 있고, 우선순위가 낮으면 빠르게 증가해 CPU에서 밀려난다. 이렇게 계산된 vruntime 값은 커널 내부에서 레드-블랙 트리(RB-tree) 구조로 관리된다. 루트에는 vruntime이 가장 작은 스레드가 오고, 커널은 항상 이 스레드에게 CPU를 할당한다. 간단히 말해, 가장 오래 CPU를 못 쓴 스레드가 다음으로 실행된다는 방식이다.

실제 커널 코드 일부를 보면 다음과 같은 흐름으로 동작한다.

(kernel/sched/fair.c).

static void enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se) {
    update_curr(cfs_rq);
    se->vruntime += calc_delta_fair(...);
    rb_insert(&se->run_node, &cfs_rq->tasks_timeline);
}

여기서 calc_delta_fair는 실행 시간에 우선순위 가중치를 적용해 vruntime을 계산하고, 이 값을 기준으로 스레드를 트리에 삽입한다. 그 결과, 스케줄러는 가장 공정한 방식으로 CPU 시간을 분배한다.

이 구조는 DataStore와 같은 비동기 아키텍처와 매우 잘 맞는다. 이유는 명확하다. 코루틴이 실행되는 스레드는 대부분 짧은 작업을 수행하고 suspend 상태로 돌아간다. 즉, CPU를 길게 점유하지 않는다. 이런 태스크는 CFS에서 자연스럽게 vruntime이 낮게 유지되기 때문에, 다시 실행될 때 우선권을 얻는다. 반대로, 긴 작업을 하는 스레드는 vruntime이 빠르게 증가해 잠시 밀려난다. 이 결과, DataStore는 수많은 코루틴을 짧게 실행하고 빠르게 양보하는 방식으로 스케줄링 효율을 극대화한다.

이처럼 CFS 스케줄러는 각 스레드의 vruntime 값을 기반으로 CPU 시간을 공정하게 분배하고, 같은 스레드가 동일 코어에 재스케줄되는 경향을 보인다. JVM의 Work-Stealing 기반 스레드 풀과 결합되면, 동일한 워커 스레드에서 코루틴이 반복적으로 실행되는 구조가 만들어지며, 이는 CPU 캐시 효율성 향상으로 이어진다. 특히 CPU의 L1/L2 캐시는 코어별로 존재하고, 코어 간 공유되는 L3 캐시보다 접근 속도가 빠르기 때문에, 동일 코어에서 작업을 재실행할수록 성능 상 이점을 얻게 된다.

그런데 이러한 CPU 캐시 활용만으로는 충분하지 않다. DataStore는 결국 .pb 파일에 데이터를 직렬화하여 저장하는 구조이기 때문에, 디스크 I/O가 반드시 개입하게 된다. 이 시점에서 JVM은 FileOutputStream이나 FileChannel을 통해 파일 쓰기를 요청하고, 내부적으로는 write() 시스템 콜을 호출하며 커널 모드로 전환된다.

안드로이드는 이 과정에서 Linux 커널의 VFS(Virtual File System)를 통해 파일 시스템 접근을 추상화하고, 페이지 캐시를 적극적으로 활용해 성능을 높인다. 페이지 캐시는 디스크의 실제 블록 데이터를 메모리에 유지함으로써, 동일한 파일에 반복 접근할 때 실제 디스크 I/O를 생략할 수 있게 해준다. 결국 코루틴의 효율적인 스케줄링은 CPU 캐시에서, DataStore의 빠른 I/O는 페이지 캐시에서 각각 최적화되며, 이 두 구조는 시스템 자원을 효율적으로 사용하는 핵심 메커니즘으로 동작하게 된다.

디스크에 기록하기

updateData는 결국 디스크에 새로운 데이터를 기록한다. 이 과정에서 중요한 점은 DataStore가 결코 UI 스레드를 블로킹하지 않는다는 것이다. 그 이유는 단순히 suspend 때문이 아니라, 내부적으로 Dispatcher.IO + 비동기 시스템 콜 + 페이지 캐시 전략이 유기적으로 작동하기 때문이다.

먼저, 코드에서 실행되는 부분을 다시 보자.

dataStore.updateData { current ->
    current.toBuilder()
        .setUserId(id)
        .setIsAutoLogin(isAutoLogin)
        .setUserRole(role)
        .build()
}

이 블록 내부에서 새로운 UserPreferences 객체가 생성된다. 여기서 중요한 것은 DataStore가 데이터를 직렬화하는 방식이다. SharedPreferences는 XML 기반으로 동작하지만, DataStore는 Protobuf 직렬화를 사용한다. Protobuf는 데이터를 Compact한 바이너리 형태로 변환하므로, XML보다 훨씬 빠르고 공간 효율적이다. 이 변환 과정에서 CPU는 메모리를 순회하며 직렬화 작업을 수행한다. 직렬화가 끝나면, 이 데이터는 OutputStream을 통해 파일에 기록된다. 실제로는 다음 코드가 내부에서 호출된다.

override suspend fun writeTo(t: UserPreferences, output: OutputStream) {
    t.writeTo(output)
}

여기서 t.writeTo(output)는 Protobuf의 핵심 메서드다. 내부적으로는 CodedOutputStream을 사용해 데이터를 바이트 배열로 변환한 뒤, 버퍼 단위로 OutputStream에 쓴다. 이 시점에서 JVM은 아직 사용자 모드(User Mode)에 있다. 즉, 우리가 보는 모든 Kotlin 코드는 유저 공간에서 실행되는 로직이다. 하지만 디스크에 데이터를 기록하려면 커널 모드로 넘어가야 한다. 이때 등장하는 것이 시스템 콜(System Call)이다.

JVM의 FileOutputStream.write()는 결국 네이티브 메서드 writeBytes()를 호출한다. 이 메서드는 JNI(Java Native Interface)를 통해 리눅스의 write() 시스템 콜로 매핑된다. write()는 다음과 같은 시그니처를 가진다.

ssize_t write(int fd, const void *buf, size_t count);

이 호출이 발생하면, CPU는 유저 모드에서 커널 모드로 전환한다. 이 과정은 트랩(Trap)이라는 메커니즘으로 이루어진다. 트랩이 발생하면 CPU는 현재 레지스터 상태를 저장하고, 커널의 시스템 콜 핸들러로 점프한다. 이때 문맥 전환(Context Switch)이 발생하며, 사용자 스택과 커널 스택이 바뀐다.

커널 모드로 진입한 후, 리눅스는 VFS(Virtual File System)를 통해 파일을 처리한다. VFS는 ext4, f2fs 등 다양한 파일 시스템을 추상화한 계층이며, DataStore의 파일은 안드로이드 내부 저장소(/data/data//files/datastore/)에 위치하고 보통 이들 파일 시스템 위에 존재한다. 이때 커널은 write() 시스템 콜이 호출되더라도 즉시 디스크에 기록하지 않고, 데이터를 먼저 페이지 캐시(Page Cache)에 저장한다. 페이지 캐시는 디스크 블록을 메모리에 매핑한 버퍼로, 디스크 접근을 최소화해 성능을 높이는 핵심 전략이다. 따라서 시스템 콜은 빠르게 반환되고, suspend된 코루틴은 resume되어 다음 작업을 이어갈 수 있다.

하지만 단순히 커널이 페이지 캐시에 데이터를 복사했다고 해서, 그것만으로 파일 쓰기의 안정성이 보장되지는 않는다. 만약 시스템이 이 시점에서 크래시된다면, 캐시에만 존재하고 디스크에는 아직 쓰이지 않은 데이터는 유실될 수 있기 때문이다. 이 문제를 해결하기 위해, DataStore는 내부적으로 데이터를 보다 안전한 방식으로 기록한다. 먼저 새로운 데이터를 임시 파일에 기록하고, 이 파일에 대해 flush를 호출하여 디스크에 강제로 기록되었음을 보장한 뒤, rename() 시스템 콜을 통해 기존 파일을 새 파일로 원자적으로 교체하는 방식이다.

이 과정은 다음과 같은 코드 흐름으로 이해할 수 있다:

tempFile.writeBytes(data)                    // 1. 임시 파일에 데이터 기록
FileOutputStream(tempFile).fd.sync()        // 2. flush: 디스크에 강제 기록
tempFile.renameTo(realFile)                 // 3. rename: 기존 파일과 원자적으로 교체

rename은 POSIX 표준에서 원자적 연산을 보장하기 때문에, rename 이전까지 크래시가 나면 기존 파일은 그대로 유지되고, rename 이후라면 새 파일로 완전히 교체된 상태이므로 일관성이 유지된다. 즉, DataStore는 외부적으로는 suspend 함수로 인해 비동기처럼 보이지만, 내부적으로는 flush와 rename을 통해 실제 디스크에 안전하게 데이터를 기록한 후에야 처리를 종료한다. 이는 데이터 무결성과 복원력을 동시에 만족시키는 방식이며, 단순한 성능 최적화를 넘어선 설계상의 필수 선택이라 할 수 있다.

이 모든 과정이 코루틴에서 자연스럽게 연결되는 이유는 suspend 함수와 Dispatcher.IO의 역할 때문이다. write() 시스템 콜은 블로킹 연산이다. 그러나 코루틴은 Dispatchers.IO에서 실행되고, suspend는 내부적으로 스레드를 블로킹하지 않고 다른 코루틴을 실행할 수 있도록 상태를 Continuation에 저장한다. 다시 말해, 스레드는 I/O 대기 중에 놀지 않는다. 대신 다른 코루틴의 작업을 처리한다. 이 메커니즘이 DataStore의 성능을 결정짓는 핵심이다.

여기까지가 JVM에서 시작해 커널을 거쳐 디스크에 데이터를 기록하는 전체 경로다. 이제 남은 것은 데이터 읽기 과정이다. 읽기는 Flow를 통해 이뤄지며, 여기서 Flow의 Cold Stream 구조, collect 호출 시점의 실행 메커니즘, 그리고 Compose의 collectAsState()가 어떻게 UI를 Reactive하게 갱신하는지 살펴볼 차례다.

읽기 연산

DataStore의 읽기 연산에서 핵심은 data 프로퍼티다. data는 Flow를 반환하며, 사용자는 이를 collect하거나, Jetpack Compose에서 collectAsState()를 통해 UI 상태로 변환한다. 예를 들어, 다음 코드를 보자.

override val userId: Flow<String?> = dataStore.data.map { prefs ->
    prefs.userId.ifBlank { null }
}

겉으로는 단순한 Flow 변환처럼 보이지만, 내부에서는 상당히 복잡한 흐름이 있다. Flow는 기본적으로 Cold Stream이다. 즉, collect가 호출되기 전까지 아무런 작업도 하지 않는다. 이 설계는 매우 중요한데, 불필요한 연산을 피하고, 구독이 발생하는 시점에만 실제 데이터 처리를 시작할 수 있도록 한다.

Flow의 핵심 인터페이스는 다음과 같다.

interface Flow<T> {
    suspend fun collect(collector: FlowCollector<T>)
}

이제 실제 동작을 살펴보자. dataStore.data는 내부적으로 DataStoreImpl에서 다음과 같이 정의되어 있다.

override val data: Flow<T> = readData()

readData()는 데이터 파일을 읽고, 변경 사항이 발생할 때마다 emit하는 Flow를 생성한다. 내부적으로 callbackFlow나 channelFlow를 사용하는 구조인데, 이건 사실상 코루틴 채널(Channel)을 기반으로 한 구현이다. Flow가 emit할 때는 collector.emit(value)를 호출한다. 이 메서드는 suspend로 정의되어 있어, 백프레셔(Backpressure)와 안전한 동시성 제어가 가능하다.

그렇다면 collect는 어떻게 동작할까? collect가 호출되는 순간, Flow는 새로운 코루틴을 시작한다. Dispatcher는 기본적으로 Dispatchers.Main.immediate가 사용되며, Compose에서는 이 코루틴이 UI 스레드에서 실행된다. 이 코루틴은 내부에서 emit되는 모든 값을 순차적으로 받아 처리한다.

여기서 Jetpack Compose의 역할이 등장한다. Compose는 Flow를 State로 변환하기 위해 collectAsState()를 제공한다. 이 함수는 내부적으로 launchIn()과 rememberCoroutineScope()를 이용해 Flow를 구독하고, 값이 emit될 때마다 mutableStateOf()를 업데이트한다. 이 mutableState는 Compose 런타임에 의해 추적되며, 값이 바뀌면 자동으로 recomposition이 발생한다. 즉, DataStore에서 값이 바뀌는 순간 Flow가 emit → collectAsState가 State 변경 → Compose가 UI를 다시 그림으로 이어지는 것이다.

내부 코드를 조금 더 살펴보자. collectAsState()는 결국 다음과 같은 흐름을 가진다.

@Composable
fun <T> Flow<T>.collectAsState(initial: T): State<T> {
    val state = remember { mutableStateOf(initial) }
    LaunchedEffect(this) {
        collect { value -> state.value = value }
    }
    return state
}

LaunchedEffect는 Compose에서 코루틴을 시작하는 방법이다. 여기서 collect는 suspend 함수이므로, 코루틴은 Flow의 emit을 계속 대기한다. 값이 도착하면 state.value = value로 상태를 갱신한다. 이 한 줄이 UI 전체를 다시 그리는 트리거가 된다.

이 설계는 Reactive UI의 핵심을 보여준다. UI는 Flow에 의존하지 않고, 단지 State에 의존한다. Flow는 State를 갱신하고, Compose는 State가 바뀌면 UI를 다시 그린다. 이 방식 덕분에 UI는 데이터 변경에 완벽하게 반응하며, 개발자는 복잡한 옵저버 관리 로직을 구현할 필요가 없다.

Flow의 또 다른 강점은 suspend 기반의 backpressure 처리다. 예를 들어, DataStore에서 데이터가 빠르게 emit되더라도, collect가 완료될 때까지 emit은 일시 중단된다. 이는 전통적인 Observer 패턴에서 흔히 발생하는 “폭주 문제”를 근본적으로 해결한다.

여기까지가 Flow의 emit → collect → Compose recomposition까지의 핵심 경로다. 이제 다음 단계에서는 DataStore가 왜 Mutex.withLock과 Actor 패턴을 사용해 동시성을 제어하는지, 그리고 이 메커니즘이 어떻게 멀티코어 환경에서 락 경합을 줄이는지 분석한다. 특히, Mutex 내부 구현과 Actor = Channel + 단일 Consumer 구조를 코틀린 소스 레벨에서 보여줄 것이다. Observer 패턴과의 차이도 정리한다.

데이터 정합성에 대한 이야기

DataStore가 직면하는 가장 큰 문제 중 하나는 여러 코루틴이 동시에 updateData를 호출할 때 데이터 정합성을 어떻게 보장할 것인가다. SharedPreferences는 이 문제를 해결하기 위해 synchronized를 사용했다. 그러나 이 방식은 스레드 단위의 블로킹을 발생시킨다. 예를 들어 메인 스레드에서 commit()을 호출하면, 해당 스레드가 디스크 I/O를 끝낼 때까지 완전히 멈춘다. 이로 인해 UI가 프리징되는 문제가 빈번하게 발생했다.

DataStore는 이런 문제를 해결하기 위해 Coroutine 친화적 락인 Mutex를 사용한다. Mutex는 kotlinx.coroutines.sync 패키지에 있으며, 내부적으로는 스레드 블로킹이 아닌 suspend 기반의 대기를 제공한다. 즉, 락을 얻을 수 없으면 스레드를 멈추는 대신, 현재 코루틴을 중단하고 다른 작업으로 스케줄링한다. 이게 얼마나 중요한 차이인지 코드를 보자.

private val lock = Mutex()

suspend fun updateData(transform: (T) -> T): T {
    return lock.withLock {
        val newData = transform(currentData)
        writeToDisk(newData)
        newData
    }
}

withLock의 내부 구현을 살펴보면, 다음과 같이 suspend를 적극 활용하고 있다.

public suspend inline fun <T> Mutex.withLock(action: () -> T): T {
    lock()    // suspend until lock is available
    try {
        return action()
    } finally {
        unlock()
    }
}

여기서 lock()은 suspend 함수다. 즉, 락을 기다리는 동안 스레드를 점유하지 않는다. 기존의 synchronized가 스레드를 묶어 두는 방식과 정반대다. 이 덕분에 DataStore는 메인 스레드에서 호출해도 UI 프리징이 발생하지 않는다.

하지만 DataStore는 여기서 한 발 더 나아간다. 단순히 Mutex로 직렬화하는 대신, 내부적으로 Actor 패턴을 사용한다. Actor는 메시지 기반의 단일 소비자 모델이다. DataStore는 update 요청을 채널(Channel)에 넣고, 단일 코루틴이 이 채널을 소비하면서 요청을 순차적으로 처리한다. 이 방식은 멀티코어 환경에서 락 경합을 크게 줄인다. 이유는 명확하다. 전통적인 락 기반 모델은 여러 스레드가 동일한 락을 쟁탈하기 때문에 경쟁이 발생하고, 캐시 라인이 계속 invalidation되는 비용이 발생한다. 반면 Actor는 경쟁 자체를 없앤다. 요청은 큐에 순서대로 쌓이고, 단일 코루틴이 이를 하나씩 처리한다. CPU 레벨에서 보면, 락이 아니라 메시지 패싱으로 전환한 것이다.

이제 Actor 패턴과 Observer 패턴을 비교해 보자. Observer는 하나의 이벤트를 여러 소비자가 동시에 구독하는 모델이다. 반면 Actor는 여러 이벤트를 하나의 소비자가 직렬로 처리한다. Observer는 병렬성을 높이지만, 동시성 문제를 해결하기 어렵다. 반면 Actor는 병렬성은 낮지만, 데이터 정합성 보장이 쉽다. DataStore는 안정성이 최우선이므로 Actor를 선택했다.

실제로 DataStore의 내부 소스(SingleProcessDataStore.kt)를 보면 이런 패턴이 확인된다.

private val updateChannel = Channel<UpdateMessage>(Channel.UNLIMITED)

init {
    scope.launch {
        for (msg in updateChannel) {
            handleUpdate(msg)
        }
    }
}

모든 update 요청은 updateChannel.send()를 통해 전달되고, 단일 코루틴이 for (msg in updateChannel) 루프에서 이를 처리한다. 이게 바로 DataStore가 구현한 Actor다.

멀티코어 환경에서 이 방식은 특히 유리하다. 락 기반 구조에서는 스레드들이 동일한 락을 얻기 위해 스핀락을 돌거나 커널로 진입하는 비용이 발생한다. 이때 CPU 캐시 라인은 코어 간 동기화로 인해 계속 invalidation된다. Actor는 이런 비용을 없애고, 단일 처리 모델로 일관성을 보장한다. 물론 대기열이 길어지면 처리 지연이 생길 수 있지만, DataStore의 typical workload(작은 빈도, 빠른 I/O)에서는 오히려 효율적이다.

이제 남은 건 두 가지다. 첫째, 왜 DataStore는 Binder IPC를 사용하지 않는지. 둘째, UI 레벨에서 Flow가 emit될 때 Compose가 어떻게 VSYNC 루프와 동기화되는지다. 이 두 가지를 분석하면 DataStore의 전체 그림이 완성된다.

앱 프로세스와 데이터 스토어

DataStore는 완전히 앱 프로세스 내부에서 동작한다. SharedPreferences가 기본적으로 같은 프로세스에서만 안전하게 사용되었던 것처럼, DataStore 역시 IPC를 하지 않는다. 왜 Binder를 사용하지 않을까?

Binder IPC는 강력하지만, 비용이 크다. Binder는 커널을 통해 두 프로세스 간 메모리를 공유하는 메커니즘인데, 데이터가 오갈 때는 다음과 같은 단계가 필요하다.

호출 프로세스 → 커널 모드 진입 (시스템 콜)

커널에서 Binder 드라이버를 통해 메모리 매핑

상대 프로세스의 Binder 스레드에서 처리 후 다시 커널로 반환

호출자에게 돌아옴

이 과정은 두 번의 컨텍스트 스위칭과 커널 모드 전환을 포함한다. DataStore는 앱 로컬 설정을 다루기 때문에 굳이 이런 비용을 감수할 이유가 없다. 오히려 내부에서 Actor 패턴 + Mutex로 동시성을 관리하는 편이 훨씬 빠르고 단순하다. Binder는 ContentProvider나 System Service처럼 프로세스 간 공유가 필요한 경우에만 쓰인다.

이제 DataStore가 Flow로 emit한 값이 Compose에서 어떻게 UI를 다시 그리는지 살펴보자. 여기서 중요한 건 안드로이드 렌더링 파이프라인이다. Compose는 Flow를 collectAsState()로 구독한다. 값이 emit되면 mutableStateOf()가 업데이트되고, Compose 런타임은 Recomposition을 스케줄링한다. 이 스케줄링은 즉시 화면을 그리는 것이 아니라, 다음 VSYNC 이벤트에 맞춰 그리기 작업을 예약하는 방식으로 진행된다.

안드로이드의 렌더링 루프는 기본적으로 16.6ms 주기의 VSYNC 시그널을 기준으로 돌아간다. 이 시그널은 SurfaceFlinger가 하드웨어 디스플레이와 동기화하기 위해 발생시키는 것이다. 앱 프로세스는 Choreographer를 통해 이 시그널을 받는다. Compose도 Choreographer를 활용한다. 상태가 바뀌면 Compose는 Choreographer.postFrameCallback()을 호출해 다음 프레임에서 Recomposition을 실행하도록 예약한다.

실제로 Compose 런타임 코드를 보면 이런 흐름이 확인된다.

Choreographer.getInstance().postFrameCallback {
    recomposer.runRecomposeAndApplyChanges()
}

이 시점에서 UI 스레드는 새로운 상태를 반영해 레이아웃을 계산하고, 새로운 UI 트리를 생성한다. 그리고 RenderThread로 전달해 OpenGL이나 Vulkan을 통해 화면을 다시 그린다. 모든 작업은 VSYNC에 맞춰 실행되므로, 화면은 부드럽게 업데이트된다.

이 구조에서 DataStore가 Flow로 데이터를 emit하는 순간부터 화면이 바뀌기까지의 전체 경로를 다시 정리하면 이렇다.

DataStore 내부에서 updateData()가 실행 → Protobuf 직렬화 → 파일 저장 → Flow emit

Flow는 suspend 기반으로 Collector에 값을 전달

collectAsState()가 State를 갱신

Compose 런타임이 Recomposition을 스케줄링

Choreographer가 다음 VSYNC에서 FrameCallback 실행

Compose가 새로운 UI 트리 생성 → RenderThread가 Surface에 렌더링

이 설계는 Reactive UI의 이상적인 구조를 구현한다. 데이터가 변경되면 UI는 즉시가 아니라 최적의 시점(VSYNC)에 업데이트되며, 모든 과정이 메인 스레드 블로킹 없이 이뤄진다.

이제 마지막으로, 전체 분석을 요약하면 DataStore는 단순한 데이터 저장소가 아니라 OS 스케줄러, JVM, Coroutine, Flow, UI 렌더링 파이프라인까지 아우르는 고도화된 비동기 아키텍처다. updateData가 호출된 순간부터 화면이 다시 그려지는 순간까지, 코루틴은 상태 머신으로 suspend/resume되고, JVM은 ForkJoinPool 기반의 Work-Stealing 스레드 풀에서 코루틴을 실행하며, 커널은 CFS로 스레드를 공정하게 스케줄링한다. 디스크 I/O는 페이지 캐시와 시스템 콜을 통해 최적화되고, Flow는 Reactive 스트림을 구현해 Compose UI를 자연스럽게 연결한다. Mutex와 Actor는 동시성을 안전하게 관리하며, 이 모든 과정이 UI 프레임 드랍 없이 이뤄진다.

So

지금까지 비동기라는 키워드를 중심으로 DataStore의 구조를 살펴보았다. 누군가는 이런 분석이 다소 쓸데없다고 생각할 수도 있다. 실제로 DataStore를 사용하는 데에는 복잡한 이해가 필수는 아니고, 그냥 가져다 쓰기만 해도 잘 동작한다. 하지만 라이브러리를 뜯어보는 과정에서 비동기 시스템의 원리, 그리고 특정 맥락에서 코루틴과 플로우가 어떻게 협력하는지를 더 깊이 이해할 수 있었다. 그런 의미에서 이번 글은 단순히 DataStore 소개가 아니라, 비동기를 다루는 사고 방식을 확장하는 계기였다. 다음 편에서는 Room 라이브러리를 다루며, 오늘 다루지 못한 비동기의 다른 측면들을 이어서 살펴볼 예정이다. 이를 통해 비동기에 대한 이해를 한층 넓히고, 관련 주제를 계속 확장해 나갈 생각이다. 오늘은 여기까지다.

profile
안녕하세요. 날씨가 참 덥네요.

0개의 댓글