Chef.Yeon
Code Cook
Chef.Yeon
전체 방문자
오늘
어제
  • 분류 전체보기 (230)
    • 게임 개발 (1)
      • Unity (1)
    • Android (27)
      • Kotlin (19)
      • 우아한테크코스 5기 (4)
    • Language (11)
      • 파이썬 (3)
      • Java (7)
    • DB (2)
      • SQL (16)
    • Spring (25)
    • 코딩테스트 (56)
    • Git (1)
    • TIL (85)
    • DevOps (6)

블로그 메뉴

  • 홈
  • 태그
  • 방명록

공지사항

인기 글

태그

  • 백준
  • til
  • 코딩테스트
  • Wil
  • grafana
  • 파이썬
  • kotlin
  • 프로그래머스
  • 우아한테크코스
  • ec2
  • webflux
  • spring
  • SQL
  • 문자열
  • elasticsearch
  • 에라토스테네스의 체
  • Docker
  • enum
  • MariaDB
  • 프리코스
  • 코틀린
  • 레포지토리
  • 코틀린 인 액션
  • rsocket
  • 내림차순
  • 다이나믹 프로그래밍
  • kibana
  • java
  • Android
  • 안드로이드

최근 댓글

최근 글

티스토리

hELLO · Designed By 정상우.
Chef.Yeon

Code Cook

[TIL - 20230620] Webflux CORS
TIL

[TIL - 20230620] Webflux CORS

2023. 6. 21. 02:02

 

💻문제점1

클라이언트에서 다음과 같이 결제가 성공하면 POST 요청을 보내도록 했다.

if (rsp.paid_amount === data.response.amount) {
          alert('결제가 완료되었습니다.');
          const requestBody = {
            points: parseInt(churOption.substring(0, churOption.length - 2)),
          };
          try {
            console.log("충천 포인트: ", churOption.substring(0, churOption.length - 2));
            const { data } = await axios.post('http://localhost:8080/points', requestBody, { headers });
          } catch(error) {
            console.error('Error while points request:', error);
          }
        } else {
          alert('결제가 실패했습니다.');
        }

 

그런데 CORS 오류가 떴다...? 

 

기존에 CORS를 허용해주는 코드는 다음과 같다.

 

CORSFilter

@Configuration
@EnableWebFlux
public class CORSFilter implements WebFluxConfigurer {
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("*")
                .allowedMethods("*")
                .exposedHeaders("Access_Token", "Refresh_Token");
    }
}

 

WebfluxSecurityConfiguration

@Configuration
@EnableWebFluxSecurity
@RequiredArgsConstructor
public class WebfluxSecurityConfiguration {

    private final AuthenticationManager authenticationManager;
    private final SecurityContextRepository securityContextRepository;
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity serverHttpSecurity) {
        serverHttpSecurity
                .csrf(ServerHttpSecurity.CsrfSpec::disable)
                .formLogin(ServerHttpSecurity.FormLoginSpec::disable)
                .httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
                .authorizeExchange(exchanges -> exchanges
                        .pathMatchers("/members/signup", "/members/login", "ws/**").permitAll()
                        .pathMatchers("/broadcasts/**", "/streams/**", "/verifyIamport/**").permitAll()
                        .anyExchange().authenticated())
                .securityContextRepository(securityContextRepository)
                .authenticationManager(authenticationManager)
                .exceptionHandling(exceptionHandlingSpec -> exceptionHandlingSpec
                        .accessDeniedHandler((swe, e) ->
                                Mono.fromRunnable(() -> swe.getResponse().setStatusCode(HttpStatus.FORBIDDEN)))
                        .authenticationEntryPoint((swe, e) ->
                                Mono.fromRunnable(() -> swe.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED))));

        return serverHttpSecurity.build();
    }
}

🔍문제점1-해결

다음과 같이 CorsWebFilter를 빈으로 등록했다.

CorsWebFilter는 SPring Webflux에서 제공하는 내장된 CORS 필터다. 

여기서 CorsConfiguration 객체를 생성해서 필요한 구성 정보를 설정했다.

 

CORSFilter

@Configuration
@EnableWebFlux
public class CORSFilter {
    @Bean
    public CorsWebFilter corsWebFilter() {
        CorsConfiguration corsConfig = new CorsConfiguration();
        corsConfig.setAllowedOrigins(Collections.singletonList("*"));
        corsConfig.setAllowedMethods(Arrays.asList("GET", "POST", "DELETE", "PUT"));
        corsConfig.setAllowedHeaders(Collections.singletonList("*"));

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", corsConfig);
        corsConfig.setExposedHeaders(Arrays.asList("Access_Token", "Refresh_Token"));

        return new CorsWebFilter(source);
    }
}

 

WebfluxSecurityConfiguration

CORSFilter를 주입하고, addFilterAt을 사용해서 필터를 등록했다.

여기서 두 번째 인자인 SecurityWebFiltersOrder.CORS는 내가 추가하려는 필터를 SecurityWebFiltersOrder.CORS에 위치시키는 것이다.

입력된 인자를 통해 필터를 생성하고 순서에 맞게 필터가 배치된다.

@Configuration
@EnableWebFluxSecurity
@RequiredArgsConstructor
public class WebfluxSecurityConfiguration {

    private final AuthenticationManager authenticationManager;
    private final SecurityContextRepository securityContextRepository;
    private final CORSFilter corsFilter; //추가

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity serverHttpSecurity) {
        serverHttpSecurity
                .csrf(ServerHttpSecurity.CsrfSpec::disable)
                .formLogin(ServerHttpSecurity.FormLoginSpec::disable)
                .httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
                .authorizeExchange(exchanges -> exchanges
                        .pathMatchers("/members/signup", "/members/login", "ws/**").permitAll()
                        .pathMatchers("/broadcasts/**", "/streams/**", "/verifyIamport/**").permitAll()
                        .anyExchange().authenticated())
                .securityContextRepository(securityContextRepository)
                .authenticationManager(authenticationManager)
                .exceptionHandling(exceptionHandlingSpec -> exceptionHandlingSpec
                        .accessDeniedHandler((swe, e) ->
                                Mono.fromRunnable(() -> swe.getResponse().setStatusCode(HttpStatus.FORBIDDEN)))
                        .authenticationEntryPoint((swe, e) ->
                                Mono.fromRunnable(() -> swe.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED))))
                .addFilterAt(corsFilter.corsWebFilter(), SecurityWebFiltersOrder.CORS); //추가

        return serverHttpSecurity.build();
    }
}

 

public enum SecurityWebFiltersOrder {

	FIRST(Integer.MIN_VALUE),

	HTTP_HEADERS_WRITER,

	/**
	 * {@link org.springframework.security.web.server.transport.HttpsRedirectWebFilter}
	 */
	HTTPS_REDIRECT,

	/**
	 * {@link org.springframework.web.cors.reactive.CorsWebFilter}
	 */
	CORS,

	//생략

}

 


💻문제점2

MemberService

    @Transactional
    public Mono<ResponseEntity<String>> signup(SignupRequestDto signupRequestDto) {
        return memberRepository
                .existsByUserId(signupRequestDto.getUserId())
                .flatMap(exists -> {
                    if (exists) return Mono.error(new IllegalArgumentException("중복된 아이디입니다."));
                    else {
                        return memberRepository
                                .existsByNickname(signupRequestDto.getNickname())
                                .flatMap(duplicated -> {
                                    if (duplicated) return Mono.error(new IllegalArgumentException("중복된 닉네임입니다."));
                                    else return memberRepository
                                            .save(new Member(signupRequestDto, passwordEncoder.encode(signupRequestDto.getPassword()), MemberRoleEnum.USER))
                                            .onErrorResume(exception -> {
                                                return Mono.error(new RuntimeException("회원 정보 저장 오류"));
                                            })
                                            .flatMap(member -> {
                                                return channelRepository
                                                        .save(new Channel(member.getNickname()))
                                                        .onErrorResume(exception -> {
                                                            return Mono.error(new RuntimeException("회원 정보 저장 오류"));
                                                        });
                                            })
                                            .thenReturn(ResponseEntity.ok(signupRequestDto.getNickname() + "님 회원 가입 완료"));
                                });
                    }
                });
    }

 

이제 서비스에서 결제 시스템을 사용하여 츄르... 즉 포인트를 구매할 수 있기 때문에 회원가입할 때 Point 테이블에 컬럼을 추가해주어야 했다. 현재 return channelRepository.save()를 하는 flatMap 이후에 또다시 faltMap을 하면 channel 객체가 들어와서 어디서 pointRepository.save를 해줄까 고민이었다.


📃문제점2-시도

channelRepository에 save를 한 뒤 다시 flatmap을 통해 기존 member 객체를 계속 사용하도록 했다. 이렇게 되면 channelRepository save() 후에 pointRepository가 save() 가 호출된다. 사실 저장 순서가 필요하지 않는 작업인데.. 흠

@Transactional
public Mono<ResponseEntity<String>> signup(SignupRequestDto signupRequestDto) {
    return memberRepository.existsByUserId(signupRequestDto.getUserId())
            .flatMap(exists -> {
                if (exists) {
                    return Mono.error(new IllegalArgumentException("중복된 아이디입니다."));
                } else {
                    return memberRepository.existsByNickname(signupRequestDto.getNickname())
                            .flatMap(duplicated -> {
                                if (duplicated) {
                                    return Mono.error(new IllegalArgumentException("중복된 닉네임입니다."));
                                } else {
                                    return memberRepository.save(new Member(signupRequestDto, passwordEncoder.encode(signupRequestDto.getPassword()), MemberRoleEnum.USER))
                                            .flatMap(member -> {
                                                return channelRepository.save(new Channel(member.getNickname()))
                                                        .flatMap(channel -> {
                                                            return pointRepository.save(new Point(member.getUserId(), 0))
                                                                    .thenReturn(ResponseEntity.ok(signupRequestDto.getNickname() + "님 회원 가입 완료"));
                                                        });
                                            });
                                }
                            });
                }
            });
}

 

zip을 사용해서 세 가지 작업을 병렬적으로 처리하고 마지막에 ResonseEntity를 반환하게도 짜봤다. 

@Transactional
public Mono<ResponseEntity<String>> signup(SignupRequestDto signupRequestDto) {
    return memberRepository.existsByUserId(signupRequestDto.getUserId())
            .flatMap(exists -> {
                if (exists) {
                    return Mono.error(new IllegalArgumentException("중복된 아이디입니다."));
                } else {
                    return memberRepository.existsByNickname(signupRequestDto.getNickname())
                            .flatMap(duplicated -> {
                                if (duplicated) {
                                    return Mono.error(new IllegalArgumentException("중복된 닉네임입니다."));
                                } else {
                                    Mono<Member> memberMono = memberRepository.save(new Member(signupRequestDto, passwordEncoder.encode(signupRequestDto.getPassword()), MemberRoleEnum.USER))
                                            .onErrorResume(exception -> Mono.error(new RuntimeException("회원 정보 저장 오류")));

                                    Mono<Channel> channelMono = memberMono.flatMap(member -> channelRepository.save(new Channel(member.getNickname()))
                                            .onErrorResume(exception -> Mono.error(new RuntimeException("회원 정보 저장 오류"))));

                                    Mono<Point> pointMono = memberMono.flatMap(member -> pointRepository.save(new Point(member.getUserId(), 0)));

                                    return Mono.zip(memberMono, channelMono, pointMono)
                                            .thenReturn(ResponseEntity.ok(signupRequestDto.getNickname() + "님 회원 가입 완료"));
                                }
                            });
                }
            });
}

🔍문제점2-해결

일단은 다음과 같이 코드를 짰다. 

memberRepository에 save를 하고 반환한 Mono<Member> 객체를 사용해 flatMap으로 channelRepository와 pointRepository에 저장을 수행하고 Mono.zip을 통해 channelMono와 pointMono 작업을 병렬적으로 수행하게 했다.

    @Transactional
    public Mono<ResponseEntity<String>> signup(SignupRequestDto signupRequestDto) {
        return memberRepository.existsByUserId(signupRequestDto.getUserId())
                .flatMap(exists -> {
                    if (exists) {
                        return Mono.error(new IllegalArgumentException("중복된 아이디입니다."));
                    } else {
                        return memberRepository.existsByNickname(signupRequestDto.getNickname())
                                .flatMap(duplicated -> {
                                    if (duplicated) {
                                        return Mono.error(new IllegalArgumentException("중복된 닉네임입니다."));
                                    } else {
                                        Mono<Member> memberMono = memberRepository.save(new Member(signupRequestDto, passwordEncoder.encode(signupRequestDto.getPassword()), MemberRoleEnum.USER))
                                                .onErrorResume(exception -> Mono.error(new RuntimeException("회원 정보 저장 오류")));

                                        return memberMono
                                                .flatMap(member -> {
                                                    Mono<Channel> channelMono = channelRepository.save(new Channel(member.getNickname()))
                                                            .onErrorResume(exception -> Mono.error(new RuntimeException("회원 정보 저장 오류")));

                                                    Mono<Point> pointMono = pointRepository.save(new Point(member.getUserId(), 0));
                                                    return Mono.zip(channelMono, pointMono)
                                                            .thenReturn(member);
                                                }).thenReturn(ResponseEntity.ok(signupRequestDto.getNickname() + "님 회원 가입 완료"));
                                    }
                                });
                    }
                });
    }

 

테이블을 생성해주고 PostMan으로 테스트 해보자

CREATE TABLE points
(
    id BIGSERIAL PRIMARY KEY,
    user_id VARCHAR(255) NOT NULL,
    points INTEGER NOT NULL
);

 

728x90

'TIL' 카테고리의 다른 글

[TIL - 20230622] K6 성능 테스트 시나리오 작성  (0) 2023.06.23
[TIL - 20230621] Webflux R2DBC 후원 동시성 문제 해결  (0) 2023.06.22
[TIL - 20230616]  (0) 2023.06.17
[TIL - 20230615] EC2 + Docker Compose 통한 배포  (0) 2023.06.16
[TIL - 20230614] docker compose  (0) 2023.06.15
    'TIL' 카테고리의 다른 글
    • [TIL - 20230622] K6 성능 테스트 시나리오 작성
    • [TIL - 20230621] Webflux R2DBC 후원 동시성 문제 해결
    • [TIL - 20230616]
    • [TIL - 20230615] EC2 + Docker Compose 통한 배포
    Chef.Yeon
    Chef.Yeon
    보기 좋고 깔끔한 코드를 요리하기 위해 노력하고 있습니다.

    티스토리툴바