Showing Posts From

Redis

Redis TTL 설정 실수로 생긴 버그의 추억

Redis TTL 설정 실수로 생긴 버그의 추억

Redis TTL 설정 실수로 생긴 버그의 추억 시작은 평범했다 월요일 오전 10시. 커피 두 잔 마시고 PR 하나 머지했다. 기분 좋았다. 점심 먹기 전에 간단한 캐시 로직 하나 추가하는 작업. "30분이면 되겠네." Redis에 상품 정보 캐싱하는 거였다. DB 부하 줄이려고. 간단하다. SET 하고 EXPIRE 설정하면 끝. TTL은 1시간으로. 자주 바뀌는 데이터 아니니까. 코드 짰다. 로컬에서 돌려봤다. 잘 된다. PR 올렸다. 팀장이 Approve. 배포했다. 11시 반. 점심 먹으러 갔다. 김치찌개 먹으면서 슬랙 확인했다. 조용하다. 좋다.3일이 지났다 수요일 오후 4시. 평화로웠다. 다음 스프린트 계획 세우는 중이었다. 슬랙에 빨간 점. CS팀에서 멘션. "개발님, 상품 가격이 업데이트가 안 되는 것 같은데요?" 심장이 쿵. 했다. 아니 근데 가격? 우리 가격 업데이트 로직 건드린 적 없는데. "언제부터요?" "월요일부터 고객 신고가 몇 건 들어왔어요. 근데 저희가 확인해보니까 관리자 페이지에선 가격이 바뀌는데, 실제 상품 페이지에선 안 바뀌더라고요." 월요일. 월요일. 내가 월요일에 뭐 했지. Redis. 캐시. 아. 씨발.코드를 열었다 손이 떨렸다. 파일 열었다. 내가 짠 코드. redisTemplate.opsForValue().set(key, productInfo); redisTemplate.expire(key, 3600, TimeUnit.SECONDS);3600초. 1시간. 맞는데? 아니 잠깐. 상품 정보 업데이트하는 로직 봤다. 기존 코드. DB만 업데이트한다. 캐시 무효화 로직이 없다. 당연하다. 원래 캐시가 없었으니까. 내가 캐시를 추가했으면. 업데이트 로직에도. 캐시 삭제를 추가했어야 했다. 근데 난 추가 안 했다. 상품 정보는 한 번 캐싱되면. 1시간 동안 죽어도 안 바뀐다. 아니 1시간도 아니다. 누군가 그 상품을 조회할 때마다. 캐시 히트. TTL 다시 1시간. 월요일 오전에 캐싱된 데이터가. 수요일 오후까지 살아있었다. 72시간. 고객들은 3일 동안. 구 가격을 봤다.패닉 팀 단톡에 쳤다. "긴급. 상품 캐시 버그. 지금 핫픽스 준비합니다." 팀장이 전화했다. "뭔데?" 설명했다. 3분 동안. 팀장이 한숨 쉬는 소리 들렸다. "일단 캐시 전부 날려. 그리고 핫픽스 PR 올려." Redis CLI 켰다. FLUSHDB. 엔터. 캐시 전부 날아갔다. DB 쿼리가 폭증할 거다. 근데 어쩔 수 없다. 모니터링 대시보드 봤다. DB CPU 사용률 급등. 60%. 70%. 80%. 멈췄다. 85%에서. 다행이다. 죽진 않았다. 코드 수정했다. 상품 업데이트 로직에 캐시 삭제 추가. 아니 더 정확하게는 캐시 키 패턴으로 관련된 거 전부 삭제. 상품 상세, 리스트, 카테고리별. 다. 30분 걸렸다. PR 올렸다. 팀장 리뷰. 1분 만에 Approve. "빨리 배포해." 배포했다. 6시 10분. 사후 처리 배포 끝났다. 테스트했다. 가격 업데이트. 캐시 삭제. 잘 된다. CS팀에 보고했다. "수정 완료했습니다. 현재는 정상 작동합니다." "고객들한테 어떻게 안내드리죠?" 어떻게. 어떻게 하긴. "죄송합니다. 시스템 오류로 가격 정보가 지연 반영되었습니다. 현재는 정상화되었습니다." 이렇게 쓰는 거지. 근데 문제가 하나 더 있었다. 구 가격으로 주문한 사람들. 있을까? 봤다. 있었다. 12건. 신규 가격보다 저렴한 구 가격으로. 회계팀 전화 왔다. "이거 손실 처리 어떻게 해요?" "...제가 결정할 사항은 아닌 것 같은데요." "개발팀장님이랑 통화 연결해드릴게요." 통화 30분. 결론. 12건은 그냥 구 가격으로 인정. 손실 처리. 약 24만원. 내 잘못으로 회사가 24만원 날렸다. 퇴근은 9시였다. 회고 미팅 다음날 오전. 팀 회고. 나 혼자 하는 반성문 낭독회. "캐시 추가할 때 쓰기 경로를 체크 안 했습니다." "TTL 설정만 하면 된다고 생각했습니다." "캐시 무효화 전략을 설계 안 했습니다." 팀장이 말했다. "이번 건으로 배운 게 뭐야?" "캐시는 읽기만 빠르게 하는 게 아니라, 쓰기도 고려해야 한다는 거요." "TTL은 만능이 아니다." "캐시 도입하려면 설계 단계부터 쓰기 경로 파악해야 한다." 맞는 말만 했다. 근데 의미 없다. 이미 사고 쳤는데. 후배가 물었다. "그럼 앞으로 캐시 추가할 땐 어떻게 해야 해요?" "체크리스트 만들어야겠지. 읽기 경로 분석. 쓰기 경로 분석. TTL 전략. 무효화 전략. 모니터링. 롤백 시나리오." 팀장이 끄덕였다. "그거 문서로 만들어. 다음 주까지." 일이 늘었다. 그 후 체크리스트 만들었다. 캐시 도입 가이드. 5페이지짜리. 예시 코드 포함. Confluence에 올렸다. 후배들이 읽었다. "오 좋은데요?" 댓글 달았다. 근데 솔직히. 이거 안 만들어도. 다들 내 실수 알고 있으니까. 안 그러려고 할 거다. 한 달 지났다. 다른 팀에서 나한테 물었다. "Redis 캐시 도입하려는데, 조언 좀 해주실 수 있어요?" "제가요? 제가 조언을?" "네, 개발님이 관련 문서 작성하셨다고 들었어요." 아. 내 삽질이 레퍼런스가 됐다. 이상한 기분이었다. 좋은 건지 나쁜 건지. 남은 것들 지금도 상품 업데이트 로직 볼 때마다. 캐시 삭제 코드 확인한다. 있나 없나. 두 번 본다. Redis 모니터링 대시보드. 즐겨찾기에 있다. 하루에 세 번 연다. 캐시 히트율. TTL 분포. Key 개수. 다 본다. 후배가 캐시 관련 PR 올리면. 꼼꼼히 본다. "쓰기 경로는 확인했어?" 댓글 단다. 매번. 팀장이 말했다. "네가 너무 신경 쓰는 거 아니야?" "한 번 당했으니까요." "실수는 누구나 해. 중요한 건 반복 안 하는 거고." 맞는 말이다. 근데 나한테는. 그 3일이. 72시간이. 아직도 생생하다. 고객들이 틀린 가격 보고 있을 때. 나는 커피 마시면서 다음 작업 계획 세우고 있었다. 그게 제일 씁쓸하다. 결국 버그는 고쳤다. 문서는 만들었다. 프로세스는 개선했다. 근데 그보다. 배운 건. "간단해 보이는 것도 간단하지 않다." 캐시 하나 추가하는 거. 30분 작업. 그렇게 생각했다. 근데 시스템은 연결돼 있다. 읽기만 빠르게 하면 끝이 아니다. 쓰기도 있다. 업데이트도 있다. 삭제도 있다. TTL 설정했다고 끝이 아니다. 데이터는 변한다. 변하면 캐시도 바뀌어야 한다. 안 바뀌면 거짓말을 하는 거다. 고객한테. 7년 차다. 이런 실수는 안 해야 한다. 근데 했다. 그래도. 다음엔 안 한다. 이번엔 확실히 배웠으니까. Redis 띄울 때마다. 생각한다. "TTL은 보험이지 전략이 아니다." 쓰기 경로부터 확인한다. 무효화 로직부터 짠다. 그 다음에 캐시를 추가한다. 좀 느려졌다. 예전보다. 신중해졌다. 그게 나쁘진 않다.실수는 또 할 거다. 근데 같은 실수는 아닐 거다. 그걸로 됐다.

Redis 캐시 전략으로 밤새 디버깅한 날

Redis 캐시 전략으로 밤새 디버깅한 날

Redis 캐시 전략으로 밤새 디버깅한 날 그건 화요일 저녁 6시의 일이었다 일반적으로 배포는 금요일 오후 3시에 한다. 근데 이번엔 달랐다. 기획팀이 수요일 자정까지 급하게 처리해야 할 기능이 있다고 했고, 우리 팀은 당연히 전쟁 준비를 했다. 사실 나는 지난주부터 Redis 캐시 도입을 준비하고 있었다. 회사 서비스가 요즘 느리다는 컴플레인이 계속 들어오고 있었다. 사용자가 늘어나면서 데이터베이스 쿼리가 답답해 지기 시작한 거다. 데이터는 자주 바뀌지 않는데 매번 디비에서 갱신하고 있었으니까 말이다. 그래서 내가 제안했다. "Redis 캐시 레이어 추가하면 어때요?" 팀장은 "좋지, 김개발이가 해봐" 라고 했다. 요즘 팀장은 항상 그렇다. 뭔가 새로운 기술이 나오면 나한테 건넨다. 직책도 없으면서. 아무튼. 나는 지난주 내내 Redis 연결, TTL 설정, 캐시 무효화 로직을 짰다. 로컬에서는 완벽했다. DB 응답 시간이 200ms에서 20ms로 뚝 떨어졌다. 이거야말로 진짜 최적화다. 나는 자신감에 차 있었다. 화요일 저녁 6시, 배포가 시작되었다. 테스트 서버부터. 로그를 봤다. 모든 게 정상이었다. 프로덕션 배포. 여전히 정상. "좋네. 이제 집 갈까" 라고 생각했다. 아내한테 카톡했다. "늦게 먹을 거 같아" 라고. 아내는 "알겠어 조심해" 라고 했다. 근데 밤 10시. 슬랙에서 핑이 울렸다. 안 좋은 예감이 들었다. 모니터가 시커먼 밤 10시의 공포 [Alert] User ID 12345 - 잘못된 데이터 제공 반복 이 메시지가 모니터에 떴을 때, 나는 한숨을 쉬면서 생각했다. "또 시작이네." 기획팀에서 온 리포트였다. 몇몇 사용자가 캐시된 데이터가 맞지 않는다는 피드백을 주었다는 거다. 사용자 A의 정보를 조회할 때 사용자 B의 정보가 나온다는 식. 이건 내가 가장 싫어하는 케이스였다. "대체 뭐가 문제지?" 하고 한참 고민해야 하는 상황 말이다. 로컬에선 진짜 문제가 없었는데. 나는 일단 프로덕션 서버의 로그부터 뒤지기 시작했다. CloudWatch 대시보드를 열었다. 레이턴시는 확실히 내려갔다. 그건 좋은 신호였다. 그럼 캐시 정책이 작동한다는 뜻인데... 오류 로그를 보니 패턴이 보였다. 2024-01-17 22:10:45 - Cache HIT for user:12345 - result from Redis 2024-01-17 22:10:50 - user:12345 updated in DB 2024-01-17 22:11:02 - Cache HIT for user:12345 - result from Redis (STALE DATA)아. 이거다. 내가 짠 캐시 무효화 로직이 DB 업데이트를 감지하지 못하고 있었다.나는 잠깐, 내가 뭘 놨더라 하면서 코드를 다시 봤다. 캐시 무효화 로직은 이렇게 짜여 있었다: @Transactional public void updateUser(User user) { userRepository.save(user); redisTemplate.delete("user:" + user.getId()); }당연하지. 이렇게 간단할 리가 없다. 문제는 우리 시스템이 마이크로서비스 아키텍처로 되어 있다는 거였다. 사용자 정보는 User Service에서 관리하지만, 프로필 이미지 같은 건 Content Service에서 관리했다. 그리고 두 서비스는 다른 데이터베이스를 쓰고 있었다. 즉, 내 무효화 로직은 User Service의 캐시만 날리고 있었다. Content Service에서 업데이트된 데이터는 Redis에 남아있었던 거다. "아 그거요" 라고 중얼거렸다. 이제 문제를 알았으니까 고칠 수 있다. 근데 문제는... 10시가 넘었다. 새벽 3시, 커피가 식어가는 시간 나는 일단 핫픽스를 생각했다. Redis의 모든 관련 캐시를 무조건 날려버리는 방식으로. 그럼 성능이 또 떨어질 텐데, 일단 버그는 없을 거다. @Transactional public void updateUser(User user) { userRepository.save(user); // 일단 관련된 모든 캐시 다 날려버림 redisTemplate.delete("user:" + user.getId()); redisTemplate.delete("profile:" + user.getId()); redisTemplate.delete("content:" + user.getId()); // ... 기타등등 }팀 슬랙에 메시지를 띄웠다. "일단 핫픽스 할게요. 내일 제대로 리팩토링합시다." 배포는 밤 11시에 끝났다. 버그는 사라졌다. 근데 나는 계속 앉아 있었다. 뭔가 불안했다. "이게 진짜 완벽한 해결책인가?" 밤 12시, 커피를 또 마셨다. 셋째 잔. 나는 비즈니스 로직을 다시 생각했다. 지금 우리 시스템은 여러 마이크로서비스가 동일한 사용자 정보를 캐시하고 있었다. 어느 한 서비스에서 업데이트되면, 다른 서비스도 알아야 한다. 근데 어떻게? 메시지 큐? Kafka? 그건 복잡할 것 같은데. 아니면 그냥 캐시 TTL을 짧게 가져가? 그럼 매번 다 조회해야 하는데 결국 성능 개선이 없는 거잖아. 나는 다시 생각했다. "근본적인 문제가 뭐지?" 아. 여러 서비스가 같은 데이터를 각각 캐시하고 있다는 게 문제다. 그럼 캐시를 중앙화해야 한다. 모든 데이터 조회는 하나의 서비스를 거쳐서, 그 서비스가 Redis를 관리하도록. 밤 2시. 나는 구조를 다시 그렸다. 손으로 노트에다가. 종이와 펜이 제일 빠르다. User Service → [Redis Cache Manager Service] → Redis Content Service ↓ Profile Service ↓이렇게 하면 어떻게 되지? 음... 네트워크 호출이 하나 더 늘어난다. 하지만 모든 캐시가 일관성 있게 유지된다. 밤 3시, 슬랙에 긴 스레드를 남겼다.팀장님, 내일 아침에 얘기하고 싶은데, 현재 캐시 설계에 근본적인 문제가 있는 것 같습니다. 마이크로서비스 간 캐시 무효화가 제대로 안 되고 있어요. 긴급 핫픽스는 했는데, 장기적으로는 아래처럼 개선이 필요할 것 같습니다:Redis Cache Manager 서비스 신설 모든 캐시 쓰기/삭제 요청을 여기로 집중 TTL 정책 중앙화 ...그리고 나는 커피를 마셨다. 넷째 잔. (셋째가 아니라) 컴퓨터는 여전히 켜져 있었고, 모니터에는 코드가 떠 있었다. 나는 다시 생각했다. 혹시 내가 놓친 게 있나? 아. 하나 더 있었다. Cache Invalidation Pattern이었다. 우리는 지금 TTL-based invalidation을 쓰고 있었는데, 이건 보장되지 않는다. 업데이트가 되고 나서 TTL이 끝날 때까지의 그 시간 동안, 잘못된 데이터가 나간다는 뜻이다. 그럼 Event-driven invalidation은? 업데이트가 발생하면 즉시 이벤트를 발행하고, 그걸 듣는 모든 캐시 레이어가 반응한다. Kafka 같은 메시지 큐를 쓰면 돼. 근데 이건 더 복잡하다. 그리고 비용도 는다. 나는 한숨을 쉬었다. 개발이 정말 쉬워 보이기만 하지, 실제로는 이렇게 트레이드오프를 계속 해야 한다. 밤 4시, 나는 침대로 갔다. 결국 아내 옆에 누웠다. 아내는 자고 있었다. 새벽 5시, 깨어나면서 든 생각 꿈을 봤는데, 뭔지는 모르겠다. 그냥 코드를 계속 보고 있는 느낌이었다. 눈을 떴을 때 시간이 5시였다. 휴... 밤 11시에서 4시까지 거의 5시간을 일한 거다. 그런데 뭔가 좋은 느낌이 들었다. 문제를 찾았으니까. 그리고 해결 방법도 여러 개 생각했으니까. 근데 동시에 깨달았다. 이 문제는 사실 처음부터 예측할 수 있었어야 했다는 거다. 마이크로서비스 아키텍처에서 캐시를 도입할 때는 항상 무효화 전략을 먼저 생각해야 한다. 성능 개선은 나중이다. 나는 다시 한 번 생각했다. "내가 면접 준비를 해야 하나? 다른 회사에서는 이런 실수가 안 나나?" 아니다. 어디든 있다. 다만 시간에 여유가 있는 회사는 이런 문제를 천천히 해결한다. 우리처럼 급하게 배포하는 회사에서는 항상 이런 문제가 터진다. 아내가 깨어났다. "뭐 해?" "일." "밤에?" "응. 어제 배포에서 버그가 났어." 아내는 한숨을 쉬었다. "내일 출근 안 하면 되지?" "그럼 누가 고칠?" 아내는 다시 누웠다. 우리 부부는 서로의 일에 대해 깊이 있게 물어보지 않는다. 그냥 있다는 걸 알고 있을 뿐이다.아침 8시, 회의실에서의 대화 나는 2시간 반을 더 잤다. 당연히 피곤했다. 사무실 도착 시간은 오전 8시 30분. 평소보다 30분 빨랐다. 팀장에게 내가 쓴 스레드를 보여주고 싶었다. 팀장은 커피를 마시고 있었다. "팀장, 어제 그 버그 이야기 들으셨어요?" "응. 잘 고쳤네. 고마워." "네, 근데 이건 임시방편이고요. 우리 캐시 아키텍처에 근본적인 문제가..." 팀장이 손을 들었다. "알겠어. 문서화해서 보여줘. 그 다음에 우리 얘기할 거 있고." 나는 알았다. 진짜 얘기는 나중에 나온다는 뜻이다. 퀵스탠드업 미팅이 끝나고, 팀장이 나를 불렀다. "어제 밤을 새웠네." "...네. 문제를 찾아야 해서." "잘 했어. 근데 이런 식으로 일하면 안 돼. 네가 팀에서 가장 시니어인데, 뭔가 도움이 필요하면 후배들한테 물어봐. 너 혼자 다 해야 하는 건 아니야." 나는 대답을 못 했다. 팀장 말이 맞는데, 내 일의 성격상 그게 쉽지 않거든. 후배들한테 "이거 이상한데 뭐 같아?" 라고 물어보면, 결국 내가 다시 다 설명해야 한다. 그럼 시간이 더 든다. 하지만 입으로는 "알겠습니다" 라고만 했다. 이제는 무엇을 배울 것인가 지금 오후 2시다. 나는 캐시 무효화 로직을 다시 작성하고 있다. 이번엔 다르다. Event-driven 방식은 아니지만, 최소한 여러 서비스 간의 캐시 일관성을 보장하도록 했다. 간단한 로직이다: // User Service에서 업데이트 발생 @Transactional public void updateUser(User user) { userRepository.save(user); // 자신의 캐시만 날리지 말고, 다른 서비스에도 알려주기 cacheInvalidationService.invalidateUserCache(user.getId()); }// CacheInvalidationService public void invalidateUserCache(Long userId) { // 모든 관련 캐시 패턴을 한 곳에서 관리 List<String> patterns = getCachePatterns(userId); redisTemplate.delete(patterns); // 나중에는 메시지 큐로 다른 서비스에도 전파 }사실 이것도 완벽한 해결책은 아니다. 하지만 나는 이제 알았다. 완벽한 해결책은 없다. 있는 건 상황에 맞는 적절한 트레이드오프일 뿐이다. 귀가 길에, 나는 커피숍에 들어갔다. 커피 한 잔 더. 다섯째 잔. 바리스타가 내 얼굴을 봤다. 아마도 피곤해 보였을 거다. "힘든 하루였어요?" "네. 밤을 새웠어요." "또 배포 날씸?" "아뇨. 버그 때문에요." 바리스타는 웃었다. "개발자는 항상 뭔가 때문에 밤을 새네요." 나도 웃었다. "그래서 정신이 올빠르지." 집에 도착했을 때, 아내가 있었다. 보통 이 시간엔 없는데. "조금 일찍 나왔어?" "응. 넌 뭐해? 피곤해 보여." 나는 아내에게 어제 밤 일을 설명했다. Redis, 캐시 무효화, 마이크로서비스... 아내는 대부분 못 알아듣겠지만, 그냥 들어주는 것만으로도 충분했다. "고생했네. 밥 먹고 쉬자. 내가 뭐 사 올까?" "김치찌개." "또?" "점심으로 먹었어도 저녁으로 먹고 싶어." 아내는 한숨을 쉬고 나갔다. 나는 소파에 누웠다. 모니터는 꺼졌다. 슬랙도 무음. 일단 이 순간은 나의 것이다. 이제 깨달았다. 완벽한 코드를 짜려다 밤을 새는 것보다, 괜찮은 코드를 짜고 시간을 자기는 게 훨씬 나낫다는 걸. 적어도 내 정신 건강을 위해선. 내일부터는 어떻게 할까? 유사한 문제가 또 나오면? 나는 아마 또 밤을 새우겠지. 왜냐하면 내가 팀의 일인당 전문가니까. 근데 적어도 이번엔 알았다. 혼자 다 하려고 하지 말고, 처음부터 아키텍처를 제대로 생각해야 한다는 걸. 그리고 또 하나. 우리 팀은 이제 Redis 캐시 가이드 문서가 필요하다. 마이크로서비스 환경에서의 캐시 무효화 패턴, TTL 설정 기준, 모니터링 방법 등등. 나는 내일부터 그걸 작성해야 한다. 그럼 또 다른 후배가 같은 실수를 하지 않겠지. 아내가 돌아왔다. 김치찌개 냄새가 풍겼다. "먹자."결국 버그는 나를 성장시켰고, 밤샘은 나를 피로하게 했지만, 그 둘의 조합이 내를 개발자로 만들었다.