inflearn logo
강의

강의

N
챌린지

챌린지

멘토링

멘토링

N
클립

클립

로드맵

로드맵

지식공유

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

해결된 질문

287

유요한

작성한 질문수 49

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

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

스프링부트 리액트 알림 sse

답변 1

0

인프런 AI 인턴

안녕하세요, 인프런 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 설정 출처

해당 강의를 듣고 나면

0

397

1

렌더링 차이

0

479

1

이클립스 사용 http://localhost:8080/hello 작동 안되는 문제

0

3843

2

assertThat 오류 질문

0

352

1

질문에 대한 답을 아직 안알려주셔서 다시 질문 올립니다 !!

0

208

1

JpaRepository를 이용할시 에러가 발생합니다.

0

4548

4

테스트코드에 피라미터를 넣게되면 오류가 발생하는 이유를 알 수 있을까요??

0

703

2

Validate예외처리 페이지 만들기

1

202

1

insert문에 null이 아닌 default값이 들어옵니다.

0

373

1

테이블 컬럼 추가에 대해서 질문사항이 있습니다.

0

628

1

counter 변수도 초기값이 없으면 널이 되더라구요.

1

427

1

한글이 깨지는것 같아요

1

5342

6

버튼을 누를 때마다 매번 Form() 을 호출하는 걸까요?

2

353

2

스프링 안드로이드 연동 실습을 해보고 싶은데 질문사항이 있습니다 !!

0

943

1

부트 로그인 화면

0

346

0

쌤 리액트로 앱 만들수 있는건가요?

0

325

0

const title =<h1>제목</h1>; 에는 괄호가 없어도 되네요?

3

307

2

@Transactional 관련 커넥션풀 반환 원리 질문드립니다.

0

2717

1

리액트 관련 서버 구축 중 server파일에서 발생한 오류

0

279

0

강의 7:41 삼항연산자 왜쓰는건가요?

0

674

1

img태그의 src=로컬호스트/이미지 이 부분 질문있습니다!

1

1532

1

state에서 count값 올릴때 +와 ++차이점이 궁금합니다

0

724

1

Create React App에서 node_modules파일 지워졌을 경우

0

1943

1

커리큘럼 질문드립니다

0

304

1