에러 메시지 'null pointer exception': 다시는 싫다

에러 메시지 'null pointer exception': 다시는 싫다

에러 메시지 'null pointer exception': 다시는 싫다 오전 10시 32분 커피 마시다가 슬랙 알림이 울렸다. QA팀이다. "개발님, 회원가입 안 돼요." 손이 떨렸다. 어제 배포한 거다. 30분 전에 올라갔다. 벌써 터졌다. 로그를 켰다. 빨간 글씨가 가득하다. java.lang.NullPointerException at com.company.user.UserService.register(UserService.java:247)또 null이다. 247번째 줄이다. 코드를 열었다. String email = userDto.getEmail().toLowerCase();getEmail()이 null을 뱉었다. 당연히 toLowerCase()에서 터진다. "아..." 한숨이 나왔다. 어제 급하게 짠 코드다. null 체크를 안 했다. 3년 전 신입도 아니고, 7년 차가 이러면 안 되는데. 핫픽스 브랜치를 땄다. 손가락이 자동으로 움직인다. if (userDto.getEmail() == null) { throw new IllegalArgumentException("이메일은 필수입니다"); }5분 컷이다. 커밋, 푸시, PR, 머지. 배포는 10분. QA팀에 "해결했습니다" 보냈다. "감사합니다^^" 답장이 왔다. 고맙진 않다. 부끄럽다.점심시간, 김치찌개집 사장님이 물었다. "오늘 표정 안 좋네요?" "일이 좀..." "개발자는 힘들어요. 우리 아들도 그래요." 김치찌개를 떴다. 뜨거웠다. 혀가 데었다. null pointer exception. NPE. 개발자라면 누구나 안다. 제일 흔한 에러다. 제일 짜증 나는 에러다. 왜 짜증 나냐면, 내 잘못이기 때문이다. 문법 에러는 컴파일러가 잡아준다. 네트워크 에러는 인프라 탓을 할 수 있다. DB 에러는... 뭐, DBA한테 물어보면 된다. 근데 NPE는 다르다. 내가 null을 만들었다. 내가 null 체크를 안 했다. 내가 실수했다. 그래서 더 화난다. 나한테. 후배 민수가 작년에 물었다. "형, null 체크 매번 하는 게 맞아요? 코드가 너무 길어져요." "해야지. 안 하면 터져." "Optional 쓰면 안 돼요?" "그것도 결국 체크는 해야 해. 다른 방식일 뿐이지." 민수는 고개를 끄덕였다. 그러고 2주 뒤에 NPE로 배포 롤백했다. 내가 리뷰할 때 못 봤다. 둘 다 잘못이다.오후 3시, 회의실 장애 회고다. 팀장이 물었다. "원인이 뭐였어요?" "null 체크 누락이었습니다." "왜 누락됐죠?" 대답이 안 나왔다. '급했어요'라고 할 순 없다. 그건 핑계다. "제가 놓쳤습니다." 팀장이 한숨을 쉬었다. "다음부턴 조심하죠." 회의가 끝났다. 자리로 돌아왔다. 모니터를 봤다. 내 코드에 null 체크가 몇 개나 있을까. 세어봤다. 파일 하나에 12개다. if (user == null) return; if (user.getName() == null) return; if (request == null) throw new Exception(); if (request.getBody() == null) throw new Exception();지겹다. 정말 지겹다. 코틀린으로 짜는 팀이 부럽다. Null Safety가 언어 레벨에서 된다. 자바는 안 된다. Optional이 있긴 한데, 레거시 코드는 다 null 투성이다. "리팩토링 해야 하는데..." 혼잣말이 나왔다. 옆자리 수진이 물었다. "뭘요?" "아니, 아무것도." 리팩토링할 시간이 어딨나. 기획자는 매주 새 기능을 요구한다. PM은 일정을 당긴다. 기술 부채는 쌓인다. 그래서 또 null 체크를 빼먹는다. 그래서 또 터진다. 악순환이다.퇴근 직전, 6시 10분 민수가 PR을 올렸다. 리뷰 요청이 왔다. 코드를 열었다. 200줄짜리 서비스 로직이다. 쭉 읽었다. 50번째 줄에서 멈췄다. Payment payment = paymentRepository.findById(paymentId); payment.setStatus("COMPLETE");findById()는 null을 리턴할 수 있다. 민수는 체크 안 했다. 코멘트를 달았다. "findById null 체크 필요합니다." 5분 뒤에 민수가 수정했다. Payment payment = paymentRepository.findById(paymentId); if (payment == null) { throw new NotFoundException("결제 정보가 없습니다"); } payment.setStatus("COMPLETE");Approve 눌렀다. 그러고 내 코드를 열었다. 어제 짠 거다. 다시 읽었다. 3군데에서 null 체크가 없었다. 아직 배포 안 된 코드다. 고쳤다. "휴..." 7년 차다. 아직도 이런다. 언제까지 이럴 건가. 검색창에 쳤다. "how to avoid null pointer exception java" 스택오버플로우가 떴다. 똑같은 질문이 1만 개다. 똑같은 대답이다.null 체크 하세요 Optional 쓰세요 애초에 null 반환하지 마세요다 안다. 다 해봤다. 그래도 터진다. 왜냐면 사람은 실수하니까. 피곤하면 놓친다. 급하면 까먹는다. 리뷰어도 사람이라 못 본다. 결국 답은 하나다. 조심하는 수밖에. 저녁 9시, 집 아내가 물었다. "오늘 힘들었어?" "응. 장애 났었어." "또? 지난주에도 그랬잖아." "응... 내 실수야." 아내가 맥주를 건넸다. 땄다. 마셨다. NPE 이야기를 했다. 아내는 디자이너라 잘 모른다. 그래도 들어줬다. "그게 그렇게 자주 나는 거야?" "응. 진짜 많이 나." "그럼 자동으로 체크하는 프로그램 같은 거 없어?" "있어. SonarQube, SpotBugs... 다 있어. 근데 완벽하진 않아. 못 잡는 케이스도 많고." "흠..." 아내는 더 안 물었다. 나도 더 안 말했다. 맥주를 다 마셨다. 냉장고를 열었다. 한 캔 더 꺼냈다. 노트북을 켰다. 회사 코드를 열었다. 출퇴근 기록부는 6시 퇴근이다. 근데 지금 코드를 본다. UserService.java를 열었다. 쭉 읽었다. null 체크를 추가했다. 5군데. 커밋 메시지를 썼다. "add null checks for safety" 푸시했다. PR 올렸다. 내일 아침에 머지하면 된다. 시계를 봤다. 10시 반이다. "또 일해?" 아내 목소리다. 돌아봤다. 문 앞에 서 있다. "응... 좀." "그만 해. 내일 하면 안 돼?" "응. 내일 해야지." 노트북을 덮었다. 근데 머릿속은 아직 코드다. 247번째 줄. getEmail(). toLowerCase(). null. 또 떠올랐다. 자기 전까지 계속 떠오를 것이다. 그래도 해야 한다 NPE는 없앨 수 없다. 완벽한 코드는 없다. 사람은 실수한다. 근데 줄일 순 있다. null 체크를 습관화한다. PR 리뷰 때 집중한다. 단위 테스트를 꼼꼼히 짠다. 로그를 잘 남긴다. 그렇게 하면 조금 덜 터진다. 조금 덜 야근한다. 조금 덜 부끄럽다. 7년 차 개발자의 결론이다. "null은 적이다. 근데 없앨 순 없다. 그러니까 경계해야 한다." 이게 끝이다. 내일도 출근한다. 내일도 코드 짠다. 내일도 null 체크한다. 그러다 보면 또 놓친다. 또 터진다. 그럼 또 고친다. 또 배운다. 이게 개발자다.오늘도 null 체크 30개 추가. 내일은 40개.

배포 전 마지막 체크리스트: 놓친 게 뭐지?

배포 전 마지막 체크리스트: 놓친 게 뭐지?

오후 4시의 슬랙 메시지 "오늘 배포 맞죠?" 기획자 메시지다. 알고 있다. 3일 전부터 알고 있었다. 손이 떨린다. 커피를 너무 마신 건지, 아니면 그냥 배포 전이라서 그런 건지 모르겠다.체크리스트를 연다. 노션에 만들어둔 거다. 지난번 배포 때 새벽 2시에 롤백하면서 "다음엔 이런 일 없게 하자"고 다짐하며 만든 것. 근데 이게 문제다. 체크리스트를 볼 때마다 항목이 늘어난다. 처음엔 10개였다. 지금은 37개다. 체크리스트라는 이름의 불안 데이터베이스 백업. 체크. 스테이징 환경 테스트. 체크. API 문서 업데이트. 체크. 슬랙 배포 공지. 체크. 여기까진 기본이다. 문제는 여기서부터다. "혹시 인덱스 추가한 거 확인했나?" 확인했다. 근데 다시 확인한다. EXPLAIN 결과를 또 본다. 쿼리 플랜이 이상하진 않다. 근데 프로덕션 데이터로도 테스트했나? 스테이징이랑 데이터 양이 다른데? 다시 확인한다."Redis 캐시 워밍업은?" 아. 맞다. 배포하고 첫 요청 때 DB 커넥션 폭증하는 거. 지난번에 그거 때문에 30초간 타임아웃 났었지. 체크리스트에 추가한다. 38개. "로그 레벨 확인했나?" 디버그 모드로 돌린 적 있었나? 없다. 근데 확인한다. 혹시 모르니까. "헬스체크 엔드포인트는?" 당연히 있다. 근데 실제로 동작하나? 5분 전에 확인했는데 또 확인한다. 이게 무한루프다. 후배의 PR "형, PR 올렸어요." 오후 5시. 배포 3시간 전. 제목을 본다. "hotfix: 결제 금액 소수점 처리". 소수점? 결제에? 심장이 뛴다. "이거 급한 거야?" "아뇨, 발견해서 고쳤어요." 발견? 언제? 어디서? 코드를 본다. Math.round() 하나 추가한 거다. 테스트 코드도 있다. 근데 불안하다. 결제는 민감한 부분이다. 이거 지금 넣어야 하나? 다음 배포로 미뤄야 하나? "스테이징에서 테스트했어?" "네." "얼마로?" "1000원이요." 1000원. 실제 결제는 십만원대도 있는데."십만원으로도 해봐." "네." 기다린다. 10분. 답이 온다. "됐어요." 됐다고? 뭐가? 테스트가 통과했다는 건가, 아니면 문제없다는 건가? 전화한다. 직접 확인하는 게 빠르다. 통화 15분. 괜찮은 것 같다. 근데 '것 같다'가 문제다. "일단 빼자. 다음 배포 때 넣어." "네..." 후배 목소리가 풀이 죽는다. 미안하다. 근데 배포는 조심해야 한다. 체크리스트에 항목 추가. "긴급 수정사항 재확인". 39개. 오후 6시의 회의 "배포 전 최종 확인 회의 시작하겠습니다." 누가 시작하자고 했나. 기억이 안 난다. 근데 매번 한다. 참석자: 나, 후배 둘, 기획자, QA, DevOps 형. 기획자가 묻는다. "이번 배포 범위가 어떻게 되죠?" 말한다. 회원가입 플로우 개선, 상품 상세페이지 성능 개선, 관리자 대시보드 차트 추가. "회원가입 플로우에서 소셜로그인도 테스트했죠?" 했다. 구글, 카카오, 네이버. 전부 했다. "탈퇴한 회원이 재가입하는 케이스는?" 아. 안 했다. "확인해볼게요." 회의 중간에 노트북 연다. 스테이징 접속. 테스트 계정 하나 탈퇴시킨다. 재가입 시도. 에러. "unique 제약조건 위반". 이메일 컬럼에 unique 인덱스가 걸려있는데, soft delete 방식이라 탈퇴해도 데이터가 남는다. "이거 고쳐야겠는데요." 기획자가 묻는다. "시간 얼마나 걸려요?" 모른다. 30분? 1시간? DB 마이그레이션까지 하면 2시간? "배포 연기할까요?" 연기. 그 단어만 들어도 한숨 나온다. 기획자도, 대표님도, 전부 오늘 배포 알고 있다. "아니요. 고치겠습니다." 회의 끝. 오후 6시 40분. 긴급 수정 손이 빠르게 움직인다. 이럴 때는 생각하지 말고 코딩한다. deleted_at 컬럼 추가. unique 인덱스를 partial index로 변경. WHERE deleted_at IS NULL. MySQL은 partial index를 지원하지 않는다. 망했다. 다른 방법. unique 제약조건 제거하고, 애플리케이션 레벨에서 체크. 안 된다. 동시성 이슈. 같은 이메일로 동시 가입하면? 세 번째 방법. email + deleted_at 복합 unique. 이것도 안 된다. deleted_at이 NULL이면 중복 허용된다. 네 번째 방법. 탈퇴할 때 이메일을 수정한다. "deleted_{timestamp}_{original_email}". 이건 된다. 근데 더럽다. 정말 더럽다. 시간이 없다. 일단 이걸로 간다. 코드 수정. 10분. 테스트 코드 작성. 15분. 스테이징 배포. 5분. 테스트. 10분. 동작한다. 더럽지만 동작한다. PR 올린다. 제목: "hotfix: 탈퇴 회원 재가입 이슈 수정". DevOps 형한테 메시지. "긴급 수정 하나 더 들어갑니다." "알았어. 근데 배포 시간 좀 늦어질 것 같은데." "얼마나요?" "30분?" 오후 7시 30분. 원래 배포 시간. 지금 8시로 밀린다. 체크리스트를 다시 본다. 39개 항목. 하나하나 다시 확인한다. 배포 30분 전 체크리스트 완료. 전부 체크했다. 근데 불안하다. 뭔가 놓친 것 같다. 모니터링 대시보드를 연다. 그라파나. 지난 일주일 트래픽 패턴을 본다. 평일 저녁 8시. 동접 2000명. 초당 요청 150개. DB 커넥션 풀 사용률 60%. 괜찮다. 여유롭다. 근데 혹시 모른다. 배포하면 캐시가 날아간다. 첫 요청들이 몰린다. DB 커넥션이 순간적으로 튄다. "혹시 캐시 워밍업 스크립트 준비했나?" 준비 안 했다. 체크리스트엔 있는데 실제론 안 했다. 지금 만들까? 30분 안에? 불가능하다. "캐시 없이 버틸 수 있나?" 계산한다. 캐시 미스율 100%라고 가정. 모든 요청이 DB로. 초당 150개. DB는 초당 500개까지 처리 가능하다고 했다. AWS 스펙 문서에서 봤다. 근데 그건 단순 SELECT 기준이다. JOIN 많은 쿼리는? 집계 쿼리는? 모른다. "일단 가자." 결정했다. 캐시 워밍업 없이 간다. 문제 생기면 그때 대응한다. 체크리스트에 항목 하나 더 추가. "배포 후 캐시 모니터링". 40개. 배포 시작 오후 8시. DevOps 형이 메시지 올린다. "배포 시작합니다." 슬랙 배포 채널에 공지 올라간다. "@channel 배포 시작. 약 15분 소요 예정." 그라파나를 본다. 초당 요청 수. DB 커넥션. CPU 사용률. 메모리. 디스크 IO. 전부 정상이다. 아직은. Jenkins 로그가 흐른다. [INFO] Building application... [INFO] Running tests... [INFO] All tests passed. [INFO] Creating Docker image... [INFO] Pushing to registry...손에 땀이 난다. 마우스가 미끄럽다. 후배가 옆에서 묻는다. "형, 괜찮을까요?" "모르지." 솔직한 대답이다. 배포는 매번 도박이다. 아무리 체크해도 변수는 있다. [INFO] Deploying to production... [INFO] Updating service... [INFO] Waiting for health check...헬스체크. 30초 걸린다. 가장 긴 30초다. [INFO] Health check passed. [INFO] Deployment completed.완료. 그라파나를 본다. 트래픽이 들어온다. 초당 요청 수가 튄다. 150에서 200으로. DB 커넥션. 60%에서 85%로. 응답 시간. 평균 200ms에서 800ms로. 에러율. 0%에서 0.3%로. 심장이 뛴다. 배포 후 10분 슬랙에 메시지가 터진다. "로그인이 느려요." "상품 페이지 로딩 중..." "관리자 페이지 접속 안 돼요." 패닉이다. 응답 시간을 본다. 계속 오른다. 800ms, 1200ms, 1500ms. 에러율. 0.3%, 0.5%, 1.2%. 뭐가 문제지? 로그를 연다. Elasticsearch. 에러 로그를 검색한다. java.sql.SQLException: Timeout: Pool empty. Unable to fetch a connection in 30 seconds.커넥션 풀. 비었다. 예상했던 거다. 캐시가 없어서 DB 쿼리가 몰렸다. "커넥션 풀 늘려야겠어요." DevOps 형한테 메시지. "DB 커넥션 풀 100에서 200으로 늘려주세요." "지금요?" "네, 지금요." 설정 변경. 재시작. 5분. 다시 본다. 응답 시간 떨어진다. 1500ms에서 1000ms로. 800ms. 600ms. 에러율. 1.2%에서 0.8%. 0.5%. 0.2%. 안정화된다. 슬랙 메시지. "다시 괜찮아진 것 같아요." 한숨 쉰다. 등에 땀이 찬다. 배포 후 1시간 오후 9시. 모니터링 지표 전부 정상이다. 트래픽: 초당 요청 160개 응답 시간: 평균 250ms 에러율: 0.05% DB 커넥션: 70% 배포 전보다 약간 느리다. 캐시가 아직 다 안 차서 그렇다. 체크리스트를 다시 본다. 40개 항목. 결국 놓쳤다. 커넥션 풀 증설. 체크리스트엔 없었다. 추가한다. 41개. "다들 수고했어요." 팀원들한테 메시지 보낸다. 답장 온다. "수고하셨습니다." "고생했어요 형." "역시 형이에요." 역시가 아니다. 운이 좋았을 뿐이다. 퇴근 오후 10시. 사무실을 나선다. 혼자다. 다른 팀원들은 9시에 나갔다. "먼저 갈게요." "네, 조심히 가세요." 혼자 남아서 1시간 더 모니터링했다. 안정화 확인. 엘리베이터를 탄다. 거울에 비친 내 얼굴. 피곤하다. 폰을 본다. 아내 메시지. "저녁 먹었어?" "아직." "편의점에 뭐 있어. 데워먹어." 고맙다. 집에 도착한다. 10시 40분. 편의점 도시락을 데운다. 전자레인지 돌아가는 소리. 3분. 소파에 앉는다. 도시락을 먹는다. 맛은 모르겠다. 넷플릭스를 켠다. 뭔가 틀어놓는다. 뭔지 모른다. 폰을 본다. 슬랙. 알림 없다. 다행이다. 그라파나 앱을 연다. 습관이다. 지표 확인. 전부 정상. 앱을 닫는다. 폰을 내려놓는다. 눈을 감는다. 그래도 다음날 출근한다. 팀원이 말한다. "어제 배포 잘됐죠?" "응. 좀 삐걱거렸는데." "그래도 잘 넘어갔잖아요." 맞다. 잘 넘어갔다. 기획자가 메시지 보낸다. "어제 배포 건 잘 적용됐어요. 고객 반응 좋네요." 고객 반응. 좋다고 한다. CS 채널을 확인한다. 어제 배포 관련 문의. 없다. 회원가입 전환율 대시보드. 전일 대비 12% 상승. 상품 페이지 체류시간. 평균 23초 증가. 숫자로 보니 실감난다. 체크리스트를 연다. 41개 항목. 다음 배포 때도 이걸 볼 것이다. 또 항목이 늘어날 것이다. 또 불안할 것이다. 그래도 배포는 한다. 이게 개발자다.체크리스트는 늘어나고, 불안은 줄지 않지만, 배포는 계속된다. 그게 우리 일이니까.

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은 보험이지 전략이 아니다." 쓰기 경로부터 확인한다. 무효화 로직부터 짠다. 그 다음에 캐시를 추가한다. 좀 느려졌다. 예전보다. 신중해졌다. 그게 나쁘진 않다.실수는 또 할 거다. 근데 같은 실수는 아닐 거다. 그걸로 됐다.

MySQL 슬로우 쿼리 로그와의 밤샘 전쟁

MySQL 슬로우 쿼리 로그와의 밤샘 전쟁

MySQL 슬로우 쿼리 로그와의 밤샘 전쟁 새벽 3시의 슬랙 알림 진동이 울렸다. 아내가 뒤척였다. 폰을 집어들었다. 슬랙이다. 프로덕션 알림 채널. [CRITICAL] Slow Query Detected Response Time: 8.3s Endpoint: /api/users/activity8초. 망했다. 잠옷 바람으로 거실로 나왔다. 노트북을 켰다. 부팅되는 동안 물을 마셨다. 손이 떨렸다. 커피 때문인지 떨림 때문인지 모르겠다. VPN 연결. 프로덕션 DB 접속. 슬로우 쿼리 로그를 열었다. SELECT u.*, ua.last_active, ua.page_views FROM users u LEFT JOIN user_activities ua ON u.id = ua.user_id WHERE u.created_at >= '2024-01-01' AND u.status = 'active' ORDER BY ua.last_active DESC LIMIT 100;평범해 보였다. 그게 더 문제다.EXPLAIN은 거짓말을 하지 않는다 EXPLAIN을 돌렸다. +----+-------------+-------+------+---------------+------+---------+------+--------+----------+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | +----+-------------+-------+------+---------------+------+---------+------+--------+----------+ | 1 | SIMPLE | u | ALL | NULL | NULL | NULL | NULL | 450000 | Using... | | 1 | SIMPLE | ua | ALL | NULL | NULL | NULL | NULL | 890000 | Using... | +----+-------------+-------+------+---------------+------+---------+------+--------+----------+type이 ALL. 둘 다. 45만 로우와 89만 로우를 풀스캔. 카르테시안 곱. 400억 건을 뒤진다는 소리다. "씨발." 소리가 나왔다. 작게. user_activities 테이블을 확인했다. 인덱스가 하나도 없다. user_id에도. last_active에도. 누가 만든 건지 git blame을 돌렸다. 6개월 전. 커밋 메시지 "Add user activity tracking". 커밋한 사람은 나. "아 씨." 이번엔 크게 나왔다. 6개월 전엔 데이터가 얼마 없었다. 몇천 건. 그때는 문제없었다. 근데 지금은 89만 건. 매일 3천 건씩 쌓인다. 프로덕션에서 터지기 전까진 아무도 몰랐다. 스테이징에 데이터가 없으니까.인덱스를 추가할 수 없는 이유들 인덱스 추가하면 된다. 간단하다. 근데 프로덕션에서 ALTER TABLE은 간단하지 않다. 89만 건 테이블에 인덱스 추가하면 락이 걸린다. InnoDB라도 시간이 걸린다. 10분? 20분? 그 동안 서비스는 멈춘다. 새벽 3시에 배포는 못 한다. 승인이 필요하다. CTO 결재. 근데 CTO는 자고 있다. 당연히. 옵션을 생각했다.아침까지 기다린다 - 유저들은 8초 쿼리를 계속 겪는다 지금 긴급 배포 - CTO 깨워서 승인받기 임시방편으로 쿼리 수정 - 근본 해결 아님2번은 무섭다. 3번은 찝찝하다. 1번은 책임이 무섭다. 슬랙을 켰다. CTO에게 DM. "프로덕션 슬로우 쿼리 발생했습니다. 인덱스 추가 필요합니다. 긴급 배포 승인 부탁드립니다." 읽음 표시가 안 뜬다. 10분을 기다렸다. 20분을 기다렸다. 답이 없다.임시방편이라는 이름의 선택 기다릴 수 없었다. 쿼리를 수정하기로 했다. 인덱스 없이 빠르게 만드는 방법. -- 기존 WHERE u.created_at >= '2024-01-01'-- 수정 WHERE u.created_at >= DATE_SUB(NOW(), INTERVAL 90 DAY)범위를 줄였다. 1년치가 아니라 90일치만. -- ORDER BY 제거 -- LIMIT 100만 남김정렬을 포기했다. 어차피 프론트에서 다시 정렬한다. 코드를 수정했다. 커밋. 푸시. 젠킨스에서 빌드. 배포는 못 한다. 승인이 없으니까. 근데 DB 쿼리는 바로 테스트할 수 있다. 프로덕션 DB에 직접. EXPLAIN SELECT u.*, ua.last_active FROM users u LEFT JOIN user_activities ua ON u.id = ua.user_id WHERE u.created_at >= DATE_SUB(NOW(), INTERVAL 90 DAY) AND u.status = 'active' LIMIT 100;0.3초. 됐다. 근데 이건 임시방편이다. 근본 해결이 아니다. 데이터는 계속 쌓인다. 90일 범위도 언젠가 느려진다. 새벽 4시. CTO한테 전화했다. "여보세요?" 목소리가 잠에 절어있다. "죄송합니다. 프로덕션 이슈입니다." 상황을 설명했다. 쿼리 수정으로 임시 해결했다고. 인덱스 추가가 필요하다고. "아침에 회의하자. 6시까지는 자야겠다." 끊겼다. 나는 못 잔다. 아침 9시의 포스트모텀 출근했다. 4시간 잤다. CTO실에 들어갔다. 기획팀장도 있다. 왜 기획팀장까지. "상황 설명해봐." 화면을 공유했다. EXPLAIN 결과. 슬로우 쿼리 로그. 응답시간 그래프. "인덱스가 없었어요. user_activities 테이블에." "왜 없었는데?" "제가... 6개월 전에 만들 때 안 넣었습니다." "왜?" "데이터가 적어서 문제 될 줄 몰랐습니다." 침묵. 기획팀장이 물었다. "유저들 피해는?" "어젯밤 3시부터 8시까지. 약 5시간. 해당 API를 쓰는 유저들이 8초씩 기다렸습니다." "몇 명?" "로그 확인 결과 430명 정도." "우리 서비스 쓸 때 8초씩 기다렸다는 거네?" "...네." 또 침묵. CTO가 말했다. "인덱스 추가 언제 할 건데?" "오늘 점심시간에 하겠습니다. 유저 적을 때." "락 걸리는 시간은?" "15분 정도 예상합니다." "공지는?" "11시 50분에 올리겠습니다." "좋아. 근데 다음부터는 테이블 설계할 때 인덱스 미리 생각해." "네." 회의가 끝났다. 자리로 돌아왔다. 후배가 물었다. "형 괜찮아요?" "응." 괜찮을 리 없다. 점심시간의 배포 11시 50분. 공지를 올렸다. "점심시간 동안 DB 작업으로 서비스 일시 중단됩니다. 12:00 ~ 12:20 예정" 슬랙에 난리가 났다. "갑자기요?" "고객사 미팅 있는데" "점심시간이라 괜찮을 거예요" "아 진짜" 무시했다. 할 건 해야 한다. 12시 정각. 프로덕션 DB 접속. ALTER TABLE user_activities ADD INDEX idx_user_id (user_id), ADD INDEX idx_last_active (last_active);엔터를 눌렀다. 쿼리가 실행됐다. 진행률은 안 보인다. 그냥 기다려야 한다. 1분. 2분. 5분. 슬랙 알림이 울렸다. "아직 안 돼요?" "언제 돼요?" 10분. 12분. "Query OK, 890000 rows affected (13 min 47 sec)" 됐다. 서비스를 켰다. API 테스트. 응답시간 0.2초. 공지를 올렸다. "작업 완료. 서비스 정상화" 아무도 반응 안 했다. 다들 점심 먹으러 갔다. 나도 밥 먹으러 갔다. 김치찌개집. 사장님이 물었다. "오늘 안색이 안 좋네?" "어제 못 잤어요." "야근?" "비슷한 거요." 밥을 먹었다. 맛이 없었다. 배는 고픈데 넘어가지가 않았다. 그 후 일주일 인덱스 추가 후 일주일. 모니터링했다. 슬로우 쿼리 0건. 평균 응답시간 0.18초. 완벽하다. 근데 찝찝하다. user_activities 말고 다른 테이블은? 확인했다. orders 테이블 - 인덱스 3개뿐. user_id, created_at, status. payments 테이블 - 인덱스 2개. order_id, created_at. notifications 테이블 - 인덱스 1개. user_id만. 전부 시한폭탄이다. 데이터 쌓이면 터진다. 금요일 오후. 팀 회의에서 말했다. "모든 테이블 인덱스 점검이 필요합니다." 팀장이 물었다. "얼마나 걸려?" "일주일?" "다음주는 스프린트 마감인데." "그럼 그 다음 주에." "그때는 신규 기능 개발 들어가는데." 결국 없던 일이 됐다. 인덱스 점검은 우선순위에서 밀렸다. 또. 터지기 전까진 아무도 신경 안 쓴다. 나도 마찬가지다. 일이 많으니까. 다음번엔 어느 테이블이 터질까. orders? payments? 모르겠다. 터지면 그때 고치지 뭐. 새벽 3시에 또 깰 것 같다는 생각을 했다. 배운 것과 못 배우는 것 이번 일로 배운 거.테이블 만들 때 인덱스부터 생각하기 데이터 적을 때 빠르다고 안심하지 말기 스테이징에도 프로덕션급 데이터 넣어두기 슬로우 쿼리 알림은 진동 말고 무음으로근데 못 배우는 것도 있다. 기술 부채는 계속 쌓인다. 치울 시간은 없다. 우선순위는 항상 밀린다. 터지기 전까진 아무도 신경 안 쓴다. 개발자도. 기획자도. CTO도. 터지면 그제야 급하게 고친다. 새벽에. 혼자서. 그러고 일주일 지나면 다들 잊는다. 나만 기억한다. 다음번엔 어디가 터질지. 이게 7년차 개발자의 일상이다. 화려하지 않다. 그냥 버티는 거다. 터지면 고치고. 또 터지면 또 고치고. 가끔 생각한다. 이직해야 하나. 근데 다른 회사도 똑같을 거다. 기술 부채는 어디에나 있다. 레거시 코드도. 밤샘 배포도. 그냥 이렇게 사는 거다. 커피 한 잔 더 마셨다. 네 번째다. 오늘도 야근이다.다음번 슬로우 쿼리는 언제 터질까. 폰은 무음으로 해뒀다.

Java 메모리 누수: 10시간을 날리다

Java 메모리 누수: 10시간을 날리다

Java 메모리 누수: 10시간을 날리다 금요일 오후 3시 슬랙이 울렸다. 운영팀이다. "개발님, 프로덕션 서버 메모리 사용량이 계속 올라가요." 금요일 오후 3시. 퇴근까지 3시간. 배포한 지 일주일 된 기능이다. "재시작하면 괜찮아지나요?" "네, 근데 2일 정도 지나면 다시 80% 넘어가요." 메모리 누수다. 확신했다.일단 재시작 "일단 재시작 한 번 해주세요. 제가 로그 확인해볼게요." 재시작했다. 메모리 사용량이 정상으로 돌아왔다. 시간을 벌었다. 하지만 원인을 찾아야 한다. 일주일 전 배포 내역을 확인했다. 커밋 6개. 파일 23개 변경. "이거 하나하나 다 봐야 하나." 커피를 마셨다. 네 번째다. 금요일 오후 4시. 퇴근까지 2시간. 로그를 뒤졌다 애플리케이션 로그. 특이사항 없다. GC 로그. 여기서 뭔가 보인다. [Full GC 3.2G->3.1G(4G), 2.3 secs] [Full GC 3.3G->3.2G(4G), 2.5 secs] [Full GC 3.5G->3.4G(4G), 2.8 secs]Full GC가 계속 돌아도 메모리가 안 줄어든다. 전형적인 메모리 누수. "문제는 어디서 새는가지." 힙 덤프를 떠야 한다. 운영 서버에서. "혹시 성능 영향 있을까요?" "잠깐일 거예요. 10초 정도?" 거짓말이다. 3GB 힙 덤프는 30초 걸린다. 하지만 말 안 했다. 금요일 오후 5시. 퇴근까지 1시간.힙 덤프 분석 덤프 파일을 받았다. 3.2GB. Eclipse MAT을 켰다. 로딩하는데 5분 걸렸다. "Leak Suspects Report"를 눌렀다. One instance of "java.util.HashMap" loaded by <system class loader> occupies 2.8 GB (87.5%) bytes.HashMap 하나가 2.8GB를 먹고 있다. "뭐야 이게." 해당 HashMap의 참조를 추적했다. CacheManager -> userSessionCache -> HashMap아, 세션 캐시구나. 일주일 전에 추가한 기능이다. 사용자 세션을 캐시에 담아서 조회 속도를 높이는. 근데 이게 왜 2.8GB? 금요일 오후 6시. 퇴근 시간이다. 아내한테 카톡 보냈다. "야근할 것 같아. 저녁 먼저 먹어." "또? 어제도 늦었잖아." "미안. 메모리 누수 잡아야 해." "무슨 말인지 모르겠지만 힘내." 코드를 봤다 해당 CacheManager 클래스를 열었다. @Service public class CacheManager { private Map<String, UserSession> userSessionCache = new HashMap<>(); public void addSession(String userId, UserSession session) { userSessionCache.put(userId, session); } public UserSession getSession(String userId) { return userSessionCache.get(userId); } }세션을 넣는 로직은 있다. 빼는 로직이 없다. "아..." 사용자가 로그아웃하거나 세션이 만료돼도 캐시에서 안 지워진다. 일주일 동안 쌓인 세션이 2.8GB. DAU 5만. 평균 세션 크기가 대충 60KB라고 치면. 5만 × 7일 × 60KB = 21GB. 아니다. 중복 로그인도 있으니까... 계산이 안 맞는다. 어쨌든 지워야 한다. 금요일 오후 7시. 배가 고프다.해결책을 찾았다 세 가지 옵션.세션 만료 시 캐시에서 제거 TTL 기반 자동 삭제 LRU 캐시로 교체1번은 로그아웃 로직 모두 수정해야 한다. 시간 걸린다. 3번은 라이브러리 교체. 테스트 범위가 크다. 2번이 답이다. Guava Cache로 교체. @Service public class CacheManager { private Cache<String, UserSession> userSessionCache = CacheBuilder.newBuilder() .expireAfterAccess(2, TimeUnit.HOURS) .maximumSize(10000) .build(); public void addSession(String userId, UserSession session) { userSessionCache.put(userId, session); } public UserSession getSession(String userId) { return userSessionCache.getIfPresent(userId); } }2시간 동안 접근 없으면 자동 삭제. 최대 1만 개 유지. 코드 수정 완료. 20분 걸렸다. 금요일 오후 8시. 치킨집 마감 시간이 10시다. 테스트 로컬에서 테스트했다. 세션 1만 개 생성. 메모리 확인. 정상. 2시간 대기는 못 한다. 시간을 1분으로 줄여서 테스트. 1분 후 GC 로그 확인. 메모리 회수됐다. "됐다." 개발 서버에 배포했다. 30분 모니터링. 문제없다. QA팀한테 슬랙 보냈다. "내일 아침에 테스트 부탁드려요. 급한 버그 픽스입니다." "네, 확인했습니다." 금요일 오후 9시. 프로덕션 배포 결정. 프로덕션 배포 배포 준비했다. 운영팀한테 연락했다. "10분 뒤 배포합니다. 모니터링 부탁드려요." "네, 대기하고 있겠습니다." Jenkins 빌드 시작. 3분 걸린다. 빌드 성공. 배포 시작. 서버 1대씩 순차 배포. Blue-Green 방식이다. 10분 후 배포 완료. 헬스체크 확인. 정상. 메모리 사용량 확인. 40%에서 유지. "일단 됐다." 하지만 확신은 못 한다. 2일 지나봐야 안다. 금요일 오후 10시. 치킨집 문 닫았다. 모니터링 집에 가지 않았다. 사무실에서 30분마다 메모리 그래프를 확인했다. 10시 30분. 메모리 42%. 11시. 메모리 43%. 11시 30분. 메모리 44%. "올라가는 거 아냐?" 아니다. 트래픽이 증가하는 시간대다. 정상 범위다. 12시. 메모리 45%. 안정됐다. 아내한테 카톡 보냈다. "이제 들어간다." "벌써 12시야. 택시 타." "응." 택시를 탔다. 요금 2만3천원. 금요일 자정. 10시간이 지났다. 토요일 아침 9시에 눈을 떴다. 제일 먼저 한 일. 폰으로 메모리 그래프 확인. 메모리 48%. 안정적이다. 슬랙 확인. 장애 알림 없다. "살았다." 이불을 다시 덮었다. 오후 2시에 일어났다. 월요일 오전 출근했다. 주말 동안 메모리 사용량을 확인했다. 최대 52%. 더 이상 안 올라갔다. 운영팀이 슬랙을 보냈다. "서버 안정적이에요. 감사합니다." "네." 팀 회의 때 공유했다. "금요일에 메모리 누수 있었는데 해결했습니다." "고생하셨어요." 끝이다. 아무도 자세히 안 물어본다. 10시간 날린 건 나만 안다. 배운 것캐시에는 항상 만료 정책이 있어야 한다. HashMap은 메모리 누수의 주범이다. 코드 리뷰 때 캐시 관련 코드는 집중해서 본다. 금요일 오후 3시 장애는 최악이다.그리고 하나 더. 힙 덤프 분석은 5분 안에 끝난다고 생각했다. 30분 걸렸다. 항상 예상의 6배가 걸린다. 일주일 후 CTO가 이메일을 보냈다. "지난주 메모리 이슈 빠르게 해결해줘서 감사합니다." 답장 안 했다. 감사하면 연봉 올려주든가.메모리 누수는 찾는 데 1시간, 고치는 데 20분이다. 나머지 8시간 40분은 불안이다.