로컬에선 되던 삭제가 서버에선 405를 뱉었다
로컬에선 되던 DELETE와 PUT이 서버에서만 405로 막혔다. POST로 우회한 이유와, 나중에 찾은 진짜 원인인 ModSecurity 기본 규칙을 실제 요청 결과와 함께 정리했다.
#Apache #REST #Express #트러블슈팅
커플 맛집 앱에서 즐겨찾기 삭제 버튼을 눌렀는데 콘솔에 빨갛게 이게 떴다. DELETE https://... 405 (Method Not Allowed) 로컬에선 분명 잘 됐다. 즐겨찾기도 잘 지워지고, 닉네임 수정(PUT)도 멀쩡했다. 근데 서버에 올리고 나서 똑같은 코드가 405를 뱉는다. 코드는 안 건드렸는데 환경만 바뀌었더니 깨진 거다. 이런 게 제일 난감하다. 뭘 고쳐야 할지 감이 안 오니까. 이 글은 그 405가 어디서 났고, 왜 결국 DELETE랑 PUT을 다 버리고 POST로 통일했는지에 대한 기록이다. 그리고 한참 뒤에 진짜 원인이 어디였는지 확인한 것까지 덧붙였다. 범인은 내 코드가 아니었다 한참을 Express 라우터만 들여다봤다. router.delete 경로도 맞고, 미들웨어도 맞고, 로컬에선 되니까 코드는 죄가 없었다. 그러다 문득 로컬엔 없고 서버엔 있는 게 뭐지 싶었다. Apache 였다. 내 서버는 전부 Apache 리버스 프록시 뒤에서 돈다(이 구조는 따로 정리해둔 글이 있다). 그리고 이 Apache는 DELETE, PUT 같은 메서드를 위험한 걸로 보고 막아버리고 있었다. 그러니까 내 요청은 Express까지 가지도 못하고 Apache 선에서 잘려서 405로 돌아온 거였다. 로컬은 Apache가 없으니 당연히 멀쩡했고. 이걸 확인하는 방법은 간단하다. 같은 경로를 프록시 앞(도메인)과 뒤(localhost 포트)로 각각 쳐보는 거다. 아래는 이 글을 다시 손보면서 지금 찍은 화면인데, 도메인으로 DELETE를 보내면 Apache의 기본 에러 페이지가 405로 오고, 포트로 직접 보내면 Express가 404를 준다. 404는 Express까지 요청이 닿았다는 뜻이다. 라우트가 없어서 404일 뿐이다. 응답 본문이 Express가 만든 JSON이 아니라 Apache 기본 HTML이고 charset도 iso-8859-1인 걸 보면, 이 응답을 만든 게 누구인지 바로 보인다. 코드를 아무리 봐도 안 보였던 이유가 이거였다. 문제는 코드 안이 아니라 코드 앞에 있었다. 두 갈래 길 선택지는 두 개였다. Apache 설정을 풀어서 DELETE와 PUT을 허용하거나, 막히지 않는 메서드인 POST와 GET만 쓰거나. 앞쪽이 정석이긴 하다. 근데 이건 서버 전체의 메서드 정책을 건드리는 거다. 여기엔 맛집 앱만 도는 게 아니라 블로그, 코인, 모니터링까지 여러 서비스가 같이 산다. 그 공용 Apache 설정을 풀었다가 딴 데서 뭐가 터질지 모르는 게 부담스러웠다. 그래서 뒤쪽을 택했다. 삭제든 수정이든 전부 POST로. 좀스러워 보여도, 건드리는 범위가 내 앱 안으로 한정되는 게 마음 편했다. 기존 걸 안 갈아엎고 POST를 얹기 이미 DELETE와 PUT으로 짜둔 라우트가 꽤 됐다. 다 지우고 새로 짜는 건 일이라, 같은 핸들러를 POST로도 같이 등록하는 식으로 했다. 핸들러 하나에 메서드 두 개를 물려두는 거다. // 기존 DELETE는 그대로 두고, POST로도 같은 핸들러 등록 router.delete('/comment/delete/:id', deleteCommentHandler); router.post('/comment/delete/:id', deleteCommentHandler); // 수정도 마찬가지 router.put('/nickname', updateNicknameHandler); router.post('/nickname', updateNicknameHandler); 이러면 로컬에선 DELETE도 되고, 서버(Apache 뒤)에선 POST로 부르면 된다. 프론트는 아예 PUT과 DELETE를 안 쓰고 POST와 GET만 부르도록 통일했다. 어느 환경에 올라가든 안 깨지게. 삭제인데 POST 쓰는 거, 찜찜하지 않냐면 찜찜하다. REST대로면 삭제는 DELETE가 맞다. POST로 삭제하는 건 교과서적으로 안 예쁘다. 나도 안다. 근데 현실에선 이론적으로 옳은 것보다 지금 이 서버에서 진짜 도는 것이 우선이었다. 어차피 이 API를 부르는 클라이언트는 내가 만든 프론트 하나뿐이고, 경로에 /delete, /disconnect라고 의도가 박혀 있어서 헷갈릴 일도 없다. 메서드 이름이 좀 안 맞아도 동작이 분명하면 그걸로 됐다고 봤다. 1년 뒤에 똑같은 걸 또 만났다 여행 일정 서비스를 만들다가 같은 벽에 부딪혔다. 이번엔 DELETE가 아니라 PATCH였다. 커밋 로그에 그대로 남아 있다. 2026-06-10 WAF PATCH 차단 대비 POST 별칭 추가 (planner update, place select) 두 번째라 원인 찾는 데 오래 안 걸렸다. 로컬에서 되고 서버에서 안 되면 일단 메서드를 POST로 바꿔보는 게 습관이 됐다. 근데 이게 좋은 습관인지는 아직 모르겠다. 그래서 진짜로 막는 건 누구였나 이 글을 다시 손보면서 어디서 막는지 끝까지 찾아봤다. Apache 설정 파일에는 DELETE를 막는 줄이 없었다. 대신 Apache에 ModSecurity 가 붙어 있었고, 거기 기본 규칙 세트(CRS)의 911100번 규칙이 허용 목록에 없는 메서드를 전부 차단하고 있었다. 기본 허용 목록은 이렇다. # /usr/share/modsecurity-crs/rules/REQUEST-901-INITIALIZATION.conf setvar:'tx.allowed_methods=GET HEAD POST OPTIONS' # REQUEST-911-METHOD-ENFORCEMENT.conf SecRule REQUEST_METHOD "!@within %{tx.allowed_methods}" \ "id:911100, ... ,deny,status:405" GET, HEAD, POST, OPTIONS 네 개만 통과시키고 나머지는 405로 끊는다. DELETE, PUT, PATCH가 전부 걸린 이유가 이거다. 위 커밋에 WAF라고 적어둔 게 맞긴 했는데, 그때는 호스팅 쪽 방화벽인 줄 알았다. 내 서버의 Apache 안에서 도는 거였다. 풀려면 crs-setup.conf에서 tx.allowed_methods에 PUT DELETE PATCH를 더해주면 된다. 근데 이 설정도 사이트 열 개가 같이 쓰는 거라, 지금은 여전히 POST 우회로 두고 있다. 어디서 막는지 알고 두는 것과 모르고 두는 것은 다르니까, 이 정도면 됐다고 본다. 로컬에서 되던 요청이 서버에서 405를 주면, 앱 로그를 뒤지기 전에 프록시 앞뒤로 같은 요청을 한 번씩 쳐보길 추천한다. 응답 본문의 모양만 봐도 누가 답했는지 나온다.