때로는 예상치 못한 상황과 통제할 수 없는 외부 요인들이 터무니없이 비정통적인 요구사항을 만들어냅니다. 최근 저는 데이터베이스 ID에 전혀 의존하지 않고 ActiveRecord를 사용해야 하는 경험을 했습니다. 혹시 같은 일을 고려하고 계신다면, 솔직히 말씀드리면 다른 방법을 찾아보시길 강력히 권합니다! 하지만 어쩔 수 없는 사정이라면, 이 글이 도움이 될 것입니다. 이야기를 계속 이어가 보겠습니다.
결정은 이미 내려진 상태였습니다. 구조는 같지만 데이터는 서로 다른 여러 개의 작은 데이터베이스를 하나로 통합해야 했습니다. 제가 프로젝트에 합류했을 때, 팀은 한 데이터베이스에서 다른 데이터베이스로 레코드를 옮기는 스크립트의 마무리 작업을 진행 중이었는데, 그 스크립트는 ID까지 포함해 모든 것을 있는 그대로 복사하고 있었습니다.
데이터베이스 A
| id | fruit | user_id |
|---|---|---|
| ... | ... | ... |
| 123 | orange | 456 |
| ... | ... | ... |
데이터베이스 B
| id | fruit | user_id |
|---|---|---|
| ... | ... | ... |
| 123 | banana | 74 |
| ... | ... | ... |
병합 후의 데이터베이스 A
| id | fruit | user_id |
|---|---|---|
| ... | ... | ... |
| 123 | orange | 456 |
| 123 | banana | 74 |
| ... | ... | ... |
보시다시피 이것은 ID가 존재하는 근본적인 이유, 즉 고유한 식별을 무너뜨립니다. 세부적인 문제점까지는 알지 못했지만, 중복 ID가 시스템에 들어오는 순간 각종 문제가 쏟아질 것이라는 직감이 들었습니다. 뭔가 말하고 싶었지만, 막 프로젝트에 합류한 신입 입장에서 다른 팀원들은 이것이 최선의 방향이라고 확신하는 분위기였습니다. 며칠 후면 코드를 배포하고 중복 ID가 섞인 데이터를 다루게 될 예정이었습니다. 질문은 더 이상 "이걸 해도 되는가?"가 아니라 "어떻게 해야 하는가?", "얼마나 더 걸리는가?"로 바뀌어 있었습니다.
중복 ID를 가진 데이터 다루기
그렇다면 중복 ID가 있는 데이터는 어떻게 처리해야 할까요? 해결책은 여러 필드를 조합한 복합 ID(composite ID)를 만드는 것이었습니다. 대부분의 DB 조회 코드는 다음과 같이 수정되었습니다:
# 이건 동작하지 않음 — id가 123인 유저가 2명일 수 있음
FavoriteFruit.find(123)
# 여러 조건으로 조회 범위를 좁혀 올바른 레코드를 찾음
FavoriteFruit.find_by(id: 123, user_id: 456)
모든 ActiveRecord 호출이 이런 식으로 수정되었고, 코드를 훑어보았을 때는 문제가 없어 보였습니다. 배포하기 전까지는요.
모든 것이 무너지다
배포 직후 전화벨이 울리기 시작했습니다. 고객들은 맞지 않는 숫자들을 보고 있었고, 자신의 레코드를 수정할 수도 없었습니다. 각종 기능들이 줄줄이 고장 나기 시작한 것입니다.
어떻게 해야 할까요? 우리는 단순히 코드만 배포한 게 아니라 한 데이터베이스에서 다른 데이터베이스로 데이터도 옮겼고, 배포 이후에는 새로운 데이터도 생성·수정되고 있었습니다. 단순 롤백으로 해결될 상황이 아니었던 것입니다. 빠르게 문제를 고쳐야 했습니다.
Rails는 대체 무슨 짓을 하고 있나?
디버깅의 첫 단계는 현재 동작을 확인하고 오류를 재현하는 것이었습니다. 저는 운영 데이터의 클론을 만들어 Rails 콘솔을 실행했습니다. 설정에 따라 ActiveRecord 쿼리를 실행할 때 Rails가 날리는 SQL 쿼리가 자동으로 보이지 않을 수 있습니다. 콘솔에서 SQL 문장을 확실히 보려면 다음과 같이 설정하세요:
ActiveRecord::Base.logger = Logger.new(STDOUT)
그다음 몇 가지 흔한 Rails 쿼리를 시도해 보았습니다:
$ FavoriteFruit.find_by(id: 123, user_id: 456)
FavoriteFruit Load (0.6ms)
SELECT "favorite_fruits".*
FROM "favorite_fruits"
WHERE "favorite_fruits"."id" = $1
AND "favorite_fruits"."user_id" = $2
[["id", "123"], ["user_id", "456"]]
find_by는 잘 동작하는 것 같았습니다. 하지만 곧 이런 코드를 발견했습니다:
fruit = FavoriteFruit.find_by(id: 123, user_id: 456)
...
...
fruit.reload
reload가 눈에 걸려 바로 테스트해 보았습니다:
$ fruit.reload
FavoriteFruit Load (0.3ms)
SELECT "favorite_fruits".*
FROM "favorite_fruits"
WHERE "favorite_fruits"."id" = $1
LIMIT $2
[["id", 123], ["LIMIT", 1]]
이런, 큰일입니다. 처음에 find_by로 올바른 레코드를 가져왔더라도, reload를 호출하는 순간 해당 레코드의 ID만으로 단순 find-by-id 쿼리를 날립니다. 당연히 중복 ID 때문에 잘못된 데이터를 가져올 때가 많았습니다.
왜 이런 동작을 하는 걸까요? 답을 찾기 위해 Rails 소스 코드를 들여다보았습니다. Ruby on Rails 코딩의 훌륭한 점 중 하나는 소스 코드가 순수 Ruby로 작성되어 누구나 자유롭게 열람할 수 있다는 것입니다. "ActiveRecord reload"를 검색하자 금방 이런 코드를 찾았습니다:
# File activerecord/lib/active_record/persistence.rb, line 602
def reload(options = nil)
self.class.connection.clear_query_cache
fresh_object =
if options && options[:lock]
self.class.unscoped { self.class.lock(options[:lock]).find(id) }
else
self.class.unscoped { self.class.find(id) }
end
@attributes = fresh_object.instance_variable_get("@attributes")
@new_record = false
self
end
이 코드가 보여주듯 reload는 사실상 self.class.find(id)의 래퍼(wrapper)입니다. ID만으로 조회하는 동작이 메서드에 하드코딩되어 있었던 것입니다. 중복 ID 환경에서 작업하려면 Rails 핵심 메서드를 오버라이드하거나(절대 권장하지 않음) 아예 reload를 사용하지 않는 수밖에 없었습니다.
우리의 임시방편
그래서 우리는 코드베이스의 모든 reload를 찾아내 find_by로 교체해, 여러 키를 기준으로 DB를 조회하도록 변경했습니다.
하지만 이것으로 해결된 버그는 일부분이었습니다. 더 파고든 끝에 update 호출도 테스트해 보기로 했습니다:
$ fruit = FavoriteFruit.find_by(id: 123, user_id: 456)
$ fruit.update(last_eaten: Time.now)
FavoriteFruit Update (43.3ms)
UPDATE "favorite_fruits"
SET "last_eaten" = $1
WHERE "favorite_fruits"."id" = $2
[["updated_at", "2020-04-16 06:24:57.989195"], ["id", 123]]
또 문제입니다. find_by가 특정 필드로 조회 범위를 좁혔더라도, Rails 레코드에 update를 호출하면 단순한 WHERE id = x 쿼리가 생성됩니다. 역시 중복 ID 앞에서는 깨지는 동작입니다. 어떻게 우회했을까요?
커스텀 업데이트 메서드 update_unique를 만들었습니다:
class FavoriteFruit
def update_unique(attributes)
run_callbacks :save do
self.class
.where(id: id, user_id: user_id)
.update_all(attributes)
end
self.class.find_by(id: id, user_id: user_id)
end
end
이제 ID뿐 아니라 여러 조건으로 범위를 좁혀 레코드를 수정할 수 있습니다:
$ fruit.update_unique(last_eaten: Time.now)
FavoriteFruit Update All (3.2ms)
UPDATE "favorite_fruits"
SET "last_eaten" = '2020-04-16 06:24:57.989195'
WHERE "favorite_fruits"."id" = $1
AND "favorite_fruits"."user_id" = $2
[["id", "123"], ["user_id", "456"]]
이 코드는 레코드 수정 범위를 확실히 좁혀 주지만, 클래스의 update_all 메서드를 호출하는 탓에 평소 업데이트 시 함께 실행되던 콜백(callbacks)이 사라집니다. 따라서 콜백을 수동으로 실행해야 했고, update_all은 수정된 레코드를 반환하지 않기 때문에 갱신된 레코드를 다시 가져오는 추가 DB 호출도 필요했습니다. 최종 결과물이 너무 지저분한 건 아니지만, fruit.update 한 줄에 비하면 확실히 읽기 어려워졌습니다.
진짜 해결책
매몰비용(sunk cost), 경영진의 압박, 촉박한 일정 등의 이유로 우리의 최종 선택은 Rails를 몽키패치(monkey patch)해서 모든 DB 호출이 여러 키를 사용하도록 강제하는 것이었습니다. 고객이 여전히 제품을 구매하고 사용한다는 점에서 '동작은' 했지만, 여러 가지 이유로 나쁜 결정이었습니다:
- 숨은 버그의 재발 가능성: 이후 개발 과정에서 흔한 Rails 메서드를 사용하다가 의도치 않게 버그를 다시 도입할 수 있습니다. 새로 합류하는 개발자에게는
reload같은 메서드를 쓰지 않도록 엄격한 교육이 필요합니다. - 유지보수성 저하: 코드는 더 복잡해지고 명확성은 떨어집니다. 이는 프로젝트가 진행될수록 개발 속도를 점점 더 늦추는 기술 부채(technical debt)입니다.
- 테스트 비용 증가: 함수가 동작하는지만 확인해서는 안 되고, 다양한 객체가 중복 ID를 가질 때도 동작하는지 검증해야 합니다. 테스트 작성에 더 많은 시간이 들고, 매번 테스트 스위트를 돌릴 때마다 추가된 테스트를 모두 실행하는 데 더 많은 시간이 소요됩니다. 게다가 각 개발자가 모든 가능한 시나리오를 꼼꼼히 테스트하지 않으면 버그를 놓치기 쉽습니다.
이 문제의 진짜 해결책은 애초에 중복 ID를 만들지 않는 것입니다. 데이터를 한 데이터베이스에서 다른 데이터베이스로 옮겨야 한다면, 이전 스크립트가 ID를 제외한 데이터만 수집·삽입하고, 받는 쪽 데이터베이스가 표준화된 auto-increment 카운터로 각 레코드에 고유한 ID를 부여하도록 해야 합니다.
또 다른 해결책은 모든 레코드에 UUID를 사용하는 것입니다. UUID는 정수 ID처럼 순차적으로 증가하는 값이 아니라 무작위로 생성된 긴 문자열입니다. 이 방식을 쓰면 데이터를 다른 데이터베이스로 옮길 때 충돌이나 문제가 발생하지 않습니다.
핵심을 정리하면 이렇습니다. Rails는 레코드당 ID가 고유하며, 특정 데이터를 빠르고 쉽게 조작하는 수단이라는 전제 위에 설계되었습니다. Rails는 독선적(opinionated) 프레임워크이며, 그 아름다움은 Rails의 방식대로만 일한다면 모든 것이 매끄럽게 굴러간다는 데 있습니다. 이는 Rails뿐 아니라 프로그래밍의 여러 다른 영역에도 적용되는 원칙입니다. 문제가 복잡해졌을 때 원인을 식별하는 능력도 중요하지만, 명확하고 유지보수 가능하며 관례를 따르는 코드를 작성한다면 애초에 이런 복잡함을 상당 부분 피할 수 있습니다.