⏰💡 소셜 로그인 + 선착순 강의 신청 시스템 통합 (JWT 기반 userId 전환)

2026. 2. 23. 23:37·프로젝트/스프링 부트 3 백엔드 개발자 되기 Blog + 선착순 강의 프로젝트

📌 개요

기존 강의 신청 시스템은 사용자를 식별하기 위해 UUID 기반 userKey를 프론트에서 생성하고 전달하는 구조였다.

하지만 이 방식은 보안·유지보수·확장성 측면에서 문제가 많았다.

그래서 다음과 같은 목표로 시스템을 전면 개선했다.

🎯 목표

✅ 목표 1 — userKey(UUID) 제거

  • 프론트에서 임의 UUID 생성 및 전달 구조 제거
  • API에서 ?userKey=... 파라미터 사용 중단

✅ 목표 2 — JWT 기반 사용자 식별

  • 로그인된 사용자 정보를 서버가 JWT에서 직접 추출
  • userId(Long)를 기준으로 Redis / DB 처리

✅ 목표 3 — 소셜 로그인 → 강의 신청 흐름 통합

  • OAuth2 로그인 성공 시 JWT 발급
  • 자동으로 강의 목록(/lectures) 페이지 이동

1️⃣ 변경 전 vs 변경 후 전체 흐름

❌ 변경 전 (UUID userKey 방식)

  1. 상세 페이지에서 “신청하기”
  2. 서버가 UUID 생성 후 응답
  3. 프론트가 localStorage에 저장
  4. 이후 모든 요청에 userKey 포함

예시:

/queue/me?userKey=...
/enroll?userKey=...

⚠️ 문제점

  • 로그인과 무관한 사용자 식별
  • UUID 탈취 시 악용 가능
  • 프론트가 사용자 식별 책임을 가짐
  • 실제 사용자 기준 기능 구현 어려움

✅ 변경 후 (JWT + userId 방식)

  1. 소셜 로그인 성공 → JWT 발급
  2. 프론트는 JWT만 저장
  3. 모든 API 요청은 Authorization 헤더 사용
  4. 서버가 JWT 검증 후 userId 추출
  5. 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
'프로젝트/스프링 부트 3 백엔드 개발자 되기 Blog + 선착순 강의 프로젝트' 카테고리의 다른 글
  • ⏰💡 선착순 강의 Redis 대기열(ZSET) + 입장권 원자화 + 소비(consume) 적용하기 (Lua 1개로 끝)
  • ⏰🚨 선착순 강의 신청 페이지에서 버튼이 동작하지 않던 문제 해결
  • ⏰🚨 동시성 테스트 중 500 Internal Server Error 발생 원인과 해결
  • ⏰🚨 Enroll 동시성 테스트에서 신청이 거의 발생하지 않은 문제(JMeter)
hak0622
hak0622
개발하면서 “이게 뭐지?”라는 순간마다 궁금한 점을 바탕으로 정리한 개발 블로그입니다.
  • hak0622
    궁금한 개발 이야기 Why?
    hak0622
  • 전체
    오늘
    어제
    • 분류 전체보기 (68)
      • 공부 (36)
        • 1. 자바 ORM 표준 JPA 프로그래밍 - 기본.. (35)
        • 시험 (1)
      • 프로젝트 (32)
        • 스프링 부트 3 백엔드 개발자 되기 Blog + .. (32)
  • 인기 글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
hak0622
⏰💡 소셜 로그인 + 선착순 강의 신청 시스템 통합 (JWT 기반 userId 전환)
상단으로

티스토리툴바