Notice
Recent Posts
Recent Comments
Link
«   2025/04   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30
Tags
more
Archives
Today
Total
관리 메뉴

josolha

Eco-reading 트러블 슈팅(실시간 알림 기능) 본문

Spring

Eco-reading 트러블 슈팅(실시간 알림 기능)

josolha 2023. 11. 27. 17:59
이슈 정의

 

환경을 살리기 위해 헌 책을 무료로 나눔하고 받을 수 있는 사이트를 개발 하고 있다.

 

이러한 개발 중 실시간 알림 기능 서비스를 구현해야 하는데

 

어떠한 방식들이 있고 어떤 방식을 도입하게 되었는지 작성하려고 한다.

 

우선 구현하려는 알림 기능은

 

1. 사용자가 책을 등록하면 관리자는 그 책을 가져가기 위해 배송을 시작한다. (배송 시작 알림)

 

2. 이후 검수를 통해 책 상태에 따른 마일리지를 지급한다 (검수 완료 알림)

 

3. 무료 나눔한 책을 사용자가 받기 했을때 (배송 시작 알림)

 

이렇게 3단계가 존재하는데,

그 단계가 완료가 될 때 마다 현재 접속된 사용자의 헤더에 있는 종모양에 모양의 변경을 주고 싶었다.


사실 수집

 

이제 필요한 정보를 찾아보기 시작했다.

 

우선 해당 실시간 통신의 구현 방법 종류를 찾아봤다.

 

1. Polling

2. Long-Poling

3. SSE

4. Web Socket 

 

4가지의 방식이 있었고 구체적으로 어떠한 차이가 있는지 알아보기 시작했다.

 


 

Polling

Polling 방법

 

일정 주기를 가지고 서버의 API를 호출하는 방법이다.

예를 들어, 클라이언트에서 5초마다 한 번씩 알림 목록을 호출한다면, 업데이트 내역이 5초마다 갱신되며 변경 사항을 적용할 수 있다.

이 방식은 기본적인 HTTP 통신을 기반으로 하기 때문에 호환성이 좋다는 장점이 있다.

 

하지만 해당 방식은 업데이트 주기가 길다면 실시간으로 데이터가 갱신 되지 않고,

또 짧다면 갱신 사항이 없음에도 서버에 요청이 들어와 불필요한 서버 부하가 발생한다는 것이다.

 

예시 코드)

@RestController
public class NotificationController {

    @GetMapping("/notifications")
    public ResponseEntity<List<Notification>> getNotifications() {
        List<Notification> notifications = notificationService.getLatestNotifications();
        return ResponseEntity.ok(notifications);
    }
}

해당 코드 처럼 /notifications 라는 엔드포인트가 존재하고

function fetchNotifications() {
    fetch('/notifications')
        .then(response => response.json())
        .then(data => {
            console.log('Notifications:', data);
            // 여기에서 데이터를 처리하고 사용자 인터페이스를 업데이트합니다.
        })
        .catch(error => console.error('Error fetching notifications:', error));
}

// 5초마다 알림을 가져오기
setInterval(fetchNotifications, 5000);

클라이언트 측에서는 JavaScript를 사용하여 5초마다 서버의 /notifications 엔드포인트를 호출하는 코드이다.

 

폴링 방식에서는 클라이언트 측 자바스크립트를 사용하여 정해진 시간 간격(예: 5초)마다

서버의 특정 엔드포인트(/notifications)에 접근한다.

이때 서버는 최신의 알림 데이터를 반환하고, 클라이언트는 이 데이터를 받아 사용자 인터페이스를 업데이트한다.

 

이 방식은 구현이 간단하고 널리 호환되지만,

앞서 말했듯이 실시간 데이터 처리에 있어서는 빈번한 요청으로 인한 서버 부하나 업데이트 지연과 같은 단점이 있다.


 

Long-Polling

 

Long Polling 방법

 

Polling과 비슷하나, 업데이트 발생시에만 응답을 보내는 방식이다.

서버로 요청이 들어올 경우, 일정 시간동안 대기하였다가 요청한 데이터가 업데이트 되면 웹 브라우저에게 응답을 보낸다.

Polling에서 불필요한 응답을 주는 경우를 줄이기 위해 사용할 수 있는 방법이다.

 

따라서 연결이 된 경우엔 실시간으로 데이터가 들어올 수 있다는 장점이 있다.

 

하지만 이 방식 또한 데이터 업데이트가 빈번하게 일어난다면 연결을 위한 요청도 똑같이 발생하고,

여전히 HTTP 요청-응답 모델의 제한을 가지고 있으며 Polling과 유사하게 서버에 부하가 일어날 수 있다.

 

예시 코드)

@RestController
public class NotificationController {

    private final NotificationService notificationService;
    
    @GetMapping("/notifications")
    public DeferredResult<ResponseEntity<List<Notification>>> getNotifications() {
        DeferredResult<ResponseEntity<List<Notification>>> deferredResult = new DeferredResult<>();

        notificationService.getLatestNotifications(notifications -> {
            deferredResult.setResult(ResponseEntity.ok(notifications));
        });
        return deferredResult;
    }
}

JavaScript는 위에 코드 그대로 두고 컨트롤러만 수정한다.

 

 

여기서 notificationService.getLatestNotifications()는 새로운 알림이 있을 때까지 대기하고,

새 알림이 도착하면 콜백을 통해 DeferredResult에 결과를 설정한다.

 

DeferredResult는 스프링 프레임워크에서 제공하는 클래스이다.

이 클래스는 비동기 요청 처리에 사용되며, 특히 Long Polling 같은 시나리오에서 유용하다고 한다.

DeferredResult를 사용하면 서버는 요청을 즉시 처리하지 않고 보류할 수 있으며,

나중에 어떤 이벤트가 발생하면 결과를 반환할 수 있다.

 

이는 서블릿 컨테이너의 스레드를 효율적으로 사용할 수 있게 해주며, 클라이언트는 서버로부터 새로운 데이터가 준비되었을 때 알림을 받을 수 있다.


SSE(Server-Sent Event)

SSE 방법

 

웹 브라우저에서 서버쪽으로 특정 이벤트를 구독하면, 서버에서는 해당 이벤트 발생시 웹브라우저 쪽으로 이벤트를 보내주는 방식이다.

따라서 한 번만 연결 요청을 보내면, 연결이 종료될 때까지 재연결 과정 없이 서버에서 웹 브라우저로 데이터를 계속해서 보낼 수 있다.

다만, 서버에서 웹 브라우저로만 데이터 전송이 가능하고, 그 반대는 불가능하다는 단점이 있다.

또, 최대 동시 접속 횟수가 제한되어 있다.

 

(예시 코드 생략)


 

Web Socket

web socket 방법

 

서버와 웹브라우저 사이 양방향 통신이 가능한 방법이다.

변경 사항에 빠르게 반응해야하는 채팅이나,

리소스 상태에 대한 지속적 업데이트가 필요한 문서 동시 편집과 같은 서비스에 많이 사용되는 방식이다.

이 방식은 양방향 통신이 지속적으로 이루어질 수는 있으나,

연결을 유지하는 것 자체가 비용이 들기 때문에 트래픽 양이 많아진다면 서버에 큰 부담이 된다는 단점이 있다.

 

(예시 코드 생략)


원인 추론

 

SSE(Server-Sent Events) 방식을 이용하여 실시간 알림 기능을 구현하기로 결정했다.

 

Polling 방식은 지속적인 요청을 보내야 하므로 리소스 낭비가 심할 수 있다.

반면, 내가 구현하고자 하는 알림 기능은 서버에서 클라이언트 방향으로만 데이터를 보내면 충분하다.

이런 상황에서 양방향 통신을 제공하는 WebSocket은 필요하지 않았다.

 

SSE는 전통적인 HTTP를 통해 전송되며, 특별한 프로토콜이나 서버 구현이 필요하지 않다는 장점이 있다.

이는 서버 보낸 이벤트(Server-Sent Events)의 강력한 기능을 활용할 수 있게 해준다.

자동 재연결, 이벤트 ID, 임의 이벤트 전송 등과 같은 기능은 SSEWebSocket에 비해 가볍고 단방향 통신에 적합하게 만든다.

 

스프링 프레임워크에서는 SseEmitter라는 클래스를 통해 SSE를 쉽게 구현할 수 있다.

SseEmitter는 스프링의 MVC 프레임워크에서 서버 측 이벤트를 지원하기 위해 설계된 클래스로, 이를 통해 서버에서 클라이언트로 실시간으로 데이터를 푸시할 수 있다.

 

이 기능은 배송 시작, 검수 완료와 같은 알림을 서버에서 클라이언트로 효율적으로 전송하는 데 매우 적합하다.

 

SseEmitter를 사용하면, 서버에서 클라이언트로 이벤트 스트림을 전송할 때 간편하게 다양한 데이터 형식을 지원하고,

클라이언트 측에서는 이 이벤트들을 쉽게 구독하고 처리할 수 있다.

이로 인해 개발자는 보다 간결하고 효율적인 코드로 실시간 알림 기능을 구현할 수 있다.

 

결론적으로, SSE는 웹소켓에 비해 가벼우면서도 서버에서 클라이언트로 데이터를 보내는 단방향 통신에 효과적이다.

스프링의 SseEmitter를 사용하면 이러한 기능을 손쉽게 구현할 수 있어, 내 프로젝트에 잘 맞는 선택이라고 판단했다.


결과

 

1.JavaScript 코드

const eventSource = new EventSource('http://localhost:8099/alert/init');

// 알림을 드롭다운 메뉴에 추가하는 함수
function addNotificationToMenu(message, id) {
    const notificationMenu = document.getElementById('notificationDropdownMenu');
    const newNotification = document.createElement('div');
    newNotification.classList.add('dropdown-item');
    newNotification.setAttribute('data-alert-id', id); // 알림 ID 설정

    // 알림 텍스트 추가
    const notificationText = document.createElement('span');
    notificationText.textContent = message;
    newNotification.appendChild(notificationText);

    // 삭제 버튼 추가
    const deleteButton = document.createElement('button');
    deleteButton.textContent = 'X';
    deleteButton.classList.add('delete-notification');
    deleteButton.onclick = function() {
        removeNotification(newNotification, id); // 삭제 시 ID 사용
    };
    newNotification.appendChild(deleteButton);
    notificationMenu.appendChild(newNotification);
    // 알림 카운터 업데이트 로직 추가 (있다면)
    updateNotificationCount(1); // 알림 카운터 업데이트
}
eventSource.addEventListener('new-alert', event => {
    const newAlert = JSON.parse(event.data);
    addNotificationToMenu(newAlert.message, newAlert.alertId);
});


// 알림 카운터 업데이트 함수
function updateNotificationCount(increment) {
    const notificationCount = document.getElementById('notificationCount');
    let currentCount = parseInt(notificationCount.innerText) || 0;
    notificationCount.innerText = currentCount + increment;
}

eventSource.addEventListener('init', event => {
    //const message = event.data;
    const notifications = JSON.parse(event.data);
    //const { alertId, userId, status, message } = data;
    // if (message !== "연결되었습니다.") {
    //     addNotificationToMenu(message);
    // }
    notifications.forEach(notification => {
        const { alertId, userId, status, message } = notification;
        addNotificationToMenu(message, alertId);
    });
});

// 알림 제거 함수
function removeNotification(notificationElement, alertId) {
    // 알림 ID를 사용하여 서버에 삭제 요청
    deleteNotificationFromServer(alertId);

    // UI에서 알림 제거
    const notificationMenu = document.getElementById('notificationDropdownMenu');
    notificationMenu.removeChild(notificationElement);
    // 알림 카운터 업데이트 로직 추가 (있다면)
    updateNotificationCount(-1); // 알림 카운터 감소
}
function deleteNotificationFromServer(alertId) {
    fetch(`http://localhost:8099/alert/delete/${alertId}`, {
        method: 'DELETE'
    })
        .then(response => {
            if (!response.ok) {
                throw new Error('Network response was not ok');
            }
            // 추가적인 성공 처리 로직
        })
        .catch(error => console.error('Error deleting notification:', error));
}
eventSource.onerror = error => {
    console.error('EventSource failed:', error);
    eventSource.close();
};
  1. EventSource 객체 생성: EventSource 객체는 지정된 URL에서 서버로부터 오는 이벤트를 수신하기 위해 사용한다.
  2. 알림 추가 함수 (addNotificationToMenu): 이 함수는 새로운 알림을 사용자의 드롭다운 메뉴에 동적으로 추가한다.
    알림 메시지와 고유 ID를 받아서, 알림 항목을 생성하고 삭제 버튼을 포함한다.
  3. 'new-alert' 이벤트 리스너: 서버에서 'new-alert' 이벤트가 전송되면, 이 리스너가 알림 데이터를 받아서 addNotificationToMenu 함수를 호출하여 알림을 추가한다.
  4. 알림 카운터 업데이트 (updateNotificationCount): 이 함수는 새 알림이 추가되거나 삭제될 때 알림 카운터를 업데이트 한다.
  5. 'init' 이벤트 리스너: 사용자가 페이지에 처음 접속할 때, 이 리스너는 초기 알림 목록을 로드하고 각 알림을 메뉴에 추가한다.
  6. 알림 제거 함수 (removeNotification): 사용자가 알림을 삭제하려 할 때 이 함수가 호출됩니다. 서버에 삭제 요청을 보내고, UI에서 해당 알림을 제거한다.
  7. 알림 삭제 서버 요청 (deleteNotificationFromServer): 알림 ID를 사용하여 서버에 해당 알림을 삭제하도록 요청한다.
  8. SSE 연결 오류 핸들링: EventSource 객체에 오류가 발생하면 연결을 종료한다.

2.Repository

@Repository
@RequiredArgsConstructor
@Slf4j
public class EmitterRepository {

    // 모든 SseEmitter를 저장하는 ConcurrentHashMap
    private final Map<Long, SseEmitter> emitters = new ConcurrentHashMap<>();

    // Emitter 저장 메서드
    public void addEmitter(Long id, SseEmitter emitter) {
        emitters.put(id, emitter);
    }

    // Emitter 조회 메서드
    public Optional<SseEmitter> get(Long userId) {
        return Optional.ofNullable(emitters.get(userId));
    }

    // Emitter 제거 메서드
    public void removeEmitter(Long id) {
        emitters.remove(id);
    }
}

3.NotificaionService

@Service
@RequiredArgsConstructor
public class NotificationService {

    private final EmitterRepository emitterRepository;
    private final UserRepository usersRepository;
    private final AlertRepository alertRepository;
    private static final Long DEFAULT_TIMEOUT = 60L * 1000 * 60;

    // 실시간 알림 보내기
    public void sendNotification(Long userId, String message) {
        Optional<SseEmitter> emitter = emitterRepository.get(userId);
        if (emitter.isPresent()) {
            // 사용자가 로그인 중일 경우 실시간 알림 전송
            sendRealtimeNotification(emitter.get(), userId, message);
        } else {
            // 로그아웃 상태일 경우 오프라인 알림 저장
            saveOfflineNotification(userId, message);
        }
    }

    // 실시간 알림 전송 메서드
    private void sendRealtimeNotification(SseEmitter emitter, Long userId, String message) {
        try {
            Users user = usersRepository.findById(userId)
                    .orElseThrow(() -> new RuntimeException("User not found with id: " + userId));

            Alert newAlert = Alert.builder()
                    .user(user)
                    .message(message)
                    .build();
            alertRepository.save(newAlert);

            AlertSendDTO newAlertDto = AlertSendDTO.fromEntity(newAlert);
            String notificationJson = new Gson().toJson(newAlertDto);

            emitter.send(SseEmitter.event().name("new-alert").data(notificationJson));
        } catch (IOException exception) {
            emitterRepository.removeEmitter(userId);
            emitter.completeWithError(exception);
        }
    }

    // 오프라인 알림 저장 메서드
    private void saveOfflineNotification(Long userId, String message) {
        Users user = usersRepository.findById(userId)
                .orElseThrow(() -> new RuntimeException("User not found with id: " + userId));

        Alert alert = Alert.builder()
                .user(user)
                .message(message)
                .build();
        alertRepository.save(alert);
    }

    // SSE 연결 초기화 및 이전 알림 전송 메서드
    public SseEmitter init(Long userId) {
        Optional<SseEmitter> existingEmitter = emitterRepository.get(userId);
        if (existingEmitter.isPresent()) {
            existingEmitter.get().complete();
            emitterRepository.removeEmitter(userId);
        }
        SseEmitter newEmitter = new SseEmitter(DEFAULT_TIMEOUT);
        emitterRepository.addEmitter(userId, newEmitter);

        newEmitter.onCompletion(() -> emitterRepository.removeEmitter(userId));
        newEmitter.onTimeout(() -> emitterRepository.removeEmitter(userId));
        newEmitter.onError(e -> emitterRepository.removeEmitter(userId));

        sendUnreadNotifications(newEmitter, userId);
        return newEmitter;
    }

    // 읽지 않은 알림 전송 메서드
    private void sendUnreadNotifications(SseEmitter emitter, Long userId) {
        List<Alert> unreadAlerts = alertRepository.findByUser_UsersId(userId);
        List<AlertSendDTO> unreadAlertsDto = unreadAlerts.stream()
                .map(AlertSendDTO::fromEntity)
                .collect(Collectors.toList());

        if (!unreadAlerts.isEmpty()) {
            String notificationsJson = convertNotificationsListToJson(unreadAlertsDto);
            try {
                emitter.send(SseEmitter.event().name("init").data(notificationsJson));
            } catch (IOException e) {
                emitter.completeWithError(e);
            }
        }
    }

    // 알림 삭제 메서드
    public void deleteNotification(Long alertId) {
        Alert alert = alertRepository.findById(alertId)
                .orElseThrow(() -> new RuntimeException("Alert not found with id: " + alertId));
        alertRepository.delete(alert);
    }

    // JSON 데이터 변환 메서드
    private String convertNotificationsListToJson(List<AlertSendDTO> alerts) {
        Gson gson = new Gson();
        return gson.toJson(alerts);
    }
}
  1. NotificationService 클래스: 알림 관련 로직을 처리하는 서비스 클래스이다. EmitterRepository, UserRepository, AlertRepository를 주입받아 사용한다.
  2. 실시간 알림 전송 (sendNotification): 특정 사용자에게 알림을 보내는 메서드이다.
    사용자가 온라인 상태일 경우 실시간으로 알림을 보내고, 오프라인일 경우 데이터베이스에 저장한다.
  3. SSE 연결 초기화 (init): 특정 사용자를 위한 새로운 SseEmitter 객체를 생성하고, 초기화 이벤트를 설정한다.
    또한, 사용자의 미처리된 알림을 전송한다.
  4. 읽지 않은 알림 전송 (sendUnreadNotifications): 사용자의 미처리된 알림을 조회하고, 이를 SseEmitter를 통해 클라이언트로 전송한다.
  5. 알림 삭제 (deleteNotification): 특정 알림 ID에 해당하는 알림을 데이터베이스에서 삭제한다.
  6. EmitterRepository 클래스: SseEmitter 객체를 저장하고 관리하는 리포지토리 클래스이다.
    사용자 ID와
    SseEmitter의 매핑을 관리한다.

이슈 발생

Open Session in View(OSIV)와 SSE에서의 연결 고갈 문제

프로젝트 실행 후 알림서비스 기능을 여러번 실행하니 connection pool 에러로 서버가 멈추는 현상이 일어났다.

 

OSIV는 spring.jpa.open-in-view 속성을 디폴트를 true로 설정함으로써 활성화된다.

이 설정은 HTTP 요청이 시작될 때부터 종료될 때까지 데이터베이스 세션을 열린 상태로 유지한다.

일반적인 웹 요청에서는 문제가 되지 않을 수 있지만,

SSE(Server-Sent Events)와 같이 장시간 연결을 유지하는 서비스에서는 큰 문제가 되어 고갈 문제가 생긴것이다.

 

좀 더 SSE 연결의 특성을 보자면  SSE는 서버에서 클라이언트로 실시간으로 정보를 지속적으로 푸시하는 방법이다.

이 기술은 주로 실시간 알림, 라이브 티커 등에 사용되며,

HTTP 연결을 장시간 열어 두어 데이터가 필요할 때마다 스트림을 통해 전송한다.

 

따라서 SSE를 통해 알림 서비스를 구현할 때, 각 사용자의 연결마다 개별적인 데이터베이스 세션을 계속해서 열어 두어야 한다.

이는 OSIV 설정으로 인해 자동으로 처리된며 많은 사용자가 동시에 연결을 시도하면,

이 풀에서 사용 가능한 연결 수가 고갈되어 추가 요청이 불가능해질 수 있다.

 

(OSIV가 정리 글  -> https://josolha.tistory.com/49 )


이슈 해결

 

해결책으로 OSIV 비활성화를 진행했다.

(spring.jpa.open-in-view: false -> yml 코드 추가)

 

이렇게 하면 각 HTTP 요청의 처리가 끝나는 즉시 데이터베이스 연결이 반환되어,

연결 풀의 데이터베이스 연결을 효율적으로 관리할 수 있게 된다.

 

각 트랜잭션은 필요한 작업을 수행하는 동안만 데이터베이스 연결을 사용하고,

작업이 완료되면 연결을 즉시 해제하여 다른 요청이나 트랜잭션이 해당 연결을 재사용할 수 있도록 한다.

 

이 방식은 특히 많은 동시 요청을 처리해야 하는 애플리케이션에서 연결 고갈 문제를 효과적으로 방지할 수 있다고한다.


결과 이미지

 

책을 나눔 받기 헤더 이미지

알림 오기 전

 

 

책을 나눔 받기 헤더 이미지

알림 이 후


끝.

'Spring' 카테고리의 다른 글

JPA, Hibernate, Spring Data JPA  (0) 2023.12.04
Servlet Container , Spring Container  (0) 2023.12.02
AWS S3 (이미지 다운로드 에러)  (1) 2023.11.20
JPA의 @Builder , @Builder.Default  (1) 2023.11.13
스프링 (AOP)  (0) 2023.11.09