본문 바로가기
팀 프로젝트/심화 프로젝트

WebSocket+AOP로 알림 기능 구현하기

by pon9 2025. 1. 9.

webSocket과 aop로 알림 기능을 구현해보자.

 

webSocket vs redis pub/sub

장바구니 캐시에서도 redis를 쓰지 않았고 작은 규모의 프로젝트라 단일 인스턴스에서 간편히 하는 게 좋다.

그리고 인증인가를 jwt로 처리해서 websocket 테스트가 굉장히 편한데 아래에서 다루겠다.

 

aop vs eventListner

eventlistner는 코드가 너무 더러워지고 aop로 해야 알림기능을 한 데 모아 관리하기 편하다.

 

 

1. websocket 초기 설정

//WebSocket
implementation 'org.springframework.boot:spring-boot-starter-websocket'

//AOP
implementation 'org.springframework.boot:spring-boot-starter-aop'

각각 websocket과 aop의존성을 추가해준다. 둘 다 스프링 내장 기능이라 접근성이 좋다는 장점이 있다.

 

@Configuration
@EnableWebSocket
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketConfigurer {

    private final WebSocketService webSocketService;
    private final JwtUtil jwtUtil;

    @Bean
    public WebSocketHandler webSocketHandler() {
        return new WebSocketHandler(webSocketService, jwtUtil);
    }

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(webSocketHandler(), "/ws")
            .setAllowedOrigins("*");
    }

}

websocketConfig: websocket 설정 클래스

웹소켓을 활성화 하고 핸들러를 빈으로 등록한다. 나는 "/ws"로 오는 요청을 처리하도록 설정했는데, 포트 8080에서 서버를 열 경우
ws://localhost:8080/ws 로 웹소켓 테스트가 가능하다.

 

@Service
@Slf4j
public class WebSocketService {

    //사용자 id와 세션 매핑 유지
    private final ConcurrentHashMap<Long, WebSocketSession> sessions 
    = new ConcurrentHashMap<>();

    public void addSession(Long userId, WebSocketSession session) {
        sessions.put(userId, session);
    }

    public void removeSession(Long userId) {
        sessions.remove(userId);
    }

    public void sendNotificationToUser(Long userId, String message) {
        WebSocketSession session = sessions.get(userId);
        if (session != null) {
            try {
                if (session.isOpen()) {
                    session.sendMessage(new TextMessage(message));
                } else {
                    log.warn("유저 {}의 웹소켓 세션을 삭제합니다.", userId);
                    removeSession(userId);
                }
            } catch (IOException e) {
                log.error("유저 {}에게 웹소켓 메세지 전송을 실패하였습니다: 
                {}", userId, e.getMessage());
                removeSession(userId);
            }
        } else {
            log.warn("유저 {} 웹소켓 연결 에러", userId);
        }
    }
}

websocketService: websocket 세션과 메세지 관리ㅊ

사용자 id를 키로 해서 세션을 저장하거나 제거하고, 특정 사용자 id의 세션을 찾아서 메세지를 전송한다.

연결이 닫힌 경우에는 세션을 삭제하고 로깅한다.

 

@RequiredArgsConstructor
public class WebSocketHandler extends TextWebSocketHandler {

    private final WebSocketService webSocketService;
    //토큰 파싱 & 검증 담당
    private final JwtUtil jwtUtil;

    @Override
    public void afterConnectionEstablished(WebSocketSession session) {
        String token = 
        Objects.requireNonNull(session.getUri()).getQuery().split("=")[1];
        Claims claims = 
        jwtUtil.extractClaims(jwtUtil.substringToken(token));
        Long userId = 
        Long.parseLong(claims.getSubject());

        webSocketService.addSession(userId, session);
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
        String token = 
        Objects.requireNonNull(session.getUri()).getQuery().split("=")[1];
        Claims claims = 
        jwtUtil.extractClaims(jwtUtil.substringToken(token));
        Long userId = 
        Long.parseLong(claims.getSubject());

        webSocketService.removeSession(userId);
    }

}

websocketHandler: websocket 요청 처리 핸들러

웹소켓 연결 생명주기를 관리하고 jwt인증을 처리한다.

연결이 열릴 때(afterConnectionEstablished)는 클라이언트에서 전달된 jwt를 읽고 검증하고, 사용자 id를 추출하여 세션을 websocketService에 추가한다.

닫힐 때(-Closed)는 관련 사용자 id를 찾아 세션을 제거한다.

 

 

2. aspect

//리뷰 작성 알림(서버 -> 상점 주인)
@AfterReturning(
    pointcut = "execution(* com.example.outsourcing.domain.review.service.ReviewService.createReview(..))",
    returning = "review"
)
public void afterReviewCreated(UserReviewResponseDto review) {
    Shop shop = shopRepository.findById(review.shopId()).orElseThrow();
    Long ownerId = shop.getUser().getId();

    String message = String.format("새로운 리뷰가 작성되었습니다: %s (평점: %d)", review.content(),
        review.rating());

    webSocketService.sendNotificationToUser(ownerId, message);
}

알림 기능 총 4개를 구현했는데 코드는 하나만 가져왔다.

 

완료된 작업에 한해서만 알림을 보냈기 때문에 @AfterReturning을 사용했고, return 값을 이용해 알림을 보냈다.

createReview 서비스는 완료된 주문에 사용자가 리뷰를 다는 것이기 때문에 dto를 반환한다.

따라서 dto를 이용해 ownerId를 알아내고, 리뷰에 대한 메세지를 보내게 된다.

 

일단 개발하면서 느낀 점은 mapper를 service단에서 처리하니까 aop 파라미터가 dto가 되어버린 점이 불편하다. 역시 매핑은 컨트롤러에서 하는 게 맞는걸까..

 

 

3. 실행 결과

https://websocketking.com/

 

WebSocket King client: A testing and debugging tool for WebSockets

 

websocketking.com

websocket king client로 실시간 알림을 테스트 할 수 있다. 위에서 jwt토큰을 사용하기 때문에 웹소켓 테스트가 편하다 한 것도 이때문이다.

웹소켓은 http의 핸드쉐이크 요청을 통해 연결된다. 이 과정에서 jwt를 http헤더에 포함하여서 클라이언트를 인증할 수 있다.

왼쪽에 손님 토큰, 오른쪽에 사장님 토큰을 두고 연결하면 실시간 알림을 받을 수 있다.

유효한 jwt토큰을 가지고 connect 버튼을 누르면 웹소켓에 연결이 된다.

현실에서는 못 다는 악플을 한번 달아보았다.

jwt토큰엔 유효시간이 있어서, 시간이 지나면 여기서도 자동으로 연결이 끊어진다.

너무 악플만 단 것 같아서 선플도 좀 달아줬다. 하하하하

 

어쨋든 구현 완료