[Android/오르다 다이어리] 레거시 리팩토링 06 - DBHelper 싱글톤 패턴 적용
지난글: https://yujinius45.tistory.com/160
[Android/오르다 다이어리] 레거시 리팩토링 05 - Configuration Change 대응 (ViewModel 적용)
[Android/오르다 다이어리] 레거시 리팩토링 05 - Configuration Change 대응 (ViewModel 적용) 지난 글: https://yujinius45.tistory.com/159 [Android/오르다 다이어리] 레거시 리팩토링 04 - Fragment 마이그레이션[Android/오
yujinius45.tistory.com
🔥 ViewModel 도입 과정에서 발견한 DBHelper 문제
지난 글에서는 Configuration Change 대응을 위해 ViewModel을 도입하고, LiveData와 DataBinding을 활용하여 MVVM 패턴을 적용했다.
이 과정에서 ViewModel에서 DBHelper를 주입하는 과정에서 예상치 못한 문제를 발견했다.
바로, DBHelper가 여러 개 생성될 수 있다는 점이었다! 😱
"어? 우리는 DBHelper가 하나만 존재해야 한다고 생각했는데...?"
MVVM 패턴 적용을 통해 UI와 비즈니스 로직을 분리하면서 오히려 과거 우리가 의도했던 구조의 문제점이 드러난 것이었다.
📌 과거 우리 팀이 DBHelper를 설계했던 방식
⏳ 3년 전, 오르다 다이어리를 처음 만들던 시절
오르다 다이어리 프로젝트를 처음 시작할 때, 우리 팀은 DBHelper를 어떻게 관리할지 고민했다.
당시 우리 팀의 프로그래밍 사고방식은 이랬다.
💡 "DBHelper는 어차피 SQLite에 접근해서 데이터를 저장/조회하는 용도인데, 굳이 여러 개 만들 필요 있을까?"
💡 "하나만 만들어서 모든 곳에서 공유해서 쓰면 되지 않을까?"
그래서 MainActivity에서 DBHelper를 static으로 선언한 후, 모든 곳에서 MainActivity.mDBHelper를 import해서 사용했다.
📌 당시 우리가 선택한 코드 (기존 방식)
public class MainActivity extends AppCompatActivity {
private ActivityMainBinding binding;
private NavController navController;
public static DBHelper mDBHelper; // static으로 선언
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
mDBHelper = new DBHelper(this); // MainActivity에서 직접 생성
}
}
이 방식을 선택한 이유는 단순했다.
👉 "static으로 선언하면 앱 전역에서 같은 인스턴스를 공유할 수 있겠지?"
결과적으로 우리가 원했던 대로 동작했다.
📌 한 번 생성된 DBHelper를 여러 Fragment에서 import하여 그대로 사용할 수 있었음.
하지만...! 🧐
이 방식이 생각보다 많은 문제를 내포하고 있었다.
📌 ViewModel에 DBHelper를 주입하며 발견한 문제
이전 포스팅에서 MVVM 패턴을 적용하면서 ViewModel에서 DBHelper를 주입하는 코드가 필요했다.
그런데 이 과정에서 충격적인(?) 사실을 발견했다.
🔍 DBHelper를 새로 생성해서 넣을 수 있었다!
즉, 우리가 의도했던 "DBHelper는 하나만 있어야 한다"는 규칙이 깨질 가능성이 있었다.
이제 이 방식의 구체적인 문제점을 살펴보자.
❌ 기존 방식의 문제점 (MainActivity에서 관리하는 방식)
1️⃣ MainActivity에 대한 강한 의존성
현재 코드에서는 DBHelper를 사용하려면 반드시 MainActivity를 import해야 한다.
즉, MainActivity가 없으면 DBHelper를 독립적으로 사용할 수 없다.
📌 예제 코드 (Fragment에서 DBHelper 사용)
public class SomeFragment extends Fragment {
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// MainActivity를 통해서만 DBHelper를 참조할 수 있음
DBHelper dbHelper = MainActivity.mDBHelper;
dbHelper.insertData(...);
}
}
📌 참고 구조 이미지 ▼

- 오르다 다이어리 레거시 리팩토링 전 모든 화면은 Activity였고 MainActivity에서 DBHelper를 import해서 사용했다. 그리고 리팩토링하여 Fragment로 바뀐 지금도 DBHelper를 MainActivity에서 import해서 사용하고 있는 상태인 것이다. (이전 이야기는 이전 포스팅을 참고하길 바란다.)
🚨 확장성을 고려하여 넓게 봤을 때의 문제점
- 독립적인 모듈(예: ViewModel)에서 DBHelper를 사용하려면 아래 그림처럼 MainActivity를 import해야 함.

- 테스트 코드 작성이 어려움 → MainActivity가 실행되지 않으면 DBHelper를 사용할 수 없음.
- MainActivity가 종료되면 DBHelper도 사라질 위험이 있음.
- 아래의 그림처럼 mDBHelper를 가진 Activity가 destory된다면 이를 import 해서 쓰던 A, B, C는 오류가 발생할 것이다.

2️⃣ DBHelper가 여러 개 생성될 가능성
DBHelper는 오직 하나만 유지되어야 한다.
하지만, 기존 방식에서는 다른 사람이 DBHelper를 new DBHelper(context)로 직접 생성할 수도 있는 구조였다.
📌 예제 코드 (ViewModel에서 DBHelper를 또 생성한 경우)
public class DiaryViewModel extends ViewModel {
private DBHelper dbHelper;
public DiaryViewModel(Application application) {
super(application);
dbHelper = new DBHelper(application); // 새로운 DBHelper 인스턴스 생성!
}
public void insertDiary(...) {
dbHelper.insertData(...);
}
}
🚨 문제점
- 같은 DBHelper를 써야 하는데, 새로운 인스턴스가 여러 개 생성될 가능성이 있다.
- 메모리 낭비가 발생할 수 있음.
- 동일한 DB에 여러 개의 객체가 접근하면 데이터 동기화 문제가 발생할 가능성이 있음.
📌 실제 발생한 상황
- 아래의 그림처럼 A, B는 Activity의 mDBHelper를 import하고 쓰고 있었는데, 어떤 사람이 mDBHelper를 C에서 새로 만들어서 DB에 접근한다고 생각하면 된다.
- 그리고 실제로 우측과 같이 수정 도중에 발견했는데, 팀원이 작성한 코드 중 일부에서 DBHelper를 생성하여 사용하고 있는 것이 확인되었다

✅ 해결 방법: DBHelper를 싱글톤 패턴으로 변경!
이제 DBHelper를 싱글톤 패턴(Singleton Pattern)으로 변경하여 하나의 인스턴스만 유지하고, 어디서든 같은 DBHelper를 사용할 수 있도록 개선하자.
💡 잠깐, 싱글톤(Singleton) 패턴이란?
- 하나의 클래스에 대해 오직 하나의 객체만 생성되도록 보장하는 디자인 패턴.
- 모든 코드에서 같은 인스턴스를 공유하며 사용하므로, 메모리 절약 & 데이터 일관성 유지가 가능하다.
- DBHelper 같은 객체는 여러 개 만들 필요 없이, 앱 전역에서 하나만 유지하면 되므로 싱글톤 패턴이 적합하다.
📌 수정 후 코드 (싱글톤 적용)
public class DBHelper extends SQLiteOpenHelper {
private static volatile DBHelper instance;
private DBHelper(Context context) {
super(context.getApplicationContext(), DB_NAME, null, DB_VERSION);
}
public static DBHelper getInstance(Context context) {
if (instance == null) { // 첫 번째 체크 (동기화 불필요)
synchronized (DBHelper.class) {
if (instance == null) { // 두 번째 체크 (동기화 블록 내 실행)
instance = new DBHelper(context);
}
}
}
return instance;
}
}
📌 왜 더블 체크 락킹(Double-Checked Locking)을 사용했을까?
🤔 현재 오르다 다이어리는 멀티 스레드가 아닌데?
처음에는 synchronized 키워드를 메서드 전체에 적용하려 했지만, 현재 오르다 다이어리는 따로 멀티 스레드를 만들어서 처리하고 있는 작업이 없기 때문에 동기화 작업이 매번 필요하지 않다.
public static synchronized DBHelper getInstance(Context context) {
if (instance == null) {
instance = new DBHelper(context);
}
return instance;
}
이렇게 synchronized 키워드를 메서드 전체에 적용하면 매번 getInstance()를 호출할 때마다 동기화가 발생하여 성능 저하 가능성이 있다고 판단했다.
🤔 그렇지만 확장성을 고려한다면?
그러나 추후 확장성을 고려했을 때 추가 스레드를 만들어 처리한다고 하면 synchronized가 필요하게 될 것이다.
😃 고민 해결!
그래서 더블 체크 락킹을 통해 현재 상황에서 불필요한 synchronized 진입을 하지 않게 하면서 추가 스레드가 있을 때 동기화 처리는 되는 방식으로 구현했다. 더블 체크 락킹을 적용하여 불필요한 동기화를 최소화하고 성능을 최적화했다.
🚀 더블 체크 락킹의 장점
- 이미 인스턴스가 있으면 동기화 블록에 진입하지 않고 바로 반환하여 성능 최적화
- 멀티스레드 환경에서도 인스턴스가 여러 개 생성되지 않도록 보장
- volatile 키워드 사용으로 CPU 캐시 일관성 유지
💡 잠깐, synchronized 키워드?
synchronized는 멀티스레드 환경에서 한 번에 하나의 스레드만 특정 코드 블록을 실행하도록 보장하는 키워드이다.- 즉, 공유 자원(예: DBHelper 인스턴스)에 여러 스레드가 동시에 접근하는 것을 막고, 데이터 정합성을 보장한다. JVM 내부적으로 모니터 락을 요청하는 등의 작업이 이루어진다. 스레드가 락을 얻고 반환한느 과정이 포함된다는 것이다.
- 싱글톤 패턴에서
synchronized를 사용하는 이유?→ 여러 개의 스레드가 동시에getInstance()를 호출할 경우, 여러 개의 객체가 생성되는 문제를 방지하기 위해 사용한다.
💡 잠깐, volatile 키워드?
volatile키워드는 CPU 캐시와 메인 메모리 간의 일관성을 보장하는 키워드이다.- 멀티스레드 환경에서 한 스레드가 변수 값을 변경하면, 다른 스레드에서도 즉시 변경된 값을 볼 수 있도록 보장한다.
- 싱글톤 패턴에서 인스턴스 변수를
volatile로 선언하는 이유?→ 더블 체크 락킹(Double-Checked Locking)에서 CPU 캐시 최적화로 인해 발생하는 인스턴스 초기화 순서 문제를 방지하기 위함.
🌟 싱글톤 적용 후 개선된 점
| 문제 | 기존 코드 (MainActivity에서 관리) | 개선 코드 (싱글톤 적용) |
| MainActivity 의존성 | MainActivity.mDBHelper 필요 |
❌ MainActivity 없이 DBHelper.getInstance(context) 사용 가능 |
| 여러 개의 인스턴스 생성 가능성 | 가능 (new DBHelper() 가능) | ❌ 하나의 인스턴스만 존재 |
| 테스트 코드 작성 | MainActivity 없으면 불가능 | ✅ MainActivity 없이 독립적으로 사용 가능 |
| 메모리 낭비 | 여러 개의 DBHelper 인스턴스 생성 가능 | ✅ 싱글톤 적용으로 메모리 절약 |
🔍 결론
- 기존 코드에서는 DBHelper를 MainActivity에서 관리하며 static으로 선언, 모든 곳에서
MainActivity.mDBHelper를 사용해야 했다. - 이로 인해 의존성이 강해지고, 여러 개의 DBHelper 인스턴스가 생성되는 등의 문제가 발생했다.
- 싱글톤 패턴을 적용하여
DBHelper.getInstance(context)방식으로 변경함으로써 의존성을 줄이고, 인스턴스를 하나만 유지하도록 개선했다.
✍️ 현재 구조 도식화

✅ DBHelper를 싱글톤 패턴으로 변경하여 MainActivity 의존성을 제거하고, 어디서든 같은 인스턴스를 사용 가능하도록 개선
✅ 더블 체크 락킹을 적용하여 불필요한 동기화 호출을 최소화하고 성능을 최적화
✅ 이제 DBHelper가 중복 생성되지 않으며, 모든 코드에서 DBHelper.getInstance(context)를 통해 하나의 객체만 유지됨
📌 참고: Kotlin에서 싱글톤 패턴 적용하는 방법
- 현재 오르다 다이어리는 Java로 작성되어 리팩토링도 Java 코드 그대로 수정하고 있다. 최근 진행했던 Nature Album 프로젝트는 Kotlin 기반이었는데, Kotlin에서의 싱글톤 패턴 구현 방법도 간단히 적어두고자 한다. Kotlin에서는 object를 활용하면 기본적으로 안전한 싱글톤을 쉽게 구현할 수 있으며, 필요한 경우 companion object와 lazy 초기화를 활용하면 된다. companion object는 주로 java랑 호환을 중요시 한다면 사용하고, Kotlin에서는 object를 사용하는 것을 권장한다.
- object 키워드는 클래스를 인스턴스화하지 않고 즉시 싱글톤 객체를 생성한다. JVM 레벨에서 자동으로 스레드 안전(Thread-Safe)하게 관리되므로, synchronized 또는 volatile을 사용할 필요가 없다.
☺️ 후기
3년 전, 우리는 DBHelper를 하나만 만들고 공유해서 쓰고 싶었다.
그런데 시간이 지나고 보니, 우리가 했던 고민이 싱글톤 패턴과 맞닿아 있었던 것 같다.
그리고 이번 리팩토링을 통해 왜 싱글톤 패턴이 필요한지, 어떻게 동작하는지를 확실하게 이해할 수 있었다.
- 싱글톤 패턴을 확실하게 알고 넘어갈 수 있는 리팩토링이었다.
- 과거 3년 전 그때는 싱글톤 패턴도 몰랐는데 우리가 하나의 객체만 만들고 접근해서 사용하려고 했던 것이 싱글톤 패턴과 유사한 것 같아 기분이 묘하다. 그러면서 동시에 왜 싱글톤 패턴이 만들어졌고 이를 위해 멀티 스레드가 동시에 만들려고 했을 때의 처리 등도 고려해야 한다는 것을 확실하게 알게 되었다.
- Kotlin 기반 프로젝트를 진행할 때는 object를 사용하다 보니 Thread-Safe까지는 직접 고려할 필요가 없었는데 이번 기회에 고려할 수 있게 되어서 좋았다.
'Android > 오르다 다이어리' 카테고리의 다른 글
| [Android/오르다 다이어리] 레거시 리팩토링 08 - SQL Injection 테스트 및 해결 (0) | 2025.03.02 |
|---|---|
| [Android/오르다 다이어리] 레거시 리팩토링 07 - SQLite, MapMemo 기능 완성 (1) | 2025.03.02 |
| [Android/오르다 다이어리] 레거시 리팩토링 05 - Configuration Change 대응 (ViewModel 적용) (0) | 2025.03.01 |
| [Android/오르다 다이어리] 레거시 리팩토링 04 - Fragment 마이그레이션 (0) | 2025.02.24 |
| [Android/오르다 다이어리] 레거시 리팩토링 03 - Navigation Component 적용 (0) | 2025.02.20 |