1. N+1 쿼리 문제와 성능 저하의 원인
Rails에서 연관된 모델의 개수를 세는 가장 직관적인 방법은 @qr_code.clicks.count와 같은 메서드를 호출하는 것입니다. 하지만 목록 페이지에서 수십 개의 레코드를 출력할 때 각 레코드마다 개별적인 COUNT(*) 쿼리가 실행되면서 데이터베이스에 엄청난 부하를 주게 됩니다. 이를 N+1 쿼리 문제라고 하며, 데이터가 늘어날수록 응답 시간은 기하급수적으로 늘어납니다.
2. Rails 표준 counter_cache 구현
Rails는 belongs_to 관계에서 counter_cache: true 옵션을 통해 이 문제를 간단히 해결할 수 있는 내장 기능을 제공합니다.
- 마이그레이션: 대상 테이블에 clicks_count와 같은 정수형 컬럼을 추가합니다.
- 모델 설정: Click 모델에서 belongs_to :link, counter_cache: true를 선언합니다.
- 동작 원리: 새로운 클릭이 생성되거나 삭제될 때 Rails가 자동으로 links 테이블의 clicks_count 컬럼을 업데이트합니다. 이후 카운트 조회 시 데이터베이스는 복잡한 집계 대신 단순한 정수 컬럼 값만 읽으면 되므로 속도가 매우 빠릅니다.
3. 조건부 집계를 위한 커스텀 카운터 캐시
표준 counter_cache는 연관된 모든 레코드를 세지만, 특정 조건(예: 소스가 ‘qr’인 클릭만 집계)이 필요한 경우에는 커스텀 로직이 필요합니다.
- ActiveRecord 콜백 활용: after_create 및 after_destroy 콜백을 사용하여 특정 조건이 충족될 때만 카운트 컬럼을 업데이트합니다.
- 원자적 업데이트(Atomic Updates): increment! 또는 decrement! 메서드를 사용하여 여러 프로세스가 동시에 접근할 때 발생할 수 있는 레이스 컨디션(Race Condition)을 방지하고 데이터 무결성을 보장합니다.
4. 성능 벤치마크 결과
실제 100개의 링크와 1,500개의 클릭 데이터를 대상으로 테스트한 결과는 다음과 같습니다. - Naive Approach (Direct Count): 약 5.234초 (100번의 COUNT 쿼리 발생) - Eager Loading (includes): 약 0.456초 (메모리 부하 발생) - Counter Cache: 약 0.003초 (단일 쿼리 및 정수 읽기)
카운터 캐시를 적용할 경우 단순 조회 방식보다 약 1,700배 이상의 성능 향상을 기대할 수 있으며, 이는 API 응답 시간을 획기적으로 단축시킵니다.
5. 데이터 무결성 및 운영 고려사항
- Backfilling: 카운터 캐시 도입 이전에 생성된 데이터는
find_each와update_column을 사용하여 수동으로 카운트를 맞춰줘야 합니다. - 벌크 작업 주의:
delete_all이나update_all같은 메서드는 콜백을 실행하지 않으므로, 카운터 캐시 값이 어긋날 수 있습니다. 이 경우reset_counters메서드를 통해 주기적으로 동기화해야 합니다. - 데이터베이스 제약 조건: 컬럼 생성 시
default: 0과null: false를 설정하여 예기치 않은 오류를 방지하는 것이 권장됩니다.