[Java, JavaScript] 웹 소켓을 이용해 다중 1대1 채팅방 구현하기(DB 연동)

2023. 9. 20. 22:32프로그래밍/Java

개발 환경 : Spring boot 2.7.6 (JPA), mysql, javascript(ajax)

 

팀 프로젝트 중 채팅을 구현해야 할 일이 생겼다.

채팅을 간단하게 1대 다 혹은 1대1 로만 구현하는 것은 굉장히 쉬워보였으나 내가 원하는 기능은 '1대1 대화가 서로 서로 가능한 채팅방 구현'이었다. 이게 된다면 1대다, 다대다도 비슷한 원리로 모두 가능하다. 그러나 우선은 내가 필요한 기능은 1대1로 서로 구분된 채팅을 하는 것이었다. 이를 위해서 우선 socket의 원리에 대해 이해해야 했다.

내 막연한 상상으로는 하나의 포트번호에서 여러개의 소켓으로 서로 다른 통신을 한다는 것이 불가능해보였기 때문이다.

그러나 엔드포인트를 다르게 둔다면 같은 포트에서도 충분히(당연한건지 모르겠으나) 가능한 일이었다.

엔드포인트란 네트워크에 연결해 통신하는 모든 디바이스를 뜻하는데 쉽게 직역하자면 다음과 같다.

"각각의 채팅방에 다른 roomID를 두어 같은 사용자라도 채팅방마다 별도의 소켓 세션을 생성한다."

 

* 프로젝트 배포에 실패했기 때문에 나는 서로 다른 채팅 구현을 url로 세션을 구분지어 테스트했다. 실제로 배포됐다고 한다면 local 피시가 모두 다를 것이고 당연히 session값도 다를 것이다. 그러므로 배포를 했다고 해도 기능 동작에는 이상이 없을 것이다.

 

우선 소켓 통신을 위한 방법을 순서대로 나열하면 아래와 같다.

 

1. java에서 WebSocketHandler 혹은 TextWebSocketHandler를 상속받은 클래스를 작성한다(혹은 @Endpoint 어노테이션을 붙여도 된다고 하지만 나의 경우에는 javascript에서의 소켓 생성을 java클래스에서 전혀 인식하지 못했다)

 

2. 해당 클래스에서 소켓 생성 시 인식할 엔드포인트 url을 WebSocketHandler를 구현한 클래스에 addHandler 메서드의 매개변수로 등록한다.

registry.addHandler(myHandler,"/ws/{roomId}/{userNumber}");

 

3. javascript에서 다음과 같이 소켓 생성을 한다.

webSocket = new WebSocket('ws://' + location.host + '/ws/' + roomId + '/' + userNumber);

여기서 roomId 및 userNumber는 내가 설정해놓은 javascript 변수값이다. 

 

4. 소켓 생성을 하게 되면 java의 Handler로 등록해놓은 클래스에서(여기서는 MyHandler) 이벤트를 감지한다. WebSocketHandelr 클래스를 상속받으면 소켓의 액션을 감지하는 여러개의 메서드를 오버라이딩 할 수 있는데 주요 메서드는 다음과 같다.

 

@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {

javascript에서 new Websocket()으로 소켓을 생성하고 엔드포인트 및 url이 정상적으로 설정돼서 java에서도 소켓 연결이 성공했을 때 실행되는 메서드. 그 때 session값이 무엇인지 매개변수를 통해 알 수 있다.

 

@Override
public void handleTextMessage(WebSocketSession session, TextMessage message) {

javascript에서 sendMessage메서드를 통해 message를 전송하게 되면 어떤 세션이 메세지를 보냈는지, 메세지의 내용이 무엇인지 알 수 있다. javasciprt에서 메세지를 전송할때는 JSON형식으로 데이터를 전송해야한다(javascript property혹은 JSON.stringify() 사용)

 

@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception {

소켓이 비정상적이든 정상적이든 어떤 이유로 인해서 종료가 됐을 때 호출되는 메서드이다. javascript에서 소켓 종료는 페이지를 새로고침하거나 혹은 내가 특정 버튼 클리시 onClose() 메서드를 실행하면 java에서 위 메서드가 실행된다.

 

@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {

에러가 발생했을 시에 실행되는 메서드이다. 

 

이렇게 총 4개의 메서드만 오버라이딩해도 필요한 기능을 모두 구현할 수 있다.

 

이렇게 구현해놓으면 javascript에서 소켓을 생성하고 제거할때 java에서도 전역변수로 webSocketSession들을 저장하고 있는 Map 객체들을 같이 업데이트 시켜주면 되며 채팅방 구분은 javascript에서 소켓 생성시 매개변수로 사용한 엔드포인트를 기준으로 Map 객체에 세션을 삽입한다. 

 

 

-SessionManager

@Service
public class SessionManager {

    

    private final Map<Long, Map<Long, WebSocketSession>> roomList = new HashMap<>();

    public void addSession(Long roomId, Long userNumber, WebSocketSession session) {

        Map<Long, WebSocketSession> sessions = new HashMap<>(); // 세션은 그때그떄 다른 걸 넣어줘야한다.

        if (roomList.get(roomId) == null) {
            sessions.put(userNumber, session);
            roomList.put(roomId, sessions);
        } else {
            roomList.get(roomId).put(userNumber, session);
        }

        System.out.println(roomList);

    }

    public WebSocketSession getSession(Long roomId, Long userNumber) {


        Map<Long, WebSocketSession> chatRoom = roomList.get(roomId);
        WebSocketSession session = null;


        for (Long userKey : chatRoom.keySet()) {

            if (!userKey.equals(userNumber)) {
                session = chatRoom.get(userKey);
            }
        }
        return session;
    }

    public void removeSession(Long roomId, Long userNumber){

        roomList.get(roomId).remove(userNumber);


    }



}

SessionManger클래스에서는 MyHandler 클래스에서 감지한 세션의 생성, 메세지 전송, 종료 등에 따라 실행돼야 하는 메서드가 구현돼있다. 이 클래스에서 전역변수인 roomList의 session을 관리한다.

 

 

-MyHandler

@Service
@SessionAttributes("user")
@Slf4j
public class MyHandler extends TextWebSocketHandler {

    Map<String, WebSocketSession> sessionMap = new HashMap<>(); //웹소켓 세션을 담아둘 맵

    @Autowired
    ChatRoomService chatRoomService;

    @Autowired
    ChatMessageService chatMessageService;

    @Autowired
    WebChatController webChatController;

    @Autowired
    SessionManager sessionManager;


    // 소켓 연결이 완료됐을 때 실행되는 메서드
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {


        log.info("연결완료");

        String str = session.getUri().getPath().substring((session.getId().lastIndexOf("/")) + 5);
        Long roomId = Long.parseLong(str.split("/")[0]);
        Long userNumber = Long.parseLong(str.split("/")[1]);

        log.info("연결된 소켓의 RoomIㅇ는 {}",roomId);

        sessionManager.addSession(roomId, userNumber, session);

    }


    @Override
    public void handleTextMessage(WebSocketSession session, TextMessage message) {


        String type = "";  // 전송된 메시지의 타입.
        JSONObject jsonObject = null;
        JSONParser jsonParser = new JSONParser();
        ObjectMapper objectMapper = new ObjectMapper();

        objectMapper.registerModule(new JavaTimeModule());
        String str = session.getUri().getPath().substring((session.getId().lastIndexOf("/")) + 5);
        System.out.println("str = " + str);

        Long roomId = Long.parseLong(str.split("/")[0]);
        Long userNumber = Long.parseLong(str.split("/")[1]);

        WebSocketSession yourSession = sessionManager.getSession(roomId, userNumber);


        try {

            type = (String) ((JSONObject) jsonParser.parse(message.getPayload())).get("type");

            if (type.equals("close")) { // 소켓 종료 알림시 세션 제거후 바로 메서드 종료.
                sessionManager.removeSession(roomId, userNumber);
                return;
            }

            jsonObject = (JSONObject) jsonParser.parse(message.getPayload());

            String content = (String) jsonObject.get("content");
            ChatMessage chatMessage = chatMessageService.saveMessage(roomId, userNumber, content);// 저장 한 후의 message content를 가지고 온다.
            String parseSendMessage = "";
            parseSendMessage = objectMapper.writeValueAsString(chatMessage);

            jsonObject.put("message", parseSendMessage);

            if (yourSession != null) {
                yourSession.sendMessage(new TextMessage(jsonObject.toJSONString()));
            }

        } catch (ParseException e) {
            throw new RuntimeException(e);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }


    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {

        System.out.println("error");
        ;
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception {

        log.info("핸들러에의해 remove 메서드 실행(비정상적인 경로로 종료 / reoload, 페이지 종료 등");
        String str = session.getUri().getPath().substring((session.getId().lastIndexOf("/")) + 5);
        Long roomId = Long.parseLong(str.split("/")[0]);
        Long userNumber = Long.parseLong(str.split("/")[1]);
        sessionManager.removeSession(roomId, userNumber);

    }

    @Override
    public boolean supportsPartialMessages() {
        return false;
    }


}

TextWebSocketHandler 클래스를 구현한 클래스이다. 이 handler클래스에 대부분의 기능이 모두 구현돼있다. 

Handler클래스에서는 SessionManager를 통해 java에서의 전역변수인 WebSocketSession등을 관리하고 db 접근이 필요하다면 charRoomSerivce 혹은 chatMessageService로 접근한다.

 

 

-WebChatConfig 클래스

@Configuration
@EnableWebSocket
@Service
public class WebChatConfig implements WebSocketConfigurer {

    @Autowired
    MyHandler myHandler;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {

        registry.addHandler(myHandler,"/ws/{roomId}/{userNumber}");
    }


}

 

여기서는 별다른 기능 없이 MyHandler에 엔드포인트를 지정해주는 설정(Configure) 클래스이다.

 

 

- ChatRoomService 클래스

@Slf4j
@Service
public class ChatRoomService {

    @Autowired
    ChatRoomRepository chatRoomRepository;

    @Autowired
    UserRepository userRepository;



    public List<ChatRoom> getChatRoomList(Long userNumber){


        return chatRoomRepository.selectChatRoomByUserNumber(userNumber);
    }

    public ChatRoom getChatRoomByRoomId(Long roomId){

        return chatRoomRepository.findById(roomId).orElse(null);
    }

    public void addChatRoom(Long writerNumber, Long userNumber){


        //이미 있는 방인지 없는 방인지도 검사해야한다.
        ChatRoom result = chatRoomRepository.findByUser1AndUser2(writerNumber,userNumber);
        if(result != null){ // 이미 생성된 대화방이 있었으면 대화방을 만들 필요가 없다.

            return;
        }

        log.info("작성자 {} 신청자 {}",writerNumber,userNumber);
        log.info("result값{}",result);

        User writer = userRepository.findById(writerNumber).orElse(null);
        User user = userRepository.findById(userNumber).orElse(null);

        ChatRoomForm chatRoomForm = new ChatRoomForm();
        chatRoomForm.setUser1(writer);
        chatRoomForm.setUser2(user);
        ChatRoom chatRoom = chatRoomForm.toEntity();
        chatRoomRepository.save(chatRoom);

    }
}

db의 chat_room Table로 접근하기 위한 service 클래스이다.

 

 

- ChatMessageService

@Service
public class ChatMessageService {

    @Autowired
    ChatMessageRepository chatMessageRepository;

    @Autowired
    UserRepository userRepository;

    @Autowired
    ChatRoomRepository chatRoomRepository;


    public ChatMessage saveMessage(Long roomId, Long userNumber,String content){

        ChatMessage chatMessage = new ChatMessage();
        ChatRoom chatRoom = chatRoomRepository.findById(roomId).orElse(null);
        User user = userRepository.findById(userNumber).orElse(null);

        chatMessage.setUser(user);
        chatMessage.setChatRoom(chatRoom);
        chatMessage.setContent(content);
        chatMessage.setSendTime(new Date());


        ChatMessage saved_message = chatMessageRepository.save(chatMessage);

        return saved_message;

    }

    public List<ChatMessage> getAllMessage(Long roomId){

        ChatRoom chatRoom = chatRoomRepository.findById(roomId).orElse(null);
        List<ChatMessage> chatMessageList = chatMessageRepository.findAllByChatRoomOrderBySendTimeAsc(chatRoom);

        return chatMessageList;
    }

    public List<String> getLastMessageList(Long userNumber, List<ChatRoom> chatRoom){

        List<String> messageList = new ArrayList<>();
        Pageable pageable = PageRequest.of(0,1,Sort.by("sendTime").descending());
        String lastMessage = "";
        for(int i = 0 ; i < chatRoom.size(); i++) {
            Page<String> pageList = chatMessageRepository.selectLastMessageByChatRoom(chatRoom.get(i).getRoomId(), pageable);
            System.out.println(pageList.getContent());
            if(pageList.getContent().size() != 0){
                lastMessage = pageList.getContent().get(0);
            }else{
                lastMessage = "대화내용이 없습니다.";
            }
            messageList.add(lastMessage);
        }

        System.out.println(messageList);
        return messageList;
    }
}

db의 chat_message 테이블로 접근하기 위한 service 클래스이다. 

 

 

- ChatRoomRepository

public interface ChatRoomRepository extends JpaRepository<ChatRoom, Long> {


    @Query("SELECT r, MAX(m.sendTime) AS time " +
            "FROM ChatRoom r " +
            "LEFT JOIN ChatMessage m " +
            "ON r.roomId = m.chatRoom.roomId " +
            "WHERE r.user1.userNumber = :userNumber OR r.user2.userNumber = :userNumber " +
            "GROUP BY r.roomId " +
            "ORDER BY time DESC")
    List<ChatRoom> selectChatRoomByUserNumber(@Param("userNumber")Long userNumber);
    @Query("select r from ChatRoom r where (r.user1.userNumber = :writerNumber and r.user2.userNumber = :userNumber) " +
            "or (r.user1.userNumber = :userNumber and r.user2.userNumber =:writerNumber) ")
    ChatRoom findByUser1AndUser2(@Param("writerNumber")Long writerNumber,@Param("userNumber")Long userNumber);
}

Entity를 통해 db의 chat_room 테이블에 쿼리문을 실행하기 위한 JPA 구현 인터페이스이다.

여담으로 가장 최근에 채팅메시지가 생긴 방을 채팅방 목록 중 가장 상단에 위치하기 위한 JPQL을 작성했다.

 

 

- ChatMessageRepository

public interface ChatMessageRepository extends JpaRepository<ChatMessage, Long> {


    List<ChatMessage> findAllByChatRoomOrderBySendTimeAsc(ChatRoom chatRoom);

    // 조회되는 채팅방과 같은 roomId를 가지는 메서드이면서 동시에 가장 최근의 메시지
    @Query("select m.content from ChatMessage m where m.chatRoom.roomId = :room_id order by m.sendTime desc")
    Page<String> selectLastMessageByChatRoom(@Param("room_id")Long room_id, Pageable pageable);
}

Entity를 통해 db의 chat_message 테이블에 쿼리문을 실행하기 위한 JPA 구현 인터페이스이다.

위쪽의 쿼리메서드(findAll...)는 채팅방 목록 중 채팅방을 선택하게 되면 해당 채팅방의 전체 메시지 목록을 불러온다.

아래쪽의 JPQL은 각 채팅방읙 가장 마지막 메시지가 무엇인지 리스트로 가지고 온다.

 

 

 

- ChatMessage (Entity)

@Entity
@Setter
@Getter
@ToString
//@AllArgsConstructor // 모든 멤버 변수를 초기화하는 생성자
@NoArgsConstructor // 기본 생성자.
@EqualsAndHashCode // equa

public class ChatMessage {



    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)// 기본키 값을 자동으로 생성한다.
    @Column(name = "message_id")       // 예슬 추가함(외래키)
    private Long messageId;


    @ManyToOne(fetch = FetchType.EAGER, cascade = CascadeType.MERGE)
    @JoinColumn( name = "room_id")
    private ChatRoom chatRoom;


    @Column
    private String content; // 메시지 내용

    @Column
    private Date sendTime; // 전송 시각

    @ManyToOne(fetch = FetchType.EAGER, cascade = CascadeType.MERGE)
    @JoinColumn(name = "sender_id")
    private User user; // 해당 메시지의 발신자  (테이블 컬럼명 : sender_id)




}

ORM, JPA를 위한 Entity 클래스이다.

 

 

- ChatRoom(Entity)

@Entity
@Setter
@Getter
@ToString
@NoArgsConstructor
@EqualsAndHashCode
@AllArgsConstructor
@Builder
public class ChatRoom {


    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "room_id")
    private Long roomId;


    @ManyToOne(fetch = FetchType.EAGER, cascade = CascadeType.MERGE)
    @JoinColumn( name = "userNumber1")
    private User user1;

    @ManyToOne(fetch = FetchType.EAGER, cascade = CascadeType.MERGE)
    @JoinColumn( name = "userNumber2")
    private User user2;


    @JsonIgnore
    @OneToMany(mappedBy = "chatRoom", orphanRemoval = true, fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    private List<ChatMessage> messages = new ArrayList<>();




}

ORM, JPA를 위한 Entity 클래스이다.

 

 

-WebChatController


@Slf4j
@Controller
@SessionAttributes({"user","user1","user2","user3","user4"})

public class WebChatController {


    @Autowired
    ChatRoomService chatRoomService;

    @Autowired
    ChatMessageService chatMessageService;

    @Autowired
    UserService userService;



    @PostMapping("/getUserNumber")
    @ResponseBody
    public Long getUserNumber(HttpSession session,@ModelAttribute("userNumber")Long userNumber,Model model){

        User user = (User) session.getAttribute("user");
        System.out.println(user);

//        if(user == null) {
//            User user = userService.getUserByNumber(userNumber);
//            Long userNumber = Long.valueOf(-1);
//        }
//
        if(userNumber == 2){
            model.addAttribute("user2",user);
        }else if(userNumber == 1){
            model.addAttribute("user1",user);
        }else if(userNumber == 3){
            model.addAttribute("user3",user);
        }else if(userNumber == 4){
            model.addAttribute("user4",user);
        }


        if(user != null){
            userNumber = user.getUserNumber();
        }
        System.out.println("userNumber입니다" + userNumber);

        return userNumber;
    }



    @GetMapping("addSession2")
    public String addSession2(Model model,HttpSession session){
        List<ChatRoom> roomList = null;


        User user2 = (User)session.getAttribute("user2");
        User user3 = (User)session.getAttribute("user3");
        List<User> userList = new ArrayList<>();

        if(user2 !=null){
            userList.add(user2);

        }else if(user3 != null){
            userList.add(user3);

        }

        User user = new User();
        long userNumber = 2;

        user = userService.getUserByNumber(userNumber);
        model.addAttribute("user2",user);

        return "community/home";

    }


    @PostMapping("/getMessageList")
    @ResponseBody
    public Map<String,Object> getMessageList(@ModelAttribute("roomId")Long roomId){


        Map<String,Object> chatRoomInfo = new HashMap<>();

        List<ChatMessage> chatMessageList = chatMessageService.getAllMessage(roomId);
        ChatRoom chatRoom = chatRoomService.getChatRoomByRoomId(roomId);

        chatRoomInfo.put("chatRoom",chatRoom);
        chatRoomInfo.put("chatMessageList",chatMessageList);

        return chatRoomInfo;
    }


    @RequestMapping("/chatTest1")
    public String chatTest2(Model model, HttpSession session){


        User user = new User();
        long userNumber = 1;

        user = userService.getUserByNumber(userNumber);
        model.addAttribute("user1",user);

        return "community/home";

    }


    @RequestMapping("/chatTest2")
    public String chatTest1(Model model, HttpSession session){


        User user = new User();
        long userNumber = 2;

        user = userService.getUserByNumber(userNumber);
        model.addAttribute("user2",user);

        return "community/home";

    }

    @RequestMapping("/chatTest3")
    public String chatTest3(Model model, HttpSession session){


        User user = new User();
        long userNumber = 3;

        user = userService.getUserByNumber(userNumber);
        model.addAttribute("user3",user);

        return "community/home";

    }


    @RequestMapping("/chatTest4")
    public String chatTest4(Model model, HttpSession session){


        User user = new User();
        long userNumber = 4;

        user = userService.getUserByNumber(userNumber);
        model.addAttribute("user4",user);

        return "community/home";

    }


    @PostMapping("/getChatData")
    @ResponseBody
    public Map<String,Object> getChatData(@ModelAttribute("userNumber")Long userNumber){


        System.out.println("userNUmber : " + userNumber);



        List<ChatRoom> roomList = null;
        List<String> lastMessageList = null;
        Map<String, Object> userInfo = new HashMap<>();


        // null 일수 있기 때문에 sessionㄱ 객체로 뽑아낸다
        User user = userService.getUserByNumber(userNumber);

        if(user != null) {

            userNumber = user.getUserNumber();
            roomList = chatRoomService.getChatRoomList(userNumber);
            lastMessageList = chatMessageService.getLastMessageList(userNumber,roomList);

        }


        // 채팅방을 열게되면 채팅방 목록에 해당하는 마지막 메시지 리스트를 받아와야한다.
        // 여기도 바꿔야하낟. 테스트하려면.
        userInfo.put("user",user);
        userInfo.put("roomList",roomList); // roomList객체를 반환한다.
        userInfo.put("lastMessageList",lastMessageList);
        return userInfo;


    }

    @ResponseBody
    @RequestMapping("/addChatRoom")
    public void addChatRoom(@ModelAttribute("writerNumber")Long writerNumber,
                              @ModelAttribute("userNumber")Long userNumber){


            chatRoomService.addChatRoom(writerNumber, userNumber);

    }




}

ajax 통신을 위한 webChatController. 채팅방 생성 및 소켓 생성을 위해 여러가지 데이터를 넘겨준다. 

 

 

 

 

 

- chat_room, chat_message, user 테이블 간 ERD 설계

 

 

- chat.js

var userNumber = -1; // document load후에 현재의 세션값으로 초기화된다.
var user;
var roomId = -1;
var webSocket = null;


var url = location.pathname.split('/');





$.ajax({

    method: 'POST',
    url: '/getUserNumber',
    data: {userNumber: userNumber}, // 임의로 설정한 userNumber를 넘겨주고 그걸 세션으로 더해준다.
    // 그러면 chatTest1에서는 세션1이 추가되고 chatTest2에서는 세션2기 추가될 것.
    // ***********실제로는 data쿼리없이 진짜 data만 받아와야한다.**********************
    // 여기서 userNumber를 얻어온 후
    success: function (data) {


        // 이떄 얻어낸 usernUmber를 컨트롤러의 세션user에서 얻어온 userNumber와 동일한 값으로 본다.
        userNumber = data;
        console.log(userNumber + "userNumber값");

        if (url[1] === 'chatTest1') {
            userNumber = 1;
        } else if (url[1] === 'chatTest2') {
            userNumber = 2;
        } else if (url[1] === 'chatTest3') {
            userNumber = 3;
        } else if (url[1] === 'chatTest4') {
            userNumber = 4;
        } else if (url[1] === 'manager') {

            userNumber = 0;
        }

    }, error: function () {

    }
})





console.log(userNumber)






//클릭관련 이벤트 함수는 위쪽 배치. 실제 실행되는 함수는 아래쪽 배치.
$('#quit-chat-btn').click(function () {
    $('.chat-modal').css('display', 'none');
    $('.chat-room-block').remove(); // 메시지를 모두 지운다.
    $('#message-input').addClass('disappear'); // 채팅입력하는 채팅창 disappear
    $('.message-block').remove(); // 채팅 메시지 블럭 제거
    $('#back-chat-btn').addClass('disappear'); // 뒤로가기버튼 disappear
    $('.chat-room-info').addClass('disappear');
    $('.my-message').remove();
    $('#chat-modal-content').removeClass('show-chat-room-info'); //  패딩 탑 속성 제거.

    if (webSocket != null) {

        webSocket.close();

    }

})


$('#back-chat-btn').click(function () { // 뒤로가기 버튼 클릭시 채팅내용을 갖고 있던 건 모두 지워버린후 기존의 채팅방 조회 목록으로 진입.

    $('#message-input').addClass('disappear'); // 채팅입력하는 채팅창 disappear
    $('.message-block').remove(); // 채팅 메시지 블럭 제거
    $('#back-chat-btn').addClass('disappear'); // 뒤로가기버튼 disappear
    $('.chat-room-info').addClass('disappear');
    $('#chat-modal-content').removeClass('show-chat-room-info'); //  패딩 탑 속성 제거.
    $('.my-message').remove();
    // 현재 열려 있는 채팅방의 소켓을 닫는다.

    webSocket.close(); // 이것만으로는 java의 소켓 세션이 종료되지 않는다.
    showChatRoom();

})


$('#live-chat').click(function () {

    //모달을 열때마다 userNumber를 요청한다.
    showChatRoom();

    // 소켓이 열린 상태라면 실행해서는 안된다.


    // 세션의 usernumber를 이용해 접속한 채팅방 목록을 불러온다.

})


// 메시지 전송을 눌렀을떄(Enter) sendMessage메서드 실행.
$('.message-input').on('keypress', function (e) {

        sendMessage(e);

    }
)


// 모달 열기 + 채팅방 목록 불러오는 함수
// 처음 모달을 열떄는 소켓 통신이 되어있어야한다.
function showChatRoom() {

    if (userNumber == -1) {

        Swal.fire('로그인 후 이용해 주세요.', '', 'info')

        return;
    }

    $.ajax({

        method: 'POST',
        // url: '/getUserNumber',      원래는 getUserNumber를 통해 관련데이터를 넘겨받는 식이지만
        url: '/getChatData',                                // 현재로서는 서로 다른 세션을 가져오기 위해 userNumber는 원래 가지고 있다고 가정하고 해당 userNumber로
        data: {userNumber: userNumber},                                // 채팅방 및 메세지 정보를 가지고 오는 ajax요청을 실행한다.
        success: function (data) {

            console.log(data)
            user = data.user; // 현재 접속한 유저 정보를 저장한다.

            userNumber = user.userNumber;

            $('.chat-modal').css('display', 'block');


            addLastMessage(data); // roomList정보에 맞게 소켓정보를 추가해준다.

            // 채팅방 목록 불러오기 완료, 불러온 채팅방 ui 추가.

            // 세션을 통해 유저 정보를 가지고 오고 해당 유저 정보를 가진 채팅방을 모두 불러온다.

        }, error: function () {

        }
    })

}

function addLastMessage(data) {

    // 소켓 추가하는 기능.
    user = data.user; // 현재 접속한 유저 정보를 저장한다.
    var roomList = data.roomList; // 현재 속해있는 방의 리스트를 가지고 온다.
    var lastMessageList = data.lastMessageList;

    var body = ''

    for (var i = 0; i < roomList.length; i++) {

        var roomId = roomList[i].roomId;


        var tmpUser = roomList[i].user1.userNumber != user.userNumber ? roomList[i].user1 : roomList[i].user2;

        // 현재 유저 말고 다른 유저를 가지고 온다.

        body += `<div class="chat-room-block" onclick="joinChatRoom(this)" id=${roomId}>` +

            `<div class = "chat-room-block-left"><img class = "chat-room-img" src = "/file/profile_image/${tmpUser.userImg}"></div>` +
            `<div class = "chat-room-block-right">` +
            `<div class = "chat-room-name">${tmpUser.userId.split('@')[0]}</div>` +
            `<div class = "chat-room-content">${lastMessageList[i]}</div>` +
            `</div>` +
            `</div>`
    }

    $('.chat-modal-content').append(body);

}


// enter눌렸을 떄 실행되는 메서드.
function sendMessage(e) {


    if (e.key === "Enter" && $('.message-input').val().trim() != '') {

        e.preventDefault(); // enter키를 막아놓고 텍스트 전송만 실시한다.

        var query = {
            type: 'message',     // 데이터 전송 타입
            roomId: roomId,    // 채팅방번호
            content: $('.message-input').val().trim() // 전송 데이터.
        }

        webSocket.send(JSON.stringify(query));
        // 수신측에서는 onmessage함수내에서 블럭을 추가한다.


        var userId = user.userId.split('@')[0];

        var message = {
            user: user,
            content: $('.message-input').val(),
            sendTime: new Date(),
        }

        addMessageBlock(message);


        $('.message-input').focus();
        $('.message-input').val().trim();
        $('.message-input').val(''); // 빈칸으로 만들어준다.


        var scroll = document.getElementById('chat-modal-content'); // content의 스크롤 아래로 이동.
        scroll.scrollTop = scroll.scrollHeight;


    }
}


// 실제 소켓 연결이 된 후 채팅방에 접속하고 나가고 할때 이뤄지는 동작들(close, send, error등)
function run() {
    webSocket.onopen = function (e) {
        console.log('소켈열림.')
    }
    webSocket.close = function (e) {

        var data = {
            type: "close"
        }
        webSocket.send(JSON.stringify(data));

        console.log("소켓연결이 종료됐습니다.")
    }


    // 소켓 연결된 다른 클라이언트에게 메시지가 왔을 떄.
    webSocket.onmessage = function (e) {

        // 넘어온 데이터는 기존 타입에서 chatMessage 객체가 추가된 property일것이다.
        var data = JSON.parse(e.data);
        // var sendId = JSON.parse(data.sendId); //료 발신자 user객체

        var type = data.type; //메시지 전송타입.
        var message = JSON.parse(data.message);

        console.log(data);


        if (type === 'message') { // 전송된 데이터가 단순 메시지 전송이라면


            addMessageBlock(message);

        }
    }

}

// 메시지를 수신시, 송신이세 modal-content에 자식요소 div추가하는 메서드.
// 수신시에 실시간으로 메시지를 추가해줘야한다. 그러나 이떄 현재 내가 대화하는 roomId와
// 메시지를 발신한 사람의 roomId가 같아야만 한다.
// 소켓이 연결돼있다하더라도
function addMessageBlock(message) {


    var user = message.user; // db의 필드명인 sendId로 저장되는 것이 아니고 Message객체의 필드인 USer객체이름인 user로 저장된다.
    var date = new Date(message.sendTime); // date타입을 string으로 가지고 있기 때문에 다시 파싱한다.
    var hour = String(date.getHours()).padStart(2, '0');
    var minute = String(date.getMinutes()).padStart(2, '0');

    const formattedDate = `${hour}:${minute}`; // '09:11'
    var userId = user.userId.split('@')[0];
    var body = '';

    if (message.user.userNumber != userNumber) { //발신자와 수신자가 다를떄.


        // <div className="message-block" style="margin-top: 100px;">
        //     <div className="message-center">
        //         <div className="message-img">
        //             <img className="message-img" src="/img/store/rentcar/celtos.png" alt="">
        //         </div>
        //
        //         <div className="message-userId">${userIsadasdd}</div>
        //     </div>
        //     <div className="message-bottom" style="display : flex; padding-left : 15px">
        //         <div className="message-content"> s</div>
        //
        //         <div className="message-time">${formattedDate}</div>
        //     </div>
        // </div>
        //
        // body = '<div class = "message-block">' +
        //     '<div class = "message-img">' +
        //     `<img class = "message-img" src="/file/profile_image/${user.userImg}" alt="">` +
        //     '</div>' +
        //     `<div class = "message-center">` +
        //     `<div class = "message-userId">${userId}</div>` +
        //     `<div class = "message-content" >${message.content}</div>` +
        //     `</div>` +
        //     `<div class = "message-time">${formattedDate}</div>` +
        //     '</div>'

        body = `
                  <div class="message-block">
                    <div class="message-center">
                      <div class="message-img">
                             <img class = "message-img" src="/file/profile_image/${user.userImg}" alt=""> 
                      </div>
                      <div class="message-userId">${userId}</div>
                    </div>
                    <div class="message-bottom">
                      <div class="message-content">${message.content}</div>
                      <div class="message-time">${formattedDate}</div>
                    </div>
                  </div>`;

    } else {

        body = '<div class = "my-message">' +
            `<div class = "message-time">${formattedDate}</div>` +
            `<div class = "my-message-center">` +
            `<div class = "my-message-content" >${message.content}</div>` +
            `</div>` +
            '</div>'

    }


    $('.chat-modal-content').append(body); // 채팅창에 메시지를 추가한다.

}


// 채팅방을 들어왔을 때 실행되는 메서드.
// 채팅방에 들어왔을떄는 기존의 버튼을 제거하고 위쪽의 뒤로가기 버튼을 추가해줘야한다.
function joinChatRoom(element) {


    // 방을 접속하게 되면 현재 접속한 방의 번호와 유저정보를 가지고 있어야한다.
    //유저 정보는 어차피 한번 불러오면 세션에서 동일한 유저일 것임.
    roomId = element.id;
    webSocket = new WebSocket('ws://' + location.host + '/ws/' + roomId + '/' + userNumber);

    run(); // addEvenetListener를 추가한다고 생각하면 됨.


    // webSocketList[roomId] = webSocket; // webSocketList에 roomId를 키값으로 해서 webSocket을 차례대로 put한다
    // webSocket = webSocketList[roomId]; // 현재 접속한 방번호로 socket을 교체한다.
    // // 채팅방을 접속하고 나면 대화목록을 모두 불러와야 한다.


    $('.chat-chat-modal-content').remove(); // 채팅방 목록을 제거한다.
    $('#back-chat-btn').removeClass('disappear'); // 뒤로가기 버튼을 생성한다.

    $.ajax({

        method: 'POST',
        url: '/getMessageList',
        data: {roomId: roomId},
        success: function (data) {

            var chatMessageList = data.chatMessageList;
            var chatRoom = data.chatRoom;
            var tmpUser = chatRoom.user1.userNumber != userNumber ? chatRoom.user1 : chatRoom.user2;

            var body = '';

            $('.chat-room-block').remove(); // 채팅방목록을 제거한다.


            for (var i = 0; i < chatMessageList.length; i++) {

                var message = chatMessageList[i]
                addMessageBlock(message);


            }


            ``
            // 이전 대화내용을 모두 불러온다.
            // userNumber는 전역변수로 선언돼있다.
            $('.chat-modal-content').append(body);
            $('.modal-btn').remove(); // 모달 나가기 버튼 없애기
            $('.message-input').removeClass('disappear');
            $('.chat-room-info').removeClass('disappear');
            $('.chat-modal-content').addClass('show-chat-room-info')//패딩 탑 추가ㅏ

            var chatRoomInfo = document.getElementById("chat-room-info"); //채팅방 타이틀바
            chatRoomInfo.innerText = tmpUser.userId.split('@')[0];

            var scroll = document.getElementById('chat-modal-content');
            scroll.scrollTop = scroll.scrollHeight;


        }, error: function () {

        }

    })


}


// show 채팅방목록


// show 채팅방 들어가ㄴㄴㄴ


// accompany_detail의 1:1채팅 클릭시
// 1:1채팅 버튼 클릭시.
// 로그인이 돼있는 상태여야한다.
// detail은 로그인하지 않은 상태에서도 볼 수 있으므로 session을 검사하낟.
// ===========댓글에서 채팅하기 클릭시 로그인하지 않았을때 유효성 검사 추가===========
$('.chat-btn').click(function () {

    console.log('1:1 채팅 클릭!');


    console.log($(this).val());
    var writerNumber = $(this).val();

    console.log(userNumber)
    console.log(writerNumber)

    if (userNumber == writerNumber) { // 내가 나한테 채팅을 걸려고 한다면.


        Swal.fire({
            title: '본인에게는 채팅을 거실 수 없습니다.',
            icon: 'error',
            confirmButtonColor: '#00b8ff',
            confirmButtonText: '확인'
        });

    } else if (userNumber == -1) {
        Swal.fire({
            title: '로그인 후 채팅하실 수 있습니다.',
            icon: 'error',
            confirmButtonColor: '#00b8ff',
            confirmButtonText: '확인'
        }).then(function(){
            location.href='/member/login';
        });

    } else if (userNumber != -1) { // 현재 가지고 있는 userNumber값이 없을때만 채팅방을 생성한다.
        $.ajax({


            url: '/addChatRoom',
            method: 'POST',
            data: {
                writerNumber: writerNumber,
                userNumber: userNumber
                // 맨 처음 userNumber를 한번 얻어온 후로 그 userNumber가 곧 세션값이라고 본다.
                // 따라서 컨트롤러에서 세션을 얻는것이 아닌, 여기서 쿼리로 넘겨준다.
            },
            success: function (data) {

                console.log('통신성공 data' + data);

                showChatRoom();

            }, error: function () {


            }


        })
    }

})

코드를 보면 알겠지만 url을 통해 userNumber를 임의로 설정하는 것을 볼 수 있다. 값을 바꿔가며 테스트해보기를 바란다.

 

 

- chat.html

<div class="chat-modal" style="display:none">
    <div class="no-show">
        <div class="ant-chat-modal-root">
            <div class="ant-chat-modal-mask"></div>
            <div tabindex="-1" class="ant-chat-modal-wrap">
                <div role="dialog" aria-modal="true" class="ant-chat-modal">
                    <div class="ant-chat-modal-content">
                        <div class="chat-modal-top-box">
                            <div><!-- 뒤로가기 아이콘 -->
                                <svg id="back-chat-btn" class="disappear" xmlns="http://www.w3.org/2000/svg"
                                     height="1em" viewBox="0 0 448 512">
                                    
                                    <style>svg {
                                        fill: #b4b4b4
                                    }</style>
                                    <path d="M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l160 160c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L109.2 288 416 288c17.7 0 32-14.3 32-32s-14.3-32-32-32l-306.7 0L214.6 118.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-160 160z"/>
                                </svg>
                            </div>

                            <div> <!-- 나가기 아이콘 -->
                                <svg id="quit-chat-btn" xmlns="http://www.w3.org/2000/svg" height="1em"
                                     viewBox="0 0 384 512">
                                    
                                    <style>svg {
                                        fill: #e3e3e3
                                    }</style>
                                    <path d="M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z"/>
                                </svg>
                            </div>
                        </div>
                        <div class="ant-chat-modal-body">

                                    <div class = "chat-room-info disappear" id="chat-room-info"></div>
                                <div class="chat-modal-content" id = "chat-modal-content">


                                </div>

                            <div class="chat-modal-bottom"
                                 style="display: flex; justify-content: space-between; margin-top: 30px; position: relative; bottom: 0px;">
                            </div>
                            <div class="text-area-box">
                                <textarea type="text" class="message-input disappear" id="message-input"></textarea>
                            </div>
                        </div>
                    </div>
                    <div tabindex="0" aria-hidden="true" cursor="pointer"
                         style="width: 0px; height: 0px; overflow: hidden; outline: none;"></div>
                </div>
            </div>
        </div>
    </div>
</div>
<button id="live-chat">채팅창 열기 💬</button>

 

 

- chat.css


.ant-modal-mask {
    /*position: fixed;*/
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
    z-index: 1000;
    height: 100%;
}


.ant-chat-modal-root{

    position: fixed;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
    overflow: auto;
    outline: 0;
    z-index: 1000;
}



.ant-chat-modal-wrap {

    position: fixed;
    bottom: 100px;
    right: 20px;
    border: none;
    border-radius: 16px;
    background: #8fc5f7;
    /* padding: 12px; */
    font-weight: bold;
    box-shadow: 0px 5px 15px gray;
    z-index: 9999;
    height: 650px;
    box-sizing: border-box;
}

.ant-chat-modal {

    box-sizing: border-box;
    color: #000000d9;
    font-size: 14px;
    font-variant: tabular-nums;
    line-height: 1.5715;
    list-style: none;
    font-feature-settings: "tnum";
    pointer-events: none;
    position: relative;
    width: auto;
    max-width: calc(100vw - 16px);
    /* margin: 8px; */
    height: 100%;
    padding: 15px;

    transform-origin: 216.5px 845px;
    border-radius: 10px;
    width: 430px !important;
}

/*.chatcontentBox {*/
/*    transform-origin: 216.5px 845px;*/
/*    border-radius: 10px;*/
/*    width: 430px !important;*/
/*}*/

.contentBox .ant-chat-modal-content {

    border-radius: 10px;
    box-shadow: rgba(0, 0, 0, 0.3) 0px 0px 4px 0px;
}

.ant-chat-modal-content {
    /*position: relative;*/
    background-color: #fff;
    background-clip: padding-box;
    border: 0;
    border-radius: 2px;
    box-shadow: 0 3px 6px -4px #0000001f, 0 6px 16px #00000014, 0 9px 28px 8px #0000000d;
    pointer-events: auto;
    height: 100%;
}

.ant-chat-modal-body {

    padding: 24px;
    font-size: 14px;
    line-height: 1.5715;
    word-wrap: break-word;
    padding-bottom : 10px;
    height: 560px;
}

.chat-modal-intro-content {
    height: auto;
    background: rgb(234, 234, 234);
    padding: 20px;
    border-radius: 10px;
    margin-bottom: 30px;
    font-size: 15px;
    word-break: keep-all;
}

.chat-modal-content-sections {
    margin-bottom: 50px;
}

.chat-modal-content-sections .title {
    margin-bottom: 10px;
    font-size: 16px;
    font-weight: 700;
}

.chat-modal-content-sections .img {
    display: flex;
    flex: 1 1 0%;
    margin: 15px 0px;
    background: rgb(242, 246, 253);
    -webkit-box-pack: center;
    justify-content: center;
    height: 280px;
    -webkit-box-align: center;
    align-items: center;
}
.chat-modal-content{

    overflow : scroll;
    border : none;
    height: 550px;
    padding-right: 10px;

}
.chat-modal-content-sections .sub-description {
    font-size: 13px;
    color: rgb(146, 146, 146);
    margin-top: 10px;
}

.chat-modal-content-sections .img img {
    width: 100%;
    height: 100%;
}

.chat-modal-content-sections .description {
    font-size: 14px;
}

.chat-modal {
    display: none;
}

.chat-modal-content-sections:last-child img {
    width: 228px;
    height: 218px;
}

.chat-modal-content-sections .description .list {
    display: flex;
    flex-wrap: wrap;
    -webkit-box-align: center;
    align-items: center;
    margin-bottom: 12px;
}

.chat-modal-content-sections .imgsmaller img {
    width: 49%;
    height: 100%;
}

.chat-modal-content-sections .imgsmaller img:nth-child(2) {
    margin-left: 5px;
}

.policy-detail {
    max-height: 0px;
    position: relative;
    color: rgb(112, 112, 112);
    top: 10px;
    margin-bottom: 0px;
}

.arrow {
    width: 14px;
    height: 14px;
    transform: rotate(0);
    -webkit-transform: rotate(0deg);
    transition: -webkit-transform .5s;
    transition: transform .5s;
    transition: transform .5s, -webkit-transform .5s;
    -webkit-transition: transform .5s
}

.onArrow {
    transform: rotate(180deg);
    -webkit-transform: rotate(180deg)
}

.hiddenContent {
    overflow: hidden;
    transition: max-height .5s;
    -webkit-transition: max-height .5s;
}

.show {
    display: block;
}

.showTale {
    display: table;
}

.success-result-info {
    height: auto;
    margin-bottom: 50px;
    font-size: 30px;
    font-weight: bold;
    word-break: keep-all;
}

.orderInfo {
    display: flex;
    justify-content: space-between;
    margin-bottom: 8px;
    font-weight: 700;
}

.orderInfo-detail {
    margin-bottom: 50px;
    border-top: 1px solid black;
}

.orderInfo-con {
    padding-top: 10px;
    font-size: 14px;
    word-break: keep-all;
}

.orderBox {
    width: 580px;
    transform-origin: 216.5px 845px;
    border-radius: 10px;
    width: 500px !important;
}

.chat-modal-open {
    overflow: hidden;
    padding-right: 15px; /* 스크롤바의 너비 만큼 패딩 추가 */
    /*position: fixed;*/
    width: 100%;
}

.chat-modal-content::-webkit-scrollbar {

    display: none;
}



/* 채팅 버튼 */
#live-chat {
    position: fixed;
    bottom: 20px;
    right: 20px;
    min-width: 130px;
    width: auto;
    font-size : 16px;
    border: none;
    border-radius: 16px;
    background: #00B8FF;
    color: white;
    padding: 12px;
    font-weight: bold;
    box-shadow: 0px 5px 15px gray;
    cursor: pointer;
    z-index : 999;

}


/* 모달 관련 커스텀, 채팅방 목록 띄우기 위한 작업 */


.chat-room-block{

    width: 100%;
    border-bottom : 1px solid gainsboro;
    height: 70px;
    display : flex;
    padding : 10px;
    align-items: center;

    /*border-bottom : 1px solid black;*/
}



.chat-room-block:hover{

    cursor: pointer;
    background-color: #d6f2fc;
}

.chat-room-img{
    width: 50px;
    border-radius: 50px;
}

.chat-room-block-left{
    display: flex;
    justify-content: center;
    align-items: center;
}

.chat-room-block-right{
    width: auto;
    padding: 10px;
    /* padding-top: 0px; */
}

.chat-room-name{


    height: 25px;
    font-size : 16px;
    font-weight : 600;
    text-align: left;

}

.chat-room-content{
    height: 30px;
    font-size : 14px;
    display : flex;
    justify-content: left;
    align-items: center;
    font-weight : 450;
}
.chat-room-img{

    display : flex;
    justify-content: center;
    align-items: center;
}


.message-block{

    /*display : flex;*/
    justify-content: left;
    width: auto;
    height: auto;
    margin-bottom: 10px;

}

.message-bottom{
    display : flex;
    padding-left: 30px;
}

.message-img{

    border-radius: 50px;
    width: 30px;
}


.message-content{

    width: auto;
    padding: 5px 10px;
    border-radius: 10px;
    border: 1px solid rgb(218, 218, 218);
    box-shadow: rgb(230, 230, 230);
    margin-right: 10px;
    margin-left: 10px;
    font-weight: 450;
    max-width: 250px;
    width: auto;
}

.message-time{
    display : flex;
    align-items: end;
    font-size : 13px;
    width: 30px;

}

.message-center{

    display : flex;
    height: 25px;
}


.my-message-center{

    display : flex;
}


.message-userId{

    margin-left: 10px;
    text-align: left;

}



.message-input{

    font-size: 16px;
    padding: 10px 10px;
    padding-top: 5px;
    width: 95%;
    height: auto;
    border-radius: 15px;
    border: 1px solid grey;
    outline: none;
    color: black;
    overflow: hidden;
    resize: none;
    font-weight: normal;

}

.message-input ::-webkit-scrollbar{

    display : none;
}

.chat-modal-top-box{

    padding : 0px 10px;
    padding-top : 10px;
    width: auto;
    height: 20px;
    display : flex;
    justify-content: space-between;
}

.chat-modal-top-box svg{
    fill: rgb(160,160,160);
    height: 25px;
}

.chat-modal-top-box div:hover{

    cursor:pointer;

}


.text-area-box{
    text-align: center;
}


.no-show{

    width: 100%;
    height: 100%;
    z-index: 8888;


}



.my-message{

    justify-content: right;
    display : flex;
    margin-bottom: 10px;

}

.my-message-content{

    background-color: #d6f2fc;
    border : 1px solid white;
    width: auto;
    padding: 5px 10px;
    border-radius: 10px;
    box-shadow: rgb(230, 230, 230);
    margin-right: 10px;
    margin-left: 10px;
    font-weight: 450;
    max-width: 250px;

    width: auto;
}

.chat-room-info{

    position : fixed;
    height: 40px;
    font-size: 18px;
    padding-left: 10px;
    background-color: white;
    width: 350px;
    border-bottom : 1px solid rgb(230,230,230);
    /*box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25); !* 가로, 세로, 번짐, 색상 설정 *!*/
}
.show-chat-room-info{

    box-sizing: border-box;
    padding-top: 60px;
    height: 450px;
}

.disappear{

    display : none;
}

 

문제점 : 화면 이동 시 채팅방이 꺼지면서 유지되지 않는다. react와 같은 javascript 라이브러리를 사용해 생명주기를 관리하지 않고 오직 javascript로만 작성했기 때문에 한계가 있다. react로 구현했다고 해도 다른 라이브러리의 의존성을 추가해야할 듯 하다.