데이터를 가져오고 저장하는 방법을 추상화한 패턴입니다.
데이터가 서버에서 오든, 로컬 DB에서 오든, 메모리에서 오든 상관없이 통일된 인터페이스로 데이터를 다룹니다.
// ViewModel에서 직접 데이터 출처를 알고 있는 경우 (나쁜 예)
public class LoginViewModel {
private ApiService apiService; // 서버 통신
private SharedPreferences prefs; // 로컬 저장소
private Database database; // 로컬 DB
public void login(String email, String password) {
// 서버에서 로그인 시도
LoginResponse response = apiService.login(email, password);
if (response.isSuccess()) {
// 토큰을 SharedPreferences에 저장
prefs.edit().putString("token", response.getToken()).apply();
// 사용자 정보를 Database에 저장
database.insertUser(response.getUser());
}
}
}
// Repository 인터페이스 (계약서)
public interface LoginRepository {
LoginResult login(String email, String password);
void saveLoginToken(String token);
String getLoginToken();
void saveUser(User user);
}
// Repository 구현체 (실제 구현)
public class LoginRepositoryImpl implements LoginRepository {
private ApiService apiService; // 서버 통신
private SharedPreferences prefs; // 로컬 저장소
private UserDao userDao; // 로컬 DB
public LoginRepositoryImpl(ApiService apiService,
SharedPreferences prefs,
UserDao userDao) {
this.apiService = apiService;
this.prefs = prefs;
this.userDao = userDao;
}
@Override
public LoginResult login(String email, String password) {
// 1. 서버에 로그인 요청
try {
LoginResponse response = apiService.login(email, password);
if (response.isSuccess()) {
// 2. 토큰 저장 (내부 구현은 Repository만 알면 됨)
saveLoginToken(response.getToken());
// 3. 사용자 정보 저장
User user = new User(
response.getUserId(),
response.getUserName(),
response.getEmail()
);
saveUser(user);
return new LoginResult(true, "로그인 성공", user);
} else {
return new LoginResult(false, "로그인 실패", null);
}
} catch (Exception e) {
return new LoginResult(false, "네트워크 오류: " + e.getMessage(), null);
}
}
@Override
public void saveLoginToken(String token) {
// SharedPreferences에 저장
prefs.edit().putString("auth_token", token).apply();
}
@Override
public String getLoginToken() {
// SharedPreferences에서 가져오기
return prefs.getString("auth_token", null);
}
@Override
public void saveUser(User user) {
// Room Database에 저장
userDao.insertUser(user);
}
}
// LoginResult 모델
public class LoginResult {
private boolean success;
private String message;
private User user;
public LoginResult(boolean success, String message, User user) {
this.success = success;
this.message = message;
this.user = user;
}
public boolean isSuccess() { return success; }
public String getMessage() { return message; }
public User getUser() { return user; }
}
// 이제 ViewModel은 간단해짐!
public class LoginViewModel {
private LoginRepository repository; // 인터페이스만 의존
public LoginViewModel(LoginRepository repository) {
this.repository = repository;
}
public void login(String email, String password) {
// Repository가 알아서 처리해줌!
LoginResult result = repository.login(email, password);
if (result.isSuccess()) {
System.out.println("✅ " + result.getMessage());
} else {
System.out.println("❌ " + result.getMessage());
}
}
}
모든 데이터는 하나의 출처에서만 관리되어야 한다는 원칙입니다.
// 나쁜 예: 여러 곳에서 사용자 정보를 관리
public class ProfileActivity {
private User userFromApi; // API에서 받은 사용자 정보
private User userFromCache; // 캐시된 사용자 정보
private User userFromDatabase; // DB에 저장된 사용자 정보
public void loadProfile() {
// 어떤 데이터를 믿어야 할까?
// API: 이름이 "김준석"
// Cache: 이름이 "준석"
// Database: 이름이 "Kim Junseok"
// 데이터 불일치 발생!
}
}
// 좋은 예: Repository가 유일한 데이터 출처
public class UserRepositoryImpl implements UserRepository {
private ApiService apiService; // Remote (서버)
private UserDao userDao; // Local (DB)
@Override
public User getUser(String userId) {
// 1. 먼저 로컬 DB 확인 (빠름!)
User cachedUser = userDao.getUser(userId);
if (cachedUser != null) {
// 캐시가 있으면 즉시 반환
System.out.println("캐시에서 사용자 정보 로드");
// 백그라운드에서 서버 데이터로 갱신
updateUserInBackground(userId);
return cachedUser;
}
// 2. 캐시가 없으면 서버에서 가져오기
try {
User userFromServer = apiService.getUser(userId);
// 3. 서버에서 받은 데이터를 DB에 저장 (캐시 갱신)
userDao.insertUser(userFromServer);
System.out.println("🌐 서버에서 사용자 정보 로드 및 캐시 저장");
return userFromServer;
} catch (Exception e) {
System.out.println("❌ 서버 요청 실패: " + e.getMessage());
return null;
}
}
private void updateUserInBackground(String userId) {
// 백그라운드에서 서버 데이터로 캐시 갱신
new Thread(() -> {
try {
User userFromServer = apiService.getUser(userId);
userDao.updateUser(userFromServer);
System.out.println("백그라운드에서 캐시 갱신 완료");
} catch (Exception e) {
System.out.println("백그라운드 갱신 실패");
}
}).start();
}
}
┌──────────────────────────────────────┐
│ ViewModel │
│ "사용자 정보 주세요!" │
└──────────────────────────────────────┘
↓
┌──────────────────────────────────────┐
│ Repository (단일 진실 공급원) │
│ "여기서만 데이터를 제공합니다!" │
└──────────────────────────────────────┘
↓ ↓
┌────────┐ ┌────────┐
│ Local │ │ Remote │
│ (DB) │ │ (API) │
└────────┘ └────────┘
데이터 흐름:
1. Repository에 요청
2. Local에 있으면 즉시 반환
3. 없으면 Remote에서 가져오기
4. Remote 데이터를 Local에 저장
5. 다음부터는 Local에서 빠르게 제공
app/
├── data/
│ ├── model/ # 데이터 모델
│ │ ├── User.java
│ │ ├── LoginRequest.java
│ │ └── LoginResponse.java
│ │
│ ├── remote/ # 서버 통신
│ │ ├── ApiService.java
│ │ └── RetrofitClient.java
│ │
│ ├── local/ # 로컬 저장소
│ │ ├── UserDao.java
│ │ ├── AppDatabase.java
│ │ └── SharedPreferencesManager.java
│ │
│ └── repository/ # Repository 구현
│ ├── LoginRepository.java (인터페이스)
│ └── LoginRepositoryImpl.java (구현체)
│
└── ui/
└── login/
├── LoginViewModel.java
└── LoginActivity.java
// 1. 데이터 모델들
public class User {
private String userId;
private String userName;
private String email;
public User(String userId, String userName, String email) {
this.userId = userId;
this.userName = userName;
this.email = email;
}
public String getUserId() { return userId; }
public String getUserName() { return userName; }
public String getEmail() { return email; }
}
public class LoginRequest {
private String email;
private String password;
public LoginRequest(String email, String password) {
this.email = email;
this.password = password;
}
public String getEmail() { return email; }
public String getPassword() { return password; }
}
public class LoginResponse {
private boolean success;
private String message;
private String token;
private User user;
public LoginResponse(boolean success, String message, String token, User user) {
this.success = success;
this.message = message;
this.token = token;
this.user = user;
}
public boolean isSuccess() { return success; }
public String getMessage() { return message; }
public String getToken() { return token; }
public User getUser() { return user; }
}
// 2. Remote Data Source (API)
public interface ApiService {
LoginResponse login(String email, String password);
}
public class ApiServiceImpl implements ApiService {
@Override
public LoginResponse login(String email, String password) {
// 실제로는 Retrofit으로 서버 통신
// 여기서는 예시로 간단히 구현
System.out.println("🌐 서버에 로그인 요청: " + email);
// 간단한 검증
if (email.contains("@") && password.length() >= 6) {
User user = new User("user123", "김준석", email);
return new LoginResponse(
true,
"로그인 성공",
"auth_token_12345",
user
);
} else {
return new LoginResponse(false, "로그인 실패", null, null);
}
}
}
// 3. Local Data Source (저장소 관리자)
public class LocalStorageManager {
// 실제로는 SharedPreferences나 DataStore 사용
private String savedToken = null;
private User savedUser = null;
public void saveToken(String token) {
this.savedToken = token;
System.out.println("토큰 저장: " + token);
}
public String getToken() {
return savedToken;
}
public void saveUser(User user) {
this.savedUser = user;
System.out.println("사용자 정보 저장: " + user.getUserName());
}
public User getUser() {
return savedUser;
}
public void clearAll() {
savedToken = null;
savedUser = null;
System.out.println("로컬 데이터 삭제");
}
}
// 4. Repository 인터페이스
public interface LoginRepository {
LoginResponse login(String email, String password);
void logout();
User getCurrentUser();
boolean isLoggedIn();
}
// 5. Repository 구현체 (핵심!)
public class LoginRepositoryImpl implements LoginRepository {
private ApiService apiService;
private LocalStorageManager localStorage;
public LoginRepositoryImpl(ApiService apiService, LocalStorageManager localStorage) {
this.apiService = apiService;
this.localStorage = localStorage;
}
@Override
public LoginResponse login(String email, String password) {
System.out.println("\n=== Repository: 로그인 처리 시작 ===");
// 1. 서버에 로그인 요청
LoginResponse response = apiService.login(email, password);
// 2. 성공하면 로컬에 저장
if (response.isSuccess()) {
localStorage.saveToken(response.getToken());
localStorage.saveUser(response.getUser());
System.out.println("✅ Repository: 로그인 성공 및 로컬 저장 완료");
} else {
System.out.println("❌ Repository: 로그인 실패");
}
return response;
}
@Override
public void logout() {
System.out.println("\n=== Repository: 로그아웃 처리 ===");
localStorage.clearAll();
System.out.println("✅ Repository: 로그아웃 완료");
}
@Override
public User getCurrentUser() {
// 로컬에서 사용자 정보 반환
return localStorage.getUser();
}
@Override
public boolean isLoggedIn() {
// 토큰이 있으면 로그인 상태
String token = localStorage.getToken();
return token != null && !token.isEmpty();
}
}
public class LoginViewModel {
private LoginRepository repository;
private String statusMessage = "";
private User currentUser = null;
public LoginViewModel(LoginRepository repository) {
this.repository = repository;
}
public void login(String email, String password) {
System.out.println("\n=== ViewModel: 로그인 요청 ===");
// 1. 입력값 검증
if (email == null || email.isEmpty()) {
statusMessage = "이메일을 입력하세요";
System.out.println("❌ " + statusMessage);
return;
}
if (password == null || password.isEmpty()) {
statusMessage = "비밀번호를 입력하세요";
System.out.println("❌ " + statusMessage);
return;
}
// 2. Repository에 로그인 요청
LoginResponse response = repository.login(email, password);
// 3. 결과 처리
statusMessage = response.getMessage();
if (response.isSuccess()) {
currentUser = response.getUser();
System.out.println("✅ ViewModel: " + statusMessage);
} else {
currentUser = null;
System.out.println("❌ ViewModel: " + statusMessage);
}
}
public void logout() {
System.out.println("\n=== ViewModel: 로그아웃 요청 ===");
repository.logout();
currentUser = null;
statusMessage = "로그아웃 되었습니다";
System.out.println("✅ ViewModel: " + statusMessage);
}
public boolean isLoggedIn() {
return repository.isLoggedIn();
}
public User getCurrentUser() {
return currentUser != null ? currentUser : repository.getCurrentUser();
}
public String getStatusMessage() {
return statusMessage;
}
}
public class Main {
public static void main(String[] args) {
System.out.println("Login App 시작\n");
// 의존성 주입
ApiService apiService = new ApiServiceImpl();
LocalStorageManager localStorage = new LocalStorageManager();
LoginRepository repository = new LoginRepositoryImpl(apiService, localStorage);
LoginViewModel viewModel = new LoginViewModel(repository);
// 시나리오 1: 로그인 시도
System.out.println("\n[시나리오 1] 로그인 시도");
viewModel.login("junseok@email.com", "password123");
System.out.println("현재 사용자: " + viewModel.getCurrentUser().getUserName());
System.out.println("로그인 상태: " + viewModel.isLoggedIn());
// 시나리오 2: 로그아웃
System.out.println("\n[시나리오 2] 로그아웃");
viewModel.logout();
System.out.println("로그인 상태: " + viewModel.isLoggedIn());
// 시나리오 3: 잘못된 로그인 시도
System.out.println("\n[시나리오 3] 잘못된 로그인 시도");
viewModel.login("invalid", "12345");
System.out.println("상태 메시지: " + viewModel.getStatusMessage());
}
}
--
public class UserRepositoryImpl implements UserRepository {
private ApiService apiService;
private UserDao userDao;
// 캐시 유효 시간 (5분)
private static final long CACHE_EXPIRE_TIME = 5 * 60 * 1000;
@Override
public User getUser(String userId) {
// 1. 캐시 확인
UserCache cache = userDao.getUserCache(userId);
if (cache != null) {
long currentTime = System.currentTimeMillis();
long cacheAge = currentTime - cache.getTimestamp();
// 캐시가 유효하면 즉시 반환
if (cacheAge < CACHE_EXPIRE_TIME) {
System.out.println("캐시 사용 (유효 시간: " + (CACHE_EXPIRE_TIME - cacheAge) + "ms 남음)");
return cache.getUser();
}
}
// 2. 캐시가 없거나 만료됨 → 서버에서 가져오기
System.out.println("서버에서 최신 데이터 가져오기");
User user = apiService.getUser(userId);
// 3. 캐시 갱신
userDao.insertUserCache(new UserCache(user, System.currentTimeMillis()));
return user;
}
}
public class PostRepositoryImpl implements PostRepository {
@Override
public List<Post> getPosts() {
try {
// 1. 온라인: 서버에서 최신 게시글 가져오기
List<Post> posts = apiService.getPosts();
// 2. 로컬 DB에 저장
postDao.insertAll(posts);
return posts;
} catch (NetworkException e) {
// 3. 오프라인: 로컬 DB에서 가져오기
System.out.println("오프라인 모드: 캐시된 데이터 사용");
return postDao.getAllPosts();
}
}
}