Git worktree를 쓰면 여러 branch를 동시에 다루기 편하다.
각 checkout은 source tree가 분리되고, 서로 다른 branch에서 작업해도 working tree가 섞이지 않는다.
그래서 어느 순간부터 자연스럽게 이렇게 생각하기 쉽다.
worktree separated
→ environment separated
하지만 PhotoGram에서 실제로 회원가입이 500으로 깨진 사건을 따라가 보니, 이 등식은 틀렸다.
source checkout은 분리돼 있었지만 local MariaDB는 여러 checkout이 함께 사용하고 있었다.
문제는 그 사이에서 생겼다.
main에는 없는 컬럼 때문에 main의 INSERT가 실패했다
디자인 export용 테스트 계정을 만들기 위해 실제 register → login 흐름을 실행했다.
그런데 회원가입 INSERT가 실패했다.
MariaDB가 요구한 필드는 다음 두 개였다.
account_settings.metadata_processing_consent
account_settings.place_exposure_consent
둘 다 NOT NULL, NO DEFAULT 상태였다.
이상한 점은 현재 main source 어디에도 이 두 컬럼이 없었다는 것이다.
entity에도 없고 canonical migration에도 없었다.
즉 main이 기대하는 schema와 local DB의 실제 schema가 달랐다.
MAIN SOURCE
!=
LOCAL DATABASE STATE
원인은 다른 worktree의 ddl-auto update였다
이 두 필드는 우연한 이름이 아니었다.
같은 시기에 작업하던 feature/place-v1에는 정확히 metadataProcessingConsent와 placeExposureConsent라는 정책이 존재했다.
그리고 local 개발환경에서는 Hibernate ddl-auto:update가 사용될 수 있었다.
가능한 흐름은 단순했다.
feature/place-v1 checkout
→ shared local MariaDB
→ Hibernate ddl-auto:update
→ two columns added
later
main checkout
→ same shared MariaDB
→ entity does not know those columns
→ INSERT omits them
→ DB rejects INSERT
branch를 바꾼 것은 source뿐이었다.
DB는 branch와 함께 과거 상태로 돌아가지 않았다.
Worktree isolation은 storage isolation이 아니다
이 사건에서 가장 중요한 부분은 Hibernate 자체보다 격리의 범위를 잘못 생각한 것이었다.
WORKTREE
= source checkout isolation
WORKTREE
!= database isolation
!= Redis isolation
!= object storage isolation
!= external service isolation
source가 서로 깨끗하게 분리돼 있어도 둘이 같은 stateful dependency를 바라보면 한 checkout이 남긴 mutation이 다른 checkout으로 넘어간다.
특히 schema는 persistent state라 더 위험하다.
branch lifecycle
!= database lifecycle
checkout을 삭제하거나 branch를 바꿔도 ALTER TABLE은 자동으로 되돌아가지 않는다.
가장 쉬운 잘못된 수리는 main을 DB drift에 맞추는 것이다
에러만 보면 몇 가지 즉각적인 우회가 떠오른다.
1. main entity에 두 필드를 다시 추가
2. DB column에 default 추가
3. compatibility migration 작성
4. signup INSERT를 수정
그러면 당장의 500은 없어질 수 있다.
하지만 이번 local DB는 production data가 아니었다.
개발/테스트용 disposable state였고, 두 컬럼은 현재 main contract에 존재하지 않았다.
그렇다면 이 상황에서 DB가 source of truth가 될 이유가 없다.
STALE LOCAL STATE
MUST NOT
FORCE CURRENT SOURCE COMPATIBILITY
현재 contract에 없는 과거 checkout의 흔적을 보존하기 위해 main에 compatibility code를 추가하면 오히려 잘못된 schema history를 canonical source로 승격시키게 된다.
그래서 데이터 보존이 아니라 drift 제거를 선택했다
이번 local DB는 테스트 데이터이고 해당 컬럼을 보존해야 할 production constraint가 없었다.
따라서 수리는 local schema만 대상으로 했다.
ALTER TABLE account_settings
DROP COLUMN IF EXISTS metadata_processing_consent,
DROP COLUMN IF EXISTS place_exposure_consent;
그 뒤 schema를 다시 확인했고 실제 register → login flow를 재실행했다.
회원가입은 정상적으로 성공했다.
중요한 점은 이번 수리에서 하지 않은 일이다.
NO backend source change
NO entity compatibility field
NO new Flyway migration
NO arbitrary default
NO signup logic workaround
문제의 위치가 local persistent state였기 때문에 그 state만 복구했다.
데이터가 있으니 보존도 항상 맞는 판단은 아니다
database incident를 만나면 보수적으로 데이터를 보존해야 한다는 판단이 쉽게 나온다.
production에서는 당연히 중요하다.
하지만 모든 DB가 같은 authority를 갖는 것은 아니다.
이번 경우에는:
LOCAL DB
= development/test state
rogue columns
= obsolete checkout mutation
current main
= desired schema contract for this run
이었다.
따라서 혹시 필요할 수 있으니 남긴다는 판단은 오히려 현재 source를 과거 local drift에 종속시키는 결과가 된다.
필요한 것은 무조건 보존이 아니라 그 데이터가 무엇의 authority인지 먼저 확인하는 일이었다.
더 위험한 경우는 branch마다 schema가 실제로 다를 때다
이번에는 main에 맞춰 local drift를 제거하면 됐다.
하지만 같은 shared DB를 사용하면서 두 active branch가 서로 다른 schema를 요구한다면 상황은 더 복잡해진다.
main
→ schema A
feature branch
→ schema A + new required fields
both checkouts
→ same MariaDB
이면 어느 한쪽에서 ddl-auto:update를 실행하는 순간 다른 쪽의 runtime assumption이 깨질 수 있다.
이 경우 해결책은 compatibility code를 마구 추가하는 것이 아니라 환경 자체를 나누는 쪽에 가깝다.
per-branch DB
or
explicit reset/recreate
or
migration-controlled disposable environment
핵심은 source checkout과 stateful dependency의 lifecycle을 같이 생각하는 것이다.
ddl-auto:update는 branch-aware하지 않다
Hibernate는 현재 실행 중인 entity mapping과 DB를 비교한다.
Git branch 의미를 알지 못한다.
Hibernate sees:
current entity model
+
current database
Hibernate does not see:
this column came from another worktree yesterday
따라서 개발환경에서 편리한 ddl-auto:update는 shared DB와 결합될 때 의도치 않은 cross-branch mutation surface가 된다.
이 자체가 항상 나쁘다는 뜻은 아니다.
하지만 다음 가정은 위험하다.
git checkout changed
→ runtime state also reverted
그런 자동 rollback은 없다.
이번 사건에서 배운 건 schema migration보다 environment lineage였다
처음에는 단순한 회원가입 오류였다.
실제로는 다음 세 lifecycle이 따로 움직이고 있었다.
Git branch/worktree lifecycle
Database schema lifecycle
Test data lifecycle
문제는 이 셋을 같은 것으로 취급했을 때 생겼다.
결론은 단순하다.
SOURCE ISOLATION
!= STATE ISOLATION
CLEAN GIT TREE
!= CLEAN RUNTIME ENVIRONMENT
worktree를 많이 사용하는 개발환경이라면 현재 어떤 branch인가만큼 이 checkout이 어떤 persistent state를 공유하고 있는가도 확인해야 한다.
그리고 drift가 발생했을 때는 과거 state를 현재 source에 억지로 맞추기보다, 어느 쪽이 이번 작업의 authority인지 먼저 결정해야 한다.
이번 PhotoGram에서는 답이 명확했다.
main이 요구하지 않는 disposable local schema mutation은 보존 대상이 아니었다.