본문 바로가기

학습 기록 (Learning Logs)/Today I Learned

채팅

소켓

소켓 vs 웹소켓 차이점

📌 핵심 차이점:

  • **소켓(Socket)**은 일반적으로 네트워크 애플리케이션에서 TCP 또는 UDP를 직접 다루는 저수준 API
  • TCP/UDP 네트워크 애플리케이션이면 → 소켓(Socket) 사용
  • **웹소켓(WebSocket)**은 HTTP 환경에서 양방향 실시간 통신을 가능하게 해주는 기술
  • 웹 환경에서 실시간 통신이 필요하면 → 웹소켓(WebSocket) 사용

 

 

 

 

1. os 에서 tcp 연결 : ip, port 필요

 

2. 스트림으로 데이터 주고 받음

 

 

 

3. sessionManager 필요 - accept() 여러번 호출

 

 

 

4. 단체 카톡방 원리:

클라이언트1이 서버로 메시지를 보내고, 받은 메시지를 서버가 다른 클라이언트 2,3에게 전달함

 


WebSocketSession vs HttpSession 차이점

 

 

WebSocket에서 연결된 클라이언트를 관리하기 위해 WebSocketSession을 사용합니다.

@Component
public class ChatWebSocketHandler extends TextWebSocketHandler {
    // 채팅방별 WebSocket 세션 저장 (roomId -> Set<WebSocketSession>)
    private final Map<String, Set<WebSocketSession>> chatRooms = new ConcurrentHashMap<>();

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        // WebSocket 연결이 성공하면 세션을 저장
        String roomId = getRoomId(session);
        chatRooms.computeIfAbsent(roomId, k -> ConcurrentHashMap.newKeySet()).add(session);
        System.out.println("새로운 클라이언트 접속: " + session.getId() + " / 채팅방: " + roomId);
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        // 메시지를 같은 채팅방에 있는 모든 세션에게 전송
        String roomId = getRoomId(session);
        for (WebSocketSession wsSession : chatRooms.getOrDefault(roomId, Collections.emptySet())) {
            if (wsSession.isOpen()) {
                wsSession.sendMessage(message);
            }
        }
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        // 연결이 닫히면 세션 제거
        String roomId = getRoomId(session);
        chatRooms.getOrDefault(roomId, Collections.emptySet()).remove(session);
        System.out.println("클라이언트 연결 종료: " + session.getId());
    }

    private String getRoomId(WebSocketSession session) {
        return session.getUri().toString().split("/chat/")[1]; // 예: "/chat/{roomId}" 에서 roomId 추출
    }
}

 

HttpSession을 WebSocket에서 활용

WebSocketSession은 HTTP 요청의 HttpSession과 별개지만, HttpSession 정보를 WebSocket에서 가져올 수 있습니다.

 

WebSocket에서 HttpSession 접근하기

@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
    // HttpSession에 접근하기 위한 속성 가져오기
    Map<String, Object> attributes = session.getAttributes();
    HttpSession httpSession = (HttpSession) attributes.get("HTTP_SESSION");

    if (httpSession != null) {
        String userId = (String) httpSession.getAttribute("USER_ID");
        System.out.println("WebSocket 연결된 사용자 ID: " + userId);
    }
}

채팅 설계: 카카오톡

 

  • 실시간 채팅: WebSocket + Redis Pub/Sub
  • 메시지 저장: MongoDB or RDBMS (PostgreSQL)
  • Redis: 캐싱 및 Pub/Sub 활용

 

채팅방에 여러 유저가 접근하는 방법

 

  • 채팅방 관리
    • ChatRoom 엔티티를 생성하여 방을 관리
    • 각 사용자는 특정 roomId를 가지고 방에 접속
  • Redis Pub/Sub 활용
    • 특정 채팅방에 있는 모든 유저에게 메시지 브로드캐스트
    • 여러 서버에서 WebSocket을 처리할 경우, Redis가 중앙 메시지 브로커 역할 수행
  • WebSocket 연결 관리
    • 각 채팅방에 대한 연결된 세션을 관리
    • 메시지를 보낼 때 해당 방에 있는 모든 클라이언트에게 전송

채팅방 엔티티

 

@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "chatRooms")
public class ChatRoom {
    @Id
    private String roomId;
    private String roomName;
    private Set<String> users = new HashSet<>(); // 채팅방에 있는 유저 목록
}

 

 

채팅방 관리 서비스

@Service
public class ChatRoomService {
    private final ChatRoomRepository chatRoomRepository;

    public ChatRoomService(ChatRoomRepository chatRoomRepository) {
        this.chatRoomRepository = chatRoomRepository;
    }

    // 채팅방 생성
    public ChatRoom createRoom(String roomName) {
        ChatRoom chatRoom = new ChatRoom(UUID.randomUUID().toString(), roomName, new HashSet<>());
        return chatRoomRepository.save(chatRoom);
    }

    // 특정 채팅방 조회
    public ChatRoom getRoomById(String roomId) {
        return chatRoomRepository.findById(roomId)
            .orElseThrow(() -> new RuntimeException("채팅방을 찾을 수 없습니다."));
    }

    // 유저 추가
    public void addUserToRoom(String roomId, String userId) {
        ChatRoom chatRoom = getRoomById(roomId);
        chatRoom.getUsers().add(userId);
        chatRoomRepository.save(chatRoom);
    }
}

 

WebSocket 채팅 핸들러 (여러 유저 지원)

@Component
public class ChatWebSocketHandler extends TextWebSocketHandler {

    private final ObjectMapper objectMapper = new ObjectMapper();
    private final RedisTemplate<String, Object> redisTemplate;
    private final Map<String, Set<WebSocketSession>> chatRoomSessions = new ConcurrentHashMap<>();

    public ChatWebSocketHandler(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        // 세션 연결 시 roomId 저장 (URL에서 추출)
        String roomId = getRoomId(session);
        chatRoomSessions.computeIfAbsent(roomId, k -> new HashSet<>()).add(session);
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        ChatMessage chatMessage = objectMapper.readValue(message.getPayload(), ChatMessage.class);
        String roomId = chatMessage.getRoomId();

        // 메시지를 Redis에 저장하고 Pub/Sub으로 전송
        redisTemplate.convertAndSend("chat:" + roomId, chatMessage);

        // 같은 채팅방에 있는 모든 유저에게 메시지 전송
        broadcastMessage(roomId, chatMessage);
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        // 연결 종료 시 세션 제거
        String roomId = getRoomId(session);
        chatRoomSessions.getOrDefault(roomId, new HashSet<>()).remove(session);
    }

    private void broadcastMessage(String roomId, ChatMessage chatMessage) throws IOException {
        Set<WebSocketSession> sessions = chatRoomSessions.getOrDefault(roomId, Collections.emptySet());
        for (WebSocketSession session : sessions) {
            if (session.isOpen()) {
                session.sendMessage(new TextMessage(objectMapper.writeValueAsString(chatMessage)));
            }
        }
    }

    private String getRoomId(WebSocketSession session) {
        String uri = session.getUri().toString();
        return uri.substring(uri.lastIndexOf("/") + 1); // 예: "/chat/{roomId}"
    }
}

 

 

Redis Pub/Sub 사용 (멀티 서버 지원)

@Service
public class RedisSubscriber implements MessageListener {

    private final ObjectMapper objectMapper = new ObjectMapper();
    private final ChatWebSocketHandler chatWebSocketHandler;

    public RedisSubscriber(ChatWebSocketHandler chatWebSocketHandler) {
        this.chatWebSocketHandler = chatWebSocketHandler;
    }

    @Override
    public void onMessage(Message message, byte[] pattern) {
        try {
            String body = new String(message.getBody(), StandardCharsets.UTF_8);
            ChatMessage chatMessage = objectMapper.readValue(body, ChatMessage.class);
            chatWebSocketHandler.broadcastMessage(chatMessage.getRoomId(), chatMessage);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

 

WebSocket 설정

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {

    private final ChatWebSocketHandler chatWebSocketHandler;

    public WebSocketConfig(ChatWebSocketHandler chatWebSocketHandler) {
        this.chatWebSocketHandler = chatWebSocketHandler;
    }

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

 

채팅방 컨트롤러

@RestController
@RequestMapping("/chat")
public class ChatRoomController {

    private final ChatRoomService chatRoomService;

    public ChatRoomController(ChatRoomService chatRoomService) {
        this.chatRoomService = chatRoomService;
    }

    @PostMapping("/room")
    public ResponseEntity<ChatRoom> createRoom(@RequestParam String roomName) {
        return ResponseEntity.ok(chatRoomService.createRoom(roomName));
    }

    @GetMapping("/room/{roomId}")
    public ResponseEntity<ChatRoom> getRoom(@PathVariable String roomId) {
        return ResponseEntity.ok(chatRoomService.getRoomById(roomId));
    }

    @PostMapping("/room/{roomId}/join")
    public ResponseEntity<Void> joinRoom(@PathVariable String roomId, @RequestParam String userId) {
        chatRoomService.addUserToRoom(roomId, userId);
        return ResponseEntity.ok().build();
    }
}

 

 


 

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-websocket'
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'
}

 

WebSocket 설정 (실시간 메시지 전송)

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {

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

    @Bean
    public WebSocketHandler chatHandler() {
        return new ChatWebSocketHandler();
    }
}
 

WebSocket 핸들러 (메시지 처리)

@Component
public class ChatWebSocketHandler extends TextWebSocketHandler {

    private final ObjectMapper objectMapper = new ObjectMapper();
    private final RedisTemplate<String, Object> redisTemplate;

    public ChatWebSocketHandler(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        ChatMessage chatMessage = objectMapper.readValue(message.getPayload(), ChatMessage.class);
        
        // 메시지 저장 (MongoDB 또는 Redis)
        redisTemplate.convertAndSend("chat", chatMessage);
        
        // 모든 클라이언트에 메시지 전송
        for (WebSocketSession webSocketSession : session.getOpenSessions()) {
            webSocketSession.sendMessage(new TextMessage(objectMapper.writeValueAsString(chatMessage)));
        }
    }
}

 

채팅 메시지 DTO

@Data
@AllArgsConstructor
@NoArgsConstructor
public class ChatMessage {
    private String sender;
    private String message;
    private String roomId;
    private LocalDateTime timestamp;
}

 

Redis Pub/Sub (채팅 메시지 전파)

@Service
public class RedisSubscriber implements MessageListener {

    private final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public void onMessage(Message message, byte[] pattern) {
        try {
            String body = new String(message.getBody(), StandardCharsets.UTF_8);
            ChatMessage chatMessage = objectMapper.readValue(body, ChatMessage.class);
            System.out.println("Received message: " + chatMessage);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

 

MongoDB 메시지 저장

@Document(collection = "chatMessages")
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ChatMessageEntity {
    @Id
    private String id;
    private String sender;
    private String message;
    private String roomId;
    private LocalDateTime timestamp;
}

@Repository
public interface ChatMessageRepository extends MongoRepository<ChatMessageEntity, String> {
    List<ChatMessageEntity> findByRoomIdOrderByTimestamp(String roomId);
}

 

채팅 메시지 저장 서비스

@Service
public class ChatService {

    private final ChatMessageRepository chatMessageRepository;

    public ChatService(ChatMessageRepository chatMessageRepository) {
        this.chatMessageRepository = chatMessageRepository;
    }

    public void saveMessage(ChatMessage chatMessage) {
        ChatMessageEntity entity = new ChatMessageEntity(
            UUID.randomUUID().toString(),
            chatMessage.getSender(),
            chatMessage.getMessage(),
            chatMessage.getRoomId(),
            chatMessage.getTimestamp()
        );
        chatMessageRepository.save(entity);
    }

    public List<ChatMessageEntity> getChatHistory(String roomId) {
        return chatMessageRepository.findByRoomIdOrderByTimestamp(roomId);
    }
}