ChatGPT 는 왜 다른 사람의 채팅을 보여준걸까?
오늘 42 동료들과 이야기를 하던 중 재미있는 주제가 나왔다. "지그재그"라는 플랫폼에서 회원 정보 탭에서 내 정보가 아닌 타인의 정보가 보인 적이 있다는 것. 비슷한 사례를 살펴보니, 2023 년 ChatGPT 에서도 발생한 적이 있었다. 이 두 사례에서는 대체 어쩌다가 내 로그인 정보로 다른 사람의 정보를 볼 수 있게 된 것일까?
임베디드로 온지 꽤 되었지만 백엔드를 해본 입장에서 상당히 이해가 되지 않는 문제라서 이리저리 함께 고민을 해보았다. (웃긴건 그 자리에 백엔드는 한 명 밖에 없었다. ㅋㅋ) 먼저 문제를 다루는 기사는 다음과 같다.
문제 정리


위 두 사례로 그 증세가 비슷하다. 대략 문제를 정리해보면 다음과 같았다.
- 사용자의 인증이 필요하며 해당 사용자에게 속한 정보를 조회하였다.
- 인증에 문제는 없었으며 타인의 정보가 노출되었다.
- 매번 같은 사람의 정보가 유출된 것이 아니라, 랜덤하게 다른 사람의 정보가 유출되었다.
추론
특히 재미있는 부분은 "랜덤하게 다른 사람의 정보가 보인다"는 것이다. 인증면에서도 문제가 없었으며 백엔드에서도 정상 동작을해서 응답을 준 상황으로 보인다. 그러면 문제가 있을 법한 부분은 요정도가 있지 않을까? 하고 이야기를 했다.
- 인증을 하고 엉뚱한 유저 정보를 반환하여 해당 값으로 데이터를 요청함
- PK 를 바탕으로 데이터를 조회하는 경우 잘못된 DB 관리로 인한 문제.
- DB 혹은 Cache 에 대한 비동기 요청에서 발생하는 문제.
1 번의 경우 가능성이 높진 않다고 생각했다. 높은 확률로 JWT 와 같이 비세션 방식을 통해 인증을 처리하고 있었을 것이고 (실제로 ChatGPT는 JWT 사용), 이 경우 서버 측 세션이 꼬여서 유저 정보를 잘못 반환하는 전형적인 문제는 발생하지 않는다.
다만, 그렇다고 해서 “절대 발생할 수 없다”는 것도 아니다. JWT 자체는 stateless지만, 클라이언트에서 토큰을 관리하는 방식이나, 프록시/캐시 서버의 처리, 혹은 백엔드의 context 보존 방식에 따라 예기치 않은 상태 꼬임이 발생할 여지는 있다. 그래도 이번 사례에서는 그런 식의 흐름 오류는 아니었던 것 같아서 가능성 낮게 봤다.
2 번의 경우는 PK 기반으로 데이터를 조회하면서, 여러 DB를 운영하는 구조에서 테이블을 나눠 쓰거나 마이그레이션 중 실수로 동일한 ID가 중복되어 나타나는 문제가 아니냐는 추론이었다. 예전부터 흔하게 나오는 멀티테넌시 관련 사고 중 하나이기도 하다.
그런데 이건 구조적으로 그렇게 설계했다면 진작에 테스트나 QA 과정에서 걸렸을 확률이 높고, 실제로 ID가 중복되더라도 “랜덤하게 타인의 데이터가 보인다”는 증상과는 조금 결이 다르다. 중복된 ID가 있다고 해서 매 요청마다 타인이 계속 바뀌는 식으로 유출되진 않을 가능성이 크기 때문이다. 물론 실수로 이렇게 만들 수는 있겠지만, 그게 root cause였다면 좀 더 다른 양상으로 문제가 나타났을 거라고 본다.
3 번의 경우 비동기로 DB 에 데이터를 얻는 과정에서 커넥션에 문제가 생겨 꼬인 것이 아니냐는 것. 그럴 수도 있지만 DB 와의 커넥션 요청에 대한 응답의 순서가 꼬인다는 것은 조금 이상하다. 요청에서 커넥션을 가지고 온 뒤 요청-응답의 pair 가 완성이 되어있을 것이기에 일반적으로 발생하지 않는다.
이야기를 나누다가, 기사를 통해 힌트를 얻었는데, Redis-py 라는 라이브러리와 비동기 상황에서의 문제라는 것을 알게되었다. redis 자체나 백엔드 서버 로직에서의 문제보다는 redis 와의 커넥션을 관리하거나 요청, 응답을 처리하는 과정에서 무언가 꼬인 것이다.
그러면 만약 redis 와의 연결에서 이러한 구조를 가지고 있다면 3번과 같은 케이스가 발생할 수 있다고 생각했다. Redis 와의 요청 / 응답 구조가 이렇게 되지 않을까? 싶었다. (결국 여러 요청에 대한 비동기적 응답 구조는 이렇게 되기 마련이니까)
- Request queue 를 통해 command request
- Response queue 를 통해 command response
- 즉, Request / Response 가 각 queue 를 통해 이루어짐
- 그런데 Request 에 대한 Response 유실이 발생 (못받거나 안받거나)
- Requester 입장에서 Response 가 하나씩 밀리는 문제가 발생
NVMe 에서는 CID 를 통해 Command 에 대한 요청-응답 매칭이 가능하지만, CID 와 같이 요청에 대한 응답임을 확인할 수 있는 구조가 아닌, 늘 순차적인 처리가 보장된다면 충분히 발생할 수 있는 문제이다. 이 경우 랜덤하게 나타나는 타인의 데이터를 설명할 수 있다.
원인 리뷰
그러면 실제 문제는 무엇일까? 이 문제는 이미 취약점으로 잘 정리가 되어있었다.

- connection pool 이 존재
- 한 connection 에서 redis 쪽으로 요청(write) 이후 request 취소
- response 가 pop 되기 이전 connection 이 망가지고, 해당 connection 자체는 살아서 반환
- 다른 요청에서 해당 Connection 을 받으면 dequeue 시 남아있던 response 를 받으면서 순서가 꼬임.
꽤 근접한 접근을 했다. (뿌듯) 그런데 글을 읽으면서, 재밌다고 느낀 점은 Redis 의 요청 처리구조는 CID 와 같이 request / response 에 대한 pair 를 확인할 수 없다는 것이다. 이 부분이 너무 당연하다고 생각했다. 왜냐면, NVMe 에서는 CID 를 통해서 이를 보장하곤하는데 (애초에 Request 에 대한 Response 의 순서가 보장되지 않으니, 없으면 안된다.) Redis 에서는 이러한 장치가 존재하지 않았다.
Redis 구조 맛보기
왜 이렇게 처리를 하는 것일까? 이는 Redis 의 철학과 연관된다.
- 단순화를 통해서 성능 극대화 / No Lock / Atomic / ... --> single thread
- single thread 기반이니 sequential 한 처리
- request / response 또한 FIFO 구조 + 상태 예측 가능.
그러니 redis 입장에서는 할 일을 해준 것이다. 그런데 문득, queue 에 대해서도 궁금함이 생겼다. 실제로 queue 는 어느 레벨에서 이루어지는 것일까? 만약 한 connection 에서 response 를 가져가지 않는다면 다른 connection 에서도 이에 대한 영향을 받는다. 그러나 상황을 살펴볼 때 connection 단위로 queue 가 유지되는 것으로 보인다.
Redis 에서의 queue 는 TCP 를 통한 socket stream 이다. 즉, connection 에서 client 는 server 와의 write buffer 에 write 하면서 요청을 하고, server 는 응답을 다시 buffer 에 써주면서 response 를 하는 구조이다. 즉, connection 이 끊기면 queue 가 날라가는 셈이다.
내부적으로 connection 또한 어떻게 처리되는지 궁금해서 찾아보니, single thread 기반이기에 epoll 과 같은 i/o multiplexing 을 통해 작동한다. epoll 을 통해 event 를 받아 i/o 요청을 처리하게 될 것이고, 각 connection 에서도 다시 순차적으로 request 에 대해 response 가 순차적으로 돌아가는 구조이다.
더 생각해볼만한 주제로는 이러한 순차적 처리 구조가 좋을 수도 있지만, 한 편으로는 processing time 이 긴 명령에 대해서는 이후의 latency 를 증가시키기에 single thread 구조에서는 다소 불리할 수도 있는 것 아닌가? 하는 것이다. 물론 atomic 하게 순차적으로 처리하도록 하는 것이 여러면에서 유리할 수는 있으나 atomic 하지 않아도 되는 명령이 들어오는 상황에서는 latency 를 늘리는 상황이 많이 연출될 수 있기 때문이다. 똑똑한 사람들이 만드니 분명 무슨 대책이 있었을 것 같은데, 나중에 한번 찾아볼만한 것 같다.
다른 곳에서는?
하나 더 생각해볼 주제라면, DB 나 다른 프로토콜은 어떻게 처리하는가? 이다. 이러한 문제는 MySQL, Postgres 와 같은 일반적인 DB 에서는 발생하지 않았다. 즉, 내부적으로 Request / Response 에 대한 pair 를 보장해주고 있다는 것이다. 이 방법에 대해 우선 찾아본 방식은 두 가지 정도로 보인다.
- Stream / Request 에 대한 고유 식별자 제공
- Connection 에서 한 번에 하나의 요청 처리
1 번의 경우 kafka / HTTP2 stream 에서의 방식이다. 찾아보지는 않았지만, 결국 request 에 대한 response 가 순차적이지 않은 환경이다. (Storage 의 I/O 도 마찬가지) 이러한 경우 만약 request 에 대해 Sequential 하게 처리 할 경우 느린 응답이 있는 경우 latency 가 굉장히 증가하게 될 것이므로 비효율적이다. 따라서 순차적이지 않은 응답에 대해 pair 를 만들 방법이 필요하고, 이를 고유 ID 를 활용한다.
2 번의 경우 RDBMS 에서 주로 선택하는 방식이다. 이들은 connection 에서 하나의 요청이 끝나고 난 뒤 그 다음 요청에 대해 처리하도록 한다. 물론 이들도 redis 와 달리 multi thread 를 통해 병렬적으로 처리되나 connection 단위의 격리된 context 를 통해 데이터 일관성을 보장하고자 함으로 보인다. (만약 connection 내에서 이렇게 하지 않으면 엄청나게 더 많은 장치들과 함께 우리는 배울게 더 늘어났을 것이다...)
반면, Redis 는 single-thread / sequential 처리로 작동하여 구조적으로 문제가 없으나 Client 측에서 async + connection pool 을 구현하는 과정에서 이를 처리하지 못하여 나타나는 문제가 되버린 케이스다.
마무리
상당히 재미있는 주제였다. (나는 임베디드 맨이라 잘 모르겠지만) 백엔드 엔지니어로서도 기초적인 이론에 기반해서 분석하고 배울 것이 많은 이슈인 것 같다. SSD FW 를 다루는 입장에서도 이러한 명령 처리 과정 중 failure 상황에서 발생할 수 있는 문제나 NVMe 에서의 queue 구조에 대해서도 한번 다시 생각을 해볼 수 있었다. (FW 내부에서의 명령 처리 구조에서도 고민해볼만한 재미있는 주제같다.) 덕분에 취업하고 한참 쉬던 블로그에 재미있는 글도 싣는다.