2025. 5. 8. 14:28ㆍJAVA/Spring Boot
기본 준비가 돼었으니 이제는 채팅방 생성을 시작한다.
그러기 위해 먼저 DB Table을 생성한다.
CREATE TABLE chat_rooms (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL, -- 채팅방명
creator_id BIGINT NOT NULL, -- 채팅방 만든 사람 ID
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
백엔드소스
ChatRoom.java
package com.company.common.database.entity;
import java.time.LocalDateTime;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 채팅방 엔티티 (PostgreSQL)
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table("chat_rooms")
public class ChatRoom {
@Id
private Long id; // 채팅방 ID (자동 증가)
private String name; // 채팅방 이름
private Long creatorId; // 채팅방 만든 사람(방장)
private LocalDateTime createdAt; // 생성일시
}
ChatRoomRepository.java
package com.company.chat.repository;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import com.company.common.database.entity.ChatRoom;
import reactor.core.publisher.Flux;
public interface ChatRoomRepository extends ReactiveCrudRepository<ChatRoom, Long> {
// 모든 채팅방 목록 조회
Flux<ChatRoom> findAll();
}
ChatRoomService.java
package com.company.chat.service;
import java.time.LocalDateTime;
import org.springframework.stereotype.Service;
import com.company.chat.repository.ChatRoomRepository;
import com.company.common.database.entity.ChatRoom;
import lombok.RequiredArgsConstructor;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
/**
* 채팅방 생성 및 조회 서비스
*/
@Service
@RequiredArgsConstructor
public class ChatRoomService {
private final ChatRoomRepository chatRoomRepository;
// 채팅방 생성
public Mono<ChatRoom> createRoom(String name, Long userId) {
ChatRoom room = ChatRoom.builder()
.name(name)
.creatorId(userId)
.createdAt(LocalDateTime.now())
.build();
return chatRoomRepository.save(room);
}
// 모든 채팅방 목록 조회
public Flux<ChatRoom> getAllRooms() {
return chatRoomRepository.findAll();
}
}
ChatRoomController.java
package com.company.chat.controller;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.company.chat.service.ChatRoomParticipantService;
import com.company.chat.service.ChatRoomService;
import com.company.chat.service.UserService;
import com.company.common.database.entity.ChatRoom;
import com.company.common.database.entity.User;
import com.company.common.dto.ApiResponse;
import com.company.common.utils.JwtUtil;
import lombok.RequiredArgsConstructor;
import reactor.core.publisher.Mono;
/**
* 채팅방 생성/조회 API
*/
@RestController
@RequestMapping("/api/v1/chat/chatroom")
@RequiredArgsConstructor
public class ChatRoomController {
private final ChatRoomService chatRoomService;
private final UserService userService;
private final JwtUtil jwtUtil;
/**
* 채팅방을 생성한다.
* @param name
* @return
*/
@PostMapping
public Mono<ResponseEntity<ApiResponse<ChatRoom>>> createRoom(@RequestParam String name, ServerHttpRequest request) {
String token = request.getHeaders().getFirst("Authroization");
if( token == null || !token.startsWith("Bearer ")) {
return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(new ApiResponse<>(false, "채팅방 목록 조회 오류(미로그인)", null)));
}
String username = jwtUtil.extractUsername(token.replace("Bearer ", ""));
return userService.findByUsername(username)
.flatMap( user ->
chatRoomService.createRoom(name, user.getId())
.map(room -> ResponseEntity.ok(new ApiResponse<>(true, "채팅방 생성 성공", room)))
);
}
/**
* 채팅방 목록을 조회 한다.
* - 채팅방 목록은 로그인한 사용자만 볼수 있도록 한다.
* @return
*/
@GetMapping
public Mono<ResponseEntity<ApiResponse<List<ChatRoom>>>> getAllRooms(ServerHttpRequest request) {
String token = request.getHeaders().getFirst("Authroization");
if( token == null || !token.startsWith("Bearer ")) {
return Mono.just(
ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(new ApiResponse<>(false, "채팅방 목록 조회 오류(미로그인)", null))
);
}
return chatRoomService.getAllRooms()
.collectList()
.map(rooms -> ResponseEntity.ok(new ApiResponse<>(true, "채팅방 목록", rooms)) );
}
}
우선 로그인 사용자만 채팅방을 생성할 수 있도록 처리 했다.
저기서 보면 로그인이 되어 있는지 모든 method에서 체크를 하고 있다. 이건 추후에 바꿔야 겠다.
아니다 지금 바꾼다.
우선 WebFlux에서는 어노테이션 + AOP는 권장하지 않는다고 한다.
⚠️ WebFlux에서 AOP 방식이 비추천되는 이유
1. 🔄 WebFlux는 비동기(논블로킹) 기반 → 프록시 체인에 한계 있음
- Spring AOP는 **동기 메서드 호출(프록시 기반)**을 전제로 동작합니다.
- WebFlux는 Mono<>, Flux<> 기반의 비동기 스트림 체인이라
AOP의 진입/반환 시점이 예상과 다르게 작동하거나 적용이 누락될 수 있습니다.
예: @Before에서 Mono.defer() 내부 코드가 아직 실행되지 않아서 인증 대상이 되지 않음
2. ⚠️ @ArgumentResolver가 WebFlux에는 없음
- Spring MVC에서는 @CurrentUser 같은 어노테이션을 만들고,
HandlerMethodArgumentResolver로 컨트롤러에 자동으로 주입 가능 - 그러나 WebFlux에는 HandlerMethodArgumentResolver가 아예 존재하지 않음
→ 사용자 객체 자동 바인딩 기능 없음
이게 ChatGPT의 답변이다.
WebFlux에서 가장 안정적인 방법은 WebFilter을 사용하는거라고 한다.
JwtAuthFilter.java
package com.company.config.filter;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import com.company.chat.repository.UserRepository;
import com.company.common.utils.JwtUtil;
import lombok.RequiredArgsConstructor;
import reactor.core.publisher.Mono;
/**
* JWT 인증 필터 (WebFlux용)
* - 모든 요청의 Authorization 헤더에서 JWT를 추출하고 검증
* - 유효한 경우 User 객체를 request attribute로 저장
*/
@Component
@RequiredArgsConstructor
public class JwtAuthFilter implements WebFilter {
private final JwtUtil jwtUtil;
private final UserRepository userRepository;
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
String path = exchange.getRequest().getPath().toString();
//로그인 회원가입은 필터에서 제외처리
if( path.startsWith("/api/v1/auth")) {
return chain.filter(exchange);
}
String token = exchange.getRequest().getHeaders().getFirst("Authorization");
if( token == null || !token.startsWith("Bearer ")) {
return unauthorized(exchange, "Authorization 헤더가 없습니다.");
}
String username;
try {
username = jwtUtil.extractUsername(token.replace("Bearer ", ""));
} catch (Exception e) {
return unauthorized(exchange, "JWT 파싱 실패");
}
return userRepository.findByUsername(username)
.switchIfEmpty(Mono.error(new RuntimeException("사용자 없음")))
.flatMap(user -> {
// 인증된 사용자 정보를 attribute에 저장
exchange.getAttributes().put("user", user);
return chain.filter(exchange); // 다음 필터 or Controller로 진행
})
.onErrorResume(e -> unauthorized(exchange, e.getMessage()));
}
private Mono<Void> unauthorized(ServerWebExchange exchange, String message) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
}
가장 주요한 기능은 로그인이 필요하지 않은 경로를 제외하고 로그인 체크를 하며
로그인이 되어 있는 경우에는 attributes에 사용자를 등록하여 Controller에서 꺼내어 사용하는 방법이다.
그리고 변환된 Controller 이다
ChatRoomController.java
package com.company.chat.controller;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.company.chat.service.ChatRoomParticipantService;
import com.company.chat.service.ChatRoomService;
import com.company.common.database.entity.ChatRoom;
import com.company.common.database.entity.User;
import com.company.common.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import reactor.core.publisher.Mono;
/**
* 채팅방 생성/조회 API
*/
@RestController
@RequestMapping("/api/v1/chat/chatroom")
@RequiredArgsConstructor
public class ChatRoomController {
private final ChatRoomService chatRoomService;
/**
* 채팅방을 생성한다.
* @param name
* @return
*/
@PostMapping
public Mono<ResponseEntity<ApiResponse<ChatRoom>>> createRoom(@RequestParam String name, ServerHttpRequest request) {
User user = (User) request.getAttributes().get("user"); // 필터에서 저장한 사용자 정보
return chatRoomService.createRoom(name, user.getId())
.map(room -> ResponseEntity.ok(new ApiResponse<>(true, "채팅방 생성 성공", room)));
}
/**
* 채팅방 목록을 조회 한다.
* - 채팅방 목록은 로그인한 사용자만 볼수 있도록 한다.
* @return
*/
@GetMapping
public Mono<ResponseEntity<ApiResponse<List<ChatRoom>>>> getAllRooms(ServerHttpRequest request) {
return chatRoomService.getAllRooms()
.collectList()
.map(rooms -> ResponseEntity.ok(new ApiResponse<>(true, "채팅방 목록", rooms)) );
}
}
이렇게 변경이 되었다.
User user = (User) request.getAttributes().get("user"); // 필터에서 저장한 사용자 정보
이 부분이 들어가고 소스는 심플해졌다.
이렇게 처리하고 실행을 했는데 갑자기 CORS 오류가 또 발생했다.
분명히 CORS Config를 만들어서 설정을 했는데도 말이다.
결과 적으로는
CorsWebFilter가 실행되기 전에 WebFilter가 종료되면, 브라우저가 Access-Control-Allow-Origin 헤더를 못 받는다고 한다.
그래서 다시 수정을 하면
JwtAuthFilter.java 부분수정
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
String path = exchange.getRequest().getPath().toString();
String method = exchange.getRequest().getMethod().name();
if ("OPTIONS".equalsIgnoreCase(method)) {
return chain.filter(exchange);
}
//로그인 회원가입은 필터에서 제외처리
if( path.startsWith("/api/v1/auth")) {
return chain.filter(exchange);
}
...
}
JWT 필터에서 OPTIONS는 무조건 통과시켜야 하고
CorsGlobalConfig.java 부분 수정
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE) // ✅ 가장 먼저 실행되도록 설정
CorsWebFilter corsWebFilter() {
CorsConfiguration corsConfig = new CorsConfiguration();
corsConfig.addAllowedOrigin("http://localhost:5173"); // React 개발서버 Origin 허용
corsConfig.addAllowedMethod("*"); // 모든 HTTP 메소드 허용 (GET, POST, PUT, DELETE 등)
corsConfig.addAllowedHeader("*"); // 모든 헤더 허용
corsConfig.setAllowCredentials(true); // 인증정보 포함 허용 (ex: 쿠키)
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", corsConfig); // 모든 경로에 대해 CORS 설정 적용
return new CorsWebFilter(source);
}
명시작으로 Order을 넣어서 무조건 가장먼저 실행되도록 설정을 해야 한다.
이게 Spring MVC만 사용하다가 WebFlux를 사용하려니 여간 힘든게 아니다...ㅜㅜ
여기까지 했는데 역시나.. route 정보를 가져오는 곳을 필터 제외하지 않아 계속 오류가 생겨 빼줘야 했다.
모두 처리한 최종 WebFilter 이다.
JwtAuthFilter.java
package com.company.config.filter;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import com.company.chat.repository.UserRepository;
import com.company.common.dto.ApiResponse;
import com.company.common.utils.JwtUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
/**
* JWT 인증 필터 (WebFlux용)
* - 모든 요청의 Authorization 헤더에서 JWT를 추출하고 검증
* - 유효한 경우 User 객체를 request attribute로 저장
*/
@Component
@RequiredArgsConstructor
public class JwtAuthFilter implements WebFilter {
private final JwtUtil jwtUtil;
private final UserRepository userRepository;
private final ObjectMapper objectMapper = new ObjectMapper(); // JSON 변환 도구
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
String path = exchange.getRequest().getPath().toString();
String method = exchange.getRequest().getMethod().name();
if ("OPTIONS".equalsIgnoreCase(method)) {
return chain.filter(exchange);
}
//로그인 회원가입과 라우터 정보를 가져오는 경로는 필터에서 제외처리
if( path.startsWith("/api/v1/auth") || path.startsWith("/api/v1/routes")) {
return chain.filter(exchange);
}
String token = exchange.getRequest().getHeaders().getFirst("Authorization");
if( token == null || !token.startsWith("Bearer ")) {
return writeUnauthorizedResponse(exchange, "Authorization 헤더가 없습니다.");
}
String username;
try {
username = jwtUtil.extractUsername(token.replace("Bearer ", ""));
} catch (Exception e) {
return writeUnauthorizedResponse(exchange, "JWT 파싱 실패");
}
return userRepository.findByUsername(username)
.switchIfEmpty(Mono.error(new RuntimeException("사용자 없음")))
.flatMap(user -> {
// 인증된 사용자 정보를 attribute에 저장
exchange.getAttributes().put("user", user);
return chain.filter(exchange); // 다음 필터 or Controller로 진행
})
.onErrorResume(e -> writeUnauthorizedResponse(exchange, e.getMessage()));
}
private Mono<Void> writeUnauthorizedResponse(ServerWebExchange exchange, String message) {
// 응답 객체 구성
ApiResponse<Object> response = new ApiResponse<>(false, message, null);
try {
// JSON 변환
byte[] json = objectMapper.writeValueAsBytes(response);
//응답 설정
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
// JSON을 response body에 쓰기
DataBufferFactory bufferFactory = exchange.getResponse().bufferFactory();
DataBuffer buffer = bufferFactory.wrap(json);
return exchange.getResponse().writeWith(Flux.just(buffer));
} catch (Exception e) {
return exchange.getResponse().setComplete();
}
}
}
흠... 여기까지... 힘들게 왔다.
프론트엔드는 다음에 작성할란다. 힘들다...
'JAVA > Spring Boot' 카테고리의 다른 글
Spring WebFlux를 이용한 chat 프로그램 - WebSocket 설정 (0) | 2025.05.08 |
---|---|
Spring WebFlux를 이용한 chat 프로그램 - Dynamic Route 설정 (1) | 2025.05.03 |
Spring WebFlux를 이용한 chat 프로그램 - 회원가입 및 로그인 (0) | 2025.05.02 |
Spring WebFlux를 이용한 chat 프로그램 - 백엔드 기본 구성 (0) | 2025.05.01 |
Spring WebFlux를 이용한 chat 프로그램 - 프론트엔드 환경 구성 (0) | 2025.05.01 |