반응형
Android App Architecture 완벽 가이드 - MVVM 패턴과 권장 아키텍처 설계 방법
Android 앱 개발에서 가장 중요한 것은 확장 가능하고 유지보수가 쉬운 아키텍처를 설계하는 것입니다. Google이 공식적으로 권장하는 Android App Architecture는 관심사 분리, 테스트 용이성, 수명주기 관리를 핵심으로 하며, ViewModel, LiveData, Repository 패턴을 활용합니다. 이번 포스팅에서는 강력한 프로덕션 품질의 Android 애플리케이션을 구축하기 위한 모범 사례와 권장 아키텍처를 상세히 알아보겠습니다. 특히 Activity와 Fragment의 역할을 최소화하고, 데이터 레이어와 UI 레이어를 명확히 분리하는 설계 원칙을 실전 예제와 함께 설명합니다. 이 가이드는 Android 프레임워크 기본을 이해하고 있는 중급 이상 개발자를 대상으로 하며, MVVM 패턴과 Clean Architecture 개념을 실무에 적용하는 방법을 다룹니다.
목차
1. 모바일 앱 사용자 환경의 특수성
2. Android Architecture 설계 원칙
3. 권장 아키텍처 구조와 구현
4. 실전 예제와 베스트 프랙티스
5. 자주 묻는 질문 (FAQ)
#1. 모바일 앱 사용자 환경의 특수성
Android 앱은 데스크톱 애플리케이션과 근본적으로 다른 환경에서 작동합니다. 복잡한 구성요소 상호작용, 제한된 리소스, 예측 불가능한 수명주기를 이해하는 것이 좋은 아키텍처 설계의 첫걸음입니다.
1) 데스크톱 vs 모바일 앱의 차이점
대부분의 데스크톱 앱은 단일 시작 지점에서 실행되어 하나의 프로세스로 계속 작동합니다. 사용자가 명시적으로 종료하기 전까지 메모리에 상주하며, 앱의 상태를 유지할 수 있습니다.
(1) 데스크톱 앱의 특징
① 단일 진입점에서 시작하여 사용자가 종료할 때까지 실행
② 충분한 메모리와 CPU 리소스 활용 가능
③ 운영체제가 임의로 프로세스를 종료하는 경우가 드뭄
④ 상태 관리가 비교적 단순하고 예측 가능
② 충분한 메모리와 CPU 리소스 활용 가능
③ 운영체제가 임의로 프로세스를 종료하는 경우가 드뭄
④ 상태 관리가 비교적 단순하고 예측 가능
(2) Android 앱의 복잡한 구조
반면 Android 앱은 훨씬 더 복잡하고 유동적인 구조를 가지고 있습니다. 일반적인 Android 앱은 다음과 같은 다양한 구성요소를 포함합니다.
| 구성요소 | 역할 | 수명주기 특징 |
|---|---|---|
| Activity | 화면 단위의 사용자 인터페이스 | 사용자 상호작용에 따라 자주 생성/소멸 |
| Fragment | Activity 내 재사용 가능한 UI 모듈 | Activity에 종속되어 복잡한 생명주기 |
| Service | 백그라운드에서 장시간 작업 수행 | 독립적으로 실행되며 우선순위에 따라 종료 |
| Content Provider | 앱 간 데이터 공유 | 시스템이 필요 시 자동으로 시작 |
| Broadcast Receiver | 시스템 또는 앱 이벤트 수신 | 이벤트 발생 시에만 활성화 |
개발자는 앱 매니페스트(AndroidManifest.xml)에서 이러한 구성요소 대부분을 선언하게 되며, Android OS는 이 파일을 사용하여 기기의 전반적인 사용자 환경에 앱을 통합하는 방법을 결정합니다.
. . . . .
2) 복잡한 사용자 워크플로우
Android 사용자는 짧은 시간에 여러 앱을 오가며 상호작용합니다. 이는 앱이 다양한 진입점과 중단점을 가져야 함을 의미하며, 개발자는 이러한 복잡한 워크플로우를 고려해야 합니다.
(1) 소셜 미디어 사진 공유 시나리오
실제 사용 사례를 통해 Android 앱의 복잡성을 이해해보겠습니다. 사용자가 소셜 네트워킹 앱에서 사진을 공유하는 과정을 생각해보세요.
① 카메라 인텐트 트리거 - 소셜 앱이 카메라 앱 실행을 요청합니다. Android OS가 인텐트를 처리하여 적절한 카메라 앱을 찾아 실행합니다.
② 앱 전환 - Android OS가 카메라 앱을 포그라운드로 가져옵니다. 이때 사용자는 소셜 앱을 떠나지만 사용 경험은 끊김없이 연결됩니다.
③ 추가 인텐트 발생 가능 - 카메라 앱이 갤러리나 파일 선택기를 실행할 수 있습니다. 이처럼 여러 앱이 연쇄적으로 실행될 수 있습니다.
④ 중단 이벤트 - 이 과정에서 언제든지 전화 수신, 알림, 배터리 부족 경고 등이 발생할 수 있습니다. 사용자는 이러한 중단에 대응한 후에도 원래 작업으로 돌아가 계속할 수 있어야 합니다.
⑤ 복귀 및 작업 완료 - 사용자가 다시 소셜 네트워크 앱으로 돌아와서 사진을 공유합니다. 이전 상태가 정확히 복원되어야 합니다.
② 앱 전환 - Android OS가 카메라 앱을 포그라운드로 가져옵니다. 이때 사용자는 소셜 앱을 떠나지만 사용 경험은 끊김없이 연결됩니다.
③ 추가 인텐트 발생 가능 - 카메라 앱이 갤러리나 파일 선택기를 실행할 수 있습니다. 이처럼 여러 앱이 연쇄적으로 실행될 수 있습니다.
④ 중단 이벤트 - 이 과정에서 언제든지 전화 수신, 알림, 배터리 부족 경고 등이 발생할 수 있습니다. 사용자는 이러한 중단에 대응한 후에도 원래 작업으로 돌아가 계속할 수 있어야 합니다.
⑤ 복귀 및 작업 완료 - 사용자가 다시 소셜 네트워크 앱으로 돌아와서 사진을 공유합니다. 이전 상태가 정확히 복원되어야 합니다.
휴대기기에서는 이렇게 앱을 바꾸는 동작이 일반적이므로, 앱에서 이러한 흐름을 올바르게 처리해야 합니다.
(2) 휴대기기의 리소스 제약
Android 기기는 데스크톱에 비해 메모리와 배터리가 제한적입니다. 따라서 운영체제는 새로운 앱을 위한 공간을 확보하기 위해 언제든지 일부 앱 프로세스를 종료해야 할 수 있습니다.
// Android의 프로세스 우선순위 (높은 순서대로)
1. Foreground Process (현재 사용자와 상호작용 중)
- 현재 화면에 표시된 Activity (onCreate ~ onResume 상태)
- 포그라운드에서 실행 중인 Service
- 사용자가 상호작용하는 BroadcastReceiver
2. Visible Process (화면에 보이지만 포커스 없음)
- 부분적으로 가려진 Activity (onPause 상태)
- Foreground Service에 바인딩된 프로세스
3. Service Process (백그라운드 서비스 실행 중)
- startService()로 시작된 서비스
- 음악 재생, 파일 다운로드 등
4. Cached Process (현재 필요하지 않은 프로세스)
- 백그라운드로 간 Activity (onStop 상태)
- 메모리 부족 시 가장 먼저 종료됨
- LRU(Least Recently Used) 순서로 관리
1. Foreground Process (현재 사용자와 상호작용 중)
- 현재 화면에 표시된 Activity (onCreate ~ onResume 상태)
- 포그라운드에서 실행 중인 Service
- 사용자가 상호작용하는 BroadcastReceiver
2. Visible Process (화면에 보이지만 포커스 없음)
- 부분적으로 가려진 Activity (onPause 상태)
- Foreground Service에 바인딩된 프로세스
3. Service Process (백그라운드 서비스 실행 중)
- startService()로 시작된 서비스
- 음악 재생, 파일 다운로드 등
4. Cached Process (현재 필요하지 않은 프로세스)
- 백그라운드로 간 Activity (onStop 상태)
- 메모리 부족 시 가장 먼저 종료됨
- LRU(Least Recently Used) 순서로 관리
중요: 운영체제는 언제든지 Cached Process를 종료할 수 있으며, 개발자는 이를 제어할 수 없습니다. 따라서 앱 구성요소에 중요한 데이터나 상태를 저장해서는 안 됩니다.
. . . . .
3) 아키텍처 설계 시 핵심 고려사항
이러한 환경적 특성을 고려할 때, Android 앱 아키텍처는 다음 원칙을 따라야 합니다.
(1) 구성요소의 독립성
앱 구성요소는 개별적이고 비순차적으로 실행될 수 있습니다. 사용자가 앱을 시작할 때 항상 MainActivity부터 시작하는 것이 아닙니다. 딥링크, 알림, 위젯, 공유 인텐트 등 다양한 진입점이 존재합니다.
// 다양한 앱 진입점 예시
// 1. 일반 앱 런처에서 시작
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
// 2. 딥링크를 통한 진입
<activity android:name=".ProfileActivity">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="myapp" android:host="profile" />
</intent-filter>
</activity>
// 3. 공유 인텐트 수신
<activity android:name=".ShareActivity">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity>
// 1. 일반 앱 런처에서 시작
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
// 2. 딥링크를 통한 진입
<activity android:name=".ProfileActivity">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="myapp" android:host="profile" />
</intent-filter>
</activity>
// 3. 공유 인텐트 수신
<activity android:name=".ShareActivity">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity>
(2) 데이터와 상태 관리의 분리
앱 구성요소에 앱 데이터나 상태를 저장해서는 안 됩니다. Activity나 Fragment는 언제든지 시스템에 의해 소멸될 수 있기 때문에, 이들에 데이터를 저장하면 데이터 손실이 발생합니다.
또한 앱 구성요소가 서로 종속되면 안 됩니다. 예를 들어 ActivityA가 ActivityB의 인스턴스 변수에 직접 접근한다면, ActivityB가 소멸될 때 앱 전체에 문제가 발생할 수 있습니다.
#2. Android Architecture 설계 원칙
앱 구성요소에 데이터와 상태를 저장할 수 없다면, 어떻게 앱을 설계해야 할까요? Google이 권장하는 두 가지 핵심 원칙을 알아보겠습니다.
1) 관심사 분리 (Separation of Concerns)
관심사 분리는 Android 아키텍처에서 가장 중요한 원칙입니다. 많은 초보 개발자들이 Activity나 Fragment에 모든 코드를 작성하는 실수를 범합니다.
(1) 잘못된 설계 패턴
// ❌ 나쁜 예: Activity에 모든 로직이 집중됨
public class UserProfileActivity extends AppCompatActivity {
// UI 관련 변수
private TextView nameTextView;
private ImageView profileImageView;
// 데이터 저장
private User currentUser;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_user_profile);
// Activity에서 직접 네트워크 호출
loadUserData(getUserId());
}
// 네트워크 통신 코드가 Activity에 직접 작성됨
private void loadUserData(String userId) {
new Thread(() -> {
try {
URL url = new URL("https://api.example.com/user/" + userId);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
// JSON 파싱도 Activity에서...
String json = readInputStream(conn.getInputStream());
currentUser = parseUserJson(json);
// DB 저장도 Activity에서...
saveToDatabase(currentUser);
runOnUiThread(() -> updateUI(currentUser));
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
}
public class UserProfileActivity extends AppCompatActivity {
// UI 관련 변수
private TextView nameTextView;
private ImageView profileImageView;
// 데이터 저장
private User currentUser;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_user_profile);
// Activity에서 직접 네트워크 호출
loadUserData(getUserId());
}
// 네트워크 통신 코드가 Activity에 직접 작성됨
private void loadUserData(String userId) {
new Thread(() -> {
try {
URL url = new URL("https://api.example.com/user/" + userId);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
// JSON 파싱도 Activity에서...
String json = readInputStream(conn.getInputStream());
currentUser = parseUserJson(json);
// DB 저장도 Activity에서...
saveToDatabase(currentUser);
runOnUiThread(() -> updateUI(currentUser));
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
}
위 코드의 심각한 문제점은 다음과 같습니다.
① 테스트가 거의 불가능 - Activity를 인스턴스화하지 않고는 로직을 테스트할 수 없습니다.
② 재사용 불가능 - 다른 화면에서 같은 데이터를 사용할 때 코드 중복이 발생합니다.
③ 수명주기 의존성 - 화면 회전이나 Activity 소멸 시 진행 중인 네트워크 요청이 중단되거나 메모리 누수가 발생합니다.
④ 유지보수 어려움 - 하나의 클래스에 너무 많은 책임이 집중되어 수정이 매우 어렵습니다.
⑤ 데이터 손실 - Activity가 백그라운드로 가면 currentUser 데이터가 사라질 수 있습니다.
② 재사용 불가능 - 다른 화면에서 같은 데이터를 사용할 때 코드 중복이 발생합니다.
③ 수명주기 의존성 - 화면 회전이나 Activity 소멸 시 진행 중인 네트워크 요청이 중단되거나 메모리 누수가 발생합니다.
④ 유지보수 어려움 - 하나의 클래스에 너무 많은 책임이 집중되어 수정이 매우 어렵습니다.
⑤ 데이터 손실 - Activity가 백그라운드로 가면 currentUser 데이터가 사라질 수 있습니다.
(2) UI 기반 클래스의 올바른 역할
Activity와 Fragment는 UI 및 운영체제 상호작용을 처리하는 로직만 포함해야 합니다. 이러한 클래스를 최대한 가볍게 유지하면 수명주기 관련 문제를 피할 수 있습니다.
// ✅ 좋은 예: Activity는 UI 표시만 담당
public class UserProfileActivity extends AppCompatActivity {
private UserViewModel viewModel;
private TextView nameTextView;
private ImageView profileImageView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_user_profile);
// 1. ViewModel 초기화 (수명주기 독립적)
viewModel = new ViewModelProvider(this).get(UserViewModel.class);
// 2. UI 컴포넌트 초기화
nameTextView = findViewById(R.id.name_text_view);
profileImageView = findViewById(R.id.profile_image_view);
// 3. 데이터 관찰 (Observer 패턴)
viewModel.getUser().observe(this, user -> {
// UI 업데이트만 수행
if (user != null) {
nameTextView.setText(user.getName());
Glide.with(this)
.load(user.getProfileImageUrl())
.into(profileImageView);
}
});
// 4. 데이터 로드 요청 (실제 로직은 ViewModel에서)
String userId = getIntent().getStringExtra("user_id");
viewModel.loadUser(userId);
}
}
public class UserProfileActivity extends AppCompatActivity {
private UserViewModel viewModel;
private TextView nameTextView;
private ImageView profileImageView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_user_profile);
// 1. ViewModel 초기화 (수명주기 독립적)
viewModel = new ViewModelProvider(this).get(UserViewModel.class);
// 2. UI 컴포넌트 초기화
nameTextView = findViewById(R.id.name_text_view);
profileImageView = findViewById(R.id.profile_image_view);
// 3. 데이터 관찰 (Observer 패턴)
viewModel.getUser().observe(this, user -> {
// UI 업데이트만 수행
if (user != null) {
nameTextView.setText(user.getName());
Glide.with(this)
.load(user.getProfileImageUrl())
.into(profileImageView);
}
});
// 4. 데이터 로드 요청 (실제 로직은 ViewModel에서)
String userId = getIntent().getStringExtra("user_id");
viewModel.loadUser(userId);
}
}
Activity와 Fragment는 소유의 대상이 아닙니다. 이들은 Android OS와 앱 사이의 계약을 나타내는 클래스일 뿐이며, 사용자 상호작용이나 시스템 조건(메모리 부족 등)에 따라 언제든지 OS가 제거할 수 있습니다. 따라서 이러한 클래스에 대한 의존성을 최소화하는 것이 좋습니다.
. . . . .
2) Model에서 UI 만들기 (UI from Model)
두 번째 중요한 원칙은 Model에서 UI를 만들어야 한다는 것입니다. 특히 지속적인 Model(Persistent Model)을 사용하는 것이 권장됩니다.
(1) Model의 정의와 역할
Model은 앱의 데이터 처리를 담당하는 구성요소로, 다음과 같은 중요한 특징을 가집니다.
① View 개체와 독립적 - UI 컴포넌트(Activity, Fragment)의 수명주기와 무관하게 존재합니다.
② 앱 구성요소와 독립적 - Activity나 Fragment가 소멸해도 Model은 유지됩니다.
③ 수명주기 문제의 영향을 받지 않음 - 화면 회전이나 앱 백그라운드 전환 시에도 데이터가 보존됩니다.
④ 단일 진실 공급원(SSOT) - 앱의 모든 데이터는 Model로부터 나옵니다.
② 앱 구성요소와 독립적 - Activity나 Fragment가 소멸해도 Model은 유지됩니다.
③ 수명주기 문제의 영향을 받지 않음 - 화면 회전이나 앱 백그라운드 전환 시에도 데이터가 보존됩니다.
④ 단일 진실 공급원(SSOT) - 앱의 모든 데이터는 Model로부터 나옵니다.
(2) Persistent Model의 장점
지속적인 Model이 이상적인 이유는 다음과 같습니다.
| 장점 | 설명 | 사용자 경험 개선 |
|---|---|---|
| 데이터 보존 | Android OS가 리소스 확보를 위해 앱을 제거해도 사용자 데이터가 삭제되지 않음 | 앱 재시작 시 이전 상태 즉시 복원 |
| 오프라인 지원 | 네트워크 연결이 취약하거나 없어도 앱이 계속 작동 | 지하철이나 비행기에서도 정상 사용 가능 |
| 테스트 용이성 | 데이터 관리 책임이 명확히 분리되어 단위 테스트 작성이 쉬움 | 버그 감소와 안정성 향상 |
| 일관성 유지 | 단일 진실 공급원으로 데이터 불일치 방지 | 예측 가능한 앱 동작 |
(3) Model 구현 철학
데이터 관리 책임이 잘 정의된 Model 클래스를 기반으로 앱을 만들면 쉽게 테스트하고 일관성을 유지할 수 있습니다. Model은 비즈니스 로직의 중심이 되며, UI는 단순히 Model의 상태를 표시하는 역할만 합니다.
#3. 권장 아키텍처 구조와 구현
이 섹션에서는 종합적인 사용 사례(end-to-end use case)를 통해 Google이 권장하는 아키텍처를 상세히 살펴보겠습니다. 사용자 프로필을 표시하는 화면을 구현한다고 가정하고, 비공개 백엔드 및 REST API를 사용하여 데이터를 가져오는 과정을 단계별로 설명합니다.
참고: 모든 시나리오에서 완벽하게 작동하는 단일 아키텍처는 존재하지 않습니다. 하지만 이 권장 아키텍처는 대부분의 상황 및 워크플로에 유용한 출발점이 될 것입니다. 이미 일반 아키텍처 원칙을 준수하는 Android 앱 작성 방법을 사용하고 있다면 방법을 변경할 필요가 없습니다.
1) 권장 아키텍처 개요
먼저 다음 다이어그램을 살펴보세요. 앱을 설계한 이후 모든 모듈이 서로 어떻게 상호작용해야 하는지 보여줍니다.
이미지1 - Android Architecture Components 다이어그램
(UI Layer → ViewModel → Repository → Data Sources 구조)
(UI Layer → ViewModel → Repository → Data Sources 구조)
아키텍처 다이어그램에서 볼 수 있듯이, 각 구성요소는 한 수준 아래의 구성요소에만 종속됩니다. 이는 단방향 의존성(Unidirectional Dependency)이라고 하며, 다음과 같은 이점이 있습니다.
① 테스트 용이성 - 하위 레이어를 Mock 객체로 대체하여 독립적으로 테스트할 수 있습니다.
② 모듈화 - 각 레이어를 독립적으로 개발하고 수정할 수 있습니다.
③ 순환 참조 방지 - 복잡한 의존성 문제를 원천 차단합니다.
④ 명확한 책임 분리 - 각 레이어의 역할이 명확하여 유지보수가 쉽습니다.
② 모듈화 - 각 레이어를 독립적으로 개발하고 수정할 수 있습니다.
③ 순환 참조 방지 - 복잡한 의존성 문제를 원천 차단합니다.
④ 명확한 책임 분리 - 각 레이어의 역할이 명확하여 유지보수가 쉽습니다.
(1) 아키텍처 레이어 설명
| 레이어 | 구성요소 | 역할 |
|---|---|---|
| UI Layer | Activity, Fragment | 사용자 인터페이스 표시 및 사용자 이벤트 처리 |
| ViewModel Layer | ViewModel, LiveData | UI 상태 관리 및 비즈니스 로직 처리 |
| Repository Layer | Repository | 데이터 소스 조정 및 캐싱 전략 구현 |
| Data Source Layer | Room DB, Retrofit API | 실제 데이터 저장 및 네트워크 통신 |
예를 들어 Activity 및 Fragment는 ViewModel에만 종속됩니다. Activity는 Repository나 데이터베이스를 직접 알지 못하며, 오직 ViewModel을 통해서만 데이터에 접근합니다.
특히 주목할 점은 Repository가 여러 데이터 소스에 종속되는 유일한 클래스라는 것입니다. 이 예제에서 Repository는 지속 데이터 Model(Room Database)과 원격 백엔드 데이터 소스(Remote Data Source, Retrofit API) 모두에 종속됩니다.
. . . . .
2) 아키텍처가 제공하는 사용자 경험
이 아키텍처 디자인은 일관되고 즐거운 사용자 경험을 만들어 줍니다.
(1) 즉각적인 데이터 표시
사용자가 마지막으로 앱을 닫은 후 몇 분 후 또는 며칠 후에 앱으로 돌아오든 상관없이, 로컬에서 앱이 지속하는 사용자의 정보를 즉시 볼 수 있습니다. 네트워크 요청을 기다릴 필요가 없어 체감 성능이 매우 빠릅니다.
(2) 백그라운드 데이터 갱신
로컬 데이터가 오래된 경우, 앱의 Repository 모듈이 백그라운드에서 데이터를 업데이트하기 시작합니다. 사용자는 데이터가 업데이트되는 것을 거의 눈치채지 못할 정도로 자연스럽게 최신 정보를 받게 됩니다.
(3) 오프라인 지원
네트워크 연결이 없어도 캐시된 로컬 데이터로 앱이 정상 작동합니다. 사용자는 지하철이나 비행기에서도 이전에 본 정보를 확인할 수 있습니다.
(4) 화면 회전 대응
ViewModel은 Activity의 수명주기보다 오래 살아남기 때문에, 화면 회전 시에도 데이터가 유지됩니다. 네트워크 요청을 다시 할 필요가 없어 불필요한 API 호출과 데이터 낭비를 방지합니다.
. . . . .
3) 각 레이어별 책임과 구현 가이드
실제 구현에서 각 레이어가 어떤 책임을 가져야 하는지 구체적으로 알아보겠습니다.
(1) Data Source Layer - 데이터의 원천
이 레이어는 실제 데이터가 저장되고 관리되는 곳입니다. 크게 두 가지로 나뉩니다.
① Local Data Source (Room Database)
- SQLite 데이터베이스를 추상화한 Room 라이브러리 사용
- 오프라인 데이터 저장 및 빠른 접근 제공
- Entity, DAO, Database 클래스로 구성
② Remote Data Source (REST API)
- Retrofit을 사용한 네트워크 통신
- 서버로부터 최신 데이터 가져오기
- API 인터페이스 정의
- SQLite 데이터베이스를 추상화한 Room 라이브러리 사용
- 오프라인 데이터 저장 및 빠른 접근 제공
- Entity, DAO, Database 클래스로 구성
② Remote Data Source (REST API)
- Retrofit을 사용한 네트워크 통신
- 서버로부터 최신 데이터 가져오기
- API 인터페이스 정의
(2) Repository Layer - 데이터 조정자
Repository는 데이터 소스를 추상화하고 캐싱 전략을 구현하는 중간 레이어입니다.
① Local과 Remote 데이터 소스를 조합하여 최적의 데이터 제공
② 캐시 우선 전략(Cache-First Strategy) 구현
③ 네트워크 오류 처리 및 재시도 로직
④ ViewModel이 데이터 출처를 알 필요 없도록 추상화
② 캐시 우선 전략(Cache-First Strategy) 구현
③ 네트워크 오류 처리 및 재시도 로직
④ ViewModel이 데이터 출처를 알 필요 없도록 추상화
(3) ViewModel Layer - UI 상태 관리
ViewModel은 UI와 비즈니스 로직을 연결하는 다리 역할을 합니다.
① Repository로부터 데이터를 가져와 UI에 노출
② LiveData를 사용하여 데이터 변경을 Activity/Fragment에 알림
③ 화면 회전 시에도 데이터 유지 (Configuration Changes 대응)
④ Context를 참조하지 않아 메모리 누수 방지
② LiveData를 사용하여 데이터 변경을 Activity/Fragment에 알림
③ 화면 회전 시에도 데이터 유지 (Configuration Changes 대응)
④ Context를 참조하지 않아 메모리 누수 방지
(4) UI Layer - 사용자 인터페이스
Activity와 Fragment는 순수하게 UI 표시에만 집중합니다.
① ViewModel의 LiveData를 관찰(observe)하여 UI 업데이트
② 사용자 입력을 ViewModel에 전달
③ 네비게이션 및 권한 요청 등 Android 시스템 상호작용
④ 비즈니스 로직을 포함하지 않음
② 사용자 입력을 ViewModel에 전달
③ 네비게이션 및 권한 요청 등 Android 시스템 상호작용
④ 비즈니스 로직을 포함하지 않음
#4. 실전 예제와 베스트 프랙티스
이론적인 아키텍처를 실제 프로젝트에 적용할 때 알아야 할 베스트 프랙티스와 주의사항을 코드 예제와 함께 살펴보겠습니다.
1) 실전 코드 예제
사용자 프로필을 표시하는 화면을 단계별로 구현해보겠습니다.
(1) Data Model 정의
// User.java - Room Entity 정의
@Entity(tableName = "users")
public class User {
@PrimaryKey
@NonNull
private String id;
private String name;
private String email;
private String profileImageUrl;
private long lastUpdated;
// Constructor, Getters, Setters
public User(String id, String name, String email) {
this.id = id;
this.name = name;
this.email = email;
this.lastUpdated = System.currentTimeMillis();
}
}
@Entity(tableName = "users")
public class User {
@PrimaryKey
@NonNull
private String id;
private String name;
private String email;
private String profileImageUrl;
private long lastUpdated;
// Constructor, Getters, Setters
public User(String id, String name, String email) {
this.id = id;
this.name = name;
this.email = email;
this.lastUpdated = System.currentTimeMillis();
}
}
(2) Room DAO 구현
// UserDao.java - 데이터베이스 접근 인터페이스
@Dao
public interface UserDao {
@Query("SELECT * FROM users WHERE id = :userId")
LiveData<User> getUserById(String userId);
@Insert(onConflict = OnConflictStrategy.REPLACE)
void insertUser(User user);
@Query("DELETE FROM users WHERE id = :userId")
void deleteUser(String userId);
}
@Dao
public interface UserDao {
@Query("SELECT * FROM users WHERE id = :userId")
LiveData<User> getUserById(String userId);
@Insert(onConflict = OnConflictStrategy.REPLACE)
void insertUser(User user);
@Query("DELETE FROM users WHERE id = :userId")
void deleteUser(String userId);
}
(3) Retrofit API 인터페이스
// UserApiService.java - REST API 인터페이스
public interface UserApiService {
@GET("users/{userId}")
Call<User> getUser(@Path("userId") String userId);
@PUT("users/{userId}")
Call<User> updateUser(
@Path("userId") String userId,
@Body User user
);
}
public interface UserApiService {
@GET("users/{userId}")
Call<User> getUser(@Path("userId") String userId);
@PUT("users/{userId}")
Call<User> updateUser(
@Path("userId") String userId,
@Body User user
);
}
(4) Repository 패턴 구현
// UserRepository.java - 데이터 조정 및 캐싱
public class UserRepository {
private final UserDao userDao;
private final UserApiService apiService;
private final ExecutorService executorService;
public UserRepository(UserDao userDao, UserApiService apiService) {
this.userDao = userDao;
this.apiService = apiService;
this.executorService = Executors.newSingleThreadExecutor();
}
/** 캐시 우선 전략으로 사용자 데이터 조회 */
public LiveData<User> getUser(String userId) {
// 1. 로컬 DB에서 즉시 반환 (LiveData)
LiveData<User> cachedUser = userDao.getUserById(userId);
// 2. 백그라운드에서 서버 데이터 갱신
executorService.execute(() -> refreshUser(userId));
return cachedUser;
}
private void refreshUser(String userId) {
try {
Response<User> response = apiService.getUser(userId).execute();
if (response.isSuccessful() && response.body() != null) {
User user = response.body();
userDao.insertUser(user); // LiveData 자동 업데이트
}
} catch (IOException e) {
Log.e("Repository", "Failed to refresh user", e);
}
}
}
public class UserRepository {
private final UserDao userDao;
private final UserApiService apiService;
private final ExecutorService executorService;
public UserRepository(UserDao userDao, UserApiService apiService) {
this.userDao = userDao;
this.apiService = apiService;
this.executorService = Executors.newSingleThreadExecutor();
}
/** 캐시 우선 전략으로 사용자 데이터 조회 */
public LiveData<User> getUser(String userId) {
// 1. 로컬 DB에서 즉시 반환 (LiveData)
LiveData<User> cachedUser = userDao.getUserById(userId);
// 2. 백그라운드에서 서버 데이터 갱신
executorService.execute(() -> refreshUser(userId));
return cachedUser;
}
private void refreshUser(String userId) {
try {
Response<User> response = apiService.getUser(userId).execute();
if (response.isSuccessful() && response.body() != null) {
User user = response.body();
userDao.insertUser(user); // LiveData 자동 업데이트
}
} catch (IOException e) {
Log.e("Repository", "Failed to refresh user", e);
}
}
}
(5) ViewModel 구현
// UserViewModel.java - UI 상태 관리
public class UserViewModel extends ViewModel {
private final UserRepository repository;
private LiveData<User> currentUser;
public UserViewModel(UserRepository repository) {
this.repository = repository;
}
public LiveData<User> getUser(String userId) {
if (currentUser == null) {
currentUser = repository.getUser(userId);
}
return currentUser;
}
}
public class UserViewModel extends ViewModel {
private final UserRepository repository;
private LiveData<User> currentUser;
public UserViewModel(UserRepository repository) {
this.repository = repository;
}
public LiveData<User> getUser(String userId) {
if (currentUser == null) {
currentUser = repository.getUser(userId);
}
return currentUser;
}
}
(6) Activity 구현
// UserProfileActivity.java - UI Layer
public class UserProfileActivity extends AppCompatActivity {
private UserViewModel viewModel;
private TextView nameTextView;
private TextView emailTextView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_user_profile);
// UI 초기화
nameTextView = findViewById(R.id.name_text_view);
emailTextView = findViewById(R.id.email_text_view);
// ViewModel 생성
viewModel = new ViewModelProvider(this).get(UserViewModel.class);
// 데이터 관찰
String userId = getIntent().getStringExtra("user_id");
viewModel.getUser(userId).observe(this, user -> {
if (user != null) {
nameTextView.setText(user.getName());
emailTextView.setText(user.getEmail());
}
});
}
}
public class UserProfileActivity extends AppCompatActivity {
private UserViewModel viewModel;
private TextView nameTextView;
private TextView emailTextView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_user_profile);
// UI 초기화
nameTextView = findViewById(R.id.name_text_view);
emailTextView = findViewById(R.id.email_text_view);
// ViewModel 생성
viewModel = new ViewModelProvider(this).get(UserViewModel.class);
// 데이터 관찰
String userId = getIntent().getStringExtra("user_id");
viewModel.getUser(userId).observe(this, user -> {
if (user != null) {
nameTextView.setText(user.getName());
emailTextView.setText(user.getEmail());
}
});
}
}
. . . . .
2) 주의사항과 안티패턴
아키텍처를 구현할 때 피해야 할 흔한 실수들입니다.
(1) ViewModel에서 Context 참조 금지
// ❌ 나쁜 예: ViewModel이 Activity Context를 참조
public class BadViewModel extends ViewModel {
private Context context; // 메모리 누수 발생!
public void showToast(String message) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show();
}
}
// ✅ 좋은 예: AndroidViewModel 사용
public class GoodViewModel extends AndroidViewModel {
public GoodViewModel(Application application) {
super(application);
}
public Context getAppContext() {
return getApplication().getApplicationContext();
}
}
public class BadViewModel extends ViewModel {
private Context context; // 메모리 누수 발생!
public void showToast(String message) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show();
}
}
// ✅ 좋은 예: AndroidViewModel 사용
public class GoodViewModel extends AndroidViewModel {
public GoodViewModel(Application application) {
super(application);
}
public Context getAppContext() {
return getApplication().getApplicationContext();
}
}
(2) LiveData 노출 시 불변성 유지
// ❌ 나쁜 예: MutableLiveData를 외부에 노출
public class BadViewModel extends ViewModel {
public MutableLiveData<User> user = new MutableLiveData<>();
// Activity에서 user.setValue() 직접 호출 가능!
}
// ✅ 좋은 예: LiveData로 노출하여 읽기 전용
public class GoodViewModel extends ViewModel {
private MutableLiveData<User> _user = new MutableLiveData<>();
public LiveData<User> user = _user;
public void loadUser(String id) {
_user.setValue(new User(id, "Name", "email"));
}
}
public class BadViewModel extends ViewModel {
public MutableLiveData<User> user = new MutableLiveData<>();
// Activity에서 user.setValue() 직접 호출 가능!
}
// ✅ 좋은 예: LiveData로 노출하여 읽기 전용
public class GoodViewModel extends ViewModel {
private MutableLiveData<User> _user = new MutableLiveData<>();
public LiveData<User> user = _user;
public void loadUser(String id) {
_user.setValue(new User(id, "Name", "email"));
}
}
. . . . .
3) 추가 학습 자료
Android Architecture에 대해 더 깊이 학습하고 싶다면 다음 주제들을 추가로 공부해보세요.
① Dependency Injection (Hilt/Dagger) - 의존성 주입으로 코드 간결성 향상
② Coroutines & Flow - 비동기 처리와 리액티브 프로그래밍
③ Navigation Component - 화면 전환 관리
④ WorkManager - 백그라운드 작업 스케줄링
⑤ Paging 3 - 대용량 데이터 효율적 로딩
② Coroutines & Flow - 비동기 처리와 리액티브 프로그래밍
③ Navigation Component - 화면 전환 관리
④ WorkManager - 백그라운드 작업 스케줄링
⑤ Paging 3 - 대용량 데이터 효율적 로딩
#5. 자주 묻는 질문 (FAQ)
1) Q: 모든 Android 앱에 이 아키텍처를 적용해야 하나요?
아닙니다. 모든 시나리오에 완벽하게 맞는 단일 아키텍처는 존재하지 않습니다. 이 권장 아키텍처는 대부분의 상황과 워크플로에 유용한 출발점이 될 것입니다. 간단한 프로토타입이나 학습용 앱의 경우 이 아키텍처가 과도할 수 있으며, 더 단순한 구조로 충분합니다. 반대로 대규모 엔터프라이즈 앱의 경우 Clean Architecture나 MVI 패턴 등 더 정교한 아키텍처가 필요할 수 있습니다. 중요한 것은 관심사 분리와 Model 기반 UI라는 핵심 원칙을 지키는 것입니다.
. . . . .
2) Q: MVP 패턴과 MVVM 패턴의 차이는 무엇인가요?
MVP(Model-View-Presenter)는 View와 Presenter가 1:1 관계로 강하게 결합되며, Presenter가 View 인터페이스를 통해 UI를 직접 조작합니다. 반면 MVVM(Model-View-ViewModel)은 ViewModel과 View가 LiveData를 통해 느슨하게 결합되어 있으며, ViewModel은 View를 전혀 알지 못합니다. MVVM이 더 테스트하기 쉽고, Android의 수명주기 문제를 더 잘 처리하기 때문에 Google이 공식 권장하는 패턴입니다.
. . . . .
3) Q: Repository는 항상 필요한가요?
단일 데이터 소스만 사용하는 간단한 앱이라면 Repository를 생략할 수 있습니다. 하지만 대부분의 실전 앱은 로컬 데이터베이스와 원격 API를 모두 사용하므로 Repository 패턴이 유용합니다. Repository는 데이터 소스를 추상화하여 ViewModel이 데이터의 출처를 알 필요 없게 만들고, 캐싱 전략을 중앙에서 관리할 수 있게 합니다. 또한 테스트 시 Mock Repository로 쉽게 대체할 수 있어 단위 테스트가 용이해집니다.
. . . . .
4) Q: LiveData 대신 RxJava나 Coroutines Flow를 사용할 수 있나요?
네, 가능합니다. LiveData는 Android 수명주기를 자동으로 처리하는 장점이 있지만, RxJava나 Kotlin Coroutines의 Flow도 훌륭한 대안입니다. Flow는 Kotlin의 suspend 함수와 자연스럽게 통합되며, 더 강력한 연산자를 제공합니다. 다만 수명주기 관리를 직접 해야 하므로 lifecycleScope나 viewModelScope를 적절히 사용해야 합니다. 프로젝트가 이미 Kotlin Coroutines를 사용한다면 Flow가, RxJava 기반이라면 Observable이 더 자연스러울 수 있습니다.
. . . . .
5) Q: ViewModel에서 다른 ViewModel을 참조할 수 있나요?
권장하지 않습니다. ViewModel 간 직접 참조는 강한 결합을 만들어 테스트와 유지보수를 어렵게 합니다. 대신 다음 방법을 고려하세요. ① 공통 로직은 Repository나 UseCase 클래스로 분리하여 여러 ViewModel이 공유하도록 합니다. ② 데이터 공유가 필요하면 SharedViewModel을 사용하거나, Activity/Fragment 스코프의 ViewModel을 만듭니다. ③ 이벤트 전달이 필요하면 Event Bus나 Shared LiveData를 사용합니다.
. . . . .
6) Q: 화면 회전 시 ViewModel의 데이터는 어떻게 유지되나요?
ViewModel은 Configuration Changes(화면 회전, 언어 변경 등)에도 살아남습니다. Activity가 재생성될 때 ViewModelProvider는 기존 ViewModel 인스턴스를 반환하므로, 데이터가 그대로 유지됩니다. 이는 onSaveInstanceState()와 달리 직렬화가 필요 없고, 대용량 데이터도 보존할 수 있습니다. 다만 프로세스가 완전히 종료되면 ViewModel도 사라지므로, 정말 중요한 데이터는 onSaveInstanceState()나 데이터베이스에 저장해야 합니다.
. . . . .
7) Q: Room 없이 SharedPreferences만 사용해도 되나요?
간단한 설정값 저장에는 SharedPreferences가 적합하지만, 구조화된 데이터나 관계형 데이터를 저장하려면 Room이 훨씬 효율적입니다. Room은 SQL 쿼리를 컴파일 타임에 검증하고, LiveData와 자연스럽게 통합되며, 마이그레이션을 체계적으로 관리할 수 있습니다. 일반적으로 사용자 설정이나 간단한 키-값 쌍은 SharedPreferences에, 복잡한 데이터는 Room에 저장하는 것이 좋습니다.
. . . . .
8) Q: Retrofit의 Call 대신 suspend 함수를 사용할 수 있나요?
네, Kotlin Coroutines를 사용한다면 suspend 함수가 더 권장됩니다. Retrofit 2.6.0부터 suspend 함수를 네이티브로 지원하여, Call<T> 대신 suspend fun getUser(): User 형태로 직접 정의할 수 있습니다. 이렇게 하면 코드가 더 간결해지고, try-catch로 예외 처리를 할 수 있으며, Coroutines의 구조화된 동시성을 활용할 수 있습니다. Repository에서는 withContext(Dispatchers.IO)로 백그라운드 스레드를 명시적으로 지정하면 됩니다.
. . . . .
9) Q: 이 아키텍처에서 Dagger/Hilt는 필수인가요?
필수는 아니지만 강력히 권장됩니다. 의존성 주입 없이도 아키텍처를 구현할 수 있지만, 수동으로 객체를 생성하고 전달하는 코드가 많아져 보일러플레이트가 증가합니다. Hilt를 사용하면 ViewModel, Repository, Database 등의 생성과 주입을 자동화할 수 있고, 싱글톤 관리가 쉬우며, 테스트 시 Mock 객체로 쉽게 교체할 수 있습니다. 중소규모 앱에서는 Hilt만으로도 충분하며, 대규모 앱에서는 Dagger를 고려할 수 있습니다.
. . . . .
10) Q: 이 아키텍처를 기존 레거시 프로젝트에 적용할 수 있나요?
네, 점진적으로 마이그레이션할 수 있습니다. 한 번에 전체를 바꾸지 말고, 새로운 기능부터 MVVM 아키텍처를 적용하세요. 기존 코드는 그대로 두고, 점차 화면 단위로 리팩토링하는 것이 안전합니다. 순서는 다음과 같이 진행하는 것을 권장합니다. ① Room Database 도입 → ② Repository 레이어 추가 → ③ ViewModel 적용 → ④ LiveData로 데이터 관찰 → ⑤ 기존 Activity/Fragment 간소화. 이렇게 하면 리스크를 최소화하면서 점진적으로 코드 품질을 개선할 수 있습니다.
마무리
Android App Architecture는 확장 가능하고 유지보수하기 쉬운 앱을 만들기 위한 핵심 요소입니다. 이번 포스팅에서 다룬 내용을 요약하면 다음과 같습니다.
① 모바일 환경의 특수성 이해 - 복잡한 구성요소, 제한된 리소스, 예측 불가능한 수명주기
② 관심사 분리 원칙 - Activity/Fragment는 UI만 담당하고, 비즈니스 로직은 분리
③ Model 기반 UI - 지속적인 Model을 사용하여 데이터 손실 방지 및 오프라인 지원
④ 계층화된 아키텍처 - UI → ViewModel → Repository → Data Source 구조
⑤ 단방향 의존성 - 각 레이어는 한 단계 아래 레이어에만 종속
② 관심사 분리 원칙 - Activity/Fragment는 UI만 담당하고, 비즈니스 로직은 분리
③ Model 기반 UI - 지속적인 Model을 사용하여 데이터 손실 방지 및 오프라인 지원
④ 계층화된 아키텍처 - UI → ViewModel → Repository → Data Source 구조
⑤ 단방향 의존성 - 각 레이어는 한 단계 아래 레이어에만 종속
이 아키텍처를 적용하면 테스트 가능하고, 유지보수하기 쉬우며, 확장 가능한 Android 앱을 만들 수 있습니다. 화면 회전이나 앱 백그라운드 전환 같은 수명주기 문제도 자연스럽게 해결되며, 일관된 사용자 경험을 제공할 수 있습니다.
처음에는 복잡해 보일 수 있지만, 한 번 익숙해지면 코드 작성 속도가 빨라지고 버그가 줄어드는 것을 체감할 수 있을 것입니다. 새로운 프로젝트를 시작한다면 이 아키텍처를 기본으로 사용하고, 기존 프로젝트는 점진적으로 마이그레이션하는 것을 추천드립니다.
긴 글 읽어주셔서 감사합니다.
끝.
끝.
반응형
'Development > Android' 카테고리의 다른 글
| [Android] Android Picasso vs Glide 이미지 라이브러리 비교와 선택 기준 (0) | 2019.10.01 |
|---|---|
| [Android] Android ViewModel과 LiveData 실전 구현 방법 (아키텍처 가이드) (0) | 2019.09.23 |
| [Android] Android ProGuard 완벽 가이드 - 소스코드 난독화와 최적화 방법 (0) | 2019.09.22 |
| [Android] Android Button 텍스트 밑줄 추가하는 4가지 방법 (0) | 2019.09.22 |
| [Android] Android ABI 완벽 가이드 : 초보 개발자를 위한 적용 및 관리 방법 (0) | 2019.09.22 |