[스프링/Spring] 웹소켓으로 실시간 채팅 구현하기

dongbrown·2024년 6월 30일

Spring

목록 보기
4/23
post-thumbnail

1. socket 서버 생성

WebSocket 서버는 TextWebSocketHandler 클래스를 상속받아 구현합니다. 이 클래스는 텍스트 기반의 WebSocket 통신을 처리하는 데 특화되어 있습니다.

import org.springframework.web.socket.handler.TextWebSocketHandler;

public class ChattingServer extends TextWebSocketHandler {
    // 구현 내용
}

주요 메서드와 그 역할:

  • afterConnectionEstablish(WebSocketSession): 클라이언트가 서버에 연결되었을 때 호출됩니다. 여기서 새로운 연결에 대한 초기화 작업을 수행할 수 있습니다.
  • handleTextMessage(WebSocketSession, TextMessage): 클라이언트로부터 텍스트 메시지를 수신했을 때 호출됩니다. 이 메서드에서 메시지를 처리하고 적절한 응답을 보낼 수 있습니다.
  • afterConnectionClosed(WebSocketSession, CloseStatus): 클라이언트와의 연결이 종료되었을 때 호출됩니다. 여기서 연결 종료에 따른 정리 작업을 수행할 수 있습니다.

2. Spring bean 등록

Spring Framework에서 WebSocket을 사용하기 위해서는 필요한 의존성을 추가하고, WebSocket 핸들러를 Spring bean으로 등록해야 합니다.

pom.xml

Maven 프로젝트의 경우, pom.xml 파일에 WebSocket 관련 의존성을 추가합니다.

<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-websocket</artifactId>
    <version>${spring.version}</version>
</dependency>
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-messaging</artifactId>
    <version>${spring.version}</version>
</dependency>

spring-websocket은 WebSocket 기능을, spring-messaging은 메시지 처리 기능을 제공합니다.

servlet-context.xml

XML 기반 설정을 사용하는 경우, servlet-context.xml 파일에 WebSocket 핸들러를 Spring bean으로 등록합니다.

<beans:bean id="chattingServer" class="com.example.ChattingServer"/>

Java 기반 설정을 사용하는 경우, @Configuration 클래스에 다음과 같이 bean을 등록할 수 있습니다:

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
    @Bean
    public ChattingServer chattingServer() {
        return new ChattingServer();
    }

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

3. WebSocket handler 등록

WebSocket 핸들러를 URL 경로에 매핑하여 등록해야 합니다.

XML 설정의 경우 (servlet-context.xml):

<websocket:handlers>
    <websocket:mapping handler="chattingServer" path="/chat"/>
    <websocket:handshake-interceptors>
        <beans:bean class="org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor"/>
    </websocket:handshake-interceptors>
</websocket:handlers>

Java 설정의 경우:

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(chattingServer(), "/chat")
                .addInterceptors(new HttpSessionHandshakeInterceptor())
                .setAllowedOrigins("*");
    }
}

HttpSessionHandshakeInterceptor는 HTTP 세션 속성을 WebSocket 세션으로 복사하는 역할을 합니다. 이를 통해 HTTP 세션에 저장된 사용자 정보 등을 WebSocket 연결에서도 사용할 수 있습니다.

4. 페이지 이동 설정

채팅 페이지로의 이동을 설정하기 위해 WebMvcConfigurer를 구현합니다. 이 설정은 특정 URL 요청을 특정 뷰로 매핑합니다.

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    @Override
    public void addViewControllers(ViewControllerRegistry registry) {
        registry.addViewController("/chatpage").setViewName("chat/chatpage");
    }
}

이 설정은 "/chatpage" URL 요청이 들어오면 "chat/chatpage" 뷰를 반환하도록 합니다. 실제 뷰 파일의 위치와 확장자는 ViewResolver 설정에 따라 결정됩니다.

5. ChattingServer 구현

ChattingServer 클래스는 실제 WebSocket 통신을 처리하는 핵심 로직을 포함합니다.

import org.springframework.web.socket.handler.TextWebSocketHandler;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.TextMessage;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Slf4j
public class ChattingServer extends TextWebSocketHandler {
    
    private Map<String, WebSocketSession> clients = new ConcurrentHashMap<>();
    
    @Autowired
    private ObjectMapper mapper;

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        log.info("사용자 입장: {}", session.getId());
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        Message msg = mapper.readValue(message.getPayload(), Message.class);
        switch(msg.getType()) {
            case "open": addClient(session, msg); break;
            case "msg": sendMessage(msg); break;
            case "close": removeClient(session); break;
        }
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        removeClient(session);
        log.info("사용자 퇴장: {}", session.getId());
    }

    private void addClient(WebSocketSession session, Message msg) {
        clients.put(msg.getSender(), session);
        sendMessage(Message.builder().type("join").sender(msg.getSender()).build());
        sendClientList();
    }

    private void removeClient(WebSocketSession session) {
        String sender = clients.entrySet().stream()
                .filter(entry -> entry.getValue().equals(session))
                .map(Map.Entry::getKey)
                .findFirst()
                .orElse(null);
        
        if (sender != null) {
            clients.remove(sender);
            sendMessage(Message.builder().type("leave").sender(sender).build());
            sendClientList();
        }
    }

    private void sendClientList() {
        try {
            Message msg = Message.builder()
                    .type("clientList")
                    .msg(mapper.writeValueAsString(clients.keySet()))
                    .build();
            sendMessage(msg);
        } catch(Exception e) {
            log.error("Error sending client list", e);
        }
    }

    private void sendMessage(Message msg) {
        String jsonMsg = null;
        try {
            jsonMsg = mapper.writeValueAsString(msg);
        } catch (Exception e) {
            log.error("Error converting message to JSON", e);
            return;
        }

        TextMessage textMessage = new TextMessage(jsonMsg);
        clients.values().forEach(session -> {
            try {
                session.sendMessage(textMessage);
            } catch (Exception e) {
                log.error("Error sending message to client", e);
            }
        });
    }
}

주요 포인트:

  • ConcurrentHashMap을 사용하여 스레드 안전성을 보장합니다.
  • ObjectMapper를 사용하여 JSON 변환을 처리합니다.
  • 메시지 타입에 따라 다른 처리를 수행합니다 (open, msg, close).
  • 클라이언트 목록을 관리하고, 입장/퇴장 시 모든 클라이언트에게 알립니다.
  • 에러 처리와 로깅을 적절히 수행합니다.

6. Message 클래스 구현

Message 클래스는 클라이언트와 서버 간에 주고받는 메시지의 구조를 정의합니다.

import lombok.Builder;
import lombok.Data;

@Data
@Builder
public class Message {
    private String type;    // 메시지 타입 (ex: open, msg, close)
    private String sender;  // 발신자
    private String receiver; // 수신자 (필요한 경우)
    private String msg;     // 실제 메시지 내용
}

Lombok의 @Data@Builder 어노테이션을 사용하여 코드를 간결하게 유지합니다.

7. ObjectMapper 사용

ObjectMapper는 Jackson 라이브러리의 일부로, JSON과 Java 객체 간의 변환을 쉽게 해줍니다.

@Autowired
private ObjectMapper mapper;

// JSON to Object
Message message = mapper.readValue(jsonString, Message.class);

// Object to JSON
String json = mapper.writeValueAsString(message);

주의사항:

  • readValue()writeValueAsString() 메서드는 checked exception을 던질 수 있으므로 적절한 예외 처리가 필요합니다.
  • 순환 참조가 있는 객체를 변환할 때 주의가 필요합니다. 필요하다면 @JsonIgnore 어노테이션을 사용하여 특정 필드를 JSON 변환에서 제외할 수 있습니다.

8. 클라이언트 측 구현 (JavaScript)

클라이언트 측에서는 WebSocket API를 사용하여 서버와 통신합니다.

const socket = new WebSocket("ws://localhost:8080/chat");

socket.onopen = function(e) {
    console.log("서버에 연결됨");
    socket.send(JSON.stringify({type: "open", sender: username}));
};

socket.onmessage = function(event) {
    const data = JSON.parse(event.data);
    switch(data.type) {
        case "join":
            console.log(`${data.sender} 님이 입장하셨습니다.`);
            break;
        case "msg":
            displayMessage(data.sender, data.msg);
            break;
        case "leave":
            console.log(`${data.sender} 님이 퇴장하셨습니다.`);
            break;
        case "clientList":
            updateClientList(JSON.parse(data.msg));
            break;
    }
};

socket.onclose = function(event) {
    if (event.wasClean) {
        console.log(`연결이 정상적으로 종료되었습니다, 코드=${event.code} 이유=${event.reason}`);
    } else {
        console.log('연결이 끊겼습니다');
    }
};

socket.onerror = function(error) {
    console.log(`[에러] ${error.message}`);
};

function sendMessage(message) {
    socket.send(JSON.stringify({
        type: "msg",
        sender: username,
        msg: message
    }));
}

function displayMessage(sender, message) {
    // DOM을 조작하여 메시지를 화면에 표시
}

function updateClientList(clients) {
    // DOM을 조작하여 클라이언트 목록을 업데이트
}
  • WebSocket 연결 시 즉시 'open' 타입의 메시지를 보내 서버에 사용자 정보를 알립니다.
  • 수신된 메시지의 타입에 따라 다른 처리를 수행합니다.
  • 연결 종료와 에러 상황에 대한 처리도 구현합니다.

이 가이드는 Spring Framework를 사용한 WebSocket 서버 구현과 JavaScript를 이용한 클라이언트 구현의 기본적인 틀을 제공합니다. 실제 프로덕션 환경에서는 보안, 성능 최적화, 에러 처리, 확장성 등 추가적인 고려사항이 필요합니다.

0개의 댓글