[history] Migrate to sql::Statement time accessors

IN REVIEW2026components/historysqlrefactor
2026. 9. 9.jmsmg 프로필 이미지jmsmg

history 데이터베이스 세 파일이 손으로 하던 base::Time/base::TimeDelta ↔ int64 변환 10곳을 sql::Statement의 전용 접근자(ColumnTime()·ColumnTimeDelta()·BindTimeDelta())로 옮겼습니다(crbug.com/40176243, crbug.com/40251269). 이미 Fixed로 닫혀 있는 이슈를 다시 열어본 데서 시작한 작업이고, 12파일 32곳짜리 마이그레이션을 OWNER 그룹 단위로 쪼갠 시리즈의 첫 CL입니다.

문제 설명

  • issue url: https://crbug.com/40176243 (Time) · https://crbug.com/40251269 (TimeDelta)

  • sql::Statement는 시간 컬럼을 읽고 쓰는 전용 접근자를 제공하고, 헤더가 직접 "이 스니펫들을 대체하라"고 지시합니다:

    [sql/statement.h] This is equivalent to the following snippets, which should be replaced.
    base::Time::FromInternalValue(ColumnInt64(col)) /
    base::Time::FromDeltaSinceWindowsEpoch(base::Microseconds(ColumnInt64(col)))
    TODO(crbug.com/40176243): Migrate all time serialization to this method, and then remove the migration details above.

  • 2026년 1월 CL 7455767이 이 마이그레이션의 쓰기 쪽 일부를 이행하고 Fixed:로 두 이슈를 닫았습니다. 그런데 이슈 제목 자체가 "Migrate to BindTime() and ColumnTime()" 으로 읽기까지 범위였고, 읽기 쪽과 여러 줄에 걸친 쓰기 패턴은 그대로 남았습니다. 헤더의 TODO 두 개도 ToT에 살아 있습니다.

  • 그래서 history 컴포넌트 안에서 읽기와 쓰기가 비대칭인 상태가 됐습니다. 같은 컬럼을 한쪽에서는 접근자로 쓰고 다른 쪽에서는 손으로 변환해 읽습니다.

    • download_database.cc — 쓰기는 BindTime()(610·613·692행)인데 읽기 3곳(468·479·482)은 base::Time::FromInternalValue(ColumnInt64())
    • keyword_search_term.cc:29last_visit_timeurl_database.ccBindTime()으로 쓰고, 여기서는 손으로 읽음
    • visit_annotations_database.cc — 같은 파일 900행은 BindTime()인데 330·333·412·415행은 BindInt64(N, delta.InMicroseconds())

해결 내용

세 가지 패턴을 각각 대응하는 접근자로 바꿨습니다. 총 10곳 / 3파일, +14/−18.

// 읽기 (base::Time) — DownloadDatabase::QueryDownloads(), KeywordSearchTermVisitFromStatement()
- info->start_time =
-     base::Time::FromInternalValue(statement_main.ColumnInt64(column++));
+ info->start_time = statement_main.ColumnTime(column++);

// 읽기 (base::TimeDelta) — VisitAnnotationsDatabase::GetContextAnnotationsForVisit()
-     statement.ColumnInt64(1), base::Microseconds(statement.ColumnInt64(2)),
+     statement.ColumnInt64(1), statement.ColumnTimeDelta(2),

// 쓰기 (base::TimeDelta) — Add/UpdateContextAnnotationsForVisit()
- statement.BindInt64(
-     2, visit_context_annotations.duration_since_last_visit.InMicroseconds());
+ statement.BindTimeDelta(2, visit_context_annotations.duration_since_last_visit);

바뀌는 것은 호출자가 직렬화 규약을 아는가입니다. 지금까지는 "이 컬럼의 int64는 Windows epoch 기준 마이크로초"라는 지식이 호출자 여섯 함수에 흩어져 있었고, 이제 그 지식은 sql::Statement 안에만 있습니다.

변환은 바이트 단위로 동일합니다. base::Time의 internal value가 곧 Windows epoch 기준 마이크로초이고, ColumnTime()FromDeltaSinceWindowsEpoch(Microseconds(int64))로 정확히 그것을 복원합니다(sql/statement.cc). ColumnTimeDelta()Microseconds(int64), BindTimeDelta()InMicroseconds()입니다. 저장 포맷·스키마·마이그레이션은 그대로입니다.

Fixed:가 아닌 Bug: 40176243, 40251269를 썼습니다. 남은 22곳이 다른 컴포넌트에 있고, 헤더의 TODO는 시리즈 마지막 CL에서 지울 예정입니다.

발굴 과정

트래커 검색으로는 나오지 않는 종류의 작업이었습니다. 이슈가 닫혀 있기 때문입니다.

  1. TODO(crbug.com/sql/ 을 훑어 statement.h의 TODO 두 개를 찾고, 거기서 이슈 번호를 역추적했습니다.

  2. 이슈는 Fixed였지만 TODO는 코드에 남아 있었습니다. 선례 CL을 열어 실제 범위를 확인하니 쓰기 일부만 이행돼 있었습니다.

  3. 트리 전체를 다시 스캔했습니다. 이때 선례 CL의 grep이 놓친 패턴이 드러났습니다 — 한 줄짜리 BindInt64(N, x.ToInternalValue())만 잡고, 아래처럼 인자가 다음 줄로 넘어간 경우를 놓친 것입니다:

    statement.BindInt64(
        2, visit_context_annotations.duration_since_last_visit.InMicroseconds());

    결과적으로 읽기 23곳 + 쓰기 9곳 = 32곳 / 12파일이 남아 있었습니다.

  4. 12파일이 8개 OWNERS 그룹에 걸쳐 있어 한 CL로 묶으면 승인자가 여덟 명이 됩니다. 컴포넌트 단위로 쪼개고, 그중 components/history/OWNERS 한 그룹으로 끝나는 세 파일을 첫 CL로 삼았습니다. 같은 history 안에서도 journeys/는 OWNERS가 따로 갈리고 그 파일에 열린 CL이 있어 다음 CL로 분리했습니다.

리뷰어는 components/history/OWNERS이면서 선례 CL 7455767에 Code-Review+1을 준 사람으로 정했습니다. 같은 마이그레이션의 나머지 절반이라는 맥락이 그대로 이어집니다.

테스트 방법

동작 불변 리팩토링이라 새 테스트 없이 기존 테스트 전원 통과가 검증입니다.

  • components_unittests --gtest_filter='HistoryBackendDBTest.*:URLDatabaseTest.*:VisitAnnotationsDatabaseTest.*:HistoryBackendTest.*:HistoryServiceTest.*' 281/281 통과
  • 빌드는 Build Succeeded 30,065스텝 / 12시간 54분이 걸렸습니다. 변경은 3파일인데 직전 브랜치가 base/ 헤더를 건드린 CL이어서, 그 헤더가 원상복구되며 base/check.h를 include하는 트리 전체가 무효화된 탓입니다.

배운 점

  • Fixed로 닫힌 이슈가 끝난 이슈는 아닙니다. 여기서는 이슈 제목이 이행되지 않은 범위(ColumnTime())를 그대로 담고 있었고 헤더의 TODO도 살아 있었습니다. 닫힌 번호를 Bug:로 참조하는 것은 허용되므로, 재오픈을 기다리며 멈추는 대신 CL 설명에 선례 CL을 part 1로 인용하고 남은 범위를 적었습니다.
  • 정규식 한 줄로 훑은 마이그레이션은 대개 여러 줄 패턴을 놓칩니다. 선례 CL이 남긴 9곳이 전부 인자가 줄바꿈된 형태였습니다. 남은 작업을 셀 때는 포매터가 어떻게 줄을 나눴을지까지 감안해 다시 스캔하는 편이 정확합니다.
  • 리팩토링의 범위는 코드량이 아니라 OWNERS 경계로 정합니다. 32곳을 한 번에 올리면 diff는 작아도 승인 여덟 개를 모아야 합니다. 컴포넌트 단위로 쪼개면 각 CL이 리뷰어 한 명으로 끝납니다.
  • "동일하다"는 주장은 구현으로 확인해야 합니다. FromInternalValueColumnTime()이 같다는 것을 문서가 아니라 sql/statement.cc의 실제 코드로 확인했고, 그 근거를 CL 설명에 적었습니다. 저장 포맷을 건드리는 것처럼 보이는 변경일수록 리뷰어가 가장 먼저 확인하고 싶어 하는 지점입니다.

참고 자료