토큰을 갱신했더니 로그아웃됐다 — 리프레시 토큰 회전의 경쟁 상태

토큰을 갱신했더니 로그아웃됐다 — 리프레시 토큰 회전의 경쟁 상태

보안을 위해 리프레시 토큰을 쓸 때마다 새 토큰으로 교체하는 회전(rotation) 방식을 도입했습니다. 그런데 앱에서 여러 요청이 거의 동시에 토큰을 갱신하면 갑자기 로그아웃되는 제보가 들어왔습니다. 동시 갱신이 서로의 토큰을 무효화한다 토큰 회전은 옛 리프레시 토큰을 즉시 폐기하고 새 토큰을 내주는 구조입니다. 그런데 모바일 앱이 만료 직후 여러 API를 동시에 호출하면, 갱신 요청도 여러 개가 거의 같은 … 더 읽기

로깅 필터를 붙였더니 컨트롤러가 요청 바디를 못 읽었다

로깅 필터를 붙였더니 컨트롤러가 요청 바디를 못 읽었다

API 요청/응답을 로깅하는 필터를 붙였다. 로그는 잘 남았는데, 그 뒤로 컨트롤러가 요청 바디를 못 읽는다며 빈 값으로 처리되기 시작했다. 요청 바디 스트림은 한 번만 읽힌다 HTTP 요청 바디는 InputStream이라 기본적으로 한 번 읽으면 끝이다. 로깅 필터에서 바디를 읽어 로그로 남기는 순간 스트림이 소진됐고, 그 뒤 컨트롤러가 다시 읽으려 하니 남은 게 없었다. 로그를 위해 데이터를 … 더 읽기

서버를 늘렸더니 정산 메일이 두 통씩 갔다

서버를 늘렸더니 정산 메일이 두 통씩 갔다

매일 아침 정산 메일을 보내는 스케줄러가 있었습니다. 서버를 두 대로 늘린 다음 날부터 사용자들이 같은 메일을 두 통씩 받기 시작했습니다. 코드는 그대로였는데 말이죠. 인스턴스마다 스케줄러가 돈다 @Scheduled는 그 애플리케이션 안에서 시간이 되면 실행됩니다. 문제는 서버가 두 대면 스케줄러도 두 개가 각자 돈다는 겁니다. 둘 다 아침 9시에 깨서 각자 메일을 보냈으니 사용자는 두 통을 받은 … 더 읽기

JSON 응답이 무한루프에 빠졌다 — 양방향 연관과 직렬화

JSON 응답이 무한루프에 빠졌다 — 양방향 연관과 직렬화

주문 엔티티를 그대로 JSON으로 내려 줬더니 응답이 안 끝나고 서버가 뻗었다. 로그엔 끝없이 반복되는 스택. com.fasterxml.jackson…JsonMappingException: Infinite recursion (StackOverflowError) 양방향 연관이 서로를 계속 부른다 Order는 Member를 참조하고, Member는 자기 Order 목록을 참조하는 양방향 관계였다. Jackson이 Order를 직렬화하려고 Member를 펼치고, Member를 펼치다 다시 Order를 만나고, 그 Order가 또 Member를 부르고… 무한 루프에 빠져 스택이 터진 거다. … 더 읽기

A 요청에서 B의 정보가 나왔다 — ThreadLocal을 안 지운 대가

A 요청에서 B의 정보가 나왔다 — ThreadLocal을 안 지운 대가

요청마다 사용자 정보를 ThreadLocal에 담아 두고 어디서든 꺼내 쓰는 구조를 만들었습니다. 편했습니다. 그런데 가끔 A 사용자의 요청에서 B 사용자의 정보가 튀어나오는 아찔한 버그가 보고됐습니다. 스레드는 재사용된다 톰캣이든 스프링이든 요청은 스레드 풀에서 스레드를 빌려 처리하고, 끝나면 그 스레드를 반납합니다. 반납된 스레드는 다음 요청에 다시 쓰이죠. 그런데 ThreadLocal에 담아 둔 값을 지우지 않으면, 그 값이 다음 요청까지 … 더 읽기

밤마다 뜨던 데드락, 잠그는 순서를 고정하니 사라졌다

밤마다 뜨던 데드락, 잠그는 순서를 고정하니 사라졌다

포인트 정산 배치가 밤마다 몇 건씩 실패했다. 로그엔 이 한 줄. Deadlock found when trying to get lock; try restarting transaction 재시도하면 성공하니 넘어갈 뻔했는데, 원인을 안 잡으면 트래픽 늘 때 폭발할 종류의 버그라 파고들었다. 두 트랜잭션이 서로 반대 순서로 잠갔다 정산 로직이 두 계좌 사이에 포인트를 옮기는 구조였는데, 어떤 트랜잭션은 A를 잠그고 B를 잠그려 … 더 읽기

인덱스는 걸려 있는데 왜 느릴까 — 복합 인덱스 컬럼 순서

인덱스는 걸려 있는데 왜 느릴까 — 복합 인덱스 컬럼 순서

주문 검색이 유독 느렸습니다. 회원 ID와 상태로 걸러 최신순으로 뽑는 흔한 쿼리인데 3초씩 걸렸죠. 인덱스는 분명히 걸려 있었습니다. EXPLAIN을 찍어 보고 나서야 인덱스가 있어도 안 타는 이유를 알았습니다. EXPLAIN SELECT * FROM orders WHERE member_id = ? AND status = ‘PAID’ ORDER BY created_at DESC LIMIT 20; — type: ALL, rows: 1,200,000, Using filesort 인덱스는 … 더 읽기

커넥션이 30초 만에 다 말라버렸다 — HikariCP 풀 고갈 추적기

커넥션이 30초 만에 다 말라버렸다 — HikariCP 풀 고갈 추적기

배포하고 이틀 뒤 새벽이었다. 결제는 멀쩡히 되는데 마이페이지·주문내역 같은 조회 API가 간헐적으로 500을 뱉었다. 재현이 안 돼 더 골치였는데, 로그를 시간대로 잘라 보니 저녁 트래픽이 몰리는 20~22시에만 터졌다. 낮엔 하루 종일 멀쩡했다. 그 시간대 로그엔 이 줄이 도배돼 있었다. HikariPool-1 – Connection is not available, request timed out after 30000ms 풀에서 커넥션을 못 받고 connectionTimeout(기본 … 더 읽기

@Transactional을 붙였는데 롤백이 안 됐던 이유

@Transactional을 붙였는데 롤백이 안 됐던 이유

대량 회원 데이터를 외부에서 받아 저장하는 배치를 만들었습니다. 요구사항은 단순했습니다. 한 건이 잘못돼도 나머지는 저장돼야 한다. 그래서 각 건을 독립된 트랜잭션으로 처리하려고 @Transactional(REQUIRES_NEW)를 붙였습니다. 그런데 운영에 올리고 보니, 중간에 한 건이 실패하면 그 배치 전체가 통째로 롤백되거나, 반대로 실패한 건까지 저장돼 버리는 이상한 일이 벌어졌습니다. @Service public class MemberImportService { public void importAll(List<MemberDto> rows) { … 더 읽기

@Async로 보낸 작업의 예외가 조용히 사라지던 문제

@Async로 보낸 작업의 예외가 조용히 사라지던 문제

회원 가입 시 환영 메일을 보내는 코드를 @Async로 감쌌다. 메일이 느려도 가입 응답은 빨라야 하니 비동기로 뺀 거다. 로컬에서 잘 됐고, 운영에서도 메일은 잘 나갔다. 문제는 실패했을 때였다. @Async public void sendWelcomeMail(Long memberId) { Member m = memberRepository.findById(memberId).orElseThrow(); mailSender.send(m.getEmail(), welcomeTemplate(m)); } 어느 날 메일 서버가 30분쯤 죽었다. 그동안 가입한 사람들은 환영 메일을 못 받았는데, 정작 … 더 읽기