개발 기록

220117 springboot 웹소켓으로 채팅 기능 구현 -1 본문

TIL

220117 springboot 웹소켓으로 채팅 기능 구현 -1

수염차 2022. 1. 17. 16:26

저번에 웹소켓으로 댓글 알림 후 채팅 기능에도 도전..

채팅방 만들고 입장 퇴장 알림, 대화까지 완성

채팅방 참여인원만 추가하면 됨..

고민 중인 것은.. 입장하고 퇴장할때 count를 늘리고 줄이는 방법은,, 같은 사람이 입장할때마다 숫자가 올라가는 것 같아 entity를 새로 만들어서 저장하고 갯수를 새야하나 고민중.. 새로 만들기는 귀찮은데 

 

내가 이해하려고 쓰는 과정

채팅방이 만들어짐 -> 특정 채팅방에 들어가면 웹소켓과 연결되면서 /enter api에 입장알림 메시지가 보내지면서 입장했습니다 메시지 뜸 (퇴장도 같은 과정) -> 메시지를 보내면 /message api로 메시지가 가는데 현재 접속되어 있는 유저 정보와(카카오아이디 사용) 메시지 전송시 같이 보내진 유저정보가 같을때와 다를때 각각 다른 색의 말풍선 사용

--> 로컬스토리지에 있는 username(=db에 저장되어있는 카카오아이디)를 사용해서 유저정보를 가져오기 위해 카카오아이디로 유저정보를 조회할 수 있는 api를 추가했다 ( ㅠㅠ 더 간단한 방법이 있을 것 같은데 일단 이렇게 함 / 프론트에서 유저정보를 어떻게 가져올지 이것밖에 생각이 안 났음 )

 

**입장,퇴장 안내 메시지는 공통이므로 클라이언트에서 보내기보다 서버에서 일괄적으로 처리하는게 낫다고 어떤 블로그에서 말했다. 나중에 할 수 있으면 그렇게 리팩토링 해봐야겠다.

 


서버

 

의존성 추가

    implementation 'org.springframework.boot:spring-boot-starter-websocket'
    implementation 'org.webjars:stomp-websocket:2.3.3-1'
    implementation 'org.webjars:sockjs-client:1.1.2'

 

-chatRoom이라는 클래스를 하나 만들어줬다.

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class ChatRoom extends BaseTimeEntity{

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

    @Column(nullable = false)
    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;

    public ChatRoom(String name, User user) {
        this.name = name;
        this.user = user;
    }
}

-메세지DTO와 채팅방을 생성하고 조회할때 사용하는 requestDto, responseDto도 각각 만들어줌

(원래는 하나의 dto였는데 반환값이 조금 달라서 그냥 두개로 나눴다)

@Getter
@Setter
public class ChatMessageDto {
    private String roomId;
    private String sender;
    private String message;
}

requestDto는 채팅방 이름만 받으면 된다

@Getter
@Setter
@NoArgsConstructor
public class ChatRoomRequestDto {
    private String name;

    public ChatRoomRequestDto(String name) {
        this.name = name;
    }
}

responseDto는 조회할때 필요한 값들 (방번호, 방이름, 생성날짜, 생성자가 필요했음)

@Getter
@Setter
@NoArgsConstructor
public class ChatRoomResponseDto {
    private Long roomId;
    private String name;
    private String createdDate;
    private String creator;

    public ChatRoomResponseDto(ChatRoom chatRoom) {
        this.roomId = chatRoom.getId();
        this.name= chatRoom.getName();
        this.createdDate = chatRoom.getCreatedDate().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
        this.creator = chatRoom.getUser().getKakaoId();
    }
}

 

Controller

입장,퇴장,메시지 전송시 알림 메시지 내용을 다르게 전송하기 위해서 api도 나눴다. ( 안 나눠도 될 것 같긴 함 )

@Controller
@RequiredArgsConstructor
public class ChatApiController {

    private final SimpMessageSendingOperations messagingTemplate;

    @MessageMapping("/chat/enter")
    public void enter(ChatMessageDto messageDto){
        messagingTemplate.convertAndSend("/sub/chat/room/" + messageDto.getRoomId(), messageDto);
    }

    @MessageMapping("/chat/message")
    public void message(ChatMessageDto messageDto) {
        messagingTemplate.convertAndSend("/sub/chat/room/" + messageDto.getRoomId(), messageDto);
    }

    @MessageMapping("/chat/exit")
    public void exit(ChatMessageDto messageDto) {
        messagingTemplate.convertAndSend("/sub/chat/room/" + messageDto.getRoomId(), messageDto);
    }
}

+ 채팅방 생성, 조회 등과 관련된 api 컨트롤러

@RequiredArgsConstructor
@RestController
@RequestMapping("/chat")
public class ChatRoomApiController {

    private final ChatRoomService chatRoomService;

    /**
     * 채팅방 목록 조회 API
     */
    @GetMapping("/rooms")
    public List<ChatRoomResponseDto> getRooms() {
        return chatRoomService.getAllRooms();
    }

    /**
     * 채팅방 생성 API
     */
    @PostMapping("/room")
    public ResultResponseDto createRoom(@RequestBody ChatRoomRequestDto chatRoomDto,
                                        @AuthenticationPrincipal UserDetailsImpl userDetails) {
        chatRoomService.createRoom(chatRoomDto, userDetails.getUser());
        return new ResultResponseDto("success", "채팅 방이 생성되었습니다.");
    }

    /**
     * 채팅방 상세 조회 API
     */
    @GetMapping("/room/{roomId}")
    public ChatRoomResponseDto getRoom(@PathVariable Long roomId) {
        return chatRoomService.getRoom(roomId);
    }

    /**
     * 채팅방 삭제 API
     */
    @DeleteMapping("/room/{roomId}")
    public ResultResponseDto deleteRoom(@PathVariable Long roomId,
                                        @AuthenticationPrincipal UserDetailsImpl userDetails) {
        chatRoomService.deleteRoom(roomId, userDetails.getUser());
        return new ResultResponseDto("success", "채팅 방이 삭제되었습니다.");
    }

}

service

서비스도 간단하게 생성, 조회, 삭제와 관련된 로직들

@Slf4j
@RequiredArgsConstructor
@Service
public class ChatRoomService {

    private final ChatRoomRepository chatRoomRepository;

    @Transactional
    public List<ChatRoomResponseDto> getAllRooms() {
        return chatRoomRepository.findAll()
                .stream()
                .map(ChatRoomResponseDto::new)
                .collect(Collectors.toList());
    }

    @Transactional
    public void createRoom(ChatRoomRequestDto chatRoomDto, User user) {
        chatRoomRepository.save(new ChatRoom(chatRoomDto.getName(), user));
    }

    @Transactional
    public ChatRoomResponseDto getRoom(Long roomId) {
        ChatRoom byId = chatRoomRepository.findById(roomId).orElseThrow(
                () -> new ApiRequestException("해당 채팅방이 없습니다.")
        );
        return new ChatRoomResponseDto(byId);
    }

    @Transactional
    public void deleteRoom(Long roomId, User user) {
        ChatRoom chatRoom = chatRoomRepository.findByIdAndUser(roomId, user);
        chatRoomRepository.delete(chatRoom);
    }
}

sebSocketConfig

@RequiredArgsConstructor
@Configuration
@EnableWebSocketMessageBroker 
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { 

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/sub"); // 메모리 기반 메세지 브로커가 해당 api를 구독하고 있는 클라이언트에게 메세지를 전달한다.
        registry.setApplicationDestinationPrefixes("/pub"); // 서버에서 클라이언트로부터의 메세지를 받을 api를 prefix 설정한다.
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) { // 클라이언트에서 websoket을 연결할 api를 설정한다.
        registry.addEndpoint("/ws-stomp").setAllowedOrigins("*").withSockJS();
    }
}

끝인가..? 

 

 

클라이언트

채팅방 리스트 부분은 생략하고 채팅방에 들어가 웹소켓 연결되는 코드만 

    <script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.5.2/sockjs.min.js"></script>

-채팅방에 들어가면 채팅방 정보 조회 함수와 웹소켓 연결 함수가 실행되도록 한다

let userOwn = localStorage.getItem('username'); //현재 접속하고 있는 유저

//채팅방 정보 가져오기
        function getRoomInfo() {
            $.ajax({
                type: "GET",
                url: `/chat/room/${roomId}`,
                data: {},
                success: function (response) {
                    console.log(response);
                    roomId = response['roomId'];
                    roomName = response['name'];
                    creator = response['creator'];
                    $('#room-name').text(roomName);
                    //채팅방 개설자는 삭제버튼 보이기
                    if (creator === userOwn) {
                        $('#chatButton').append(`<button type="button" class="btn btn-danger" style="margin-left: 10px" onclick="deleteRoom()">채팅 방 삭제</button>`)
                    }
                }
            });
        }

-웹소켓 연결 함수

채팅방에 들어가면 웹소켓 연결이 되고 입장 메시지가 보내지면 같이 전송된 메시지와 보낸 사람 값을 추출해서

또 메시지를 나타내는 함(showMsg)에 보내줬다 ( 다른 사람이 보낸 메시지랑 구별하기 위해서...... )

반환 값을 split으로 나눠서 값을 얻는 건 너무 예뻐보이지 않아서... 다른 방법으로 고쳐야겠음

//웹소켓연결
        function onSocket() {
            let socket = new SockJS('http://localhost:8080/ws-stomp');
            stompClient = Stomp.over(socket);
            stompClient.connect({}, function () {
                stompClient.subscribe('/sub/chat/room/' + roomId, function (chat) {

                    //메시지 날린사람 정보 가져오기
                    let kakaoId = chat.body.split('"')[7];
                    let message = chat.body.split('"')[11];

                    $.ajax({
                        type: "GET",
                        url: `/user/${kakaoId}`,
                        beforeSend: function (xhr) {
                            xhr.setRequestHeader('Authorization', 'Bearer ' + localStorage.getItem('token'));
                        },
                        success: function (response) {
                            username = response['username'];
                            imgLink = response['imgUrl'];
                            console.log(response)
                            //보낸 메시지 나타내기
                            showMsg(response, kakaoId, message);
                        }
                    })
                });
                //입장 알림 메시지 보내기
                stompClient.send("/pub/chat/enter", {}, JSON.stringify({
                    'roomId': roomId, 'sender': userOwn, 'message': "입장했습니다."
                }))
            });
        }

 

-메세지를 작성하고 전송을 눌렀을때 실행되는 함수

//채팅 전송
        function msgSend() {
            let msg = $("#chatMsg").val();
            console.log(msg)
            if (msg !== "") {
                stompClient.send("/pub/chat/message", {}, JSON.stringify({
                    'roomId': roomId,
                    'sender': userOwn,
                    'message': msg
                }));
                $("#chatMsg").val("");
            }
        }

-채팅방 나가기

//채팅방 나가기
        function exitRoom() {
            stompClient.send("/pub/chat/exit", {}, JSON.stringify({
                'roomId': roomId,
                'sender': userOwn,
                'message': "퇴장했습니다."
            }));
            if (stompClient !== null) {
                stompClient.disconnect();
            }
            window.location.href = "chat.html"
        }

 

 

비루한 결과물 ㅎㅎ

말풍선이 조절되고 그런건.. 다음에.. 참여인원도 다음에...

 

Comments