장고에서 for 루프 안에 transaction.on_commit으로 콜백을 등록할 때 인자가 언제 평가되는지를 놓쳐 생기는 버그와 그 해결법이다. 당첨자 10명에게 알림을 보내려던 코드가 마지막 사용자에게만 10번 보내는 상황을 예로 든다. 익명 함수의 매개변수 기본값으로 값을 붙잡아 해결한다.
핵심 포인트- on_commit에 등록한 콜백은 트랜잭션 커밋 후 등록된 순서대로 실행된다.
- 루프 변수를 그대로 참조하는 람다를 등록하면 실행 시점에는 루프가 끝나 마지막 값만 남아 있다.
- 결과적으로 열 명에게 한 번씩이 아니라 마지막 한 명에게 열 번 알림이 간다.
- on_commit은 매개변수 없는 함수를 받으며 호출할 때 인자를 넘겨주지 않는다.
- 람다의 매개변수 기본값으로 루프 변수를 받으면 그 시점의 값이 고정돼 의도대로 동작한다.
상세 정리- 쓰임새: 트랜잭션이 성공적으로 커밋된 경우에만 어떤 동작을 실행해야 할 때 on_commit에 콜백을 등록한다.
- 문제 코드: atomic 블록 안에서 추첨을 돌리고 당첨자 목록을 순회하며 알림 발송 람다를 등록하는 형태다.
- 증상: 당첨자마다 한 번씩 발송될 것으로 기대했지만 마지막 사용자 한 명에게만 열 번 발송된다.
- 원인: 등록 시점에는 콜백 안의 이름만 잡히고 값은 평가되지 않는다. 실제 실행은 순회가 모두 끝난 뒤이므로 그 이름이 가리키는 값은 마지막 사용자다.
- 트랜잭션 경계와의 관계: 등록은 atomic 블록 안에서 일어나지만 실행은 블록을 빠져나와 커밋이 확정된 뒤이므로 등록과 실행 사이에 시점 차이가 생긴다.
- 시그니처 제약: on_commit은 매개변수가 없는 함수를 받으므로 필수 매개변수를 가진 람다를 등록하는 방식은 쓸 수 없다.
- 풀어쓴 형태 1: 문제 코드의 콜백들을 함수로 펼치면 열 개의 함수가 모두 같은 외부 변수를 참조하는 모양이 된다. 실행 시점에 그 변수를 평가하므로 결과가 같아진다.
- 해결책: 람다의 매개변수에 기본값을 주는 방식으로 등록 시점의 값을 붙잡는다.
- 풀어쓴 형태 2: 이렇게 하면 각 함수가 첫 번째 사용자, 두 번째 사용자처럼 서로 다른 기본값을 갖게 된다.
- 동작 근거: 기본값이 정의된 함수는 인자를 넘기지 않고 호출해도 되므로 on_commit이 인자 없이 불러도 각자의 기본값을 쓴다.
- 발견 난도: 등록 코드만 보면 사용자 수만큼 콜백이 생기므로 정상으로 보이고, 실제 발송 결과를 확인해야 드러난다.
- 적용 범위: on_commit에 한정된 함정이 아니라 루프 안에서 콜백을 만들어 나중에 실행하는 모든 상황에 해당한다.
- 예시 시나리오: 추첨 이벤트에서 열 명을 뽑고 당첨자에게 안내를 보내야 하는 상황을 가정한다. 커밋이 확정된 뒤에만 알림이 나가야 하므로 on_commit이 적합한 자리다.
- 대안 형태의 한계: 콜백 자리에 사용자를 필수 매개변수로 둔 람다를 넣는 방법이 먼저 떠오르지만, on_commit이 인자를 넘기지 않으므로 이 방식은 성립하지 않는다.
- 등록 순서: 콜백은 등록한 순서대로 실행되므로 순서에 의존하는 후처리도 같은 방식으로 이어 붙일 수 있다.
왜 읽나커밋 후 알림이나 후처리를 루프로 등록했는데 마지막 건만 반복 실행된 적이 있다면 원인과 한 줄 수정이 여기 있다.