Android 앱은 운영체제가 관리하는 컴포넌트, 화면을 구성하는 UI 계층, 상태를 관리하는 계층, 데이터를 보존하는 계층으로 나뉜다.
이 개념들은 서로 연결되어 있지만 생명주기와 책임이 다르다.
Android OS
└── Application Process
├── Activity / Service / BroadcastReceiver
└── UI
└── Composable
└── ViewModel
└── Repository
├── Room
├── DataStore
└── Server
Android 애플리케이션과 운영체제의 경계
Android 앱은 하나의 main() 함수에서 시작해 내부 코드만 실행하는 일반적인 프로그램과 다르다. Activity, Service 같은 컴포넌트를 Android 운영체제가 필요할 때 생성하고 호출한다.
운영체제가 앱의 구성요소를 알 수 있도록 AndroidManifest.xml에 다음과 같은 정보를 선언한다.
- 앱의 시작 Activity
- Service와 BroadcastReceiver
- 필요한 권한
- 처리할 수 있는 Intent와 딥링크
앱 프로세스가 생성되면 Application 객체가 만들어진다. Application은 앱 프로세스 전체 범위의 객체지만 영구 저장소는 아니다. 프로세스가 종료되면 Application과 그 안의 값도 모두 사라진다.
Activity와 Window
Activity는 흔히 화면 하나로 설명되지만, 정확히는 Android가 생명주기를 관리하는 UI 실행 단위다.
Activity는 자신에게 주어진 Window에 UI를 배치한다. 앱은 Window의 내용과 속성을 설정하고, 운영체제의 WindowManager는 화면 배치, 포커스, 다른 창과의 순서를 관리한다. Theme은 Window와 UI에 적용되는 기본 디자인 규칙을 제공한다.
Activity
└── Window
└── 앱의 UI
Activity가 생성됐다고 바로 사용자에게 보이는 것은 아니다. 화면을 준비 중일 수도 있고, 다른 Activity에 가려졌거나 백그라운드로 이동했을 수도 있다. 이런 상태를 표현하기 위해 onCreate, onStart, onResume, onPause, onStop 같은 생명주기가 존재한다.
화면 회전으로 Activity가 재생성되는 것도 이 구조에서 이해할 수 있다. 회전은 화면 방향과 크기 같은 기기 설정이 변경된 사건이다. Android는 변경된 설정에 맞는 리소스와 UI를 적용하기 위해 기존 Activity를 제거하고 새 Activity를 만든다.
Context와 Intent
Context는 앱 코드가 Android 환경에 접근하기 위한 인터페이스다. 리소스를 읽거나, 시스템 서비스를 가져오거나, 다른 컴포넌트를 실행할 때 사용한다. Context 자체가 시스템 함수나 권한인 것은 아니다.
Intent는 Android에 전달하는 실행 요청 또는 메시지다.
특정 Activity 실행
다른 앱에서 URL 열기
Service 시작
외부 링크를 앱의 특정 화면으로 연결
예를 들어 poby://posts/42 같은 링크를 앱의 게시글 화면으로 연결하는 것이 딥링크다. Intent가 Android 시스템을 통해 앱 안팎의 컴포넌트를 연결한다면, Compose Navigation은 주로 현재 앱 프로세스 내부에서 Composable 화면을 전환한다.
Service는 화면 없이 수행되어야 하는 작업을 표현하는 컴포넌트다. 다만 Service가 별도 프로세스나 별도 스레드를 의미하지는 않는다. 기본적으로 Service의 콜백도 Main Thread에서 실행되므로 무거운 작업은 Coroutine이나 작업 스레드로 보내야 한다.
Main Thread는 가장 무거운 작업을 처리하는 스레드가 아니라 사용자 입력, 생명주기, 화면 그리기를 빠르게 조율하는 스레드다. 이 스레드를 오래 점유하면 입력과 렌더링이 멈춘다.
BroadcastReceiver는 소켓처럼 계속 연결되는 통신 수단이 아니다. 시스템이나 앱에서 특정 이벤트가 발생했을 때 잠깐 실행되는 이벤트 수신기다.
UI 아키텍처: View 시스템에서 Compose로
기존 Android UI는 XML과 View 객체를 기반으로 구성됐다.
<LinearLayout>
<TextView />
<Button />
</LinearLayout>
XML은 화면 객체가 아니라 객체를 생성하기 위한 선언이다. Android는 XML을 읽고 실제 LinearLayout, TextView, Button 객체를 메모리에 생성한다. 이 과정이 inflation이다.
XML
↓ inflate
View 객체
LinearLayout
├── TextView
└── Button
View 트리는 웹의 DOM 트리와 비슷한 구조를 가진다. ViewGroup은 자식 View를 담는 컨테이너라는 점에서 div와 유사하다. View는 form 같은 특정 태그보다 모든 Android UI 객체의 기반 타입에 가깝다.
화면을 변경할 때는 메모리에 생성된 View 객체를 직접 수정한다.
textView.text = "새로운 내용"
button.isEnabled = false
Fragment와 화면 전환
View 기반 앱에서 Activity가 화면의 모든 UI와 로직을 담당하면 클래스가 지나치게 커진다. Fragment는 Activity 내부 UI를 생명주기가 있는 작은 단위로 나누고 교체하기 위해 사용됐다.
MainActivity
└── FragmentContainer
├── HomeFragment
├── SearchFragment
└── ProfileFragment
Fragment를 교체하며 화면을 이동하려면 이전 화면의 이동 기록도 필요하다. Back Stack은 사용자가 방문한 화면 순서를 보관하고, 뒤로 가기를 눌렀을 때 이전 화면을 복원한다.
따라서 Back Stack의 목적은 단순한 Fragment 교체가 아니라 Navigation 기록 관리다.
Compose와 싱글 액티비티
Compose에서는 화면을 View 객체가 아닌 Composable 함수로 표현한다.
@Composable
fun Greeting(name: String) {
Text("안녕하세요, $name")
}
Composable은 React 컴포넌트와 비슷하지만 Activity와는 역할이 다르다. Activity는 Android OS가 관리하는 화면 호스트이고, Composable은 그 안에서 UI를 표현하는 함수다.
Compose Navigation은 Fragment 없이도 Composable을 화면 목적지로 사용할 수 있다.
MainActivity
└── Compose Navigation
├── HomeScreen()
├── SearchScreen()
└── ProfileScreen()
이 때문에 Compose 앱은 하나의 Activity 안에서 Composable 화면을 전환하는 싱글 액티비티 구조를 주로 사용한다.
여러 Activity를 사용하면 각 화면이 독립적인 OS 컴포넌트가 되지만 화면 사이의 상태 공유와 전환 관리가 복잡해질 수 있다. 싱글 액티비티 구조는 앱 내부 Navigation과 상태 공유가 자연스럽지만, 화면 목적지와 Back Stack을 앱에서 명확하게 관리해야 한다.
Fragment는 단순한 코드 정리 단위가 아니라 생명주기와 Navigation 기능을 가진 UI 컴포넌트다. Compose가 그 역할을 다른 방식으로 제공하기 때문에 반드시 사용할 필요가 없어진 것이다.
선언형 UI와 단방향 데이터 흐름
View 시스템에서는 화면 객체를 직접 수정하지만, Compose에서는 현재 상태에서 어떤 UI가 보여야 하는지를 선언한다.
@Composable
fun SubmitButton(enabled: Boolean) {
Button(
enabled = enabled,
onClick = { /* 이벤트 처리 */ },
) {
Text("제출")
}
}
Composable은 View와 상태가 합쳐진 영구 객체가 아니다. 상태를 읽고 UI 구조를 계산하는 함수다.
Compose Runtime은 Composable을 처음 실행하면서 다음 정보를 기록한다.
- Composable의 호출 위치와 구조
- remember로 보관한 값
- 각 Composable이 읽은 State
- 다시 실행할 수 있는 재구성 범위
이 기록이 Composition이다.
Composition
└── PostScreen()
├── Header()
├── PostContent()
└── LikeButton()
└── likeCount State를 읽음
likeCount가 변경되면 Compose는 그 값을 읽었던 재구성 범위를 무효화한다. 무효화는 해당 범위를 삭제한다는 뜻이 아니라, 이 범위가 오래된 상태를 사용했으므로 다시 계산해야 한다고 표시하는 것이다.
Recomposer는 표시된 범위를 적절한 시점에 다시 실행한다. 최상위 Composable부터 모든 함수를 무조건 실행하는 것이 아니라 Composition에 기록된 관계를 이용하며, 입력이 바뀌지 않은 하위 Composable은 건너뛸 수도 있다.
상태와 이벤트의 방향
Compose의 UI는 일반적으로 단방향 데이터 흐름을 따른다.
상태
부모 ─────────→ 자식
이벤트
부모 ←───────── 자식
@Composable
fun Counter(
count: Int,
onIncrease: () -> Unit,
) {
Button(onClick = onIncrease) {
Text("$count")
}
}
자식은 자신보다 위에 있는 모든 부모를 알지 못한다. 부모가 전달한 콜백 함수만 알고 있다.
사용자의 터치를 감지하는 것은 Compose가 처리하지만, 그 이벤트를 어떤 상태 변화로 연결할지는 개발자가 명시한다. 상태가 변경되면 Compose가 해당 상태를 읽었던 범위를 다시 실행하고 새로운 값을 하위 Composable에 전달한다.
UI 요소 하나에서만 사용하는 상태는 Composable 내부에 둘 수 있다.
var expanded by remember {
mutableStateOf(false)
}
여러 UI가 공유하거나 화면 로직에서도 필요한 상태는 상위 Composable 또는 ViewModel로 끌어올린다. 이를 State Hoisting이라고 한다.
생명주기와 상태 관리
상태를 어디에 둘지는 데이터의 종류뿐 아니라 얼마나 오래 유지돼야 하는지에 따라 결정된다.
| remember | 유지 | 소멸 | 소멸 | 소멸 |
| ViewModel | 유지 | 유지 | 소멸 | 소멸 |
| rememberSaveable | 유지 | 유지 | 조건부 복원 | 보장 없음 |
| SavedStateHandle | 유지 | 유지 | 조건부 복원 | 보장 없음 |
| Room·DataStore | 유지 | 유지 | 유지 | 유지 |
Composition 범위의 상태
remember는 값을 Composition 메모리에 저장한다.
var expanded by remember {
mutableStateOf(false)
}
재구성 중에는 값을 유지하지만 해당 Composable이 Composition에서 제거되거나 Activity가 재생성되면 사라진다.
화면 범위의 상태
ViewModel은 화면의 상태와 로직을 관리한다. Activity가 회전으로 교체돼도 ViewModelStore를 통해 기존 ViewModel이 새 Activity에 연결된다.
프로세스가 종료되면 ViewModel도 사라진다. ViewModel의 목적은 영구 보존이 아니라 화면 상태의 소유자와 생명주기를 명확하게 만드는 것이다.
Application 객체도 프로세스 종료 시 사라진다는 점은 같다. 그럼에도 화면 상태를 Application에 두지 않는 이유는 모든 화면에서 접근 가능한 전역 변경 상태가 되어 변경 경로와 생명주기를 추적하기 어려워지기 때문이다.
화면 복원을 위한 Saved State
Saved State는 영구 저장소가 아니라 화면 복구용 스냅샷이다.
상태가 변경되는 동안 실제 값은 앱 프로세스 메모리에 존재한다. Android가 상태를 저장할 때 작은 값을 Bundle 형태로 복사해 Saved State 시스템에 전달한다.
앱 메모리의 현재 상태
↓
Bundle로 복사
↓
Android가 복원용으로 관리
↓
새 Activity·ViewModel·Composition에 전달
Saved State를 시스템 메모리에 저장한다고 단순화할 수는 있지만, Android가 물리적인 저장 위치를 보장하는 것은 아니다. 앱이 보장받는 것은 시스템이 해당 화면을 복원 대상으로 유지하는 동안 저장된 값을 다시 전달할 수 있다는 동작이다.
따라서 Saved State에는 보장된 저장 기간이 없다. 시스템이 앱 프로세스만 제거하고 Task를 유지했다면 복원할 수 있지만, 사용자가 화면이나 Task를 완전히 종료하면 복원은 보장되지 않는다.
rememberSaveable과 SavedStateHandle은 모두 이 Saved State 메커니즘을 사용한다.
var expanded by rememberSaveable {
mutableStateOf(false)
}
rememberSaveable은 Composable에 가까운 UI 상태에 적합하다. 펼침 여부, 입력창의 내용, 탭 선택처럼 UI 요소 자체가 관리할 수 있는 값이 대표적이다.
class SearchViewModel(
private val savedStateHandle: SavedStateHandle,
) : ViewModel() {
val query = savedStateHandle.getStateFlow("query", "")
fun updateQuery(query: String) {
savedStateHandle["query"] = query
}
}
SavedStateHandle은 ViewModel의 화면 로직에서도 사용해야 하는 복원 상태에 적합하다.
두 API 모두 전체 게시글 목록 같은 큰 데이터를 보관하는 용도가 아니다. postId, 검색어, 필터처럼 화면을 다시 구성하는 데 필요한 최소한의 값만 저장하고, 실제 데이터는 데이터 계층에서 다시 불러와야 한다. Compose 상태 저장 문서
영속성과 데이터 계층
앱 프로세스나 화면의 생명주기보다 오래 유지돼야 하는 데이터는 디스크 또는 서버에 저장해야 한다.
Android에서는 로컬 영속성 도구로 Room과 DataStore를 주로 사용한다.
DataStore
DataStore는 작은 앱 설정을 저장하는 데 적합하다.
다크 모드
알림 설정
언어 설정
목록 정렬 방식
온보딩 완료 여부
Preferences DataStore는 Key-Value 형태로 데이터를 저장하고, 타입이 정의된 DataStore는 하나의 설정 객체를 저장한다. 데이터는 Coroutine과 Flow를 통해 비동기로 읽고 갱신한다.
Room
Room은 구조화된 데이터를 저장하는 SQLite 기반 데이터베이스다.
게시글
사용자
댓글
채팅 메시지
검색 기록
오프라인 캐시
테이블, 조건 검색, 정렬, 부분 수정, 데이터 관계, 인덱스, 트랜잭션이 필요하면 Room이 적합하다.
@Entity
data class PostEntity(
@PrimaryKey val id: Long,
val authorId: Long,
val title: String,
)
@Dao
interface PostDao {
@Query("SELECT * FROM PostEntity WHERE authorId = :authorId")
fun observeByAuthor(authorId: Long): Flow<List<PostEntity>>
}
DataStore와 Room의 차이는 단순히 저장되는 파일 형식이 아니다.
DataStore
→ 작은 설정 묶음을 읽고 갱신
Room
→ 여러 개의 구조화된 데이터를 조회하고 변경
복잡한 데이터, 부분 수정, 참조 무결성이 필요한 경우 Room을 사용하는 것이 적합하다. DataStore 공식 문서, Room 공식 문서
Repository
Repository는 데이터를 직접 저장하는 기술이 아니라 데이터 접근을 추상화하는 계층이다.
Room ─────┐
DataStore ├── Repository ── ViewModel ── Composable
Server ───┘ ↑ │
└── 이벤트 ───┘
ViewModel은 데이터가 Room에서 왔는지, 서버에서 왔는지 알 필요가 없다. Repository가 다음과 같은 정책을 담당한다.
- 서버와 로컬 데이터 중 무엇을 사용할지
- 서버 데이터를 Room에 캐시할지
- 오프라인일 때 어떤 데이터를 반환할지
- 저장소의 모델을 화면에서 사용할 모델로 변환할지
Repository 객체는 앱 프로세스가 종료되면 사라진다. 하지만 Repository가 접근하던 Room과 DataStore의 데이터는 디스크에 남는다. 새 프로세스에서 Repository를 다시 생성하고 데이터를 읽으면 된다.
전체 구조
Android 앱의 UI와 데이터 흐름은 다음과 같이 정리할 수 있다.
Android OS
└── Activity
└── Compose
└── Composable
│
│ 사용자 이벤트
↓
ViewModel
├── UI State
├── SavedStateHandle
└── Repository
├── Room
├── DataStore
└── Server
각 계층은 서로 다른 책임을 가진다.
- Activity는 Android OS와 연결되는 UI 실행 환경을 제공한다.
- Composable은 상태를 UI로 표현한다.
- Composition은 Composable 호출과 State 읽기 관계를 기록한다.
- ViewModel은 화면 상태와 로직을 관리한다.
- Saved State는 시스템이 화면을 재생성할 때 필요한 최소한의 상태를 복원한다.
- Room과 DataStore는 프로세스보다 오래 유지돼야 하는 데이터를 저장한다.
- Repository는 여러 데이터 출처를 하나의 접근 방식으로 연결한다.
상태 관리 도구를 선택할 때 가장 먼저 확인해야 하는 것은 저장 위치가 아니라 상태의 책임과 수명이다.
UI가 재구성되는 동안만 필요
→ remember
화면 회전까지 유지
→ ViewModel
시스템의 화면 복원까지 대응
→ rememberSaveable, SavedStateHandle
앱을 다시 실행해도 유지
→ Room, DataStore
여러 기기에서 공유
→ Server
'프론트엔드 > Compose' 카테고리의 다른 글
| [컴포즈] 기본 UI 컴포넌트 (2) | 2025.06.09 |
|---|---|
| [컴포즈] Jetpack Compose란? (0) | 2025.04.17 |
| [컴포즈] Kotlin: Collection (0) | 2025.04.16 |
| [컴포즈] Kotlin: 클래스 (0) | 2025.04.16 |
| [컴포즈] Kotlin: 함수 (0) | 2025.04.16 |

