๋ฌธ์ ์ํฉ
์คํ๋ง ๋ฐฐ์น ์๋น์ค์์ ์ฌ๋ฌ ์์ ๋์์ ์ค๋ ๋ ํ๋ก ๋ณ๋ ฌ ์ฒ๋ฆฌํ๊ณ , ๊ฐ ์ค๋ ๋๊ฐ ์๋ฃ ํ Telegram์ผ๋ก ๋ฐฐ์น ๊ฒฐ๊ณผ๋ฅผ ์๋ฆผํ๋ ๊ธฐ๋ฅ์ด ์๋ค.
ํ์์๋ ๋ฌธ์ ๊ฐ ์์๋๋ฐ, ๋จ๊ธฐ๊ฐ(1~2์ด)์ ๋๋๋ ๋ฐฐ์น ๋์์ด ๋ง์ ํน์ ์๊ฐ๋์ ๋ฐฐ์น์์๋ง ์๋์ ๊ฐ์ ์๋ฌ๊ฐ ๋ฐ๋ณต ๋ฐ์ํ๋ค.

14:30:00์ 46๊ฑด์ ๋์์ผ๋ก ๋ฐฐ์น ์์ ํ, ์์ฐจ์ ์ผ๋ก Telegram ์๋ฆผ์ ๋ฐ์กํ๋ค.(๋น์ฆ๋์ค ๋ก์ง ์ํ๋ถํฐ ์๋ฆผ ๋ฐ์ก๊น์ง ๋ฐฐ์น ๋์ ๋น 1~2์ด ์์ ๋๋๋ค.)
๊ทธ๋ฌ๋ค๊ฐ 14:30:33๋ถํฐ Telegram ์๋ฆผ ๋ฐ์ก ์๋ฌ๊ฐ ๋ฐ์ํ๋ค.
14:30:33 429 Too Many Requests retry_after: 27
14:30:34 429 Too Many Requests retry_after: 26
14:30:35 429 Too Many Requests retry_after: 25
429 Too Many Requests๊ฐ ๋ฐํ๋๋ฉฐ, ์๋ต ๋ณธ๋ฌธ์ retry_after ๊ฐ(์ด)์ด ํฌํจ๋๋ค.
์์ธ ๋ก์ง

์ฌ๋ฌ ์์ ์ ๋์์ ์ฒ๋ฆฌํ๊ธฐ ์ํด ์ค๋ ๋ ํ์ ์ฌ์ฉํ๋ค. ์ค๋ ๋ ํ ์์๋ worker-1, worker-2.. ๋ค์์ ์์ปค ์ค๋ ๋๊ฐ ์กด์ฌํ๊ณ , ๊ฐ ์ค๋ ๋๋ ๋ ๋ฆฝ์ ์ผ๋ก ๋ฐฐ์น ์์ ์ ์ฒ๋ฆฌํ๋ค.
์์ ์ด ๋๋๋ฉด ๊ฒฐ๊ณผ์ ๋ฐ๋ผ ์๋ฆผ ๊ฒฝ๋ก๊ฐ ๋๋๋ค. ์ฑ๊ณตํ์ ๋๋ sendSuccess()๋ฅผ ํตํด ์ฑํ ๋ฐฉ A๋ก, ์คํจํ์ ๋๋ sendFailure()๋ฅผ ํตํด ์ฑํ ๋ฐฉ B๋ก Telegram ์๋ฆผ์ด ์ ์ก๋๋ค. ๋ ์์ปค๊ฐ ๋ณ๋ ฌ๋ก ์คํ๋๊ธฐ ๋๋ฌธ์ ์ด ์๋ฆผ๋ค์ ๊ฑฐ์ ๋์์ ๋ฐ์ก๋๋ค.
๋ฐฐ์น ์์ ๋์์ด ๋ง์์ง์๋ก ๋ ์์ปค๋ ๋น ๋ฅด๊ฒ ์์ ์ ์๋ฃํ๊ณ ์ฐ์์ ์ผ๋ก ์๋ฆผ์ ์์๋ธ๋ค. ๊ฐ ์ฑํ ๋ฐฉ์ด ๋ฐ๋ ๋ฉ์์ง ๊ฑด์๊ฐ ์์ด๋ค ๋ณด๋ฉด ์ด๋ ์๊ฐ Telegram์ ์ ์ก๋ ์ ํ์ ์ด๊ณผํ๊ฒ ๋๊ณ , ๊ทธ๋๋ถํฐ 429 Too Many Requests ์๋ฌ๊ฐ ๋ฐ๋ณตํด์ ๋ฐ์ํ๋ค.
์์ธ ๋ถ์
Telegram Bot Rate Limit ์ ์ฑ
Telegram Bot API๋ ๊ณผ๋ํ ๋ฉ์์ง ์ ์ก์ ๋ง๊ธฐ ์ํด Rate Limit ์ ์ฑ ์ ๋๊ณ ์๋ค.

| ์ํฉ | ์ ํ |
| ๊ฐ์ ์ฑํ ๋ฐฉ | ์ด๋น 1๊ฑด ๊ถ์ฅ |
| ๊ทธ๋ฃน ์ฑํ | ๋ถ๋น ์ต๋ 20๊ฑด |
| ์ ์ฒด (๋ชจ๋ ์ฑํ ๋ฐฉ ํฉ์ฐ) | ์ด๋น ์ฝ 30๊ฑด |
์ด ๋ก์ง์์ ํต์ฌ์ด ๋๋ ๊ท์น์ ๊ฐ์ ์ฑํ ๋ฐฉ์ ๋ถ๋น ์ต๋ 20๊ฑด์ด๋ค. ์ฑํ ๋ฐฉ ์ ์ฒด ํฉ์ฐ์ด ์๋๋ผ ์ฑํ ๋ฐฉ ํ๋๋ฅผ ๊ธฐ์ค์ผ๋ก ์นด์ดํ ํ๋ค.
์ฑํ ๋ฐฉ์ด A, B๋ก ๋ถ๋ฆฌ๋์ด ์์ผ๋ ๊ด์ฐฎ์ง ์์๊น ์ถ์ง๋ง, ์ค์ ๋ก๋ ๊ทธ๋ ์ง ์์๋ค. ์ด ๋ฐฐ์น ์๋น์ค์์ ๊ฐ์ ์ฑํ ๋ฐฉ์ ๋ฉ์์ง๊ฐ ์์ด๋ ๊ฒฝ๋ก๊ฐ ๋ ๊ฐ์ง ์๋ค.
1.๊ฐ์ ์ฑํ ๋ฐฉ์ผ๋ก ํฅํ๋ ๋ค๋ฅธ ๋ฐฐ์น ์์ ์ด ๋์์ ์คํ๋๋ ๊ฒฝ์ฐ
์ฑํ ๋ฐฉ A๊ฐ ์ฑ๊ณต ์๋ฆผ๋ฐฉ์ด๋ผ๋ฉด, ์ด ์๋น์ค๋ฟ ์๋๋ผ ๋ค๋ฅธ ๋ฐฐ์น ํ์ ์ ์ฑ๊ณต ์๋ฆผ๋ ๊ฐ์ ์ฑํ ๋ฐฉ A๋ก ์ ์ก๋ ์ ์๋ค. ๋ฐฐ์น๊ฐ ์ฌ๋ฌ ๊ฐ ๊ฒน์ณ ์คํ๋๋ ์๊ฐ๋์๋ ์ฑํ ๋ฐฉ A๋ก ํฅํ๋ ๋ฉ์์ง๊ฐ ์ฌ๋ฌ ๋ฐฐ์น์์ ๋์์ ๋ชฐ๋ ค๋ค๊ณ , ํฉ์ฐ ๊ฑด์๊ฐ 20๊ฑด์ ๋๊ธฐ๊ฒ ๋๋ค.
2. ๋ฐฐ์น ์์ ๋์ ์์ฒด๊ฐ ๋ง์ ๊ฒฝ์ฐ
์์ ๋์์ด ์์ญ ๊ฐ๋ผ๋ฉด ๋ ์์ปค๋ ์์ ์ ๋ง์น ๋๋ง๋ค ๊ณ์ ์๋ฆผ์ ์ ์กํ๋ค. ์์ปค๊ฐ ๋น ๋ฅด๊ฒ ์์ ์ ์๋ฃํ ์๋ก ์๋ฆผ๋ ๋น ๋ฅด๊ฒ ์์ด๊ณ , 60์ด ์์ ๊ฐ์ ์ฑํ ๋ฐฉ์ผ๋ก 20๊ฑด ์ด์์ด ๋์ ๋๋ฉด ๊ทธ ์๊ฐ 429๊ฐ ๋ฐ์ํ๋ค.
์ฌ๋ผ์ด๋ฉ ์๋์ฐ
Telegram์ ์ฌ๋ผ์ด๋ฉ ์๋์ฐ ๋ฐฉ์์ผ๋ก ์ ํ์ ์ ์ฉํ๋ค.

๋ฐฐ์น๊ฐ ์์๋๋ฉด ๋ณ๋ ฌ ์์ปค๋ค์ด ์์ ์ ๋ง์น ๋๋ง๋ค Telegram ์๋ฆผ์ ์ฐ์์ผ๋ก ์ ์กํ๋ค. Telegram์ ์ด ์ ์ก๋์ ์ฌ๋ผ์ด๋ฉ ์๋์ฐ ๋ฐฉ์์ผ๋ก ์ธก์ ํ๋๋ฐ, '์ง๊ธ ์ด ์๊ฐ๋ถํฐ ๊ณผ๊ฑฐ 60์ด' ์์ ๋ค์ด์จ ๋ฉ์์ง๋ฅผ ์ค์๊ฐ์ผ๋ก ์นด์ดํ ํ๋ค.
14:30:00์ ๋ฐฐ์น๊ฐ ์์๋ ํ 33์ด ๋ง์ ๋ฉ์์ง๊ฐ 20๊ฑด์ ๋๊ธด๋ค. ๊ทธ ์๊ฐ 429๊ฐ ๋ฐํ๋๊ณ , ์๋ต์ ๋ด๊ธด retry_after ๊ฐ์ด ๋งค์ด 1์ฉ ์ค์ด๋๋ ์ด์ ๋ ์ฌ๊ธฐ์ ์๋ค. ๊ณ ์ ์๋์ฐ์๋ค๋ฉด 14:31:00์ด ๋๋ ์๊ฐ ์นด์ดํฐ๊ฐ ๋ฆฌ์ ๋๊ฒ ์ง๋ง, ์ฌ๋ผ์ด๋ฉ ์๋์ฐ๋ ์ค๋๋ ๋ฉ์์ง๊ฐ 60์ด ๋ฐ์ผ๋ก ๋ฐ๋ ค๋์ผ ๋น๋ก์ ํ ๊ฑด์ฉ ์ฌ์ ๊ฐ ์๊ธฐ๊ธฐ ๋๋ฌธ์ด๋ค. 14:31:00์ด ๋์ด์์ผ retry_after๊ฐ 0์ด ๋๊ณ ์ ์ก์ด ์ฌ๊ฐ๋๋ค.
ํด๊ฒฐ์ฑ ํ๋ณด
์ธ ๊ฐ์ง ๋ฐฉ๋ฒ์ ๊ฒํ ํ๋ค.
| ๋ฐฉ์ | ๋์ ๋ฐ์ก ๋ฐฉ์ง | ์์ปค ์ค๋ ๋ ๋ธ๋ก |
| sleep๋ง ์ถ๊ฐ | X | X |
| synchronized + sleep | O | O |
| ๋จ์ผ BlockingQueue + ์ ์ฉ ์ค๋ ๋ | O | X (๋น๋๊ธฐ) |
1. Thread.sleep() ์ถ๊ฐ
์ฒ์ ๋ ์ค๋ฅธ ํด๊ฒฐ์ฑ ์ ๋ฉ์๋์ Thread.sleep()์ ์ถ๊ฐํ๋ ๊ฒ์ด์๋ค.
public void sendSuccess(...) {
Thread.sleep(1000); // ๊ฐ ์ค๋ ๋๊ฐ 1์ด ์ ๋ ๋ค
telegramProcess.send(message); // ๋์์ ์ฌ๊ธฐ ๋๋ฌ → ์ฌ์ ํ ๋์ ๋ฐ์ก
}
๋ง์ฝ 10๊ฐ ์ค๋ ๋๊ฐ ๋์์ sleep(1000)์ ํธ์ถํ๋ฉด, 1์ด ํ์ ๋์์ ๊นจ์ด๋ ๋์์ ์ ์กํ๋ค. sleep์ ๊ฐ ์ค๋ ๋์ ์คํ์ ์ง์ฐ์ํฌ ๋ฟ, ์ ์ก ์์๋ฅผ ์ง๋ ฌํํ์ง ์๋๋ค.
2. synchronized + sleep
@Service
public class TelegramNotificationService {
private final Object lock = new Object();
private long lastSentMs = 0;
public void send(String chatId, String token, String message) {
synchronized (lock) {
long elapsed = System.currentTimeMillis() - lastSentMs;
if (elapsed < 3000) {
Thread.sleep(3000 - elapsed);
}
TelegramApi.send(chatId, token, message);
lastSentMs = System.currentTimeMillis();
}
}
}
synchronized๋ก ์ง์ ์ ์ง๋ ฌํํ๊ณ , ๋ง์ง๋ง ์ ์ก ์ดํ 3์ด๊ฐ ์ง๋์ง ์์์ผ๋ฉด ๋๋จธ์ง ์๊ฐ๋งํผ sleepํ๋ค.
๋์ ๋ฐ์ก์ ๋ง์ง๋ง ์์ปค ์ค๋ ๋๊ฐ synchronized ๋ธ๋ก ์์์ ๋ฐ์ก ์๋ฃ๊น์ง ๋ธ๋ก๋๋ค. ์ฒ๋ฆฌ ๋์์ด ๋ง์์๋ก ๋ง์ง๋ง ์ค๋ ๋์ ๋๊ธฐ ์๊ฐ์ด ๊ธธ์ด์ง๋ค.
3. BlockingQueue + ์ ์ฉ ์ค๋ ๋
@Service
public class TelegramNotificationService {
private final BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>(500);
@PostConstruct
public void startSender() {
Thread.ofVirtual().name("telegram-sender").start(() -> {
while (true) {
try {
Runnable task = queue.take();
task.run();
Thread.sleep(3000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
});
}
public void send(String chatId, String token, String message) {
queue.offer(() -> TelegramApi.send(chatId, token, message));
}
}
์์ปค ์ค๋ ๋๋ queue.offer()๋ง ํธ์ถํ๊ณ ์ฆ์ ๋ฆฌํดํ๋ค. ์ค์ ์ ์ก์ ์ ์ฉ ์ค๋ ๋ ํ๋๊ฐ 3์ด ๊ฐ๊ฒฉ์ผ๋ก ์ฒ๋ฆฌํ๋ค. ์ ์ฒด ์ ์ก๋์ด ์ต๋ 20๊ฑด/๋ถ(60์ด ÷ 3์ด)์ด ๋๋ฏ๋ก, ์ด๋ ์ฑํ ๋ฐฉ์ผ๋ก ๋ณด๋ด๋ Rate Limit์ ๊ฑธ๋ฆฌ์ง ์๋๋ค.
๋จ์ผ ํ์ ํ๊ณ - ์ฑํ ๋ฐฉ๋ณ ๋ ๋ฆฝ ํ๋ก
๋จ์ผ ํ ๋ฐฉ์์ Rate Limit ๋ฌธ์ ๋ฅผ ํด๊ฒฐํ์ง๋ง, ์ฑํ ๋ฐฉ์ด ์ฌ๋ฌ ๊ฐ์ผ ๋ ํ ๊ฐ์ง ๊ณ ๋ฏผ์ด ์๊ธด๋ค. (์ค์ ๋ก ์ฑํ ๋ฐฉ์ด 2๊ฐ๋ฟ ์๋๋ผ ๋ ์๋ค.)
๋จ์ผ ํ์์๋ ์ฑํ ๋ฐฉ A์ ์ฑํ ๋ฐฉ B์ ๋ฉ์์ง๊ฐ ๊ฐ์ ์ค์ ์๊ฒ ๋๋ค. ์ฑํ ๋ฐฉ A์ ๋ฉ์์ง๊ฐ ๋ชฐ๋ฆฌ๋ฉด, ์ฑํ ๋ฐฉ B์ ๋ฉ์์ง๋ ์ฑํ ๋ฐฉ A์ ๋ฉ์์ง๊ฐ ๋น ์ ธ๋๊ฐ ๋๊น์ง ๋๊ธฐํด์ผ ํ๋ค. ์ ์ฒด ์ฒ๋ฆฌ๋๋ ์ ์ฑํ ๋ฐฉ ํฉ์ฐ 20๊ฑด/๋ถ์ ๋ฌถ์ธ๋ค.
| ๋น๊ต ํญ๋ชฉ | ๋จ์ผ ํ | ๋ ๋ฆฝ ํ |
| ์ฑํ ๋ฐฉ ๊ฐ ๊ฐ์ญ | ์์ | ์์ |
| ์ฑํ ๋ฐฉ๋ณ ์ต๋ ์ฒ๋ฆฌ๋ | ์ ์ฒด ํฉ์ฐ 20๊ฑด/๋ถ | ์ฑํ ๋ฐฉ๋น 20๊ฑด/๋ถ |
์ฑํ ๋ฐฉ ์๊ฐ ์ ๊ณ ์๋ฆผ ๋น๋๊ฐ ๋ฎ๋ค๋ฉด ๋จ์ผ ํ๋ก๋ ์ถฉ๋ถํ๋ค. ํ์ง๋ง ์ฑํ ๋ฐฉ ๊ฐ ๊ฐ์ญ ์์ด ๊ฐ ์ฑํ ๋ฐฉ์ด ๋ ๋ฆฝ์ ์ผ๋ก ์ต๋ ์ฒ๋ฆฌ๋์ ํ์ฉํ๊ณ ์ถ๋ค๋ฉด, ์ฑํ ๋ฐฉ๋ณ ๋ ๋ฆฝ ํ + ์ฑํ ๋ฐฉ๋น ์ ์ฉ ์ค๋ ๋๋ก ๊ฐ๋ ๊ฒ์ด ์ ํฉํ๋ค๋ ์๊ฐ์ด ๋ค์๋ค.
์ต์ข ๊ตฌํ
์ฑํ ๋ฐฉ(chatId) ๋จ์๋ก ํ์ ์ ์ฉ ๋ฐ์ก ์ค๋ ๋๋ฅผ ๊ด๋ฆฌํ๋ค. ๊ฐ์ ์ฑํ ๋ฐฉ์ผ๋ก ํฅํ๋ ๋ฉ์์ง๋ผ๋ฆฌ๋ง ์ง๋ ฌํ๋๊ณ , ์๋ก ๋ค๋ฅธ ์ฑํ ๋ฐฉ์ ๋ ๋ฆฝ์ ์ผ๋ก ๋์ํ๋ค.
// ์ฑํ
๋ฐฉ๋ณ ํ
ConcurrentHashMap<String, BlockingQueue<Runnable>> roomQueues = new ConcurrentHashMap<>();
// ์ฑํ
๋ฐฉ๋ณ ์ค๋ ๋
ConcurrentHashMap<String, ExecutorService> roomExecutors = new ConcurrentHashMap<>();
๋ฉ์์ง ์ ์ก ๋ก์ง
send() ํธ์ถ
→ computeIfAbsent: ์ฒซ ํธ์ถ์ด๋ฉด ํ + ๊ฐ์ ์ค๋ ๋ ์์ฑ
→ queue.offer(task): ํ์ ์ ์ฌ ํ ์ฆ์ ๋ฆฌํด (์์ปค ์ค๋ ๋ ๋ธ๋ก ์์)
→ ๊ฐ์ ์ค๋ ๋: poll(500ms) → task.run() → sleep(3000) → ...
Graceful Shutdown ์ถ๊ฐ
์ ํ๋ฆฌ์ผ์ด์ ์ด ๊ฐ์์ค๋ฝ๊ฒ ์ข ๋ฃ๋๋ ์ํฉ์์๋ ํ์ ์์ธ ๋ฉ์์ง๊ฐ ์ ์ค๋์ง ์๋๋ก Graceful Shutdown์ ์ถ๊ฐํ๋ค.
boolean running ํ๋๊ทธ๋ก ๋ฃจํ๋ฅผ ์์ฐ ์ข ๋ฃ์ํจ๋ค.
@PreDestroy shutdown()
→ running = false
→ poll(500ms) ํ์์์ → while ๋ฃจํ ํ์ถ
→ drainQueue(): ๋จ์ ๋ฉ์์ง ์์ฐจ ์ ์ก
→ awaitTermination(): ๋๋ ์ธ ์๋ฃ ๋๊ธฐ
์ต์ข ๊ตฌํ ์ฝ๋
@Slf4j
@Service
public class TelegramNotificationService {
private volatile boolean running = true;
private final ConcurrentHashMap<String, BlockingQueue<Runnable>> roomQueues = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, ExecutorService> roomExecutors = new ConcurrentHashMap<>();
public void send(String chatId, String token, String message) {
BlockingQueue<Runnable> queue = roomQueues.computeIfAbsent(chatId, id -> {
LinkedBlockingQueue<Runnable> q = new LinkedBlockingQueue<>(500);
ExecutorService executor = Executors.newSingleThreadExecutor(
Thread.ofVirtual().name("telegram-" + id).factory()
);
executor.submit(() -> runSender(id, q));
roomExecutors.put(id, executor);
return q;
});
queue.offer(() -> TelegramApi.send(chatId, token, message));
}
private void runSender(String chatId, BlockingQueue<Runnable> queue) {
while (running) {
try {
Runnable task = queue.poll(500, TimeUnit.MILLISECONDS);
if (task == null) continue;
task.run();
Thread.sleep(3000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
} catch (Exception e) {
log.error("ํ
๋ ๊ทธ๋จ ๋ฉ์์ง ์ ์ก ์คํจ [chatId={}]", chatId, e);
}
}
drainQueue(chatId, queue);
}
private void drainQueue(String chatId, BlockingQueue<Runnable> queue) {
log.info("ํ
๋ ๊ทธ๋จ ์์ฌ ๋ฉ์์ง ์ ์ก [chatId={}, count={}]", chatId, queue.size());
Runnable task;
while ((task = queue.poll()) != null) {
try {
task.run();
} catch (Exception e) {
log.error("์ข
๋ฃ ์ค ๋ฉ์์ง ์ ์ก ์คํจ [chatId={}]", chatId, e);
}
}
}
@PreDestroy
public void shutdown() {
log.info("ํ
๋ ๊ทธ๋จ ๋ฐ์ก ์ค๋ ๋ ์ข
๋ฃ ์์ - ์์ฌ ๋ฉ์์ง ์ ์ก ์ค...");
running = false;
roomExecutors.values().forEach(ExecutorService::shutdown);
roomExecutors.forEach((id, executor) -> {
try {
if (!executor.awaitTermination(10, TimeUnit.MINUTES)) {
log.warn("ํ
๋ ๊ทธ๋จ ๋ฐ์ก ํ์์์ [chatId={}]", id);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
log.info("ํ
๋ ๊ทธ๋จ ๋ฐ์ก ์ค๋ ๋ ์ข
๋ฃ ์๋ฃ");
}
}
์ข ๋ฃ ํ ์คํธ ๋ก๊ทธ
