문제인식

스프링부트로 OAuth 자체 서버를 구축하고 react에서 PKCE를 이용한 로그인 기능을 사용하고 있었습니다.

하지만 인증과정에서 가끔 새창으로 redirect가 되어 로그인에 실패하는 경우가 있었습니다.

서비스가 무조건 로그인을 해야 사용할 수 있는 서비스여서 해당 문제를 해결하고자 했습니다.

 

문제파악

로그로는 새창이 띄어지는 이유가 전혀 파악이 되지 않아서 먼저 버그 상황을 재현을 하고자 했습니다.

여러 시도 끝에 비밀번호를 빠르게 타이핑한 후 엔터(submit)를 하면 해당 버그가 발생하는 것을 확인했습니다.

빠르게 타이핑을 하는 것이 브라우저가 예상치 못한 동작을 하게된 이유 중 하나가 될 수 있다는 것을 처음 접하였습니다.

 

문제해결

- form 태그에 아래와 같이 onsubmit을 추가

<form th:action="@{/login}" method="post" onsubmit="return handleSubmit(event)">

 

- handleSubmit은 아래와 같이 script 부분에 추가

<script>
    function handleSubmit(event) {
        // 새 창 열림 방지
        event.preventDefault();

        // 명시적으로 form을 현재 탭에서 제출
        event.target.submit();
    }
</script>

 

위와 같이 submit에 대한 동작을 명시적으로 정의하여 해결하였습니다.

 

해당 문제가 스프링부트 thymeleaf에서만 발생하는 것인지는 모르겠으나, 저와 같은 문제가 있으신 분은 위 해결방법을 적용해보면 좋을 것 같습니다.

문제 인식

백엔드 서버는 클라이언트에게 쿠키를 받아 인증 확인을 하는 로직이 있었습니다.

프론트 서버에서 테스트 용으로 인증이 필요한 API 요청을 하니 Cors 에러가 발생했습니다.

 

문제 파악

당시에 스프링부트 Cors 설정을 WebMvcConfigurer 를 이용하여 구현했습니다.

@Configuration
public class CorsConfig implements WebMvcConfigurer {

	@Override
	public void addCorsMappings(final CorsRegistry registry) {
		registry.addMapping("/**")
			.allowedOriginPatterns("http://localhost:3000")
			.allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH")
			.allowedHeaders("*")
			.allowCredentials(true);
	}
}

 

구글 서칭을 해보니 스프링 시큐리티에서 CorsConfigurationSource로 Cors 설정하는 부분이 있음을 확인했습니다.

WebMvcConfigurerCorsConfigurationSource의 차이점은  CorsConfigurationSource가 WebMvcConfigurer 보다 먼저 거치는 필터이였습니다.

 

그렇기 때문에, 쿠키를 사용할 때는 WebMvcConfigurer로 설정한다 한들, CorsConfigurationSource 필터에서 이미 걸려졌기 때문에 Cors 에러가 난 것이였습니다.

 

문제 해결

그래서 필자는 WebMvcConfigurer 대신 스프링 시큐리티 설정 클래스 안에  CorsConfigurationSource 를 구현하고 적용함으로써 해결했습니다.

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    return request -> {
        CorsConfiguration corsConfiguration = new CorsConfiguration();
        corsConfiguration.setAllowedHeaders(Collections.singletonList("*"));
        corsConfiguration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH"));
        corsConfiguration.setAllowedOriginPatterns(List.of("http://localhost:3000"));
        corsConfiguration.setAllowCredentials(true);
        return corsConfiguration;
    };
}

 

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

    http
        .cors(cors -> cors.configurationSource(corsConfigurationSource()));
        
    // 나머지 설정

문제 인식

로컬에서 테스트할 땐 크롬 브라우저에서 쿠키가 잘 도착하는 것을 확인한 후 https로 배포를 진행했습니다.

그런데 로컬에선 잘 도착하던 쿠키가 배포 환경에서는 도착하지 않는 문제가 발생했습니다.

 

문제 파악

서버 배포 환경

  • 프론트엔드 서버: React와 Next.js를 사용, Vercel 서버에 배포, https
  • 웹서버: Nginx를 사용, EC2 인스턴스 서버에 배포, https
  • 백엔드 서버: Spring Boot를 사용, EC2 서버에 배포, http     (웹서버와 다른 EC2 인스턴스 서버에 배포)

쿠키 설정

  • HttpOnly
  • Path("/")
  • Secure
  • MaxAge
  • SameSite("None")
  • Domain(".example.com")     (당시에 example.com은 웹서버의 메인 도메인)

구글 서칭과 Chat GPT에서 알아본 결과, Vercel에서 만들어준 도메인이 example.com과 아예 다른 도메인이라 쿠키의 Domain 설정 때문에 쿠키를 도착하지 못하는 문제임을 파악했습니다.

 

문제 해결

다른 팀원의 정보를 통해 Vercel 서버를 example.com의 서브 도메인으로 등록하는 방법이 있음을 알게 되었습니다.

그래서 DNS 설정에서 A 레코드를 추가하여 Vercel 서버의 IP를 example.com의 서브 도메인으로 등록하였습니다.

그 후, 다시 시도한 결과 https 환경에서도 쿠키가 잘 도착하는 것을 확인했습니다.

문제 인식

QueryDsl로 AdSimilarty → Advertisement → AdCategory 의 순으로 2Depth에 있는 엔티티 데이터를 가져오는 로직이 있었는데, AdCategory 엔티티를 불러오지 못하는 문제가 있었습니다.

 

문제 파악

QueryDsl은 기본적으로 1Depth에 있는 엔티티 데이터까지만 초기화하기 때문에, 2Depth에 있는 AdCategory 엔티티 데이터를 초기화하지 못하여 null 값으로 들어가고 있었습니다.

 

문제 해결

AdSimilarty 엔티티에서 @QueryInit 를 사용하여 2Depth에 있는 AdCategory를 초기화할 수 있게 하였습니다.

주의해야할 점은 @QueryInit 가 붙여진 엔티티를 통해 1Depth만 엔티티까지만 사용하고 있던 기존 로직이 있다면, 해당 1Depth 엔티티도 @QueryInit 에 추가해줘야 합니다.

 

참고 자료

https://morian-kim.tistory.com/50

문제 인식

도커 컴포즈로 MySQL을 외부 포트와 내부 포트를 기본 포트가 아닌 다른 포트로 매핑하여 생성했는데, 해당 포트로 접근이 되지 않는 문제가 발생했습니다.

 

문제 파악

도커 컴포즈로 내부 포트를 다른 포트로 매핑한다고 해서 MySQL은 자동으로 해당 포트로 매핑되지 않습니다.

MySQL은 기본 포트가 아닌 다른 포트를 사용하려면 cnf 파일에서 port를 수정해야합니다.

 

문제 해결

저는 cnf 파일을 수정하는 것이 번거로워서, 도커 컴포즈에서 포트를 다른 포트:다른 포트가 아닌 다른 포트:기본 포트 방식으로 매핑하였습니다.

외부에서 도커 MySQL 컨테이너에 다른 포트로 접근하게 하고, 컨테이너 내부에서 MySQL에 기본 포트로 접근하게 함으로써 빠르게 해결되었습니다.

 

참고 자료

MySQL에서 기본 포트 변경하는 방법 → https://skylit.tistory.com/253

문제 인식

저는 RedisTemplate<String, ReidsValue>의 타입으로 RedisTemplate를 사용하고 있었습니다.

RedisValue는 제가 만든 인터페이스이며, 값으로 RedisValue를 조상 클래스로 갖고 있는 RefreshToken 클래스를 넣고 있었습니다.

문제는 Redis에 저장된 RefreshToken를 가져올 때, java.lang.ClassCastException: class java.util.LinkedHashMap cannot be cast to class 에러가 찍히는 문제가 있었습니다.

 

문제 파악

해당 에러는 Redis에서 json 데이터를 가져올 때, LinkedHashMap 타입으로 가져오는데, 이 LinkedHashMap을 RefreshToken 객체 타입으로 캐스팅할 수 없어서 발생했습니다.

 

문제 해결

구글 서칭과 Chat GPT로 알아본 결과 3가지 해결 방법을 생각할 수 있었습니다.

  1. redisValueSerializer는 StringRedisSerializer를 사용하고, redis에 value를 저장하고, 읽어들이는 로직에서 직적 ObjectMapper를 사용하여 writeValueAsStringcovertValue를 구현하는 방법
  2. redisValueSerializer는 GenericJackson2JsonRedisSerializer 를 사용하고, json 데이터에 클래스 정보(@class 필드)를 포함시키는 ObjectMapper를 GenericJackson2JsonRedisSerializer에 포함시키는 방법
  3. redisValueSerializer는 Jackson2JsonRedisSerializer 를 사용하고, json 데이터 type 필드에 이름을 넣는 방법

저는 이 중에서 3번 방법을 선택했습니다.

1번 방법은 성능이 제일 좋을 것 같지만, 코드 구현 및 유지보수 측면에서 좋지 않다고 판단되어 제외했습니다.

2번 방법은 1번 방법보다 유지보수가 좋지만, 성능도 제일 떨어질 것 같고, 클래스 정보가 변경된 후 배포되면 기존에 redis에 저장되어 있던 value들은 역직렬화가 안되는 문제가 발생하고 또한, 다른 서버에서 해당 value를 가져오려면 value 객체의 위치도 동일하게 해야 한다는 종속성 문제도 있기 때문에 제외했습니다.

그래서 성능과 유지보수, 서버 종속성의 절충안으로 3번을 선택하게 되었습니다.

 

참고 자료

https://velog.io/@choidongkuen/데이터베이스-Redis-직렬화-방법에-대해서

https://jaime-note.tistory.com/17

문제 인식

저는 운영 서버에 같은 서비스 기능을 하는 스프링부트 서버 2개를 띄우고 Nginx 서버에서 로드밸런싱을 하고 있었습니다.

팀에서 Oauth2로 소셜 로그인을 구현이 되어 개발 서버에서 테스트하고 운영 서버에 반영을 했습니다.

그런데, 개발 서버에서 잘 되었던 소셜 로그인이 확률적으로 성공하는 현상이 발생했습니다.

확률적으로 성공하는 소셜 로그인은 실제 서비스에선 있을 수 없는 일이라 생각했기 때문에 빠르게 해결하고자 했습니다.

 

문제 파악

구현한 소셜 로그인 동작에 대해 생각해보니 백엔드 서버는 크게 3가지 요청이 있었습니다.

  • 클라이언트에게 위임 요청
  • 받은 위임으로 인증 서버에 토큰 요청
  • 받은 토큰으로 리소스 서버에 사용자 정보 요청

그렇다면 받은 위임과 받은 토큰으로 요청하는 것이기 때문에 이전 요청에 대한 응답을 가지고 있어야 정상적으로 작동할 것이라 생각했습니다.

즉, Nginx의 로드밸런싱으로 중간에 원래 소셜 로그인 동작을 하던 스프링부트가 아닌 다른 포트 번호의 스프링부트 서버로 요청을 했기 때문에 일어난 일이라 생각할 수 있었습니다.

 

문제 해결

저는 Nginx에서 특정 요청은 특정 포트 번호의 서버로 요청이 가게끔하면 되지 않을까라는 생각으로 해당 방향으로 알아봤습니다.

알아보니, Nginx에서 ip_hash라는 것을 사용하면 특정 ip는 같은 포트 번호로 요청을 보낼 수 있는 방법이 있었습니다.

적용한 결과, 운영 서버에서도 정상적으로 소셜로그인을 할 수 있게 되었습니다.

 

※ 개발 서버에서는 비용 문제로 1개의 스프링부트 서버만 띄우고 있어서 잘 동작한 것이었습니다.

 

 

개선점

ip_hash는 한 지역에서 많은 트래픽을 보내면 한쪽 서버으로 트래픽이 몰릴 확률이 존재합니다.

그래서 OAuth 인증 서버에서 받은 토큰을 Redis 같은 스토리지에 저장하여 공유하도록 하면 한 지역에서 많은 트래픽을 보내도 부하 분산이 가능하고 제가 겪었던 문제도 해결할 수 있는 좋은 방법이 될 것이라 생각합니다.

문제 인식

저는 필요로 인해 유저 서비스에 포함되어 있던 채팅 기능을 채팅 서버로 분리하였습니다.

그 이후 특정 상황에서 채팅 서버에서 카프카 컨슈머 역직렬화에 실패하였다는 에러 로그가 엄청나게 빨리 쌓여 채팅 서버가 존재하는 ec2 인스턴스 서버에 에러 로그가 30초도 안되서 1GB가 쌓여서 디스크 공간이 매우 빠른 속도로 고갈되는 문제가 발생했습니다.

이 문제는 해당 ec2 인스턴스는 채팅 서버 뿐만 아니라 다른 서버도 존재하였기 때문에 다른 서버에 영향을 끼치고, ec2 데이터 쓰기 비용이 크게 증가하기 때문에 빠르게 해결해야 했습니다.

 

문제 파악

알아보니 로그가 빨리 쌓이는 이유는 스프링 카프카는 컨슈머에서 들어온 메시지의 역직렬화에 실패하면, 해당 메시지에 대하여 역직렬화를 짧은 시간 내에 다시 시도합니다.

해당 에러에 대한 처리가 없다면 무한 루프가 되어 엄청난 속도로 에러 로그가 쌓이게 되는 것이었습니다.

 

다음으로 역직렬화에 실패한 이유를 파악하기 위해 에러 로그를 확인 했고, 에러 로그는 다음과 같았습니다.

Caused by: java.lang.ClassNotFoundException: com.kernelsquare.memberapi.domain.coffeecaht.dto.ChatMessageRequest

 

해당 로그를 통해서 스프링 카프카는 프로듀서에서 클래스에 대한 정보도 같이 메시지를 직렬화하여 넣는다는 것을 알게 되었습니다.

그렇기 때문에, 유저 서버에서 해당 메시지를 보내면 채팅 서버에도 똑같이 com.kernelsquare.memberapi.domain.coffeechat.dto 위치에 ChatMessageRequest 클래스가 있어야 컨슈머에서 역직렬화가 가능합니다.

 

그런데 저는 채팅 서버에서 다른 위치의 ChatMessageRequest 클래스를 사용하기 때문에 역직렬화에 실패한 것입니다.

 

문제 해결

저는 다음 3가지 해결 방법이 떠올랐습니다.

  • 첫 번째, 채팅 서버에도 똑같은 위치에 ChatMessageRequest 클래스를 생성하는 방법
  • 두 번째, 유저 서버에서 메시지를 보낼 때, 클래스 정보는 넣지 않도록 설정하는 방법
  • 세 번째, 유저 서버의 ChatMessageRequest와 채팅 서버의 ChatMessageRequest를 매핑할 수 있도록 설정하는 방법

저는 다음 2가지 이유로 세 번째 방법을 선택했습니다.

  • 매핑하는 방법이 채팅 서버 패키지 구조과 유저 서버 패키지 구조에 의존적이지 않아도 되기 때문입니다.
  • 매핑하는 방법이 프로듀서 측 클래스 정보도 포함할 수 있기 때문입니다.

매핑하는 방법은 프로듀서와 컨슈머에서 매핑 설정을 해줘야 하는데, yml 파일에서 하는 방법과 코드에서 하는 방법이 있었습니다.

저는 이미 프로듀서와 컨슈머 설정을 코드로 구현했기 때문에 코드에서 매핑 설정을 추가하기로 했습니다.

 

프로듀서

    public ProducerFactory<String, Object> producerFactory() {
        Map<String, Object> config = new HashMap<>();
        config.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, url);
        config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
        config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class);
        config.put(JsonSerializer.TYPE_MAPPINGS,
        	"ChatMessageRequest:com.kernelsquare.memberapi.domain.coffeechat.dto.ChatMessageRequest");
        return new DefaultKafkaProducerFactory<>(config);
    }

 

컨슈머

	public ConsumerFactory<String, ChatMessageRequest> consumerFactory(String groupId) {
		Map<String, Object> config = new HashMap<>();

		config.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, url);
		config.put(ConsumerConfig.GROUP_ID_CONFIG, groupId);
		config.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
		config.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, JsonDeserializer.class);
		config.put(JsonDeserializer.TYPE_MAPPINGS,
			"ChatMessageRequest:com.kernelsquare.chattingapi.domain.chatting.dto.ChatMessageRequest");
		config.put(JsonDeserializer.TRUSTED_PACKAGES, "*");
		config.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "latest");

		return new DefaultKafkaConsumerFactory<>(config);
	}

 

매핑 설정은 사용한 Serializer과 Deserializer의 TYPE_MAPPINGS에 "매핑할 이름:위치를 포함한 클래스명" 형태로 넣어주면 됩니다.

 

프로듀서와 컨슈머에 매핑 설정한 결과, 에러없이 잘 작동함을 확인했습니다.

 

더 나아가...

하지만 저는 여기까지는 반만 해결한 것이라 생각했습니다.

 

왜냐하면 모종의 이유(?)로 컨슈머에서 역직렬화하지 못하는 메시지가 메시지 브로커에 들어간다면 무한 루프로 에러 로그를 찍게 될 것이라 생각했기 때문입니다.

 

그렇다면 해당 메시지는 무시하고 다음 메시지로 넘어가게 할 수 있다면 되지 않을까라는 생각을 하게 되었습니다.

그래서 찾아보니 ErrorHandlingDeserializer라는 것을 사용하면 역직렬화에 실패하면 null을 반환하기 때문에 다음 메시지로 넘어갈 수 있다는 글이 있었습니다.

그래서 저는 ErrorHandlingDeserializer를 사용하여 컨슈머 설정을 다음과 같이 했습니다.

	public ConsumerFactory<String, ChatMessageRequest> consumerFactory(String groupId) {
		Map<String, Object> config = new HashMap<>();

		config.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, url);
		config.put(ConsumerConfig.GROUP_ID_CONFIG, groupId);
		config.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, ErrorHandlingDeserializer.class);
		config.put(ErrorHandlingDeserializer.KEY_DESERIALIZER_CLASS, StringDeserializer.class);
		config.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, ErrorHandlingDeserializer.class);
		config.put(ErrorHandlingDeserializer.VALUE_DESERIALIZER_CLASS, JsonDeserializer.class);
		config.put(JsonDeserializer.TYPE_MAPPINGS,
			"ChatMessageRequest:com.kernelsquare.chattingapi.domain.chatting.dto.ChatMessageRequest");
		config.put(JsonDeserializer.TRUSTED_PACKAGES, "*");
		config.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "latest");

		return new DefaultKafkaConsumerFactory<>(config);
	}

 

기존 ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG와 ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG에 ErrorHandlingDeserializer.class를 넣고,

ErrorHandlingDeserializer.KEY_DESERIALIZER_CLASS와 ErrorHandlingDeserializer.VALUE_DESERIALIZER_CLASS 에 기존에 사용하던 Deserializer.class를 넣어주면 됩니다.

 

이 컨슈머 설정을 적용한 후 저는 에러 로그를 한번 찍고 다음 메시지를 처리할 수 있게 되었습니다.

 

개인적인 생각

ErrorHandlingDeserializer를 적용함으로써 에러 로그를 한 번 찍고 다음 메시지로 넘어갈 수 있었지만, 그 한 번의 에러 로그가 엄청 길어서, 이것을 커스텀하여 핵심 내용만 로그에 남길 수 있다면 가독성 측면과 리소스 측면에서 나아질 것이라 생각이 들었습니다.

 

역직렬화에 실패한 메시지에 대한 관리를 따로 할 수 있다면, 나중에 해당 메시지들에 대해 파악할 수 있지 않을까 생각합니다.

+ Recent posts