Android/오르다 다이어리

[Android/오르다 다이어리] 레거시 리팩토링 07 - SQLite, MapMemo 기능 완성

yujinius 2025. 3. 2. 19:39
[Android/오르다 다이어리] 레거시 리팩토링 07 - SQLite, MapMemo 기능 완성

지난 글: https://yujinius45.tistory.com/161

 

[Android/오르다 다이어리] 레거시 리팩토링 06 - DBHelper 싱글톤 패턴 적용

[Android/오르다 다이어리] 레거시 리팩토링 06 - DBHelper 싱글톤 패턴 적용 지난글: https://yujinius45.tistory.com/160 [Android/오르다 다이어리] 레거시 리팩토링 05 - Configuration Change 대응 (ViewModel 적용)[Androi

yujinius45.tistory.com

 

지난 글을 통해 DBHelper를 싱글톤으로 변경하였고 이에 따라 테스트 코드 작성이 원활해졌다. DBHelper를 싱글톤으로 변경하면서 테스트 코드 작성이 가능해진 이유는 다음과 같다.


🔍 기존 구조의 문제 (MainActivity 의존)

  • 기존에는 MainActivity에서 static으로 선언된 mDBHelper를 import해서 사용
  • 즉, DBHelper를 사용하려면 반드시 MainActivity가 실행되어야 하는 구조
    테스트 코드에서 DBHelper를 단독으로 사용하기 어려웠음.

✅ 싱글톤 패턴 적용 후 개선된 점

  1. MainActivity 의존성 제거
    • MainActivity.mDBHelper를 참조하는 방식이 사라짐
    • DBHelper.getInstance(context)를 호출하면 어디서든 같은 인스턴스를 사용할 수 있음.
      테스트 코드에서 MainActivity 없이 독립적으로 DBHelper를 사용할 수 있음!
  2. 테스트 환경에서도 context를 넘겨줄 수 있음
    • 테스트 코드에서 ApplicationProvider.getApplicationContext() 같은 방식으로 독립적인 context를 제공 가능.
    • 따라서, DBHelper.getInstance(context)를 호출해서 데이터베이스 테스트가 가능해짐.

💡 정리하면?

이전에는 MainActivity 실행이 필요했지만, 이제는 테스트 코드에서 직접 DBHelper.getInstance(context)를 호출할 수 있어서 독립적인 테스트 코드 작성이 가능해진 것이다!


😃 오늘 진행 내용 미리보기

그래서 오늘은 테스트 코드를 추가하고, MapMemo의 기능을 완성해줄 예정이다. MapMemo 같은 경우 DB에 저장되지 않아 메모가 날라가는 문제가 있었다.

▼ MapMemo 데이터 유지X Configuration change 대응X

 

이번 작업에서는 ⭐ MapMemo를 저장할 Table을 추가하고, 데이터 저장/불러오는 코드를 작성한 후, 테스트를 먼저 진행한 뒤 로직을 추가하는 방식으로 진행했다.

이 과정에서 SQLite 사용의 불편한 점도 함께 살펴보았다. 또한, SQL Injection을 직접 테스트 해보고 이를 방지하기 위해 Prepared Statement를 적용해보았다 . 이 부분은 다음 글에 작성해보겠다. 오늘은 MapMemo 기능 완성에 집중해보자.


🔍 현재 방식: SQLite로 cursor 옮기며 직접 쿼리 실행

현재 SQLite를 사용할 때, 직접 SQL 쿼리를 실행하고 Cursor를 통해 데이터를 처리하는 방식이다.

// SQLite 방식 - Cursor 사용 (불편한 점)
Cursor cursor = db.rawQuery("SELECT * FROM MAPMEMO_TB", null);
while (cursor.moveToNext()) {
    int id = cursor.getInt(cursor.getColumnIndexOrThrow("id"));
    String title = cursor.getString(cursor.getColumnIndexOrThrow("title"));
    String content = cursor.getString(cursor.getColumnIndexOrThrow("content"));
    double latitude = cursor.getDouble(cursor.getColumnIndexOrThrow("latitude"));
    double longitude = cursor.getDouble(cursor.getColumnIndexOrThrow("longitude"));
}
cursor.close();

이 방식은 코드가 길어지고 가독성이 떨어진다는 문제가 있다.

❓만약 Room이라면?

// Room 방식 - DAO를 활용한 간결한 코드
@Query("SELECT * FROM mapmemo_tb")
fun getAllMapMemos(): List<MapMemoItem>

📌 차이점:

  • SQLite는 Cursor를 사용하여 getColumnIndex()로 일일이 값을 가져와야 함 (가독성이 안 좋음).
  • Room에서는 SQL 없이 DAO 메서드를 호출하는 것만으로 데이터를 가져올 수 있음.

✋ 잠깐! 혹시 cursor를 처음 보나요?

  • 그렇다면 아래를 읽어보면 좋다. 요즘은 RoomDB를 많이 써서 이렇게 cursor로 직접 db를 다루는 코드는 익숙하지 않을 수 있다.

🗨️ SQLite란?

SQLite는 경량화된 관계형 데이터베이스(RDBMS)로, 앱 내에서 독립적으로 실행되는 데이터베이스이다.

즉, 별도의 서버 없이 파일 기반으로 동작하고, 안드로이드에서도 기본 제공되는 내장 DB이다.

 

특징

  • 서버 없이 동작하는 임베디드(Embedded) 데이터베이스
  • 파일 기반으로 동작 (앱 내부의 .db 파일에 데이터 저장)
  • SQL 문법을 지원 (SELECT, INSERT, UPDATE, DELETE 등)
  • 앱이 설치된 기기에서 로컬 데이터 저장 용도로 사용됨

SQLite 사용 방법

  1. 데이터베이스 생성 및 테이블 정의
  2. SQL 쿼리를 실행하여 데이터 삽입, 조회, 수정, 삭제
  3. 조회된 데이터를 Cursor를 통해 접근 및 처리

🗨️ Cursor란?

Cursor는 SQL 조회(SELECT) 결과를 가리키는 포인터이다.

즉, 쿼리 실행 결과를 담고 있는 데이터셋을 가리키는 객체로, 데이터를 한 행씩 이동하면서 읽을 수 있다.

 

Cursor의 역할

  • SELECT 쿼리를 실행하면, 결과 데이터들이 Cursor에 저장됨
  • moveToNext()를 호출하여 한 행씩 이동하면서 데이터를 읽을 수 있음
  • getColumnIndex("컬럼명") 또는 getColumnIndexOrThrow("컬럼명")을 사용하여 특정 컬럼의 데이터를 가져옴
  • 데이터 처리가 끝나면 cursor.close()를 호출하여 메모리 해제를 해줘야 함

 

🔹 SQLite + Cursor를 이용한 데이터 조회 원리

SQLite에서 데이터를 조회하고 Cursor를 사용하여 데이터를 가져오는 기본적인 원리를 코드와 함께 설명하자면 다음과 같다.

✅ 1. 데이터베이스 테이블 생성

db.execSQL("CREATE TABLE IF NOT EXISTS USERS (" +
           "id INTEGER PRIMARY KEY AUTOINCREMENT, " +
           "name TEXT NOT NULL, " +
           "age INTEGER NOT NULL);");
  • USERS 테이블을 만들고, id, name, age 컬럼을 포함함.

 

✅ 2. 데이터 삽입 (INSERT)

SQLiteDatabase db = getWritableDatabase();
db.execSQL("INSERT INTO USERS (name, age) VALUES ('Alice', 25)");
db.execSQL("INSERT INTO USERS (name, age) VALUES ('Bob', 30)");
  • INSERT INTO USERS를 실행하여 데이터를 추가함.

 

✅ 3. 데이터 조회 (SELECT)와 Cursor 사용

SQLiteDatabase db = getReadableDatabase();
Cursor cursor = db.rawQuery("SELECT * FROM USERS", null);

while (cursor.moveToNext()) {
    int id = cursor.getInt(cursor.getColumnIndexOrThrow("id"));
    String name = cursor.getString(cursor.getColumnIndexOrThrow("name"));
    int age = cursor.getInt(cursor.getColumnIndexOrThrow("age"));

    System.out.println("ID: " + id + ", Name: " + name + ", Age: " + age);
}
cursor.close();

 

💡 처리 과정

  1. rawQuery("SELECT * FROM USERS", null)를 실행하면, Cursor 객체가 반환됨.
  2. moveToNext()를 호출하여 Cursor가 한 행씩 이동하면서 데이터를 읽음.
  3. getColumnIndexOrThrow("컬럼명")을 사용하여 컬럼의 인덱스를 가져옴.
  4. getInt(), getString() 등의 메서드로 데이터를 읽음.
  5. cursor.close()를 호출하여 Cursor를 닫고 메모리 해제.

 

🔹 Cursor를 사용하지 않으면?

만약 Cursor를 사용하지 않는다면, 데이터 조회 후 반환되는 행들을 직접 처리할 방법이 없다.

즉, 반복문을 사용하여 데이터를 한 줄씩 이동하면서 읽는 과정이 불가능해진다.

→ Cursor는 SQLite의 데이터를 읽는 핵심적인 역할을 담당하는 객체!

 

🔹 정리

📌 SQLite는 앱 내부에서 독립적으로 실행되는 가벼운 RDBMS이며, SQL 문법을 그대로 사용해서 데이터를 저장하고 조회할 수 있다.

📌 Cursor는 SQL 쿼리 결과를 가리키는 포인터로, 데이터를 한 행씩 이동하면서 읽는 역할을 한다.

📌 moveToNext()를 이용해 한 줄씩 데이터를 이동하면서 컬럼 값을 가져오는 방식으로 처리한다.


🔹 참고: Room

  • Room은 SQLite의 기능을 추상화하여 보다 편리하게 데이터베이스 작업을 수행할 수 있도록 설계된 라이브러리이다.
  • Room은 내부적으로 SQLite를 사용하지만, 개발자가 직접 SQL 쿼리를 작성하고 Cursor를 다루는 복잡성을 줄여준다. 대신, Entity, DAO(Data Access Object), Database의 세 가지 주요 구성 요소를 통해 데이터베이스 작업을 수행한다.

Room은 Nature Album에서 사용했었는데 확실히 지금 이렇게 cursor로 하는 것보다 헷갈리지 않고 편했었다. 그래도 오르다 다이어리는 우선 기존 방식 그대로 SQLite를 cursor를 통해 접근하는 기존 방식으로 일단 진행해보고자 한다.


📝 MapMemoItem 추가

Java 버전과 Kotlin 버전 비교

기존 Java 방식:

public class MapMemoItem {
    private int id;
    private String title;
    private String content;
    private double latitude;
    private double longitude;

  // getter, setter, constructor 필요
    public MapMemoItem(int id, String title, String content, double latitude, double longitude) {
        this.id = id;
        this.title = title;
        this.content = content;
        this.latitude = latitude;
        this.longitude = longitude;
    }

    public int getId() {
        return id;
    }

    // 생략
}

 

Kotlin으로 변경하면 가독성이 좋아진다.

package smu.team3_orda_diary.model

data class MapMemoItem(
    val id: Int,
    val title: String,
    val content: String,
    val latitude: Double,
    val longitude: Double
)

 

💡 getter, setter 없이 가독성이 좋아져서 Kotlin을 선택했다.


🛠 MapMemo 테이블 추가와 DB Upgrade

  • 테이블 수정이 있을 때마다 DB 버전을 올려야 한다.
  • 기존 유저와 신규 유저 모두 정상 동작하도록 CREATEonUpgrade()에 추가해야 한다.
public class DBHelper extends SQLiteOpenHelper {
    public static final int DB_VERSION = 2;
    public static final String DB_NAME = "Ordatest5.db";
...
    @Override
    public void onCreate(SQLiteDatabase db) {
    ...
        db.execSQL("CREATE TABLE IF NOT EXISTS MAPMEMO_TB (" +
                "id INTEGER PRIMARY KEY AUTOINCREMENT, " +
                "title TEXT NOT NULL, " +
                "content TEXT NOT NULL, " +
                "latitude REAL NOT NULL, " +
                "longitude REAL NOT NULL);");
    }
...
    @Override
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
        if (oldVersion < 2) {
            db.execSQL("CREATE TABLE IF NOT EXISTS MAPMEMO_TB (" +
                    "id INTEGER PRIMARY KEY AUTOINCREMENT, " +
                    "title TEXT NOT NULL, " +
                    "content TEXT NOT NULL, " +
                    "latitude REAL NOT NULL, " +
                    "longitude REAL NOT NULL);");
        }
    }
}

🤔 의문점: 테이블 수정 있을 때마다 onUpgrade()이 변경되어 복잡해지는가? ⇒ yes

이전까지는 버전 1인 상태였지만 테이블 수정이 일어나면 버전을 올리고 onUpgrade()에 작업을 해줘야 한다. 위의 코드에서 볼 수 있듯이 버전 업되어 수정사항이 생길 수록 많은 코드가 작성될 것으로 보이지 않는가?

맞다. 테이블 수정이 있을 때마다 onUpgrade()가 점점 복잡해질 수 있다.

 

🚀 해결 방법

 

1️⃣ DB Migration 클래스를 따로 관리하기

public class DBMigration {
    public static void migrate(SQLiteDatabase db, int oldVersion, int newVersion) {
        if (oldVersion < 2) {
            db.execSQL("CREATE TABLE IF NOT EXISTS MAPMEMO_TB (" +
                    "id INTEGER PRIMARY KEY AUTOINCREMENT, " +
                    "title TEXT NOT NULL, " +
                    "content TEXT NOT NULL, " +
                    "latitude REAL NOT NULL, " +
                    "longitude REAL NOT NULL);");
        }
    }
}

 

DBHelper에서는 아래처럼 DBMigration.migrate() 호출만 하면 된다.

@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
    DBMigration.migrate(db, oldVersion, newVersion);
}

 

2️⃣ Room Database 사용 고려 (장기적으로 가장 효율적인 방법)

Room을 사용하면 자동으로 버전 변경을 감지하고 Migration을 쉽게 관리할 수 있다.

@Database(entities = {MapMemoItem.class}, version = 2)
public abstract class AppDatabase extends RoomDatabase {
    public abstract MapMemoDao mapMemoDao();
}

 

✅ 고민과 결론

  • 현재만 수정이 이루어졌고 이후에 또 추가 기능이 언제 들어올지는 모른다. 그래서 사실 class로 빼지 않아도 된다. 또한 Room DB도 지금 저 버전 업 하나 때문에 교체하기에는 애매하다.
  • 결론적으로 다음에 DB 변경이 필요하게 되면 Room으로 아예 Migration 하여 관리하기 쉽게 해야겠다.
  • 현재는 방법만 알아두고 일단 MapMemo 기능 완성부터 진행하고자 한다.

🌟 진행 결과

  • MapMemo가 이제 DB에 저장되도록 수정되었다.
  • 테스트 코드 작성 후, SQLite 정상 동작 확인 완료했다.
  • Fragment 로직 적용: 저장 시 DB에 저장하고, 앱 재시작 시 DB에서 불러와 지도에 표시하도록 했다.

결과적으로, 이전에는 MapMemo가 유지되지 않았지만, 이제는 저장 후 다시 불러와도 유지되어 데이터 저장은 물론 Configuration change 대응도 된 것이다!

 

▼ MapMemo 데이터 유지O Configuration change 대응O