6/17
일정
•
1차 프로젝트: 7월 3일 ~ 7월 29일 (26일)
•
2차 프로젝트: 7월 29일 ~ 8월 26일 (29일)
•
취업 지원 특강
◦
6월 18일 - 수, 6월 19일 - 목
◦
8월 13일 - 수, 8월 27일 - 수
•
휴강일
◦
7월 24일 - 목, 7월 25일 - 금
•
프로젝트 내용
◦
4인 이상 한 팀
•
프로젝트 팀 구성
◦
이론 수업 끝난 이후에 1:1 상담으로 진행할 예정
◦
프로젝트 관련 기획서들 수령하여 검토 후 프로젝트 팀 구성 진행 예정
▪
구글 폼 공유
▪
1순위, 2순위, 3순위 등 순위 조정하여 진행
Docker
•
도커 이미지 명령어
•
도커 네트워크 구성 (Nginx + Spring 서버 연동)
//도커 네트워크 구성
docker network create my-network
//서버 컨테이너
docker run --name backend -d -p 8080:8080 --network my-network backend
//nginx 컨테이너 - 윈도우
docker run -d --name nginx --network my-network -p 8080:80 -v ${pwd}/nginx.conf:/etc/nginx/nginx.conf nginx
//nginx 컨테이너 - Mac
docker run -d --name nginx --network my-network -p 8080:80 -v ./nginx.conf:/etc/nginx/nginx.conf nginx
Shell
복사
•
volume의 개념
◦
container를 삭제해도 나중에 다시 container를 만들었을 때 데이터 복구가 가능하다.
•
명령어 - gradle 빌드 파일 초기화
./gradlew clean build
Shell
복사
6/18 ~ 6/20 (STOMP + CHATBOT)
STOMP 흐름도
WebSocketConfig
package org.example.backendproject.stompwebsocket.config;
import org.example.backendproject.stompwebsocket.handler.CustomHandshakeHandler;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
/**
* 구독중 Prefix
* /topic 일반 채팅을 받을 접두어
* /queue 귓속말을 받을 접두어
* 서버가 보내는 메시지를 클라이언트가 구독할 때 사용하는 경로
*/
registry.enableSimpleBroker("/topic", "/queue"); // 구독용 경로
/** 전송용 Prefix **/
// 클라이언트가 서버에 메시지를 보낼 때 사용하는 경로 접두어
registry.setApplicationDestinationPrefixes("/app"); // 클라이언트 -> 서버
// 서버가 특정 사용자에게 메시지를 보낼 때, 클라이언트가 구독할 경로 접두어
registry.setUserDestinationPrefix("/user"); // 서버 -> 특정 사용자
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws-chat")
.setHandshakeHandler(new CustomHandshakeHandler())
.setAllowedOriginPatterns("*");
// gpt 전용 채팅 EndPoint 설정
registry.addEndpoint("/ws-gpt")
.setAllowedOriginPatterns("*");
}
}
Java
복사
1. 클래스 레벨 어노테이션
•
@Configuration: 이 클래스가 Spring의 설정 클래스임을 나타낸다. Spring 컨테이너가 이 클래스를 읽어 빈(Bean)들을 등록하고 설정을 적용한다.
•
@EnableWebSocketMessageBroker: 이 어노테이션은 Spring의 STOMP 기반 WebSocket 메시징 기능을 활성화하는 핵심 역할을 수행한다. 이를 통해 메시지 브로커가 활성화되고, @MessageMapping과 같은 STOMP 관련 어노테이션을 사용할 수 있게 된다.
2. implements WebSocketMessageBrokerConfigurer
이 인터페이스를 구현함으로써 WebSocket 메시지 브로커와 관련된 다양한 설정을 오버라이드하여 구성할 수 있다. 여기서는 주로 configureMessageBroker와 registerStompEndpoints 메서드를 사용한다.
3. configureMessageBroker(MessageBrokerRegistry registry) 메서드
이 메서드는 메시지 브로커에 대한 설정을 담당한다. 메시지 브로커는 클라이언트와 서버 간의 메시지 라우팅을 처리하는 중간자 역할을 한다.
•
registry.enableSimpleBroker("/topic", "/queue");
◦
이 부분이 메시지 브로커를 활성화하는 설정이다. 여기서는 Simple Broker를 사용하겠다고 선언했다. Simple Broker는 개발 단계에서 간단하게 사용할 수 있는 인메모리(in-memory) 브로커이다.
◦
/topic: 이 접두어를 가진 메시지는 일대다(pub-sub) 통신, 즉 여러 클라이언트가 구독할 수 있는 공개 채널에 사용된다. 예를 들어, ws://localhost:8080/ws-chat으로 연결된 클라이언트가 /topic/chatRoomA를 구독하면, 이 경로로 서버가 보내는 모든 메시지를 받게 된다. 일반적인 채팅방 메시지에 적합하다.
◦
/queue: 이 접두어를 가진 메시지는 일대일(point-to-point) 통신에 사용됩니다. 특정 클라이언트 한 명에게만 메시지를 보내고자 할 때 사용된다. 예를 들어, /queue/errors나 /queue/privateMessage와 같이 특정 사용자에게 직접 전달되는 메시지에 사용될 수 있다. enableSimpleBroker에 명시된 접두어는 클라이언트가 구독할 수 있는 경로를 의미하며, 이 경로로 서버가 클라이언트에게 메시지를 보낸다.
•
registry.setApplicationDestinationPrefixes("/app");
◦
이 접두어는 클라이언트가 서버로 메시지를 보낼 때 사용하는 경로의 시작 부분이다.
◦
예를 들어, 클라이언트가 /app/chat/sendMessage로 메시지를 보내면, Spring은 이 메시지를 @MessageMapping("/chat/sendMessage") 어노테이션이 붙은 컨트롤러 메서드로 라우팅한다.
◦
이것은 서버의 비즈니스 로직을 처리하는 컨트롤러로 메시지를 보내는 용도이다.
•
registry.setUserDestinationPrefix("/user");
◦
이 접두어는 서버가 특정 사용자에게 메시지를 보낼 때 클라이언트가 구독하는 경로를 지정합니다.
◦
예를 들어, 서버에서 messagingTemplate.convertAndSendToUser("userId", "/queue/messages", message)와 같이 코드를 작성하면, Spring은 내부적으로 이 메시지를 "/user/userId/queue/messages"와 같은 경로로 변환하여 해당 userId를 가진 클라이언트에게만 전달한다.
◦
이는 귓속말이나 개인 알림 등 특정 사용자에게만 메시지를 보내야 할 때 유용하다.
4. registerStompEndpoints(StompEndpointRegistry registry) 메서드
이 메서드는 WebSocket 핸드셰이크를 처리할 엔드포인트를 등록한다. 클라이언트가 WebSocket 연결을 시도하는 초기 URL을 정의한다.
•
registry.addEndpoint("/ws-chat")
◦
클라이언트가 /ws-chat 경로로 WebSocket 연결을 시도하도록 설정한다. 예를 들어, 클라이언트는 ws://your-server-address/ws-chat으로 연결을 시작하게 된다.
◦
.setHandshakeHandler(new CustomHandshakeHandler()):
▪
이 부분은 WebSocket 핸드셰이크 과정에서 사용자 정의 로직을 추가할 수 있도록 CustomHandshakeHandler를 설정했다. HandshakeHandler는 WebSocket 연결이 수립되기 전에 HTTP 핸드셰이크 요청을 가로채서 WebSocketSession에 사용자의 정보를 추가하거나, 연결을 거부하는 등의 작업을 수행할 수 있다.
▪
이는 사용자 인증 정보(예: 로그인한 사용자 ID)를 WebSocket 세션에 연결하여 나중에 메시지를 보낼 때 사용자를 식별하는 데 매우 유용하게 활용될 수 있다.
◦
.setAllowedOriginPatterns("*"): 모든 Origin(출처)에서의 연결을 허용한다. 개발 환경에서는 편리하지만, 실제 운영 환경에서는 보안을 위해 클라이언트 애플리케이션의 도메인을 명시적으로 지정하는 것이 좋다.
•
registry.addEndpoint("/ws-gpt")
◦
/ws-gpt라는 또 다른 WebSocket 엔드포인트를 추가했다. 이는 GPT 관련 채팅을 위한 별도의 엔드포인트이다.
◦
.setAllowedOriginPatterns("*"): 이 엔드포인트도 모든 Origin을 허용하도록 설정했다.
StompPrincipal
package org.example.backendproject.stompwebsocket.handler;
import java.security.Principal;
public class StompPrincipal implements Principal {
private final String name;
public StompPrincipal(String name) {
this.name = name;
}
@Override
public String getName() {
return name;
}
}
Java
복사
•
implements Principal: java.security.Principal은 현재 인증된 개체(사용자)를 나타내는 데 표준적으로 사용되는 인터페이스다. Spring Security와 통합되거나, WebSocket 세션에서 사용자 정보를 관리할 때 이 인터페이스를 활용하면 일관성을 유지할 수 있다.
•
private final String name;: 사용자 이름을 저장하는 필드. final 키워드를 사용해서 한 번 할당되면 변경되지 않도록 불변성을 보장. 이는 객체의 상태가 예측 가능하게 유지되도록 돕고 스레드 안전성에도 기여.
•
public StompPrincipal(String name): 생성자를 통해 사용자 이름을 초기화.
•
@Override public String getName(): Principal 인터페이스의 추상 메서드를 구현. 이 메서드는 이 Principal이 나타내는 엔티티의 이름을 반환하는데, 여기서는 생성자에서 받은 name 값을 그대로 반환.
CustomHandshakeHandler
package org.example.backendproject.stompwebsocket.handler;
import java.security.Principal;
import java.util.Map;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.support.DefaultHandshakeHandler;
public class CustomHandshakeHandler extends DefaultHandshakeHandler {
@Override
protected Principal determineUser(ServerHttpRequest request,
WebSocketHandler wsHandler,
Map<String, Object> attributes) {
String nickname = getNickName(request.getURI().getQuery());
return new StompPrincipal(nickname);
}
private String getNickName(String query) {
if (query == null || !query.contains("nickname=")) {
return "닉네임없음";
} else {
return query.split("nickname=")[1];
}
}
}
Java
복사
•
extends DefaultHandshakeHandler: DefaultHandshakeHandler를 상속받는 것은 일반적인 방법이다. 이 클래스는 WebSocket 핸드셰이크의 기본 동작을 제공하며, 필요한 경우 특정 메서드를 오버라이드하여 기능을 확장할 수 있다.
•
@Override protected Principal determineUser(...): 이 메서드는 WebSocket 핸드셰이크 요청이 들어올 때 호출되며, 현재 연결을 시도하는 사용자를 식별하여 Principal 객체를 반환한다.
◦
ServerHttpRequest request: 클라이언트의 HTTP 요청 정보를 담고 있다. 여기서는 요청 URI의 쿼리 파라미터를 사용한다.
◦
WebSocketHandler wsHandler: 핸드셰이크를 처리하는 WebSocket 핸들러이다.
◦
Map<String, Object> attributes: WebSocket 세션과 관련된 속성들을 저장하는 맵이다. 이 맵에 저장된 정보는 WebSocketSession 객체에서 나중에 접근할 수 있다.
◦
String nickname = getNickName(request.getURI().getQuery());: 요청 URI의 쿼리 문자열에서 닉네임을 추출한다. 이는 클라이언트가 WebSocket 연결 시 ws://localhost:8080/ws-chat?nickname=사용자1과 같이 닉네임을 전달할 것으로 예상된다.
◦
return new StompPrincipal(nickname);: 추출한 닉네임을 사용하여 앞서 정의된 StompPrincipal 객체를 생성하고 반환한다. 이 Principal 객체는 WebSocket 세션에 연결되어 STOMP 메시징 과정에서 현재 사용자를 식별하는 데 사용된다. 예를 들어, @SendToUser와 같은 어노테이션으로 특정 사용자에게 메시지를 보낼 때 이 Principal의 name 값을 활용할 수 있다.
•
private String getNickName(String query) 메서드:
◦
이 헬퍼 메서드는 주어진 쿼리 문자열에서 "nickname=" 다음에 오는 값을 추출한다.
◦
if (query == null || !query.contains("nickname=")): 쿼리 문자열이 null이거나 "nickname="을 포함하지 않는 경우, 기본값으로 "닉네임없음"을 반환하여 닉네임이 없는 경우를 처리한다.
◦
else { return query.split("nickname=")[1]; }: 쿼리 문자열에 "nickname="이 포함되어 있으면, 이를 기준으로 문자열을 분리하여 두 번째 부분(즉, 닉네임 값)을 반환한다.
RedisConfig
package org.example.backendproject.stompwebsocket.redis;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.listener.PatternTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.listener.adapter.MessageListenerAdapter;
@Profile("!test")
@RequiredArgsConstructor
@Configuration
public class RedisConfig {
private final RedisSubscriber redisSubscriber;
@Bean
public RedisMessageListenerContainer container(RedisConnectionFactory redisConnectionFactory) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(redisConnectionFactory);
container.addMessageListener(new MessageListenerAdapter(redisSubscriber), new PatternTopic("room.*"));
container.addMessageListener(new MessageListenerAdapter(redisSubscriber), new PatternTopic("private.*")); // 귓속말
return container;
}
}
Java
복사
•
@Profile("!test"): 이 어노테이션은 이 설정 빈(Bean)이 "test" 프로파일이 활성화되지 않았을 때만 로드되도록 지정한다.
•
@RequiredArgsConstructor: Lombok 어노테이션으로, 클래스의 final 필드들을 초기화하는 생성자를 자동으로 생성해준다. 여기서는 redisSubscriber 필드를 주입받기 위해 사용되었다. 이는 보일러플레이트 코드를 줄여준다.
•
@Configuration: 이 클래스가 Spring의 설정 클래스임을 나타내며, Spring 컨테이너가 이를 읽어 빈들을 등록하고 설정을 적용한다.
•
private final RedisSubscriber redisSubscriber;: Redis에서 메시지를 수신했을 때 이를 처리할 로직을 담고 있는 RedisSubscriber 빈을 주입받는다. 이는 Redis Pub/Sub 모델의 핵심 구독자 역할을 한다.
•
@Bean public RedisMessageListenerContainer container(RedisConnectionFactory redisConnectionFactory):
◦
이 메서드는 RedisMessageListenerContainer 빈을 정의한다. 이 컨테이너는 Redis의 Pub/Sub(발행/구독) 기능과 Spring 애플리케이션을 연결하는 핵심 컴포넌트다. Redis 채널에서 메시지가 발행되면 이를 감지하고 리스너에게 전달하는 역할을 수행한다.
◦
RedisConnectionFactory redisConnectionFactory: Redis 서버와의 연결을 관리하는 팩토리다. Spring Data Redis가 자동으로 이 빈을 주입해준다.
◦
container.setConnectionFactory(redisConnectionFactory);: 컨테이너가 사용할 Redis 연결 팩토리를 설정한다.
◦
container.addMessageListener(new MessageListenerAdapter(redisSubscriber), new PatternTopic("room.*"));:
▪
이 부분이 특정 Redis 채널을 구독하는 설정이다.
▪
new MessageListenerAdapter(redisSubscriber): RedisSubscriber 객체를 MessageListenerAdapter로 감싸서, Redis 메시지가 수신될 때 RedisSubscriber 내의 특정 메서드(기본적으로 handleMessage나 onMessage)가 호출되도록 한다. 이를 통해 Redis 메시지를 Java 객체로 변환하고 비즈니스 로직을 처리할 수 있다.
▪
new PatternTopic("room.*"): room.으로 시작하는 모든 Redis 채널을 구독한다. 예를 들어, room.chatRoomA, room.general, room.game123 등 다양한 채팅방에 대한 메시지를 이 패턴으로 수신할 수 있다. 이는 채팅방의 스케일 아웃(Scale-out)을 가능하게 한다. 여러 서버 인스턴스가 각각 room.* 채널을 구독하고 있다면, 어떤 서버에서 발행된 메시지라도 모든 관련 클라이언트에게 전달될 수 있다.
◦
container.addMessageListener(new MessageListenerAdapter(redisSubscriber), new PatternTopic("private.*"));:
▪
room.*과 유사하게 private.으로 시작하는 모든 Redis 채널을 구독한다.
▪
이는 주로 귓속말(private message) 또는 특정 사용자에게만 보내는 메시지에 사용될 것으로 예상된다. 예를 들어, private.user123 채널로 메시지를 보내면, 이 채널을 구독하는 인스턴스를 통해 user123 클라이언트에게 메시지가 전달될 수 있다.
RedisPublisher
package org.example.backendproject.stompwebsocket.redis;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
@RequiredArgsConstructor
@Component
public class RedisPublisher {
private final StringRedisTemplate stringRedisTemplate;
/** 메세지를 발행하는 클래스 **/
public void publish(String channel, String msg) {
stringRedisTemplate.convertAndSend(channel, msg);
}
}
Java
복사
이 클래스는 Redis 채널로 메시지를 발행하는 역할을 한다.
•
@RequiredArgsConstructor: Lombok이 final 필드인 stringRedisTemplate에 대한 생성자를 자동으로 만들어준다.
•
@Component: 이 클래스가 Spring의 컴포넌트 스캔 대상임을 나타내며, Spring 컨테이너에 의해 빈으로 등록된다.
•
private final StringRedisTemplate stringRedisTemplate;: Spring Data Redis에서 제공하는 Redis 상호작용을 위한 템플릿이다. StringRedisTemplate은 키와 값이 모두 String 타입인 경우에 유용하게 사용된다.
•
public void publish(String channel, String msg):
◦
이 메서드가 실제 메시지 발행을 담당한다.
◦
stringRedisTemplate.convertAndSend(channel, msg);를 호출하여 지정된 channel로 msg를 발행한다. convertAndSend는 StringRedisTemplate이 제공하는 메서드로, 직렬화 과정을 추상화해준다.
RedisSubscriber
package org.example.backendproject.stompwebsocket.redis;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.example.backendproject.stompwebsocket.dto.ChatMessage;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class RedisSubscriber implements MessageListener {
private final SimpMessagingTemplate simpMessagingTemplate;
private ObjectMapper objectMapper = new ObjectMapper();
@Override
public void onMessage(Message message, byte[] pattern){
try {
String msgBody = new String(message.getBody());
ChatMessage chatMessage = objectMapper.readValue(msgBody, ChatMessage.class);
if (chatMessage.getTo() != null && !chatMessage.getTo().isEmpty()) {
simpMessagingTemplate.convertAndSendToUser(chatMessage.getTo(),
"/queue/private", chatMessage);
} else {
simpMessagingTemplate.convertAndSend("/topic/room." +
chatMessage.getRoomId(), chatMessage);
}
} catch (Exception e) {
}
}
}
Java
복사
이 클래스는 Redis 채널에서 메시지를 수신하여 STOMP 클라이언트에게 다시 라우팅하는 역할을 한다.
•
@Service: 이 클래스가 Spring의 서비스 계층 컴포넌트임을 나타낸다. 비즈니스 로직을 포함하는 경우에 사용된다.
•
@RequiredArgsConstructor: Lombok이 final 필드들(simpMessagingTemplate)에 대한 생성자를 자동으로 만들어준다.
•
private final SimpMessagingTemplate simpMessagingTemplate;:
◦
이 객체가 RedisSubscriber의 가장 중요한 부분 중 하나다. SimpMessagingTemplate은 Spring의 STOMP 메시지 브로커로 메시지를 보내는 데 사용된다. 즉, Redis에서 받은 메시지를 STOMP 프로토콜에 맞게 변환하여 클라이언트에게 다시 전달하는 역할을 수행한다.
•
private ObjectMapper objectMapper = new ObjectMapper();: Redis로부터 받은 JSON 형태의 메시지를 Java 객체(ChatMessage)로 역직렬화하기 위해 Jackson ObjectMapper 인스턴스를 사용한다. ObjectMapper는 불변 객체가 아니므로, 이렇게 직접 인스턴스화하는 대신 Spring 빈으로 등록하여 주입받는 것이 더 일반적인 방식이다. (예: private final ObjectMapper objectMapper; 와 @Autowired 또는 생성자 주입)
•
@Override public void onMessage(Message message, byte[] pattern):
◦
이 메서드는 RedisConfig에서 RedisMessageListenerContainer에 등록되어 Redis 채널에서 메시지가 수신될 때마다 자동으로 호출된다.
◦
String msgBody = new String(message.getBody());: Redis 메시지의 본문(byte[] 형태)을 String으로 변환한다.
◦
ChatMessage chatMessage = objectMapper.readValue(msgBody, ChatMessage.class);: 변환된 JSON 문자열을 ChatMessage 객체로 역직렬화한다.
◦
메시지 라우팅 로직:
▪
if (chatMessage.getTo() != null && !chatMessage.getTo().isEmpty()): ChatMessage에 to 필드가 존재하고 비어있지 않다면, 이는 귓속말 또는 특정 사용자에게 보내는 메시지로 간주한다.
•
simpMessagingTemplate.convertAndSendToUser(chatMessage.getTo(), "/queue/private", chatMessage);: to 필드에 지정된 사용자에게 /queue/private 경로로 메시지를 보낸다. convertAndSendToUser는 "/user/{username}/queue/private"과 같은 형태로 메시지를 변환하여 특정 사용자만 받을 수 있도록 한다.
▪
else: to 필드가 없거나 비어있다면, 이는 일반 채팅방 메시지로 간주한다.
•
simpMessagingTemplate.convertAndSend("/topic/room." + chatMessage.getRoomId(), chatMessage);: /topic/room.{roomId} 경로로 메시지를 발행한다. 이 경로는 해당 채팅방을 구독하고 있는 모든 클라이언트에게 메시지를 전달한다.
◦
try-catch (Exception e):
▪
예외 처리를 포함했지만, 현재는 단순히 예외를 잡아서 아무것도 하지 않는 빈 블록이다. 실제 운영 환경에서는 예외 발생 시 적절한 로깅을 남기거나, 에러를 보고하는 등의 처리가 필수적이다. 그렇지 않으면 디버깅이 매우 어려워진다. 예를 들어 log.error("Failed to process Redis message", e); 와 같이 로그를 남겨야 한다.
GPTService
package org.example.backendproject.stompwebsocket.gpt;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.HashMap;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
@Slf4j
public class GPTService {
// json 문자열 <-> 자바객체, json객체
private final ObjectMapper mapper = new ObjectMapper();
@Value("${openai.api.key}")
private String apiKey;
public String gptMessage(String message) throws Exception {
try {
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("model", "gpt-4o");
requestBody.put("input", message);
// http 요청 작성
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.openai.com/v1/responses"))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(requestBody)))
.build();
//요청 전송 및 응답 수신
HttpClient client = HttpClient.newHttpClient();
HttpResponse<String> response = client.send(request,HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
log.error("GPT API 호출 실패: " + response.statusCode() + " - " + response.body());
return "[GPT 호출 실패] 응답 코드 : " + response.statusCode();
}
// 응답을 Json으로 파싱
JsonNode jsonNode = mapper.readTree(response.body());
System.out.println("gpt 응답 : " + jsonNode);
log.info("gpt 응답 성공!!");
//메세지 부분만 추출하여 반환
String gptMessageResponse = jsonNode.get("output").get(0).get("content").get(0).get("text").asText();
return gptMessageResponse;
} catch (IOException e) {
log.error("GPT 응답 JSON 파싱 실패: " + e.getMessage());
return "[GPT 오류] 응답을 처리할 수 없습니다.";
} catch (IllegalArgumentException e) {
log.error("응답 파싱 오류: " + e.getMessage());
return e.getMessage();
} catch (Exception e) {
log.error("예상치 못한 오류 발생: " + e.getMessage());
return "[GPT 오류] 예기치 못한 문제가 발생했습니다.";
}
}
}
Java
복사
•
@Service: 이 클래스가 Spring의 서비스 계층 컴포넌트임을 나타낸다. 비즈니스 로직을 수행하는 클래스에 주로 사용된다.
•
@Slf4j: Lombok 어노테이션으로, 클래스에 log 필드를 자동으로 생성하여 로깅을 쉽게 할 수 있도록 한다.
•
private final ObjectMapper mapper = new ObjectMapper();: JSON과 Java 객체 간의 직렬화/역직렬화를 담당하는 ObjectMapper 인스턴스다.
•
@Value("${openai.api.key}") private String apiKey;: application.properties나 application.yml과 같은 설정 파일에서 openai.api.key라는 이름의 속성 값을 주입받는다. API 키를 하드코딩하지 않고 외부 설정으로 관리하는 것은 보안상 매우 바람직하다.
gptMessage(String message) 메서드
이 메서드는 주어진 메시지를 OpenAI GPT 모델에 전송하고 응답을 받아오는 핵심 로직을 포함한다.
•
try-catch 블록: 전체 로직이 try-catch 블록으로 감싸져 있어, API 호출 또는 응답 처리 중 발생할 수 있는 다양한 예외를 처리한다. 이는 서비스의 안정성을 높이는 데 중요하다.
•
요청 본문(Request Body) 구성:
◦
Map<String, Object> requestBody = new HashMap<>();
◦
requestBody.put("model", "gpt-4o");
◦
requestBody.put("input", message);
◦
여기서 model은 gpt-4o로 잘 설정되어 있다. 하지만, OpenAI의 최신 Chat Completions API는 input 필드 대신 messages 필드를 사용하여 대화 기록을 전달한다. input 필드는 이전 버전의 Completions API나 특정 임베딩 API에서 사용될 수 있다.
•
OpenAI Chat Completions API의 일반적인 messages 형식:
{
"model": "gpt-4o",
"messages": [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "message"}
]
}
JSON
복사
따라서 현재 코드의 requestBody 구성은 OpenAI Chat Completions API의 예상 입력 형식과 일치하지 않을 수 있다. 이로 인해 API 호출이 실패하거나 예상치 못한 응답을 받을 가능성이 있다.
•
HTTP 요청 작성 (HttpRequest):
◦
HttpRequest.newBuilder()를 사용하여 요청을 빌드한다.
◦
uri(URI.create("https://api.openai.com/v1/responses")): OpenAI Chat Completions API의 엔드포인트 URL이 잘못되었다. 올바른 엔드포인트는 https://api.openai.com/v1/chat/completions이다. 현재 v1/responses라는 엔드포인트는 표준 OpenAI API에 존재하지 않는다.
◦
header("Authorization", "Bearer " + apiKey): API 키를 포함한 Authorization 헤더는 올바르게 설정되어 있다.
◦
header("Content-Type", "application/json"): Content-Type 헤더도 올바르다.
◦
POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(requestBody))): 구성된 requestBody를 JSON 문자열로 변환하여 POST 요청 본문으로 설정한다.
•
요청 전송 및 응답 수신:
◦
HttpClient client = HttpClient.newHttpClient();: Java 11부터 도입된 HttpClient를 사용하여 HTTP 요청을 보낸다.
◦
HttpResponse<String> response = client.send(request,HttpResponse.BodyHandlers.ofString());: 요청을 보내고 문자열 형태의 응답 본문을 받는다.
•
응답 처리:
◦
if (response.statusCode() != 200): 응답 상태 코드가 200이 아닌 경우, 오류로 간주하고 로그를 남긴 후 사용자에게 실패 메시지를 반환한다. 이는 매우 좋은 처리다.
◦
JsonNode jsonNode = mapper.readTree(response.body());: 받은 응답 본문을 JsonNode 객체로 파싱한다.
◦
String gptMessageResponse = jsonNode.get("output").get(0).get("content").get(0).get("text").asText();:
▪
이 부분은 GPT 응답에서 실제 메시지 내용을 추출하는 로직이다. 이 경로도 OpenAI Chat Completions API의 표준 응답 구조와 일치하지 않을 가능성이 매우 높다.
{
"id": "chatcmpl-...",
"object": "chat.completion",
"created": 1677652288,
"model": "gpt-4o",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "안녕하세요! 무엇을 도와드릴까요?"
},
"logprobs": null,
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 10,
"total_tokens": 20
}
}
JSON
복사
ChatController
package org.example.backendproject.stompwebsocket.controller;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.example.backendproject.stompwebsocket.dto.ChatMessage;
import org.example.backendproject.stompwebsocket.gpt.GPTService;
import org.example.backendproject.stompwebsocket.redis.RedisPublisher;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Controller;
@Controller
@RequiredArgsConstructor
public class ChatController {
@Value("${PROJECT_NAME:web Server}")
private String instanceName;
private final RedisPublisher redisPublisher;
private final ObjectMapper objectMapper = new ObjectMapper();
// 서버가 클라이언트에게 수동으로 메세지를 보낼 수 있도록 하는 클래스
private final SimpMessagingTemplate template;
private final GPTService gptService;
// 단일 브로드캐스트 (동적으로 방 생성이 안 됨)
@MessageMapping("/gpt")
public void sendMessageGPT(ChatMessage message) throws Exception {
template.convertAndSend("/topic/gpt", message);
// gpt 메세지 반환
String getResponse = gptService.gptMessage(message.getMessage());
ChatMessage chatMessage = new ChatMessage("난 GPT", getResponse);
template.convertAndSend("/topic/gpt", chatMessage);
}
// 동적으로 방 생성 가능
@MessageMapping("/chat.sendMessage")
public void sendMessage(ChatMessage message) throws JsonProcessingException {
message.setMessage(instanceName + " " + message.getMessage());
String channel = null;
String msg = null;
if (message.getTo() != null && !message.getTo().isEmpty()) {
// 귓속말
// 내 아이디로 귓속말 경로를 활성화 함
channel = "private." + message.getRoomId();
msg = objectMapper.writeValueAsString(message);
} else {
// 일반 메시지
// message에서 roomId를 추출해서 해당 roomId를 구독하고 있는 클라이언트에게 메세지를 전달
channel = "room." + message.getRoomId();
msg = objectMapper.writeValueAsString(message);
}
redisPublisher.publish(channel, msg);
}
}
Java
복사
•
@Controller: 이 클래스가 Spring MVC의 컨트롤러 역할을 함을 나타낸다. STOMP 메시징에서는 @Controller와 @MessageMapping을 함께 사용하여 STOMP 메시지를 처리한다.
•
@RequiredArgsConstructor: Lombok이 final 필드에 대한 생성자를 자동으로 생성하여 의존성 주입을 처리해준다.
•
@Value("${PROJECT_NAME:web Server}") private String instanceName;:
◦
애플리케이션의 instanceName을 설정 파일에서 주입받는다. 만약 PROJECT_NAME 속성이 없으면 기본값으로 "web Server"를 사용한다.
◦
이 값은 메시지에 서버 인스턴스 이름을 포함시켜, 여러 서버가 동작하는 분산 환경에서 어떤 서버가 메시지를 처리했는지 식별하는 데 사용될 수 있다. 디버깅이나 운영 모니터링에 유용하다.
•
private final RedisPublisher redisPublisher;: Redis로 메시지를 발행하는 데 사용되는 RedisPublisher 빈을 주입받는다.
•
private final ObjectMapper objectMapper = new ObjectMapper();:
◦
JSON 직렬화/역직렬화를 위한 ObjectMapper 인스턴스다. 앞선 리뷰에서 언급했듯이, 이렇게 직접 생성하기보다는 Spring 빈으로 등록하여 주입받는 것이 더 좋은 방법이다.
•
private final SimpMessagingTemplate template;:
◦
Spring STOMP 메시지 브로커로 메시지를 보내는 핵심 컴포넌트인 SimpMessagingTemplate을 주입받는다. 이를 통해 서버는 클라이언트에게 특정 목적지(destination)로 메시지를 보낼 수 있다.
•
private final GPTService gptService;: GPT API 호출을 담당하는 GPTService 빈을 주입받는다.
sendMessageGPT(ChatMessage message) 메서드
이 메서드는 클라이언트가 /app/gpt 경로로 메시지를 보낼 때 처리되는 AI 챗봇 메시지 핸들러이다.
•
@MessageMapping("/gpt"): 클라이언트가 /app/gpt 목적지로 STOMP SEND 메시지를 보내면 이 메서드가 호출된다. (앞서 WebSocketConfig에서 setApplicationDestinationPrefixes("/app")로 설정했기 때문)
•
public void sendMessageGPT(ChatMessage message) throws Exception: ChatMessage 객체를 인자로 받는다.
•
template.convertAndSend("/topic/gpt", message);:
◦
클라이언트가 보낸 원본 메시지를 즉시 /topic/gpt로 브로드캐스트한다. 이는 사용자가 보낸 메시지가 챗봇 응답 전에 다른 클라이언트에게도 표시되도록 할 때 유용하다.
•
String getResponse = gptService.gptMessage(message.getMessage());:
◦
GPTService를 호출하여 GPT 모델로부터 응답을 받아온다.
◦
주의: 이전 GPTService 리뷰에서 언급했듯이, gptService.gptMessage() 메서드 내부의 OpenAI API 호출 엔드포인트와 요청/응답 형식이 올바르지 않으면 이 부분에서 예상치 못한 동작이나 오류가 발생할 것이다. 이 문제는 이 컨트롤러의 범위를 넘어선 GPTService의 문제이므로, GPTService를 먼저 수정해야 한다.
•
ChatMessage chatMessage = new ChatMessage("난 GPT", getResponse);: GPT 응답으로 새로운 ChatMessage 객체를 생성한다. 여기서 from 필드를 "난 GPT"로 설정하고, 응답 메시지를 getResponse로 설정한다. ChatMessage DTO의 생성자가 (String from, String message) 형태인지 확인해야 한다. 현재 ChatMessage DTO는 @Getter만 있고 생성자가 명시적으로 정의되어 있지 않으므로, Lombok의 @AllArgsConstructor 또는 @NoArgsConstructor와 @RequiredArgsConstructor 조합 등을 통해 적절한 생성자가 제공되어야 이 코드가 컴파일된다.
•
template.convertAndSend("/topic/gpt", chatMessage);: GPT로부터 받은 응답 메시지를 다시 /topic/gpt로 브로드캐스트한다. 이로써 모든 /topic/gpt 구독자들은 사용자의 메시지와 GPT의 응답을 모두 받게 된다.
•
@SendTo 대신 template.convertAndSend 사용: 일반적으로 @MessageMapping과 함께 @SendTo 어노테이션을 사용하여 반환 값을 특정 목적지로 보낼 수 있지만, 여기서는 void 메서드로 하고 SimpMessagingTemplate을 직접 사용하여 여러 번 메시지를 보내고, 비동기 작업(GPT 호출) 후에도 메시지를 보낼 수 있도록 유연하게 구현했다.
sendMessage(ChatMessage message) 메서드
이 메서드는 일반 채팅 메시지(공개 채팅방 및 귓속말)를 처리한다.
•
@MessageMapping("/chat.sendMessage"): 클라이언트가 /app/chat.sendMessage 목적지로 STOMP SEND 메시지를 보내면 이 메서드가 호출된다.
•
public void sendMessage(ChatMessage message) throws JsonProcessingException: ChatMessage 객체를 인자로 받는다.
•
message.setMessage(instanceName + " " + message.getMessage());:
◦
받은 메시지에 현재 서버 인스턴스 이름을 접두어로 붙인다. 이는 분산 환경에서 특정 메시지가 어떤 서버를 거쳐왔는지 추적하는 데 유용하다.
•
메시지 라우팅 로직 (귓속말 vs. 일반 메시지):
◦
if (message.getTo() != null && !message.getTo().isEmpty()): ChatMessage의 to 필드가 비어있지 않으면 귓속말로 간주한다.
▪
channel = "private." + message.getRoomId();: Redis 채널을 private.{roomId} 형태로 설정한다. 이 roomId는 수신자의 principal.name (즉, 사용자 ID)이 되어야 할 것이다. message.getRoomId()가 아닌 message.getTo()를 사용하는 것이 더 정확하다. 귓속말은 특정 사용자(TO)에게 보내는 것이지, 특정 방(roomId)에 보내는 것이 아니기 때문이다. RedisSubscriber에서도 chatMessage.getTo()를 사용하고 있으니, 일관성을 위해 여기서 channel = "private." + message.getTo();로 수정해야 한다.
▪
msg = objectMapper.writeValueAsString(message);: ChatMessage 객체를 JSON 문자열로 직렬화한다.
◦
else: to 필드가 비어있으면 일반 채팅 메시지로 간주한다.
▪
channel = "room." + message.getRoomId();: Redis 채널을 room.{roomId} 형태로 설정한다.
▪
msg = objectMapper.writeValueAsString(message);: ChatMessage 객체를 JSON 문자열로 직렬화한다.
•
redisPublisher.publish(channel, msg);:
◦
최종적으로 결정된 Redis channel과 직렬화된 msg를 RedisPublisher를 통해 Redis에 발행한다.
◦
이렇게 발행된 메시지는 Redis Pub/Sub을 통해 다른 모든 서버 인스턴스의 RedisSubscriber로 전달되고, 각 RedisSubscriber는 이를 받아 다시 해당 STOMP 클라이언트에게 메시지를 전달한다. 이는 분산 환경에서 메시지가 모든 관련 클라이언트에게 도달하도록 보장한다.
느낀 점
•
WebSocket에 대한 개념을 어느 정도 잡을 수 있었다.
•
실제 프로젝트를 진행하면서 적용할 때 훨씬 더 잘 이해할 수 있지 않을까 싶다.
•
챗봇과 STOMP 프로토콜을 활용한 WebSocket을 활용하여 어떤 기능을 구현해야 할지에 대해 고민이다.