Java & Spring

Spring WebSocket을 사용하여 특정 대상에게 메시지(알람) 전송

ju_young 2024. 2. 26. 21:07
728x90

Dependency 추가

spring boot 2를 기준으로 다음과 같이 Dependency를 추가해준다.

dependencies {  
    implementation 'org.springframework.boot:spring-boot-starter-web'  
    implementation 'org.springframework.boot:spring-boot-starter-websocket'  
    implementation 'org.springframework.boot:spring-boot-starter-security'  
    ... 
}

Security 설정

특정 대상에게 메시지를 전송하려면 그 대상의 ID 또는 unique한 값을 알아야한다. 현재 예시는 authentication을 수행하여 유저의 ID를 사용한다.

Principal 객체 정의

record class를 사용했기 때문에 java17 이상을 사용해야한다.

public record MemberPrincipal(  
        Long id,  
        String username,  
        String password,  
        String nickname,  
        Collection<? extends GrantedAuthority> authorities)  
        implements UserDetails {  

    public static MemberPrincipal of(Long id, String username, String password, String nickname) {  
        return new MemberPrincipal(  
                id,  
                username,  
                password,  
                nickname,  
                Set.of(new SimpleGrantedAuthority("USER")));  
    }  

    @Override  
    public Collection<? extends GrantedAuthority> getAuthorities() {  
        return authorities;  
    }  

    @Override  
    public String getPassword() {  
        return password;  
    }  

    @Override  
    public String getUsername() {  
        return username;  
    }  

    @Override  
    public boolean isAccountNonExpired() {  
        return true;  
    }  

    @Override  
    public boolean isAccountNonLocked() {  
        return true;  
    }  

    @Override  
    public boolean isCredentialsNonExpired() {  
        return true;  
    }  

    @Override  
    public boolean isEnabled() {  
        return true;  
    }  
}

Spring Security Configuration

@Configuration  
public class SecurityConfig {  

    @Bean  
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {  

        //모든 권한을 허용하도록 설정한다.
        return http.csrf().disable()  
                .authorizeHttpRequests(  
                        auth ->  
                                auth.requestMatchers(  
                                                PathRequest.toStaticResources().atCommonLocations())  
                                        .permitAll()  
                                        .anyRequest()  
                                        .permitAll())  
                .formLogin(Customizer.withDefaults())  
                .build();  
    }  

    @Bean  
    public UserDetailsService userDetailsService() { 
        //로그인(인증)만 되면 되기때문에 바로 principal을 반환해준다. 
        return username ->  
                MemberPrincipal.of(1L, "admin", "{noop}admin", "admin");  
    }  

    @Bean  
    public PasswordEncoder passwordEncoder() {  
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();  
    }  
}

NOTE
password에 인코딩을 적용하지 않으려면 {noop} 을 붙여주어야한다.

WebSocketHandler 구현

TextWebSocketHandler 메소드 확인

public interface WebSocketHandler {  
    //웹 소켓 연결
    void afterConnectionEstablished(WebSocketSession session) throws Exception;  

    //양방향 데이터 통신
    void handleMessage(WebSocketSession session, WebSocketMessage<?> message) throws Exception;  

    //소켓 통신 에러
    void handleTransportError(WebSocketSession session, Throwable exception) throws Exception;  

    //소켓 연결 종료
    void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception;  

    //부분 메시지를 지원하는지 여부
    boolean supportsPartialMessages();  
}

NOTE
supportsPartialMessages(): 값이 true라면 handleMessage() 를 여러번 호출해서 데이터를 부분적으로 전달한다. 기본 구현체인 AbstractWebSocketHandler에는 false로 설정되어있다.

구현

@Component  
@RequiredArgsConstructor  
public class WebSocketHandler extends TextWebSocketHandler {  
    private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>();  
    private final ObjectMapper objectMapper;  

    /**  
     * 웹 소켓 연결  
     */  
    @Override  
    public void afterConnectionEstablished(WebSocketSession session) {  
        String principalId = getPrincipalId(session);  
        //유저의 id를 key값으로하여 session을 저장한다.  
        sessions.put(principalId, session);  
    }  

    /**  
     * 양방향 데이터 통신  
     */  
    @Override  
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws JsonProcessingException {  
        JsonNode jsonNode = objectMapper.readValue(message.getPayload(), JsonNode.class);  
        //현재 session의 principal은 sender에 해당한다.  
        //message에는 receiver의 id가 포함되어야하며 이를 통해 receiver의 session을 찾아 메시지를 전송한다.  
        Optional.of(sessions.get(jsonNode.get("receiverId").textValue()))  
                .filter(WebSocketSession::isOpen)  
                .ifPresent(s -> {  
                    try {  
                        s.sendMessage(message);  
                    } catch (IOException e) {  
                        throw new RuntimeException(e);  
                    }  
                });  
    }  

    /**  
     * 소켓 통신 에러  
     */  
    @Override  
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {  
        super.handleTransportError(session, exception);  
    }  

    /**  
     * 소켓 연결 종료  
     */  
    @Override  
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {  
        sessions.remove(getPrincipalId(session));  
    }  

    private String getPrincipalId(WebSocketSession session) {  
        //session으로부터 principal을 가져와 현재 인증된 유저의 id를 얻는다. 없다면 예외가 발생한다.  
        return Optional.ofNullable((Authentication) session.getPrincipal())  
                .map(a -> String.valueOf(((MemberPrincipal) a.getPrincipal()).id()))  
                .orElseThrow(() -> new RuntimeException("current not signin"));  
    }  
}

WebSocket 설정

@Configuration  
@EnableWebSocket  
@RequiredArgsConstructor  
public class WebSocketConfig implements WebSocketConfigurer {  
    private final WebSocketHandler webSocketHandler;  

    @Override  
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {  
        registry.addHandler(webSocketHandler, "/myHandler").withSockJS();  
    }  
}

여기서 withSockJS()를 추가해주어야 프론트에서 SockJS를 사용하여 메시지를 주고 받을 수 있다. 그리고 /myHandler는 웹소켓을 연결할때의 엔드포인트이다.

Front

html

아래처럼 생긴 html으로 테스트를 수행했다. 이외에도 postman 등 다양한 툴들이 있다. (코드는 맨 아래의 reference에 gihub링크를 참고)

js

var socket = null;  

function setConnected(connected) {  
    $("#connect").prop("disabled", connected);  
    $("#disconnect").prop("disabled", !connected);  
    if (connected) {  
        $("#conversation").show();  
    } else {  
        $("#conversation").hide();  
    }  
    $("#greetings").html("");  
}  

/**  
 * 웹 소켓 연결  
 */  
function connect() {  
    var ws = new SockJS("/myHandler");  

    /**  
     * 소켓이 열릴 경우 수행  
     */  
    ws.onopen = () => {  
        setConnected(true);  
        console.log('Open');  
    };  

    /**  
     * 소켓을 통해 메시지가 전송될 경우 수행  
     */  
    ws.onmessage = (event) => {  
        console.log('On Message: ', event.data);  
        showGreeting(JSON.parse(event.data).name);  
    };  

    /**  
     * 소켓이 닫힐 경우 수행  
     */  
    ws.onclose = () => {  
        console.log('Close');  
    }  

    socket = ws;  
}  

/**  
 * 웹 소켓 종료(닫기)  
 */function disconnect() {  
    socket.close();  
    setConnected(false);  
}  

/**  
 * 메시지 전송  
 */  
function sendName() {  
    var name = $("#name").val();  
    var receiverId = '1';  
    socket.send(JSON.stringify({'name': name, 'receiverId': receiverId}));  
}  

function showGreeting(message) {  
    $("#greetings").append("<tr><td>" + message + "</td></tr>");  
}  

$(function () {  
    $("form").on('submit', (e) => e.preventDefault());  
    $("#connect").click(() => connect());  
    $("#disconnect").click(() => disconnect());  
    $("#send").click(() => sendName());  
});

위 코드들은 기능 테스트를 위해 최소한으로 구현한 것이기때문에 실제로 서비스에 적용할때는 그 서비스에 맞게 수정하거나 일부분만 참고해야할 듯하다.

 

[reference]
https://github.com/JadeKim042386/SpringWebSocket/tree/main/MessageWebSocketHandler

728x90