- 01 Dec, 2025
펜 돌리면서 버그를 찾는 개발자의 생각법
펜 돌리면서 버그를 찾는 개발자의 생각법 어제 오후 3시, 또 그 현상이 나타났다. 프로덕션 서버에서 특정 사용자의 결제 데이터가 중복으로 저장되는 버그. 이미 이틀을 싸우고 있는 녀석이다. 내 옆자리 후배 이준호는 집중해서 자기 일을 하고 있었다. 나는 천천히 펜을 집었다. 펜을 돌리는 것. 이건 버그와 싸울 때 나의 무기다. 모니터 앞에 앉아서 눈은 코드를 쫓지만, 손가락 사이에서 돌아가는 펜이 진짜 일을 한다. 누군가는 명상이라고 하겠지만, 나는 이걸 그냥 "생각하는 시간"이라고 부른다.펜 한 바퀴가 가진 의미 버그는 절대 논리적으로만 찾아지지 않는다. 이건 내가 7년간 개발하면서 배운 가장 확실한 진리다. 어제 스택오버플로우에서 본 것처럼 타임아웃 설정 부분도 있고, 동시성 이슈가 있을 수도 있고, 혹은 내가 전혀 생각하지 못한 다른 곳에서 뭔가 튀어나올 수도 있다. 이런 여러 가능성들이 머리 안에서 동시에 맴돌 때, 펜을 돈다. 펜이 한 바퀴 도는 동안 내 뇌는 다음을 한다:에러 로그를 다시 한 번 읽는다 결제 로직의 진입 지점을 추적한다 혹시 놓친 try-catch 블록이 있나 생각한다 동시에 진입하는 두 개의 요청이 있을 가능성을 본다그리고 펜이 세 바퀴쯤 돌 때쯤, 뭔가 떠오른다. "혹시... 트랜잭션 격리 수준?" 속으로 외치고 싶지만, 옆자리 준호가 있다. 내가 너무 집중한 나머지 펜을 돌리는 속도가 점점 빨라진 건 알고 있다. 소리도 나고. 타닥타닥. 쓸딱쓸딱. 학창시절 수학 시험 본다고 볼펜을 돌릴 때 그 속도다. 옆자리 시선의 무게 완전히 솔직하게 말하자면, 펜을 돌리면서 동시에 신경 쓰는 게 있다. 바로 내 옆자리다. 개발팀 레이아웃은 이렇다. 나와 준호가 마주 보는 책상에 앉아 있고, 그 옆에 은지가 앉아 있다. 우리 셋이 사실상 가장 자주 버그를 만나는 팀이기도 하다. 그래서 매일 누군가는 펜을 돌리고 있다. 특히 어려운 버그가 나타났을 때, 나는 두 가지를 동시에 한다:버그를 찾는다 옆자리에 방해가 되지 않을까 신경 쓴다이건 굉장히 비효율적이다. 하지만 이게 사람이다. 그날 오후, 펜을 돌리던 중 가만히 준호의 방향을 봤다. 그는 여전히 자기 화면에만 집중해 있었다. 그러면서도 뭔가 느낀 듯 몇 번이나 방향을 살짝 틀어 내 쪽을 쳐다봤다. "버그 있어?" 하고 묻지는 않았지만, 그 시선만으로 충분했다. 그리고 그 순간 나는 또 다른 가능성을 떠올렸다.생각하는 펜, 말하는 펜 시간이 지나면서 나는 펜을 단순히 "생각 도구"로만 쓰는 게 아니라, "소통 도구"로도 쓰게 되었다. 회의 중에 누군가 기획자가 "이거 간단하죠?"라고 물을 때, 나는 펜을 돌린다. 그리고 그것을 보는 나머지 팀원들은 안다. "어라, 김개발이가 펜을 돌린다? 뭔가 깊게 생각할 게 있는 건가?" 한 번은 CEO 앞에서 프로젝트 보고를 했을 때도 있었다. 내가 펜을 돌리기 시작하자 팀장이 슬슥 내 손목을 눌렀다. "진정해, 괜찮아." 펜을 돈다는 것은 내 안의 신호다. 심각함의 척도다. 1회전: 좀 이상한데? 3회전: 뭔가 크리티컬한 이슈가 있는데? 5회전 이상: 아, 이거 진짜 골치 아픈 버그네. 그리고 옆자리 준호도 그걸 알게 되었다. 어느 날부턴 내가 펜을 돌리면 자기도 슬쩍 준비한다. "어? 내가 리뷰를 받게 되나?" 하고. 실제로 많은 경우 그렇다. 내가 펜을 돌리다가 갑자기 "야, 너 이 부분 봤어?" 하며 모니터를 보여주게 되니까. 침묵의 협력 버그를 찾는 과정에서 가장 아름다운 순간은 언제인가. 그건 누군가 나의 펜 도는 소리를 들었을 때다. 어제 그 결제 버그는 결국 내가 푼 게 아니었다. 내가 펜을 3분 정도 돌리고 있다가, 은지가 옆에서 조용히 말했다. "혹시 데이터베이스 컨넥션풀 리셋 타임이랑 타이밍 맞나?" 정확했다. 정확했어. 나는 그 순간 웃음이 나왔다. 우리는 말이 없었지만, 펜을 돌리는 내 행동만으로도 같은 방향으로 생각하게 된 것이다. 아무도 "진정하세요" 하지 않았다. 아무도 "이미 확인했어?" 라고 질문하지 않았다. 그냥 은지는 조용히 나오면서, 자신이 생각한 것을 던져주었다. 이게 바로 개발팀의 협력이다. 코드를 공유하고, 깃허브 이슈를 열고, PR을 리뷰하는 것도 좋지만, 가끔은 그냥 "옆에 있어주는 것"이 최고의 협력이 될 때가 있다.왜 옆자리를 신경 쓰나 지금 생각해보면 이상하다. 왜 내가 펜을 돌리면서 옆자리를 신경 쓰는가. 사실은 이렇다. 개발자들은 대부분 혼자인 것처럼 느껴진다. 깊은 코드의 우물 속에 혼자 떨어져 있는 것 같다. 하지만 나는 항상 옆자리를 본다. 왜냐하면 그 옆자리가 내가 혼자가 아니라는 증거이기 때문이다. 연봉이 높다고 해서 행복한 건 아니고, 위치가 높다고 해서 덜 외로운 것도 아니다. 하지만 옆에 누군가 있다는 건 다르다. 특히 그 사람이 내가 펜을 돌릴 때 뭔가를 느끼고, 나도 그 사람이 펜을 돌릴 때 뭔가를 느낀다면 더욱. 매일 아침 9시에 출근해서 커피 3잔을 마시고, 점심은 김치찌개를 먹고, 배포일에는 10시까지 남아있는 이 루틴. 그 속에서 나를 지탱해주는 건 사실 성취감도 기술도 아니었다. 그냥 옆자리였다. 옆자리에서 "혹시 데이터베이스 컨넥션풀 리셋 타임이랑 타이밍 맞나?"라고 물어주는 은지. 내가 펜을 돌릴 때도 자기 일을 계속하지만 가끔 슬쩍 쳐다보는 준호. 팀장이 PR 리뷰를 몰아줄 때도 옆에서 "힘내" 하는 눈빛으로 응원해주는 그들. 펜은 계속 돈다 오늘도 슬랙 알림이 울렸다. "프로덕션 환경에서 API 응답이 간헐적으로 500 에러를 반환합니다." 내 손이 자동으로 펜을 집었다. 손가락 사이에서 펜이 회전을 시작했다. 옆자리 준호와 은지가 동시에 내 쪽을 봤다. 말 없이. "또 뭐야?" 라고 말하지 않았지만, 그들의 눈빛이 말해주고 있었다. 그리고 이번엔 내가 먼저 입을 열었다. "500 에러라고? 너희는 프로젝트에서 최근에 뭐 바뀐 거 없어?" 은지가 답했다. "어제 배포에서 타임아웃 설정만 조정했는데?" 그리고 준호가 덧붙였다. "혹시 그거 때문일까?" 이렇게 버그는 해결된다. 혼자가 아니라 함께. 펜을 돌리는 내 습관은 이상해 보일 수도 있지만, 사실은 아주 자연스러운 개발자의 신호 언어다. 그리고 그 신호를 받아주는 옆자리의 존재는, 내가 이 일을 계속할 수 있게 해주는 가장 소중한 것이다. 옆자리에 방해가 될까봐 신경 쓰면서도 펜을 도는 이유는, 사실은 나와 함께할 누군가를 바라는 마음이 아닐까. 버그를 혼자 잡는 것도 좋지만, 함께 잡는 게 훨씬 낫다는 걸 알기 때문이다. 내일도 펜을 돌릴 것 같다. 그리고 옆자리가 있을 것 같다. 그것으로 충분하다.펜 하나로 말 없이 나누는 이 신뢰가 결국 버그보다 더 중요한 것 같다.
- 01 Dec, 2025
스택오버플로우 없는 개발자는 존재할까
스택오버플로우 없는 개발자는 존재할까 매일 아침 9시에 회사에 도착하면 커피를 한 잔 내려 마신다. 그리고 슬랙을 켠다. 어김없이 누군가는 물어본다. "개발님, 이거 어떻게 하죠?" 나는 그럼 "아 그거요"라고 한다. 그 순간 손가락은 자동으로 움직인다. 구글 탭을 열고, 스택오버플로우에 들어가고, 검색창에 키워드를 친다. 5초, 10초 지나면 누군가는 이미 같은 문제를 겪었고, 2015년에 누군가가 정답을 남겨놨다. 나는 그 링크를 복사해서 슬랙에 붙여넣는다. "여기 봐보세요." 마치 내가 방금 그 코드를 생각해낸 것처럼.개발자의 비밀, 스택오버플로우 7년 개발 경력이라고 하면 사람들은 내가 거의 모든 에러를 처리할 수 있다고 생각한다. 솔직히 말하자면, 틀렸다. 나는 거의 모든 에러를 검색할 수 있다. 차이가 크다. 후배가 처음 입사했을 때 보여준 표정을 아직도 기억한다. 내가 NullPointerException으로 한 시간을 고민하다가 결국 스택오버플로우를 켰을 때, 후배가 물었다. "어? 시니어 분도 검색하세요?" 그 순간의 당혹스러운 감정은 이루 말할 수 없었다. 내 신비로움이 한 순간에 무너져 내렸다. 하지만 생각해보니 이건 수치스러운 게 아니었다. 이건 효율이었다. 프로그래밍이 정말로 유명해진 건 스택오버플로우 같은 플랫폼이 생겨난 후부터다. 그 전에는 어떻게 했을까? 동료에게 물었을까? 책을 뒤졌을까? 아니면 그냥 에러를 품고 살았을까? 요점은 이거다. 모든 개발자는 스택오버플로우를 쓴다. 쓰지 않는 개발자는 거짓말쟁이다. 나는 이제 이 사실이 자랑스럽다. 내가 모르는 걸 빠르게 찾을 수 있는 능력, 그게 진짜 시니어의 실력이 아닐까? '아 그거요' 신드롬 회사에서 내 입버릇이 뭐냐고 물으면 '아 그거요'라고 답할 것 같다. 진짜 입버릇이 그거다. 기획자가 "김개발님, 이거 간단하게 수정 가능하죠?"라고 물을 때. 나는 "아 그거요"라고 한다. 속으로는 '간단한 게 뭔데 아무것도 간단한 게 없는데'라고 생각하면서. 근데 이 '아 그거요'라는 말이 나올 때, 사실 나는 이미 구글을 켠 상태다. 손가락은 스택오버플로우 URL을 치고 있다. 그리고 몇 분 뒤, 나는 마치 내가 방금 이 솔루션을 생각해낸 것처럼 설명한다. "네, 이렇게 하면 돼요." 동료들은 나를 신뢰한다. 나는 빠르기 때문이다. 빠른 것처럼 보이기 때문이다. 사실 나는 빠르게 검색할 뿐이다. 회의 중에 라포톱으로 딴 짓하는 척하면서 코드를 보는 것도 그래서다. 누군가가 기술적인 질문을 던질 때, 나는 회의실에서 나가지 않고도 답할 수 있어야 한다. 그래야 존재감이 있다. 슬랙 알림보다 회의 중 질문이 더 스트레스다. 왜냐하면 구글을 켤 시간이 없기 때문이다. 레거시 코드와 스택오버플로우의 한계 그런데 여기서 흥미로운 지점이 있다. 모든 게 스택오버플로우로 해결되는 건 아니라는 것이다. 작년에 우리 회사 시스템에 심각한 버그가 발생했다. Redis 캐시가 특정 상황에서 스테일(stale) 데이터를 반환하는 문제였다. 처음엔 간단한 TTL 설정 문제인 줄 알았다. 스택오버플로우에서 관련 답변들을 찾았다. 하지만 우리 상황과 맞지 않았다. 우리는 레거시 코드를 건드리고 있었기 때문이다.그 순간 나는 깨달았다. 스택오버플로우는 일반적인 문제를 푸는 거다. 하지만 개발자가 실제로 맞닥뜨리는 건 특수한 문제다. 우리 회사의 특수한 아키텍처, 우리 팀의 특수한 레거시 코드. 이건 누구도 미리 답해줄 수 없다. 그때서야 내가 진짜로 생각해야 했다. 검색하는 게 아니라, 논리를 조립해야 했다. 에러 로그를 읽고, 데이터 플로우를 추적하고, 테스트를 써야 했다. 그게 7년의 경력이 의미를 갖는 순간이었다. 스택오버플로우 없이도 문제를 풀 수 있다는 걸 증명해야 했다. 팀 채널에 링크 붙여넣는 나 근데 여기서 또 다른 문제가 생겼다. 바로 내가 아는 것처럼 보여야 한다는 압박이다. "김개발님, 이거 어떻게 하죠?"라는 질문이 나오면, 내가 "음... 잘 모르는데 한번 알아봐야겠네요"라고 답하면 어떨까? 솔직한 답변이지만, 팀의 신뢰도는 하락한다. 그래서 나는 재빨리 스택오버플로우를 켠다. 그리고 링크를 붙여넣는다. "여기 봐보세요. 이런 식으로 하면 돼요." 후배들은 고마워한다. 나는 영웅이 된다. 아무도 내가 지금 그 링크를 처음 읽고 있다는 걸 모른다. 이게 나쁜 건 아니다. 오히려 이게 효율적인 개발 문화를 만드는 방법이다. 스택오버플로우라는 거인의 어깨 위에 서서, 우리는 더 높이 본다. 우리는 개별 문제를 푸는 시간을 줄이고, 아키텍처와 설계를 고민할 시간을 얻는다. 하지만 한 가지 확실한 건, 그 링크 뒤의 진짜 지식을 이해해야 한다는 것이다. 링크를 붙여넣는 건 쉽다. 하지만 왜 그 솔루션이 작동하는지 설명할 수 있어야 한다. 그게 바로 시니어와 주니어의 차이다. 스택오버플로우를 넘어서지난 3년간 나는 사이드 프로젝트를 3번 시작했다. 그리고 3번 모두 접었다. 왜냐하면 사이드 프로젝트에서는 스택오버플로우가 덜 효과적이기 때문이다. 회사 일과 달리, 내가 정말로 무언가를 만들어야 하기 때문이다. 회사에선 기존 구조를 이해하고 거기에 기능을 추가하면 된다. 하지만 새 프로젝트를 시작하면 처음부터 아키텍처를 설계해야 한다. 데이터베이스 스키마를 그려야 한다. API 엔드포인트를 설계해야 한다. 이건 스택오버플로우로 해결되지 않는다. 그래서 나는 프로젝트를 접었다. 불편한 진실을 마주했기 때문이다. 내가 진짜로 무언가를 만들지는 못한다는 것을. 하지만 요즘 생각이 바뀌고 있다. 회사에서 시스템을 유지보수하고 개선하는 것도 충분히 가치 있는 일이라는 걸 깨달았기 때문이다. 누군가는 새로운 기술을 개척해야 하고, 누군가는 기존 시스템을 견고하게 유지해야 한다. 나는 후자를 잘 하는 개발자다. 그리고 그건 나쁜 게 아니다. 스택오버플로우는 내 도구다. 좋은 도구를 쓰는 게 효율적인 거다. 결국, 그래서 뭐어? 어제 기획자가 또 물었다. "이 기능, 어렵죠?" 나는 답했다. "아 그거요, 간단해요." 내 손가락은 이미 스택오버플로우 URL을 치고 있었다. 5분 뒤, 나는 솔루션을 제시했다. 기획자는 놀랐다. 동료들은 감탄했다. 나는 아무 말도 하지 않았다. 필요 없었다. 그들의 신뢰가 내 상이었다. 근데 오늘따라 한 가지 생각이 떠나지 않는다. 혹시 내가 7년간 같은 걸 반복하고 있는 건 아닐까? 검색 능력만 늘고, 실제 지식은 쌓이지 않고 있는 건 아닐까? 아니다. 그건 아니다. 나는 분명히 성장했다. 스택오버플로우 링크를 붙여넣을 때, 이제는 그게 왜 작동하는지 안다. 처음엔 몰랐지만. 경험이 쌓이면서 링크 뒤의 지식도 깊어졌다. 스택오버플로우는 내가 알고 있는 것들의 색인이 되었다. 나는 더 이상 순수하게 검색만 하지 않는다. 나는 확인한다. 그 차이가 전부다.스택오버플로우 없는 개발자는 존재할 수 있지만, 그런 개발자는 아마 매우 외로울 것 같다.
- 01 Dec, 2025
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 설정 기준, 모니터링 방법 등등. 나는 내일부터 그걸 작성해야 한다. 그럼 또 다른 후배가 같은 실수를 하지 않겠지. 아내가 돌아왔다. 김치찌개 냄새가 풍겼다. "먹자."결국 버그는 나를 성장시켰고, 밤샘은 나를 피로하게 했지만, 그 둘의 조합이 내를 개발자로 만들었다.
- 01 Dec, 2025
MySQL 인덱스 설계: 경험치로는 모자란 부분
MySQL 인덱스 설계: 경험치로는 모자란 부분 느려진다는 신호, 그 다음부터가 지옥문 "김개발님, 대시보드 조회가 자꾸 타임아웃이 나네요. 인덱스를 좀 봐주실 수 있을까요?" 팀원 신입이 슬랙에 메시지를 보냈고, 나는 "아 그거요, 금방 봐드리죠"라고 답했다. 자신감 가득한 대답이었다. 그런데 이게 내 인생의 3시간을 빼앗을 줄은 몰랐다. 일반적으로 백엔드 개발자에게 "쿼리가 느리다"는 보고는 고혈압 약을 먹고도 혈압을 올릴 수 있는 마법의 말이다. 왜냐하면 가능한 원인이 너무 많기 때문이다. N+1 쿼리? 인덱스 미스? 테이블 잠금? MySQL 캐시? 네트워크 레이턴시? 아니면 서버 리소스 부족? 경력 7년차라는 타이틀이 나를 자신감 넘치게 만든다. 7년이면 충분하지 않을까? 인덱스 정도야 내가 다 알지. 나는 빠르게 쿼리 플랜을 뽑아내기로 했다. EXPLAIN EXTENDED SELECT * FROM user_logs WHERE user_id = 123 AND created_at BETWEEN '2024-01-01' AND '2024-12-31' AND status IN ('active', 'pending') ORDER BY created_at DESC LIMIT 100;플랜을 봤다. rows: 2,847,392. 풀 테이블 스캔이다. 뭐가 이렇게 많이 나오는 거야? 나는 즉각 인덱스를 설계했다. "네, 여기요. (user_id, created_at, status) 복합 인덱스를 추가하면 될 것 같습니다. 아 그거요, status도 같이 포함해야 필터링이 먹으니까요." 신입이 감탄했다. "오, 역시 시니어는 다르네요!" 그 칭찬이 나를 망쳤다. 나는 인덱스를 추가했고, 자리로 돌아가 커피를 마셨다. 그리고 30분 뒤... "어... 김개발님? 아직 느린데요?"'EXPLAIN' 다시 읽기, 내가 놓친 것들 다시 쿼리 플랜을 봤다. 이번엔 좀 더 찬찬히. 아니, 이전엔 정말 제대로 본 건가? mysql> EXPLAIN EXTENDED SELECT * FROM user_logs WHERE user_id = 123 AND created_at BETWEEN '2024-01-01' AND '2024-12-31' AND status IN ('active', 'pending') ORDER BY created_at DESC LIMIT 100\G결과를 보니:Type: ALL - 풀 테이블 스캔 Key: NULL - 인덱스를 사용하지 않음 Key_len: NULL - 인덱스 길이도 없음 Rows: 2,847,392 - 약 284만 건을 스캔 Using where; Using filesort - 디스크 정렬 사용어? 근데 내가 인덱스 추가했는데? 나는 실시간으로 테이블을 다시 확인했다. SHOW INDEXES FROM user_logs;인덱스가 추가되어 있었다. 그럼 왜 안 쓰는 건데? 그 순간이었다. 뇌가 깨어났다. 아, 쿼리 자체가 이상한데? 다시 원래 쿼리를 봤다. SELECT * FROM user_logs WHERE user_id = 123 AND created_at BETWEEN '2024-01-01' AND '2024-12-31' AND status IN ('active', 'pending') ORDER BY created_at DESC LIMIT 100;뭔가 이상하다. WHERE절에는 user_id가 먼저 오고, 그 다음에 created_at과 status가 있다. 그런데 내가 만든 인덱스는... (user_id, created_at, status)? 아 그거요. 잠깐. 인덱스가 있어도 MySQL 옵티마이저가 판단해서 안 쓸 수도 있다. 특히 카디널리티(Cardinality) 문제가 있으면 말이다. ANALYZE TABLE user_logs; SHOW INDEXES FROM user_logs\G카디널리티를 확인했다. user_id는 5개. status는 3개. 근데 전체 행은 284만 건. 오... 아. 그 순간 깨달음이 왔다. Status 컬럼의 카디널리티가 너무 낮다는 것이다. 'active'와 'pending'만 있으면, 결국 거의 모든 행이 조건을 만족한다는 뜻이다. 그럼 MySQL은 "인덱스를 타서 284만 건을 다 읽은 다음 정렬할까? 아니면 차라리 풀 스캔 한 번 하고 나중에 정렬할까?" 이렇게 판단하고, 풀 스캔이 더 빠르다고 생각한 것이다. 그런데 내가 뭘 했냐고? 인덱스를 추가하고 "야, 이제 빠르겠지?" 했다. 기초를 무시하고 경험만 믿었다. EXPLAIN EXTENDED SELECT * FROM user_logs WHERE user_id = 123 AND created_at BETWEEN '2024-01-01' AND '2024-12-31' ORDER BY created_at DESC LIMIT 100;status 조건을 빼서 실행해봤다. Type: range Key: idx_user_created_status Key_len: 12 Rows: 1,247 Using index condition어? 이제 인덱스를 쓴다? 그렇다. 내가 놓친 문제는 인덱스가 없는 게 아니었다. 쿼리 자체가 문제였고, 옵티마이저의 선택이 합리적이었고, 내가 상황을 제대로 분석하지 않았다는 것이다.쿼리 다시 쓰기, 그리고 배운 것 결국 문제는 쿼리였다. 신입이 준 쿼리를 그대로 실행시키고 있었다. SELECT * FROM user_logs WHERE user_id = 123 AND created_at BETWEEN '2024-01-01' AND '2024-12-31' AND status IN ('active', 'pending') ORDER BY created_at DESC LIMIT 100;나는 신입을 불렀다. "어, 이 쿼리를 왜 이렇게 짰어요? status를 꼭 WHERE절에 포함해야 하나요?" 신입 왈: "어... 기획팀에서 'active나 pending 상태인 로그만 보고 싶다'고 했는데..." "근데 실제로 active나 pending이 아닌 상태의 로그가 많나요?" 신입이 생각해봤다. "아, 거의 없네요. 아마 전체의 98% 정도가 active나 pending일 것 같은데요?" 그 순간이었다. 카디널리티가 높지 않은 컬럼은 WHERE절의 앞에 오면 안 된다. 특히 복합 인덱스에서는 더욱 그렇다. 인덱스 컬럼의 순서가 중요하다는 건 알았지만, 이렇게까지 직접 경험할 줄은... 나는 쿼리를 다시 설계했다. SELECT * FROM user_logs WHERE user_id = 123 AND created_at BETWEEN '2024-01-01' AND '2024-12-31' LIMIT 100;그리고 애플리케이션 단에서 status를 필터링했다. 실행 시간: 150ms → 8ms. 18배 빨라졌다. 인덱스를 하나 추가해서가 아니라, 쿼리를 바꿔서. 그 다음부터 나는 매번 "아 그거요"라고 하기 전에 EXPLAIN을 3번은 본다. 아 그거요. 근데 일단 쿼리 플랜부터 봐야 할 것 같은데요.이렇게 말한다. 조금 더 신중해진 나. 인덱스는 은탄환이 아니다 사실 이 경험이 나한테 준 가장 큰 교훈은 간단하다. 인덱스를 추가하는 것보다, 쿼리를 제대로 짜는 게 100배 중요하다. 7년차니까 알겠지 하면서 기초를 무시했다. 느린 쿼리가 나오면 인덱스를 먼저 생각한다. 하지만:EXPLAIN을 제일 먼저 봐야 한다. Type, Key, Key_len, Rows, Extra 모두. 카디널리티를 확인해야 한다. 특히 복합 인덱스를 만들 때. 인덱스 컬럼의 순서가 결정적이다. Equality, Range, Sort 순서로. LIMIT절이 있으면 MySQL은 풀 스캔도 고려한다. 특히 작은 LIMIT일 때. 데이터 분포를 알아야 한다. 같은 인덱스도 데이터가 어떻게 분포하는지에 따라 쓰일 수도, 안 쓰일 수도 있다.그날 이후로 나는 슬랙 메시지에 "쿼리가 느려요"라는 보고가 오면, 먼저 쿼리를 본다. 그 다음에 EXPLAIN을 본다. 그리고 나서야 인덱스를 생각한다. 신입이 또 다른 느린 쿼리 건을 줬을 때, 나는 EXPLAIN을 봤다. N+1이었다. 관계 데이터베이스인데 Loop Join으로 100만 번의 쿼리를 날리고 있었다. 인덱스와는 아무 상관이 없었다. "아 그거요. LEFT JOIN으로 한 번에 가져가시면 될 것 같습니다." 이번엔 자신감이 없었다. EXPLAIN을 5번 봤고, 쿼리를 다시 썼고, 인덱스 필요성을 판단했다. 그 결과 실행 시간이 47초에서 2초로 줄었다. 신입이 감탄했고, 나도 뿌듯했다. 하지만 그 뿌듯함은 다르다. 이번엔 "내가 똑똑해서"가 아니라 "내가 제대로 분석해서"다.당신이 다음부터 할 수 있는 것 이 글을 읽는 개발자에게 당부하고 싶다. 특히 "경력이 좀 쌓인" 개발자에게. EXPLAIN을 무시하지 말자. 쿼리가 느려졌다고 해서 인덱스를 먼저 생각하지 말자. 그리고 자신감으로 기초를 뛰어넘지 말자. 진짜로 해야 할 체크리스트:쿼리를 읽는다. JOIN 수, WHERE 조건, GROUP BY, ORDER BY. 이상한 부분이 있나? EXPLAIN을 본다. Type이 ref가 아닌가? Rows가 너무 많진 않은가? Using filesort가 있진 않은가? 실제 실행 시간을 본다. EXPLAIN은 추정값이다. 100% 정확하지 않다. 쿼리를 수정한다. WHERE절 순서, JOIN 방식, 서브쿼리 활용. 그래도 느리면 인덱스를 생각한다.내가 처음에 인덱스를 추가했을 때, 아무 효과가 없었던 이유는 인덱스가 문제가 아니었기 때문이다. 쿼리 자체가 비효율적이었다. 지금도 팀에서 느린 쿼리가 나오면 내가 본다. 그런데 거의 절반 이상이 인덱스 문제가 아니다. 대부분은:**SELECT *** 하는데 실제로 10개 컬럼만 필요한 경우 Left Join에서 WHERE절에 Right 테이블 조건이 있어서 LEFT가 INNER가 되는 경우 GROUP BY 후 HAVING으로 필터링 하는데, WHERE에서 미리 필터링할 수 있는 경우 Subquery로 일일이 조회하는 경우이런 것들은 인덱스로 안 된다. 쿼리를 다시 써야 한다. 나는 이제 느린 쿼리를 보면 한숨이 아니라 '어, 이게 뭔가 재미있는 문제네?' 이런 생각이 든다. 왜냐하면 대부분 쿼리 최적화로 해결할 수 있기 때문이다. 그리고 그게 훨씬 더 근본적인 솔루션이기 때문이다. 경력이 늘수록, 기초가 더 중요하다는 걸 깨닫는다. 그리고 "아 그거요"라는 대답이 가장 위험한 말이라는 것도.인덱스는 옷깃이 아니라, 기초 설계가 먼저인 법이다.
- 01 Dec, 2025
Spring Boot 버전 업그레이드: 일주일을 잃다
Spring Boot 버전 업그레이드: 일주일을 잃다 아, 그거요. 이 이야기를 하려면 먼저 지난 월요일 아침으로 돌아가야 한다. 커피를 마시면서 슬랙을 보다가 기술 뉴스 채널에서 "Spring Boot 3.2.0 릴리즈! 획기적인 성능 개선과 새로운 기능들"이라는 메시지를 봤다. 그 순간의 나는 정말 한심한 수준의 낙관주의자였다. 마치 신작 게임이 나왔다고 생각하고 선구매하는 게이머처럼, 나는 "오, 이 정도면 업그레이드 간단하겠는데?"라고 생각했다. 지금 생각해보니 그게 모든 고통의 시작이었다. 호기심 같은 업그레이드의 시작 현재 프로젝트는 Spring Boot 2.7.15 버전을 쓰고 있었다. 안정적이고, 버그도 별로 없고, 팀원들도 익숙한 버전. 근데 왜 하필 그날 업그레이드를 시작했을까? 이유는 정말 초라한 수준이었다. "요새 Spring Boot 3.2를 안 쓰면 구시대 개발자처럼 보일 거 같은데?" 이 정도의 이유였다.build.gradle 파일을 열고 springBootVersion을 3.2.0으로 바꿨다. 엔터를 쳤다. IDE는 즉시 빨간 줄을 그었다. 대량의 빨간 줄이었다. 처음엔 괜찮을 거라고 생각했다. 점진적으로 해결하면 되겠지, 하는 마음으로. 결국 모든 프로젝트의 죽음은 이런 순진함에서 시작된다. 의존성 지옥의 나락으로 첫 번째 문제는 Gradle 버전이었다. Spring Boot 3.2.0은 Gradle 7.6 이상을 요구했는데, 우리 프로젝트는 7.3을 쓰고 있었다. 간단하다고 생각했다. Gradle 버전을 올리면 되겠지. 그런데 Gradle 버전을 올리니까 이번엔 Java 버전 문제가 나왔다. 프로젝트에서 Java 11을 쓰고 있었는데, Spring Boot 3.x는 최소 Java 17을 요구한다. 알겠다, Java 버전도 올리자. Java 17로 올렸다. 이제 정말 되겠지? 아니었다. 이건 마치 도미노 같았다. 한 개를 넘치니까 다 넘어지는 그런 느낌이었다. 프로젝트에서 쓰던 Querydsl이 호환성 문제를 일으켰다. Maven 중앙 저장소를 뒤져보니, 우리가 쓰던 Querydsl 4.4.0은 Spring Boot 3.x와 호환성 문제가 있었다. 최신 버전인 5.0.0으로 올려야 했다. Querydsl 5.0.0으로 올렸다. 그럼 이제 되겠지? gradle build를 실행했다. 또 다른 에러가 나왔다. javax 패키지들이 사라졌다는 것이었다. Spring Boot 3.0부터 javax.*에서 jakarta.*로 완전히 전환되었기 때문이다. 이건 단순 버전 업 문제가 아니라 전체 구조 변경이었다. // 이게 모두 바뀌어야 함 import javax.persistence.Entity; import javax.persistence.GeneratedValue;// 이렇게 import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue;프로젝트 전체에서 javax를 jakarta로 바꾸는 작업이 시작되었다. IDE의 대량 검색-바꾸기 기능을 썼지만, 손으로 일일이 확인해야 하는 경우도 많았다. 왜냐하면 javax.servlet도 바꿔야 하고, javax.validation도 바꿔야 하고, javax.mail도... 이 작업만 3시간이 걸렸다. 화요일: 첫 번째 깨달음 화요일 오전. 슬랙에서 후배 개발자가 물어봤다. "선임님, 이 PR 리뷰 좀 해주실 수 있나요?" 나는 "오케이, 잠깐만" 이라고 했지만, 내 손은 여전히 EntityManagerFactory 설정 파일을 수정 중이었다. 일주일에 보기로 했던 신입 면접 준비도 날려먹었다. 점심 시간에 회사 근처 김치찌개집에서 사장님과 뵈면서도 "요새 너무 바빠서 ㅋㅋㅋ" 하고 웃었지만, 사실 웃음 뒤에는 절망이 묻어있었다."Spring Boot 3.2 하면서 뭐 이렇게 복잡해?" 라고 생각했지만, 알고 보니 그건 시작일 뿐이었다. 수요일: Jackson의 배신 수요일이 되자, 빌드 에러는 줄었지만 이번엔 런타임 에러가 터지기 시작했다. API 응답이 JSON으로 변환되지 않았다. 원인은 Jackson 라이브러리 업데이트였다. 예전 버전에서는 기본적으로 작동하던 기능들이 새 버전에서는 명시적으로 설정해야 했다. // 기존엔 이게 자동으로 됐는데... LocalDateTime.now()// 이제는 이렇게 설정해야 함 @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime createdAt;프로젝트 곳곳에 있는 API 응답 DTO 수십 개를 일일이 확인하고 수정했다. 각각의 LocalDateTime에 어노테이션을 달아야 했다. 이 작업을 하면서 나는 진짜로 "차라리 Spring Boot 2.7 유지하는 게 낫지 않을까?"라는 생각을 처음으로 진지하게 해봤다. 슬랙에 투덜거렸다. "이거 왜 이리 복잡하냐고... 대충 호환 모드 같은 게 없나?" 팀장이 답장했다. "그냥 고민 좀 해봐. 급할 건 없으니까." 하지만 그건 팀장 말이었고, 내 심리 상태는 이미 "이건 내가 해결해야 하는 문제"라는 강박에 빠져있었다. 목요일: 데이터베이스 커넥션의 악몽 목요일. 애플리케이션이 어느 정도 떠올랐다. 그런데 데이터베이스 커넥션에서 문제가 터졌다. Connection pool 에러였다. Spring Boot 3.x에서는 기본 데이터베이스 커넥션 풀이 HikariCP에서 다른 것으로 변경되었을 뿐 아니라, 설정 방식도 살짝 달라져있었다. # 예전엔 이게 먹혔는데 spring: datasource: hikari: maximum-pool-size: 20# 새 버전에선 이렇게 해야 함 (세부사항이 좀 다름) spring: datasource: hikari: max-lifetime: 1800000 idle-timeout: 900000설정 파일을 뒤지고, Spring Boot 공식 문서를 뒤지고, 스택오버플로우를 샅샅이 뒤졌다. "아 그거요" 하면서 몇 가지 설정을 건드렸더니, 로컬 환경에서는 돌아갔다. 근데 스테이징 환경에서는 안 돌아갔다. 왜냐하면 스테이징 환경은 우리 프로젝트가 쓰는 여러 마이크로서비스 중 하나일 뿐이었고, 다른 서비스들도 같은 데이터베이스를 쓰고 있었기 때문이다. 커넥션 풀 설정을 건드리니까 다른 서비스들의 연결이 끊어졌다. 데이터베이스 팀에 연락했다. 죄송합니다, 제가 실수했습니다. 다시 설정할 테니 잠깐만요. 금요일: 마이크로미터 메트릭 문제 금요일이 되자, 모니터링 시스템이 문제를 일으켰다. Prometheus 메트릭 수집이 안 되고 있었다. Spring Boot 3.x에서 메트릭 수집 방식이 바뀌면서 기존 설정이 더 이상 작동하지 않았다. Micrometer 라이브러리의 주요 메서드와 클래스 이름이 바뀌었다. // 예전 코드 MeterRegistry meterRegistry; meterRegistry.counter("custom.metric").increment();// 새 코드 (내부 동작 방식이 좀 다름) @Timed(value = "custom.metric") public void someMethod() { // ... }금요일 오후 3시. 나는 모니터링 알림이 작동하는 것을 보기 위해 필사적이었다. 왜냐하면 월요일에 본배포가 예정되어있었기 때문이다. 퇴근 시간을 넘겨서 계속 작업했다. 6시, 7시, 8시... 9시가 되어서야 메트릭 수집이 정상화되었다. 집에 도착한 건 10시가 넘었다. 아내는 이미 잠들어있었다. 나는 라면을 끓여먹고 침대에 쓰러졌다. 둘째 주 월요일: 통합 테스트의 지옥 둘째 주 월요일. 로컬 빌드는 성공했지만, CI/CD 파이프라인에서 테스트가 실패했다. "어? 로컬에선 되는데?" 이건 개발자가 가장 싫어하는 상황이다. 로컬 환경과 테스트 환경의 차이 때문이었다. 통합 테스트에서 쓰는 TestContainers의 버전과 Spring Boot 3.x의 호환성 문제였다. Docker를 통해 테스트 DB를 구성하는데, 이 과정에서 버전 미스매치로 인한 타이밍 이슈가 발생했다. 테스트를 하나씩 실행하면 성공하는데, 전체 테스트를 실행하면 일부가 실패했다. 이건 경합 조건(race condition) 같은 거였다. TestContainers 버전을 올렸다. 그럼 이제 되겠지? 아니었다. 이번엔 다른 테스트가 실패했다. 결국 나는 8시간을 테스트 디버깅에만 썼다. 마지막에는 테스트 타임아웃 설정을 조정하고, 데이터베이스 초기화 순서를 바꿔야 했다.셋째 주 초: 배포와 롤백의 악몽 업그레이드를 시작한 지 8일째. 드디어 본배포 준비가 됐다고 생각했다. 배포는 성공했다. 서버가 떠올랐다. 모든 것이 정상으로 보였다. 그런데 2시간 뒤, 모니터링 대시보드에서 에러율이 갑자기 올라갔다. 원인은 Spring Security 설정이었다. Spring Boot 3.x에서 보안 설정 방식이 바뀌면서, 기존 설정이 예상하지 못한 동작을 하고 있었다. 특정 엔드포인트에 대한 권한 체크가 너무 엄격해져있었다. 30분 뒤 롤백 결정. 내가 업그레이드한 Spring Boot 3.2 버전은 다시 2.7로 돌아갔다. 슬랙 타이머가 종료되었다. 돌아온 월요일: 후회와 깨달음 원점으로 돌아왔다. 일주일을 완전히 낭비했다. 이 시간에 내가 할 수 있었던 것들을 생각해봤다.신입사원 온보딩 프로세스 개선 레거시 코드 리팩토링 테크 로드맵 수립 아내와 시간 보내기 충분한 숙면대신 나는 뭘 했나? javax → jakarta 변환하고, 테스트 타임아웃 튜닝하고, Spring Security 설정을 삽질했다. 후배들이 물어봤다. "선임님, Spring Boot 3.2는 괜찮던데요?" "음, 아직 좀 이른 거 같아. 마이너 버전 유지하자." 팀장은 나에게 물었다. "이번에 뭘 배웠어?" 나는 웃었다. "아, 그거요. 버전 업그레이드는 정말 신중해야 한다는 거요." 얻은 교훈 (그리고 다음번엔 이렇게 할 거) 일주일을 낭비한 대가로, 나는 몇 가지 중요한 걸 배웠다. 먼저, 주요 메이저 버전 업그레이드는 절대 혼자 끙끙대며 하지 말 것. 팀과 함께 계획을 세우고, 명확한 일정과 롤백 계획을 세워야 한다. 둘째, 마이너 버전 유지의 가치를 인정할 것. Spring Boot 2.7이 LTS 버전이고 충분히 안정적이라면, 굳이 3.x로 업그레이드할 이유가 있을까? 비용과 이득을 따져봐야 한다. 셋째, 호환성 문서를 미리 읽을 것. Spring 공식 마이그레이션 가이드가 있다. 읽어봤으면 훨씬 빨랐을 거다. 넷째, 의존성 버전 고정의 중요성. Gradle의 dependency lock을 활용하면, 버전 충돌을 미리 감지할 수 있다. 마지막으로, 신중함의 가치. 호기심과 선도적 기술 도입은 좋지만, 모든 비용을 개인이 떠안는 건 아니다. 에필로그: 그래도 계속 개발하는 이유 이 이야기를 읽으면, "개발 일이 답답하네. 그냥 그만두지?"라고 생각할 수도 있다. 근데 솔직히, 이런 일들이 반복되어도 나는 개발을 계속한다. 왜냐하면 이 불편함 뒤에 무언가가 있기 때문이다. 새로운 기술을 배우는 과정은 고통스럽지만, 배운 후에는 문제 해결 능력이 한 단계 올라간다. Spring Boot 3.x로 업그레이드하면서 나는 Jakarta 네임스페이스, 마이크로미터 메트릭, Spring Security 설정 등에 대해 깊이 있게 배웠다. 다음에 비슷한 문제가 나타나면, 훨씬 빨리 해결할 수 있을 거다. 그리고 이 경험이, 다른 후배들에게 "신중함이 얼마나 중요한지"를 가르쳐주는 좋은 사례가 될 수도 있다. 결국 개발이라는 일은 이런 반복이다. 실패하고, 배우고, 다시 시도하고. 매번 조금씩 성장한다. 물론 일주일을 날린 건 별로 기분 좋지 않은 결과지만, 그래도 내일은 또 다른 문제를 해결하러 깃을 켤 것이다. 왜냐하면 개발자는 그렇게 되어있으니까.차라리 처음부터 "유지보수 vs 혁신"을 제대로 따져볼 걸.