PJT
프로젝트 회고
⁉️ 내가 고민했던 상황
AI 서버는 Kafka에 실시간으로 학습로그를 던진다.
그 간격에는 규칙성이 없고, 이벤트가 바뀔 때 학습로그를 찍게 되어있다. 이때 Kafka와 AI가 주고받을 데이터 형식은 {sessionId, eventType, detectedAt}이다.
이때 sessionId는 UUID로 고유한 값이라 memberId까지 식별해낼 수 있다. 멤버별로 순 공부시간, 졸음 횟수, 자리비움 횟수, 핸드폰 횟수를 구해야 한다.
기존에는 Redis에 모든 데이터를 넣고 사용하려고 했으나, 휘발성과 부하의 문제로 Kafka를 도입하게 되었다. 이때 1시간 주기로 티어를 업데이트하려고 했으나, Kafka의 도입 의미와 서비스 가치적으로 1분 주기로 티어를 갱신하기로 하였다.
Kafka로 들어온 StudyLog 데이터는 바로 DB에 저장되지 않는다. Redis 3개에 분산 저장된다.
💡 처음에 고려한 해결책
3개의 Redis를 사용해 캐싱으로 해결하려 했다.
1번째 Redis <study:session:{sessionId}:info, memberId}
- 어느 memberId에 해당하는 데이터인지 DB를 매번 조회하는 것은 비효율적이다. 따라서 세션이 시작될 때 Redis에는 해당 테이블을 조회해 key-value 형태로 sessionId에 매칭하는 memberId를 저장해둔다.
- 이는 이후 어느 멤버의 티어 점수를 갱신할지 정할 때 조회될 읽기 전용 메모리가 된다.
2번째 Redis <study:session:{sessionId}:state, {lastType:””, lastTime:””}>
- 마지막으로 추적된 상태 변화를 기록한다.
- 가장 최근에 들어온 학습 로그의 eventType과 lastType이 일치하면 lastTime만 변화하겠지만, 만약 FOCUS -> PHONE/AWAY/SLEEP 형태로 바뀌었다가 반대로 변한다면 PHONE/AWAY/SLEEP의 횟수가 누적되는 동시에 lastTime, lastType도 함께 변한다.
- 즉 eventType이 바뀌면 그에 따른 lastTime을 기록해 두어 순 공부시간, 졸음/ 자리비움/ 핸드폰 횟수를 계산하기 위한 임시 메모리가 된다.
- 이때 관련 계산은 3번째 Redis에서 이루어진다.
- 이 데이터들은 세션 시작~종료에만 유의미한 데이터가 된다. 따라서 해당 sessionId에 대해 유의미한 세션인지에 대한 처리가 필요하다.
3번째 Redis <study:member:{memberId}:delta, {time:, deltaScore:, sleep:, phone:, away:}>
- 2번째 Redis에 쌓인 데이터를 기반으로 1분 주기로 계산된다.
- 순 공부시간과 졸음/ 자리비움/ 핸드폰 횟수 계산은 어떻게 하는가?
- 1분 주기로 flush된다.
- 이 데이터들은 1분 주기로 DB에 저장되는가? 그렇다면 언제 DB에 반영되는가?
그러나 과연 state를 저장하는 레디스가 필요할 것인가에 대해 돌아봐야했다.
✅ 최종 해결책
AI서버 → Kafka → Redis A, B ↔ Spring ↔ DB
AI 서버 → Kafka
- 전달되는 데이터의 형식:
{sessionId:"", eventType:"", detectedAt:""}
Kafka → Redis A, B
- Redis A
<study:session:{sessionId}:info, {memberId}>- 필요성: DB에 StudySession 테이블을 조회해서 UUID인 sessionId로 memberId를 찾을 수 있다. 이때 세션에 들어있는 멤버가 누군지 찾을 때마다 쿼리를 날려야 하니까 이걸 캐싱으로 효율을 높인다.
- 언제 초기화할 것인가: 세션에 멤버가 입장할 때 데이터가 생겼다가 멤버가 퇴장하면 해당 memberId에 대한 데이터는 끊기겠지
- 언제 clear할 것인가: 필요한가?
- 필요한 예외처리: 그 세션이 유효한지에 대한 체크가 되면 좋을 것 같긴 한데 → 이게 유령 세션 처리인가 exitRoom 좀 공부하면 되려나
- Redis B
<study:member:{memberId}:delta, {time:"", score:"", sleep:"", phone:"", away:""}>- 언제 DB에 Schedule할 것인가?
- 1분 주기로 Scheduler에 의해서 DB에 반영된다.
- 이때 Redis에는 memberId 별로 1분 동안 생긴 변경분이 존재한다. 이를 DB에 반영하는 방식이므로 Dirty Writing 의 방식의 일환이다.
- 어떤 방식으로 DB에 write할 것인가?
- 추천 패턴: JPQL 사용
@Modifying @Query("UPDATE MemberGameStat m SET m.totalStudyTime = m.totalStudyTime + :time, m.tierScore = m.tierScore + :score WHERE m.member.id = :memberId") void accumulateStats(@Param("memberId") Long memberId, @Param("time") int time, @Param("score") int score); - 해당 Redis를 썼을 때와 안 썼을 때의 차이
- 가정: 동시 접속자 1,000명, AI는 3초마다 로그 전송.
- Redis 없이 직접 DB에 넣을 경우 (기존 방식)
- 1명당 1분에 로그 20개 발생 (60초 / 3초).
- 1,000명 × 20개 = 분당 20,000번의 DB 트랜잭션
- 초당 처리량(TPS): 약 333 TPS
- 👉 위험: DB가 슬슬 힘들어하고, 사용자가 늘어나면 100% 터집니다.
- Redis 모아서 1분마다 넣을 경우 (현재 설계)
- 1명당 1분에 딱 1번 업데이트 (스케줄러).
- 1,000명 × 1번 = 분당 1,000번의 DB 트랜잭션
- 초당 처리량(TPS): 약 16 TPS
- 👉 안전: MySQL(EC2 t3.medium 급만 되어도)은 초당 수천 건의 단순 Update를 가볍게 처리합니다. 부하가 95% 감소했습니다..
- 언제 DB에 Schedule할 것인가?
- 기존의 Redis
<study:session:{sessionId}:stat, {lastType:"", lastTime:""}>→ 제거됨- 왜 필요 없어졌을까?
- 해당 레디스에 들어가는 내용은 한번만 계산되면 사용 가치가 사라진다. 즉, 더 이상 안 볼 데이터라서 굳이 레디스에 저장할 이유가 없음.
- Kafka한테서 받고 레디스에 넣기 전에 자바에서 {lastType:””, lastTime:””}을 계산해서 바로바로 Redis B에 update해주면 된다.
- 그리고 Kafka한테서 받은 데이터는 batch update로 DB에 저장하면 되기 때문임
- 왜 필요 없어졌을까?