Backend/Spring Boot & JPA

[Spring Boot] WebSocket 서버 사용자 상태(User Status) 추적

나죽못고나강뿐 2024. 10. 17. 20:13
📕 목차

1. Intoduction
2. User Status
3. 사용자 상태 활성화
4. 사용자 뷰 상태 추적
5. 사용자 상태 비활성화
6. 박동 검사

1. Introduction

 

📌 개요
 

[Spring Boot] WebSocket + RabbitMQ를 활용하여 채팅 시스템 구축하기 (with. STOMP)

🫠 포스팅 길이가 길어지면 임시 저장 데이터가 자꾸 날아가버려서, 점진적으로 내용 추가 중입니다.✏️ 포스팅 길이가 너무 길어져 렉이 너무 심해진 관계로, User Status 관리, Redis Clustering 그

jaeseo0519.tistory.com

이전 포스팅의 길이가 너무 길어져서, 따로 분리한 포스팅.

Tistory는 일정 길이가 넘으면 임시 저장이 잘리는 건 알고 있었지만, 그 이상을 넘어버리면 그냥 렉이 엄청 심해진다.

사실 Tistory만 그런 건 아니겠지만 ㅋㅋㅋㅋㅋ 내가 너무 혹사시켰던 거 같아서, 분리하는 게 낫다고 판단..

 

여튼 이번엔 실시간 서비스에서 사용자의 상태를 체크하는 기능을 구현하고자 한다.

 

📌 Background

클라이언트에서 앱을 백그라운드 상태로 전환할 때마다 websocket 연결을 끊었다가, 다시 포그라운드로 전환했을 때 연결할 것이 아니라면, 일정 시간은 백그라운드 상태에서도 websocket 연결을 유지해야 한다.

 

문제는 여기서 메시지를 처리하는 방법이 2가지가 존재한다.

  1. 서버에서 메시지를 전달하고, 클라이언트 측에서 수신한 메시지로 푸시 알림을 띄운다.
  2. websocket 연결만 유지하고, 서버에서 메시지를 전달하지 않으며, FCM으로 푸시 알림을 전송한다.

 

위 두 가지 중 어떤 방법이 좋을 지 고민을 정말 많이 해봤다.

(1)의 방법은 간단하지만, 그만큼 앱이 백그라운드에서 수행해야 할 동작이 많아짐을 의미하고, 그만큼 전력 소모가 심해질 우려가 있다. (사용자가 앱을 지워버릴 수도..)

 

그리고 어차피 iOS, Android는 백그라운드 상태를 일정 시간 유지하면, socket 연결 상태를 끊어버릴 우려가 있었다.

그렇다면 굳이 이런 번거로운 작업을 수행할 필요 없이, 서버에서 FCM으로 푸시 알림을 전송하는 게 낫지 않을까 싶다.

 

📌 Use Case

웹 소켓 스펙에 따르면, 사용자의 상태는 "online", "offline"이란 1-bit로 표현 가능한 상태밖에 가지지 않는다.

 

하지만 내가 구독 중인 Exchange에 새로운 채팅 메시지를 수신한 경우를 생각해보자.

어떤 경우에는 outbound로 메시지를 전송하기만 하고, 어떤 경우에는 push notification을 전달해야 한다.

그것도 아니면 둘 다 보내지 않거나, 둘 다 보내야 하는 경우일 수도 있다.

 

이는 단순히 on, off만으로는 처리하기가 상당히 까다로워진다.

따라서 사용자의 상태를 실시간으로 유지할 필요가 있는데, 내가 고려한 케이스는 다음과 같다.

(참고로 무작정 구현할 게 아니고, 클라이언트 측에서 이러한 상태를 모두 개별적으로 감지 가능한 지 알아봐야 한다.)

 

상황 상태 처리
사용자가 앱을 실행 중이지만, 채팅 관련 뷰(내가 가입한 채팅방 리스트 뷰 or 임의의 채팅방 뷰)를 보고 있지 않음. ACTIVE_APP • 푸시 알림
사용자가 앱을 실행 중이며, 내가 가입한 채팅 리스트 뷰를 보고 있음. ACTIVE_CHAT_ROOM_LIST • 메시지 전달
사용자가 앱을 실행 중이며, 임의의 채팅방 뷰를 보고 있음. ACTIVE_CHAT_ROOM_{chat_room_id} • 메시지 전달
• 다른 채팅방 메시지는 푸시 알림
사용자가 앱을 백그라운드로 실행함.
(앱을 종료하지 않고 나가거나, 화면을 끄거나, 메뉴 화면으로 이동)
BACKGROUND • 푸시 알림
사용자가 앱을 종료함. INACTIVE • 푸시 알림
사용자가 절전 모드(방해 금지 모드)를 사용함.   중요 알림에 대해서는 기기 설정을 무시하고 보내거나, 배터리 잔량에 따라 동작을 조정할 필요가 있다면...?
너무 복잡한 기능이라 일단 제외
일정 시간 내 ping을 수신하지 못 함. INACTIVE • 푸시 알림
사용자가 로그아웃을 함. INACTIVE • X
사용자가 회원탈퇴를 함. 사용자 상태 제거 • X
사용자가 앱을 삭제함. offline 유지 • X

참고로 이 뿐만 아니라, 사용자가 앱 알림을 활성화 했는지, 특정 채팅방 알림을 활성화 했는 지 여부도 지속적으로 모니터링해야 한다.

 

😇 기껏 상태 구분해놓고, 전부 푸시 알림으로 처리하는 이유..

백그라운드는 그렇다 치고, 포그라운드의 경우엔 메시지를 전달하고, 푸시 알림은 iOS 팀에서 처리하도록 만들어버리는 게 이상적이지 않을까 싶었다.

하지만 iOS팀이 CoreData 따위로 캐싱을 하고 있는 상태가 아닌데다, 우리 채팅 서비스는 카카오보단 인스타그램 DM에 가깝기 때문에 할 이유도 없다. (DM 확인해보니 캐싱 안 함!)
그렇다면 굳이 불필요한 작업 늘려서 부담 주지 말고, 있는 기능 그대로 사용하는 게 best라고 판단했다.

 

📌 박동 검사에 대해서 세션 활동 상태 업데이트가 필요할까?

이 내용을 작성하다가, 생각해보니 정말 필요할까 싶어서 제외해버렸다.

 

요지는 사용자가 포그라운드에서 아무것도 안 하고, 가만히 있는 경우에 발생한다.

어떠한 뷰 이동도 없으니, 사용자의 마지막 활동 시간은 변하지 않을 것이다.

처음에는 이게 문제가 된다고 생각해서, 박동 검사를 할 때마다 활동 시간을 업데이트 하려는 interceptor를 추가하려 했다.

 

그러나 Spring WebSocket은 IEFT 권장 사항에 따라, 박동 검사 interval을 25초로 잡고 있으며

이는 모든 사용자 세션마다 25초 주기로 redis에 부하를 줘야 한다는 이야기가 된다.

 

그래서 이 시점에서 한 번 다시 생각해보게 되었는데, 사용자가 아무런 액션을 취하지 않을 때 마지막 활동 시간을 업데이트 해줘야 할 이유가 존재할까?

서비스 정책마다 다를 수 있겠지만, 내 서비스 같은 경우엔 이게 전혀 의미가 없는 행위였다.

그리고 애초에 포그라운드로 계속 냅둔다고 한들, 어쨌든 한 번이라도 백그라운드로 전환하면 상태는 알아서 바뀔 것이고, 앱을 종료해도 마찬가지.

아무런 액션도 취하지 않는 사용자의 "활동 시간을 갱신한다"라는 전제부터가 잘못 되었다고 생각하게 되었다.

 

의미도 없는데 애꿎은 redis만 괴롭힐 뻔..

 


2. User Status

 

📌 사용자 상태 상수
@RequiredArgsConstructor
public enum UserStatus implements LegacyCommonType {
    ACTIVE_APP("1", "앱 활성화"),
    ACTIVE_CHAT_ROOM_LIST("2", "채팅방 리스트 뷰"),
    ACTIVE_CHAT_ROOM("3", "채팅방 뷰"),
    BACKGROUND("4", "백그라운드"),
    INACTIVE("5", "비활성화"),
    ;

    private final String code;
    private final String type;

    @Override
    public String getCode() {
        return code;
    }

    @Override
    public String toString() {
        return type;
    }
}

사용자 상태는 Usecase에서 정의한 그대로 정의했다.

ACTIVE_CHAT_ROOM일 때는 뒤에 `_{chat_room_id}`가 붙어야 할 것 같지만, 채팅방 ID는 동적으로 결정되어야 하는 값이다.

그러나 enum 타입 특성 상, 그런 동작은 허용하지 않기 때문에 chat_room_id 값은 다른 곳에서 정의해줄 필요가 있다.

 

📌 UserSession
@RedisHash("userSession")
public class UserSession {
    @Id
    private final Long userId;
    @Convert(converter = UserStatusConverter.class)
    private UserStatus status;
    private Long currentChatRoomId;
    private LocalDateTime lastActiveAt;
}

처음에는 사용자 세션 정보를 위와 같이 관리하려고 했다.

그러나 이내 잘못된 생각이라는 걸 알게 되었는데, 동일 사용자가 다른 기기로 로그인 했을 때, 치명적인 문제가 발생한다.

 

디바이스A로 다른 뷰를 조회하면 ACTIVE_APP 상태가 되어, 채팅 메시지를 푸시 알림으로 수신해야 한다.

하지만 디바이스B로 채팅방을 조회하게 되면, 상태를 ACTIVE_CHAT_ROOM으로 바꿔버림으로 인해 디바이스A에서 푸시 알림을 수신하지 못 한다.

 

하지만 여기까진 괜찮았다.

일반적으로 하나의 기기에서 채팅 메시지를 읽었다면, 다른 기기로 푸시 알림을 보내지 않기 때문에

우리 서비스도 그렇게 정책을 수립했기 때문이다.

 

하지만 순서가 역전되면 심각한 문제가 발생한다.

 

디바이스A가 채팅방에 진입했으나, 디바이스B에 의해 상태가 ACTIVE_APP으로 덮어 씌워졌다.

이렇게 되면, 실시간으로 메시지를 전달받아야 할 디바이스A에게도 푸시 알림으로 메시지가 전달되는 현상이 벌어진다.

 

이 현상을 회피하려면, 사용자의 기기를 식별할 정보가 추가적으로 필요하다.

그렇다고 해서 device model name을 사용해선 안 되는데, 사용자의 두 기기가 동일할 수 있기 때문이다.

sessionId 또한 연결될 때마다 바뀔 수 있기 때문에, 자칫 하나의 기기에 여러 상태가 생성될 수 있다.

(그 놈의 기기 고유 식별 정보 😂)

 

iOS의 identifierForVender, Android의 Setting.Secure.ANDROID_ID 등을 사용하는 것도 고려해봤으나, iOS의 경우 앱을 완전히 삭제하고 재설치하면 값이 바뀐다고 한다.

그런데 딱히 상관없지 않나?

 

앱을 삭제한 사용자가 다시 돌아올 일은 드물기도 하고,

사용자가 장기간 미접속 상태면 캐시를 날려버릴 계획이었으므로 문제도 어느정도 다소 완화될 것이다.

 

설령, 캐시가 사라지기 전에 사용자가 앱을 재설치하여 새로운 key를 할당받는다고 해도 문제가 안 된다.

기존의 device의 상태가 INACTIVE이므로, 푸시 알림으로 메시지를 전달하려 하지만 FCM 토큰도 바뀌었기 때문에 메시지 중복 전달의 우려도 존재하지 않기 때문이다.

 

🤔 사용자에게 고유키를 받는 게 적절한 생각일까?

사용자 식별 정보를 클라이언트로부터 제공받는 방식을 원래는 상당히 꺼리는 편이다.
시스템 내부 상태 관리 방법을 들킬 우려도 있고, 고의적으로 조작된 값을 전달한다고 해서 딱히 막을 방도도 없기 때문이다.

그러나 이 경우엔 악의적인 값을 전달해봐야 본인만 손해보는 구조.
다른 사람의 데이터에 대해선 접근이 불가하기 때문에 문제가 되지 않을 것이라 판단했다.

물론 서버에서 UUID를 생성하고, 클라이언트 측에 전달해도 문제는 안 되겠지만 리스크가 더 적은 쪽을 선택하기로 결정했다. 

 

📌 UserSessionRepository

이렇게 되면, Repository의 역할이 상당히 중요해진다.

 

우선 자료구조를 생각해보자.

사용자 별로 "user_id"로 1차적으로 캐시를 식별한다.

이후, "device_unique_id"로 한 번 더 키를 매칭하여 사용자 상태를 불러와야 한다.

 

"user_id": {
    "a_device_id": {
        "status": "",
        "lastActiveAt": "",
        ...
    },
    "b_device_id": {
        "status": "",
        ...
    }
}

여러가지 방법이 있겠지만, 나는 Hash 자료형을 사용하기로 결정했다.

이유는 redis 7.4에서, 각 hash field마다 개별적으로 ttl을 걸 수 있는 기능을 제공하기 때문이다.

 

원래는 key(여기선 user_id)에 ttl을 제한하는 방법 말고는 안 됐었는데, hash key(`a_device_id`, `b_device_id`...여기선 field라고 표현한다.)마다 ttl을 걸 수 있게 된다.

사용을 위해서는 HEXPIREHTTL에 대한 이해가 필요하다.

Redis 명령어 사용법을 적기 위한 포스팅이 아니기 때문에 자세한 설명은 생략했지만, 꼭 실습을 해봤으면 좋겠다.

 

처음에 `FIELDS`에 진짜 필드값을 넣으라는 줄 알고 열심히 명령어 치는데 오류가 생겼다.

한 30분 삽질하다가, 혹시나 싶어서 FIELDS를 넣으니까 그제서야 됨 ㅋ. 골 때리네.

 

여튼 이 명령어가 Spring에서 실행이되도록 만들어야 하는데, 한 가지 문제가 생겼다.

 

Lettuce의 버전이 낮아서 그런지, hexpire이란 메서드를 제공해주지 않았다.

Judis 의존성까지 추가해주긴 좀 그래서 네이티브 명령어로 실행하려고 했는데, 계속 "io.lettuce.core.output.ByteArrayOutput does not support set(long)"이란 에러 로그가 발생했다.

 

 

ValueOutput does not support set(long) · Issue #2175 · redis/lettuce

Bug Report Current Behavior We have a lot of miscroservices which use lattuce as redis client. Sometimes (once per 1-2 weeks) lettuce in on of the instances starts to rise exception like this: Stac...

github.com

이걸 해결해보려고 클래스 파일까지 다 뒤져가면서 별의 별 짓을 다해봤지만, 도저히 해결이 되질 않았다.

 

쥐푸라기라도 잡는 심정으로 LuaScript를 사용해서 쿼리를 쏴봤는데,

@Override
public void save(Long userId, String hashKey, UserSession value) {
    String key = createKey(userId);
    String luaScript =
            "redis.call('HSET', KEYS[1], ARGV[1], ARGV[2]) " +
                    "return redis.call('HEXPIRE', KEYS[1], ARGV[3], 'FIELDS', '1', ARGV[1])";
    RedisScript<List> script = RedisScript.of(luaScript, List.class);
    try {
        List<Object> result = redisTemplate.execute(script,
                List.of(key),
                hashKey,
                serialize(value),
                ttlSeconds
         );
         log.info("User session saved for user {} with hash key {}. Result: {}", userId, hashKey, result);
    } catch (Exception e) {
        log.error("Error saving user session for user {} with hash key {}", userId, hashKey, e);
        throw new RuntimeException("Failed to save user session", e);
    }
}

놀랍게도 된다. (왜 됨?)

 

다만, opsForHash() 로 넣을 때는 byte로 보내더니, 이번엔 json으로 직렬화 해버려서 문제가 잠깐 꼬였었다.

그런데 그냥 'ObjectMapper 써버리면 되는 거 아니야?' 싶어서 해보니까 정상적으로 출력 ㅋ.

 

최종 코드는 아래에 첨부.

분명히 더 좋은 방법이 있을 것이다.

지금 구현이 너무 시급한 상황이라, 어떻게든 동작하게 만들고 치우는 것 뿐..

더보기
public class UserSession implements Serializable {
    @Serial
    private static final long serialVersionUID = 1L;

    private String deviceName;
    @Convert(converter = UserStatusConverter.class)
    private UserStatus status;
    private Long currentChatRoomId;
    private LocalDateTime lastActiveAt;
    @JsonIgnore
    private int hashCode;

    @JsonCreator
    private UserSession(
            @JsonProperty("deviceName") String deviceName,
            @JsonProperty("status") UserStatus status,
            @JsonProperty("currentChatRoomId") Long currentChatRoomId,
            @JsonProperty("lastActiveAt") LocalDateTime lastActiveAt
    ) {
        validate(deviceName, status, lastActiveAt);

        this.deviceName = deviceName;
        this.status = status;
        this.currentChatRoomId = currentChatRoomId;
        this.lastActiveAt = lastActiveAt;
    }

    /**
     * 새로운 사용자 세션을 생성한다.
     * 사용자의 상태는 ACTIVE_APP이며, 채팅방 관련 뷰룰 보고 있지 않음을 전제로 한다.
     * 마지막 활동 시간은 현재 시간으로 설정된다.
     */
    public static UserSession of(String deviceName) {
        return new UserSession(deviceName, UserStatus.ACTIVE_APP, null, LocalDateTime.now());
    }

    public String getDeviceName() {
        return deviceName;
    }

    public UserStatus getStatus() {
        return status;
    }

    /**
     * 사용자가 보고 있는 채팅방 ID를 반환한다.
     *
     * @return 사용자가 보고 있는 채팅방 ID. 채팅방을 보고 있지 않을 경우 -1을 반환한다.
     */
    public Long getCurrentChatRoomId() {
        if (!this.status.equals(UserStatus.ACTIVE_CHAT_ROOM)) {
            return -1L;
        }

        return currentChatRoomId;
    }

    public LocalDateTime getLastActiveAt() {
        return lastActiveAt;
    }

    public void updateStatus(UserStatus status) {
        this.status = status;
    }

    public void updateStatus(UserStatus status, Long currentChatRoomId) {
        this.status = status;
        this.currentChatRoomId = currentChatRoomId;
    }

    /**
     * 사용자의 마지막 활동 시간을 현재 시간으로 갱신한다.
     */
    public void updateLastActiveAt() {
        this.lastActiveAt = LocalDateTime.now();
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        UserSession that = (UserSession) o;
        return deviceName.equals(that.deviceName) && status == that.status && Objects.equals(currentChatRoomId, that.currentChatRoomId) && lastActiveAt.equals(that.lastActiveAt);
    }

    @Override
    public int hashCode() {
        if (hashCode != -1) {
            return hashCode;
        }

        int result = deviceName.hashCode();
        result = 31 * result + status.hashCode();
        result = 31 * result + (currentChatRoomId != null ? currentChatRoomId.hashCode() : 0);
        result = 31 * result + lastActiveAt.hashCode();
        return hashCode = result;
    }

    @Serial
    private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
        s.defaultReadObject();

        // 가변 요소를 방어적으로 복사
        deviceName = String.copyValueOf(deviceName.toCharArray());
        status = UserStatus.valueOf(status.name());
        lastActiveAt = LocalDateTime.of(lastActiveAt.toLocalDate(), lastActiveAt.toLocalTime());
        currentChatRoomId = this.currentChatRoomId == null ? null : Long.valueOf(currentChatRoomId);

        // 불변식을 만족하는지 검사한다.
        validate(deviceName, status, lastActiveAt);
    }

    private void validate(String deviceName, UserStatus status, LocalDateTime lastActiveAt) {
        if (deviceName == null) {
            throw new IllegalStateException("deviceName은 null일 수 없습니다.");
        }
        if (status == null) {
            throw new IllegalStateException("status는 null일 수 없습니다.");
        }
        if (lastActiveAt == null) {
            throw new IllegalStateException("lastActiveAt은 null일 수 없습니다.");
        }
    }

    @Override
    public String toString() {
        return "UserSession{" +
                "deviceName='" + deviceName + '\'' +
                ", status=" + status +
                ", currentChatRoomId=" + currentChatRoomId +
                ", lastActiveAt=" + lastActiveAt +
                '}';
    }
}
@Slf4j
@Repository
public class UserSessionRepositoryImpl implements UserSessionRepository {
    private static final long ttlSeconds = 60 * 60 * 24 * 7; // 1주일 (초)
    private final ObjectMapper objectMapper;
    private final RedisTemplate<String, UserSession> redisTemplate;

    public UserSessionRepositoryImpl(@DomainRedisTemplate RedisTemplate<String, UserSession> redisTemplate, ObjectMapper objectMapper) {
        this.redisTemplate = redisTemplate;
        this.objectMapper = objectMapper;
    }

    @Override
    public void save(Long userId, String hashKey, UserSession value) {
        executeScript(SessionLuaScripts.SAVE, userId, hashKey, serialize(value), ttlSeconds);
    }

    @Override
    public Optional<UserSession> findUserSession(Long userId, String hashKey) throws JsonProcessingException {
        Object result = executeScript(SessionLuaScripts.FIND, userId, hashKey);

        return Optional.ofNullable(deserialize(result));
    }

    @Override
    public Map<String, UserSession> findAllUserSessions(Long userId) throws JsonProcessingException {
        List<Object> result = executeScript(SessionLuaScripts.FIND_ALL, userId);

        return deserializeMap(result);
    }

    @Override
    public Long getSessionTtl(Long userId, String hashKey) {
        return executeScript(SessionLuaScripts.GET_TTL, userId, hashKey);
    }

    @Override
    public void resetSessionTtl(Long userId, String hashKey) {
        executeScript(SessionLuaScripts.RESET_TTL, userId, hashKey, ttlSeconds);
    }

    @Override
    public void delete(Long userId, String hashKey) {
        executeScript(SessionLuaScripts.DELETE, userId, hashKey);
    }

    private String createKey(Long userId) {
        return "user:" + userId;
    }

    private <T> T executeScript(SessionLuaScripts script, Long userId, Object... args) {
        try {
            return redisTemplate.execute(
                    script.getScript(),
                    List.of(createKey(userId)),
                    args
            );
        } catch (Exception e) {
            log.error("Error executing Redis script: {}", script.name(), e);
            throw new RuntimeException("Failed to execute Redis operation", e);
        }
    }

    private String serialize(UserSession value) {
        try {
            return objectMapper.writeValueAsString(value);
        } catch (JsonProcessingException e) {
            log.error("Error serializing UserSession", e);
            throw new RuntimeException("Failed to serialize UserSession", e);
        }
    }

    private UserSession deserialize(Object value) {
        if (value == null) return null;
        try {
            return objectMapper.readValue((String) value, UserSession.class);
        } catch (JsonProcessingException e) {
            log.error("Error deserializing UserSession", e);
            throw new RuntimeException("Failed to deserialize UserSession", e);
        }
    }

    private Map<String, UserSession> deserializeMap(List<Object> entries) {
        Map<String, UserSession> result = new ConcurrentHashMap<>();
        for (int i = 0; i < entries.size(); i += 2) {
            String key = (String) entries.get(i);
            UserSession value = deserialize(entries.get(i + 1));
            result.put(key, value);
        }
        return result;
    }
}
@RequiredArgsConstructor
public enum SessionLuaScripts {
    SAVE(
            "redis.call('HSET', KEYS[1], ARGV[1], ARGV[2]) " +
                    "return redis.call('HEXPIRE', KEYS[1], ARGV[3], 'FIELDS', '1', ARGV[1])",
            List.class
    ),
    FIND(
            "return redis.call('HGET', KEYS[1], ARGV[1])",
            String.class
    ),
    FIND_ALL(
            "return redis.call('HGETALL', KEYS[1])",
            List.class
    ),
    GET_TTL(
            "return redis.call('HTTL', KEYS[1], 'FIELDS', '1', ARGV[1])",
            Long.class
    ),
    EXISTS(
            "return redis.call('HEXISTS', KEYS[1], ARGV[1])",
            Boolean.class
    ),
    RESET_TTL(
            "return redis.call('HEXPIRE', KEYS[1], ARGV[2], 'FIELDS', '1', ARGV[1])",
            Long.class
    ),
    DELETE(
            "return redis.call('HDEL', KEYS[1], ARGV[1])",
            Long.class
    );

    private final String script;
    private final Class<?> returnType;

    public <T> RedisScript<T> getScript() {
        return RedisScript.of(script, (Class<T>) returnType);
    }

    public <T> Class<T> getReturnType() {
        return (Class<T>) returnType;
    }
}

 

포스팅만 보면 뚝딱 구현해낸 거 같겠지만, 이거 만드는 데 상당히 힘들었다..

이럴 때는 테스트 케이스를 하나 만들어두고 시작하는 게 훨씬 도움이 된다.

더보기
@Slf4j
@DisplayName("사용자 세션 Redis 저장소 테스트")
@SpringBootTest(classes = {UserSessionRepositoryImpl.class, RedisConfig.class})
@ActiveProfiles("test")
public class UserSessionCustomRepositoryTest extends ContainerRedisTestConfig {
    @Autowired
    private UserSessionRepository userSessionRepository;

    private Long userId;
    private String deviceId;
    private String deviceName;
    private UserSession userSession;

    @BeforeEach
    void setUp() {
        userId = 1L;
        deviceId = "123456789";
        deviceName = "TestDevice";
        userSession = UserSession.of(deviceName);
    }

    @Test
    @DisplayName("사용자 세션 저장 및 조회 테스트")
    void saveAndFindUserSessionTest() throws JsonProcessingException {
        // given
        userSessionRepository.save(userId, deviceId, userSession);

        // when
        Optional<UserSession> foundSession = userSessionRepository.findUserSession(userId, deviceId);

        // then
        log.debug("foundSession: {}", foundSession);
        assertTrue(foundSession.isPresent());
        assertEquals(deviceName, foundSession.get().getDeviceName());
        assertEquals(UserStatus.ACTIVE_APP, foundSession.get().getStatus());
    }

    @Test
    @DisplayName("모든 사용자 세션 조회 테스트")
    void findAllUserSessionsTest() throws JsonProcessingException {
        // given
        String deviceId2 = "987654321";
        String deviceName2 = "TestDevice2";
        UserSession userSession2 = UserSession.of(deviceName2);
        userSessionRepository.save(userId, deviceId, userSession);
        userSessionRepository.save(userId, deviceId2, userSession2);

        // when
        Map<String, UserSession> allSessions = userSessionRepository.findAllUserSessions(userId);

        // then
        log.debug("allSessions: {}", allSessions);
        assertThat(allSessions).hasSize(2);
        assertTrue(allSessions.containsKey(deviceId));
        assertTrue(allSessions.containsKey(deviceId2));
    }

    @Test
    @DisplayName("세션 TTL 조회 및 업데이트 테스트")
    void sessionTtlTest() throws Exception {
        // given
        userSessionRepository.save(userId, deviceId, userSession);

        // when
        Thread.sleep(1000); // 1초 대기
        Long initialTtl = userSessionRepository.getSessionTtl(userId, deviceId);
        userSessionRepository.resetSessionTtl(userId, deviceId); // 세션 초기화
        Long updatedTtl = userSessionRepository.getSessionTtl(userId, deviceId);

        // then
        log.debug("initialTtl: {}, updatedTtl: {}", initialTtl, updatedTtl);
        assertNotNull(initialTtl);
        assertNotNull(updatedTtl);
        assertTrue(updatedTtl > initialTtl); // 약간의 오차 허용
    }

    @Test
    @DisplayName("사용자 세션 존재 여부 조회 테스트")
    void existsTest() {
        // Given
        userSessionRepository.save(userId, deviceId, userSession);

        // When
        boolean exists = userSessionRepository.exists(userId, deviceId);

        // Then
        assertTrue(exists);

        // When
        boolean notExists = userSessionRepository.exists(userId, "nonExistentDevice");

        // Then
        assertFalse(notExists);
    }

    @Test
    @DisplayName("사용자 세션 삭제 테스트")
    void deleteUserSessionTest() throws JsonProcessingException {
        // given
        userSessionRepository.save(userId, deviceId, userSession);

        // when
        userSessionRepository.delete(userId, deviceId);

        // then
        Optional<UserSession> deletedSession = userSessionRepository.findUserSession(userId, deviceId);
        assertFalse(deletedSession.isPresent());
    }

    @Test
    @DisplayName("사용자 세션 상태 업데이트 테스트 (채팅방으로 이동)")
    void updateUserSessionStatusTest() {
        // given
        userSessionRepository.save(userId, deviceId, userSession);

        // when
        UserSession updatedSession = userSessionRepository.findUserSession(userId, deviceId).get();
        updatedSession.updateStatus(UserStatus.ACTIVE_CHAT_ROOM, 123L);
        userSessionRepository.save(userId, deviceId, updatedSession);

        // then
        log.debug("updatedSession: {} to {}", userSession, updatedSession);
        UserSession foundSession = userSessionRepository.findUserSession(userId, deviceId).get();
        assertEquals(UserStatus.ACTIVE_CHAT_ROOM, foundSession.getStatus());
        assertEquals(123L, foundSession.getCurrentChatRoomId());
    }

    @Test
    @DisplayName("사용자 세션 마지막 활동 시간 업데이트 테스트")
    void updateLastActiveAtTest() throws Exception {
        // given
        userSessionRepository.save(userId, deviceId, userSession);
        LocalDateTime initialLastActiveAt = userSession.getLastActiveAt();

        // when
        Thread.sleep(1000); // 1초 대기
        UserSession updatedSession = userSessionRepository.findUserSession(userId, deviceId).get();
        updatedSession.updateLastActiveAt();
        userSessionRepository.save(userId, deviceId, updatedSession);

        // then
        UserSession foundSession = userSessionRepository.findUserSession(userId, deviceId).get();
        assertTrue(foundSession.getLastActiveAt().isAfter(initialLastActiveAt));
    }

    @AfterEach
    void tearDown() {
        userSessionRepository.delete(userId, deviceId);
    }
}

 


3. 사용자 상태 활성화

 

📌 Design

사용자의 상태를 활성화로 수정해야 하는 경우는 언제일까?

세 가지 경우가 존재할 수 있다.

  1. 사용자가 서비스에 로그인하여, 웹 소켓 연결을 시도하는 경우
  2. 사용자가 백그라운드에서 포그라운드로 전환하는 경우
  3. 사용자가 백그라운드에 장기간 머물러 웹 소켓이 해제된 이후, 다시 포그라운드로 돌아오는 경우
  4. 푸시 알림을 받고, 딥 링크를 통해 앱에 접속하는 경우

 

여기서 주의해야 할 점은 언제나 ACTIVE_APP 상태로 전환하면 되는 (1)과 달리,

(2), (3), (4)의 경우엔 사용자가 채팅방 리스트 뷰, 혹은 특정 채팅방으로 직접적으로 연결될 수 있다는 점이다.

 

그렇다면 이것들을 전부 다르게 처리해주어야 할까?

사실 꼭 그렇게 보기도 어렵다.

 

(2)를 제외하고, (3), (4)는 반드시 Connect 요청을 선행해야 하며, 그렇다면 이 시점에 ACTIVE_APP 상태로 활성화될 것이다.

그 이후, Client에서 View를 생성할 때 사용자 상태 변경 요청을 추가로 보내주면(추가라기엔 원래 존재하는 로직) 문제는 해결된다.

 

(2) 또한 상태를 변경해주는 path를 하나 만들어주면 클라이언트에서 알아서 처리할 영역이므로,

서버에서 고려해야 하는 상황은 Connect 요청 시, 사용자 세션을 Activate로 수정하는 일 뿐이다.

 

📌 Connect Interceptor
@Slf4j
@Component
@RequiredArgsConstructor
public class ConnectAuthenticateHandler implements ConnectCommandHandler {
    private final AccessTokenProvider accessTokenProvider;
    private final UserService userService;
    private final UserSessionService userSessionService;

    @Override
    public boolean isSupport(StompCommand command) {
        return StompCommand.CONNECT.equals(command);
    }

    @Override
    public void handle(Message<?> message, StompHeaderAccessor accessor) {
        String accessToken = extractAccessToken(accessor);

        JwtClaims claims = accessTokenProvider.getJwtClaimsFromToken(accessToken);
        Long userId = JwtClaimsParserUtil.getClaimsValue(claims, AccessTokenClaimKeys.USER_ID.getValue(), Long::parseLong);
        LocalDateTime expiresDate = accessTokenProvider.getExpiryDate(accessToken);

        existsHeader(accessor);

        authenticateUser(accessor, userId, expiresDate);
        activateUserSession(accessor, userId);
    }
    
    ...
    
    private void authenticateUser(StompHeaderAccessor accessor, Long userId, LocalDateTime expiresDate) {
        String deviceId = accessor.getFirstNativeHeader(StompNativeHeaderFields.DEVICE_ID.getValue());
        String deviceName = accessor.getFirstNativeHeader(StompNativeHeaderFields.DEVICE_NAME.getValue());

        User user = userService.readUser(userId)
                .orElseThrow(() -> new JwtErrorException(JwtErrorCode.MALFORMED_TOKEN));
        Principal principal = UserPrincipal.of(user, expiresDate, deviceId, deviceName);

        log.info("[인증 핸들러] 사용자 인증 완료: {}", principal);

        accessor.setUser(principal);
    }
    
    private void activateUserSession(StompHeaderAccessor accessor, Long userId) {
        String deviceId = accessor.getFirstNativeHeader(StompNativeHeaderFields.DEVICE_ID.getValue());
        String deviceName = accessor.getFirstNativeHeader(StompNativeHeaderFields.DEVICE_NAME.getValue());

        if (userSessionService.isExists(userId, deviceId)) {
            log.info("[인증 핸들러] 사용자 세션을 업데이트합니다. userId: {}, deviceId: {}", userId, deviceId);
            userSessionService.updateUserStatus(userId, deviceId, UserStatus.ACTIVE_APP);
        } else {
            log.info("[인증 핸들러] 사용자 세션을 생성합니다. userId: {}, deviceId: {}", userId, deviceId);
            userSessionService.create(userId, deviceId, UserSession.of(deviceName));
        }
    }
}

사용자 인증 처리를 위해 만들었던 핸들러에서 사용자 세션 활성화 메서드를 마지막에 추가해주었다.

 

상태 바꾸는 요청 받을 때마다 deviceId와 deviceName을 받긴 좀 그러니, Principal 정보에 추가해주기로 했다.

 

마지막엔 사용자 세션 정보가 존재하면 업데이트, 없으면 생성하도록 만들었다.

그런데 사실 update도 key, hashKey를 기반으로 값을 덮어쓰는 로직으로 구현해놔서, 그냥 create로 때려넣어도 되긴 했는데...나중에 코드가 어떻게 바뀔 지 몰라서 방어적으로 작성했다.

 

📌 클라이언트 테스트

클라이언트에서 header를 누락하면 에러가 나는 것을 일단 확인했다.

 

그 다음엔 정상적인 요청으로 수정해서 요청을 보내보니, 사용자 세션 활성화까지 성공하면서 클리어!

 


4. 사용자 뷰 상태 추적

 

📌 Design

사용자가 상태를 변경하기 위해선, 추가적인 path를 열어주어야 한다.

interceptor에서 처리해도 되긴 하겠다만, 그럼 SEND 프레임마다 매번 확인해주면서 사용자 변경을 위한 메시지인지 확인해야 한다는 말이 되므로 기각.

 

📌 Status Update Business Login
public record StatusMessage(
        UserStatus status,
        Long chatRoomId
) {
    public StatusMessage {
        if (Objects.isNull(status)) {
            throw new MessageErrorException(MessageErrorCode.MALFORMED_MESSAGE_BODY);
        }

        if (status.equals(UserStatus.ACTIVE_CHAT_ROOM) && Objects.isNull(chatRoomId)) {
            throw new MessageErrorException(MessageErrorCode.MALFORMED_MESSAGE_BODY);
        }
    }

    public boolean isChatRoomStatus() {
        return status.equals(UserStatus.ACTIVE_CHAT_ROOM);
    }
}
@Slf4j
@Controller
@RequiredArgsConstructor
public class StatusController {
    private final StatusService statusService;

    @MessageMapping("status.me")
    @PreAuthorize("#isAuthenticated(#principal)")
    public void updateStatus(UserPrincipal principal, StatusMessage message, StompHeaderAccessor accessor) {
        statusService.updateStatus(principal.getUserId(), principal.getDeviceId(), message, accessor);
    }
}
@Slf4j
@Service
@RequiredArgsConstructor
public class StatusService {
    private final UserSessionService userSessionService;
    private final ApplicationEventPublisher publisher;

    public void updateStatus(Long userId, String deviceId, StatusMessage message, StompHeaderAccessor accessor) {
        if (message.isChatRoomStatus()) {
            UserSession session = userSessionService.updateUserStatus(userId, deviceId, message.chatRoomId());
            log.debug("사용자 상태 변경: {}", session);
        } else {
            UserSession session = userSessionService.updateUserStatus(userId, deviceId, message.status());
            log.debug("사용자 상태 변경: {}", session);
        }

        ServerSideMessage payload = ServerSideMessage.of("2000", "OK");
        Message<ServerSideMessage> response = MessageBuilder.createMessage(payload, accessor.getMessageHeaders());

        publisher.publishEvent(ReceiptEvent.of(response)); // @FIXME: Refresh Event와 달리 Receipt가 성공적으로 처리되지 않음.
    }
}

이것보다 쉬운 작업이 또 있겠나 하면서 작업했는데...웬걸? receipt 응답이 돌아가질 않는다.

refresh 요청할 땐 잘만 동작하면서, 왜 또 안 되는 건데 😇

 

이거 처리해보겠다고 Interceptor에 ClientOutboundChannel 빈 주입했더니, 이번엔 순환 참조 에러 발생해서 실패..

어차피 슬슬 클라이언트가 receipt 헤더 보내면, 언제나 응답을 돌려주도록 구성할 필요성을 느낀 터라 나중에 다시 고민하기로 했다.

(아니, 근데 그러든 말든 넌 돼야 할 거 아니야????? 얼탱이가 없어서 진짜)

 

🤔 왜 Status 업데이트 요청에 Receipt 프레임을 받아야 할까?

그냥 내 생각이라 정답은 아니고, 당연히 틀렸을 수도 있다.
ACTIVE_APP, INACTIVE, BACKGROUND 처럼, 어차피 메시지를 푸시 알림으로 받는다는 통일성을 갖는다면, 상태 변경 요청이 서버에 반영되었는지 확인하는 게 불필요할 수도 있을 것이다.

하지만, 채팅방 리스트를 보거나, 채팅방에 입장한 순간이라면?
사용자의 상태가 바뀌지 않은 상태로 들어가면, 클라이언트는 채팅방에 입장했는데도 메시지를 푸시 알림으로 받는 이상한 경험을 하게 될 수도 있다.

이런 걸 방지하고자 내가 테스트 용으로 작성하고 있는 코드에서도
채팅방 입장 상태 변경 요청에 대한 receipt를 받아야 입장 가능하도록 만들었는데, 계속 실패함 ㅎㅎㅎㅎ

receipt 전역 처리는 나중에 아예 따로 포스팅을 작성하도록 해야겠다.

 

📌 클라이언트 테스트

클라이언트 UI를 조금 수정해줬다.

여기엔 상태 변경 요청이 잘 되는지 확인하기 위해, 더미 페이지로 이동하는 버튼도 달아줬다. (ㅋㅋㅋ)

 

채팅방 화면에 접근하니, ACTIVE_CHAT_ROOM 상태로 전환되면서, 채팅방 아이디가 잘 들어가는 걸 확인할 수 있다.

 

이번엔 더미 페이지로 이동하면?

ACTIVE_APP 상태로 전환되면서, 채팅방 ID가 -1로 바뀌는 것도 확인.

 

채팅방으로 돌아오는 것까지 확인했는데, 서비스 로직에는 전혀 문제가 없음을 알 수 있다.

 

사실 이거 구현해두면, 백그라운드/포그라운드 전환, 뷰 이동, 로그아웃, 회원 탈퇴까지 모두 처리 가능하다.

(단, 로그아웃과 회원 탈퇴의 경우, 클라이언트에게 작업을 맡기는 게 불안하다면, Api Server가 session의 값을 조작하는 식으로 처리가 가능하긴 하나...별로 좋은 방법은 아니라고 본다.)

 


5. 사용자 상태 비활성화

 

📌 Design

사용자의 상태가 비활성화되는 경우는 DISCONNECT 프레임을 수신했을 때가 있다.

 

stompjs는 장기간 백그라운드 상태로 두면, DISCONNECT 요청을 보내 연결을 끊어버린다.

 

하지만, 이건 백그라운드에 장기간 있었기 때문에 연결이 끊긴 것을 감지했을 뿐이다.

(명시적으로 앱 혹은 브라우저를 종료해도 Disconnect가 되는데, 이 경우도 클라이언트 종료를 감지할 수 있다.)

 

만약, 포그라운드를 계속 유지하고 있었는데 네트워크가 불안정해진 상황이라면 DISCONNECT 프레임은 전달되지 않을 것이다.

소켓 서버는 연결이 끊어진 줄도 모르고, 사용자 상태를 여전히 유지하고 있을 수도 있게 된다는 의미.

 

물론 가만히 내버려둬도 Router나, 애플리케이션 레벨에서 연결이 끊김을 감지하고 결과적으론 Disconnect를 명시적으로 처리하게 되긴 한다.

그저 즉각적인 조치가 안 될 뿐이다.

 

자, 그렇다면 우리도 이러한 조치를 위해, 박동 검사를 하는 부분을 모두 구현하고, 사용자의 상태를 INVALID로 바꾸는 조치가 필요할까?

당연히 필요가 없다. 😄

 

Spring Web Socket은 이미 이러한 박동 검사를 자동으로 수행하고 있다.

위 로그에서 simpMessageType이 HEARTBEAT인 outbound 메시지가 바로 그것이다.

 

그렇다면 우린 Spring Websocket이 열심히 박동 검사하다가, DISCONNECT 처리해야 함을 명시적으로 알리면

해당 요청을 처리해주기만 하면 된다는 것.

(위 내용은 틀렸을 수 있지만...공식 문서 열심히 뒤졌는데, 아직 정확한 내용을 못 찾겠어서 나중에 수정될 수도 있습니다.)

 

📌 DISCONNECT
@Slf4j
@Component
@RequiredArgsConstructor
public class DisconnectHandler implements DisconnectCommandHandler {
    private final UserSessionService userSessionService;

    @Override
    public boolean isSupport(StompCommand command) {
        return StompCommand.DISCONNECT.equals(command);
    }

    @Override
    public void handle(Message<?> message, StompHeaderAccessor accessor) {
        UserPrincipal principal = (UserPrincipal) accessor.getUser();

        userSessionService.updateUserStatus(principal.getUserId(), principal.getDeviceId(), UserStatus.INACTIVE);
    }
}

간단하기 그지 없다.

그냥 Disconnect 프레임을 수신하면, 사용자 상태를 INACTIVE로 전환하면 된다.

 

진짜 설명할 게 없음.