Song[coding diary index]

Song 배열에 코딩 흔적 남겨두기

spring

[EP 1-6] 채팅 설계할때 고려할 점: Spring boot Webocket + STOMP

singsangssong 2025. 4. 12. 00:54
반응형

서론

socket을 공부하다보니 API 서버보다 설계단계에서 고려할 점이 너무나도 많았다.

익숙하지 않은 것도 있겠지만 api서버와는 사뭇 다른 socket이기에, "socket통신 시에 고려해야할 점"을 정리해둔 페이지이다.

부디 socket통신을 만들면서, 채팅을 만들면서 나와 같은 삽질은 하지 않고 빠르게 이해하고 만들기 바라는 마음으로 이렇게 정리해본다.

 

필자는 spring boot websocket을 사용했음을 인지하길 바란다.

    implementation 'org.springframework.boot:spring-boot-starter-websocket'

 

요구사항

내가 현재 구현해야하는 기능은 총 3가지였다.

1. 채팅

2. 푸시 알림

3. 결제

 

결제는 사업자 등록이 완료된 후에 이루어져야하기에 채팅, 푸시 알림을 한번에 구현하기로 했다.

채팅은 무조건 1:1이며, 알람은 장소 예약 완료, 채팅 2가지의 경우에 알림이 온다고 가정한다.

Socket의 간단한 설명

우리가 짜는 API서버는 http를 기반으로 하는 무상태(stateless), 즉 연결성을 보장하지 않는 서버이다.

client와 server가 서로 통신할때, 요청 응답을 보내고 모르는 사이가 되는 것이다.

👨‍💻 client: 채팅방 목록 내놔.
👾 server: ok. 여기있어.

...(10분동안 수많은 채팅이 왔음)

👨‍💻 client: 음 채팅이 아직 안왔네. 무슨일 있나? 채팅방1의 채팅내역 내놔.
👾 server: ok. 여기있어.
👨‍💻 client: 아니 너가 줬던 마지막 채팅이후에 몇개나 채팅이 온거야!!! 😡😡😡

 

이 프로토콜을 해결하기 위해서 처음에는 Polling기술이 나온다. (진화버전: Long Polling)

client가 server에게 계속 요청을 보내면서 바뀐 상태가 없는지 확인하는 것이다.

아래 10초는 임의의 수로, 주기마다 요청을 계속 보낸다고 생각하면 된다.

👨‍💻 client: 채팅방 목록 내놔.
👾 server: ok. 여기있어.

(10초후...)
👨‍💻 client: 채팅방 목록 바뀐거있어?
👾 server: 없어.

(10초후...) x n...
👨‍💻 client: 채팅방 목록 바뀐거있어?
👾 server: 없어.

(10초후...)
👨‍💻 client: 채팅방 목록 바뀐거 있어?
👾 server: ok. 여기있어.
👨‍💻 client: 오 채팅이 왔다!!

 

위 기술도 결국 무상태에서 벗어난 기술은 아니다. 

이에 우리가 지금 공부하는 socket이 등장한다!

http와 가장 큰 차이로, 상태유지(stateful)를 하기에 server에서 변경사항을 곧바로 client에게 응답한다!

👨‍💻 client: 안녕? 난 client 1이야. 잘 부탁해.
👾 server: 오 그래. 알겠어. 너를 기억해둘게!
📌 핸드쉐이크를 통해 연결설정을 거친다. 서버는 유저들을 기억하고있다.📌 

(어떤 채팅이 오게 됨.)
👾 server: 어떤 채팅이 왔어
👨‍💻 client: 오 고마워! 채팅이 바뀌는게 바로바로 보인다!

👨‍💻 client: 이제 나 갈게! 안녕~
👾 server: 오 그래. 알겠어. 너를 잊을게
📌 연결이 끊기면 상태유지를 종료한다.📌

 

이렇게 쉽게 이해하면 편하다. 근데 이제 소켓을 설계하는 과정이 여간 어려운게 아니다.

초보들은 꼭 아래 글을 읽고 설계할 때 참고하길 바란다.


1. 사용자의 상태 관리

⭐️지금 유저가 접속해있는가?⭐️

 

이부분을 놓치는 사람이 가장 많지 않을까 싶다.

socket통신을 client와 server가 서로 연결된 상태라면, server는 세션으로 client를 등록하고 연결여부를 확인한다.

그렇다면 사용자가 요청하는 정보를 어떻게 구별할까?

 

기존 http 서버에서는, API 하나당 하나의 동작을 한다.

Ex: 회원조회 -> /api/v1/member GET, 회원저장 -> /api/v1/member POST

이런식으로 http method와 uri를 통해서 요청을 구별하기 쉽다.

 

반면 socket은 다르다. socket은 하나의 연결 스트림에서 사용자의 요청이 지속적으로 오거나, 서버에서 응답을 준다.

이에 socket은 데이터에 타입을 명시해두는 전략이 좋다.

 

Ex1. 채팅방 진입 -> /ws/message

{
	"type": ENTER,
    "chatroomId": 1,
    "name": "홍길동"
}

 

Ex2. 채팅 보내기 -> /ws/message

{
	"type": MSG,
    "chatroomId": 1,
    "name": "짱구",
    "content": "예쁜 누나다!😄"
}

 

이후 보낼 상대방의 세션이 없다면 접속하지 않은상태이기에 푸시알람을 보내거나...

타입을 보고 로직을 나눠서 작성하자!

 

필자의 요구사항에서는 type: MESSAGE, STATUS, MSGROOM 3가지로 나누고,

MESSAGE: 채팅 메세지

STATUS: 채팅이나 알림 수 -> ex: A가 B의 장소에 예약했습니다!, 알림수: 10, 채팅수: 15

MSGROOM: 채팅 조회방에서 온 채팅 -> ex: 채팅방1에 유저C가 "~~"말했다.

 

필자는 소켓연결마다 하나의 역할만 한다고 생각했다. 이에 2번과 같은 실수를 저지를 뻔했다...


2. 연결 스트림의 수

1번에서 말한 실수 중 하나였다.

API서버와 동일하게 생각했기에, socket 연결 하나당 하나의 요청 및 처리만 해야한다고 생각했다.

 

❓만약 socket연결 하나당 하나의 요청만 처리한다면?

 

1. 서버 리소스

socket은 상태유지가 특징이다. 그만큼 메모리, 쓰레드, 세션을 차지하고 있다.

그리고 그 유저가 많아지면, N 유저 * M 스트림 으로 기하급수적으로 스트림 수가 늘어난다.

-> 서버의 부담이 너무나 많이 늘어나 성능 저하가 생길 수 있다. (스케일링 이슈)

 

2. 스트림, 세션 관리

유저가 세션을 뜯어보면, 연결요청마다 세션아이디를 UUID를 통해 생성한다

websocket라이브러리보다가 찾았다...

이 스트림이 N*M개만큼 많아지게 되면, 부하가 높아지며 세션을 유실하고 매핑관계가 복잡해진다.

그러면 해당 유저의 세션 상태, 연결 여부, 중복 연결 등을 추적하기 어렵고, 로그아웃 처리, 연결 끊김 감지, 중복 접속 감지 등을 구현하기 까다로워진다.

 

3. 유저 인증관리의 손해

스트림마다 인증 토큰을 다시 확인하거나, 각 소켓에 대해 유저 정보를 따로 관리해야한다.

애초에 하나의 소켓에만 유저 정보 세팅하면 되는데, 여러 개면 각 스트림마다 별도 처리해야하는 손해가 발생한다.

 

애초에 유지보수 및 관리가 너무 복잡해진다. 소켓연결의 수는 최소화하자.

실제로 이렇게 구현은 가능할 것이다. 오류도 없었지만 단일서버니까, 개발단계니까 가능한 사치이다.


3. 요청시 인증인가

socket 연결요청시에는 헨드쉐이크 과정을 거치고 사용자를 식별하도록 한다. 

이때, 인증인가는 어떻게 할까? 연결된 사용자면 모든 socket 요청을 받아주면 될까? (되겠냐)

 

Http에서 만약 jwt로 인증/인가를 수행한다면

기존에 jwt로 사용자를 식별했다면, socket의 해더에 토큰을 넣고 해당 사용자를 식별하도록 한다.

 

프론트에서 jwt를 복호화해서 보내기에는 서버와 프론트간의 역할이 모호해지는 경우가 발생하기에, 이는 역할배분관점에서 어긋난다고 생각한다. 그러니 socket연결에서 해더에 jwt를 보내줘서 인증/인가를 진행하자!

 

서버에서 socket 요청마다 핸들러로 요청의 인증인가를 진행해보는 예시이다.

@Component
@RequiredArgsConstructor
public class JwtWebSocketHandler implements HandshakeInterceptor {
    private final TokenService tokenService;

    @Override
    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
                                   WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {

        try {
            String authHeaders = request.getHeaders().get(HttpHeaders.AUTHORIZATION).toString();
            if (authHeaders == null || authHeaders.isEmpty()) {
                throw new IllegalArgumentException("Authorization 헤더가 없습니다.");
            }

            String token = authHeaders.substring(7); // "Bearer " 이후의 토큰
            String email = tokenService.validateAccessToken(token);

            attributes.put("email", email);
            return true;

        } catch (Exception e) {

        }

        return false;
    }
    @Override
    public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response,
                               WebSocketHandler wsHandler, Exception exception) {

    }
}

 

4. STOMP의 필요성

마지막으로 stomp와 같은 기존 프로토콜을 사용하는것을 추천한다.

 

이는 개인적 얘기지만, 원래 stomp없이 websocket만을 통해서 세션관리와 채팅방 관리등을 직접 수행하려고 했다.(pub/sub 사용x)

구현하던 중, 에러가 발생했을때 에러메세지를 유저에게 전송하고 싶었다.

유저에게 에러를 전송하기 위해서 기존 http에서 활용했던 공통 에러를 통해 메세지를 담아 보냈는데...

왠걸? 에러사항이 socket에 반영되지 않는다.

 

찾아보니 websocket은 로우레벨의 프로토콜이다보니 에러 메세지를 받기 위한 과정을 직접 모두 정의해아한다....

이래서 다 프레임워크쓰고 라이브러리 가져다 쓰는구나 싶었다...

 

외에도 인증/인가 핸들링이나 세션관리에 대한 설정 모두를 직접 설계하다보니 

점점 라이브러리 개발이 될지도 모른다.

 

그러니 stomp가 아니더라도 기존에 나온 프로토콜을 쓰자...!

 

매우 어설픈 글이지만 많은 왕초보들이 초보의 글을 보고 도움이 되길 바란다..!