40장. 자주 생기는 버그 고치기
![[7부] 자주 생기는 버그 고치기](https://images.tilnote.io/pages/a2c9a1aa-f8c4-42b1-8c8f-851fea505a07.webp)
![[7부] 자주 생기는 버그 고치기](https://images.tilnote.io/pages/d978bc42-3251-4884-a52c-9c9d628375f4.webp)
![[7부] 자주 생기는 버그 고치기](https://images.tilnote.io/pages/aa4166b9-c337-4619-8abb-305313a973a9.webp)
첫 번째 버그는 공포지만, 두 번째 버그부터는 경험치다
처음 버그를 만나면 누구나 얼어붙는다. 화면은 멈추고, 콘솔은 빨갛고, 방금까지 잘 되던 기능이 갑자기 말을 듣지 않는다. 특히 개발을 처음 시작한 사람에게 버그는 "내가 뭘 완전히 잘못했나?"라는 불안을 몰고 온다.
그런데 조금만 지나 보면 알게 된다. 버그는 무작위 괴물이 아니다. 자주 나오는 버그는 대개 비슷한 얼굴을 하고 다시 나타난다. 경로가 틀렸거나, 권한이 막혔거나, 상태가 꼬였거나, 환경 변수가 비어 있거나, 서버와 브라우저가 서로 다른 말을 하고 있는 경우가 많다.
즉, 버그를 잘 고치는 사람은 천재처럼 한 번에 정답을 맞히는 사람이 아니다. 문제를 작은 범주로 나누고, 단서를 차분히 따라가는 사람이다. 이번 장에서는 초보자가 가장 자주 만나는 버그들을 몇 가지 유형으로 묶어 보고, 각각을 어떻게 풀어야 하는지 익혀 보자.
버그를 만나면 먼저 문제를 작게 쪼개자
버그가 생기면 초보자는 자주 이렇게 한다. 이것저것 한꺼번에 바꾸고, 폴더 이름도 손대고, AI에게 코드 전체를 다시 짜 달라고 하고, 그러다 어디서부터 꼬였는지 더 모르게 된다.
이럴수록 순서가 중요하다. 다음 다섯 단계만 지켜도 훨씬 덜 헤맨다.
같은 문제를 다시 재현해 본다. 정말 항상 나는지, 특정 버튼에서만 나는지 확인한다.
에러 문장을 그대로 읽는다. 요약하지 말고, 실제 메시지를 본다.
어디에서 깨지는지 나눈다. 브라우저 화면인지, 콘솔인지, 터미널인지, 네트워크 요청인지 본다.
가장 최근에 바꾼 것을 의심한다. 버그는 아주 자주 마지막 수정 근처에 숨어 있다.
한 번에 하나만 바꾼다. 두 개 이상 동시에 바꾸면 무엇이 원인이었는지 놓치기 쉽다.
이 다섯 단계는 단순해 보여도 강력하다. 버그는 감정으로 잡는 것이 아니라, 범위를 줄여서 잡는다.
에러 메시지는 겁주는 문장이 아니라 힌트다
많은 초보자가 에러 메시지를 보면 무서워서 바로 닫아 버린다. 하지만 에러 메시지는 사실상 범인이 남긴 메모다. 문제는 영어라서 무섭고, 낯선 단어가 많아서 멀게 느껴질 뿐이다.
예를 들어 Module not found가 보이면 "파일을 못 찾고 있구나"라고 생각하면 된다. Unauthorized가 보이면 "인증이 안 되었구나", Forbidden이면 "로그인은 했지만 이 행동은 허용되지 않았구나"라고 해석할 수 있다.5 window is not defined는 "브라우저에서만 있는 것을 서버에서 쓰려고 했구나"라는 뜻에 가깝다.3
여기서 중요한 감각은 하나다. 에러 이름을 외우는 것보다, 이 에러가 어느 층에서 나는지 구분하는 것이 더 중요하다. 파일 찾기 문제인지, 로그인 문제인지, 데이터 문제인지, 배포 설정 문제인지만 알아도 절반은 풀린다.
경로 오류: 파일은 있는데 못 찾는다고 할 때
초보자가 정말 자주 만나는 버그가 경로 오류다. 파일도 만들었고 컴포넌트도 있는데, 화면은 "없다"고 말한다. 이럴 때는 코드가 틀린 것 같지만, 실제로는 주소가 틀린 경우가 많다.
가장 흔한 원인은 단순하다. import 경로 오타, 폴더 위치 착각, 파일명 대소문자 차이, alias 설정 불일치다. 특히 맥에서는 잘 되는데 배포 후 리눅스 환경에서 깨지는 경우가 있다. 이때는 파일 이름의 대소문자가 import 문과 정확히 같은지 꼭 확인해야 한다. 배포 환경은 이런 차이를 더 엄격하게 본다.3
예를 들어 파일 이름은 Header.tsx인데 import는 header로 적혀 있으면, 로컬에서는 넘어가도 배포에서는 실패할 수 있다. 비슷하게 components 폴더를 Components라고 잘못 적는 실수도 흔하다.
경로 오류를 고칠 때는 이렇게 물어보면 좋다.
지금 에러는 빌드할 때 나는가, 아니면 실행 중에 나는가
파일 이름과 import 경로의 대소문자가 완전히 같은가
상대경로
../../를 너무 길게 쓰다가 헷갈린 것은 아닌가@/components/...같은 alias를 쓴다면 설정이 살아 있는가
라우팅도 비슷하다. 목록 페이지는 열리는데 상세 페이지가 404가 난다면, 동적 라우트 폴더 이름이나 링크 경로가 맞지 않을 수 있다. 이럴 때는 "데이터가 잘못됐다"부터 의심하기보다, 내가 지금 올바른 주소로 이동하고 있는가를 먼저 보자.
권한 오류: 로그인 문제와 권한 문제는 다르다
권한 오류도 초보자가 자주 헷갈리는 버그다. 로그인 관련 문제는 한 덩어리처럼 보이지만, 실제로는 둘로 나뉜다. 인증이 안 된 경우와 권한이 없는 경우다.
HTTP 상태 코드 기준으로 보면 보통 401은 "먼저 로그인해 주세요"에 가깝고, 403은 "로그인은 했지만 이 행동은 허용되지 않습니다"에 가깝다.5 이 차이를 이해하면 디버깅이 훨씬 쉬워진다.
예를 들어 로그아웃 상태에서 글 작성 API를 호출했는데 실패했다면 401 쪽일 가능성이 크다. 반면 로그인은 했지만 남의 글을 수정하려고 할 때 막히면 403에 가깝다. 둘 다 "안 된다"는 점은 같지만, 해결 방법은 완전히 다르다.
우리 서비스처럼 로그인, 글 작성, 수정, 삭제가 들어간 앱에서는 이런 장면이 흔하다.
로그인은 성공했는데 글 작성이 안 된다.
내 글은 수정되는데 다른 글은 수정이 안 된다.
목록은 보이는데 삭제 버튼만 눌렀을 때 실패한다.
이럴 때는 "세션이 있는가?"와 "이 사용자가 이 데이터의 주인인가?"를 따로 봐야 한다. 특히 Supabase처럼 권한 정책이 들어가는 환경에서는, 화면에서는 로그인돼 보이는데 데이터베이스 정책이 막고 있을 수 있다. 글을 저장할 때 작성자 ID가 제대로 들어갔는지, 수정 대상 데이터의 소유자가 현재 사용자와 맞는지, 정책이 읽기만 허용하고 쓰기는 막아 놓지 않았는지 차례로 확인해 보자.
사용자에게 보여 주는 메시지도 중요하다. 예상 가능한 실패, 예를 들어 "비밀번호가 틀렸습니다", "권한이 없습니다", "입력값이 부족합니다" 같은 문제는 화면에서 자연스럽게 안내하는 편이 좋다. 반면 정말 예기치 않은 오류는 에러 경계 같은 장치로 앱 전체가 무너지는 일을 막아야 한다. Next.js도 예상 가능한 오류와 예외적인 버그를 구분해서 다루는 방식을 권장하며, 렌더링 중 발생한 예외는 error.js 같은 에러 경계에서 처리할 수 있다고 설명한다.1
상태 꼬임: 저장은 됐는데 화면은 안 바뀌는 경우
초보 프로젝트에서 가장 답답한 버그 중 하나가 이거다. "분명 저장은 됐는데 왜 목록에 안 보이지?" 혹은 "삭제한 줄 알았는데 새로고침하니 다시 나오네?"
이건 대개 상태 꼬임 문제다. 쉽게 말하면 진짜 데이터와 화면에 보여 주는 데이터가 잠깐 서로 다른 세계를 보고 있는 상태다.
이 문제를 빠르게 구분하는 아주 좋은 질문이 있다.
새로고침하면 맞아지는가?
새로고침하면 정상으로 보인다면, 서버나 데이터베이스에는 반영됐고 화면 상태만 갱신되지 않은 가능성이 크다. 반대로 새로고침해도 그대로 틀리다면, 저장 자체가 실패했거나 잘못된 데이터를 읽고 있을 수 있다.
상태 꼬임은 주로 이런 상황에서 생긴다.
저장 직후 목록 데이터를 다시 불러오지 않았다.
화면에 임시로 추가한 데이터와 실제 서버 응답이 다르다.
검색어나 정렬 조건 때문에 방금 저장한 글이 숨겨져 있다.
로딩 상태나 에러 상태를 해제하지 않아 화면이 이전 상태에 머물러 있다.
같은 데이터를 두 군데 state로 따로 관리해서 서로 어긋난다.
예를 들어 방금 글을 저장했는데 목록 최상단에 안 보인다면, 저장이 안 된 것이 아니라 최신순 정렬이 아니거나 검색 필터가 남아 있는 것일 수도 있다. 삭제 후 화면에서 사라졌는데 새로고침하면 되살아나는 경우는, 서버 삭제는 실패했고 화면만 먼저 지운 것일 수 있다.
상태 꼬임을 줄이는 가장 좋은 습관은 진실의 원천을 하나로 두는 것이다. 같은 데이터를 여기저기 따로 들고 있으면 언젠가 꼭 어긋난다. 저장, 수정, 삭제 뒤에는 "무엇을 다시 가져와야 하는가"를 늘 생각하자.
환경 변수 문제: 로컬에서는 되는데 배포하면 깨질 때
환경 변수 문제는 초보자가 가장 억울해하는 버그다. 로컬에서는 멀쩡했는데 배포하자마자 갑자기 undefined가 뜨기 때문이다.
여기에는 몇 가지 대표 패턴이 있다. 먼저 Next.js에서는 브라우저에서 읽을 환경 변수에 NEXT_PUBLIC_ 접두사가 필요하다. 이 접두사가 없으면 서버에서는 읽혀도 클라이언트 코드에서는 비어 있을 수 있다.3 그래서 "로컬 콘솔에서는 값이 보이는데 브라우저 화면에서는 안 나와요" 같은 일이 벌어진다.
또 하나는 환경 변수를 바꿨는데 서버를 다시 시작하지 않은 경우다. .env 파일을 수정했는데 개발 서버를 그대로 두면 예전 값이 계속 남아 있을 수 있다. 배포 환경에서는 더 자주 그렇다. Vercel이나 GitHub Actions에 값을 넣어도, 다시 빌드하고 다시 배포해야 반영되는 경우가 많다.
GitHub Actions를 쓴다면 한 가지 더 기억하자. 환경별 secret이나 variable은 단순히 만들어 두는 것만으로 자동 주입되지 않는다. 그 값을 쓰는 job이 해당 environment를 실제로 참조해야 접근할 수 있다.4 브랜치 이름을 production으로 맞췄다고 끝나는 것이 아니라, 워크플로우 job에 환경이 연결되어 있어야 한다는 뜻이다.
환경 변수 문제를 의심할 때는 이 순서로 보면 좋다.
변수 이름이 정확히 같은가
브라우저에서 써야 하는 값이라면
NEXT_PUBLIC_가 붙었는가로컬과 배포 환경에 둘 다 설정했는가
값 수정 후 서버 재시작 또는 재배포를 했는가
빌드 시점에 필요한 값인지, 실행 시점에 필요한 값인지 구분했는가
여기서 중요한 습관이 하나 있다. 환경 변수는 값 자체를 그대로 로그로 찍지 말고, 값이 있는지 없는지만 확인하자. 보안은 버그보다 더 무섭다.
브라우저와 서버가 서로 다른 말을 할 때
Next.js를 쓰다 보면 "로컬 개발 중에는 괜찮았는데 배포 후에만 이상한 버그"를 만날 때가 있다. 특히 하이드레이션 문제는 초보자에게 꽤 낯설다. 쉽게 말하면 서버가 먼저 만든 HTML과, 브라우저가 나중에 다시 계산한 결과가 서로 달라서 충돌하는 상황이다.
이 문제의 흔한 원인은 몇 가지로 모인다. 렌더링 중에 window나 localStorage 같은 브라우저 전용 API를 건드리거나, Math.random() 같은 랜덤 값이나 날짜·시간처럼 매번 달라질 수 있는 값을 그대로 렌더링에 넣거나, HTML 구조가 잘못 중첩된 경우다.23
예를 들어 서버는 오전 9시로 계산했는데 브라우저는 오전 9시 1분으로 계산하면, 같은 화면이라도 텍스트가 달라질 수 있다. 혹은 서버 컴포넌트에서 window.innerWidth를 읽으려 하면 아예 window is not defined가 뜰 수 있다.3
이럴 때는 해결 원칙이 분명하다.
브라우저 전용 코드는 클라이언트 컴포넌트나
useEffect안으로 옮긴다.렌더링 시점의 값은 가능하면 항상 같은 결과가 나오게 만든다.
마크업 구조가 올바른지 확인한다.
그리고 꼭 기억하자. 에러 경계는 렌더링 중 예외를 잡아 주지만, 클릭 이벤트 핸들러 안에서 난 오류까지 자동으로 처리해 주지는 않는다. 이런 경우는 직접 try/catch를 쓰고, 사용자에게 실패 상태를 보여 줘야 한다.1
버그를 빨리 찾는 질문 네 가지
버그를 볼 때마다 매번 처음부터 생각하면 지친다. 대신 아래 질문 네 개를 먼저 던져 보자.
새로고침하면 해결되는가? 그렇다면 상태나 캐시 문제일 가능성이 크다.
배포에서만 깨지는가? 그렇다면 환경 변수, 파일 대소문자, 빌드 환경 차이를 의심한다.
로그인 사용자만 겪는가? 그렇다면 인증, 세션, 권한 정책을 본다.
특정 버튼을 눌렀을 때만 나는가? 그렇다면 이벤트 처리나 API 요청 흐름을 확인한다.
이 질문들은 마치 병원 문진표 같다. 정답을 바로 주지는 않지만, 어느 과로 가야 하는지는 알려 준다.
AI에게 버그를 맡길 때는 '증상' 말고 '상황'을 주자
AI에게 "버그 고쳐 줘"라고만 말하면, 꽤 높은 확률로 코드 전체를 크게 건드리거나 엉뚱한 부분을 수정한다. 버그 요청은 특히 맥락이 중요하다.
좋은 요청은 보통 다섯 가지가 들어 있다.
재현 단계: 무엇을 했을 때 문제를 만났는지
기대 결과: 원래 어떻게 되어야 하는지
실제 결과: 지금은 어떻게 되는지
에러 메시지: 콘솔, 터미널, 네트워크에 뭐가 찍히는지
최근 변경점: 방금 무엇을 바꿨는지
예를 들어 이렇게 말하는 편이 훨씬 낫다.
"로그인한 상태에서 글 수정 버튼을 누르면 저장 요청이 실패합니다. 기대한 결과는 수정 완료 후 상세 페이지로 이동하는 것입니다. 실제로는 403 응답이 오고, 콘솔에는 권한 오류만 보입니다. 최근에 글 테이블에 작성자 ID 컬럼과 정책을 추가했습니다. 가능한 원인과 확인 순서를 제안하고, 필요한 코드 수정 범위를 최소화해 주세요."
이렇게 요청하면 AI도 훨씬 덜 헤맨다. 여러분도 결과를 검토하기 쉬워진다.
버그를 기록하는 사람은 같은 함정에 덜 빠진다
버그를 고쳤다면 끝이 아니다. 짧게라도 기록을 남기면 다음에 엄청난 시간을 아낄 수 있다.
예를 들어 이런 식이면 충분하다.
증상: 배포 후 상세 페이지 404
원인: 링크 경로는
/posts/id인데 폴더 구조는/post/[id]해결: 경로 통일 후 링크 생성 코드 수정
혹은 이렇게 남길 수도 있다.
증상: 로그인 후 글 삭제 403
원인: 삭제 정책에 현재 사용자 조건이 빠짐
해결: 작성자 ID와 세션 사용자 비교 정책 추가
이런 메모는 미래의 나를 위한 구조 신호다. 두 번째부터는 무섭지 않은 이유가 바로 여기에 있다. 이미 한 번 건너본 웅덩이는 다음에는 발을 덜 헛디딘다.
결국 버그 수정은 서비스 이해를 깊게 만든다
버그를 고치는 일은 단순히 고장 난 곳을 메우는 작업이 아니다. 경로 오류를 고치면서 폴더 구조를 이해하게 되고, 권한 오류를 고치면서 인증과 데이터 소유권을 배우게 되며, 상태 꼬임을 잡으면서 화면과 서버의 관계를 이해하게 된다. 환경 변수 문제를 해결하다 보면 로컬과 배포의 차이도 몸으로 익히게 된다.
그래서 버그는 귀찮지만, 동시에 실력이 가장 빨리 느는 구간이기도 하다. 처음에는 공포다. 하지만 두 번째부터는 경험치가 된다.
여러분이 지금 만나는 버그는, 사실 서비스가 어떻게 움직이는지 배울 기회이기도 하다. 겁먹지 말자. 버그는 "망했다"의 신호가 아니라, "어디를 이해해야 하는지"를 알려 주는 화살표다.
참고
1Getting Started: Error Handling | Next.js
2Hidden Cost of AI-Generated Code: What Breaks When You Go to Production
3Next.js Debugging Tips: Fixing Errors and Performance Issues
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.