티스토리 뷰

프로젝트

[트러블 슈팅] 채팅 서비스

perseverance 2023. 8. 9. 04:59

현재 진행중인 프로젝트에서 쪽지 관련 API를 만들면서 겪었던 문제들을 어떻게 해결하였는지 적어보려 한다.

1. Table 설계

현재 구현하려는 쪽지 기능은 카톡과 같이 한사람이 여러 채팅방을 가질 수 있으며 각각의 채팅방은 1대1 채팅방을 기본으로 한다.
이를 바탕으로 처음 설계한 Table은 다음과 같다.


user table은 간략히 표현해 보았다.
각각의 컬럼을 설명하자면 다음과 같다.

[room table]

  • id: pk값
  • last_content: 해당 채팅방의 마지막 쪽지 내용
  • time: 해당 대화방에서 마지막으로 주고받은 쪽지 시간
  • sender_id: 최초로 쪽지를 보낸 user의 pk값 (fk)
  • receiver_id: 최초로 쪽지를 받은 user의 pk값 (fk)
  • sender_is_deleted: 최초로 쪽지를 보낸 user가 해당 대화방을 삭제했는지 여부
  • receiver_is_deleted: 최초로 쪽지를 받은 user가 해당 대화방을 삭제했는지 여부

[dm table]

  • id: pk값
  • content: 쪽지 내용
  • is_from_sender: 최초로 쪽지를 보낸 user가 해당 쪽지를 만들었는지 여부
  • time: 쪽지를 보낸 시간
  • room_id: 채팅방의 pk값 (fk)
  • is_read: 상대방이 해당 쪽지를 읽었는지 여부

첫번째 문제점

위와 같이 table을 설계하였지만 문제점이 하나 발생하였다.
해당 문제는 채팅방을 삭제하고 다시 같은 상대방에게 대화를 걸었을때 생겼는데, 예시를 위해 최초에 쪽지를 보낸 사람을 sender라고 하고 쪽지를 받는 사람을 receiver라고 하자
 
sender와 receiver가 많은 쪽지들을 주고 받고 sender는 해당 채팅방을 삭제하였다. 이때 sender_is_deleted는 1이 되고 sender에게는 더이상 채팅방이 보이지 않는다.
 
이때 sender가 다시 receiver에게 쪽지를 보냈다고 가정하자. sender는 채팅방이 다시 보여야 하며 해당 채팅방에서 sender의 쪽지 내용은 채팅방을 삭제한 이후 다시 쪽지를 보낸 시점부터의 쪽지들이 보여야 한다.

  1. sender는 채팅방이 다시 보여야 하며
    -> 이건 sender_is_deleted를 다시 0으로 update하면 해결된다.
  2. 해당 채팅방에서 sender의 쪽지 내용은 채팅방을 삭제한 이후 다시 쪽지를 보낸 시점부터의 쪽지들이 보여야 한다.
    -> 여기서 문제가 발생하였다. 현재 table 설계대로라면 해당 요구사항을 만족하지 못한다.

 

해결

이러한 문제를 해결하기위해 dm에 visible이라는 새로운 컬럼을 넣었다.
visible은 enum타입으로 다음과 같은 값들을 가진다. 
 
`visible` ENUM ('BOTH', 'ONLY_SENDER', 'ONLY_RECIEVER', 'NOBODY')
- BOTH: sender와 receiver 둘다 해당 쪽지를 볼 수 있음 
- ONLY_SENDER: sender만 쪽지 확인 가능 
- ONLY_RECIEVER: receiver만 쪽지 확인 가능 
- NOBODY: 아무도 해당 쪽지를 볼 수 없음 
 
만약 sender가 채팅방을 삭제하면 채팅방에 있던 모든 쪽지들의 visible을 ONLY_RECIEVER로 update해주었다. 
sender가 다시 쪽지를 보내더라도 쪽지들을 select할때 where조건으로 쪽지의 visible이 BOTH이거나 ONLY_SENDER인 쪽지들을 보여주면 된다. 
 
이로서 문제가 해결된 줄 알았지만 여기서도 문제가 하나 있다. 
 

두번째 문제점  

채팅방을 삭제할때 이때까지 주고받은 모든 쪽지들의 visible을 update 처리 해줘야 한다. 
만약 이전에 나눈 쪽지의 개수가 1000개 이상이면 채팅방을 삭제할때 1000개의 쪽지를 update해야 한다는 소리이다.
이렇게 많은 쪽지를 주고 받은 채팅방에서의 삭제처리는 성능상 안좋을 것이라 판단되어 다른 방법을 선택하게 되었다. 
 

해결 

이러한 문제를 해결하기 위해 dm table에 컬럼을 추가하는 방식이 아닌 다른 방식으로 어떤 쪽지들을 보여줘야 하는지 알아내야 했다. 
잘 생각해보면 dm의 id값은 auto increment로 나중에 생성된 dm은 항상 id값이 가장 크다.
사용자가 대화방을 삭제하였을 때 room에 room을 삭제할 당시의 마지막 dm의 id값을 저장해준 후 나중에 다시 쪽지를 보내어 채팅방이 생기면 삭제할 당시의 마지막 dm의 id값 이후의 dm들을 보여주면 해당 문제를 해결 할 수 있다.
최종적으로 설계된 테이블은 다음과 같다. 
 

 
- sender_dm_cursor: sender가 해당 대화방을 삭제하였을때 마지막 dm의 id값
- receiver_dm_cursor: receiver가 해당 대화방을 삭제하였을때 마지막 dm의 id값
 
이 값을 보고 채팅방의 쪽지들을 조회시 sender라면 dm의 id가 sender_dm_cursor 이상인 데이터만 조회하면 되고 
receiver라면 dm의 id가 receiver_dm_cursor 이상인 데이터만 조회하면 된다. 
 

2. pagination

처음 특정 채팅방의 쪽지를 불러올때 offset기반 pagination을 통해 구현하였다. 하지만 여기서 몇가지 문제가 발생한다는 것을 알았다. 

문제1. 페이지를 요청하는 사이에 데이터의 변화가 있는 경우 중복 데이터 노출 

처음 page가 1인 쪽지를 10개 들고왔다고 하자 그럼 프론트쪽에서 10개의 데이터를 화면에 노출 시킨다. 
이때 사용자가 쪽지 3개를 보내고 다시 page가 2인 쪽지들을 요청하면 사용자가 쪽지를 보낸 개수(3개)만큼 중복된 쪽지 데이터가 응답으로 오게 된다. 
 
예를 들어 현재 room_id가 1인 dm이 다음과 같이 있다고 하자 첫 페이지를 요청했을때 다음 dm들이 나가게 된다.

SELECT * FROM DM ORDER BY ID DESC LIMIT 4 OFFSET 0
ID content
100 테스트4
99 테스트3
98 테스트2
97 테스트1

 
이제 두 번째 페이지를 요청하기 위해 다음과 같은 쿼리를 실행하게 된다. 
 

SELECT * FROM dm ORDER BY ID DESC LIMIT 4 OFFSET 4

 
그런데 첫 번째 쿼리와 두 번째 쿼리 사이에 새로운 쪽지가 삽입되었다고 가정하자 
 

ID content
101 테스트5
100 테스트4
99 테스트3
98 테스트2
97 테스트1

 
여기서 두 번째 쿼리를 실행하면 예상했던 결과인 ID가 96인 DM 부터 4개의 DM이 아닌 ID가 97인 DM부터 4개의 DM들을 반환한다. 
즉, ID가 97인 DM의 중복이 발생하게 된다. 
 

문제2. OFFSET 쿼리의 퍼포먼스 이슈 

기존에 사용 하는 페이징 쿼리는 일반적으로 다음과 같은 형태이다. 

SELECT * FROM DM ORDER BY ID DESC LIMIT 페이지 사이즈 OFFSET 페이지번호

 
이와 같은 형태의 페이징 쿼리는 뒤로갈수록 느린 이유는 결국 앞에서 읽었던 행을 다시 읽어야 하기 때문이다. 
 

 
예를 들어 limit 20, offset 10000 이라 하면 최종적으로 10,020개의 행을 읽어야 한다. 그리고 이중 응답으로 나가는 것은 마지막 20개 뿐이다. 
즉, 뒤로 갈수록 필요하지 않는 데이터지만 읽어야 할 행들이 많아 점점 더 느려지는 것이다. 
 
이러한 두가지 문제점을 해결하고자 OFFSET 기반 페이지네이션에서 CURSOR 기반 페이지네이션으로 바꾸게 되었다. 

cursor기반 페이지네이션으로 해결 

offset 기반 페이지네이션은 우리가 원하는 데이터가 몇 번째에 있다는 데에 집중하고 있다면, cursor 기반 페이지네이션은 우리가 원하는 데이터가 어떤 데이터의 다음에 있다는 데에 집중한다. 
 

SELECT * 
FROM DM 
WHERE 조건문 
AND id < 마지막 조회ID # 직전 조회 결과의 마지막 id
ORDER BY ID DESC 
LIMIT 페이지 사이즈

 
위 쿼리가 바로 cursor 기반 페이지네이션을 사용할때 기본적으로 사용되는 SQL문인데, 이전에 조회된 결과를 한번에 건너뛸수 있게 마지막 조회 결과의 ID를 조건문에 사용하는 것을 볼 수 있다. 즉, offset 기반 페이지네이션에서의 퍼포먼스 이슈를 해결할 수 있다. 
 
또한 마지막 조회 결과의 ID를 조건문에 사용하여 페이지 사이즈만큼 조회를 하니 사용자가 처음 10개의 데이터를 조회를 하고 난뒤 데이터를 삽입 하고 두번째 데이터를 조회할때 중복이 발생하지 않는다. 
 
예시를 들면 다음과 같다. 

SELECT * 
FROM DM 
WHERE  id < 101 # 직전 조회 결과의 마지막 id
ORDER BY ID DESC 
LIMIT 2

 
위 쿼리를 통해 다음 결과가 나온다 하자 
 

ID content
100 테스트100
99 테스트99

 
그러면 프론트로 응답을 줄때 직전 조회 결과의 마지막 id값인 99를 같이 반환해준다. 
그런다음 프론트는 다음 데이터가 필요할때 다음과 같이 요청하게 된다. api?cursor=99
프론트가 api?cursor=99를 요청하기 전에 쪽지를 하나 보내 DM 테이블에 튜플이 다음과 같이 하나 생성됐다고 하자. 
 

ID content
101 테스트101
100 테스트100
99 테스트99
98 테스트98

 
이제 api?cursor=99와 같이 요청을 보내면 나가는 쿼리는 다음과 같다.
 

SELECT * 
FROM DM 
WHERE  id < 99 # 직전 조회 결과의 마지막 id
ORDER BY ID DESC 
LIMIT 2

직전 조회 결과의 마지막 id값을 이용해 2개의 튜플을 가져오니 데이터가 삽입되더라도 중복되는 일이 발생하지 않게 된다. 
 

3. Mysql DeadLock 

DB log를 통해 DeadLock이 발생한것을 발견하고 어느지점에서 DeadLock이 발생하였는지 확인해 보았다. 
현재 쪽지를 보낼때 다음과 같은 순서로 쪽지를 보낸다. 
 
1.  로그인한 userId가 sender_id로 있는 대화방이 있는지 확인
   1-1. 없다면 로그인한 userId가 receiver_id로 있는 대화방이 있는지 확인
        1-1-1. 이것도 없다면 대화방 생성
2.  대화방의 sender_is_deleted, receiver_is_deleted 를 0으로 업데이트 
3. 해당 대화방의 pk를 fk로 가지는 쪽지 생성 
4. 대화방의 안읽은 쪽지 개수 +1 해주기 (업데이트)
 
해당 쿼리 순서를 확인해보니 외래키에 의한 잠금 전파가 이번 DeadLock의 발생 원인이었다.

  1: start transaction; #트랜잭션 A
  2:#1
  3:UPDATE room
  4:SET sender_is_deleted = 0, receiver_is_deleted = 0 
  5:WHERE id = 100
  6:
  7:
  8:#3
  9:UPDATE room
10:SET sender_unread_count = sender_unread_count+1
11:WHERE id = 100
 1: start transaction; #트랜잭션 B
  2:
  3:
  4:
  5:
  6:#2
  7:INSERT INTO dm (room_id,...) VALUES (100,...)
  8:
  9:
10:
11:

 쿼리 실행 순서를 순차적으로 확인해보자.
 
1. 트랜잭션 A는 room 테이블의 특정 ROW(id가 100인 ROW) 변경을 위해 X-Lock을 건다.
2. 트랜잭션 B는 Row의 INSERT절을 실행하려고 한다. dm 테이블은 채팅방(room) 테이블에 외래키를 가지고 있다.
   - 100 PK의 room을 조회하여 해당 ROW에 S-Lock이 전파되는데 트랜잭션 A에서 이미 X-Lock이 걸렸으므로 대기 상태가 된다. 
3. 트랜잭션 A는 다시 room 테이블의 특정 ROW(id가 100인 ROW) 변경을 위해 X-Lock을 걸지만 트랜잭션 B에서 S-Lock이 걸렸으므 로 대기상태가 된다.
4. 즉 트랜잭션 A,B가 서로의 잠금이 풀리기를 기다리는 교착 상태에 빠진다. 
 
이를 해결하기위해서는 쿼리 실행 순서를 바꿔주어 교착 상태를 해결하거나 외래키 제약 조건을 삭제하여 교착 상태를 해결해 줄 수 있다.
나같은 경우에는 외래키 제약조건이 필요한 상황이라 트랜잭션 A의 UPDATE를 하나로 합쳐주어 해당 문제를 해결하였다. 

DeadLock이 발생한 지점

DeadLock 해결 후 코드 

 

정리

1. Table 설계가 정말 중요하다.. 
2. OFFSET 기반 페이지네이션 방법은 지양하는것이 좋다.
3. DeadLock을 조심하자 

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/04   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30
글 보관함