[Spring] Web Socket(웹 소켓)과 Chatting(채팅) -3

윤재열·2022년 9월 18일
5

Spring

목록 보기
67/72
post-custom-banner

1.STOMP

1. STOMP 란?

  • STOMP는 Simple Text Oriented Messaging Protocol의 약자로 메시지 전송을 위한 프로토콜 입니다.

  • 기본적인 WebSocket 과 가장 크게 다른 점은 기존의 WebSocket 만을 사용한 통신은 발신자와 수신자를 Spring 단에서 직접 관리를 해야만 했습니다. 즉, webSocketHandler 를 만들어서 WebSocket 통신을 하는 사용자들을 저장하고 이를 직접 관리하며 클라이언트에서 들어오는 메시지를 다른 사용자에게 전달하는 코드를 직접 구현해야만 했습니다.

  • 하지만 STOMP는 다릅니다. STOMP 는 pub/sub 기반으로 동작하기 때문에 메시지의 송,수신에 대한 처리를 명확하게 정의 할 수 있습니다. 이말인 즉슨 추가적으로 코드 작업할 필요 없이 @MessagingMapping 같은 어노테이션을 사용하여 메시지 발행 시 엔드포인트만 조정해줌으로써 훨씬 쉽게 메시지 전송/수신이 가능합니다.

pub ,sub 의 개념

  • pub : 송신, sub : 수신
  • 채팅방 생성 : pub/sub 구현을 위한 Topic 생성 -> 즉 채팅방과 그에 맞는 주제 혹은 채팅방 명을 생각하면 됩니다.
  • 채팅방 입장 : Topic 구독(sub) -> 해당 채팅방을 웹 소켓이 연결되어있는 동안 구독합니다.
    • 구독의 개념은 해당 채팅방을 지속적으로 바라본다고 생각하면 좋습니다.
    • 지속적으로 연결되고 바라보고 있기 때문에 새로운 채팅이 송신(pub) 되면 이를 수신(구독,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 endpoint -> sub/pub 엔드포인트 설정

  • 여기서 엔드포인트란 일종의 '통신의 도착지점' 이라고 생각하면 됩니다.
  • 즉 특정한 통신이 어떤 엔드포인트에 도착했을 때 어떤 행위를 하게 만들것이라 라는 것입니다.
  • 아래에서 처럼 EndPoint를 "/ws-stomp" 로 설정해두면 웹소켓 통신이 /ws-stomp로 도착할때 우리는 해당 통신이 웹 소켓 통신 중에서 stomp 통신인 것을 확인하고, 이를 연결한다는 의미입니다.
  • 추가로 "/sub" 로 도착하는 것은 메시지를 구독(sub)할 때 사용하고, "/pub"로 도착하는 것은 메시지를 송신할 때 사용하는 엔드포인트가 되는 것입니다.
package org.codej.websocket.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

@Configuration
@EnableWebSocketMessageBroker
public class SpringConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // stomp 접속 url -> /ws-stomp
        registry.addEndpoint("ws-steomp")//연결될 엔드포인트
                .withSockJS(); //SocketJS를 연결한다는 설정
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // 메시지를 구독하는 요청 url -> 메시지를 받을 때
        registry.enableSimpleBroker("/sub");
        // 메시지를 발행하는 요청 url -> 메시지를 보낼 때
        registry.setApplicationDestinationPrefixes("/pub");
    }
}

4. ChatDto

  • 채팅 내용을 위한 DTO
package org.codej.websocket.domain;

import lombok.Builder;
import lombok.Data;


@Data
@Builder
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
  • Stomp를 통해 pub/sub를 사용하면 구독자 관리가 알아서 됩니다.
  • 따라서 따로 세션 관리를 하는 코드를 작성할 필요가 없고, 메시지를 다른 세션의 클라이언트에게 발송하는 것도 구현 필요가 없습니다.
package org.codej.websocket.domain;

import lombok.Data;
import java.util.HashMap;
import java.util.UUID;

@Data
public class ChatRoom {

    private String roomId;  // 채팅방 아이디
    private String roomName;// 채팅방 이름
    private long userCount; // 채팅방 인원수


    private HashMap<String,String> userList = new HashMap<>();

    public ChatRoom create(String roomName){
        ChatRoom chatRoom = new ChatRoom();
        chatRoom.roomId = UUID.randomUUID().toString();
        chatRoom.roomName = roomName;
        
        return chatRoom;
    }

5. CharRepository

  • DAO 역할을 하는 ChatRepository 입니다.
  • 사실 Repository라고 해뒀지만 실제로 하는 일은 Service 단의 내용이 섞여있습니다.
  • 원래는 DB와 연결해서 로그인 시 유저끼리 채팅이 가능하도록 하는 것을 계획하였기 때문에 여기 있는 코드는 추후 DB와 연결되면 분리할 예정입니다.
  • @PostConstruct : 이 어노테이션은 의존성 주입이 이루어진 후 초기화 작업이 필요한 메서드에 사용됩니다.
    해당 어노테이션이 적용된 초기화 메서드는 WAS가 띄워질 때 혹은 Bean이 생성된 후 실행됩니다.
package org.codej.websocket.repository;

import lombok.extern.slf4j.Slf4j;
import org.codej.websocket.domain.ChatRoom;
import org.springframework.stereotype.Repository;

import javax.annotation.PostConstruct;
import java.util.*;

@Repository
@Slf4j
//추후 DB와 연결 시 Service 와 Repository 로 분리 예정
public class ChatRepository {

    private Map<String, ChatRoom> chatRoomMap;

    @PostConstruct
    public void init(){
        chatRoomMap = new HashMap<>();
    }

    // 전체 채팅방 조회
    public List<ChatRoom> findAllRoom(){
        //채팅방 생성 순서를 최근순으로 반환
        List chatRooms = new ArrayList<>(chatRoomMap.values());
        Collections.reverse(chatRooms);

        return chatRooms;
    }

    // roomId 기준으로 채팅방 찾기
    public ChatRoom findByRoomId(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 increaseUser(String roomId){

        ChatRoom chatRoom = chatRoomMap.get(roomId);
        chatRoom.setUserCount(chatRoom.getUserCount()+1);
    }

    // 채팅방 인원 -1
    public void decreaseUser(String roomId){

        ChatRoom chatRoom = chatRoomMap.get(roomId);
        chatRoom.setUserCount(chatRoom.getUserCount()-1);
    }

    //채팅방 유저 리스트에 유저추가
    public  String addUser(String roomId, String userName){

        ChatRoom chatRoom = chatRoomMap.get(roomId);
        String userUUID = UUID.randomUUID().toString();
        //아이디 중복 확인 후 userList에 추가
        chatRoom.getUserList().put(userUUID,userName);

        return userUUID;
    }

    // 채팅방 유저 이름 중복 확인
    public String isDuplicateName(String roomId,String username){

        ChatRoom chatRoom = chatRoomMap.get(roomId);
        String temp = username;

        // 만약 username이 중복이라면 랜덤한 숫자를 붙여준다.
        // 이 때 랜덤한 숫자를 붙였을때 getUserList 안에 있는 닉네임이라면 다시 랜덤한 숫자 붙이기
        while(chatRoom.getUserList().containsValue(temp)){
            int ranNum = (int) (Math.random() * 100) + 1;
            temp = username+ranNum;
        }

        return temp;
    }

    // 채팅방 유저 리스트 삭제
    public void deleteUser(String roomId,String userUUID){
        ChatRoom chatRoom = chatRoomMap.get(roomId);
        chatRoom.getUserList().remove(userUUID);
    }

    // 채팅방 userName 조회
    public String getUserName(String roomId,String userUUID){
        ChatRoom chatRoom = chatRoomMap.get(roomId);

        return chatRoom.getUserList().get(userUUID);
    }

    //채팅방 전체 userList 조회
    public List<String> getUserList(String roomId){
        List<String> list = new ArrayList<>();

        ChatRoom chatRoom = chatRoomMap.get(roomId);

        chatRoom.getUserList().forEach((key,value) -> list.add(value));
        
        return list;
    }

}

6. ChatRoomController

  • 전체적으로 채팅방을 조회, 생성, 입장을 관리하는 Controller
package org.codej.websocket.controller;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.codej.websocket.domain.ChatRoom;
import org.codej.websocket.repository.ChatRepository;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

@Controller
@Slf4j
@RequiredArgsConstructor
public class ChatRoomController {

    // ChatRepository Bean 가져오기
    private final ChatRepository repository;

    // 채팅 리스트 확인
    // "/" 로 요청이 들어오면 전체 채팅방 리스트를 담아서 return
    @GetMapping("/")
    public String ChatRoomList(Model model){

        model.addAttribute("list",repository.findAllRoom());
        log.info("Show All CharList : {}",repository.findAllRoom());

        return "roomList";
    }

    // 채팅방 생성 (리스트로 리다이렉트)
    @PostMapping("/chat/createroom")
    public String createRoom(@RequestParam String roomName, RedirectAttributes rttr){

        ChatRoom chatRoom = repository.createChatRoom(roomName);
        log.info("Create ChatRoom : {}",chatRoom);

        rttr.addFlashAttribute("roomName" , chatRoom);

        return "redirect:/";
    }

    // 채팅방 입장 화면
    // 파라미터로 넘어오는 roomId를 확인 후 해당 roomId 를 기준으로
    // 채팅방을 찾아서 클라이언트를 chatroom 으로 보낸다.
    @GetMapping("/chat/joinroom")
    public String joinRoom(String roomId,Model model){

        log.info("roomId : {}",roomId);
        model.addAttribute("room",repository.findByRoomId(roomId));

        return "chatroom";
    }
}

7. ChatController

  • 채팅을 수신(sub)하고, 송신(pub)하기 위한 Controller
  • @MessageMapping : 이 어노테이션은 Stomp 에서 들어오는 message를 서버에서 발송(pub) 한 메시지가 도착하는 엔드 포인트 입니다.
  • "여기서 "/chat/enterUser" 로 되어있지만 실제로는 앞에 "/pub"가 생략되어 있다고 생각하면 됩니다.
  • 즉 클라이언트가 "/pub/chat/enterUser" 로 메시지를 발송하면 @MessageMapping 에 의해서 아래의 해당 어노테이션이 달린 메서드가 실행됩니다.
  • converAndSend() : 이 메서드는 매개변수로 각각 메시지의 도착지점과 객체를 넣어줍니다. 이를 통해서 도착지점 즉 sub 되는 지점으로 인자로 들어온 객체를 Message 객체로 변환하여 해당 도착지점을 sub 하고 있는 모든 사용자에게 메시지를 보내줍니다.
package org.codej.websocket.controller;

import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.logging.log4j.message.SimpleMessage;
import org.codej.websocket.domain.ChatDto;
import org.codej.websocket.domain.ChatRoom;
import org.codej.websocket.repository.ChatRepository;
import org.codej.websocket.service.ChatService;
import org.springframework.context.event.EventListener;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.messaging.simp.SimpMessageSendingOperations;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.socket.messaging.SessionDisconnectEvent;

import java.util.ArrayList;
import java.util.List;

@Controller
@Slf4j
@RequiredArgsConstructor
public class ChatController {


    // 아래에서 사용되는 convertAndSend 를 사용하기 위해서 서언
    // convertAndSend 는 객체를 인자로 넘겨주면 자동으로 Message 객체로 변환 후 도착지로 전송한다.
    private final SimpMessageSendingOperations template;
    private final 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.increaseUser(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("DisconnectEvent : {}",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.decreaseUser(roomId);

        //채팅방 유저 리스트에서 UUID 유저 닉네임 조회 및 리스트에서 유저 삭제
        String userName = repository.getUserName(roomId, userUUID);
        repository.deleteUser(roomId,userUUID);

        if(userName != null){
            log.info("User Disconnected : " + userName);

            ChatDto chat = ChatDto.builder()
                    .type(ChatDto.MessageType.LEAVE)
                    .sender(userName)
                    .message(userName + "님이 퇴장하였습니다.")
                    .build();

            template.convertAndSend("/sub/chat/room/" + roomId,chat);
        }
    }

    // 채팅에 참여한 유저 리스트 반환
    @GetMapping("/chat/uselist")
    @ResponseBody
    public List<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("DuplicateName : {}", userName);

        return userName;
    }


}

8. Socket.js

  • 사실상 채팅기능의 핵심입니다.
  • 스프링 채팅의 70%는 Socket.js 가 합니다.
  • 처음 웹 통신 시작 시 지정된 엔드포인트로 소켓 통신을 하고, 지정된 주소를 지속적으로 sub하게 됩니다. 또 한 지정한 주소로 pub 하는 역할도 합니다.
'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>")

let usernamePage = document.querySelector('#username-page');
let chatPage = document.querySelector('#chat-page');
let usernameForm = document.querySelector('#usernameForm');
let messageForm = document.querySelector('#messageForm');
let messageInput = document.querySelector('#message');
let messageArea = document.querySelector('#messageArea');
let connectingElement = document.querySelector('.connecting');

let stompClient = null;
let username = null;

let 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
    let 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) {
            let 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) {
    let messageContent = messageInput.value.trim();

    if (messageContent && stompClient) {
        let 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);
    let chat = JSON.parse(payload.body);

    let 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');

        let avatarElement = document.createElement('i');
        let avatarText = document.createTextNode(chat.sender[0]);
        avatarElement.appendChild(avatarText);
        avatarElement.style['background-color'] = getAvatarColor(chat.sender);

        messageElement.appendChild(avatarElement);

        let usernameElement = document.createElement('span');
        let usernameText = document.createTextNode(chat.sender);
        usernameElement.appendChild(usernameText);
        messageElement.appendChild(usernameElement);
    }

    let contentElement = document.createElement('p');

    // 만약 s3DataUrl 의 값이 null 이 아니라면 => chat 내용이 파일 업로드와 관련된 내용이라면
    // img 를 채팅에 보여주는 작업
    if(chat.s3DataUrl != null){
        let imgElement = document.createElement('img');
        imgElement.setAttribute("src", chat.s3DataUrl);
        imgElement.setAttribute("width", "300");
        imgElement.setAttribute("height", "300");

        let downBtnElement = document.createElement('button');
        downBtnElement.setAttribute("class", "btn fa fa-download");
        downBtnElement.setAttribute("id", "downBtn");
        downBtnElement.setAttribute("name", chat.fileName);
        downBtnElement.setAttribute("onclick", `downloadFile('${chat.fileName}', '${chat.fileDir}')`);


        contentElement.appendChild(imgElement);
        contentElement.appendChild(downBtnElement);

    }else{
        // 만약 s3DataUrl 의 값이 null 이라면
        // 이전에 넘어온 채팅 내용 보여주기기
        let messageText = document.createTextNode(chat.message);
        contentElement.appendChild(messageText);
    }

    messageElement.appendChild(contentElement);

    messageArea.appendChild(messageElement);
    messageArea.scrollTop = messageArea.scrollHeight;
}

function getAvatarColor(messageSender) {
    let hash = 0;
    for (let i = 0; i < messageSender.length; i++) {
        hash = 31 * hash + messageSender.charCodeAt(i);
    }

    let index = Math.abs(hash % colors.length);
    return colors[index];
}

usernameForm.addEventListener('submit', connect, true)
messageForm.addEventListener('submit', sendMessage, true)

profile
블로그 이전합니다! https://jyyoun1022.tistory.com/
post-custom-banner

1개의 댓글

comment-user-thumbnail
2023년 6월 22일

안녕하세요 글 너무 잘 봤습니다! 혹시 html부분도 함께 올려주실 수 있을까요? 아래에 git은 없는 주소로 나와서용..

답글 달기