실시간 알림 기능은 이제 대부분의 웹 서비스에 들어가는 기술입니다. 예를 들어, 메시지 알림이나 실시간 데이터 갱신 같은 기능들이 대표적이죠.
그렇다면, 이를 구현하고자 한다면 가장 먼저 무엇이 떠오르시나요?
Pollinig이란
Polling이란 어떤 값을 확인하거나 갱신하는 과정입니다. 종류로는 Short/Long polling이 존재합니다.
Short Polling이란 클라이언트가 일정한 간격으로 서버에 데이터를 요청하면 서버가 즉시 응답하는 방식입니다. 이때 응답할 것이 없다면 빈 응답을 전송합니다. 이 방식은 서버에서 줄 데이터가 없더라도 계속해서 [요청 → 응답] 과정을 반복하기 때문에 불필요한 트래픽이 많이 발생합니다.
이를 개선한 방법이 바로 Long Polling입니다. Long Polling이란 클라이언트가 서버에 데이터를 요청하는 경우 서버에서 줄 데이터가 없으면 곧바로 응답하는 것이 아니라 커넥션을 일정 시간 유지한 후 줄 데이터가 생겼을 때 응답을 보내는 방식입니다. 이 방식은 클라이언트와 연결을 계속 유지하는 동안 서버 자원을 계속해서 소모하고 있게된다는 단점이 존재합니다.
그렇다면 클라이언트의 요청이 없어도 서버에서 먼저 데이터를 발송하는 방법이 없을까요?
이때 등장한 개념이 바로 SSE(Server-Sent Events)입니다.
SSE(Server-Sent Events)란?
SSE는 클라이언트가 서버에 한 번 연결을 맺으면, 서버가 새로운 이벤트를 실시간으로 단방향[서버 → 클라이언트] 으로 전송할 수 있는 기술입니다. 별도의 프로토콜(WebSocket 등)을 사용하지 않고, HTTP 기반으로 동작하기 때문에 훨씬 간단하게 실시간 통신을 구현할 수 있습니다.
앞서 살펴본 Long Polling의 경우 서버에서 이벤트가 발생해 데이터를 응답하는 경우 바로 커넥션이 끊어지기 때문에 다시 연결해야 하지만 SSE는 서버의 데이터 응답 여부와 관계 없이 일정 시간 동안은 연결을 끊지 않고 유지합니다.
또한 Spring에서는 SseEmitter를 이용해 SSE를 쉽게 구현할 수 있도록 지원합니다. 또한 백그라운드에서 주기적으로 이벤트를 감시하고 발송하기 위해 ScheduledThreadPoolExecutor를 활용하면 효율적인 스레드 관리까지 가능합니다.
그렇다면 구현을 들어가기 전에 ScheduledThreadPoolExecutor에 대해서 알아보도록 하겠습니다.
ScheduledThreadPoolExecutor란?
ScheduledThreadPoolExecutor는 일정 시간 후에 작업을 실행하거나 주기적으로 반복 실행할 수 있는 스레드 풀을 제공합니다. 즉 지정된 시간 간격으로 작업을 수행해야 할 때 사용하기 적합한 Executor 서비스입니다.
일반적인 ExecutorService는 단순히 비동기 작업을 실행하는 용도로 사용되지만 ScheduledThreadPoolExecutor는 스케줄링 기능이 추가된 버전이라고 볼 수도 있습니다.
SSE 기능을 Spring에서 구현할 때 특정 이벤트를 주기적으로 감시하거나 지속적으로 푸시해야하는 상황이 존재합니다. 이럴 때 ScheduledThreadPoolExecutor를 사용하면 별도의 스레드를 직접 관리하지 않아도 손쉽게 주기적 실행 로직을 구성할 수 있습니다.
ExecutorService란?
java에서 동시 및 병렬 프로그래밍을 간소화하기 위해 제공되며 스레드를 직접 생성/관리하지 않고 이를 고수준으로 추상한 인터페이스입니다.
즉, 작업을 대신 실행해주는 스레드 관리도구라고 볼 수 있습니다.
ExecutorService는 Executor 인터페이스를 확장한 형태로, 스레드를 직접 제어하지 않고도 비동기 작업을 손쉽게 처리할 수 있도록 돕습니다.
public interface Executor {
void executor(Runnable command);
}
Executor 인터페이스는 Runnable 객체를 미래의 어느 시점에 주어진 명령으로 실행합니다.
Runnable 인터페이스란 인스턴스가 스레드에 의해 실행되도록 의도된 모든 클래스에서 구현하며 스레드 시작 시 별도의 실행 중인 스레드에서 객체의 run 메서드가 호출됩니다.
ExecutorService는 다음과 같습니다.
public interface ExecutorService extends Executor {
void shutdown();
List<Runnable> shutdownNow();
boolean isShutdown();
boolean isTerminated();
<T> Future<T> submit(Callable<T> task);
Future<?> submit(Runnable task);
<T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks)
throws InterruptedException;
<T> T invokeAny(Collection<? extends Callable<T>> tasks)
throws InterruptedException,
}
위에서 주로 보이는 객체들은 Callable, Future가 있습니다.
Callable 인터페이스는 자바에 다음과 같이 정의되어 있습니다.
@FunctionalInterface
public interface Callable<V> {
V call() throws Exception;
}
스레드에서 실행될 수 있다는 점이 Runnable과 유사하지만 void를 반환하던 Runnable과 다르게 결과를 반환하고 예외를 던질 수 있습니다. 하지만 Callble의 구현체인 작업은 가용 가능한 쓰레드가 존재하지 않을 경우 실행이 미뤄질 수 있습니다. 이때 나중에 실행된 결과를 받아올 객체가 필요한데, 그게 바로 Future 객체입니다.
public interface Future<V> {
//작업 취소
boolean cancel(boolean mayInterruptIfRunning);
//작업이 취소되었는지 확인
boolean isCancelled();
//작업이 끝났는지 확인
boolean isDone();
V get() throws InterruptedException, ExecutionException;
V get(long timeout, TimeUnit unit)
throws InterruptedException, ExecutionException, TimeoutException;
}
다시 말하자면, Callable 구현체를 실행 했을 때 바로 결과를 받지 못하는 경우 미래의 어느 시점에 얻을 수 있는데, 미래에 완료된 Callable의 반환 값을 구하기 위해 사용되는게 Future 객체입니다.
다시 돌아와서, Executor, ExecutorService를 구현할 스레드풀을 생성할 클래스가 필요합니다. 그것이 바로 팩토리 클래스인 Executor 클래스입니다. static 메서드를 이용해 생성해주며 스레드의 개수만 넘겨주면 생성이 완료됩니다.
public class Executors {
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
...
}
이때 Executors 클래스의 스레드풀 생성 메서드에서는 내부적으로 ThreadPollExecutor를 생성하여 반환합니다. 스레드 풀의 개수만 지정해주면 생성되지만, 내부적으로는 기본적인 값들이 디폴트로 설정되어 생성됩니다.
ScheduledThreadPoolExecutor란?
val fixedThreadPool = Executors.newFixedThreadPool(2)
val scheduledThreadPool: ExecutorService = Executors.newScheduledThreadPool(2)
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
return new ScheduledThreadPoolExecutor(corePoolSize);
}
앞서 말했듯이, 어떤 작업을 일정 시간 지연 후 실행하거나 주기적으로 실행해야한다면 사용합니다.
LinkedBlockingQueue를 이용하는 일반 Executor와 다르게 ScheduledThreadPoolExecutor은 내부적으로 DelayedWorkQueue를 이용합니다. 이 큐는 각 작업의 실행 시점을 시간 기반으로 관리하여, 지연(delay) 또는 반복 주기(period)에 따라 작업을 실행합니다.
ScheduledThreadPoolExecutor은 다음과 같이 4개의 메서드를 제공합니다.
- schedule(Runnable command, long delay, TimeUnit unit) : 일정 시간(delay) 뒤에 한 번 작업 실행
- schedule(Callable command, long delay, TimeUnit unit) : 일정 시간 뒤에 한 번 작업 실행 + 결과 반환
- scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit) : 일정 주기마다 반복 실행 (고정 간격 기준)
- scheduleWithFixedDelay(Runnable command, long initialDelay, long period, TimeUnit unit) : 작업이 끝난 후 일정 시간(delay)을 기다렸다가 다시 실행
예를 들어, 5초마다 SSE로 알림을 전송하는 경우 다음과 같이 작성할 수 있습니다.
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
scheduler.scheduleAtFixedRate(() -> {
System.out.println("새 알림을 클라이언트로 전송합니다.");
}, 0, 5, TimeUnit.SECONDS);
이 코드는 0초 후 첫 실행을 시작으로, 이후 매 5초마다 동일한 작업을 반복 실행합니다.
데몬 스레드란?
마지막으로 SSE를 구현하기 전 알아야할 개념은 데몬 스레드입니다.
데몬 스레드란 사용자가 직접적으로 제어하지 않고, 백그라운드에서 돌면서 여러 작업을 하는 프로그램입니다.
주 스레드의 작업을 돕는 보조적인 역할을 수행하는 스레드이며, 주 스레드가 종료되면 강제 종료됩니다.
저는 프로젝트에서 SSE 기반의 실시간 알림 기능을 구현할 때 기술적으로 코드를 작성하는 것보다도 그 과정에서 사용되는 객체와 메서드들의 개념을 제대로 이해하는 것이 더 어렵다고 느꼈습니다.
특히 ExecutorService, Callable, Future, ScheduledThreadPoolExecutor 같은 클래스들은
단순히 “비동기로 돌린다” 정도로만 알고 있으면 금방 한계에 부딪히게 됩니다.
이 객체들이 각각 어떤 역할을 하고, 어떤 상황에서 어떤 방식으로 스레드를 관리하는지를 이해하면
비동기 처리나 실시간 이벤트 전송 구조를 훨씬 안정적으로 설계할 수 있습니다.
결국 SSE는 “서버에서 데이터를 실시간으로 밀어주는 구조”이지만,
그 뒤에서 돌아가는 건 결국 스레드와 스케줄링의 이해입니다.
즉, SSE를 제대로 활용하려면 단순히 SseEmitter를 쓰는 것에서 끝나지 않고,
이벤트 발송의 타이밍을 제어하고 병렬 처리를 안정적으로 관리하는 구조적 이해가 필요합니다.
이번 글이 저처럼 “구현보다 개념이 더 어려웠던 분들”께
조금이라도 도움이 되었으면 좋겠습니다.
다음 포스팅에서는 실제로 제가 어떻게 구현했는지에 대해 작성해보도록 하겠습니다

'자바 > 스프링부트' 카테고리의 다른 글
| [JAVA / SSE] 스프링부트에서 SSE 구현하기 (SSE + Redis pub/sub) - 2 (0) | 2025.10.21 |
|---|---|
| [JAVA / 스프링 부트] 커스텀 필터 & AuthorizationManager로 그룹별 API 접근 제어하기 (1) | 2025.10.21 |
| [JAVA / 스프링부트] UTC 저장 + KST 응답 구조로 타임존 문제 해결하기 (Spring Boot + MariaDB) (0) | 2025.10.20 |
| [JAVA / 스프링부트] API 공통 응답 형식 지정 + 공통 에러 처리 - 2 (0) | 2025.10.13 |
| [JAVA / 스프링부트] API 공통 응답 형식 지정 + 공통 에러 처리 - 1 (0) | 2025.10.13 |