개발 history
개인 프로젝트 코드 리팩토링
김한토
2025. 5. 26. 15:42
반응형
강의 같은거 좀 보다보니 기존 코드에 많은 문제점을 발견함..
심각한 아키텍처 문제들
1. 메모리 누수 위험
// HookViewModel.kt - 심각한 메모리 누수 위험
fun fetchHooksByTagName(tagName: String) {
viewModelScope.launch {
val hooksLiveData = hookRepository.getHooksByTagName(tagName)
hooksLiveData.observeForever { hooks ->
// Observer가 제거되지 않음 - 메모리 누수 발생
}
}
}
2. Room 데이터베이스 트랜잭션 문제
// HookDao.kt - 잘못된 트랜잭션 구현
@Transaction
@Query("DELETE FROM Hook WHERE hookId = :hookId")
fun deleteHookAndTags(hookId: String) {
deleteHookById(hookId)
deleteTagByHookId(hookId)
}
설계 및 성능 문제들
3. ViewModel 인스턴스 생성 패턴 문제
// 여러 Activity에서 각각 새로운 ViewModel 인스턴스 생성
private lateinit var hookViewModel: HookViewModel
hookViewModel = HookViewModel()
4. 비효율적인 LiveData 관찰 패턴
// HookAdapter.kt - 중첩된 LiveData 관찰
hookViewModel.liveDataHook.observe(viewLifecycleOwner) { hooks ->
hookViewModel.getAllHooks().observe(viewLifecycleOwner) { hooks ->
}
}
5. 부적절한 Context 사용
// TagListAdapter.kt
class TagListAdapter(private val context: Context, ...) -> context 집접 전달
- Activity가 회전되거나 종료될 때 새로운 Activity가 생성됨
- 하지만 Adapter는 이전 Activity의 Context를 계속 참조
- 이전 Activity와 관련된 모든 View, Resource가 메모리에 계속 남아있음
6. UI 스레드 블로킹 위험
//UI 스레드에서 DB 작업 수행 가능
fun insertHook(hook: Hook) {
appDatabase.hookDao().insertHook(hook)
}
<<<수정>>>
- 메모리 누수 (observeForever)
- Room 트랜잭션 오류
- Context 사용 개선
- 예외 처리 강화
- 아키텍처 리팩토링
- DI 도입
- 테스트 코드 작성
1. Repository를 Singleton으로 구현한 이유
@Singleton
class HookRepository @Inject constructor(
private val hookDao: HookDao
) {
// ...
}
문제점: 여러 ViewModel에서 Repository 인스턴스가 중복 생성되면 메모리 낭비와 데이터 일관성 문제 발생
해결 이유:
- 메모리 효율성: Repository는 상태를 가지지 않는 stateless 클래스이므로 하나의 인스턴스만 있어도 충분
- 데이터 일관성: 모든 ViewModel이 동일한 Repository 인스턴스를 사용하여 캐시된 데이터 공유 가능
- 성능 최적화: 객체 생성 비용 절감 및 GC 부담 감소
2. observeForever 대신 switchMap을 사용한 이유
// 이전 방식 - 메모리 누수 위험
hookRepository.getHooksByTagName(tagName).observeForever { ... }
// 개선된 방식
val hooksBySelectedTag: LiveData<List<Hook>> = _selectedTagName.switchMap { tagName ->
if (tagName.isNullOrBlank()) {
MutableLiveData(emptyList())
} else {
hookRepository.getHooksByTagName(tagName)
}
}
문제점: observeForever는 수동으로 removeObserver를 호출하지 않으면 영구적으로 관찰이 유지됨
해결 이유:
- 자동 라이프사이클 관리: switchMap은 소스 LiveData가 변경될 때 이전 관찰을 자동으로 해제
- 메모리 누수 방지: ViewModel이 clear될 때 모든 관찰이 자동으로 정리됨
- 반응형 프로그래밍: 태그 선택이 변경될 때마다 자동으로 새로운 데이터 스트림 생성
3. Hilt를 도입한 이유
@HiltAndroidApp
class MainApplication : Application()
@AndroidEntryPoint
class HomeActivity : BaseActivity()
@HiltViewModel
class HookViewModel @Inject constructor(
private val hookRepository: HookRepository
) : ViewModel()
문제점: 수동 의존성 관리로 인한 보일러플레이트 코드와 테스트 어려움
해결 이유:
- 컴파일 타임 검증: 의존성 그래프를 컴파일 시점에 검증하여 런타임 에러 방지
- ViewModel 자동 주입: ViewModelFactory 보일러플레이트 제거
- 테스트 용이성: 테스트용 모듈로 쉽게 교체 가능
- 스코프 관리: @Singleton, @ActivityScoped 등으로 객체 생명주기 명시적 관리
4. 모든 DB 작업을 suspend 함수로 변경한 이유
@Dao
interface HookDao {
@Insert
suspend fun insertHook(hook: Hook): Long
@Query("DELETE FROM Hook WHERE hookId = :hookId")
suspend fun deleteHookById(hookId: String)
}
문제점: 메인 스레드에서 DB 작업 수행 시 ANR(Application Not Responding) 발생
해결 이유:
- 자동 스레드 전환: Room이 자동으로 백그라운드 스레드에서 실행
- 코루틴 통합: viewModelScope와 자연스럽게 통합되어 생명주기 관리 용이
- 순차적 실행 보장: suspend 함수는 순차적으로 실행되어 트랜잭션 관리 용이
5. LiveData 캐싱 전략을 도입한 이유
// HookAdapter.kt
private val tagsCache = mutableMapOf<String, List<Tag>>()
private fun bindTags(hookId: String) {
if (currentHookId == hookId && tagsCache.containsKey(hookId)) {
setupTagRecyclerView(tagsCache[hookId] ?: emptyList())
return
}
// ...
}
문제점: RecyclerView 스크롤 시 동일한 데이터를 반복적으로 DB에서 조회
해결 이유:
- 성능 최적화: 이미 로드한 태그 데이터를 메모리에 캐싱하여 DB 쿼리 최소화
- 부드러운 스크롤: 스크롤 시 버벅임 없이 즉시 데이터 표시
- 네트워크 부담 감소: 향후 네트워크 연동 시에도 유용한 패턴
6. BaseActivity로 공통 기능을 추출한 이유
@AndroidEntryPoint
abstract class BaseActivity() : AppCompatActivity() {
override fun dispatchTouchEvent(event: MotionEvent?): Boolean {
// EditText 외부 터치 시 키보드 숨김 처리
}
}
문제점: 모든 Activity에서 키보드 숨김 로직이 중복됨
해결 이유:
- DRY 원칙: Don't Repeat Yourself - 중복 코드 제거
- 일관된 UX: 모든 화면에서 동일한 키보드 동작 보장
- 유지보수성: 한 곳에서 수정하면 모든 Activity에 적용
7. 유틸리티 클래스를 object로 구현한 이유
object DateUtils {
fun generateHookId(): String { ... }
}
object UrlUtils {
fun isValidUrl(url: String): Boolean { ... }
}
문제점: 유틸리티 메서드들이 여러 클래스에 산재
해결 이유:
- 싱글톤 보장: object는 자동으로 싱글톤 구현
- 메모리 효율성: 인스턴스 생성 없이 직접 메서드 호출
- 네임스페이스 역할: 관련 함수들을 논리적으로 그룹화
8. Coroutine + Flow 대신 LiveData를 유지한 이유
val hooks: LiveData<List<Hook>> = hookRepository.getAllHooks()
고민: Flow가 더 현대적이고 강력한 기능 제공
LiveData 선택 이유:
- Room 통합: Room이 LiveData를 네이티브로 지원
- 생명주기 인식: Activity/Fragment 생명주기 자동 관리
- 충분한 기능: 현재 요구사항에는 LiveData로 충분
반응형