Loading the catalog…
Loading the catalog…
들어가며 지난 글 에서 AI가 저장 요청을 보낼 때 일회용 토큰 만 받게 만든 얘기를 했다. 그때 이런 문장을 슬쩍 쓰고 넘어갔다. 동시에 두 요청이 와도 이 갱신에 성공하는 건 하나뿐이다 쓰면서도 "이거 한 줄로 넘어갈 얘기가 아닌데..." 싶었다. 사실 이 한 줄을 제대로 만들기까지 두 번 틀렸다. 오늘은 그 얘기다 🤔 저장 버튼을 두 번 누르면❓ AI가 같은 토큰으로 저장을 두 번 불렀다. 네트워크가 느려서 다시 보냈을 수도 있고, 그냥 두 번 불렀을 수도 있다. 똑같은 콘텐츠가 두 벌 생긴다. 🫠 "그거야 처리 중이라고 표시해두면 되지" 싶어서 처음 떠올린 코드는 이랬다. // 이러면 안 된다 const processing = new Set(); async function apply(token) { if (processing.has(token)) return; // 처리 중이면 무시 processing.add(token); await write(token); } 혼자 테스트하면 잘 된다. 그런데 이건 우리 서버에서는 안 통한다. 첫 번째로 틀린 것: 메모리는 프로세스마다 따로다 우리 API 서버는 프로세스 여러 개로 돌아간다. 요청이 올 때마다 그중 하나가 받는다. 첫 번째 저장 → 1번 프로세스가 받음. 자기 메모리에 "처리 중" 적음 두 번째 저장 → 2번 프로세스 가 받음. 자기 메모리엔 아무것도 없음. 통과! 1번이 적어둔 걸 2번은 볼 수 없다. 메모리는 프로세스마다 따로니까. 프로세스가 여러 개면 "메모리에 적어두기"는 잠금이 아니다. 그러면 모두가 같이 보는 곳에 적어야 한다. DB다. 두 번째로 틀린 것: "확인하고 적기" 그래서 이렇게 고쳤다. // 이것도 안 된다 const doc = await tokens.findOne({ token }); if (doc.status !== 'issued') return; // 확인하고 await tokens.updateOne({ token }, { $set: { status: 'reserved' } }); // 적는다 await write(doc); DB에 적으니까 이제 되겠지 했는데, 확인과 적기 사이에 틈 이 있었다. 두 프로세스가 거의 동시에 findOne 을 하면 둘 다 "아직 안 썼네?" 하고 통과한다. 그리고 둘 다 저장한다. DB에 적는다고 끝이 아니었다;;; 해결: 확인과 적기를 한 번에 ✍️ "아직 안 쓴 토큰일 때만 예약됨으로 바꿔라" 를 DB에 한 번의 명령으로 시킨다. // 글을 위해 단순하게 다시 쓴 코드 const doc = await tokens.findOneAndUpdate( { token, status: 'issued' }, // 아직 안 쓴 토큰일 때만 { $set: { status: 'reserved' } } ); if (!doc) return; // 누가 먼저 바꿨다. 나는 손 뗀다 await write(doc); DB는 이 명령을 한 덩어리로 처리한다. 두 프로세스가 동시에 보내도 성공하는 건 하나뿐 이다. 바꾸는 데 성공한 프로세스만 일하고, 실패한 쪽은 조용히 물러난다. 이게 조건부 갱신 이다. 별거 아닌 것 같은데 이 한 줄이 잠금 역할을 한다. 그런데 "없는 걸 만드는" 건 못 막는다 조건부 갱신은 이미 있는 문서 의 상태를 바꿀 때 쓴다. 그런데 문서를 새로 만드는 경우에는 걸 조건이 없다. 아직 없으니까. 이런 경우가 있었다. 운영자가 계정을 정지시키는 기능인데, 한 계정에 유효한 정지는 하나만 있어야 한다. 운영자 두 명이 같은 계정을 동시에 정지시키면? "이미 정지돼 있는지 확인하고 → 없으면 만든다"로 짜면 위에서 본 것과 똑같은 틈이 생긴다. 둘 다 "없네?" 하고 둘 다 만든다. 이건 unique 인덱스 로 막았다. "같은 계정에 유효한 정지는 하나만" 이라고 DB에 못 박아 두는 거다. // 유효한 정지에만 unique 가 걸리게 한다 (글을 위해 단순하게 다시 쓴 코드) sanctions.createIndex( { userId: 1 }, { unique: true, partialFilterExpression: { active: true } } ); 두 번째로 만들려고 하면 DB가 거절한다. 내 코드가 확인을 빠뜨려도 DB가 막아준다. 끝난 정지는 대상에서 빠지니까, 나중에 같은 계정을 다시 정지시키는 건 된다. 쥐고 죽으면❓ 마지막 함정. 상태를 "내가 처리 중"으로 바꿔놓고 프로세스가 죽으면 어떻게 될까. 콘텐츠 출시 에서는 출시가 동시에 두 번 돌지 않게 잠금을 건다. 그런데 잠금을 쥔 채 서버가 죽으면 그 콘텐츠는 영영 출시를 못 한다. 풀어줄 놈이 죽었으니까. 메모리에 적어뒀을 때는 프로세스가 죽으면 표시도 같이 사라졌다. DB에 적으니까 죽어도 남는다. 장점이 그대로 단점이 된 거다. 그래서 잠금에 유효 시간 을 줬다. // 잠금이 비어 있거나, 유효 시간이 지났을 때만 내 것으로 (글을 위해 단순하게 다시 쓴 코드) const now = new Date(); const locked = await games.findOneAndUpdate( { _id: gameId, $or: [{ lockUntil: null }, { lockUntil: { $lt: now } }] }, { $set: { lockUntil: new Date(now.getTime() + 5 * 60 * 1000) } } ); 5분이 지나면 다음 출시가 잠금을 가져갈 수 있다. 이것도 결국 조건부 갱신이다. 조건에 "시간이 지났으면"이 하나 더 붙었을 뿐. 알고 보니 계속 같은 걸 쓰고 있었다 이 글을 쓰면서 돌아보니, 같은 방법을 여기저기서 쓰고 있었다. 어디서 조건 바꾸는 것 저장 토큰 토큰이 "발급됨"일 때만 "예약됨"으로 refresh 토큰 아직 안 쓴 토큰일 때만 "썼음"으로 출시 잠금 잠금이 비었거나 유효 시간이 지났을 때만 내 것으로 전부 모양이 같다. "상태를 바꾸는 데 성공한 놈만 일한다." 그리고 만드는 쪽은 unique 인덱스가 맡는다. 아쉬운 점 유효 시간을 얼마로 잡을지 는 여전히 어렵다. 짧게 잡으면 멀쩡히 일하고 있는 작업의 잠금을 뺏을 수 있고, 길게 잡으면 죽은 작업을 한참 기다려야 한다. 지금 5분은 "출시는 이 안에 끝난다"는 내 경험에 기댄 숫자다. 이 틈들은 혼자 테스트해서는 안 보인다. 일부러 동시에 요청을 보내는 테스트가 더 필요하다. 마무리 "한 번만 실행"은 혼자 테스트할 때는 문제가 안 보인다. 버튼을 두 번 눌러봐도 내 컴퓨터에선 프로세스가 하나니까. 서버가 여러 개가 되는 순간 갑자기 문제가 된다. 메모리에 적어두는 건 잠금이 아니다 확인하고 나서 적으면 그 사이에 틈이 생긴다 확인과 적기를 DB에 한 번에 시킨다 없는 걸 만드는 건 unique 인덱스로 막는다 쥐고 죽을 수 있으니 유효 시간을 둔다 거창한 분산 잠금 같은 걸 들이지 않고도, DB가 원래 해주는 것만으로 꽤 멀리 갈 수 있었다 👍👍👍
What RADAR observed and classified to build this opportunity. It is what the source published, not a verification that the offer is still active.
아하❓ (12) - 서버가 여러 개일 때 "딱 한 번만" 실행하기 (조건부 갱신). 들어가며 지난 글 에서 AI가 저장 요청을 보낼 때 일회용 토큰 만 받게 만든 얘기를 했다. 그때 이런 문장을 슬쩍 쓰고 넘어갔다. 동시에 두 요청이 와도 이 갱신에 성공하는 건 하나뿐이다 쓰면서도 "이거 한 줄로 넘어갈 얘기가 아닌데..." 싶었다. 사실 이 한 줄을 제대로 만들기까지 두 번 틀렸다. 오늘은 그 얘기다 🤔 저장 버튼을 두 번 누르면❓ AI가 같은 토큰으로 저장을 두 번 불렀다. 네트워크가 느려서 다시 보냈을 수도 있고, 그냥 두 번 불렀을 수도 있다. 똑같은 콘텐츠가 두 벌 생긴다. 🫠 "그거야 처리 중이라고 표시해두면 되지" 싶어서 처음 떠올린 코드는 이랬다. // 이러면 안 된다 const…
Open source