
기능 흐름
[채팅방 입장]
- 메인 화면에서 API로 캠핑장 정보 조회 후 상세보기화면에서 채팅방 참가
- 상단바의 채팅 배너를 클릭하면 채팅방 목록 확인 가능
[채팅방 내부]
- 채팅방에 참여하는 유저 리스트 확인 가능
- 채팅방 접속 / 퇴장 시 알림 메세지
- 채팅방 (캠핑장) 위치와 사용자 현재 위치를 비교해 근방에 있는 사용자는 닉네임 옆에 캠핑장 이모티콘 표시해서 채팅 커뮤니티 내에서 캠핑장에 있는 사람 / 아닌 사람을 구분
- stomp 연결을 해제해도, DB에 구독 정보를 저장해두어서 나중에 접속했을 때 기존 메세지를 불러올 수 있음
- 다른 채팅방 구독 정보가 있는 사용자가 또다른 채팅방에 접속할 경우 기존 내용 삭제되는 것을 알림
채팅 서버 구성

스프링 stomp websocket이 기본으로 제공하는 simple broker로 구현한 채팅 로직이다.
사용자는 채팅방 접속 시 채팅방ID를 토픽으로 구독하고,
브로커는 해당 채팅방에 메시지가 발행될 경우 관련 로직을 수행한 후
채팅을 구독하고 있는 사용자들에게 메시지를 브로드캐스팅 하는 pub/sub 구조로 구현되어있다.
WebSocketConfig.Java
@Configuration
@EnableWebSocketMessageBroker
@CrossOrigin("*")
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic");
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/api/ws-stomp")
.setAllowedOriginPatterns("*")
.withSockJS();
}
}
* configureMessageBroker
(클래스가 implement 받은WebSocketMessageBrokerConfigurer 인터페이스의 메서드를 재정의)
- 메세지 브로커
- “/topic”이라는 프리픽스를 달아 요청을 받으면 특정 토픽을 구독하고, 웹소켓에 연결된 클라이언트에게 메세지를 브로드캐스트할 수 있는 메세지 브로커를 활성화 한다.
- 현재 서비스는 frontend 단에서 채팅방에 처음 접속 시 서버 api 경로를 /topic/${roomId} 로 설정하여 roomId에 해당하는 토픽을 구독하게 만들고, 이 토픽으로 들어오는 메세지를 브로커가 수신하여 토픽 구독자들에게 브로드캐스트 해준다.
- “/app”이라는 프리픽스가 달린 메세지가 들어오면, 애플리케이션의 메시지 처리 메소드로 라우팅 된다.
- 현재 서비스에서는 frontend 단에서 채팅방에 메세지를 보낼 시 서버 api 경로는 /app/chat/${roomId} 로 설정하여 roomId에 해당하는 토픽에 발행된 메세지를 처리할 수 있게 만들어두었다. 이렇게 처리 된 메시지는 브로드 캐스트 되어 구독자들에게 보여지게 된다.
- “/topic”이라는 프리픽스를 달아 요청을 받으면 특정 토픽을 구독하고, 웹소켓에 연결된 클라이언트에게 메세지를 브로드캐스트할 수 있는 메세지 브로커를 활성화 한다.
- Stomp EndPoint
- Websocket 기반 메세징 애플리케이션에 STOMP 프로토콜을 등록하고
- 엔드포인트를 api/ws-stomp로 설정한다. 프론트에서 /api/ws-stomp 로 요청을 보내면 STOMP 기반 웹소켓 통신 연결이 가능하게 된다.
- WithSockJS() 는 SockJS를 사용해 웹소켓 연결을 할 수 있게 만드는 설정이다. SockJS를 사용한 이유는 웹소켓이 지원되지 않는 몇몇 브라우저에서도 웹소켓을 사용할 수 있게 해주고 http프로토콜을 사용해 클라이언트와 서버간의 통신이 가능해서 프록시 설정할 때도 편함
ChatController.java
@Controller
@Log4j2
@CrossOrigin("*")
@RequiredArgsConstructor
public class ChatController {
private final ChatService chatService;
private final ChatRoomService chatRoomService;
@MessageMapping("/chat/{roomId}")
@SendTo("/topic/{roomId}")
public ChatMessageResponseDTO chatting(ChatMessageRequestDTO message, @DestinationVariable("roomId") String roomId) throws Exception {
//캠핑장 - 사용자 반경 측정 후 boolean 값으로 저장
Boolean nearOrNot = chatService.getDistance(message);
chatService.saveChat(message,nearOrNot);
if (message.getType() == ENTER) {
chatRoomService.insertUserList(message);
}
return new ChatMessageResponseDTO(message,nearOrNot);
}
@GetMapping("/api/chat/room/{roomId}/{memberId}/before-messages")
@ResponseBody
public ResponseEntity<List<ChatBeforeMessageResponseDTO>> beforeMessages(@PathVariable String roomId, @PathVariable String memberId) {
List<ChatBeforeMessageResponseDTO> beforeMessages = chatRoomService.ChatBeforeMessages(roomId,memberId);
return ResponseEntity.ok(beforeMessages);
}
}
- chatting 메서드
- 메세지를 받을 때, 사용자의 위치가 같이 들어오는데 이 정보를 이용해서 채팅방(캠핑장)의 좌표 정보와 비교하여 근방에 있는 사람의 메세지/ 아닌 사람의 메세지를 nearOrNot이라는 boolean 값으로 구별
- nearOrNot과 들어온 메세지를 DB에 저장
- Enter 타입의 메세지가 들어왔을 경우에만 추가적으로 DB에 저장되어있는 유저 구독 리스트에 저장 (웹소켓 특성상 브라우저가 종료되거나 화면이 언마운트 되면 연결이 해제되는데, 그렇게 연결이 끊긴 후에도 다시 접속을 원하는 사람들을 위해 정보를 저장해둠)
- beforeMessages 메서드
- 앞서 언급했던 것 처럼 연결이 끊긴 후에 다시 채팅방에 접속하면 해당 유저가 채팅방 처음 접속 시 유저 구독 리스트에 저장된 시점을 기반으로 이후 생성된 모든 메세지를 반환해줌
ChatRoomController.java
@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
@CrossOrigin("*")
public class ChatRoomContoller {
private final ChatRoomService chatRoomService;
// 채팅 리스트 화면
@GetMapping("/chat/room/list")
@ResponseBody
public ResponseEntity<List<ChatRoom>> getRoomList() {
List<ChatRoom> chatRooms = chatRoomService.findAllRoom();
return new ResponseEntity<>(chatRooms, HttpStatus.OK);
}
// 채팅방 생성
@PostMapping("/chat/create")
public ResponseEntity<ChatRoomResponseDTO> createRoom(@RequestBody ChatRoomRequestDTO chatRoomRequestDTO) {
ChatRoom chatRoom = chatRoomService.createRoom(chatRoomRequestDTO);
return new ResponseEntity<ChatRoomResponseDTO>(new ChatRoomResponseDTO(chatRoom), HttpStatus.CREATED);
}
//채팅방 나가기
@DeleteMapping("/chat/room/{roomId}/{memberId}/out")
public ResponseEntity<String> userOut(@PathVariable String roomId, @PathVariable String memberId) {
chatRoomService.DeleteUserList(roomId,memberId);
return ResponseEntity.ok("삭제 완료");
}
// 특정 채팅방 조회 (1)
// 채팅방 정보를 가져오는 메서드
@GetMapping("/chat/room/{roomId}")
@ResponseBody
public ResponseEntity<ChatRoomResponseDTO> getRoomInfo(@PathVariable String roomId) {
// roomId를 이용하여 채팅방 정보를 조회하고 ResponseDTO를 생성하여 반환
ChatRoomResponseDTO chatRoomResponseDTO = chatRoomService.findRoomByRoomId(roomId);
if (chatRoomResponseDTO == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(chatRoomResponseDTO);
}
//특정 채팅방 조회 (2)
//채팅방 존재 여부 확인 메서드 (없으면 false, 있으면 true 반환)
@GetMapping("/chat/room/exist/{roomName}")
public String chatRoomCheck(@PathVariable String roomName) {
String roomCheck = chatRoomService.findRoomByRoomName(roomName);
return roomCheck; // 문자열을 JSON 형태로 변환하지 않음
}
//채팅방 유저 리스트 조회
@GetMapping("/chat/room/{roomId}/user-list")
public ResponseEntity<List<ChatUserListResponseDTO>> userList(@PathVariable String roomId) {
List<ChatUserListResponseDTO> userList = new ArrayList<>(chatRoomService.findUserListRoomByRoomId(roomId));
if (userList.isEmpty()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(userList);
}
//채팅방 기참여 여부 조회
@GetMapping("/chat/room/{roomId}/{memberId}/user-check")
public ResponseEntity<String> userInRoomCheck(@PathVariable String roomId, @PathVariable String memberId) {
String result = chatRoomService.isUserInRoom(roomId,memberId);
if (result == "InUser") {
return ResponseEntity.ok("구독 유저");
} else {
if (result == "OtherInUser") {
return ResponseEntity.ok("다른 방 구독 유저");
} else {
return ResponseEntity.ok("가능");
}
}
}
}
- 채팅방 존재 유무 조회 메서드
- 캠핑장 정보 조회 서비스가 이 웹사이트를 방문하면 필수적으로 이용하는 주요 서비스이기에 해당 서비스 모달 창에 채팅방 참여 버튼을 두었고, 이 버튼을 클릭했을 때 기존에 채팅방이 있다면 바로 참여하고 만약에 없다면 채팅방 생성 메서드를 수행하도록 만듦
- 채팅방 기참여 여부 조회 메서드
- 채팅방 구독 유저 리스트에 없는 회원일 경우 어느 채팅방이나 자유롭게 참여 가능하고,
- 다른방 구독 유저라면 구독 정보가 바뀌었을 때 채팅방의 데이터들이 날아가므로 그러한 사실을 alert 해주고
- 구독 유저라면 join 시점을 기점으로 발행된 메세지를 모두 보여주는 로직을 실행하기 위해 반환값 구별 해둠
ChatService.java
@Service
@Slf4j
@RequiredArgsConstructor
public class ChatService {
private final ChatRoomRepository chatRoomRepository;
private final ChatRepository chatRepository;
//메세지 전송 시 내용 저장 및 채팅방 정보 업데이트하는 메서드
public ChatMessage saveChat(ChatMessageRequestDTO messageDTO,boolean nearOrNot) {
ChatMessage chatMessage = new ChatMessage(messageDTO,nearOrNot);
ChatRoom chatRoom = chatRoomRepository.findById(messageDTO.getRoomId())
.orElseThrow(() -> new IllegalArgumentException("일치하는 ChatRoom이 없습니다."));
// ChatRoom의 updateTime을 수정
chatRoom.update(LocalDateTime.now());
chatRoomRepository.save(chatRoom);
return chatRepository.save(chatMessage);
}
//채팅방 최근 생성 순으로 반환하는 메서드
public List<ChatRoom> findAllRoom() {
List<ChatRoom> result = new ArrayList<>(chatRoomRepository.findAll());
Collections.reverse(result);
return result;
}
// 메세지를 보내는 사용자의 위치가 해당 캠핑장(채팅방)에 근접한 경우/ 아닌 경우 판별하는 메서드
//근접 판별 기준: 10km 이내
public boolean getDistance(ChatMessageRequestDTO message) {
double userLocationX = message.getLocationX();
double userLocationY = message.getLocationY();
double siteLocationX = chatRoomRepository.findById(message.getRoomId()).get().getLocationX();
double siteLocationY = chatRoomRepository.findById(message.getRoomId()).get().getLocationY();
double distance = distance(userLocationX, userLocationY, siteLocationX, siteLocationY);
log.info("distance결괏값 : {}", distance);
if (distance < 10) {
return true;
}
return false;
}
}
ChatroomService.java
@Service
@Slf4j
@RequiredArgsConstructor
public class ChatRoomService {
private final ChatRepository chatRepository;
private final ChatRoomRepository chatRoomRepository;
private final ChatUserListRepository chatUserListRepository;
//채팅방 생성 메서드
public ChatRoom createRoom(ChatRoomRequestDTO requestDto) {
ChatRoom chatRoom = new ChatRoom(requestDto);
return chatRoomRepository.save(chatRoom);
}
//채팅방 리스트 불러오는 메서드
public List<ChatRoom> findAllRoom() {
//채팅방 최근 생성 순으로 반환
List<ChatRoom> result = new ArrayList<>(chatRoomRepository.findAll());
Collections.reverse(result);
return result;
}
//단일 채팅방 불러오는 메서드
public ChatRoomResponseDTO findRoomByRoomId(String roomId) {
ChatRoom chatRoom = chatRoomRepository.findById(roomId).orElse(null);
return new ChatRoomResponseDTO(chatRoom);
}
//채팅방 유저리스트 조회 메서드
public List<ChatUserListResponseDTO> findUserListRoomByRoomId(String roomId) {
List<ChatUserListResponseDTO> userList = chatUserListRepository.findAllByRoomId(roomId);
return userList;
}
//채팅방 유저 리스트에서 사용자 삭제 메서드
public String DeleteUserList(String roomId, String memberId) {
ChatUserList userList = chatUserListRepository.findUserList(roomId, memberId);
chatUserListRepository.delete(userList);
return "삭제 완료";
}
//채팅방 존재여부 확인 메서드
public String findRoomByRoomName(String roomName) {
String result = chatRoomRepository.existsByRoomName(roomName);
return result;
}
//채팅방 유저 리스트에 사용자 추가 메서드
public ChatUserList insertUserList(ChatMessageRequestDTO chatMessage) {
ChatUserList chatUserList = new ChatUserList(
chatMessage.getSender(), chatMessage.getRoomId(), LocalDateTime.now());
return chatUserListRepository.save(chatUserList);
}
//사용자의 해당 채팅방 기참여 여부 확인
public String isUserInRoom(String roomId, String memberId) {
ChatUserList userList = chatUserListRepository.findUserList(roomId, memberId);
if (userList != null) {
return "InUser";
} else {
ChatUserList memberCheck = chatUserListRepository.findOtherUserList(roomId, memberId);
if (memberCheck != null) {
return "OtherInUser";
}
return "OK";
}
}
// 채팅방 기참여 여부 확인 후 - 기참여 사용자였을 경우 해당 채팅방에 처음 접속했을 때부터 다시 접속했을 때의 모든 메세지를 불러오는 메서드
public List<ChatBeforeMessageResponseDTO> ChatBeforeMessages(String roomId, String memberId) {
LocalDateTime joinTime = chatUserListRepository.findJoinTime(roomId,memberId);
List<ChatMessage> messages = chatRepository.findByRoomIdAndCreatedTimeAfter(roomId, joinTime);
List<ChatBeforeMessageResponseDTO> dtos = new ArrayList<>();
for(ChatMessage message : messages) {
ChatBeforeMessageResponseDTO dto = new ChatBeforeMessageResponseDTO();
// ChatMessageResponseDTO 객체의 필드 값을 ChatMessage 객체의 필드 값으로 설정
dto.setMessage(message.getMessage());
dto.setSender(message.getMemberId());
dto.setCreatedTime(message.getCreatedTime());
dto.setNearOrNot(message.getNearOrNot());
dtos.add(dto);
}
return dtos;
}
}
구현 결과
시연 순서
1.채팅방 입장
2.유저리스트에 추가된 것을 확인
3.다른 사람 입장 메시지 확인
4.멀티 채팅 구현 가능 (채팅방 이름인 캠핑장 위치를 기준으로 반경 3 km 내에 있는 사람은 닉네임 옆에 캠핑 이모티콘이 붙고, 아닌 사람은 그냥 닉네임만 보이게 하여 캠핑장에 있는 사용자 / 아닌 사용자를 구분할 수 있게 함)
5.웹 사이트 내 다른 서비스 이용후에 채팅방에 재 접속 시에도 메시지 내역을 볼 수 있음 (나가기 버튼을 누르면 퇴장 메시지를 발송하고, 저장해 놓은 채팅방 안에 내용이 삭제 됨)
트러블 슈팅 (프론트)

Moment.js 라이브러리를 사용해, 프론트에서 채팅방 업데이트 시간을 보여주는 영역이 있었는데 개발 환경과 배포 환경의 출력 시간이 미묘하게 달랐다.
moment.js를 사용할 경우 브라우저나 서버의 로컬 타임존에서 현재 시간을 UTC로 반환하는데 만약 로컬 타임존과 실제 시간이 다를 경우, moment()으로 얻은 결과가 실제 시간과 일치하지 않을 수 있다는 점 때문에 발생한 오류였다. 이 오류를 해결하기 위해 적절한 타임존을 지정해 주어야 한다고 판단했고 moment().tz('Asia/Seoul')이라는 메서드를 사용하여 서비스가 배포될 서울 지역의 타임존을 설정해주었다.
추후 개선되어야할 것들
- 카프카 브로커 붙이기
- 원래 카프카로 브로커를 붙여서 서버가 여러개일 때도 구현 가능한 채팅을 만들려고 했으나 완벽하게 구현을 못하고, 비용적인 측면도 있어서 중간에 내렸음.
- NoSQL 적용 (지금 join 해오는 거 하나도 없고 종속성 설정도 없어서 굳이 RDS 안써도 될듯)
- service, serviceImpl 분리 (추상화, 느슨한 결합 위해)
'개발' 카테고리의 다른 글
| Git과 Github의 차이점 (0) | 2023.02.24 |
|---|---|
| DBMS와 트랜잭션 (0) | 2022.11.21 |
| 자바스크립트 & 노드 JS 작동 원리 (이벤트 루프,WENB API, 콜스택, HEAP 이해하기) (0) | 2022.11.20 |
| AoP(관점지향적프로그래밍)에 대하여 (0) | 2022.11.17 |