📌 개요
기존 강의 신청 시스템은 사용자를 식별하기 위해 UUID 기반 userKey를 프론트에서 생성하고 전달하는 구조였다.
하지만 이 방식은 보안·유지보수·확장성 측면에서 문제가 많았다.
그래서 다음과 같은 목표로 시스템을 전면 개선했다.
🎯 목표
✅ 목표 1 — userKey(UUID) 제거
- 프론트에서 임의 UUID 생성 및 전달 구조 제거
- API에서 ?userKey=... 파라미터 사용 중단
✅ 목표 2 — JWT 기반 사용자 식별
- 로그인된 사용자 정보를 서버가 JWT에서 직접 추출
- userId(Long)를 기준으로 Redis / DB 처리
✅ 목표 3 — 소셜 로그인 → 강의 신청 흐름 통합
- OAuth2 로그인 성공 시 JWT 발급
- 자동으로 강의 목록(/lectures) 페이지 이동
1️⃣ 변경 전 vs 변경 후 전체 흐름
❌ 변경 전 (UUID userKey 방식)
- 상세 페이지에서 “신청하기”
- 서버가 UUID 생성 후 응답
- 프론트가 localStorage에 저장
- 이후 모든 요청에 userKey 포함
예시:
/queue/me?userKey=...
/enroll?userKey=...
⚠️ 문제점
- 로그인과 무관한 사용자 식별
- UUID 탈취 시 악용 가능
- 프론트가 사용자 식별 책임을 가짐
- 실제 사용자 기준 기능 구현 어려움
✅ 변경 후 (JWT + userId 방식)
- 소셜 로그인 성공 → JWT 발급
- 프론트는 JWT만 저장
- 모든 API 요청은 Authorization 헤더 사용
- 서버가 JWT 검증 후 userId 추출
- Redis/DB 모두 userId 기반 처리
Authorization: Bearer <JWT>
👉 사용자 식별 책임이 서버로 이동
👉 실제 로그인 사용자 기준 처리 가능
2️⃣ 인증 구조 변경 — JWT에서 userId 추출
2-1. TokenProvider — JWT에 userId 저장
강의 로직에서 사용자 ID를 사용하려면 JWT 내부에 userId를 포함해야 한다.
.claim("id",user.getId())
핵심 구현
@RequiredArgsConstructor
@Service
public class TokenProvider {
private final JwtProperties jwtProperties;
public String generateToken(User user, Duration expriedAt){
Date now = new Date();
return makeToken(new Date(now.getTime()+expriedAt.toMillis()), user);
}
private String makeToken(Date expiry, User user){
Date now = new Date();
return Jwts.builder()
.setIssuer(jwtProperties.getIssuer())
.setIssuedAt(now)
.setExpiration(expiry)
.setSubject(user.getEmail())
.claim("id", user.getId()) // ⭐ 핵심
.signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey())
.compact();
}
public Long getUserId(String token){
Claims claims = getClaims(token);
return claims.get("id", Long.class);
}
private Claims getClaims(String token){
return Jwts.parser()
.setSigningKey(jwtProperties.getSecretKey())
.parseClaimsJws(token)
.getBody();
}
}
👉 이제 JWT 하나만 있으면 userId를 복원 가능
2-2. TokenAuthenticationFilter — 토큰 파싱 안정화
Authorization 헤더가 없거나 형식이 잘못된 경우 오류가 발생할 수 있다.
특히 흔한 문제:
- Bearer 뒤 공백
- null 토큰
- 헤더 누락
해결 코드
private String getAccessToken(String authorizationHeader) {
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer")) {
return authorizationHeader.substring(6).trim();
}
return null;
}
👉 trim() 을 넣지 않으면 토큰 검증 실패 발생 가능
2-3. OAuth2SuccessHandler — 로그인 후 강의 페이지 이동
로그인 성공 시:
- JWT 생성
- /lectures?token=... 로 리다이렉트
String targetUrl = UriComponentsBuilder.fromUriString("/lectures")
.queryParam("token", accessToken)
.build()
.toUriString();
👉 프론트는 URL에서 token을 꺼내 저장
3️⃣ 프론트 변경 — userKey 제거
3-1. Token 모듈 생성
모든 API 호출에서 Authorization 헤더 자동 추가
const TOKEN_KEY = "access_token";
function authHeaders(extraHeaders) {
const token = localStorage.getItem(TOKEN_KEY);
return {
...(extraHeaders || {}),
...(token ? { Authorization: "Bearer " + token } : {}),
};
}
API 호출 래퍼
async function apiFetch(url, options) {
const opts = options || {};
opts.headers = authHeaders(opts.headers);
const res = await fetch(url, opts);
if (res.status === 401) throw new Error("UNAUTHORIZED");
return res.json();
}
👉 프론트는 사용자 ID를 전혀 알 필요 없음
3-2. detail.html / apply.html 변경
❌ 기존
/queue?userKey=...
/enroll?userKey=...
✅ 변경 후
POST /queue
POST /enroll
👉 서버가 JWT에서 userId 추출
4️⃣ 강의 API 변경 — 컨트롤러에서 userKey 제거
대기열 등록 API
@PostMapping("/{lectureId}/queue")
public ResponseEntity<?> enqueue(@PathVariable Long lectureId) {
Long userId = getUserIdFromSecurityContext();
Long position = queueService.enqueue(
lectureId,
String.valueOf(userId)
);
return ResponseEntity.ok(Map.of(
"status", "QUEUED",
"position", position
));
}
신청 API
@PostMapping("/{lectureId}/enroll")
public ResponseEntity<?> enroll(@PathVariable Long lectureId) {
Long userId = getUserIdFromSecurityContext();
EnrollResult result = enrollService.enroll(lectureId, userId);
return ResponseEntity.ok(Map.of(
"status", result.getStatus()
));
}
5️⃣ DB 변경 — Enrollment를 userId 기반으로 저장
5-1. 엔티티 변경
❌ 기존
user_key VARCHAR(UUID)
✅ 변경
user_id BIGINT
엔티티
@Column(name = "user_id", nullable = false)
private Long userId;
유니크 제약
uniqueConstraints = @UniqueConstraint(
columnNames = {"lecture_id", "user_id"}
)
👉 같은 사용자의 중복 신청 방지
5-2. Repository 변경
Spring Data JPA는 메서드 이름으로 쿼리를 생성한다.
❌ 기존
existsByLectureIdAndUserKey
✅ 변경
existsByLectureIdAndUserId
👉 엔티티 필드명과 정확히 일치해야 함
5-3. DB DDL 예시
ALTER TABLE enrollment ADD COLUMN user_id BIGINT;
ALTER TABLE enrollment DROP COLUMN user_key;
ALTER TABLE enrollment
ADD CONSTRAINT uk_enrollment_lecture_user
UNIQUE (lecture_id, user_id);
6️⃣ Redis 대기열도 userId 기반으로 변경
Redis ZSET member 값:
❌ 기존
UUID userKey
✅ 변경
String.valueOf(userId)
redisTemplate.opsForZSet()
.add(key, member, timestamp);
👉 로그인 사용자 기준 대기열
7️⃣ 신청 내역(receipt) 조회도 userId 기반
컨트롤러
@GetMapping("/{lectureId}/enroll/detail")
public ResponseEntity<EnrollmentResponse> detail(
@PathVariable Long lectureId) {
Long userId = SecurityUtil.getCurrentUserId();
return ResponseEntity.ok(
enrollService.getMyEnrollmentDetail(lectureId, userId)
);
}
👉 프론트는 아무 파라미터도 전달하지 않음
Service
public EnrollmentResponse getMyEnrollmentDetail(
Long lectureId,
Long userId) {
Enrollment enrollment =
repository.findByLectureIdAndUserId(lectureId, userId)
.orElseThrow();
return EnrollmentResponse.from(enrollment);
}
receipt.html
const data = await Token.apiFetch(
`/api/lectures/${lectureId}/enroll/detail`
);
👉 localStorage userKey 완전 제거
8️⃣ 최종 결과
✅ 달성된 것
✔ 소셜 로그인 → JWT 발급 → 강의 페이지 이동
✔ 프론트에서 사용자 식별 제거
✔ 서버가 JWT로 사용자 식별
✔ Redis 대기열 userId 기반 처리
✔ DB Enrollment userId 기반 저장
✔ 신청 내역 조회도 userId 기반
'프로젝트 > 스프링 부트 3 백엔드 개발자 되기 Blog + 선착순 강의 프로젝트' 카테고리의 다른 글
| ⏰💡 선착순 강의 Redis 대기열(ZSET) + 입장권 원자화 + 소비(consume) 적용하기 (Lua 1개로 끝) (0) | 2026.02.26 |
|---|---|
| ⏰🚨 선착순 강의 신청 페이지에서 버튼이 동작하지 않던 문제 해결 (0) | 2026.02.23 |
| ⏰🚨 동시성 테스트 중 500 Internal Server Error 발생 원인과 해결 (1) | 2026.02.23 |
| ⏰🚨 Enroll 동시성 테스트에서 신청이 거의 발생하지 않은 문제(JMeter) (0) | 2026.02.15 |
| ⏰💡 Enroll 동시성 테스트 + 폴링 API 부하테스트 진행 방법(JMeter) (0) | 2026.02.15 |