문제인식

스프링부트로 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

https://school.programmers.co.kr/learn/courses/30/lessons/60060

 

프로그래머스

코드 중심의 개발자 채용. 스택 기반의 포지션 매칭. 프로그래머스의 개발자 맞춤형 프로필을 등록하고, 나와 기술 궁합이 잘 맞는 기업들을 매칭 받으세요.

programmers.co.kr


1차 시도

query가 맨 앞이 "?"인지 확인하여 시작위치를 파악하고, query 길이와 word 길이가 같은지, "?"를 제외한 문자열이 word이 시작위치 기준으로 문자열 길이만큼 동일한지 확인하면 된다고 생각했습니다.

def solution(words, queries):
    answer = []
    
    for query in queries:
        cnt = 0
        N = len(query)
        if query[0] == "?":
            front = False
        else:
            front = True
        for word in words:
            if N != len(word):
                continue
            search_word = query.replace("?", "")
            search_N = len(search_word)
            if front:
                if word[:search_N] == search_word:
                    cnt += 1
            else:
                if word[-search_N:] == search_word:
                    cnt += 1
        answer.append(cnt)
    return answer
정확성: 25.0
효율성: 30.0
합계: 55.0 / 100.0

 

2차 시도

1차 시도보다 시간 효율성을 좋게하기 위해 딕셔너리를 생각했습니다.

딕셔너리 키는 word에 들어갈 수 있는 모든 query의 수이고, 값은 해당 쿼리로 검색할 수 있는 word의 수입니다.

def solution(words, queries):
    answer = []
    
    word_dict = {}
    
    for word in words:
        string = ""
        N = len(word)
        for token in word[:-1]:
            string += token
            keyword = string.ljust(N, "?")
            if keyword in word_dict:
                word_dict[keyword] += 1
            else:
                word_dict[keyword] = 1
        string = ""
        for token in word[::-1][:-1]:
            string += token
            keyword = string.ljust(N, "?")[::-1]
            if keyword in word_dict:
                word_dict[keyword] += 1
            else:
                word_dict[keyword] = 1
    
    for query in queries:
        answer.append(word_dict[query] if query in word_dict else 0)
    
    return answer
정확성: 25.0
효율성: 30.0
합계: 55.0 / 100.0

 

3차 시도

마지막으로 트리에 알고리즘을 적용했습니다.

class Node(object):
    def __init__(self, key = None):
        self.key = key
        self.length = []
        self.child = {}

class Trie():
    def __init__(self):
        self.head = Node()
        
    def insertTree(self, word):
        cur = self.head
        word_length = len(word)
        cur.length.append(word_length)
        for token in word:
            if token not in cur.child:
                cur.child[token] = Node(token)
            cur = cur.child[token]
            cur.length.append(word_length)
            
    def searchTree(self, word):
        cur = self.head
        word_length = len(word)
        for token in word:
            if token == "?":
                return cur.length.count(word_length)
            elif token not in cur.child:
                break
            cur = cur.child[token]
        return 0

def solution(words, queries):
    answer = []
    tree = Trie()
    reverse_tree = Trie()
    for word in words:
        tree.insertTree(word)
        reverse_tree.insertTree(word[::-1])
    for query in queries:
        if query[0] == "?":
            answer.append(reverse_tree.searchTree(query[::-1]))
        else:
            answer.append(tree.searchTree(query))
    return answer
정확성: 25.0
효율성: 75.0
합계: 100.0 / 100.0
 

코드 설명

Node class는 문자열의 token를 key로 갖고, 해당 노드를 거치는 word들의 길이를 나타내는 리스트를 length로 갖고, 자식노드를 나타내는 딕셔너리를 child로 갖습니다.

Trie class는 트리에 알고리즘을 적용시키기 위한 클래스입니다.

insertTree는  word의 token들을 노드에 차례로 넣고 함께 그 노드를 거치는 word들의 길이를 length 리스트에 넣어줍니다.

searchTree는 query의 token이 "?"가 나올 때까지 들어가서 "?"를 만나면 전 노드의 length에 찾고자하는 query 길이를 카운트하고 그 카운터 값을 리턴합니다.

solution function에서 "????o"와 같이 뒤에서 부터 검색하기 위한 reverse_tree를 따로 만들어 줍니다.

그리고 words에 대하여 모든 tree와 reverse_tree를 만듭니다.

그리고 queries에 대하여 query가 "?"로 시작하면 revers_tree에서 찾고 아니라면 tree에서 찾아서 answer 리스트에 추가합니다.

 

 

'프로그래머스-파이썬' 카테고리의 다른 글

[3차] 자동완성  (0) 2024.05.16
쿠키 구입  (0) 2024.05.14
도둑질  (0) 2024.05.02
호텔방 배정  (0) 2024.04.11
무지의 먹방 라이브  (0) 2024.04.09

https://school.programmers.co.kr/learn/courses/30/lessons/17685

 

프로그래머스

코드 중심의 개발자 채용. 스택 기반의 포지션 매칭. 프로그래머스의 개발자 맞춤형 프로필을 등록하고, 나와 기술 궁합이 잘 맞는 기업들을 매칭 받으세요.

programmers.co.kr


1차 시도

구현부터 해보자는 생각에 먼저 words를 돌면서 word가 완성될 수 있는 모든 경우의 수에 대한 딕셔너리를 만들었습니다.

ex) words = ["go", "gone"]는 {"g": ["go", "gone"], "go": ["go", "gone"], ":gon": ["gone"], "gone": [ "gone" ]}

딕셔너리를 만든 후 words를 돌아서 특정 word가 만들어 지는 경우의 수 key에 대해 특정 word value를 가지거나 완전한 word만 가능할 때 answer에 key의 길이를 더해주면 될 것 같다고 생각했습니다.

from collections import defaultdict

def solution(words):
    answer = 0
    
    word_dict = defaultdict(list)
    
    for word in words:
        N = len(word)
        for i in range(1, N+1):
            word_dict[word[:i]] += [word]
            
    for word in words:
        N = len(word)
        for i in range(1, N+1):
            if len(word_dict[word[:i]]) == 1:
                answer += i
                break
        else:
            answer += N
    
    return answer
정확성: 81.8
합계: 81.8 / 100.0

 

2차 시도

value에 가능한 word 리스트가 아닌 개수를 넣어서 len(word_dict[word[:i])의 시간을 줄여보려했습니다.

또한 먼저 word_dict[word]가 1가 아니라면 바로 answer에 word 길이를 더하여 word에 대해 for문이 도는 횟수를 줄이고자 했습니다.

from collections import defaultdict

def solution(words):
    answer = 0
    
    word_dict = defaultdict(int)
    
    for word in words:
        N = len(word)
        for i in range(1, N+1):
            word_dict[word[:i]] += 1
            
    for word in words:
        N = len(word)
        if word_dict[word] != 1:
            answer += N
            continue
        for i in range(1, N+1):
            if word_dict[word[:i]] == 1:
                answer += i
                break
    
    return answer
정확성: 81.8
합계: 81.8 / 100.0

 

※ 실행 결과

더보기
테스트 1 통과 (0.01ms, 10.1MB)
테스트 2 통과 (0.02ms, 10.1MB)
테스트 3 통과 (0.02ms, 10.1MB)
테스트 4 통과 (0.03ms, 10.2MB)
테스트 5 통과 (0.02ms, 10.1MB)
테스트 6 통과 (740.89ms, 115MB)
테스트 7 통과 (0.02ms, 9.93MB)
테스트 8 통과 (767.83ms, 115MB)
테스트 9 통과 (0.02ms, 10.2MB)
테스트 10 통과 (0.02ms, 9.96MB)
테스트 11 통과 (0.03ms, 10.2MB)
테스트 12 통과 (669.38ms, 115MB)
테스트 13 통과 (695.28ms, 115MB)
테스트 14 실패 (시간 초과)
테스트 15 통과 (0.02ms, 10.3MB)
테스트 16 통과 (627.05ms, 115MB)
테스트 17 통과 (687.17ms, 115MB)
테스트 18 통과 (0.03ms, 10MB)
테스트 19 실패 (시간 초과)
테스트 20 통과 (578.32ms, 115MB)
테스트 21 실패 (시간 초과)
테스트 22 실패 (시간 초과)

 

다른 사람 코드

class Node(object):
    def __init__(self, key, cnt = 0):
        self.key = key
        self.cnt = cnt
        self.child = {}

class Trie():
    def __init__(self):
        self.head = Node(None)
    def insertTree(self, string):
        cur = self.head
        for token in string:
            if token not in cur.child:
                cur.child[token] = Node(token)
            cur = cur.child[token]
            cur.cnt += 1
    def searchTree(self, string):
        cur = self.head
        cnt = 0
        for token in string:
            cnt += 1
            cur = cur.child[token]
            if cur.cnt == 1:
                return cnt
        return len(string)   

def solution(words):
    answer = 0
    wordTree = Trie()
    for word in words:
        wordTree.insertTree(word)
    for word in words:
        answer += wordTree.searchTree(word)
    return answer
정확성: 100.0
합계: 100.0 / 100.0
 

※ 실행 결과

더보기
테스트 1 통과 (0.01ms, 10.1MB)
테스트 2 통과 (0.02ms, 10.1MB)
테스트 3 통과 (0.03ms, 10.2MB)
테스트 4 통과 (0.03ms, 10.2MB)
테스트 5 통과 (0.02ms, 10.2MB)
테스트 6 통과 (2663.90ms, 275MB)
테스트 7 통과 (0.03ms, 10.1MB)
테스트 8 통과 (2793.23ms, 275MB)
테스트 9 통과 (0.02ms, 10.2MB)
테스트 10 통과 (0.03ms, 10.2MB)
테스트 11 통과 (0.03ms, 10.1MB)
테스트 12 통과 (2572.36ms, 275MB)
테스트 13 통과 (2508.36ms, 275MB)
테스트 14 통과 (2154.72ms, 398MB)
테스트 15 통과 (0.02ms, 10.2MB)
테스트 16 통과 (2154.92ms, 275MB)
테스트 17 통과 (2227.31ms, 275MB)
테스트 18 통과 (0.03ms, 10.3MB)
테스트 19 통과 (2133.43ms, 398MB)
테스트 20 통과 (2295.83ms, 275MB)
테스트 21 통과 (2349.56ms, 398MB)
테스트 22 통과 (1981.25ms, 398MB)

 

풀이

Trie 알고리즘으로 푼 코드입니다.

단어 단위로 트리 형식으로 연결합니다.

트리의 깊이가 단어의 길이가 됩니다.

이 코드에서 노드의 구조는 key(단어의 token), cnt(자식 노드 수), child(자식 노드들)로 구성되어 있습니다.

여기서 child는 딕셔너리 형식으로 상수 시간에 검색하도록 합니다.

해당 단어의 자동 완성 조건에 만족하려면 노드의 cnt가 1일 때까지 타고 들어가거나, 노드의 cnt가 1인 경우가 없어서 모든 노드를 타고 들어가야합니다.

 

궁금한 점

2차 시도에서 짠 코드의 실행 결과에서 통과된 시간을 보면 3배이상 빠른데, 시간 초과는 어떤 경우에서 뜨는지 모르겠습니다.

혹시 아시는 분은 댓글로 알려주시면 감사하겠습니다.

 

'프로그래머스-파이썬' 카테고리의 다른 글

가사 검색  (0) 2024.05.22
쿠키 구입  (0) 2024.05.14
도둑질  (0) 2024.05.02
호텔방 배정  (0) 2024.04.11
무지의 먹방 라이브  (0) 2024.04.09

+ Recent posts