Inflearn brand logo image

인프런 커뮤니티 질문&답변

유요한님의 프로필 이미지
유요한

작성한 질문수

리액트 & 스프링 부트 SSE 알람 기능 구현 CORS에러

해결된 질문

작성

·

223

0

헌재 상황은 NGINX로 HTTPS로 백엔드를 배포하고 있고 리액트는 로컬에서 작업 중인데 알람 기능을 구현할 때 OPTIONS가 CORS에러를 일으킵니다.
```

@Service
@RequiredArgsConstructor
@Log4j2
@Transactional
public class NotificationService {
    // SSE 이벤트 타임아웃 시간
    private static final Long DEFAULT_TIMEOUT = 24L * 60 * 60 * 1000;   // SSE 연결 타임아웃 (1일)
    private final CustomNotificationRepository customNotificationRepository;
    private final MemberRepository memberRepository;
    private final CommunityRepository communityRepository;
    private final NotificationRepository notificationRepository;

    // 메시지 알림
    public SseEmitter subscribe(String memberEmail, String lastEventId) throws Exception {
        // 회원 조회
        MemberEntity findMember = memberRepository.findByMemberEmail(memberEmail);

        // 매 연결마다 고유 이벤트 ID 부여
        String eventId = makeTimeIncludeId(findMember);
        log.info("eventId {} ", eventId);

        // SseEmitter 생성후 Map에 저장
        SseEmitter sseEmitter = customNotificationRepository.save(eventId, new SseEmitter(DEFAULT_TIMEOUT));


        // 사용자에게 모든 데이터가 전송되었다면 emitter 삭제
        sseEmitter.onCompletion(() -> {
            log.info("onCompletion callback");
            customNotificationRepository.deleteById(eventId);
        });

        // Emitter의 유효 시간이 만료되면 emitter 삭제
        // 유효 시간이 만료되었다는 것은 클라이언트와 서버가 연결된 시간동안 아무런 이벤트가 발생하지 않은 것을 의미한다.
        sseEmitter.onTimeout(() -> {
            log.info("onTimeout callback");
            customNotificationRepository.deleteById(eventId);
        });


        // 503 에러를 방지하기 위한 더미 이벤트 전송
        sendToClient(eventId, sseEmitter, "알림 서버 연결 성공 [memberId = " + findMember.getMemberId() + "]");

        // 클라이언트가 미수신한 Event 목록이 존재할 경우 전송하여 Event 유실을 예방
        if (hasLostData(lastEventId)) {
            sendLostData(lastEventId, findMember.getMemberId(), sseEmitter);
        }

        return  sseEmitter;
    }

    private static @NotNull String makeTimeIncludeId(MemberEntity findMember) {
        String eventId = findMember.getMemberId() + "_" + System.currentTimeMillis();
        return eventId;
    }

    private void sendToClient(String eventId, SseEmitter sseEmitter, Object data) {
        try {
            sseEmitter.send(SseEmitter.event()
                    .name("connect")
                    .id(eventId)
                    .data(data));
        } catch (IOException e) {
            customNotificationRepository.deleteById(eventId);
            throw new RuntimeException("알림 서버 연결 오류");
        }
    }

    private boolean hasLostData(String lastEventId) {
        return !lastEventId.isEmpty();
    }

    private void sendLostData(String lastEventId,
                              Long memberId,
                              SseEmitter sseEmitter) {
        Map<String, Object> eventCaches = customNotificationRepository.findAllEventCacheStartWithByMemberId(memberId);
        eventCaches.entrySet().stream()
                .filter(entry -> lastEventId.compareTo(entry.getKey()) < 0)
                .forEach(entry -> sendToClient(entry.getKey(), sseEmitter, entry.getValue()));
    }
...

이렇게 서비스를 구현하고

@RestController
@RequestMapping("/api/v1/notify")
@RequiredArgsConstructor
@Log4j2
public class NotificationController implements NotificationControllerDocs {
    private final NotificationService notificationService;

    // 메시지 알림
    // SSE 통신을 위해서는 produces로 반환할 데이터 타입을 "text/event-stream"으로 해주어야 함
    @GetMapping(value = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    @PreAuthorize("hasRole('ROLE_USER') or hasRole('ROLE_ADMIN')")
    public SseEmitter subscribe(@AuthenticationPrincipal UserDetails userDetails,
                                @RequestHeader(value = "last-event-id", required = false, defaultValue = "") final String lastEventId,
                                HttpServletResponse response) {
        try {

            if (userDetails == null) {
                throw new IllegalArgumentException("인증 정보가 필요합니다.");
            }

            response.setHeader("Connection", "keep-alive");
            response.setHeader("Cache-Control", "no-cache");
            response.setHeader("X-Accel-Buffering", "no");

            String email = userDetails.getUsername();
            SseEmitter responseEmitter = notificationService.subscribe(email, lastEventId);

            log.info("Subscribed to email: " + email);
            log.info("response: " + responseEmitter);
            return responseEmitter;
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
...

NGINX 설정

server {
        listen 443 ssl;
        server_name meettify.store;

        ssl_certificate /etc/letsencrypt/live/meettify.store/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/meettify.store/privkey.pem;
        include /etc/letsencrypt/options-ssl-nginx.conf;
        ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

            # SSE용 헤더 설정
        location /api/v1/notify/subscribe {

                    # OPTIONS preflight 요청을 처리하도록 설정
                if ($request_method = 'OPTIONS') {
                        add_header 'Access-Control-Allow-Origin' 'http://localhost:5173' always;
                        add_header 'Access-Control-Allow-Credentials' 'true' always;
                        add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
                        add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type' always;
                        add_header 'Access-Control-Max-Age' 3600 always;
                        return 204;
                }

                proxy_pass https://meettify.store;  # Spring Boot 서버로 요청 전달
                proxy_http_version 1.1;
                proxy_set_header Connection '';  # SSE 연결 유지
                proxy_set_header Host $host;
                proxy_set_header Upgrade $http_upgrade;
                proxy_set_header Connection '';
                proxy_buffering off;
                proxy_cache off;
                chunked_transfer_encoding off;


                # SSE 연결 타임아웃 방지
                proxy_set_header Cache-Control no-cache;
                proxy_read_timeout 86400s;
                keepalive_timeout 86400s; # 필요에 따라 시간 조정
                add_header Access-Control-Allow-Origin "http://localhost:5173";
                add_header Access-Control-Allow-Credentials "true";
        }

  # 기본 프록시 설정 (백엔드 애플리케이션)
        location / {
                proxy_pass  http://ubuntu-api-1:8080;
                proxy_set_header    Host                $http_host;
                proxy_set_header    X-Real-IP           $remote_addr;
                proxy_set_header    X-Forwarded-For     $proxy_add_x_forwarded_for;
                proxy_set_header Connection '';
                proxy_http_version 1.1;
                proxy_set_header Upgrade $http_upgrade;
                proxy_set_header Connection '';
                proxy_buffering off;
                proxy_cache off;
                chunked_transfer_encoding off;
        }

이렇게 엔진엑스 설정까지 한다음

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("http://localhost:5173")
                .allowedHeaders("Authorization", "Content-Type")
                .allowCredentials(true)
                .exposedHeaders("Cache-Control", "Content-Type", "X-Accel-Buffering")
                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS");
    }
}

이렇게 설정했는데 계속 CORS에러가 발생합니다.
image.pngimage.png

해당 문제가 지속적으로 반복중입니다. ㅠㅠ

답변 1

0

안녕하세요, 인프런 AI 인턴입니다.
유요한님께서 겪고 계신 CORS 문제는 보통 서버와 클라이언트 간의 도메인이 다를 때 발생합니다. 현 상황에서는 NGINX 설정과 Spring Boot의 CORS 설정에서 문제가 발생할 수 있습니다. 아래 몇 가지 확인 사항과 해결 방법을 제안드립니다.

  1. NGINX 설정 확인:

    • NGINX에서 location 블록 내의 if ($request_method = 'OPTIONS') { ... }가 제대로 작동하고 있는지 확인하세요. 예를 들어, add_header 명령어가 제대로 동작하는지, return 등 다른 설정이 선행되지는 않는지 확인해 보세요.
    • proxy_pass https://meettify.store;에서 프로토콜이 http://인지 https://인지 재확인하시고 맞게 설정되어 있는지 확인하세요.
  2. Spring Boot CORS 설정:

    • WebMvcConfigurer에서 설정한 allowedOriginshttp://localhost:5173인데, NGINX에서 allow-origin 헤더도 http://localhost:5173로 설정되어 있는지 확인해보세요.
    • 만약 계속해서 문제가 발생한다면, allowedOrigins를 모두 허용하는 "*"로 설정해 일시적으로 문제가 해결되는지 확인해 보세요.
  3. Spring Boot 로그 확인:

    • Spring Boot에서 SSE 연결 시 발생하는 예외 메시지를 확인하여, 자세한 에러 원인을 찾는 것이 좋습니다. 서버 쪽 로그에서 특정 오류 내용이 없는지 확인하세요.
  4. 확장 옵션:

    • 만약 위의 방법으로도 해결되지 않는다면, 다양한 브라우저 개발도구의 Network 탭에서 OPTIONS 요청과 응답의 헤더 정보를 자세히 확인하여 문제를 파악할 수 있습니다.

추가로, 외부 URL로 요청을 보내지 않는 일부 경우에는 CORS 설정을 생략할 수도 있음을 유념하세요. 하지만 개발 환경에서는 항상 보안 설정을 적절히 유지하는 것이 중요합니다.

이와 관련된 다른 자료들을 참조하고 문제가 해결되는지 확인해주세요. 더 궁금한 점이 있으면 언제든지 문의하세요.

Spring Boot CORS 설정 출처

유요한님의 프로필 이미지
유요한

작성한 질문수

질문하기