배포 환경에서 Nginx 사용 시 추가 설정

이전 글에서 STOMP를 이용한 실시간 채팅을 구현했다.

로컬에서 테스트 할 때는 문제 없겠지만, 배포 환경에 따라 웹소켓을 이용하기 위해서는 추가 설정을 해야할 수 있다.

만약 이 글의 서비스에서 적용된 Nginx 관련 설정이 궁금하다면 이 글을 참고한다.

 

현재 우리 애플리케이션에서는 Nginx를 리버스 프록시로 사용하여 SSL을 적용하고 있다.

즉, Nginx를 사용하여 특정 도메인의 특정 포트로의 접근을 EC2 퍼블릭 IP로 포워딩 시켜주고 있다.

HTTP 요청일때는 별 문제 없지만 웹소켓은 HTTP와는 다른 프로토콜이므로 포워딩 시켜줄 때 추가 설정을 해줘야 한다.

101 Switching Protocol 

웹소켓을 사용할 때 요청 헤더를 보면 아래와 같이 101 Switching Protocol 요청을 보내고 있다.

요청 URL은 SSL을 적용했으므로 HTTPS 처럼 보안이 적용된 wss://www.midcon.store/ws 이다.

여기서 요청 헤더를 보면 Connection, Upgrade 헤더가 담겨 있는걸 확인할 수 있다.

이 요청을 통해 HTTP 요청에서 웹소켓 프로토콜(WS)로 전환 을 요청하는 것이다.

 

따라서 EC2의 퍼블릭 IP 주소로 포워딩 해줄 때도 Connection, Upgrade 헤더를 설정 해줘야 한다.

이전 글에서 WebSocketConfig를 설정할 때 웹소켓 요청 엔드포인트를 /ws로 해두었다.

그러므로 /ws 경로로 들어오는 요청에 Connection, Upgrade 헤더를 설정해주면 될 것이다.

Nginx 추가 설정

Nginx를 설정 해주는 방식은 여러 가지가 있겠지만 본인은 sites-available/default 파일을 직접 수정했다.

추가 설정할 부분은 아래와 같다.

location /ws {
                proxy_pass http://127.0.0.1:8080;
                proxy_http_version 1.1;
                proxy_set_header Upgrade $http_upgrade;
                proxy_set_header Connection "upgrade";
}

 

설정 후 443번 포트 관련 설정은 아래 사진과 같다.

Nginx 설정 시 location 순서를 고려해야할 수도 있지만 여기서는 "/ws"와 "/" 설정의 순서는 상관없을듯 하다.

아래에 HTTP 요청을 리다이렉트 하는 부분은 어차피 certbot이 자동으로 설정해주는 부분이니 생략했다.

/etc/nginx/sites-available/default

 

Nginx 설정을 해주고 난 뒤에는 늘 nginx 를 다시 실행해줘야한다.

아래 명령어로 Nginx를 다시 실행하면 설정값이 적용 돼서 웹소켓을 사용할 수 있을것이다.

sudo service nginx restart

 

참고자료

 

nginx 에서 WebSocket Proxy 설정하기

Nginx를 이용하여 websocket 연결 요청을 websocket 전용 url로 proxy 할 필요가 생겨서 기록을 남깁니다. 환경 nginx 1.18ver cent os 7 본론 저 같은 경우 9000번 포트로 들어오는 신호를 http://서버EndPoint/socket/tes

shinwusub.tistory.com

 

 

사용 기술

프론트엔드는 Next.js / 백엔드는 스프링을 이용하여 채팅 기능을 구현해볼 것이다.

채팅 기능에만 집중해서 만들것이며 기본적인 CORS같은건 굳이 설명하지 않는다.

이 글에는 프론트엔드 사이드 내용만 적고 백엔드 쪽은 이 글로 분리하려고 한다.

sockjs-client는 npm에서 관리가 안된지 오래 돼서 타입스크립트 사용 시 타입도 따로 추가해야한다.

따라서 types/sockjs-client 라이브러리를 추가로 넣어주도록 한다.

  • Next.js 14 / App router
  • TypeScript
  • @types/sockjs-client, sockjs-client, stompjs

채팅 기능 구현

원래는 STOMP Client를 brokerUrl을 설정해서 만들어보려다가 포기하고 SockJS를 구현체로 사용했다.

이건 백엔드 도메인을 http로 받는듯.brokerUrl은 ws://localhost:8080/ws 의 형식이다.대략적인 순서는 아래와 같다.

( + 수정 - 백엔드 쪽에서 withSockJs 설정을 제거하고 프론트 쪽에서 Stomp Client 로 만들면 된다.)

  1. useEffect로 마운트 시 STOMP Client를 만들고 소켓 연결
  2. Client 생성 시 설정해둔 onConnect 콜백이 동작하여 설정해둔 topic 구독
  3. 구독 시 설정해둔 onMessageReceived 콜백이 동작하여 구독중인 topic에서 메시지를 수신
  4. 메시지 종류에 따라 switch 문으로 분기하여 원하는 동작 실행
    본인은 메시지 출력을 위해 messages 배열에 push 하도록 함
  5. 메시지를 전송하고 싶으면 client.publish 함수를 이용하여 백엔드 엔드포인트로 메시지 전송
    백엔드 코드에서 MessageMapping("url")에 해당.
    prefix(본인은 "/app"으로 설정) + url 형식으로 엔드포인트를 설정해주면 됨

 

참고로 한 페이지에 여러 채팅방을 연결해야할 경우 소켓 연결은 부모 컴포넌트에서 한 번만 해도 된다.

자식 컴포넌트에 STOMP Client 를 전달하여 자식 컴포넌트에서 topic을 구독하는 식으로 구현한다.

"use client";

import { Client } from "@stomp/stompjs";
import { useEffect, useState } from "react";
import SockJS from "sockjs-client";

const BASE_URL = "http://localhost:8080";

type Message = { content: string; sender: string; type: string };

type SocketUnitProps = {
  id: number;
};

export default function OldSocketUnit({ id }: SocketUnitProps) {
  const [client, setClient] = useState<Client | undefined>(undefined);
  const [connected, setConnected] = useState(false);
  const [messages, setMessages] = useState<string[]>([]);

  const onMessageReceived = (res: any) => {
    const data = JSON.parse(res.body);

    const { type, content, sender } = data;
    switch (type) {
      case "JOIN":
        messages.push(`${sender}님 입장`);
        break;
      case "CHAT":
        messages.push(content);
        break;
      case "LEAVE":
        messages.push(`${sender} 퇴장`);
        break;
    }
    setMessages([...messages]);
  };

  const sendMessage = () => {
    const chatMEssage = {
      sender: "mi",
      content: "내용",
      type: "CHAT",
    };
    if (client) {
      console.log(client);
      client.publish({
        destination: `/app/chat.sendMessage/${id}`, // 백엔드의 웹소켓 엔드포인트
        headers: {},
        body: JSON.stringify(chatMEssage),
      });
    }
  };

  const disconnect = () => {
    client?.deactivate();
  };

  useEffect(() => {
    const client = new Client({
      webSocketFactory: () => {
        return new SockJS("http://localhost:8080/ws");
      },
      onConnect: () => {
        setConnected(true);
        if (client) {
          client.subscribe(`/topic/${id}`, onMessageReceived); // url의 id = chatroomId
          client.publish({
            destination: `/app/chat.addUser/${id}`, // 백엔드의 웹소켓 엔드포인트
            headers: {},
            body: JSON.stringify({ sender: "name", type: "JOIN" }),
          });
        }
      },
      onStompError: () => {
        console.log("에러 발생");
      },
      debug: function (str) {
        console.log(str);
      },
      reconnectDelay: 5000,
      heartbeatIncoming: 4000,
      heartbeatOutgoing: 4000,
    });
    setClient(client);

    try {
      console.log("[웹소켓] 연결 시도");
      client.activate();
    } catch (e) {
      console.log(e);
    }
  }, []);

  return (
    <div>
      <div>connection: {connected ? "true" : "false"}</div>
      {messages.map((message, i) => (
        <div key={i}>{message}</div>
      ))}
      <div style={{ display: "flex", flexDirection: "column" }}>
        <button
          style={{ border: "1px solid green" }}
          onClick={sendMessage}
          type="button"
        >
          메시지 보내기
        </button>
        <button style={{ border: "1px solid red" }} onClick={disconnect}>
          채팅방 입장
        </button>
      </div>
    </div>
  );
}

웹소켓

팀 프로젝트 중 채팅 기능을 넣자는 이야기가 나왔다.

전에 프로젝트 할때는 웹소켓이 별로 끌리지 않았고 힘들어보여서 다른 팀원에게 넘겼었다.

하지만 이번엔 머리에 든게 꽤 있으니 그리 어렵게 하지 않을거라는 생각이 들었다.

하다가 정 안되면 저번 프로젝트의 코드를 참고하면 되겠지 싶어서 해보았다.

이번 글에서는 메시지를 저장하지는 않고 웹소켓을 통해 채팅하기 까지만 구현한다.


사용 기술

프론트엔드는 Next.js / 백엔드는 스프링을 이용하여 채팅 기능을 구현해볼 것이다.

채팅 기능에만 집중해서 만들것이며 기본적인 CORS같은건 굳이 설명하지 않는다.

어차피 설정해줄 것도 origin, credential 설정 뿐이기도 하다.

이 글에는 백엔드 사이드 내용만 적고 프론트엔드 쪽은 이 글로 분리하려고 한다.

웹 소켓 통신은 프론트/백엔드 모두 알아야 연동하기 쉬우므로 프론트쪽 글과 함께 읽는걸 추천한다.

  • Spring Boot 3.2.4 / gradle-kotlin
  • Java 17
  • STOMP(프론트엔드에서는 구현체를 SockJS로 쓰긴 했음)

1. 웹소켓 관련 설정 해주기

WebSocket 의존성 추가

// build.gradle.kts
implementation("org.springframework.boot:spring-boot-starter-websocket")

WebSocketConfig 설정

채팅 기능을 구현하려면 MessageBroker가 필요하다.

웹소켓을 사용하기로 했으므로 WebSocketMessageBrokerConfigurer 설정을 해준다.

프론트엔드에서 구현체를 SockJS로 써서 아래처럼 설정한다.

+ 추가 ) 만약 STOMP Client를 사용한다면 withSockJS() 부분을 제거해야한다.

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

    private final AppConfig appConfig;

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/topic"); // "/topic"을 구독하는 클라이언트에게 메시지 전송
        registry.setApplicationDestinationPrefixes("/app"); // 프론트에서 붙여줄 엔드포인트의 prefix
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws") // 백엔드 URL의 엔드포인트
            .setAllowedOrigins(appConfig.getFrontUrl())  // 허용할 origin
            .withSockJS();  // SockJS로 통신함.STOMP Client를 사용하면 이 부분을 제거한다.
    }
}

2. 웹소켓을 이용한 채팅 구현

2-1. 도메인 객체 구현

연습용으로는 DTO로 만들어도 되지만 아마 저장까지 해야할것 같아서 엔티티로 만들었다.

단순하게 발송자, 내용, 메시지 타입만 설정해두었다.

ChatMessage

package com.pawland.chat.domain;

import com.pawland.user.domain.User;
import jakarta.persistence.*;
import lombok.*;

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ChatMessage {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String content;

    private String sender;

    private MessageType type;

    @Builder
    public ChatMessage(String content, String sender, MessageType type) {
        this.content = content;
        this.sender = sender;
        this.type = type;
    }
}

MessageType

아래는 프론트와 주고 받을 메시지 타입이다.

public enum MessageType {
    CHAT,
    JOIN,
    LEAVE
}

2-2. 컨트롤러 구현

컨트롤러 설정은 아래와 같다.

@Payload 애노테이션으로 프론트에서 JSON으로 받을 데이터를 DTO로 역직렬화 할 수 있다.

@DestinationVariable 애노테이션으로 HTTP요청의 @PathVariable 처럼 사용할 수 있다.

@Slf4j
@RestController
@RequiredArgsConstructor
public class ChatController {

    @MessageMapping("/chat.addUser/{roomId}") // 프론트가 publish 할 때 사용할 백엔드 엔드포인트
    @SendTo("/topic/{roomId}") // 백엔드에서 웹소켓으로 프론트에 메시지를 보내주는 구독 url
    public ChatMessage addUser(@Payload ChatMessage chatMessage, @DestinationVariable String roomId, SimpMessageHeaderAccessor headerAccessor) {
        // Add username in websocket session
        log.info("[유저 입장] chatMessage = {}, roomId = {}", chatMessage, roomId);
        headerAccessor.getSessionAttributes().put("username", chatMessage.getSender());
        headerAccessor.getSessionAttributes().put("roomId", roomId);
        return chatMessage;
    }

    @MessageMapping("/chat.sendMessage/{roomId}") // 프론트가 publish 할 때 사용할 백엔드 엔드포인트
    @SendTo("/topic/{roomId}")   // 백엔드에서 웹소켓으로 프론트에 메시지를 보내주는 구독 url
    public ChatMessage sendMessageToOne(@Payload ChatMessage chatMessage, @DestinationVariable String roomId) {
        log.info("[메시지 전송 to {}번 방] chatMessage = {}", roomId, chatMessage);
        log.info("id = {}", roomId);
        return chatMessage;
    }
}

 

본인이 작성한 엔드포인트에 대한 설명은 아래와 같다.

/app/chat.addUser/{roomId}

프론트에서는 위 엔드포인트로 입장 알림을 보낸다.

이 때 headerAccessor에 username과 roomId 값을 넣는다.

이는 이후 한쪽이 연결을 종료했을 때 상대방에게 퇴장 메시지를 보내기 위한 EventListener에서 사용한다.

/app/chat.addUser/{roomId}

프론트에서 입력한 채팅 메시지를 /topic/{roomId} 토픽을 구독하고 있는 모든 사용자에게 전송한다.

2-3. WebSocketListener 구현

한쪽이 연결을 종료했을 때 상대방에게 퇴장 메시지를 보내는 EventListener를 작성한다.

SessionDisConnectEvent를 이용하여 구현하였다.

프론트에서는 남은 한 쪽이 이걸 받고 메시지를 확인할 수 있게 출력하면 된다.

@Component
@RequiredArgsConstructor
@Slf4j
public class WebSocketListener {

    private final SimpMessageSendingOperations messageTemplate;

    @EventListener
    public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
        StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
        String username = (String) headerAccessor.getSessionAttributes().get("username");
        String roomId = (String) headerAccessor.getSessionAttributes().get("roomId");
        if (username != null) {
            log.info("User disconnected");
            ChatMessage chatMessage = ChatMessage.builder()
                .type(MessageType.LEAVE)
                .sender(username)
                .build();
            messageTemplate.convertAndSend("/topic/" + roomId, chatMessage);
        }
    }
}

+ Recent posts