24시간이 지난 상품은 누가 자동으로 낙찰시켜줘야 한다. 근데 서버가 계속 켜져 있으리라는 보장이 없다. 이 문제를 어떻게 풀었는지가 이번 편의 전부다.
const targets = await Good.findAll({
where: { SoldId: null, createdAt: { [Op.lte]: yesterday } },
});
targets.forEach(async (good) => {
const t = await sequelize.transaction();
const success = await Auction.findOne({
where: { GoodId: good.id },
order: [["bid", "DESC"]],
transaction: t,
});
await good.setSold(success.UserId, { transaction: t });
await User.update(
{ money: sequelize.literal(`money - ${success.bid}`) },
{ where: { id: success.UserId }, transaction: t }
);
await t.commit();
});
서버가 꺼져있던 사이에 이미 24시간이 지나버린 상품들. 이런 애들은 스케줄을 걸 필요도 없이 서버 켜지자마자 바로 처리한다. 낙찰자 지정과 잔액 차감을 트랜잭션으로 묶은 이유는 간단하다 — 낙찰자는 지정됐는데 돈은 안 깎이거나, 그 반대가 되면 안 되니까.
const ongoing = await Good.findAll({
where: { SoldId: null, createdAt: { [Op.gte]: yesterday } },
});
ongoing.forEach((good) => {
const end = new Date(good.createdAt);
end.setDate(end.getDate() + 1);
const job = scheduleJob(end, async () => {
const success = await Auction.findOne({ where: { GoodId: good.id }, order: [["bid", "DESC"]] });
await good.setSold(success.UserId);
await User.update({ money: sequelize.literal(`money - ${success.bid}`) }, { where: { id: success.UserId } });
});
});
node-schedule의 scheduleJob은 특정 시각을 넘겨주면 그 시각에 딱 한 번 실행되는 잡을 만든다. 상품마다 등록 시각 + 24시간을 계산해서 각자의 낙찰 시각에 예약을 건다. 상품이 100개면 예약된 잡도 100개가 떠 있는 셈이다.
여기서 신경 쓰인 부분: 이 예약은 메모리에만 떠 있다. 서버가 죽으면 예약도 같이 날아간다. 그래서 위의 "재시작 시 밀린 것부터 처리"가 필요한 거다 — 서버가 다시 켜지면 그사이 놓친 낙찰들을 한 번 쓸어담고, 그다음부터 다시 예약을 건다. 이 두 로직이 짝을 이뤄야 어떤 상황에서도 낙찰이 새지 않는다.
sse.on("connection", (client) => {
setInterval(() => client.send(Date.now().toString()), 1_000);
});
Socket.IO 말고 SSE(Server-Sent Events)도 하나 붙여봤다. 얘는 서버 → 클라이언트 단방향이라 양방향 통신이 필요 없는 "그냥 흘려보내기만 하면 되는 데이터"에 잘 맞는다. 지금은 서버 시간을 1초마다 흘려보내는 정도로만 써봤는데, 실전이라면 남은 경매 시간 카운트다운 같은 데 쓰기 좋을 것 같다.
checkAuction(); // 서버 켜질 때 낙찰 스케줄러 시작
...
const server = app.listen(app.get("port"), () => { ... });
webSocket(server, app); // socket.io
sse(server, app); // sse
미들웨어, 라우터, 소켓, SSE, 스케줄러 — 따로따로 만들어둔 조각들을 여기서 순서대로 꽂는다. checkAuction()은 서버가 뜨자마자 호출해서 밀린 낙찰부터 처리하고, listen 이후에 소켓이랑 SSE를 붙인 건 둘 다 server 인스턴스가 필요해서다.
여기까지 하니 회원가입 → 상품 등록 → 실시간 입찰 → 자동 낙찰까지 한 바퀴가 다 돈다. Node.js 교과서 따라 만든 경매 사이트치고는 꽤 그럴싸하게 굴러간다.