Model diagram
erDiagram
User ||--|| Auth : "authenticates"
User ||--|| Friend : "친구 목록 / 상태"
User ||--|| Chat_User : "채팅방 참가 유저"
ChatRoom ||--|| Chat_User : "채팅방 참가 유저"
ChatLog ||--|| ChatRoom : "채팅방 로그 들"
User ||--|| ChatLog : "채팅 로그의 유저"
주요 기능
- 유저는 친구를 추가할 수 있다.
- 친구들과 1:1 채팅을 할 수 있다.
- 그룹챗을 만들 수 있다.
- 그룹챗의 주요 기능
- 친구 초대
- 채팅 로그들을 어떻게 관리 하지
- 채팅을 읽은 사람들을 표시해야 함
- 어디까지 읽었는지 알아야 한다.
erDiagram
User {
long id PK
string username
string password
string description
PhoneNumber phoneNumber
}
ChatRoom {
long id PK
enum type "private|group"
string name
String lastMessage
String lastTime
timestamp created_at
}
Friend {
long id PK
long user_id FK
enum status "requested|refused|banned|accepted"
timestamp created_at
boolean banned
}
ChatUser {
long id PK
long room_id FK
long user_id FK
enum role "admin|member"
timestamp created_at
}
ChatLog {
long id PK
long chatroom_id FK
long user_id FK
text content
enum type "text"
timestamp created_at
}
ReadReceipt {
long id PK
long chatlog_id FK
long user_id FK
timestamp created_at
}
ChatRoom ||--o{ ChatUser : "참여자"
ChatLog ||--o{ ReadReceipt : "읽음 상태"
ChatUser }o--|| User : "참여"
User ||--|| Friend : "친구 관계는<br/>양방향으로 관리"
ChatRoom ||--|{ ChatLog : "채팅방 당<br/>로그를 관리 한다."
ChatLog }|--|| User : "발신자 1:1 연결"
ReadReceipt }|--|| User : "사용자 당<br/>읽음 기록 보유"
유저 로그인 기능(세션 활용)
채팅 서비스 주요 기능
개인 채팅 방
- 채팅 방 생성
- 대화 기록 저장
- 읽음 여부 표시
그룹 채팅 방
css파일을 구조적으로 분리
components.css
- 개별 UI 컴포넌트(버튼, 카드, 모달 등)에 대한 스타일만 정의한다.
- 컴포넌트 단위로 재사용이 가능하도록 설계한다.
- 클래스명이나 컴포넌트명에 맞춰 스타일을 작성하며, 다른 컴포넌트와의 의존성을 최소화한다.
layout.css
- 전체 페이지의 구조와 배치(헤더, 네비게이션, 메인, 푸터 등)와 관련된 스타일을 담당한다.
- Flexbox, Grid, Position, Float 등 레이아웃 속성을 주로 사용한다.
- 페이지의 큰 틀을 잡는 역할을 한다.
pages.css
- 특정 페이지(예: 홈, 로그인, 마이페이지 등)에서만 사용되는 스타일을 정의한다.
- 페이지별로 고유한 스타일이 필요할 때 사용하며, components나 layout에서 포함되지 않는 예외적인 스타일을 포함한다.
base.css
- 전체 사이트에 공통적으로 적용되는 기본 스타일을 정의한다.
responsive.css
- 반응형 웹을 위한 미디어 쿼리와 관련된 스타일을 담당한다.
- 화면 크기(데스크탑, 모바일 등)에 따라 레이아웃이나 컴포넌트의 스타일이 다르게 적용되도록 설정한다.
의사 결정
채팅의 안읽은 유저를 표시하기
- 각 메시지별로 누가 읽었는지 기록하는 테이블을 두는 방식이다.
장점
- 누가 읽었는지 정확한 정보를 제공한다.
- 그룹 채팅에서 누가 읽었는지 상세히 보여줄 수 있다.
단점
- 데이터가 폭증한다.
- 쿼리가 복잡해지며 쓰기 부하가 올 수 있다.
ReadRecipt 테이블 활용
채팅방 멤버별 마지막 읽인 메시지 처리 방식
- 각 사용자가 해당 채팅방에서 마지막으로 읽은 메시지의 ID를 저장한다.
장점
- 효율적인 저장
- 빠른 쿼리 및 쓰기 부하 감소
단점
- 메시지별 읽음을 처리할 수 없다.
채팅 Flow 테스트 중에 영속성 컨텍스트와 DB 흐름 정리
예제 코드
- 채팅 테스트 코드
ChatRoom room = chatService
.createChatRoom(userA, List.of(userB), RoomType.PRIVATE, "secretRoom");
String firstChatData = "안녕하세요 B";
// when
chatService.writeChat(room, userA, "안녕하세요 A", ChatContentType.TEXT);
chatService.writeChat(room, userB, firstChatData, ChatContentType.TEXT);
// then
List<ChatLogResponseDto> chatLogs = chatService
.readChatLog(userA, room, PageRequest.of(0, 20));
// 채팅방의 최근 채팅을 확인
room = chatService.findChatRoomById(room.getId());
assertThat(room.getLastMessage()).isEqualTo(firstChatData);- WriteChat Method 코드
// 저장
ChatLog chatLog = chatLogRepository.save(ChatLog.createLog(room, user, chatData, type));
...
if (isUpdate) {
// 최근 메시지 업데이트
chatRoomRepository.updateLastMessageAndTime(
room.getId(), chatLog.getContent(), chatLog.getCreatedAt());
}- updateLastMessageAndTime Jpa 코드
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("UPDATE chat_room c SET c.lastMessage = :content, c.lastTime = :lastTime WHERE c.id = :id AND c.lastTime < :lastTime")
void updateLastMessageAndTime(Long id, String content, LocalDateTime lastTime);Flow 설명
createChatRoom()
- ChatRoom 객체가 생성되고 chatRoomRepository.save(…)를 통해 영속성 컨텍스트에 저장
- 트랜잭션 내에서 아직 flush되지 않았기 때문에 DB에는 미반영 상태
- 단, 이후의 chatLogRepository.save(…)에서 참조할 수 있음
writeChat(room, userA, “안녕하세요 A”, …)
- ChatLog 엔티티가 영속성 컨텍스트에 저장됨
- 아직 flush()되지 않았으므로 DB에는 반영되지 않았을 수 있음
chatRoomRepository.updateLastMessageAndTime(...)를 실행하면 flush와 clear가 실행된다.
@Modifying(flushAutomatically = true, clearAutomatically = true)아래와 같이 진행된다.
| 단계 | 설명 |
|---|---|
| flushAutomatically = true | EntityManager.flush() 호출 → **현재 영속성 컨텍스트에 있는 모든 변경사항(DB로 write)**→ ChatLog가 DB에 INSERT됨 |
| clearAutomatically = true | EntityManager.clear() 호출 → 영속성 컨텍스트가 완전히 초기화됨→ 기존에 저장된 ChatRoom, ChatLog 모두 비영속(detached) 상태가 됨 |
readChatLog(…) 및 findChatRoomById(room.getId())
- ChatLog를 DB로부터 페이지네이션 조회
- 이전 단계에서 flush()가 되어 DB에 2개의 ChatLog가 존재하므로 정상적으로 2개가 조회됨
흐름을 다이어그램으로 표현
sequenceDiagram
participant TestCode
participant ChatService
participant ChatLogRepo
participant ChatRoomRepo
participant EntityManager
participant Database
TestCode->>ChatService: writeChat(room, userA, "안녕하세요 A")
ChatService->>ChatLogRepo: save(ChatLog A)
ChatLogRepo->>EntityManager: persist ChatLog A (영속 상태 등록)
ChatService->>ChatRoomRepo: updateLastMessageAndTime(roomId, "안녕하세요 A", timeA)
ChatRoomRepo->>EntityManager: flushAutomatically
EntityManager->>Database: INSERT ChatLog A\nJPQL UPDATE ChatRoom
EntityManager->>EntityManager: clearAutomatically\n(영속성 컨텍스트 초기화)
Note over EntityManager, Database: ChatLog A, ChatRoom.lastMessage = "A" 상태
TestCode->>ChatService: writeChat(room, userB, "안녕하세요 B")
ChatService->>ChatLogRepo: save(ChatLog B)
ChatLogRepo->>EntityManager: persist ChatLog B
ChatService->>ChatRoomRepo: updateLastMessageAndTime(roomId, "안녕하세요 B", timeB)
ChatRoomRepo->>EntityManager: flushAutomatically
EntityManager->>Database: INSERT ChatLog B\nJPQL UPDATE ChatRoom
EntityManager->>EntityManager: clearAutomatically
Note over EntityManager, Database: ChatLog A+B 존재,\nChatRoom.lastMessage = "B"
TestCode->>ChatService: readChatLog(...)
ChatService->>Database: SELECT ChatLog ORDER BY createdAt DESC
Database-->>ChatService: [ChatLog A, ChatLog B]
TestCode->>ChatService: findChatRoomById(roomId)
ChatService->>Database: SELECT * FROM ChatRoom WHERE id = ?
Database-->>ChatService: ChatRoom.lastMessage == "안녕하세요 B"
flush만 한 경우
- flushAutomatically = true는 ChatLog와 ChatRoom에 대한 DB 업데이트를 제대로 수행함
- 그러나 EntityManager는 여전히 이전 ChatRoom 인스턴스를 보유한다.
- findChatRoomById()는 DB가 아닌 1차 캐시의 저장된 엔티티를 반환
- assertThat(room.getLastMessage())에서 테스트가 실패한다.
sequenceDiagram
participant TestCode
participant ChatService
participant ChatLogRepo
participant ChatRoomRepo
participant EntityManager
participant Database
Step B
TestCode->>ChatService: writeChat(room, userB, "안녕하세요 B")
ChatService->>ChatLogRepo: save(ChatLog B)
ChatLogRepo->>EntityManager: persist ChatLog B
ChatService->>ChatRoomRepo: updateLastMessageAndTime(...)
ChatRoomRepo->>EntityManager: flushAutomatically
EntityManager->>Database: INSERT ChatLog B\nUPDATE ChatRoom.lastMessage = "안녕하세요 B"
Note over Database: ChatRoom.lastMessage = "안녕하세요 B"
Note over EntityManager: 여전히 초기 상태의 room 인스턴스 보유 중
Step A: 안녕하세요 A
TestCode->>ChatService: writeChat("안녕하세요 A")
ChatService->>ChatLogRepo: save(ChatLog A)
ChatLogRepo->>EntityManager: persist ChatLog A
ChatService->>ChatRoomRepo: updateLastMessageAndTime
EntityManager->>EntityManager: JPQL 실행 전 flush() 발생
EntityManager->>Database: INSERT ChatLog A\nUPDATE ChatRoom
EntityManager->>EntityManager: clear() ← ChatLog A는 이미 저장됨
테스트 종료 시 flush + commit
Note over EntityManager: ChatLog B는 clear 이후 영속성 컨텍스트에 없음\n→ flush되지 않음
Read Room
TestCode->>ChatService: findChatRoomById()
ChatService->>Database: SELECT * FROM chat_room
Database-->>ChatService: ChatRoom.lastMessage = "안녕하세요 B"
Refresh Token 적용하기
Redis로 jpa 적용
DB가 아니라 Redis를 선택이유
- RefreshToken은 만료 이후에 오래 저장해서 관리를 할 필요가 없음
- TTL을 사용해서 일정시간이 지나면 자동으로 삭제되어 메모리 관리를 효율적으로 할 수 있음
코드
@RedisHash(value = "token", timeToLive = (REFRESH_TOKEN_TIME / 1000))
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Getter
public class RefreshToken {
@Id
private Long userId;
@Indexed
private String token;
private LocalDateTime createdAt;
}