개발 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로 충분
반응형