웹소켓으로 실시간 채팅 구현하기

김서아·2024년 3월 12일

OZ의 집

목록 보기
2/2
post-thumbnail

소켓 통신

소켓 통신이란? https://helloworld-88.tistory.com/215

🏷️ HTTP 통신과 소켓 통신의 차이점

  • HTTP 통신은 소켓 기반으로 작동하지만, 일반 소켓 통신과는 구별되는 특징을 가집니다.
  • 데이터를 빈번하게 주고받아야 하는 상황에서는 일반적으로 소켓 통신을 선호하며, 그렇지 않은 경우 HTTP 통신이 더 적합합니다.
  • HTTP는 사용자가 서버에 요청을 보내고 그에 대한 응답을 받는 단방향 통신 방식인 반면, 소켓 통신은 서버와 클라이언트 간 양방향 통신을 지원합니다.
  • 연결을 지속적으로 유지하는 소켓 통신은 HTTP 통신보다 더 많은 자원을 사용합니다.

Websocket

  • 양방향 통신 기능을 제공하기 위해 개발된 프로토콜로, 단방향 HTTP 프로토콜과의 호환성을 가지고 있습니다.
  • 웹소켓은 일반 소켓 통신과 달리 HTTP의 포트(80)를 사용하기 때문에 방화벽 제약이 적습니다.
  • 초기 접속은 HTTP 프로토콜을 통해 이루어지고, 이후의 통신은 웹소켓 자체 프로토콜로 진행됩니다.
  • 웹 소켓은 HTTP를 사용하는 네트워크 데이터 통신의 한계를 극복하기 위해 고안되었습니다.

Polling,Long Polling,Streaming

웹소켓이 존재하기 전에는 Polling이나 Long Polling,Streaming 등의 방식으로 해결했었다.

위와 같은 방식은 오른쪽 링크 참조하길 바란다. 참조 링크


  • 이처럼 HTTP 통신의 기본 작동 방식인 연결을 맺고 바로 해제하는 절차는 데이터 전송 효율을 저하시키며, 이로 인해 실시간 통신이 필요한 웹 애플리케이션에서는 대체적으로 외부 플러그인에 의존하게 되었습니다.

  • 이러한 문제점을 해결하고자, 2014년에 HTML5 표준에 웹소켓 기술이 통합되었습니다. 웹소켓 기술은 한 번의 접속 요청과 서버의 응답 이후에도 연결을 지속 유지하며, 서버가 클라이언트로부터 별도의 요청을 받지 않아도 데이터를 송신할 수 있게 하는 프로토콜입니다.

  • 이 프로토콜은 ws://를 통한 요청으로 시작하며, 보안을 강화한 wss:// 형태도 지원합니다. 웹소켓은 HTTP 환경 내에서 양방향, 즉 전이중 통신을 가능하게 하는 프로토콜로, RFC 6455 문서에 그 세부 사항이 명시되어 있습니다.

  • 초기 연결 설정은 HTTP 프로토콜을 통해 수행되나, HandShaking 절차가 완료된 이후에는 HTTP와는 다른, 웹소켓 자체의 통신 방식을 사용합니다.


장점

  • 웹소켓의 핵심 장점은 처음 연결을 시작할 때 보통의 웹 요청(HTTP)으로 통신을 시작한다는 것입니다. 이 방법은 우리가 이미 익숙한 웹 환경에 자연스럽게 녹아들며, 일반적인 웹 포트인 80과 443을 사용함으로써 별도의 방화벽 설정 없이도 양방향 통신을 가능하게 해줍니다. 더불어, 웹의 보안 규칙(CORS)과 인증 절차를 기존 방식대로 유지할 수 있어 보안성도 높습니다.

  • 웹소켓은 웹에 동적인 기능을 추가하기에 탁월하지만, 모든 상황에 완벽한 해법은 아닙니다. 변경이 드물고 양이 적은 데이터의 경우, Ajax나 스트리밍, 롱 폴링 같은 기술이 더 적합할 수 있습니다. 그러나 실시간으로 데이터를 주고받아야 하고, 데이터의 변경이 잦으며, 빠른 반응속도가 필요한 경우 웹소켓이 더 나은 해결책입니다.

  • 예를 들어, 뉴스나 이메일, 소셜 미디어 업데이트 같은 것은 몇 분마다 한 번씩 정보를 새로고침하는 것이 적당합니다. 하지만, 협업 툴, 온라인 게임, 금융 앱 등 실시간 작업이 중요한 서비스는 웹소켓의 실시간 통신 능력이 큰 장점이 됩니다.


WebSocket 구현

1. 라이브러리 추가

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

2. WebSocket Handler

  • 소켓 통신을 통해 서버는 여러 클라이언트와 동시에 연결을 유지할 수 있는 1:N 관계를 형성합니다. 이는 하나의 서버가 여러 클라이언트의 접속을 받아들일 수 있음을 의미합니다. 이러한 구조에서 서버는 다양한 클라이언트로부터 전송된 메시지를 적절히 처리하기 위해 특별한 핸들러가 필요합니다.

  • 텍스트 기반의 채팅 애플리케이션을 만들기 위해서는 TextWebSocketHandler 클래스를 활용할 것입니다. 이 클래스를 상속받아 구현된 핸들러는 클라이언트로부터 메시지를 받았을 때 이를 로그로 기록하고, 클라이언트에게 환영 메시지를 보내는 기능을 수행합니다.

아래 코드는 직접 구현해본 Handler이다.

@Slf4j
@RequiredArgsConstructor
@Component
public class WebsocketHandler extends TextWebSocketHandler {

	private final ObjectMapper objectMapper;

	private final ChattRoomService chattRoomService;

	private final ChattService chattService;

	private Map<String, HashSet<WebSocketSession>> roomSessions = new ConcurrentHashMap<>();

	@Override
	public void afterConnectionEstablished(WebSocketSession session) throws Exception {
		log.info("웹소켓 연결이 설정되었습니다.");
	}

	@Override
	protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
		String payload = message.getPayload();
		ChattDTO chatt = objectMapper.readValue(payload, ChattDTO.class);
		String roomNumStr = String.valueOf(chatt.getRoomNum());

		switch (chatt.getType()) {
		case ENTER:
			enterRoom(session, chatt, roomNumStr);
			break;
		case TALK:
			saveAndBroadcastMessage(session, chatt, roomNumStr);
			break;
		case QUIT:
			quitRoom(session, chatt, roomNumStr);
			break;
		}
	}

	private void enterRoom(WebSocketSession session, ChattDTO chatt, String roomNumStr) throws IOException {
	    HashSet<WebSocketSession> sessions = roomSessions.computeIfAbsent(roomNumStr, k -> new HashSet<>());
	    sessions.add(session);

	 // 현재 채팅방에 입장한 사용자 수
	    int numberOfUsers = sessions.size();
	    
	    // 사용자 입장 메시지 방송
	    chatt.setMsg(chatt.getSender() + "님이 입장했습니다.");
	    broadcast(chatt, roomNumStr);
	}


	private void saveAndBroadcastMessage(WebSocketSession session, ChattDTO chattDTO, String roomNumStr)
	        throws IOException {
	    chattDTO.setRoomNum(Integer.parseInt(roomNumStr));
	    chattDTO.setInTime(LocalDateTime.now());
	    chattDTO.setReadStatus(1);
	    String receiver = determineReceiver(chattDTO.getRoomNum(), chattDTO.getSender());
	    chattDTO.setRecipient(receiver);
	    
	    Chatt savedChatt = chattService.save(new Chatt(chattDTO)); // DTO를 엔티티로 변환 후 저장
	    broadcast(chattDTO, roomNumStr);
	}
	
	private String determineReceiver(Integer roomNum, String sender) {
	    String receiver = chattRoomService.findOtherParticipant(roomNum, sender);
	    return receiver;
	}

	private void quitRoom(WebSocketSession session, ChattDTO chatt, String roomNumStr) throws IOException {
	    log.info("Handling quit for user: {} in room: {}", chatt.getSender(), roomNumStr);
	    HashSet<WebSocketSession> sessions = roomSessions.get(roomNumStr);
	    if (sessions != null) {
	        sessions.remove(session);
	        log.info("Updated presence status to off for user: {} in room: {}", chatt.getSender(), roomNumStr);
	        chatt.setMsg(chatt.getSender() + "님이 퇴장했습니다.");
	        broadcast(chatt, roomNumStr);
	    }
	}

	private void broadcast(ChattDTO chatt, String roomNumStr) throws IOException {
		HashSet<WebSocketSession> sessions = roomSessions.get(roomNumStr);
		if (sessions != null) {
			TextMessage textMessage = new TextMessage(objectMapper.writeValueAsString(chatt));
			for (WebSocketSession session : sessions) {
				session.sendMessage(textMessage);
			}
		}
	}

	@Override
	public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
		log.info("웹소켓 연결이 종료되었습니다: " + session.getId());
		// 모든 채팅방에서 해당 세션 제거
		roomSessions.forEach((roomNum, sessions) -> sessions.remove(session));
	}

	// 모든 연결된 세션에 Heartbeat 메시지를 보냄
	@Scheduled(fixedRate = 10000) // 10초마다 실행
	public void sendHeartbeat() {
		roomSessions.values().forEach(sessions -> {
			sessions.forEach(session -> {
				if (session.isOpen()) {
					try {
						session.sendMessage(new TextMessage("{\"type\":\"HEARTBEAT\",\"message\":\"ping\"}"));
						log.info("Heartbeat 메시지 'ping'을 전송했습니다.");
					} catch (IOException e) {
						log.error("Heartbeat 메시지 전송 중 오류 발생", e);
					}
				}
			});
		});
	}

}
  1. 웹소켓 연결 설정 : afterConnectionEstablished 메서드
    • 클라이언트와의 웹소켓 연결이 성공적으로 이루어지면 로그 출력
  2. 텍스트 메시지 처리 : handleTextMessage 메서드
    • 클라이언트로부터 받은 텍스트 메시지를 처리합니다. 메시지는 JSON 형태로 예상되며, ChattDTO 객체로 변환됩니다.
    • 메시지 타입에 따라 다른 동작을 수행합니다. (ENTER: 방 입장, TALK: 메시지 송수신, QUIT: 방 퇴장)
    • 방 입장 시 enterRoom, 메시지 저장 및 방송 saveAndBroadcastMessage, 방 퇴장 처리 quitRoom, 메시지 방송 broadcast
  3. 연결 종료 처리 : afterConnectionClosed 메서드
    • 웹소켓 연결이 종료되면, 해당 세션을 모든 채팅방에서 제거
  4. 하트비트 메시지 전송 sendHearbeat 메서드
    • 정기적으로(위 코드에선 10초마다) 모든 연결된 세션에 하트비트 메시지를 보내 연결 상태를 확인

3. WebSocket Config

  • WebSocket 통신 기능을 활성화하기 위해 @EnableWebSocket 어노테이션을 사용합니다. 이는 클래스가 WebSocket 서버로 동작하도록 설정하는 데 필요합니다.

  • WebSocket에 접속할 수 있는 엔드포인트를 /ws/chat으로 설정합니다. 또한, 다른 도메인에서의 접속을 허용하기 위해 setAllowedOrigins("*")를 추가하여 CORS 정책을 설정합니다. 이로써 클라이언트는 ws://localhost:8080/ws/chat을 통해 서버와의 연결을 시도하고 메시지 통신을 시작할 수 있습니다.

@RequiredArgsConstructor
@Configuration
@EnableWebSocket   //이게 websocket 서버로서 동작하겠다는 어노테이션
public class WebsocketConfig implements WebSocketConfigurer { 
    private final WebSocketHandler webSocketHandler;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(webSocketHandler, "/ws/chat").setAllowedOrigins("*");
        // handler 등록,   js에서 new Websocket할 때 경로 지정
        //다른 url에서도 접속할 수있게(CORS방지)
    }
}

4. ChattController

@Controller
@RequiredArgsConstructor
@RequestMapping("/ozMarket")
public class ChattController {

	private final ChattRoomService chattRoomService;

	private final ChattService chattService;

	private final MarketProService marketProService;

	private static final String PATH = "C:\\ozMarket\\";

	// 채팅 리스트
	@GetMapping("/chatts")
	public String chatList(Model model, @AuthenticationPrincipal MemberSecurityDTO member, HttpServletRequest req) {
		if (member == null) {
			req.setAttribute("msg", "로그인 후 이용가능합니다.");
			req.setAttribute("url", "/main");
			return "message";
		}
		List<ChattRoom> roomList = chattRoomService.findBymyId(member.getMemberNickname());
		String nickname = member.getMemberNickname();

		roomList.forEach(room -> {
	        Optional<Chatt> lastMessage = Optional.ofNullable(chattService.findLastMessageByRoomNum(room.getRoomNum()));
	        if (lastMessage.isPresent()) {
	            room.setLastMessage(lastMessage.get().getMsg());
	        } else {
	            room.setLastMessage("주고 받은 메시지가 없습니다.");
	        }
	    });
		
		for (ChattRoom r : roomList) {
			if (r.getMyId().equals(nickname)) {
				r.setPartner(r.getOtherId());
			} else {
				r.setPartner(r.getMyId());
			}
		}

		model.addAttribute("roomList", roomList);
		model.addAttribute("nickname", nickname);

		return "client/ozMarket/chatRoom";
	}

	// 채팅방 생성
	@PostMapping("/chatt")
	public String createChatRoom(@AuthenticationPrincipal MemberSecurityDTO member,
			@RequestParam("proNum") Integer proNum, @RequestParam("sellerNickname") String sellerNickname, HttpServletRequest req, Model model) {
		if (member == null) {
			req.setAttribute("msg", "로그인 후 이용가능합니다.");
			req.setAttribute("url", "/main");
			return "message";
		}
		ChattRoom room = chattRoomService.findOrCreateRoom(member.getMemberNickname(), sellerNickname, proNum);
		model.addAttribute("room", room);

		return "redirect:/ozMarket/chattRoom/" + room.getRoomNum();
	}

	// 채팅방 입장
	@GetMapping("/chattRoom/{roomNum}")
	public String chatRoom(HttpServletRequest req, @AuthenticationPrincipal MemberSecurityDTO member, Model model,
			@PathVariable("roomNum") String roomNum) throws IOException {
		ChattRoom room = chattRoomService.findRoomByNum(Integer.parseInt(roomNum));
		List<ChattRoom> roomList = chattRoomService.findBymyId(member.getMemberNickname());

		String nickname = member.getMemberNickname();

		for (ChattRoom r : roomList) {
			if (r.getMyId().equals(nickname)) {
				r.setPartner(r.getOtherId());
			} else {
				r.setPartner(r.getMyId());
			}
		}

		model.addAttribute("roomList", roomList);
		model.addAttribute("memberNickname", member.getMemberNickname());
		model.addAttribute("roomNum", roomNum);
		model.addAttribute("room", room);
		model.addAttribute("nickname", nickname);

		Integer proNum = room.getProNum();

		String root = PATH + "\\" + "img";
		Optional<OzMarketProDTO> optionalDto = Optional.of(marketProService.getProduct(proNum));

		if (optionalDto.isPresent()) {
			OzMarketProDTO dto = optionalDto.get();
			req.setAttribute("getProduct", dto);

			List<String> encodedImagesPro = new ArrayList<>();
			String[] imageProFiles = dto.getProImgPro().split(",");
			for (String imageFileName : imageProFiles) {
				File imageProFile = new File(root, imageFileName);
			}
		}

		String partnerNickname;
		if (room.getMyId().equals(nickname)) {
			partnerNickname = room.getOtherId();
		} else {
			partnerNickname = room.getMyId();
		}
		
		roomList.forEach(room1 -> {
	        Optional<Chatt> lastMessage = Optional.ofNullable(chattService.findLastMessageByRoomNum(room1.getRoomNum()));
	        if (lastMessage.isPresent()) {
	            room1.setLastMessage(lastMessage.get().getMsg());
	        } else {
	            room1.setLastMessage("주고 받은 메시지가 없습니다.");
	        }
	    });
		model.addAttribute("partnerNickname", partnerNickname);

		model.addAttribute("roomList", roomList);
		model.addAttribute("nickname", nickname);

		return "client/ozMarket/chatt";
	}

	// 채팅방 메시지 로드 엔드포인트
	@CrossOrigin
	@GetMapping("/chattRoom/messages/{roomNum}")
	public ResponseEntity<List<Chatt>> getMessagesByRoomNum(@PathVariable("roomNum") Integer roomNum) {
		List<Chatt> messages = chattService.findMessagesByRoomNum(roomNum);
		return ResponseEntity.ok(messages);
	}

	@PostMapping("/reserveProduct/{proNum}")
	public String reserveProduct(HttpServletRequest req, @PathVariable("proNum") Integer proNum,
			@AuthenticationPrincipal MemberSecurityDTO member) {
		boolean res = marketProService.reserveProduct(proNum, member.getMemberNickname());
		if (res) {
			req.setAttribute("msg", "성공했습니다.");
		} else {
			req.setAttribute("msg", "실패했습니다. 다시 시도하세요.");
		}
		req.setAttribute("url", "/ozMarket/myInfo");
		return "message";
	}

	@PostMapping("/confirmPurchase/{proNum}")
	public String confirmPurchase(HttpServletRequest req, @PathVariable("proNum") Integer proNum,
			@AuthenticationPrincipal MemberSecurityDTO member) {
		boolean res = marketProService.confirmPurchase(proNum, member.getMemberNickname());
		if (res) {
			req.setAttribute("msg", "성공했습니다.");
		} else {
			req.setAttribute("msg", "실패했습니다. 다시 시도하세요.");
		}
		req.setAttribute("url", "/ozMarket/myInfo");
		return "message";
	}

	@PostMapping("/cancelReservation/{proNum}")
	public String cancelReservation(HttpServletRequest req, @PathVariable("proNum") Integer proNum) {
		boolean res = marketProService.cancelReservation(proNum);
		if (res) {
			req.setAttribute("msg", "성공했습니다.");
		} else {
			req.setAttribute("msg", "실패했습니다. 다시 시도하세요.");
		}
		req.setAttribute("url", "/ozMarket/myInfo");
		return "message";
	}

	@PostMapping("/deleteChatRoom/{roomNum}")
	public String deleteChatRoom(@PathVariable("roomNum") int roomNum, Model model) {
	    
		chattService.deleteMessagesByRoomNum(roomNum);

		// 채팅방 삭제 기능을 호출하고 결과를 받아옵니다.
	    chattRoomService.deleteChatRoom(roomNum);
	    
	    // 채팅방 목록 페이지로 리다이렉트합니다.
	    return "redirect:/ozMarket/chatts";
	}


}
  • 위 코드에서 채팅방 목록 보기, 채팅방 생성, 입장 그리고 이전 채팅 메시지 보기, 상품 예약, 구매 확정, 예약 취소, 채팅방 삭제기능을 구현했습니다.

  • 제일 중요한 실시간 채팅을 위해 아래 jsp 코드를 보자.

5. Chat.jsp

<script>
	var wsUrl = "ws://" + location.host + "/ws/chat"; // 웹소켓 서버 URL
	var socket; // 웹소켓 객체를 전역 변수로 선언
	var sender = '<c:out value="${memberNickname}" />'; // 현재 사용자의 닉네임
	var roomNum = '<c:out value="${room.roomNum}" />'; // 현재 방 번호
	var nickname = "<c:out value='${nickname}'/>"; // JSP에서 JavaScript 변수로 값을 전달

	function initWebSocket() {
		socket = new WebSocket(wsUrl); // 방 번호를 URL에 포함하여 웹소켓 연결
		socket.onopen = function(event) {
			console.log("Connected to WebSocket server.");
			sendEventMessage("ENTER");
			loadChatHistory(roomNum);
		};
		socket.onmessage = function(event) {
			var msgData = JSON.parse(event.data); // 서버에서 받은 메시지 데이터
			console.log("Received message:", msgData);
			if (msgData.type === "TALK") {
				displayMessage(msgData); // 메시지 표시 함수 호출
			}
		};
		socket.onclose = function(event) {
			console.log("Disconnected from WebSocket server.");
		};
		socket.onerror = function(event) {
			console.error("WebSocket error: ", event);
		};
	}
	// 메시지를 화면에 표시하는 함수
	function displayMessage(messageData) {
	    var msgArea = document.querySelector('.msgArea'); 
    	var msgDiv = document.createElement('div'); // Create a new div for the message
    	var msgBubble = document.createElement('div');
    	var msgContent = document.createElement('div');
    	var msgTime = document.createElement('div');

	    msgContent.textContent = messageData.msg;
	    msgContent.className = 'message-content';

	    msgTime.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
    	msgTime.className = 'message-time';

	    msgBubble.appendChild(msgContent);
    	msgBubble.appendChild(msgTime);

    	if (messageData.sender === sender) {
        	msgDiv.className = 'chat-message sent';
        	msgBubble.className = 'message-bubble sent';
    	} else {
     	   msgDiv.className = 'chat-message received';
    	    msgBubble.className = 'message-bubble received';
    	}

    	msgDiv.appendChild(msgBubble);
    
    	msgArea.appendChild(msgDiv);

    	// Scroll to the new message
    	msgArea.scrollTop = msgArea.scrollHeight;
	}

	// 시간 데이터를 '오전/오후 시:분' 형식으로 변환하는 함수
	function formatTime(timestamp) {
		var date = new Date(timestamp); // Timestamp를 Date 객체로 변환
		return date.toLocaleTimeString('ko-KR', {
		hour: '2-digit',
    	minute: '2-digit',
		hour12: true
		});
	}

	function sendMsg() {
		var messageInput = document.getElementById('messageInput');
		var message = messageInput.value;
        if (message === "") {
			alert("메시지를 입력하세요.");
			return;
		}
		var msgData = {
			type : "TALK",
			sender : sender,
			msg : message,
			roomNum : roomNum
		};
		socket.send(JSON.stringify(msgData));
		messageInput.value = ''; // 입력 필드 초기화
	}

	function sendEventMessage(eventType) {
		var msgData = {
		type : eventType,
		sender : sender,
		roomNum : roomNum
		};
		socket.send(JSON.stringify(msgData));
	}

	window.onload = function() {
	initWebSocket();
	document.getElementById('messageInput').addEventListener(
    'keypress', function(e) {
		if (e.key === 'Enter' && !e.shiftKey) {
		e.preventDefault(); // 기본 이벤트 방지
		sendMsg(); // 메시지 전송 함수 호출
    	}
	});
    };

var httpUrl = "http://" + location.host + "/ozMarket/chattRoom/messages/"+ roomNum;
	// 채팅 기록을 불러오는 함수
	function loadChatHistory(roomNumber) {

		// 서버에 AJAX 요청을 보내 이전 메시지를 불러옵니다.
		var xhr = new XMLHttpRequest();
		xhr.open('GET', httpUrl, true);
		xhr.onload = function() {
			if (this.status == 200) {
				var messages = JSON.parse(this.responseText);
				insertDateDivider(messages);

				// 메시지를 화면에 표시
				messages.forEach(function(message) {
				displayMessage(message);
				});
				// 스크롤을 맨 아래로 설정
				var msgArea = document.querySelector('.msgArea');
				msgArea.scrollTop = msgArea.scrollHeight;
			} else {
				console.error('메시지를 불러오는데 실패했습니다.');
                }
			};
			xhr.send();
			}

	function insertDateDivider(messages) {
		let lastDate = null;
		const msgArea = document.querySelector('.msgArea'); // msgArea를 여기서 정의

		messages.forEach((message) => {
		const messageDate = new Date(message.inTime).toLocaleDateString('ko-KR');
		if (messageDate !== lastDate) {
		lastDate = messageDate;
		const dateDivider = document.createElement('div');
		dateDivider.className = 'date-divider';
		dateDivider.textContent = messageDate; // 현지화된 날짜 표시
		msgArea.appendChild(dateDivider);
        }
		displayMessage(message);
		});
	}

	function quit() {
		sendEventMessage("QUIT");
		socket.close();
		window.location.href = '/ozMarket/chatts'; // 채팅 목록 페이지로 리다이렉션
	}
</script>

문제점

  • 짧은 시간에 구현하기 위해 효율적인 코드가 아닌 구주막구하게 작성되었다. stomp 프로토콜을 추가하여 더 효율적으로 관리가 필요하다.

추후 추가할 점

  • 읽음/읽지않음 표시
  • 알림 기능
  • 실시간 채팅 시 채팅방 목록의 마지막 메시지 ajax로 실시간 변동
  • 보안을 위한 메시지 암호화
  • 사진 전송

0개의 댓글