핵심 기능 요구사항
위치 공유 기능이란?
여기로 모여라 앱의 위치 공유기능 요구사항은 아래와 같습니다.
- 방에 입장하면 지도에 내 위치가 마커로 표시
- 방을 나가면 마커 삭제
- 실시간으로 위치가 바뀌면 마커도 같이 움직임
화면 동작 방식
- 초기 위치 표시
- 방 접속 시 모든 참여자의 현재 위치를 받아온다.
- 받아온 위치 정보로 지도에 마커를 생성
- 실시간 위치 업데이트
- 참여자의 위치 변경을 감지
- 기존 마커를 지운 뒤 새로운 위치에 마커를 다시 찍어준다.
이 과정을 연속적으로 처리하면 부드러운 위치 추적이 가능합니다.
기술적 요구사항
실시간 데이터 전송
예를 들어 A, B, C가 같은 방에 있을 때, A의 위치가 변경되면 B와 C에게도 즉시 업데이트된 위치 정보를 전달해야 합니다. 이를 위해서는 클라이언트의 요청 없이도 서버가 데이터를 보낼 수 있는 Server Push 기술이 필요했습니다.
빠른 응답 처리의 필요성
실시간으로 위치가 변경될 때마다 화면에 부드럽게 마커가 이동하려면, 데이터베이스에 저장된 위치 정보를 빠르게 읽고 쓸 수 있어야 했습니다.이를 위해서는:
- 위치 데이터의 빠른 조회
- 실시간 위치 업데이트의 신속한 저장
- 최소한의 지연시간
이 세 가지가 중요했습니다. 마커가 끊기거나 버벅이지 않고 자연스럽게 이동하려면 이 과정이 매우 빨라야 했기 때문입니다.
기술 선택 과정
실시간으로 위치 정보를 전송하기 위해 Server Push를 구현해야 했습니다. 크게 세 가지 방식을 고려했습니다.
- WebSocket
- 양방향 실시간 통신이 가능
- 서버와 클라이언트가 계속 연결을 유지합니다.
- SSE (Server-Sent Events)
- 서버에서 클라이언트로 단방향 통신을 합니다
- Long Polling
- 클라이언트가 주기적으로 서버에 요청합니다.
- 가장 구현하기 쉽지만 서버 부하가 있다.
- 실시간성이 조금 떨어질 수 있습니다
방은 총 48시간 동안 유지되며 서버 부하 및 실시간성이 떨어져서 Long polling 방식은 제외 시켰으며 양방향 실시간 통신이 가능한 WebSocket을 선택했습니다.
WebSocket 기반 실시간 위치 공유 서비스의 상태 일관성 확보하기
입장은 명시적으로 구분할 수 있지만, 퇴장의 경우 다양한 상황이 발생할 수 있습니다.
퇴장은 사용자가 명시적으로 퇴장 버튼을 눌러 방을 떠나는 경우뿐만 아니라 다음과 같은 비정상적인 시나리오도 포함됩니다:
- 스마트폰이 꺼짐
- 네트워크 연결 문제로 WebSocket 세션 종료
- 앱 강제 종료
명시적인 퇴장 버튼 클릭 시에는 Member 도메인에 매핑된 Room 데이터를 모두 제거해야 합니다. 반면, 비정상적인 종료 상황에서는 사용자가 재입장할 수 있도록 데이터를 그대로 유지해야 합니다.
WebSocket 환경에서 개발하면서 데이터 상태 관리의 중요성을 실감했으며, 이에 대한 경험을 간단히 공유하고자 합니다.
발생한 이슈
- 상태 불일치: HTTP API와 WebSocket의 비동기적 특성으로 인한 상태 불일치 발생
- 퇴장 시나리오 구분: WebSocket 연결 종료시 정상/비정상 종료 구분 불가
- 책임 분산: 도메인 로직이 여러 서비스에 분산되어 유지보수 어려움
Member 도메인
-회원 정보를 담는 클래스
@Table(name = "member")
@Entity
public class Member extends BaseTime {
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Id
@Column(name = "member_seq")
@Comment("member 식별값")
private Long seq;
@Comment("아이디")
private String identity;
@Comment("비밀번호")
private String password;
@Comment("닉네임")
private String nickname;
@Comment("이미지 key - uuid")
private String imageKey;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "room_seq")
private Room room;
@Comment("활성화 유무")
private boolean isActive;
}
WebSocketAuth
-member 식별값과 socket session id를 연결하는 클래스
@Getter
@RedisHash(value = "webSocketAuth", timeToLive = 86400L)
public class WebSocketAuth {
@Id
private Long memberSeq;
@Indexed
private String sessionId;
}
LocationShareEvent
-회원들의 현재 상태(위치, 프로필 등)를 추적하고 공유하는 클래스
@Getter
@RedisHash(value = "locationShareEvent", timeToLive = 86400L)
public class LocationShareEvent {
private Long roomSeq;
private List<MemberLocation> memberLocations = new ArrayList<>();
public static class MemberLocation{
private Long memberSeq;
private String sessionId;
private String nickname;
private String imageUrl;
private Double presentLat;
private Double presentLng;
private Double destinationDistance;
}
}
입장, 재입장, 퇴장로직
입장 로직
- Member 객체에 Room 도메인이 매핑됩니다.
- 새로운 sessionId가 발급됩니다.
- webSocketAuth 객체가 생성됩니다.
- LocationShareEvent 객체에 Member가 등록됩니다.
재입장 로직
- Member에 이미 매핑된 Room 객체가 존재합니다.
- 새로운 sessionId가 발급됩니다.
- webSocketAuth에 등록된 Member의 키에 해당하는 sessionId가 갱신됩니다.
퇴장 로직
- Member 객체에서 매핑된 Room이 제거됩니다.
- webSocketAuth에서 해당 데이터를 삭제합니다.
- LocationShareEvent 객체에서 Member를 제거합니다.
- 발급된 sessionId를 삭제합니다.
퇴장 프로세스
퇴장 로직은 2가지 이벤트로 실행이 됩니다.
- 앱에서 퇴장버튼을 통한 api 통신
- socket session disconnect 되면서 TextWebSocketHandler 의 afterConnectionClosed 가 응답을 받아 비즈니스 로직이 실행됩니다.
문제점 및 해결 과정
문제점
- 상태 불일치
- WebSocket의 비동기 특성으로 인해 afterConnectionClosed 메서드가 작동하지 않거나 지연되는 경우, 상태가 불일치하는 문제가 발생했습니다.
- 예: Room에 매핑된 Member가 재입장했을 때, 이전 상태가 남아 중복 데이터가 발생함.
- 퇴장 시나리오 구분 어려움
- WebSocket 연결 종료 시 정상/비정상 종료를 구분할 수 없음.
- 책임 분산
- 상태 관리 로직이 여러 서비스에 분산되어 유지보수가 어려움.
해결 방법
퇴장 로직 변경
HTTP API 통신으로 퇴장 처리를 일원화
- Member 객체에서 매핑된 Room을 제거.
- WebSocketAuth member 삭제.
- LocationShareEvent member 삭제.
public void exitRoom(ExitRoomRequestDto request, Long memberSeq) {
Member member = memberRepository.getBySeq(memberSeq);
if (!member.getRoom().getSeq().equals(request.getRoomSeq())) {
throw new RoomException(ResponseStatus.NOT_FOUND_ROOM_SEQ, HttpStatus.FORBIDDEN);
}
LocationShareEvent locationShareEvent = locationShareEventRepository.getByRoomSeq(member.getRoom().getSeq());
locationShareEvent.removeMemberLocation(member.getSeq());
if(locationShareEvent.getMemberLocations().isEmpty()){
locationShareEventRepository.delete(locationShareEvent);
}else{
locationShareEventRepository.update(locationShareEvent);
}
member.exitRoom();
Optional<WebSocketAuth> webSocketAuth = webSocketAuthRepository.findMemberSeq(memberSeq);
webSocketAuth.ifPresent(webSocketAuthRepository::deleteByMemberSeq);
}
afterConnectionClosed에서 최소한의 책임만 유지
- 발급된 sessionId를 삭제하는 역할만 수행.
@Transactional
public void connectClosedHandle(WebSocketSession session, CloseStatus status) {
sessionList.remove(session);
}
결론
정확한 상태 관리를 위해 핸들링 가능한 부분과 불가능한 부분을 명확히 구분하고, 가능한 영역에 비즈니스 로직을 집중적으로 적용하려고 노력했습니다. 이러한 접근 덕분에 데이터 불일치 문제가 사라졌고, 입장 및 재입장 시 발생하던 이슈를 효과적으로 해결할 수 있었습니다.
'Programming > Back-end Language' 카테고리의 다른 글
RabbitMq 이해 (0) | 2025.01.14 |
---|---|
여기로 모여라 회고-1 (0) | 2024.10.18 |
Docker 환경에서 Nginx를 사용하여 letsencrypt SSL 설정 (1) | 2024.10.18 |
K6를 이용한 서버 성능 테스트 이슈 (0) | 2024.09.06 |
Apache Tomcat과 JAVA (0) | 2024.05.16 |