친구 기능 - 사용자 기능 요구사항
| Req.ID | 기능 ID | 기능명 | 요구사항 내용 |
|---|---|---|---|
| UFR.03 | UFR03_FRD01 | 친구 검색 | 사용자는 학번 또는 닉네임을 이용하여 다른 사용자를 검색할 수 있다. |
| UFR03_FRD02 | 친구 요청 | 사용자는 검색한 다른 사용자에게 친구 요청을 보낼 수 있다. | |
| UFR03_FRD03 | 친구 요청 수락 | 사용자는 받은 친구 요청을 수락할 수 있다. | |
| UFR03_FRD04 | 친구 요청 거절 | 사용자는 받은 친구 요청을 거절할 수 있다. | |
| UFR03_FRD05 | 친구 목록 조회 | 사용자는 자신의 친구 목록을 조회할 수 있다. | |
| UFR03_FRD06 | 친구 삭제 | 사용자는 기존 친구 관계를 삭제할 수 있다. | |
| UFR03_FRD07 | 전송한 요청 목록 조회 | 사용자는 자신이 보낸 친구 요청 내역을 볼 수 있다 | |
| UFR03_FRD08 | 전달받은 요청 목록 조회 | 사용자는 자신이 받은 친구 요청 내역을 볼 수 있다. |
친구 기능 - 시스템 기능 요구사항
| Req.ID | 기능 ID | 기능명 | 요구사항 내용 | 사용 데이터 | 연관 UFR ID |
|---|---|---|---|---|---|
| SFR.03 | SFR_FRD01 | 친구 검색 | 백엔드 서버는 클라이언트로부터 전달받은 닉네임으로 사용자를 검색하고 닉네임과 일치하는 사용자가 존재할 경우 해당 사용자의 ID를 반환한다. | 사용자 닉네임 | UFR03_FRD01 |
| SFR_FRD02 | 친구 요청 | 백엔드 서버는 클라이언트로부터 전달받은 친구 요청 정보를 데이터베이스에 저장하고, 요청 대상 사용자에게 알림을 전송한다. | 요청자 ID, 요청 대상자 ID | UFR03_FRD02 | |
| SFR_FRD03 | 친구 요청 수락 | 백엔드 서버는 클라이언트로부터 전달받은 친구 요청 수락 정보를 데이터베이스에 반영하여 두 사용자를 친구 관계로 설정한다. | 요청자 ID, 수락자 ID | UFR03_FRD03 | |
| SFR_FRD04 | 친구 동일 대학 여부 확인 | 백엔드 서버는 친구 요청 수락 단계에서 두 사용자가 같은 대학인지, 다른 대학인지를 검증하여 데이터베이스에 저장할 때 반영한다 | 요청자 ID, 수락자 ID, 요청자 대학 정보, 수락자 대학 정보 | UFR03_FRD03 | |
| SFR_FRD05 | 친구 요청 거절 | 백엔드 서버는 클라이언트로부터 전달받은 친구 요청 거절 정보를 데이터베이스에 반영한다. | 요청자 ID, 거절자 ID | UFR03_FRD04 | |
| SFR_FRD06 | 친구 목록 조회 | 백엔드 서버는 클라이언트의 요청에 따라 해당 사용자의 친구 목록을 데이터베이스에서 조회하여 반환한다. | 사용자 ID | UFR03_FRD05 | |
| SFR_FRD07 | 친구 삭제 | 백엔드 서버는 클라이언트로부터 전달받은 친구 삭제 요청을 처리하여 데이터베이스에 반영한다. | 사용자 ID, 삭제할 친구 ID | UFR03_FRD06 | |
| SFR_FRD08 | 전송한 요청 목록 조회 | 벡엔드 서버는 사용자가 전송한 친구 요청 목록을 데이터베이스에서 조회하여 반환한다. | 사용자 ID | UFR03_FRD07 | |
| SFR_FRD09 | 전달받은 요청 목록 조회 | 벡엔드 서버는 사용자가 전달받은 친구 요청 목록을 데이터베이스에서 조회하여 반환한다. | 사용자 ID | UFR03_FRD08 |
핵심적으로 고려한 부분은 SFR_FRD08과 SFR_FRD09 이다. 친구 요청을 누가 전송했고, 누가 수락한 것인지에 대한 데이터를 저장하는 컬럼이 필요해 보인다.
도메인 요구사항을 충족시키기 위한 테이블을 설계하는데 꽤 애를 먹었다.
처음에는 단순하게 친구 요청을 보낸 사람, 친구 요청을 받은 사람, 요청의 상태만으로 테이블을 구성 할 수 있을 것 같았다.
1. A 사용자가 B에게 친구 요청을 보낸 경우
|REQUEST_USER|RECEIVE_USER|STATUS |
|------------|------------|-------|
| A | B |PENDING|
2. B가 A의 요청을 수락한 경우
|REQUEST_USER|RECEIVE_USER|STATUS |
|------------|------------|--------|
| A | B |ACCEPTED|
3. B가 A의 요청을 거절한 경우
|REQUEST_USER|RECEIVE_USER|STATUS |
|------------|------------|--------|
| A | B |REJECTED|
그러나 이렇게 테이블을 설계한 경우 서로 중복해서 친구 요청을 보내면 애매한 상황이 발생하게 된다.
1. A 사용자가 B에게 친구 요청을 보낸 경우
|REQUEST_USER|RECEIVE_USER|STATUS |
|------------|------------|-------|
| A | B |PENDING|
2. 1의 상황에서 추가적으로 B가 A에게 친구 요청을 보낸 경우
|REQUEST_USER|RECEIVE_USER|STATUS |
|------------|------------|--------|
| A | B |PENDING |
| B | A |PENDING |
좀 더 직관적으로 상황을 해결하기 위해서 A 사용자가 B에게 요청을 보낸경우 A -> B 와 B -> A 두가지 레코드를 모두 생성하기로 했다.
첫번째 레코드에선 REQUEST_USER 가 A이고, RECEIVE_USER가 B 이며 STATUS 컬럼은 PENDING이다. 두번째 레코드에선 REQUEST_USER가 B 이고, RECEIVE_USER가 A이다. 이 레코드의 STATUS는 REJECTED 로 처리한다.
1. A 사용자가 B에게 친구 요청을 보낸 경우
|REQUEST_USER|RECEIVE_USER|STATUS |
|------------|------------|---------|
| A | B |PENDING |
| B | A |REJECTED |
2. B 사용자가 A에게 친구 요청을 보낸 경우
|REQUEST_USER|RECEIVE_USER|STATUS |
|------------|------------|---------|
| B | A |PENDING |
| A | B |REJECTED |
두번째 레코드의 STATUS를 PENDING으로 둔다면 누가 누구에게 친구 요청을 보낸 것인지 구분 할 수 없어진다.
1. A 사용자가 B에게 친구 요청을 보낸 경우
|REQUEST_USER|RECEIVE_USER|STATUS |
|------------|------------|---------|
| A | B |PENDING |
| B | A |PENDING |
2. B 사용자가 A에게 친구 요청을 보낸 경우
|REQUEST_USER|RECEIVE_USER|STATUS |
|------------|------------|---------|
| B | A |PENDING |
| A | B |PENDING |'
서로 다른 두 상황을 구분 할 수 없다.
요청을 받은 사용자가 친구 요청을 수락할 경우 연관된 두 레코드의 STATUS를 모두 ACCEPTED로 처리한다. 반대로 거절한 경우 두 레코드의 STATUS를 REJECTED로 처리하면 된다.
1. A 사용자가 B에게 친구 요청을 보낸 경우
|REQUEST_USER|RECEIVE_USER|STATUS |
|------------|------------|---------|
| A | B |PENDING |
| B | A |REJECTED |
2. B 사용자가 A의 요청을 수락한 경우
|REQUEST_USER|RECEIVE_USER|STATUS |
|------------|------------|--------|
| A | B |ACCEPTED|
| B | A |ACCEPTED|
3. B 사용자가 A의 요청을 거절한 경우
|REQUEST_USER|RECEIVE_USER|STATUS |
|------------|------------|--------|
| A | B |REJECTED|
| B | A |REJECTED|
문제가 해결된 것 처럼 보이지만, 이 경우 친구 요청이 수락 혹은 거절된 상황에서 두 사용자 중 누가 요청을 보내고, 누가 요청을 받아서 처리했는지 알 수 없다는 또 다른 문제가 존재한다. 이를 해결하기 위해 각각 요청을 수락한 사람의 ID와 요청을 거절한 사람의 ID를 저장하는 컬럼을 추가했다.
1. A 사용자가 B에게 친구 요청을 보낸 경우
|REQUEST_USER|RECEIVE_USER| STATUS |ACCEPT_USER_ID|REJECT_USER_ID|
|------------|------------|----------|--------------|--------------|
| A | B | PENDING | NULL | NULL |
| B | A | REJECTED | NULL | NULL |
2. B 사용자가 A의 요청을 수락한 경우
|REQUEST_USER|RECEIVE_USER| STATUS |ACCEPT_USER_ID|REJECT_USER_ID|
|------------|------------|----------|--------------|--------------|
| A | B | ACCEPTED | B | NULL |
| B | A | ACCEPTED | B | NULL |
3. B 사용자가 A의 요청을 거절한 경우
|REQUEST_USER|RECEIVE_USER| STATUS |ACCEPT_USER_ID|REJECT_USER_ID|
|------------|------------|----------|--------------|--------------|
| A | B | REJECTED | NULL | B |
| B | A | REJECTED | NULL | B |
최종 테이블 형태

오랫동안 고민해서 위 테이블도 꽤 많은 문제가 있어보인다.
- 서비스의 이용자가 많아지고 친구 요청이 많아질 수록 데이터베이스의 레코드 수가 2배로 증가한다.
- 테이블의 구조가 복잡하고 데이터베이스의 구조를 통해 비즈니스 로직을 유추하기 어렵다.
- 데이터 조회 시 양방향 레코드를 조회해야 하므로 조회 성능이 떨어진다.
그러나 서비스의 특성 상 해당 도메인의 사용 빈도가 적을 것으로 보이고, 복잡한 테이블 구조는 반대로 여러 가지 통계 기능을 제공하는데 용이하다는 장점이 있으므로 이러한 구조를 사용하기로 했다.
@Entity
@Getter
@NoArgsConstructor
public class Friend extends BaseEntity {
@Id @GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "REQUEST_USER_ID")
private User requestUser;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "RECEIVE_USER_ID")
private User receiveUser;
@Enumerated(EnumType.STRING)
private FriendStatus status;
private boolean isSameUniversity;
@Setter
private Long rejectUserId;
@Setter
private Long acceptUserId;
requestUser와 receiveUser에 대해 단방향 연관관계만을 정의했다. User 엔티티에서 Friend 엔티티로의 역방향 참조는 비즈니스 로직 상 불필요하므로 복잡한 양방향 연관관계를 정의하지 않았다.
rejectUserId와 acceptUserId는 User 엔티티와 연관관계를 맺지 않았다. 이 컬럼들은 누가 요청을 수락한 것인지에 대한 정보를 저장하는 용도이므로 이들을 통해 User를 조회할 일이 드물 것이라고 판단했다.
friendStatus는 PENDING, ACCEPTED, REJECTED 의 값을 가진다.
엔티티 간 연관관계가 복잡해질수록 예상치 못한 문제 발생 가능성이 높아지고 성능 최적화가 어려워진다. 특히 추후 기능 확장을 고려할 때, 도메인 상에서 필수적인 연관관계만을 정의하는 것이 좋은 구조의 JPA 엔티티를 설계하기 위한의 효율적인 방법일 것이다.
참고한 글