10.29 추가 : 일반(문자) 채팅만 구현하는 코드는 git 의 master 브렌치를 참고해주시기 바랍니다. master-Webrtc-jpa 는 화상 채팅과 jpa 를 이용한 DB 연결을 포함하는 브렌치입니다.
1. 기본 개념 설명
STOMP
- STOMP 는 Simple Text Oriented Messaging Protocol 의 약자로 메시지 전송을 위한 프로토콜이다. 기본적인 Websocket 과 가장 크게 다른 점은 기존의 Websocket 만을 사용한 통신은 발신자와 수신자를 Spring 단에서 직접 관리를 해야만 했다. 즉 WebSocketHandler 를 만들어서 WebSocket 통신을 하는 사용자들을 Map 등으로 저장하고 이를 직접 관리하며 클라이언트에서 들어오는 메시지를 다른 사용자에게 전달하는 코드를 직접 구현해야만 했다.
그러나 STOMP 는 다르다! stmp 는 pub/sub 기반으로 동작하기 때문에 메시지의 송신, 수신에 대한 처리를 명확하게 정의 할 수 있다. 이 말인 즉슨 추가적으로 코드 작업할 필요 없이 @MessagingMapping 같은 어노테이션을 사용해서 메시지 발행 시 엔드포인트만 조정해줌으로써 훨씬 쉽게 메시지 전송/수신이 가능하다!
그럼 여기서 Sub/Pub 개념에 대해서 알아야 한다.
채팅방 생성 : pub/sub 구현을 위한 Topic 생성 -> 즉 채팅방과 그에 맞는 주제 혹은 채팅방 명을 생각하면 된다.
채팅방 입장 : Topic 구독(sub) -> 해당 채팅방을 웹 소켓이 연결되어있는 동안 구독한다. 구독의 개념은 해당 채팅방을 지속적으로 바라본다라고 생각하면 좋다. 지속적으로 연결되고 바라보고 있기 때문에 새로운 채팅이 송신(pub) 되면 이를 수신(구독, sub) 할 수 있다
==> 이해하기 힘들면 넷플릭스나 디즈니 플러스 같은 '구독' 서비스를 생각해보자. 해당 서비스를 내가 '구독' 하는 상태인 동안 연결해서 내용을 수신(sub) 가능하기 때문!!
채팅방 메시지 수신 : 해당 Topic 로 메시지 송신(pub) -> 해당 채팅방으로 메시지를 송신(pub) 한다.
2. 코드로 확인하기
- 중요한 내용만 추가 설명을 해두었고, 대부분의 내용은 코드 주석으로 달아두었습니다.
- 설명문과 함께 코드 주석 참고해주세요!
1) Gradle 라이브러리 임포트
// WebSocket
implementation 'org.springframework.boot:spring-boot-starter-websocket'
// sockjs
implementation 'org.webjars:sockjs-client:1.5.1'
// stomp
implementation 'org.webjars:stomp-websocket:2.3.4'
// gson
implementation 'com.google.code.gson:gson:2.9.0'
2) SpringConfig - Stomp 엔드포인트 -> sub/pub 엔드포인트 설정
- 여기서 엔드포인트란 일종의 "통신의 도작지점" 이라고 생각하면 될 것 같다. 즉 특정한 통신이 어떤 엔드포인트에 도착했을 때 어떤 행위를 하게 만들것이다라는 것이다.
- 아래에서 처럼 Endpoint 를 "/ws-stomp" 로 설정해두면 웹소켓 통신이 /ws-stomp 로 도착할때 우리는 해당 통신이 웹 소켓 통신 중에서 stomp 통신인 것을 확인하고, 이를 연결한다는 의미이다.
- 추가로 /sub 로 도착하는 것은 메시지를 구독(sub) 할 때 사용하고, "/pub" 로 도착하는 것은 메시지를 송신 할 때 사용하는 엔드포인트가 되는 것이다.
@Configuration
@EnableWebSocketMessageBroker
public class SpringConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// stomp 접속 주소 url => /ws-stomp
registry.addEndpoint("/ws-stomp") // 연결될 엔드포인트
.withSockJS(); // SocketJS 를 연결한다는 설정
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// 메시지를 구독하는 요청 url => 즉 메시지 받을 때
registry.enableSimpleBroker("/sub");
// 메시지를 발행하는 요청 url => 즉 메시지 보낼 때
registry.setApplicationDestinationPrefixes("/pub");
}
}
3) ChatDTO
- 채팅 내용을 위한 DTO => 간단하기 때문에 자세한 내용은 주석 참고
package webChat.dto;
import lombok.*;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class ChatDTO {
// 메시지 타입 : 입장, 채팅
// 메시지 타입에 따라서 동작하는 구조가 달라진다.
// 입장과 퇴장 ENTER 과 LEAVE 의 경우 입장/퇴장 이벤트 처리가 실행되고,
// TALK 는 말 그대로 내용이 해당 채팅방을 SUB 하고 있는 모든 클라이언트에게 전달된다.
public enum MessageType{
ENTER, TALK, LEAVE;
}
private MessageType type; // 메시지 타입
private String roomId; // 방 번호
private String sender; // 채팅을 보낸 사람
private String message; // 메시지
private String time; // 채팅 발송 시간간
}
4) ChatRoom
- 채팅룸을 위한 DTO => 간단하기 때문에 자세한 내용은 주석 참고
package webChat.dto;
import lombok.Data;
import java.util.HashMap;
import java.util.UUID;
// Stomp 를 통해 pub/sub 를 사용하면 구독자 관리가 알아서 된다!!
// 따라서 따로 세션 관리를 하는 코드를 작성할 필도 없고,
// 메시지를 다른 세션의 클라이언트에게 발송하는 것도 구현 필요가 없다!
@Data
public class ChatRoom {
private String roomId; // 채팅방 아이디
private String roomName; // 채팅방 이름
private long userCount; // 채팅방 인원수
private HashMap<String, String> userlist = new HashMap<String, String>();
public ChatRoom create(String roomName){
ChatRoom chatRoom = new ChatRoom();
chatRoom.roomId = UUID.randomUUID().toString();
chatRoom.roomName = roomName;
return chatRoom;
}
}
5) ChatRepository
- 사실 클래스명은 ChaService 가 맞고, 어노테이션도 @Service 가 맞습니다 : 10.26 수정
- DAO 역할을 하는 ChatRepository 이다.
- Repository 라고 해뒀지만 실제로 하는 일은 Service 단의 내용이 섞여있다. 원래는 DB 와 연결해서 로그인 시 유저끼리 채팅이 가능하도록하는 것을 계획했기 때문에 여기 있는 코드는 추후 DB 와 연결되면 Service 와 Repository 로 분리 예정이다.
- @PostConstruct 어노테이션 : 이 어노테이션은 의존성 주입이 이루어진 후 초기화 작업이 필요한 메서드에 사용된다. 해당 어노테이션이 적용된 초기화 메서드는 WAS 가 띄워질 때 혹은 Bean 이 생성된 후 실행된다.
=> 해당 어노테이션은 채팅방을 만들어 저장하는 ChatRoomMap 에 대해서 사용하였다. 이를 통해서 ChatRepository Bean 이 생성된 후 초기화 되도록 만들었다.
- LinkedHashMap : LinkedHashMap 은 key:value 를 저장할때 HashMap 과는 다르게 순서대로! 저장된다는 특징이 있다. 성능상 HashMap 보다 더 좋다는데 크게 차이가 나는것은 아니라고 한다.
자세한 내용은 아래 레퍼런스 참고
package webChat.dao;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;
import webChat.dto.ChatRoom;
import javax.annotation.PostConstruct;
import java.util.*;
// 추후 DB 와 연결 시 Service 와 Repository(DAO) 로 분리 예정
@Repository
@Slf4j
public class ChatRepository {
private Map<String, ChatRoom> chatRoomMap;
@PostConstruct
private void init() {
chatRoomMap = new LinkedHashMap<>();
}
// 전체 채팅방 조회
public List<ChatRoom> findAllRoom(){
// 채팅방 생성 순서를 최근순으로 반환
List chatRooms = new ArrayList<>(chatRoomMap.values());
Collections.reverse(chatRooms);
return chatRooms;
}
// roomID 기준으로 채팅방 찾기
public ChatRoom findRoomById(String roomId){
return chatRoomMap.get(roomId);
}
// roomName 로 채팅방 만들기
public ChatRoom createChatRoom(String roomName){
ChatRoom chatRoom = new ChatRoom().create(roomName); // 채팅룸 이름으로 채팅 룸 생성 후
// map 에 채팅룸 아이디와 만들어진 채팅룸을 저장장
chatRoomMap.put(chatRoom.getRoomId(), chatRoom);
return chatRoom;
}
// 채팅방 인원+1
public void plusUserCnt(String roomId){
ChatRoom room = chatRoomMap.get(roomId);
room.setUserCount(room.getUserCount()+1);
}
// 채팅방 인원-1
public void minusUserCnt(String roomId){
ChatRoom room = chatRoomMap.get(roomId);
room.setUserCount(room.getUserCount()-1);
}
// 채팅방 유저 리스트에 유저 추가
public String addUser(String roomId, String userName){
ChatRoom room = chatRoomMap.get(roomId);
String userUUID = UUID.randomUUID().toString();
// 아이디 중복 확인 후 userList 에 추가
room.getUserlist().put(userUUID, userName);
return userUUID;
}
// 채팅방 유저 이름 중복 확인
public String isDuplicateName(String roomId, String username){
ChatRoom room = chatRoomMap.get(roomId);
String tmp = username;
// 만약 userName 이 중복이라면 랜덤한 숫자를 붙임
// 이때 랜덤한 숫자를 붙였을 때 getUserlist 안에 있는 닉네임이라면 다시 랜덤한 숫자 붙이기!
while(room.getUserlist().containsValue(tmp)){
int ranNum = (int) (Math.random()*100)+1;
tmp = username+ranNum;
}
return tmp;
}
// 채팅방 유저 리스트 삭제
public void delUser(String roomId, String userUUID){
ChatRoom room = chatRoomMap.get(roomId);
room.getUserlist().remove(userUUID);
}
// 채팅방 userName 조회
public String getUserName(String roomId, String userUUID){
ChatRoom room = chatRoomMap.get(roomId);
return room.getUserlist().get(userUUID);
}
// 채팅방 전체 userlist 조회
public ArrayList<String> getUserList(String roomId){
ArrayList<String> list = new ArrayList<>();
ChatRoom room = chatRoomMap.get(roomId);
// hashmap 을 for 문을 돌린 후
// value 값만 뽑아내서 list 에 저장 후 reutrn
room.getUserlist().forEach((key, value) -> list.add(value));
return list;
}
}
6) ChatRoomController
- 전체적으로 채팅방을 조회, 생성, 입장을 관리하는 Controller
package webChat.controller;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import webChat.dao.ChatRepository;
import webChat.dto.ChatRoom;
@Controller
@Slf4j
public class ChatRoomController {
// ChatRepository Bean 가져오기
@Autowired
private ChatRepository chatRepository;
// 채팅 리스트 화면
// / 로 요청이 들어오면 전체 채팅룸 리스트를 담아서 return
@GetMapping("/")
public String goChatRoom(Model model){
model.addAttribute("list", chatRepository.findAllRoom());
// model.addAttribute("user", "hey");
log.info("SHOW ALL ChatList {}", chatRepository.findAllRoom());
return "roomlist";
}
// 채팅방 생성
// 채팅방 생성 후 다시 / 로 return
@PostMapping("/chat/createroom")
public String createRoom(@RequestParam String name, RedirectAttributes rttr) {
ChatRoom room = chatRepository.createChatRoom(name);
log.info("CREATE Chat Room {}", room);
rttr.addFlashAttribute("roomName", room);
return "redirect:/";
}
// 채팅방 입장 화면
// 파라미터로 넘어오는 roomId 를 확인후 해당 roomId 를 기준으로
// 채팅방을 찾아서 클라이언트를 chatroom 으로 보낸다.
@GetMapping("/chat/room")
public String roomDetail(Model model, String roomId){
log.info("roomId {}", roomId);
model.addAttribute("room", chatRepository.findRoomById(roomId));
return "chatroom";
}
}
7) ChatController
- 채팅을 수신(sub) 하고, 송신(pub) 하기 위한 Controller
- @MessageMapping : 이 어노테이션은 Stomp 에서 들어오는 message 를 서버에서 발송(pub) 한 메시지가 도착하는 엔드포인트이다. 여기서 "/chat/enterUser" 로 되어있지만 실제로는 앞에 "/pub" 가 생략되어있다라고 생각하면 된다. 즉 클라이언트가 "/pub/chat/enterUser"로 메시지를 발송하면 @MessageMapping 에 의해서 아래의 해당 어노테이션이 달린 메서드가 실행된다.
- convertAndSend() : 이 메서드는 매개변수로 각각 메시지의 도착 지점과 객체를 넣어준다. 이를 통해서 도착 지점 즉 sub 되는 지점으로 인자로 들어온 객체를 Message 객체로 변환해서 해당 도작지점을 sub 하고 있는 모든 사용자에게 메시지를 보내주게 된다.
package webChat.controller;
// 임포트는 생략
@Slf4j
@RequiredArgsConstructor
@Controller
public class ChatController {
private final SimpMessageSendingOperations template;
@Autowired
ChatRepository repository;
// MessageMapping 을 통해 webSocket 로 들어오는 메시지를 발신 처리한다.
// 이때 클라이언트에서는 /pub/chat/message 로 요청하게 되고 이것을 controller 가 받아서 처리한다.
// 처리가 완료되면 /sub/chat/room/roomId 로 메시지가 전송된다.
@MessageMapping("/chat/enterUser")
public void enterUser(@Payload ChatDTO chat, SimpMessageHeaderAccessor headerAccessor) {
// 채팅방 유저+1
repository.plusUserCnt(chat.getRoomId());
// 채팅방에 유저 추가 및 UserUUID 반환
String userUUID = repository.addUser(chat.getRoomId(), chat.getSender());
// 반환 결과를 socket session 에 userUUID 로 저장
headerAccessor.getSessionAttributes().put("userUUID", userUUID);
headerAccessor.getSessionAttributes().put("roomId", chat.getRoomId());
chat.setMessage(chat.getSender() + " 님 입장!!");
template.convertAndSend("/sub/chat/room/" + chat.getRoomId(), chat);
}
// 해당 유저
@MessageMapping("/chat/sendMessage")
public void sendMessage(@Payload ChatDTO chat) {
log.info("CHAT {}", chat);
chat.setMessage(chat.getMessage());
template.convertAndSend("/sub/chat/room/" + chat.getRoomId(), chat);
}
// 유저 퇴장 시에는 EventListener 을 통해서 유저 퇴장을 확인
@EventListener
public void webSocketDisconnectListener(SessionDisconnectEvent event) {
log.info("DisConnEvent {}", event);
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
// stomp 세션에 있던 uuid 와 roomId 를 확인해서 채팅방 유저 리스트와 room 에서 해당 유저를 삭제
String userUUID = (String) headerAccessor.getSessionAttributes().get("userUUID");
String roomId = (String) headerAccessor.getSessionAttributes().get("roomId");
log.info("headAccessor {}", headerAccessor);
// 채팅방 유저 -1
repository.minusUserCnt(roomId);
// 채팅방 유저 리스트에서 UUID 유저 닉네임 조회 및 리스트에서 유저 삭제
String username = repository.getUserName(roomId, userUUID);
repository.delUser(roomId, userUUID);
if (username != null) {
log.info("User Disconnected : " + username);
// builder 어노테이션 활용
ChatDTO chat = ChatDTO.builder()
.type(ChatDTO.MessageType.LEAVE)
.sender(username)
.message(username + " 님 퇴장!!")
.build();
template.convertAndSend("/sub/chat/room/" + roomId, chat);
}
}
// 채팅에 참여한 유저 리스트 반환
@GetMapping("/chat/userlist")
@ResponseBody
public ArrayList<String> userList(String roomId) {
return repository.getUserList(roomId);
}
// 채팅에 참여한 유저 닉네임 중복 확인
@GetMapping("/chat/duplicateName")
@ResponseBody
public String isDuplicateName(@RequestParam("roomId") String roomId, @RequestParam("username") String username) {
// 유저 이름 확인
String userName = repository.isDuplicateName(roomId, username);
log.info("동작확인 {}", userName);
return userName;
}
}
8) Socket.js ; 매우매우매우 중요
- 사실상 채팅의 핵심인 Socket.js
- 스프링 채팅의 70% 는 얘가 다 해준다고 생각하면 된다. 백엔드단에서 할 것은 오히려 적은 편이라고 생각될 정도
- 처음 웹 통신 시작시 지정된 엔드포인트로 소켓 통신을 시작하고, 지정된 주소를 지속으로 sub(구독) 하게 된다. 또한 지정한 주소로 pub(발송) 하는 역할도 한다.
- 여기는 내 코드에 따라서 수정한 부분이 많다. 다만 소켓 통신에서 가장 중요한 부분인 connect, onConnected, onError, sendMessage, onMessageReceived 정도는 거의 변경하지 않았으니, 이 부분은 꼭 확인하자!
- 자세한 내용은 주석 참고 부탁드립니다! 여기는 제가 잘 모르는 JS 영역이기도 하고, 주석을 보면서 코드를 보면서 해야 더 이해가 쉬울 것 같습니다. 코드 자체를 어렵지 않거든요!
'use strict';
// document.write("<script src='jquery-3.6.1.js'></script>")
document.write("<script\n" +
" src=\"https://code.jquery.com/jquery-3.6.1.min.js\"\n" +
" integrity=\"sha256-o88AwQnZB+VDvE9tvIXrMQaPlFFSUTR+nldQm1LuPXQ=\"\n" +
" crossorigin=\"anonymous\"></script>")
var usernamePage = document.querySelector('#username-page');
var chatPage = document.querySelector('#chat-page');
var usernameForm = document.querySelector('#usernameForm');
var messageForm = document.querySelector('#messageForm');
var messageInput = document.querySelector('#message');
var messageArea = document.querySelector('#messageArea');
var connectingElement = document.querySelector('.connecting');
var stompClient = null;
var username = null;
var colors = [
'#2196F3', '#32c787', '#00BCD4', '#ff5652',
'#ffc107', '#ff85af', '#FF9800', '#39bbb0'
];
// roomId 파라미터 가져오기
const url = new URL(location.href).searchParams;
const roomId = url.get('roomId');
function connect(event) {
username = document.querySelector('#name').value.trim();
// username 중복 확인
isDuplicateName();
// usernamePage 에 hidden 속성 추가해서 가리고
// chatPage 를 등장시킴
usernamePage.classList.add('hidden');
chatPage.classList.remove('hidden');
// 연결하고자하는 Socket 의 endPoint
var socket = new SockJS('/ws-stomp');
stompClient = Stomp.over(socket);
stompClient.connect({}, onConnected, onError);
event.preventDefault();
}
function onConnected() {
// sub 할 url => /sub/chat/room/roomId 로 구독한다
stompClient.subscribe('/sub/chat/room/' + roomId, onMessageReceived);
// 서버에 username 을 가진 유저가 들어왔다는 것을 알림
// /pub/chat/enterUser 로 메시지를 보냄
stompClient.send("/pub/chat/enterUser",
{},
JSON.stringify({
"roomId": roomId,
sender: username,
type: 'ENTER'
})
)
connectingElement.classList.add('hidden');
}
// 유저 닉네임 중복 확인
function isDuplicateName() {
$.ajax({
type: "GET",
url: "/chat/duplicateName",
data: {
"username": username,
"roomId": roomId
},
success: function (data) {
console.log("함수 동작 확인 : " + data);
username = data;
}
})
}
// 유저 리스트 받기
// ajax 로 유저 리스를 받으며 클라이언트가 입장/퇴장 했다는 문구가 나왔을 때마다 실행된다.
function getUserList() {
const $list = $("#list");
$.ajax({
type: "GET",
url: "/chat/userlist",
data: {
"roomId": roomId
},
success: function (data) {
var users = "";
for (let i = 0; i < data.length; i++) {
//console.log("data[i] : "+data[i]);
users += "<li class='dropdown-item'>" + data[i] + "</li>"
}
$list.html(users);
}
})
}
function onError(error) {
connectingElement.textContent = 'Could not connect to WebSocket server. Please refresh this page to try again!';
connectingElement.style.color = 'red';
}
// 메시지 전송때는 JSON 형식을 메시지를 전달한다.
function sendMessage(event) {
var messageContent = messageInput.value.trim();
if (messageContent && stompClient) {
var chatMessage = {
"roomId": roomId,
sender: username,
message: messageInput.value,
type: 'TALK'
};
stompClient.send("/pub/chat/sendMessage", {}, JSON.stringify(chatMessage));
messageInput.value = '';
}
event.preventDefault();
}
// 메시지를 받을 때도 마찬가지로 JSON 타입으로 받으며,
// 넘어온 JSON 형식의 메시지를 parse 해서 사용한다.
function onMessageReceived(payload) {
//console.log("payload 들어오냐? :"+payload);
var chat = JSON.parse(payload.body);
var messageElement = document.createElement('li');
if (chat.type === 'ENTER') { // chatType 이 enter 라면 아래 내용
messageElement.classList.add('event-message');
chat.content = chat.sender + chat.message;
getUserList();
} else if (chat.type === 'LEAVE') { // chatType 가 leave 라면 아래 내용
messageElement.classList.add('event-message');
chat.content = chat.sender + chat.message;
getUserList();
} else { // chatType 이 talk 라면 아래 내용용
messageElement.classList.add('chat-message');
var avatarElement = document.createElement('i');
var avatarText = document.createTextNode(chat.sender[0]);
avatarElement.appendChild(avatarText);
avatarElement.style['background-color'] = getAvatarColor(chat.sender);
messageElement.appendChild(avatarElement);
var usernameElement = document.createElement('span');
var usernameText = document.createTextNode(chat.sender);
usernameElement.appendChild(usernameText);
messageElement.appendChild(usernameElement);
}
var textElement = document.createElement('p');
var messageText = document.createTextNode(chat.message);
textElement.appendChild(messageText);
messageElement.appendChild(textElement);
messageArea.appendChild(messageElement);
messageArea.scrollTop = messageArea.scrollHeight;
}
function getAvatarColor(messageSender) {
var hash = 0;
for (var i = 0; i < messageSender.length; i++) {
hash = 31 * hash + messageSender.charCodeAt(i);
}
var index = Math.abs(hash % colors.length);
return colors[index];
}
usernameForm.addEventListener('submit', connect, true)
messageForm.addEventListener('submit', sendMessage, true)
9) chatroom && roollist html
- chatroom 과 roomlist html 는 설명에서 빼두었습니다. 코드 길이도 엄청 길고, 사실 저도 이 부분들은 검색&다른 사람들것 가져와서 살짝 수정한 정도라 설명하기 어렵기도 하고 자칫 잘못 설명할까봐 부끄럽기도 해서요ㅠㅠ
- 뭣보다 제가 html 을 정말 정말...못하는지라ㅠㅠ 이 부분들은 git 참고해서 확인 부탁드립니다!
- Reference
https://velog.io/@qkrqudcks7/STOMP%EB%9E%80
STOMP란?
websocket 위에서 동작하는 문자 기반 메세징 프로토콜로써 클라이언트와 서버가 전송할 메세지의 유형, 형식, 내용들을 정의하는 매커니즘이다.TCP와 웹소켓과 같은 신뢰할 수 있는 양방향 스트리
velog.io
https://wecandev.tistory.com/105
[Spring] @PostConstruct와 @PreDestroy 어노테이션
@PostConstruct와 @PreDestroy Annotation 사용하기 1. 언제 쓰는가 최근에 개발하다가 딱 한번만 받아오면 되는 값을 사용 시 마다 요청을 새로 호출하는 실수를 했다. 아니 누가 이따위로 개발했는가 하고
wecandev.tistory.com
[스프링 인 액션] 8.비동기 메시지 전송하기
이 장에서 배우는 내용비동기 메시지 전송JMS, RabbitMQ, 카프카를 사용해서 메시지 전송하기보로커에서 메시지 가져오기메시지 리스닝하기이전 장에서는 REST를 사용한 동기화 통신을 알아보았다.
velog.io
HashMap vs LinkedHashMap 성능 비교
HashMap vs LinkedHashMap performance in iteration over values()
Is there any performance difference between HashMap and LinkedHashMap for traversal through values() function?
stackoverflow.com
댓글