feat: 학습 센터 코스 진도 체크 — 완료 토글 API + 하단 완료 바 + 허브 진도율

- 백엔드: CourseProgress 엔티티(user+slug 유니크)·서비스·API
  (GET /api/course-progress/me, POST /api/course-progress/{slug}/toggle, slug 형식 검증)
- 프론트: CourseShell 래퍼로 72개 코스에 하단 완료 바 자동 부착,
  허브에 전체/카테고리별 진도율·완료 카드 표시(✓·청록 테두리)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
AWESOMEDEV 2026-07-16 15:18:10 +09:00
parent 1303d06811
commit b8c9da2d4a
8 changed files with 386 additions and 49 deletions

View File

@ -0,0 +1,66 @@
package dev.awesomedev.mirim.domain;
import jakarta.persistence.*;
import java.time.Instant;
/**
* 파일이 하는 :
* "어떤 사용자가 학습 센터의 어떤 코스를 완료했다" 사실 하나를 기록하는 JPA 엔티티다.
* 행이 존재하면 완료, 없으면 미완료 완료 취소는 삭제로 표현한다 (Progress와 같은 방식).
*
* 학습 포인트: 체크리스트(Progress) 달리 코스 목록은 DB 테이블이 아니라
* 프론트의 courseCatalog.jsx(데이터 파일) 있다. 그래서 외래키 대신
* 코스의 slug 문자열("hardware", "sql" 같은 주소 조각) 그대로 저장한다.
* 장점: 코스를 추가할 DB 작업이 필요 없다. 단점: DB가 slug의 유효성을 보장하지 못한다.
* 이런 트레이드오프(교환 관계) 알고 선택하는 것이 설계다.
*/
@Entity
@Table(
name = "course_progress",
uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "course_slug"})
)
public class CourseProgress {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** 완료한 사용자. DB에는 user_id 외래키 컬럼이 된다. */
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id")
private User user;
/** 완료한 코스의 slug (예: "hardware", "sql", "tool-vscode") */
@Column(name = "course_slug", nullable = false, length = 60)
private String courseSlug;
/** 완료한 시각 */
@Column(nullable = false)
private Instant completedAt;
protected CourseProgress() {
}
public CourseProgress(User user, String courseSlug) {
this.user = user;
this.courseSlug = courseSlug;
this.completedAt = Instant.now();
}
public Long getId() {
return id;
}
public User getUser() {
return user;
}
public String getCourseSlug() {
return courseSlug;
}
public Instant getCompletedAt() {
return completedAt;
}
}

View File

@ -0,0 +1,23 @@
package dev.awesomedev.mirim.repository;
import dev.awesomedev.mirim.domain.CourseProgress;
import dev.awesomedev.mirim.domain.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
/**
* 파일이 하는 :
* course_progress 테이블에 대한 DB 접근 창구.
* 학습 포인트: 메서드 이름 규칙(findBy + 필드명) 지키면
* Spring Data JPA가 SQL을 대신 만들어 준다 SQL 입문 코스의 "JPA와 SQL의 관계" 참고.
*/
public interface CourseProgressRepository extends JpaRepository<CourseProgress, Long> {
/** 이 사용자의 완료 기록 전부 */
List<CourseProgress> findByUser(User user);
/** 이 사용자가 이 코스를 완료했는지 (있으면 완료) */
Optional<CourseProgress> findByUserAndCourseSlug(User user, String courseSlug);
}

View File

@ -0,0 +1,52 @@
package dev.awesomedev.mirim.service;
import dev.awesomedev.mirim.domain.CourseProgress;
import dev.awesomedev.mirim.domain.User;
import dev.awesomedev.mirim.repository.CourseProgressRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
/**
* 파일이 하는 :
* 코스 완료 기록의 비즈니스 로직 완료 목록 조회, 완료 토글(있으면 삭제, 없으면 저장).
* ProgressService(체크리스트) 같은 패턴이다. 패턴이 같으면 읽는 사람이 편하다.
*/
@Service
public class CourseProgressService {
private final CourseProgressRepository courseProgressRepository;
public CourseProgressService(CourseProgressRepository courseProgressRepository) {
this.courseProgressRepository = courseProgressRepository;
}
/** 내가 완료한 코스 slug 목록 */
@Transactional(readOnly = true)
public List<String> completedSlugs(User user) {
// 학습 포인트: 엔티티 리스트에서 필요한 (slug) 뽑아 돌려준다.
// 프론트는 slug 배열만 있으면 되니까 필요한 만큼만 주는 것이 좋은 API다.
return courseProgressRepository.findByUser(user).stream()
.map(CourseProgress::getCourseSlug)
.toList();
}
/**
* 완료 상태 뒤집기. 반환값 = 토글 완료 여부.
* 학습 포인트: "완료 취소" 별도 API로 만들지 않고 토글 하나로 처리
* 프론트 버튼 하나와 API 하나가 1:1로 대응해 단순하다.
*/
@Transactional
public boolean toggle(User user, String courseSlug) {
Optional<CourseProgress> existing =
courseProgressRepository.findByUserAndCourseSlug(user, courseSlug);
if (existing.isPresent()) {
courseProgressRepository.delete(existing.get());
return false;
}
courseProgressRepository.save(new CourseProgress(user, courseSlug));
return true;
}
}

View File

@ -0,0 +1,51 @@
package dev.awesomedev.mirim.web;
import dev.awesomedev.mirim.domain.User;
import dev.awesomedev.mirim.service.AuthService;
import dev.awesomedev.mirim.service.CourseProgressService;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Map;
/**
* 파일이 하는 :
* 학습 센터 코스 완료(진도) API 완료 목록 조회, 완료 토글.
* ProgressController(체크리스트) 같은 구조다.
*/
@RestController
@RequestMapping("/api/course-progress")
public class CourseProgressController {
private final CourseProgressService courseProgressService;
private final AuthService authService;
public CourseProgressController(CourseProgressService courseProgressService,
AuthService authService) {
this.courseProgressService = courseProgressService;
this.authService = authService;
}
/** GET /api/course-progress/me — 내가 완료한 코스 slug 배열 */
@GetMapping("/me")
public List<String> myCompleted(Authentication authentication) {
User user = authService.requireUser(authentication);
return courseProgressService.completedSlugs(user);
}
/** POST /api/course-progress/{slug}/toggle — 완료 상태 뒤집기 */
@PostMapping("/{slug}/toggle")
public Map<String, Object> toggle(@PathVariable String slug, Authentication authentication) {
User user = authService.requireUser(authentication);
// 학습 포인트: slug는 프론트 카탈로그에만 있는 값이라 서버가 목록 검증은 하지만,
// 형식(소문자·숫자·하이픈, 60자 이내) 검사해서 이상한 값이 쌓이는 것을 막는다.
if (!slug.matches("[a-z0-9-]{1,60}")) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "잘못된 코스 주소입니다.");
}
boolean completed = courseProgressService.toggle(user, slug);
return Map.of("slug", slug, "completed", completed);
}
}

View File

@ -7,6 +7,7 @@ import { Suspense } from 'react';
import { Routes, Route, NavLink, Navigate, Outlet } from 'react-router-dom';
import { useAuth } from './AuthContext';
import { ALL_COURSES } from './courseCatalog';
import CourseShell from './components/CourseShell';
import LoginPage from './pages/LoginPage';
import SignupPage from './pages/SignupPage';
import FindAccountPage from './pages/FindAccountPage';
@ -112,11 +113,13 @@ export default function App() {
<Route path="/" element={<DashboardPage />} />
{/* 학습 센터: 허브 + 코스들(카탈로그에서 자동 생성) */}
<Route path="/learn" element={<LearnHubPage />} />
{/* : CourseShell 72
하단 "완료" 바가 자동으로 붙는다 공통 기능은 감싸는 컴포넌트로. */}
{ALL_COURSES.map((course) => (
<Route
key={course.slug}
path={`/learn/${course.slug}`}
element={<course.Component />}
element={<CourseShell course={course} />}
/>
))}
<Route path="/setup" element={<SetupGuidePage />} />

View File

@ -0,0 +1,70 @@
// : ""
// ( + ) .
//
// : 72 72 .
// (App.jsx )
// " " React .
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import client from '../api/client';
export default function CourseShell({ course }) {
const [completed, setCompleted] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
// : JSX <Component /> .
// ( React HTML )
const Component = course.Component;
//
useEffect(() => {
let cancelled = false;
setLoading(true);
client
.get('/course-progress/me')
.then((res) => {
if (!cancelled) setCompleted(res.data.includes(course.slug));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [course.slug]);
async function toggle() {
setSaving(true);
try {
const res = await client.post(`/course-progress/${course.slug}/toggle`);
setCompleted(res.data.completed);
} finally {
setSaving(false);
}
}
return (
<div>
<Component />
{/* 코스 하단 완료 바 — sticky로 화면 아래에 붙어 다닌다 */}
<div className={`course-complete-bar${completed ? ' done' : ''}`}>
<Link to="/learn" className="btn btn-ghost">
학습 센터
</Link>
<span className="course-complete-label">
{course.icon} {course.title}
{completed && <span className="badge badge-teal" style={{ marginLeft: 8 }}>완료됨</span>}
</span>
<button
className={completed ? 'btn' : 'btn btn-primary'}
onClick={toggle}
disabled={loading || saving}
>
{saving ? '저장 중...' : completed ? '완료 취소' : '✓ 이 코스 완료하기'}
</button>
</div>
</div>
);
}

View File

@ -1,10 +1,30 @@
// : " " courseCatalog .
// : ! courseCatalog.jsx() ,
// " " . .
// : " " courseCatalog ,
// () .
// : ( ) courseCatalog.jsx, ( )
// DB " " .
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import client from '../api/client';
import { COURSE_CATEGORIES, ALL_COURSES } from '../courseCatalog';
export default function LearnHubPage() {
// slug Set ( )
const [completedSlugs, setCompletedSlugs] = useState(new Set());
useEffect(() => {
let cancelled = false;
client.get('/course-progress/me').then((res) => {
if (!cancelled) setCompletedSlugs(new Set(res.data));
});
return () => {
cancelled = true;
};
}, []);
const totalDone = ALL_COURSES.filter((c) => completedSlugs.has(c.slug)).length;
const totalCount = ALL_COURSES.length;
const percent = totalCount === 0 ? 0 : Math.round((totalDone / totalCount) * 100);
return (
<div>
<div className="hero">
@ -12,8 +32,19 @@ export default function LearnHubPage() {
<h1>어썸데브 학습 센터</h1>
<p>
하드웨어에서 네트워크, 서버, 코드, 디자인까지 아래에서 위로 쌓는 순서 그대로.
{ALL_COURSES.length + 1} 강좌, 아침 수업(CS 커리큘럼) 짝을 이루는 자율 학습 코너예.
코스를 읽으면 하단의 <strong>"✓ 이 코스 완료하기"</strong> 눌러 진도를 남기세.
</p>
<div className="chip-row" style={{ alignItems: 'center' }}>
<div className="stat">
<span className="stat-num">{percent}%</span>
<span className="stat-label">
{totalDone} / {totalCount} 코스 완료
</span>
</div>
<div className="progress-track" style={{ flex: 1, minWidth: 160 }}>
<div className="progress-fill" style={{ width: `${percent}%` }} />
</div>
</div>
<div className="chip-row">
<span className="chip">카드의 번호(01, 02) 배우는 순서예요</span>
<span className="chip">카테고리당 12강좌</span>
@ -21,57 +52,64 @@ export default function LearnHubPage() {
</div>
</div>
{COURSE_CATEGORIES.map((group) => (
<div key={group.category}>
<div className="day-head">
<span className="day-badge">{group.category}</span>
<span className="day-count">
{group.desc} · {group.items.length}강좌
</span>
</div>
<div className="grid-cards">
{group.items.map((course, index) => {
// external , /learn/<slug> .
const to = course.external || `/learn/${course.slug}`;
return (
<Link key={to} to={to} style={{ color: 'inherit' }}>
<div className="card card-clickable" style={{ height: '100%' }}>
{/* : index .
padStart(2,'0') 1 "01" 만들어 준다 (자릿수 맞추기). */}
{COURSE_CATEGORIES.map((group) => {
const groupDone = group.items.filter((i) => i.slug && completedSlugs.has(i.slug)).length;
return (
<div key={group.category}>
<div className="day-head">
<span className="day-badge">{group.category}</span>
<span className="day-count">
{group.desc} · {groupDone} / {group.items.length} 완료
</span>
</div>
<div className="grid-cards">
{group.items.map((course, index) => {
// external , /learn/<slug> .
const to = course.external || `/learn/${course.slug}`;
const done = course.slug && completedSlugs.has(course.slug);
return (
<Link key={to} to={to} style={{ color: 'inherit' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
}}
className={`card card-clickable${done ? ' card-completed' : ''}`}
style={{ height: '100%' }}
>
<span style={{ fontSize: 26 }}>{course.icon}</span>
<span
{/* : index .
padStart(2,'0') 1 "01" 만들어 준다 (자릿수 맞추기). */}
<div
style={{
fontSize: 13,
fontWeight: 800,
color: 'var(--primary)',
fontVariantNumeric: 'tabular-nums',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
}}
>
{String(index + 1).padStart(2, '0')}
</span>
<span style={{ fontSize: 26 }}>{course.icon}</span>
<span
style={{
fontSize: 13,
fontWeight: 800,
color: done ? 'var(--teal)' : 'var(--primary)',
fontVariantNumeric: 'tabular-nums',
}}
>
{done ? '✓ 완료' : String(index + 1).padStart(2, '0')}
</span>
</div>
<div className="assign-title-row">
<span className="assign-title">{course.title}</span>
<span className="badge badge-primary">{course.level}</span>
</div>
<p className="muted" style={{ fontSize: 13.5, marginTop: 6 }}>
{course.desc}
</p>
</div>
<div className="assign-title-row">
<span className="assign-title">{course.title}</span>
<span className="badge badge-primary">{course.level}</span>
</div>
<p className="muted" style={{ fontSize: 13.5, marginTop: 6 }}>
{course.desc}
</p>
</div>
</Link>
);
})}
</Link>
);
})}
</div>
</div>
</div>
))}
);
})}
</div>
);
}

View File

@ -722,6 +722,40 @@ inline-code, .icode {
letter-spacing: -0.01em;
}
/* 코스 완료 바 — 코스 페이지 하단에 sticky로 붙는다 (CourseShell.jsx) */
.course-complete-bar {
position: sticky;
bottom: 14px;
margin-top: 26px;
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: var(--card);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: 0 6px 24px rgba(16, 26, 40, 0.14);
}
.course-complete-bar.done {
border-color: color-mix(in srgb, var(--teal) 45%, transparent);
}
.course-complete-label {
flex: 1;
font-size: 14px;
font-weight: 700;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 허브 카드 완료 상태 */
.card-completed {
border-color: color-mix(in srgb, var(--teal) 45%, transparent);
}
/* 접었다 펴는 상세 — 학습 포인트: <details>는 JS 없이도 접기/펼치기가 되는 HTML 기본 기능 */
.fold summary {
cursor: pointer;