[ApartTime] Admin System 구축기 - 실시간 회원가입 알림 (Kafka + SSE)

고뭉남·2025년 6월 2일

ApartTime

목록 보기
1/6
post-thumbnail

아파트타임 서버 구성

아파트타임은 2개의 주요 서버로 구성되어 있습니다.

Application Server

Next.js 기반으로, 프론트엔드와 백엔드가 통합된 구조입니다.

대부분의 비즈니스 로직(회원가입, 심부름 요청 등)은 해당 서버에서 처리되며, 실질적인 사용자 요청을 담당합니다.

Admin Server

현재 제가 구축 및 기능 구현 중인 Spring Boot 기반의 관리자(admin) 전용 서버입니다.

관리자는 해당 서버를 통해 서비스 전반의 운영 현황(신규 회원가입 요청, 신고 당한 채팅 등)을 확인할 수 있습니다.

화면은 Thymeleaf 템플릿 엔진을 통해 서버 사이드 렌더링(SSR) 방식으로 구현되어 있습니다.

Kafka 도입 배경

아파트타임은 일반 사용자가 회원가입을 요청하면, 관리자가 이를 승인하거나 거부하는 구조입니다.

단순 가입 후 자동 로그인이 되는 방식이 아니라, 관리자의 개입을 전제로 한 인증 절차가 존재합니다.

따라서 Application Server에서 회원가입 요청이 발생한 즉시, 이를 Admin Server에 실시간으로 전달해주는 흐름이 필수적입니다.

처음에는 이 문제를 해결하기 위해, Application Server에서 Admin Server로 REST API를 호출하는 방식을 고려했습니다.

예를 들어, 사용자가 회원가입을 완료하면 DB의 member 테이블에 statusPENDING인 상태로 저장되고, 이때 Application Server가 Admin Server에 해당 정보를 API로 전달하여 관리자 화면에 반영하는 구조입니다.

그러나 Application Server와 Admin Server는 서로 명확히 분리된 역할을 수행하는 독립적인 시스템이기 때문에 이런 REST API 기반의 직접 통신은 서버 간의 의존성을 높이고 구조적 결합 문제를 야기할 수 있다는 한계가 있었습니다.

그래서 REST API 방식이 아닌 다른 방법들도 고민해봤습니다.

그 중 하나는 Admin Server에서 주기적으로 DB를 polling하여 statusPENDING인 회원 데이터를 조회하는 방식이었습니다.

과거 프로젝트에서도 비슷한 방식으로 Logstash를 이용해 DB 데이터를 3초마다 polling하고 이를 Elasticsearch에 적재했던 경험이 있었기 때문에 충분히 현실적인 대안으로 고려되었습니다.

(관련 글: [Dowith] Logstash 데이터 파이프라인에서 updated_at < NOW() 조건이 필요한 이유)

하지만 이 방식은 지속적인 polling으로 인해 서버 리소스를 과도하게 소모할 수 있고, 무엇보다도 완벽한 실시간성을 보장하기 어렵다는 점에서 한계가 있었습니다.

실제로 Logstash를 사용한 과거 프로젝트에서 t2.micro 인스턴스가 자주 다운되었던 경험도 있었기에, 이번에는 보다 안정적이고 효율적인 이벤트 전달 방식이 필요하다고 판단했습니다.

이러한 고민 끝에 Kafka를 도입하여 비동기 메시지 기반 아키텍처를 적용해보기로 결정했습니다.

Kafka 도입 아키텍처

Kafka는 메시지를 발행하는 Producer와 해당 메시지를 소비하는 Consumer 간의 직접적인 연결 없이도 이벤트를 안정적으로 전달할 수 있는 메시지 브로커입니다.

Kafka를 도입한 아파트타임의 아키텍처에서는 Application Server가 회원가입 이벤트를 Kafka에 발행하면, Admin Server는 Kafka의 해당 토픽을 구독하여 필요한 시점에 메시지를 비동기적으로 수신할 수 있습니다.

Application Server에서 신규 회원가입을 처리하고 member-signup이라는 topic으로 event를 발행합니다.

Kafka에서는 해당 topic을 구독 중인 모든 consumer들에게 해당 메시지를 전달하며, Admin Server는 이 topic을 구독하고 있다가 @KafkaListener를 통해 메시지를 수신하고 이를 바탕으로 실시간 관리자 알림을 위한 후속 처리를 수행합니다.

Kafka 도입 아키텍처

SSE 채택 이유

Kafka를 통해 Admin Server로 회원가입 이벤트가 도달한 이후, 관리자는 해당 이벤트를 즉시 확인할 수 있어야하기에 Admin Server는 SSE(Server-Sent Events)를 활용하여 프론트엔드에 실시간 알림을 푸시합니다.

SSE는 서버에서 클라이언트를 향해 단방향으로 지속적인 연결을 유지하며 데이터를 전송할 수 있는 HTTP 기반의 통신 방식입니다.

WebSocket에 비해 복잡도가 낮고 관리자 알림처럼 서버 → 클라이언트 단방향 통신에 적합하여 SSE를 채택했습니다.

추후 구현하는 기능들 중, 클라이언트와 서버 간 양방향 통신이 필요한 경우에는 SSE를 걷어내고 WebSocket을 도입 할 예정입니다.

SSE를 통한 실시간 알림 흐름

관리자가 로그인하여 /admin/dashboard 화면에 진입하면 해당 시점에 클라이언트는 Admin Server의 /admin/sse/notification 엔드포인트로 SSE 연결을 시도합니다.

(이 연결은 관리자가 인증을 마친 상태에서만 동작하며, Admin Server는 Spring Security를 통해 /admin/** 경로에 대한 인증을 선행하도록 구성되어 있습니다.)

연결이 성공하면 Admin Server는 Kafka로부터 수신한 회원가입 이벤트 메시지를 실시간으로 해당 관리자에게 전달합니다.

프론트엔드에서의 SSE 연결

아래는 실제 dashboard.html에 포함된 JavaScript 코드로, SSE 연결을 맺고 메시지를 수신하여 화면에 표시하는 역할을 합니다.

<!-- SSE 알림 스크립트 -->
<script th:inline="javascript">
  const sse = new EventSource("/sse/notification");

  // 서버 렌더링된 초기값 사용
  let signupAlertCount = /*[[${pendingCount}]]*/ 0;

  sse.addEventListener("member-signup", function (e) {
    const alertArea = document.getElementById("alert-area");
    alertArea.innerHTML = "";

    const alertBox = document.createElement("div");
    alertBox.textContent = "🔔 가입 요청: " + e.data;
    alertBox.style = "background: #fff3cd; padding: 10px; margin-bottom: 10px; border: 1px solid #ffeeba; border-radius: 4px;";
    alertArea.appendChild(alertBox);

    signupAlertCount++;
    const badge = document.getElementById("signup-badge");
    badge.textContent = signupAlertCount;
    badge.style.display = "inline-block";
  });

  sse.onerror = function () {
    console.warn("SSE 연결이 끊어졌습니다.");
  };
</script>

백엔드에서의 SSE 처리 구조

클라이언트가 /admin/sse/notification 엔드포인트로 요청을 보내면, 아래의 SseController가 이를 처리합니다.

@RestController
@RequiredArgsConstructor
@RequestMapping("/admin")
public class SseController {

    private final SseEmitterService sseEmitterService;

    @GetMapping("/sse/notification")
    public SseEmitter subscribe() {
        return sseEmitterService.subscribe();
    }
}

내부에서는 SseEmitterServicesubscribe() 메서드를 통해 SseEmitter 객체를 반환합니다.

실제 연결과 메시지 전송을 담당하는 핵심 로직은 SseEmitterService에 정의되어 있습니다.

@Slf4j
@Service
public class SseEmitterService {

    private final List<SseEmitter> emitters = new CopyOnWriteArrayList<>();

    public SseEmitter subscribe() {
        SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);
        emitters.add(emitter);
        emitter.onCompletion(() -> emitters.remove(emitter));
        emitter.onTimeout(() -> emitters.remove(emitter));
        return emitter;
    }

    public void send(MemberSignupEvent event) {
        String message = "신규 가입 요청! " + event.getUsername() + " (" + event.getEmail() + ")";
        for (SseEmitter emitter : emitters) {
            try {
                emitter.send(SseEmitter.event()
                    .name("member-signup")
                    .data(message));
                log.info("SSE 알림 전송 완료!!");
            } catch (IOException e) {
                emitters.remove(emitter); // 연결 끊긴 emitter 제거
            }
        }
    }
}

subscribe()는 새로운 관리자가 연결을 시도할 때마다 SseEmitter 객체를 생성해 등록하고, 연결 종료 시 자동으로 제거합니다.

send()는 Kafka를 통해 전달된 회원가입 이벤트를 연결된 모든 관리자에게 member-signup 이름의 이벤트로 전송합니다.

연결 유지 및 실시간 반영

이런 구조를 통해 관리자는 새로운 가입 요청이 발생할 때마다 즉시 알림을 받을 수 있고 페이지를 새로고침하지 않아도 브라우저 상에서 실시간으로 이벤트가 반영됩니다.

  1. /admin/dashboard 진입

  2. SSE 연결 시도

  3. Admin Server가 SseEmitter로 응답

  4. 연결 유지

  5. Kafka로부터 회원가입 이벤트 수신

  6. SseEmitterService.send()를 통해 현재 연결 중인 관리자들에게 알림 push

profile
개발자 고뭉남입니다.

0개의 댓글