find_or_initialize_by vs create_or_find_by
RecordNotUnique가 터지는 이유 — 찾기와 만들기의 순서
두 탭에서 같은 버튼을 거의 동시에 누르면 ActiveRecord::RecordNotUnique가 터진다. Bugsnag에 이 에러가 찍혀 있으면 십중팔구 범인은 find_or_initialize_by나 find_or_create_by다.
왜 터지나
find_or_initialize_by는 SELECT를 먼저 날리고, 없으면 메모리에서 new만 한다. INSERT는 나중에 save를 부를 때 실행된다. 문제는 이 찾기와 저장 사이의 틈이다.
요청 A가 SELECT를 날린다. 없다. 요청 B도 SELECT를 날린다. A가 아직 INSERT 전이라 B도 없다고 본다. A의 INSERT가 성공하면, B의 INSERT는 유니크 인덱스에 걸려 죽는다.
find_or_create_by도 find → create 순서는 똑같아서 이 틈을 못 없앤다. bang(!)을 붙여도 순서는 안 바뀐다.
create_or_find_by는 순서를 뒤집는다
SELECT 없이 일단 INSERT를 시도한다. 이미 있어서 유니크 제약에 걸리면, 그 RecordNotUnique를 내부에서 rescue하고 SELECT로 기존 레코드를 찾아 돌려준다.
존재 판정을 애플리케이션의 SELECT 결과가 아니라 DB의 유니크 제약에 맡기는 것. 동시 요청이 와도 한쪽은 INSERT 성공, 다른 쪽은 기존 레코드를 받아가니 에러가 밖으로 새지 않는다.
전제가 하나 있다
검색 조건 컬럼 조합에 유니크 인덱스가 있어야 한다. 없으면 충돌 자체가 안 나서 중복 행이 조용히 쌓인다. 인덱스 없는 create_or_find_by는 그냥 create다.
고르는 기준은 단순하다. 폼에 뿌릴 저장 안 된 객체가 필요하면 find_or_initialize_by, 동시에 만들어질 수 있는 레코드를 안전하게 확보해야 하면 create_or_find_by.
세 메서드 비교
| 항목 | find_or_initialize_by | find_or_create_by | create_or_find_by |
|---|---|---|---|
| 순서 | SELECT → new | SELECT → INSERT | INSERT → (충돌 시) SELECT |
| 반환 시점 상태 | 없으면 미저장 new_record | persisted | persisted |
| 동시 요청 시 | save에서 RecordNotUnique | create에서 RecordNotUnique | 에러 없이 기존 레코드 반환 |
| 전제 | — | — | 유니크 인덱스 필수 |
레이스 컨디션 타임라인
탭 A: SELECT → 없음
탭 B: SELECT → 없음 (A가 아직 INSERT 전이라 B도 못 본다)
탭 A: INSERT → 성공
탭 B: INSERT → 유니크 인덱스 위반 → RecordNotUnique
create_or_find_by 내부는 사실상 이렇게 생겼다
def create_or_find_by(attrs)
transaction(requires_new: true) { create(attrs) }
rescue ActiveRecord::RecordNotUnique
find_by!(attrs)
end
requires_new: true가 savepoint를 만들기 때문에, 바깥에 이미 트랜잭션이 있어도 INSERT 실패가 전체를 오염시키지 않는다. PostgreSQL에서 특히 중요하다.
⚠ 유니크 인덱스가 없으면 무의미
충돌이 안 나면 rescue도 없다. 검색 조건 컬럼 조합에 유니크 인덱스를 먼저 걸어야 한다. 그리고 전달한 속성만으로 즉시 INSERT하므로, 다른 컬럼에 필수 검증이 있으면 그 시점에 실패한다는 것도 기억해 둘 것.
핵심 포인트
find_or_initialize_by는 SELECT 후 없으면 new — INSERT는 save 시점에 실행된다
동시 요청이면 둘 다 없음을 보고 둘 다 INSERT — 늦은 쪽이 RecordNotUnique로 죽는다
find_or_create_by(!)도 find → create 순서라 똑같이 터진다
create_or_find_by는 INSERT 먼저 — 유니크 제약에 걸리면 rescue하고 기존 레코드를 SELECT
전제: 검색 조건 컬럼에 유니크 인덱스 필수 — 없으면 중복이 조용히 쌓인다
transaction(requires_new: true) savepoint를 쓰므로 바깥 트랜잭션 안에서도 안전하다
장점
- ✓ 존재 판정을 DB 유니크 제약에 맡겨 동시 요청에도 에러가 안 샌다
- ✓ 반환 시점에 항상 persisted 상태가 보장된다
- ✓ savepoint(requires_new: true)라 바깥 트랜잭션 안에서도 안전
단점
- ✗ 유니크 인덱스가 없으면 그냥 create — 중복이 조용히 쌓인다
- ✗ 전달한 속성만으로 즉시 INSERT하므로 다른 필수 컬럼 검증에 걸릴 수 있다
- ✗ 충돌 직후 상대 트랜잭션이 롤백되면 find_by!가 못 찾는 드문 케이스가 있다