하루의 기록을 준비하고 있어요

잠시만 기다려 주세요.

목록으로

사진 한 장은 왜 비용 정책이 되었고, 채팅방 하나는 왜 보안 정책이 되었나

채팅 사진의 반복 전송을 줄이기 위해 폴링과 미디어 저장 방식을 바꾸고, 여러 그룹과 비공개방을 운영하며 화면 숨김이 아닌 서버 권한 경계를 세운 둘째 주의 시행착오를 기록했습니다.

책처럼 보기
보통
큰 사진과 작은 사진 인화물이 놓인 나무 작업대에서 빛의 연결선이 네 칸짜리 나무 상자와 열쇠로 이어지는 장면

주제

하루스페이스 랩

사진 한 장은 왜 비용 정책이 되었고, 채팅방 하나는 왜 보안 정책이 되었나

채팅 사진의 반복 전송을 줄이기 위해 폴링과 미디어 저장 방식을 바꾸고, 여러 그룹과 비공개방을 운영하며 화면 숨김이 아닌 서버 권한 경계를 세운 둘째 주의 시행착오를 기록했습니다.

요약

요약

  1. 한 번 올린 사진이 폴링 때마다 다시 전송되는 구조를 발견하고 증분 조회, 썸네일 우선 표시와 브라우저 직접 Blob 업로드로 전송 범위를 줄였습니다.
  2. 공개형 오브젝트 저장소(Public Blob)는 비용 경로를 바꾸는 저장소일 뿐 비공개 권한을 보장하지 않으므로, 민감도에 따라 별도 저장·접근 방식을 선택해야 한다는 한계를 남겼습니다.
  3. 그룹과 채팅방 권한을 서버에서 검사하고 초대 링크와 실제 멤버십 생성을 분리했지만, 이후 초대코드와 비밀번호 조합에서 발견된 허점을 통해 복합 조건을 함께 검증하는 법을 배웠습니다.
12페이지
큰 사진과 작은 사진 인화물이 놓인 나무 작업대에서 빛의 연결선이 네 칸짜리 나무 상자와 열쇠로 이어지는 장면

Summary

한눈에 보기

  • 한 번 올린 사진이 폴링 때마다 다시 전송되는 구조를 발견하고 증분 조회, 썸네일 우선 표시와 브라우저 직접 Blob 업로드로 전송 범위를 줄였습니다.
  • 공개형 오브젝트 저장소(Public Blob)는 비용 경로를 바꾸는 저장소일 뿐 비공개 권한을 보장하지 않으므로, 민감도에 따라 별도 저장·접근 방식을 선택해야 한다는 한계를 남겼습니다.
  • 그룹과 채팅방 권한을 서버에서 검사하고 초대 링크와 실제 멤버십 생성을 분리했지만, 이후 초대코드와 비밀번호 조합에서 발견된 허점을 통해 복합 조건을 함께 검증하는 법을 배웠습니다.

이 글은 2026년 7월 6–12일의 Git 기록과 당시 미디어·그룹 권한 설계 문서, 그리고 그 뒤에 발견해 고친 비공개방 이슈를 함께 복기해 쓴 둘째 주 회고다.

채팅방에 사진 한 장을 올렸을 때 사람의 눈에는 한 번 보낸 것으로 보인다.

하지만 당시 하루스페이스의 네트워크에서는 그렇지 않았다. 채팅 화면은 새 메시지가 있는지 일정한 간격으로 서버에 물었고, 서버는 그때마다 메시지 목록을 돌려줬다. 목록 응답에 원본 이미지가 문자열 형태로 섞여 있으면, 이미 본 사진도 조회할 때마다 다시 따라왔다. 방에 머무는 사람과 사진이 늘수록 한 번의 업로드가 여러 번의 다운로드로 불어나는 구조였다.

둘째 주에 나는 사진을 단순한 첨부 기능으로 볼 수 없게 됐다.

사진 한 장을 어떻게 불러오느냐는 화면 구현이면서 데이터 전송 정책이고, 결국 비용 정책이었다.

같은 주에 채팅방도 비슷한 질문을 던졌다. 목록에서 방을 숨기면 비공개가 되는가. 초대 링크를 받은 사람은 곧바로 방의 구성원인가. 플랫폼 관리자는 모든 그룹의 대화를 당연히 읽을 수 있어야 하는가.

이 질문을 따라가자 채팅방 하나는 메뉴 항목이 아니라 권한 경계가 됐다.

한 장을 보냈는데 왜 계속 전송됐을까

초기 채팅은 짧은 간격의 폴링으로 새 메시지를 확인했다. 구현은 단순했고 작은 시험 환경에서는 충분히 빨라 보였다. 문제는 조회 빈도와 응답 크기를 따로 생각했다는 데 있었다.

당시 구조에는 세 가지가 겹쳐 있었다.

  • 방 목록과 메시지 목록을 화면이 열려 있는 동안 반복해서 조회했다.
  • 메시지 목록은 새 항목만이 아니라 최근 기록 묶음을 다시 받을 수 있었다.
  • 썸네일이 없으면 목록에서도 원본 이미지 데이터를 대신 사용할 수 있었다.

각 선택은 혼자 있을 때는 사소했다. 합치면 달라졌다. 원본 크기에 조회 횟수와 접속 인원을 곱한 값이 반복 전송의 대략적인 위험 크기가 된다. 사진이 많아지면 데이터베이스 응답도 무거워지고, 서버리스 함수가 내보내는 전송량과 모바일 데이터 사용량도 함께 늘어난다.

여기서 기록의 한계를 분명히 해 두고 싶다. 당시 커밋에는 폴링 간격과 응답 형태를 바꾼 사실이 남아 있지만, 변경 전후의 운영 전송량을 같은 조건에서 재어 비교한 계측 자료는 충분히 남아 있지 않다. 따라서 “비용을 몇 퍼센트 절감했다”는 식으로 말할 수는 없다.

우리가 확인한 것은 측정된 절감률이 아니라 코드에서 반복 전송이 생길 수밖에 없는 경로였다. 비용 위험은 원본 크기, 반복 주기, 이용자 수로 추정했고, 실제 절감 효과는 이후 관측 지표로 확인해야 할 과제로 남겼다. 설계 추정과 운영 측정을 섞지 않는 일도 이때 배운 운영 습관이다.

먼저 덜 묻고, 달라진 것만 받았다

첫 조치는 폴링을 없애는 것이 아니라 낭비를 줄이는 일이었다. 방 목록 확인 주기를 늘리고, 메시지 확인도 너무 촘촘하지 않게 조정했다. 브라우저 탭이 보이지 않을 때는 반복 조회를 멈췄다.

그다음에는 응답의 범위를 바꿨다.

채팅방에 처음 들어올 때는 최근 메시지 일부를 받아 맥락을 만든다. 그 이후에는 마지막으로 받은 메시지의 생성 시각과 식별자를 함께 보내 그 뒤에 생긴 것만 요청한다. 같은 순간에 여러 메시지가 저장되더라도 하나를 놓치거나 중복으로 쌓지 않도록, 클라이언트는 식별자를 기준으로 합친다.

이 작은 변경에는 중요한 관점 전환이 있었다.

이전 질문은 “새 메시지가 있는가”였다. 새 질문은 “내가 이미 가진 것과 비교해 무엇이 달라졌는가”였다. 전자는 서버에 현재 상태 전체를 반복해서 요구하기 쉽고, 후자는 변화량만 주고받는 구조를 만든다.

물론 증분 조회도 만능은 아니다. 클라이언트가 마지막 위치를 잘못 기억하면 누락될 수 있고, 같은 시각의 데이터 순서를 식별자 없이 판단하면 경계에서 메시지가 빠질 수 있다. 그래서 시각 하나에 기대지 않고 시각과 식별자를 함께 사용했으며, 최초 조회와 이후 조회의 역할도 분리했다.

원본과 목록용 이미지는 같은 자원이 아니었다

폴링을 줄여도 목록 응답에 큰 원본이 들어가면 낭비는 남는다. 그래서 미디어의 이동 경로 자체를 바꿨다.

브라우저는 파일을 선택하면 목록과 말풍선에 쓸 작은 썸네일을 만든다. 서버는 로그인 상태와 해당 방에 접근할 권한이 있는지 확인한 뒤 업로드에 필요한 제한된 권한만 발급한다. 원본과 썸네일은 브라우저에서 오브젝트 저장소로 직접 올라가고, 메시지 데이터에는 파일 이름과 형식, 크기, 저장 위치 같은 메타데이터만 남긴다.

사용자가 채팅을 읽을 때는 작은 썸네일을 먼저 본다. 썸네일이 없다면 원본을 몰래 대신 불러오지 않고 자리 표시자를 보여 준다. 원본은 사용자가 사진을 눌러 크게 보는 순간에만 가져온다. 이전 방식으로 저장된 첨부도 갑자기 사라지지 않도록 별도의 호환 경로를 유지했다.

이 구조를 나는 세 문장으로 정리했다.

  1. 업로드 권한은 서버가 판단한다.
  2. 목록에는 목록에 필요한 크기만 보낸다.
  3. 원본은 원본이 필요한 행동에서만 불러온다.

이렇게 하면 서버리스 함수가 원본 파일을 매번 중계하는 부담과 데이터베이스 응답 크기를 줄일 수 있다. 하지만 비용이 사라지는 것은 아니다. 원본을 오브젝트 저장소에서 내려받을 때는 그 저장소의 전송량이 발생한다. 비용의 경로를 바꿨다는 것과 비용을 없앴다는 것은 다르다.

Public Blob은 비공개 금고가 아니었다

이 절은 2026년 7월 당시 저장 방식을 복기한 내용이며, 현재 첨부 보안 정책이나 민감 자료 업로드 가능 여부를 안내하는 문장이 아니다.

당시 선택한 저장 방식은 Public Blob이었다. 브라우저가 저장소에서 바로 파일을 받기 쉬워 미디어 전송 경로를 단순화하는 데 도움이 됐다. 그러나 이름 그대로 링크를 아는 사람은 파일에 접근할 수 있는 성격을 가진다.

앱에서 방 접근을 막았다고 해서 이미 알려진 공개형 객체 주소까지 앱의 권한 검사가 대신 막아 주지는 않는다. 메시지 목록을 그룹과 방 권한으로 보호하는 것과, 저장된 원본 자체를 비공개로 전달하는 것은 서로 다른 문제다.

그래서 Public Blob을 선택한 사실을 “첨부가 완전히 비공개가 됐다”는 뜻으로 적지 않기로 했다. 둘째 주의 구현은 원본 자동 전송과 서버 경유 비용을 줄이는 데 초점이 있었고, 링크 자체의 기밀성을 보장하는 구조는 아니었다.

자료의 민감도가 높다면 선택도 달라져야 한다. 비공개 저장소에서 요청할 때마다 권한을 확인하거나, 짧은 시간만 유효한 서명 주소를 발급하는 방식이 필요하다. 보관 기간, 삭제, 링크 재사용과 캐시 정책도 함께 정해야 한다.

이 구분은 이후 하루스페이스의 공개·비공개 데이터 원칙으로 이어졌다.

앱 화면이 비공개인 것, API가 접근을 거부하는 것, 저장 객체가 비공개인 것은 서로 다른 세 개의 경계다.

그룹이 생기자 모든 조회에 소속이 따라붙었다

미디어의 비용 경로를 고치는 동안 하루스페이스는 한 모임만 쓰는 서비스에서 여러 그룹이 함께 쓰는 서비스로 바뀌고 있었다. 이 변화는 그룹 선택 화면 하나를 추가하는 일이 아니었다. 사용자, 방, 메시지, 공지와 메뉴 설정이 어느 그룹에 속하는지 모든 주요 조회와 변경에서 확인해야 했다.

당시 첫 권한 모델은 플랫폼 전체를 관리하는 역할, 한 그룹을 관리하는 역할, 일반 구성원을 구분했다. 사용자가 승인되면 자기 그룹의 기본 공개방에만 들어가도록 했고, 과거처럼 신규 사용자를 모든 방에 자동으로 넣는 흐름은 금지했다. 공개방은 같은 그룹의 목록에 보일 수 있지만, 비공개방은 관리자나 이미 참여한 구성원에게만 보이게 했다. 비밀번호 잠금은 목록 공개 여부와 별도의 조건으로 두었다.

가장 중요한 문장은 문서의 아래쪽에 있었다.

클라이언트에서 버튼과 방을 숨기는 것은 보안이 아니다.

화면은 사용자가 실수하지 않도록 도울 뿐이다. 메시지 읽기와 쓰기, 첨부 원본 조회, 방 설정 변경은 서버가 세션, 그룹과 방 멤버십을 다시 확인해야 했다. 다른 그룹의 식별자를 직접 넣어 요청하더라도 데이터를 받을 수 없어야 했다. 존재 여부 자체가 불필요하게 드러나지 않도록 응답 방식도 조심해야 했다.

이 원칙 덕분에 화면과 서버의 책임이 분리됐다. 화면은 “이 사용자가 무엇을 할 수 있는가”를 친절하게 보여 준다. 서버는 “이 요청이 실제로 허용되는가”를 최종 판단한다. 두 결과가 어긋나면 화면을 고치는 것으로 끝내지 않고 서버 거부가 유지되는지 먼저 확인하게 됐다.

초대 링크는 멤버십이 아니었다

비공개방을 함께 쓰려면 초대가 필요했다. 링크를 누르면 편리했지만, 링크가 곧 권한이 되어서는 안 됐다.

우리는 초대 링크를 방으로 가는 이동 정보로 취급했다. 로그인하지 않은 사람은 먼저 인증으로 돌아가고, 인증을 마치면 원래 초대 흐름을 이어간다. 로그인한 클라이언트도 링크를 읽는 것만으로 구성원이 되지 않는다. 초대 정보를 서버에 제출하고, 서버가 같은 그룹의 승인된 사용자와 유효한 방인지 확인한 뒤에야 방 멤버십을 만든다.

이 구분은 사용 경험을 조금 더 복잡하게 만들 수 있다. 대신 링크가 검색 기록이나 메신저 전달 과정에 남았다는 이유만으로 세션과 그룹 확인이 생략되는 일을 막는다.

당시에는 이것을 다음처럼 나눠 생각했다.

  • 링크: 사용자가 어느 방에 들어가려는지 알려 주는 정보
  • 인증: 요청한 사람이 누구인지 확인하는 절차
  • 그룹 멤버십: 그 사람이 해당 공동체의 승인된 구성원인지 확인하는 상태
  • 방 멤버십: 그 구성원이 이 비공개방에 들어올 수 있다는 서버 기록

네 가지를 하나의 “초대 성공”으로 뭉치면 어느 단계에서 허용됐는지 알기 어렵다. 분리하면 실패 이유도 설명할 수 있고, 권한이 생기는 정확한 순간도 기록할 수 있다.

첫 경계는 완성된 경계가 아니었다

여기까지 쓰면 둘째 주에 보안 문제가 정리된 것처럼 보일 수 있다. 실제로는 그렇지 않았다.

그룹 경계를 도입한 첫 버전은 계정 가입 단계에서 한 그룹을 정하고, 플랫폼 관리자와 그룹 소속을 지금보다 가깝게 묶어 두었다. 이후 한 사람이 여러 그룹에 참여하게 되면서 계정 생성과 그룹 참여 요청을 분리했고, 플랫폼을 점검하는 권한과 실제 그룹 대화를 읽는 멤버십도 분리했다. 그룹을 나간 뒤의 비활성 상태, 재가입과 과거 기록 보존 같은 수명주기 규칙도 뒤에 더해졌다.

즉, 둘째 주의 성과는 완성된 권한 체계가 아니라 모든 데이터에 그룹이라는 경계를 적용하기 시작한 것이었다. 지금의 정책을 당시부터 모두 갖췄다고 말하면 변화 과정에서 배운 것을 지우게 된다.

더 직접적인 실패도 있었다. 몇 주 뒤 비밀번호가 걸린 초대 전용 방에서, 초대코드의 노출 조건과 비밀번호 검증이 서로 다른 경로에 놓인 허점을 발견했다. 화면에서는 잠긴 것처럼 보였지만 특정 조합에서는 초대 정보가 참여 전 사용자에게 보일 수 있었고, 서버도 초대코드와 비밀번호라는 두 조건을 한 번에 강제하지 못했다.

우리는 참여자에게만 초대 정보를 반환하도록 범위를 줄이고, 참여 전에는 관련 메뉴를 열 수 없게 했다. 더 중요한 수정은 서버가 두 조건을 모두 통과한 뒤에만 멤버십을 만들도록 순서를 바꾼 일이었다. 화면을 닫는 수정만 했다면 같은 요청을 다른 방식으로 보내는 문제는 남았을 것이다.

이 실패는 “초대 링크와 멤버십을 분리했다”는 초기 결정이 옳았다는 사실과, 그 구현이 충분했다는 주장이 서로 다르다는 것을 보여 줬다. 좋은 원칙도 모든 조합을 시험하지 않으면 빈틈이 생긴다.

비용과 권한은 같은 방식으로 새어 나갔다

돌아보면 미디어 비용과 방 권한 문제는 닮아 있었다.

원본 이미지는 목록에서 필요하지 않은데도 편의를 위해 응답에 따라왔다. 초대 정보는 참여 전 사용자에게 필요하지 않은데도 방 객체에 들어 있다는 이유로 따라갈 수 있었다. 둘 다 “이미 데이터에 있으니 함께 보내도 되겠지”라는 느슨한 경계에서 시작했다.

그래서 둘째 주 이후에는 API 응답을 만들 때 필드마다 같은 질문을 붙였다.

  • 이 화면과 이 행동에 지금 꼭 필요한가
  • 요청자는 이 값을 받을 소속과 권한이 있는가
  • 반복 호출될 때 크기와 비용이 어떻게 늘어나는가
  • 응답이 브라우저, 기록, 공유 링크에 남아도 괜찮은가

최소 전송과 최소 권한은 서로 다른 원칙처럼 보이지만 실무에서는 같은 설계 습관이었다. 필요한 사람에게, 필요한 순간에, 필요한 양만 보낸다. 비용 최적화는 종종 보안을 개선하고, 권한을 세밀하게 나누면 불필요한 조회도 줄어든다.

둘째 주가 남긴 실전 점검표

비슷한 채팅이나 협업 서비스를 만든다면 기능 단위가 아니라 데이터 이동 단위로 다음을 확인하고 싶다.

1. 반복 화면의 요청 횟수와 응답 크기를 함께 본다

폴링 간격만 늘리거나 파일 크기만 줄이지 않는다. 한 사용자의 한 시간당 호출 수, 응답 한 번의 평균 크기, 동시에 보는 사람 수를 곱해 위험을 추정한다. 그리고 추정값은 실제 전송량 지표와 분리해 기록한다.

2. 목록용 데이터와 상세용 데이터를 나눈다

목록에는 제목, 메타데이터와 썸네일만 둔다. 원본과 긴 본문은 사용자가 여는 순간에 별도로 요청한다. 썸네일이 없다고 원본을 자동 대체하지 않는다.

3. 저장소의 공개 범위를 제품의 공개 범위와 혼동하지 않는다

공개형 객체 주소를 비공개 채팅에서 사용한다면 그 한계를 문서에 남긴다. 민감한 자료는 비공개 저장, 서버 권한 확인이나 짧은 유효 기간의 접근 방식으로 분리한다.

4. 모든 읽기와 쓰기에서 소속을 다시 확인한다

화면에서 메뉴가 안 보이는지는 보조 확인일 뿐이다. 서버는 사용자 상태, 활성 그룹, 방 멤버십과 필요한 역할을 요청마다 확인한다. 다른 그룹의 식별자를 넣는 부정 경로 테스트도 포함한다.

5. 링크, 코드, 비밀번호와 멤버십을 별도 상태로 다룬다

각 값은 서로 다른 질문에 답한다. 두 개 이상의 조건이 필요한 방은 조건을 따로 통과시키지 말고, 서버의 한 결정에서 모두 확인한 뒤 멤버십을 만든다.

6. 성공 경로보다 조합표를 먼저 만든다

같은 그룹과 다른 그룹, 참여자와 비참여자, 공개방과 초대 전용방, 비밀번호 있음과 없음의 조합을 표로 만든다. 각 칸에서 목록 노출, 초대 정보, 메시지 읽기와 멤버십 생성 결과를 확인한다.

사진과 방이 남긴 다음 질문

둘째 주가 끝났을 때 하루스페이스는 사진을 더 가볍게 보여 주고, 그룹 밖의 요청을 서버에서 거부하는 첫 기준을 갖췄다. 그렇다고 비용이 모두 측정된 것도, 첨부가 완전한 비공개 저장으로 바뀐 것도, 권한 모델이 완성된 것도 아니었다.

중요한 변화는 기능의 이름 뒤에 운영 단위를 보기 시작한 일이었다.

사진 뒤에는 업로드 한 번, 목록 조회 여러 번, 썸네일과 원본의 서로 다른 수명이 있었다. 채팅방 뒤에는 화면 노출, 그룹 소속, 방 멤버십과 서버의 최종 거부가 있었다. 이 단위를 나누자 비용과 보안은 출시 뒤에 붙이는 점검 항목이 아니라 기능을 만드는 재료가 됐다.

한 장을 한 번만 보내고, 한 방을 허용된 사람에게만 여는 것. 둘째 주의 일은 결국 그 단순한 약속을 코드의 모든 경로에서 지키는 일이었다.

다음 기록에서는 이 폴링 구조를 실시간 연결로 바꾸면서 무료 전송량 한도와 구형 아이폰의 제약을 어떻게 함께 다뤘는지, 그리고 연결은 살아 있는데 일부 이벤트만 사라져 보였던 문제를 어떻게 추적했는지 이어가려 한다.

이어 읽기

이전 글 · 다음 글

이전 글복제 대신 목적을 선택하다 — 하루스페이스가 메신저가 된 첫 주다음 글 연결됨은 동기화됨이 아니었다 — 무료 한도·구형 iPhone·Realtime 누락을 다룬 이틀