πŸ“Œβ€œμ§„λ„μœ¨ λ‘œμ§μ€ κ²°κ΅­ μƒνƒœ 머신이닀 – ProgressService 섀계기”

πŸ—“ 2025λ…„ 4μ›” ✍️ by 박찬희

🧭 문제 μ •μ˜: λ‹¨μˆœν•œ νΌμ„ΌνŠΈκ°€ μ•„λ‹Œ, ν•™μŠ΅ μƒνƒœμ˜ 흐름

μ‚¬μš©μžμ˜ μ½˜ν…μΈ  ν•™μŠ΅ μ§„ν–‰ 상황을 β€œμˆ«μž ν•˜λ‚˜(%)β€λ‘œ ν‘œν˜„ν•˜λŠ” 건 μ‰¬μ›Œ λ³΄μ΄μ§€λ§Œ,

이λ₯Ό μ‹ λ’°ν•  수 있게 μ €μž₯ν•˜κ³ , 수료 쑰건에 따라 μƒνƒœλ₯Ό μ •ν™•νžˆ μ „ν™˜ν•˜λŠ” 건 μž‘μ§€ μ•Šμ€ 일이닀.

이번 λͺ¨λ“ˆμ˜ λͺ©ν‘œλŠ” λ‹¨μˆœν•œ progressPercent μ €μž₯이 μ•„λ‹ˆλΌ,

μ •ν™•ν•œ μƒνƒœ 전이(μ§„λ„μœ¨ β†’ 수료)κΉŒμ§€ μ±…μž„μ§€λŠ” β€œν•™μŠ΅ μƒνƒœ 머신”을 μ„€κ³„ν•˜λŠ” 데 μžˆμ—ˆλ‹€.


🎯 섀계 λͺ©ν‘œ: μ§„λ„μœ¨ μ €μž₯ + 수료 μžλ™ν™” + 퍼포먼슀 μ΅œμ ν™”

ν•­λͺ© μ „λž΅
πŸ’‘ 단일 μ§„μž…μ  λͺ¨λ“  λ‘œμ§μ„ saveOrUpdateProgress()에 집쀑
πŸ“Š 수료 μƒνƒœ μžλ™ν™” 전체 μ½˜ν…μΈ  μ™„λ£Œ μ‹œ 수료 처리
πŸ’Ύ DB λΆ€ν•˜ μ΅œμ†Œν™” λΆˆν•„μš”ν•œ μ—…λ°μ΄νŠΈ 제거 (WHERE progress < newValue)
πŸ§ͺ ν…ŒμŠ€νŠΈ κ°€λŠ₯μ„± JUnit 기반 λ‹¨μœ„ ν…ŒμŠ€νŠΈ ꡬ쑰둜 섀계
πŸ” ν™•μž₯μ„± Redis/Kafka 기반 ν™•μž₯ μ—¬μ§€ 확보

🧱 전체 ꡬ쑰 흐름

[μ‚¬μš©μž μ½˜ν…μΈ  μž¬μƒ]
      ↓
[ProgressService.saveOrUpdateProgress()]
      β”œβ”€β”€ κΈ°μ‘΄ 진도 < μ‹ κ·œ 진도일 경우만 upsert
      β”œβ”€β”€ lectureId 역좔적 (contentId β†’ lectureId)
      └── μž”μ—¬ λ―Έμ™„λ£Œ μ½˜ν…μΈ  수 = 0 β†’ 수료 처리(markEnrollmentComplete)

πŸ”§ 핡심 둜직 상세

1️⃣ μ§„λ„μœ¨ μ €μž₯ - Upsert μ΅œμ ν™”

mapper.upsertProgress(userId, contentId, progressPercent);
UPDATE user_progress
SET progress_percent = #{progressPercent}
WHERE content_id = #{contentId} AND user_id = #{userId} AND progress_percent < #{progressPercent}

βœ… DB λΆ€ν•˜λ₯Ό μ΅œμ†Œν™”ν•˜κ³ , λ„€νŠΈμ›Œν¬ λŒ€μ—­λ„ μ€„μ—¬μ€Œ


2️⃣ 수료 μƒνƒœ μžλ™ν™” – μ •ν™•ν•˜κ³  λͺ…ν™•ν•˜κ²Œ

int remaining = mapper.countIncompleteContents(userId, lectureId);

if (remaining == 0) {
  mapper.markEnrollmentComplete(userId, lectureId);
}

πŸ“Œ μ‹€λ¬΄μ—μ„œλŠ” β€œν•˜λ‚˜λΌλ„ μ™„λ£Œ μ•ˆ 된 μ½˜ν…μΈ κ°€ 있으면 수료 X”가 맀우 μ€‘μš”

β†’ μ‚¬μš©μžμ˜ 신뒰와 보상 체계λ₯Ό μ§€ν‚€λŠ” 기반이 λœλ‹€.


3️⃣ μ§„λ„μœ¨ 쑰회 – Map 기반 ꡬ쑰

Map<Integer, Integer> getProgressMap(userId, lectureId);

βœ… μ‹€μ‹œκ°„ μ§„λ„μœ¨ UI ꡬ성에 이상적인 ꡬ쑰


βš™οΈ νŠΈλžœμž­μ…˜ & μ˜ˆμ™Έ 처리 μ „λž΅

try (SqlSession session = MyBatisUtil.getSqlSessionFactory().openSession()) {
  ...
  throw new RuntimeException("❌ μ§„λ„μœ¨ μ €μž₯ λ˜λŠ” 수료 μƒνƒœ 처리 쀑 였λ₯˜", e);
}

πŸ“‰ μ„±λŠ₯ κ³ λ € 및 ν™•μž₯ μ „λž΅

μ „λž΅ μ„€λͺ…
λΆˆν•„μš” UPDATE λ°©μ§€ progress < newValue 쑰건 적용
수료 검증 μ΅œμ†Œν™” 남은 μ½˜ν…μΈ  수만 체크 (countIncompleteContents)
비동기화 ν™•μž₯ μΆ”ν›„ @Async, Kafka둜 처리 뢄리 κ°€λŠ₯
캐싱 κ³ λ € Redis 기반 μ§„λ„μœ¨ μΊμ‹œ (λŒ€ν˜• κ°•μ˜ λŒ€μ‘)
배치 처리 반볡적인 progress β†’ complete μ „ν™˜ μ‹œ Batching κ³ λ € κ°€λŠ₯

πŸ§ͺ λ‹¨μœ„ ν…ŒμŠ€νŠΈ κ³ λ € ꡬ쑰

예:

@Test
void whenAllContentsComplete_thenLectureMarkedAsComplete() {
  // given: λͺ¨λ“  μ½˜ν…μΈ  μ™„λ£Œλœ μƒνƒœ
  // when: λ§ˆμ§€λ§‰ progress μ €μž₯
  // then: 수료 처리 확인
}

🧠 μ‹œλ‹ˆμ–΄ 리뷰 ν”Όλ“œλ°± (μ‹€μ œ μ½”λ“œ 리뷰 μš”μ•½)

평가 ν•­λͺ© 리뷰
βœ… μœ μ§€λ³΄μˆ˜μ„± β€œλͺ¨λ“  둜직이 Service에 μ§‘μ€‘λ˜μ–΄ μžˆμ–΄ 좔적과 변경이 μš©μ΄ν•˜λ‹€β€
βœ… 도메인 연계성 β€œκ΅μœ‘ μ„œλΉ„μŠ€ 도메인에 λ§žλŠ” 수료 전이 ꡬ쑰가 섀계에 λ…Ήμ•„ μžˆλ‹€β€
βœ… DB μ „λž΅ β€œλΆˆν•„μš”ν•œ updateκ°€ μ°¨λ‹¨λœ 점이 맀우 쒋닀”
πŸ”œ ν™•μž₯μ„± β€œλ©€ν‹° λͺ¨λ“ˆ κ°•μ˜, μΆœμ„ 기반 ν•™μŠ΅μ—λ„ λŒ€μ‘ κ°€λŠ₯ν•  ꡬ쑰”

πŸ“Œ ν–₯ν›„ ν™•μž₯ κ³„νš


βœ… μš”μ•½

κΈ°λŠ₯ μ„€λͺ…
μ§„λ„μœ¨ μ €μž₯ upsert (κΈ°μ‘΄ 값보닀 클 λ•Œλ§Œ μ €μž₯)
수료 처리 λͺ¨λ“  μ½˜ν…μΈ  μ™„λ£Œ μ‹œ μžλ™ 전이
μ§„λ„μœ¨ 쑰회 Map<contentId, percent> λ°˜ν™˜ ꡬ쑰
νŠΈλžœμž­μ…˜ μ‹€νŒ¨ μ‹œ 전체 λ‘€λ°±, μžμ› λˆ„μˆ˜ λ°©μ§€
ν…ŒμŠ€νŠΈμ„± λ‹¨μœ„ ν…ŒμŠ€νŠΈλ‘œ λͺ¨λ“  둜직 흐름 확인 κ°€λŠ₯

🧾 회고

이번 ProgressServiceλŠ” λ‹¨μˆœ μ €μž₯이 μ•„λ‹Œ,

ν•™μŠ΅ μƒνƒœλ₯Ό μ œμ–΄ν•˜λŠ” 핡심 μƒνƒœ λ¨Έμ‹ μœΌλ‘œ μž‘λ™ν•˜κ²Œ μ„€κ³„ν–ˆλ‹€.

  • μœ μ €μ—κ²ŒλŠ” μ •ν™•ν•œ 수료 κ²½ν—˜μ„,
  • μ‹œμŠ€ν…œμ—κ²ŒλŠ” ν™•μž₯μ„±κ³Ό μ˜ˆμ™Έ μ•ˆμ •μ„±μ„,
  • κ°œλ°œμžμ—κ²ŒλŠ” ν…ŒμŠ€νŠΈ κ°€λŠ₯ν•œ ꡬ쑰와 μœ μ§€λ³΄μˆ˜μ„±μ„ μ œκ³΅ν•˜λŠ”
νƒ„νƒ„ν•œ 도메인 쀑심 λͺ¨λ“ˆμ΄ λ˜μ—ˆλ‹€.