GET 요청으로 읽음 처리해도 될까 — REST 표준과 Gmail·GitHub의 선택

REST API 통신과 백엔드 데이터 처리에 최적화된 모던한 데스크탑 개발 환경 (AI로 생성)
GET 요청 안에서 read/seen 상태까지 바꾸면 안 된다. GET은 HTTP 표준상 "읽기 전용(safe)" 메서드라서, 상태 변경을 의도하는 순간 CDN 캐시 오염과 프리페치 오작동이라는 운영 사고로 이어진다. 알림·메시지·콘텐츠 상세 조회 API를 만드는 개발자가 한 번쯤 마주치는 이 질문에, RFC 9110 근거와 실제 사고 시나리오, 그리고 Gmail·Slack·GitHub가 택한 패턴까지 근거로 답한다.
한눈에 보는 선택지
| 설계 | 표준 적합 | 캐시·프리페치 안전 | 권장 |
|---|---|---|---|
| GET이 읽음 처리까지 | ❌ safe 위반 | ❌ 사고 위험 | 쓰지 않는다 |
| GET 조회 + PATCH 읽음 | ✅ | ✅ | 단건 기본값 |
| POST 일괄 읽음 | ✅ | ✅ | 목록·모두읽음 |
| POST /open (조회+읽음 1콜) | ⚠️ 합법 | ✅ | "한 번에" 요구 시 |
핵심은 하나다. read는 "사용자가 의도한 상태 변경"이므로 쓰기(write) 작업이고, GET이 아니라 PATCH나 POST로 보내야 한다.
GET은 표준이 정한 읽기 전용 메서드다
현행 HTTP 표준인 RFC 9110 §9.2.1은 GET·HEAD·OPTIONS·TRACE를 "safe(안전)" 메서드로 규정한다. safe의 정의는 "메서드의 의미가 본질적으로 읽기 전용"이라는 것이다.
여기서 흔한 오해가 갈린다. safe는 "서버가 물리적으로 아무것도 못 바꾼다"가 아니라 "클라이언트가 변경을 요청한 게 아니다"라는 계약이다. 표준은 한발 더 나아가, safe 메서드는 자동화 도구가 사용자 동의 없이 호출해도 안전하다고 광고하는 신호라고 본다. 프리페치·크롤러·링크 미리보기가 GET을 마음대로 때려도 되는 근거가 바로 이 계약이다.
또 하나 구분할 개념이 멱등성(idempotent)이다. RFC 9110 §9.2.2 기준으로 GET·HEAD·PUT·DELETE는 멱등(여러 번 호출해도 결과 동일)이고, POST와 PATCH는 멱등이 보장되지 않는다.
| 메서드 | safe | idempotent | 의미 |
|---|---|---|---|
| GET / HEAD | O | O | 순수 조회 |
| PUT | X | O | 전체 교체 |
| DELETE | X | O | 삭제 |
| PATCH | X | 보장 안 됨 | 부분 수정 |
| POST | X | X | 명령·생성 |
읽음 처리는 두 번 해도 결과가 같아서 멱등하긴 하다. 그래서 "멱등하니 GET에 넣어도 된다"는 주장이 자주 나온다. 함정이 바로 여기다. safe와 멱등은 다른 축이다. 모든 safe 메서드는 멱등이지만, 멱등하다고 safe한 건 아니다. read가 멱등인 것과 GET에 실어도 되는 것은 별개 문제다.
로그는 되는데 읽음 처리는 왜 다른가
"GET에서 조회수를 올리고 접속 로그를 남기는 건 다들 하는데, 읽음 처리는 왜 안 되나"라는 반박이 있다. 표준 자체가 이 둘을 가른다.
RFC 9110 §9.2.1과 그 전신인 RFC 2616의 유명한 문장은 이렇다. "GET이 부수효과를 생성하지 않음을 보장할 수는 없다. 중요한 구분은 사용자가 그 부수효과를 요청하지 않았다는 점이며, 따라서 사용자가 책임지지 않는다."
기준은 단 하나, "사용자가 그 변화를 의도하고 요청했는가"이다.
| 구분 | 예시 | GET 허용 |
|---|---|---|
| 비의도적 부수효과 | 접속 로그, 조회수 카운터, 캐시 갱신, last_accessed | 관용 |
| 의도적 상태 변경 | 읽음 처리, 좋아요, 구독, 읽음 영수증 | 위반 |
로그나 조회수는 사용자가 요청한 게 아니고, 누락되거나 중복돼도 사용자에게 무해하다. 반면 읽음 처리는 다르다. 모바일에서 읽으면 웹의 안 읽음 배지가 줄어들고, 같은 알림을 다시 조회하면 readAt 값이 달라진다. 사용자에게 보이는 상태 그 자체가 제품의 UI다. 사용자가 "이걸 열었다"고 의도한 결과이기도 하다. RFC가 그어둔 의도성 경계선 위에 읽음 처리가 정확히 걸린다. 더 깊은 배경은 MDN의 safe 메서드 정의에서도 같은 결을 확인할 수 있다.
GET에 읽음 처리를 넣으면 실제로 깨지는 것들
표준 위반이라는 명분보다 더 현실적인 문제는 운영 사고다. GET이 상태를 바꾸도록 만들면 다음이 깨진다.
첫째, 캐시 오염이다. RESTCookbook이 지적하듯, GET 응답은 중간 프록시와 CDN이 자유롭게 캐싱한다. 읽음 처리로 바뀐 응답이 캐시되면 다른 사용자나 다른 시점에 오염된 데이터가 전달된다. 캐시를 막으려 no-store를 거는 순간, GET의 가장 큰 장점인 캐시 가능성을 스스로 버리게 된다.
둘째, 유령 읽음이다. 카카오톡·슬랙·iMessage의 링크 미리보기, 구글 같은 검색 크롤러, 브라우저 프리페치, Uptime 모니터가 전부 GET을 사용자 없이 호출한다. GET에 읽음 처리를 실으면 사용자가 열어보지도 않은 알림이 "읽음"으로 바뀐다.
이게 이론이 아니라는 신호는 최근 커뮤니티 반응에서도 보인다. 2026년 6월 r/webdev에서 "봇이 웹 트래픽의 절반을 넘었다"는 글이 2천 표 넘게 받았고, 한 개발자는 "페이스북 공유 봇이 페이지가 바뀌었을까 봐 수천 번씩 때려서 사실상 DDoS가 났다"고 토로했다. 사람이 한 번도 누르지 않은 GET이 이렇게 쏟아진다. 그 모든 호출이 "읽음 처리"가 된다고 생각하면 위험이 분명해진다.
Gmail, Slack, GitHub는 읽음을 어떻게 처리하나
추상적인 논쟁보다 메이저 서비스의 실제 설계가 답을 명확히 한다. 조회와 읽음 처리를 분리하지 않은 곳은 한 군데도 없다.
| 서비스 | 조회 | 읽음 처리 | GET이 상태 변경 |
|---|---|---|---|
| Gmail | GET messages/{id} | POST messages/{id}/modify (UNREAD 라벨 제거) | 아니오 |
| Slack | GET conversations.history | POST conversations.mark (읽음 커서 이동) | 아니오 |
| GitHub | GET notifications/threads/{id} | PATCH 단건 / PUT 전체 | 아니오 |
| Microsoft Graph | GET me/messages/{id} | PATCH me/messages/{id} (isRead: true) | 아니오 |
패턴은 둘로 모인다. 단건 읽음은 PATCH(리소스의 read 필드 수정) 또는 POST(명령), 일괄 읽음은 PUT이나 POST 배치다. GitHub가 PATCH 단건과 PUT 일괄을 구분하는 방식, Gmail이 modify와 batchModify를 나눈 방식이 그대로 참고할 만한 레퍼런스다.
권장 설계: 조회와 읽음 처리를 나눈다
위 사례를 종합한 권장안은 GitHub·Gmail 노선을 따르는 분리형이다.
GET /notifications/{id} # 순수 조회, 캐시 가능
PATCH /notifications/{id} # { "read": true } 단건 읽음
POST /notifications/read # { "ids": [...] } 일괄 읽음
PATCH 응답에 서버 타임스탬프 readAt을 담아 돌려주면, 여러 기기 사이의 읽음 동기화 기준점으로 쓸 수 있다. read를 필드로 다루면 { "read": false }로 안 읽음 되돌리기도 같은 엔드포인트로 처리되고, 일괄 읽음 API는 알림 목록 화면의 "모두 읽음"에 그대로 대응한다.
여기서 한 가지 짚을 점은, 어떤 설계를 택하든 일괄 읽음 API는 어차피 따로 필요해진다는 사실이다. 목록을 스크롤하며 여러 알림이 동시에 노출될 때 한 건씩 호출할 수는 없다. GET에 읽음 처리를 넣는 방식은 일괄 처리를 표현할 길이 아예 없어서, 확장성에서도 막힌다.
한 번만 호출하고 싶다면 POST로 연다
"클라이언트가 GET 한 번, PATCH 한 번 두 번 호출하는 게 번거롭다"는 의견은 흔하고, 일리도 있다. 다만 그 답이 "GET이 읽음 처리까지"는 아니다.
진짜 한 번에 처리하고 싶다면 정직한 방법은 command 스타일의 POST다.
POST /notifications/{id}/open
-> 상세 데이터 + readAt 을 함께 반환
POST는 표준상 safe가 아니므로 캐시되지 않고, 프리페치·크롤러도 함부로 호출하지 않는다. 앞에서 본 사고 시나리오가 메서드 선택만으로 원천 차단된다. 페이스북 메신저가 읽음 표시에 sender_action을 POST로 보내는 방식도 같은 발상이다. 순수 리소스 지향 REST 관점에서는 한 응답에 조회와 명령이 섞인다는 점이 살짝 타협이지만, 합법이고 실용적이다.
두 번 호출이 정말 부담이라면 또 다른 답은 비동기다. 상세는 GET 응답으로 이미 화면에 그렸으니, 읽음 PATCH는 백그라운드로 던지면 된다(fire-and-forget). 사용자 체감 지연은 0이다. 불편한 건 사용자가 아니라 클라이언트 코드 한두 줄인데, 그건 SDK 래퍼 함수 하나로 흡수된다.
그래도 GET을 고집한다면
팀이 표준 위반을 감수하고서라도 GET에서 읽음 처리를 하기로 했다면, 최소한의 가드레일은 갖춰야 한다.
- 응답에 Cache-Control: no-store, private 강제로 CDN·프록시 캐시 오염을 막는다.
- ?markRead=true 같은 명시적 쿼리 파라미터가 있을 때만 읽음 처리한다. 프리페치·크롤러는 보통 이 파라미터를 붙이지 않는다.
- Sec-Purpose: prefetch 헤더나 봇 User-Agent, HEAD 요청은 읽음 처리에서 제외한다.
- Uptime·합성 모니터의 IP나 전용 토큰도 읽음 처리 대상에서 뺀다.
- OpenAPI 문서에 "이 GET은 부수효과가 있다"고 명시해, 미래의 개발자와 연동사가 안전하다고 오인하지 않게 한다.
솔직히 이 항목들을 전부 구현하느니, POST /open 하나면 위 작업이 통째로 불필요해진다. 가드레일은 "굳이 GET을 고집할 때의 차선책"이지 권장 경로가 아니다.
REST가 아니라 GraphQL·gRPC라면
같은 원칙이 다른 프로토콜에도 그대로 적용된다. GraphQL은 데이터를 가져오는 query와 상태를 바꾸는 mutation이 스키마 차원에서 갈린다. 읽음 처리는 query의 부수효과로 넣지 말고 markNotificationRead 같은 mutation으로 분리하는 게 정석이다. query에 부수효과를 넣으면 Apollo·Relay 같은 클라이언트 캐시가 같은 query를 자동 재실행하거나 배칭하면서 앞서 본 REST 프리페치 사고와 똑같은 문제가 생긴다. gRPC도 마찬가지로 GetNotification(조회)과 MarkAsRead(변경)를 별도 RPC로 둔다. 프로토콜이 REST든 GraphQL이든 gRPC든, "조회와 의도적 상태 변경을 분리한다"는 한 줄이 본질이다.
자주 묻는 질문
GET이 멱등하면 읽음 처리해도 안전한 것 아닌가?
멱등성과 safe는 다른 축이다. 멱등은 "여러 번 호출해도 결과가 같다"는 재시도 안전성이고, safe는 "상태를 바꾸지 않는다"는 캐시·프리페치 안전성이다. 읽음 처리는 멱등이지만 safe하지 않다. 문제가 되는 건 멱등이 아니라 safe 위반 쪽이다.
PATCH와 POST 중 뭘 써야 하나?
읽음을 "리소스의 한 필드"로 본다면 PATCH가 자연스럽다(GitHub·Microsoft Graph 노선). "열기"라는 행위로 본다면 POST 명령이 맞다. 단건 읽음은 PATCH, 조회와 읽음을 한 번에 묶는 command형은 POST를 권장한다.
비동기로 읽음 처리하면 사용자가 못 본 알림이 읽음 되지 않나?
읽음 처리를 호출하는 시점을 "상세 화면이 실제로 렌더링됐을 때"로 두면 된다. 비동기는 호출을 백그라운드로 보낸다는 뜻이지, 사용자 행위와 무관하게 호출한다는 뜻이 아니다.
여러 알림을 한 번에 읽음 처리하려면?
POST /notifications/read 에 { "ids": [...] } 또는 { "before": "타임스탬프" }를 보낸다. 건수가 많으면 GitHub처럼 202 Accepted로 받고 비동기 처리하는 패턴이 안전하다.
unread로 되돌리는 기능도 같은 API로 되나?
read를 필드로 설계하면 PATCH { "read": false }로 같은 엔드포인트에서 처리된다. GET이 읽음 처리까지 하는 설계는 단방향이라 안 읽음 되돌리기를 표현할 수 없다.
GET에 ?markRead=true 쿼리를 붙이면 괜찮나?
사고 위험은 줄지만 표준 위반은 그대로다. 쿼리 파라미터가 붙은 GET이라도 의도적 상태 변경이므로 safe 계약은 깨진다. 위험 완화책일 뿐 정답은 아니다.
마무리
- GET은 safe 메서드다. 읽음 처리 같은 의도적 상태 변경을 넣으면 표준 위반이고, CDN 캐시와 프리페치로 실제 사고가 난다.
- 권장안은 GET(조회) + PATCH(단건 읽음) + POST(일괄 읽음) 분리다. Gmail·Slack·GitHub·Microsoft Graph 모두 이 방향이다.
- "한 번에 호출"이 절대 조건이면 GET이 아니라 POST /open으로 푼다.
HTTP 메서드의 의미론과 멱등·safe 구분은 RFC 9110 §9.2가 1차 출처다. 각 서비스의 엔드포인트와 동작은 공식 문서가 수시로 갱신되므로, 구현 직전 위 공식 문서를 다시 확인하는 것을 권한다.
댓글
댓글 쓰기