[디자인 패턴] 구글 아키텍처 - Repository 패턴

Ju-unn·2026년 5월 23일

1. Repository 패턴이란?

정의

데이터를 가져오고 저장하는 방법을 추상화한 패턴입니다.
데이터가 서버에서 오든, 로컬 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());
        }
    }
}

이런 코드의 문제

  1. ViewModel이 너무 많이 알고 있음
    • API, SharedPreferences, Database를 모두 알아야 함
    • ViewModel이 복잡해짐
  2. 데이터 출처 변경 시 ViewModel도 수정해야 함
    • SharedPreferences → DataStore로 바꾸면?
    • Database → Room으로 바꾸면?
    • ViewModel 코드를 다 뜯어고쳐야 함!
  3. 테스트가 어려움
    • API, SharedPreferences, Database를 모두 mock해야 함

2. Repository 패턴의 해결책

Repository로 데이터 출처 숨기기

// 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());
        }
    }
}

장점

  1. ViewModel이 단순해짐
    • Repository 인터페이스만 알면 됨
    • 내부 구현은 몰라도 됨
  2. 데이터 출처 변경 용이
    • SharedPreferences → DataStore로 바꿔도
    • ViewModel 코드는 안 바뀜!
  3. 테스트 용이
    • Repository만 mock하면 됨

3. Single Source of Truth (단일 진실 공급원) 원칙

정의

모든 데이터는 하나의 출처에서만 관리되어야 한다는 원칙입니다.

왜 필요한가요?

문제 상황: 데이터 불일치

// 나쁜 예: 여러 곳에서 사용자 정보를 관리
public class ProfileActivity {
    private User userFromApi;        // API에서 받은 사용자 정보
    private User userFromCache;      // 캐시된 사용자 정보
    private User userFromDatabase;   // DB에 저장된 사용자 정보
    
    public void loadProfile() {
        // 어떤 데이터를 믿어야 할까?
        // API: 이름이 "김준석"
        // Cache: 이름이 "준석"
        // Database: 이름이 "Kim Junseok"
        
        // 데이터 불일치 발생!
    }
}

Repository로 Single Source of Truth 구현

// 좋은 예: 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();
    }
}

Single Source of Truth 전략

┌──────────────────────────────────────┐
│         ViewModel                 │
│  "사용자 정보 주세요!"              │
└──────────────────────────────────────┘
                 ↓
┌──────────────────────────────────────┐
│      Repository (단일 진실 공급원)  │
│  "여기서만 데이터를 제공합니다!"      │
└──────────────────────────────────────┘
         ↓                    ↓
    ┌────────┐          ┌────────┐
    │ Local  │         │ Remote │
    │  (DB)  │         │ (API)  │
    └────────┘          └────────┘
 
데이터 흐름:
1. Repository에 요청
2. Local에 있으면 즉시 반환
3. 없으면 Remote에서 가져오기
4. Remote 데이터를 Local에 저장
5. 다음부터는 Local에서 빠르게 제공

4. Repository 계층 구조

실제 프로젝트 구조

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

5. Repository 패턴 실전 구현

완전한 Login Repository 예제

// 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();
    }
}

6. Repository를 사용하는 ViewModel

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;
    }
}

7. 전체 동작 테스트

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());
    }
}

--

8. Repository 패턴의 실무 활용

캐싱 전략

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();
        }
    }
}

핵심 정리

Repository 패턴 3줄 요약

  1. 데이터 출처를 숨긴다: ViewModel은 데이터가 어디서 오는지 몰라도 됨
  2. Single Source of Truth: Repository가 유일한 데이터 제공자
  3. 캐싱 & 오프라인 지원: Local과 Remote를 조합해서 최적화

왜 Repository를 사용하나?

  • ✅ ViewModel 단순화
  • ✅ 데이터 출처 변경 용이
  • ✅ 테스트 용이
  • ✅ 캐싱 전략 적용 쉬움
profile
한줄한줄 기록해보자

0개의 댓글