처음 프로젝트 진행 시에 친구 테이블을 어떻게 설계해야 할지 감이 안 잡혔었다.
생각해보고, 구글링으로 여러 사례를 참고해서 아래와 같이 친구 테이블 ERD를 설계하였다.
🎯 Friend 테이블 중심 Column
friend 테이블에서 중요하게 봐야할 Column은
'from_user_id'와 'to_user_id', 'are_we_friend' 이다.
나머지 컬럼들은 friend User의 정보들이라서 크게 고민해야 할 것은 없다.
1. from_user_id : 친구 요청을 보낸 User의 id
2. to_user_id : 친구 요청을 받은 User의 id (FK로, User 테이블의 PK)
3. are_we_friend : 서로 친구인지 판단하는 Column
따라서 앞으로 설명 시에 위의 3가지 컬럼을 중심으로 설명해보고자 한다.
🎯 1. 첫 번째 구현 시도 - 요청당 하나의 레코드 추가(정방향 레코드만)
우선 프로젝트를 진행할 때 서로 친구가 되는 프로세스는 다음과 같았다.
A, B 두 명의 유저가 존재한다고 할 때,
1. A -> B에게 친구 요청 보내기
2. B가 A의 친구 요청 수락or거절
단순히 생각했을 때,
1번을 구현하기 위해서 A -> B에게 친구 요청을 보냈을 시에
'from_user_id' 컬럼에 A User의 id를 넣고, 'to_user_id' 컬럼에 B User의 id를 넣으면 됐다.
그리고 'are_we_friend' 컬럼은 false로 설정을 하면 될 것이라고 생각했다.
그리고 2번을 구현하기 위해서 B가 A의 친구 요청을 수락하면
'are_we_friend' 컬럼을 true로 변경하고,
B가 A의 친구 요청을 거절하면
해당 행을 삭제하는 로직을 구현하면 될 것이라고 생각했다.
위처럼 로직을 구현하고, 상황을 추가해서 데이터를 여러 개 추가했을 때
테이블 상황은 다음과 같았다.
(글에서는 이해상 user_id 컬럼에 user A의 id가 아닌, 'A' 이렇게 User 자체로 표현하도록 하겠다!)
A <-> B : A가 B에게 친구 요청을 보내고, B가 수락하여 A, B 서로 친구인 상태
C <-> A : C가 A에게 친구 요청을 보내고, C가 수락하여 A, C 서로 친구인 상태
A -> D : A가 D에게 친구 요청을 보낸 상태
E -> A : E가 A에게 친구 요청을 보낸 상태
from_user_id | to_user_id | are_we_friend
A | B | true
C | A | true
A | D | false
E | A | false
이 상황에서, 내가 구현해야 하는 기능은 3가지였다.
1. 내가 친구 요청 보낸 친구 조회 기능
2. 나에게 친구 요청 보낸 친구 조회 기능
3. 나와 서로 친구인 친구 조회 기능
위와 같은 테이블 상황에서 하나씩 기능 구현을 해보려고 했다.
A가 로그인했다고 가정하고, A의 입장에서 생각해보았다.
1. 내가 친구 요청 보낸 친구 조회 기능
우선 첫 번째 기능에서 조회 시 A와 친구가 되기 전인, A가 친구 요청 보낸 친구는 D이다.
조회 시 'from_user_id' 컬럼이 'A'이고, 'are_we_friend' 컬럼이 'false'인 행의
'to_user_id' 컬럼인 유저 id가 D의 유저 id로, 이 userId로 조회하면 됐다.
2. 나에게 친구 요청 보낸 친구 조회 기능
두 번째 기능에서 조회 시 A와 친구가 되기 전인, A에게 친구 요청 보낸 친구는 E이다.
조회 시 'to_user_id' 컬럼이 'A'이고, 'are_we_friend' 컬럼이 'false'인 행의
'from_user_id' 컬럼인 유저 id가 E의 유저 id로, 이 userId로 조회하면 됐다.
3. 나와 서로 친구인 친구 조회 기능
문제는 3번에서 발생했다.
A와 서로 친구인 친구는 B와 C였는데
B는 A가 친구 요청을 보냈기 때문에 'from_user_id' 컬럼이 'A'이고,
C는 C가 A에게 친구 요청을 보냈기 때문에 'to_user_id' 컬럼이 'A'였다.
따라서, 이러한 상황을 모두 고려해서 조회하기 위해서는
'from_user_id' 컬럼이 'A'이고, 'to_user_id' 컬럼이 'A'인 행을 조회해야하므로
이 두가지 상황을 모두 쿼리해야한다는 점이 문제였다.
물론 문제까지는 아니지만, 쿼리 조회 성능적으로 좋지 못한 것 같았다.
또한, 다른 문제점도 존재했는데
이미 친구인 상황에서 역방향으로 요청 시에 요청이 성공한다는 점이다.
위의 상황에서 A가 B에게 친구 요청을 하고, B가 친구 요청 수락을 하여 서로 친구인 상황인데,
B가 A에게 친구 요청을 보낸다면 'from_user_id'과, 'to_user_id' 이 서로 바뀌기 때문에
다른 행이 되어 요청이 성공하게 된다.
이러한 2가지 문제 때문에 다른 방법을 찾아보게 되었다.
🎯 2. 두 번째 구현 시도 - 요청당 두 개의 레코드 추가(정방향 레코드 + 역방향 레코드)
첫 번째 시도 시 위의 의문점을 품고 구글링을 해보니,
친구 추가 요청 시 역방향 레코드도 INSERT를 하는 방법으로 해결할 수 있었다.
요약하면, A가 B에게 친구 요청을 보낼 때 다음과 같이 INSERT된다.
from_user_id | to_user_id | are_we_friend
A | B | true
B | A | false
이렇게 친구 요청 시에 역방향 레코드도 추가함으로써 앞선 2가지 문제를 해결했다.
이렇게 역방향 레코드를 추가한 결과, 테이블 상황은 다음과 같다.
A <-> B : A가 B에게 친구 요청을 보내고, B가 수락하여 A, B 서로 친구인 상태
C <-> A : C가 A에게 친구 요청을 보내고, C가 수락하여 A, C 서로 친구인 상태
A -> D : A가 D에게 친구 요청을 보낸 상태
E -> A : E가 A에게 친구 요청을 보낸 상태
from_user_id | to_user_id | are_we_friend
A | B | true
B | A | true
C | A | true
A | C | true
A | D | true
D | A | false
E | A | true
A | E | false
A와 서로 친구인 유저를 조회하기 위해서
'from_user_id' 컬럼이든, 'to_user_id' 컬럼이든 한 가지로 조회하면
서로 친구인 유저가 빠짐없이 조회되게 되었다.
따라서 한 번의 쿼리로 조회가 가능해졌다.
또한,
A가 B에게 친구 요청을 할 때 역방향 레코드(B->A에게 친구 요청)도 생겼기 때문에,
B가 A에게 요청을 보낼 때 이미 있는 데이터이기 때문에 보낼 수 없게 된다.
따라서 이미 친구인 상황에서 역방향으로 요청 시 요청이 실패한다.
이렇게 2가지 문제를 해결한 대신,
서로 친구인 유저 조회 기능 구현 로직이 조금 바뀌게 됐다.
🎯 나와 서로 친구인 친구 조회 기능
이전에는 하나의 행만 조회해서 친구 여부를 판단했었는데,
역방향 레코드를 추가해서 A와 관련된 행이 2개가 되었기 때문에
2개의 행을 엮어서 판단해야한다.
from_user_id | to_user_id | are_we_friend
A | B | true
B | A | true
하나의 쿼리에서
'from_user_id' 컬럼이 'A'고 'are_we_friend' 컬럼이 'true'인 조건을 판단하고,
해당 'to_user_id' 컬럼이 'A'고 'are_we_friend' 컬럼이 'true'인 행이 있다는 조건을 판단해야한다.
따라서, 하나의 테이블에서 두 개의 컬럼을 사용해야하므로, 셀프 조인을 사용해야한다!
프로젝트에서는 Querydsl을 사용중이었는데, Querydsl에서 셀프 조인하는 방법을 몰랐었다.
구글링을 해보니, 따로 별칭을 주는 것처럼 Q객체를 새로 생성하여 다음과 같이 사용하면 됐었다.
public List<Friend> findAllFriendsWithEachOther(Long loginUserId) {
QFriend selfFriend = new QFriend("selfFriend");
return query.selectFrom(friend)
.join(selfFriend)
.on(friend.toUser.id.eq(selfFriend.fromUserId))
.where(
friend.fromUserId.eq(loginUserId),
friend.areWeFriend.eq(true),
selfFriend.areWeFriend.isTrue()
)
.fetch();
}
💻 1, 2번째 방법 차이 요약
첫 번째 방법은 하나의 행으로 기능을 처리하기 때문에 DB 데이터가 가벼운 장점이 있었다.
하지만, 역방향 요청이 성공한다는 단점과
서로 친구인 목록 조회 시 'from_user_id' 컬럼이든, 'to_user_id' 컬럼 모두 조회해야 한다는 단점이 있었다.
프로젝트에서 사용한 두 번째 방법은,
첫 번째 방법의 단점을 요청 시 역방향 레코드를 추가하면서 2개 행으로 처리하여 해결했다.
하지만 요청 당 레코드 2개씩 늘어나기 때문에 그만큼 DB 데이터 크기가 늘어난다는 단점이 존재했다.
내 프로젝트는 조그마한 사이드 프로젝트였기 때문에 데이터가 많이 없어서 체감은 나지 않았지만
실제 유저가 많은 배포 환경에서도 이렇게 2개씩 사용하는 것이 부담이 되지 않을까? 라는 궁금증이 남았다.
하지만, 첫 번째 방법은 치명적인 문제점이 있었기 때문에 두 번째 방법으로 구현할 수 밖에 없었다!
Reference
https://okky.kr/articles/590874
'DB' 카테고리의 다른 글
[DB] MySQL InnoDB의 인덱스(feat. 클러스터링 인덱스, 세컨더리 인덱스, 인덱스 스캔 종류, 다중 컬럼 인덱스) (1) | 2023.11.13 |
---|---|
[DB] DB 인덱스(Index)란? (1) | 2023.11.12 |
[DB] DB Lock이란? (feat. Lock 종류, 블로킹, 데드락) (0) | 2023.11.08 |
[DB] 트랜잭션 격리 수준 알아보기 (2) | 2023.09.29 |
[DB] 트랜잭션이란? (feat. ACID 특성) (2) | 2023.04.18 |