Skip to content

20260201 #229 출첵qr - #212

Merged
discipline24 merged 7 commits into
mainfrom
20260201_#229_출첵QR
Feb 7, 2026

Hidden character warning

The head ref may contain hidden characters: "20260201_#229_\ucd9c\uccb5QR"
Merged

discipline24 merged 7 commits into
mainfrom
20260201_#229_출첵QR

Conversation

@daye200

@daye200 daye200 commented Feb 1, 2026 •

Copy link
Copy Markdown
Contributor

Summary by CodeRabbit

  • 새 기능

    • 실시간 QR 토큰 스트리밍(SSE) 엔드포인트 및 스트리밍 서비스 추가
    • Quartz 기반 자동 출석 라운드 상태 전환 스케줄러 및 실행 작업 추가
  • 개선 사항

    • 윈도우 기반 롤링 QR 토큰 발급/검증 도입으로 토큰 보안 및 정합성 강화
    • 세션별 허용 지각 시간 적용 로직 개선
    • 유지보수용 스케줄 호출 분리(Quartz 트리거 연동)
  • 보안

    • 출석 라운드 관리 권한을 OWNER로 강화
    • 쿠키 보안 설정 관련 주석 정비

@daye200
daye200 requested a review from discipline24 as a code owner February 1, 2026 14:31
@coderabbitai

coderabbitai Bot commented Feb 1, 2026 •

Copy link
Copy Markdown

Walkthrough

Quartz 기반 스케줄러와 윈도우 단위 회전형 QR 토큰 유틸, SSE 토큰 스트리밍 서비스 및 컨트롤러 엔드포인트, 그리고 관련 권한·유지보수 로직이 추가/수정되었습니다.

Changes

Cohort / File(s) Summary
빌드 설정 & Quartz 구성
backend/build.gradle, backend/src/main/java/org/sejongisc/backend/attendance/config/AttendanceQuartzConfig.java, backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceRoundStateJob.java
Quartz 의존성 추가, JobDetail/Trigger 및 SpringBeanJobFactory 등록, Quartz Job(AttendanceRoundStateJob) 추가.
SSE 기반 QR 토큰 스트리밍
backend/src/main/java/org/sejongisc/backend/attendance/service/QrTokenStreamService.java, backend/src/main/java/org/sejongisc/backend/attendance/controller/AttendanceRoundController.java
SseEmitter 구독 API 및 토큰·핑 주기 브로드캐스트 구현, 구독 엔드포인트 GET /api/attendance/rounds/{roundId}/qr-stream 추가, 권한·상태 검증 포함.
회전식 QR 토큰 유틸리티
backend/src/main/java/org/sejongisc/backend/attendance/util/RollingQrTokenUtil.java
윈도우 기반 토큰 발급/검증(윈도우 만료 계산, HMAC-SHA256 서명, 스큐 허용), IssuedToken/ParsedToken 레코드 및 공개 API 추가.
라운드 서비스 변경 및 유지보수 흐름
backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceRoundService.java
권한 요구(Admin→Owner 강화), QR 토큰 로직을 RollingQrTokenUtil로 전환, @Scheduled 제거 및 public 유지보수 메서드로 변경(Quartz 호출 대상으로 전환).
출석 지각 판단 보정
backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceService.java
지각 판단 로직이 세션의 allowedMinutes를 사용하도록 변경(기본 5분 유지).
기타 사소한 변경
backend/src/main/java/org/sejongisc/backend/common/auth/controller/AuthCookieHelper.java, backend/src/main/java/org/sejongisc/backend/common/config/swagger/SwaggerConfig.java, backend/src/main/java/org/sejongisc/backend/attendance/entity/AttendanceStatus.java
로컬 개발용 주석 추가(.secure/.sameSite), 불필요한 import 추가(무해), enum 공백 라인 제거.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Controller as AttendanceRoundController
    participant StreamService as QrTokenStreamService
    participant RoundService as AttendanceRoundService
    participant Repo as AttendanceRoundRepository
    participant Scheduler as Quartz Scheduler
    participant Job as AttendanceRoundStateJob

    rect rgba(100,200,150,0.5)
    Note over Client,Controller: SSE QR 토큰 구독 플로우
    Client->>Controller: GET /api/attendance/rounds/{roundId}/qr-stream
    Controller->>StreamService: subscribe(roundId, userId)
    StreamService->>RoundService: validateRound(roundId) / check admin
    RoundService->>Repo: fetch round
    StreamService-->>Controller: SseEmitter 반환
    Controller-->>Client: SSE 연결 설정
    StreamService->>StreamService: 정기 토큰 발행 및 핑 전송
    StreamService-->>Client: event "qrToken" (토큰 페이로드)
    StreamService-->>Client: event "ping" ("ok")
    end

    rect rgba(150,150,200,0.5)
    Note over Scheduler,Job: Quartz 기반 라운드 상태 유지보수
    Scheduler->>Job: trigger at ROUND_STATE_CRON
    Job->>RoundService: runRoundStatusMaintenance()
    RoundService->>Repo: 조회 및 상태 전환 (UPCOMING→ACTIVE, ACTIVE→CLOSED)
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Suggested reviewers

  • discipline24

Poem

🐰 시골어미가 달려오네, Quartz가 북을 쳐요,
SSE 창문 열리면 토큰 반짝반짝 춤추네.
HMAC 비밀 속에 안전히 숨어, 창은 매번 빛나고,
OWNER가 지키니 라운드는 제때 깨어나네.
토큰 굴러 돌며 출석도 반짝반짝! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 39.47% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR 제목이 한국어로 작성되었으며, 출석 체크 QR 코드 기능 구현이라는 주요 변경 사항을 명확하게 나타냅니다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch 20260201_#229_출첵QR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/src/main/java/org/sejongisc/backend/common/auth/controller/AuthCookieHelper.java (1)

17-24: ⚠️ Potential issue | 🟠 Major

삭제 쿠키의 secure/sameSite가 생성 쿠키와 불일치합니다.

로컬 HTTP 환경에서 deleteCookie()가 secure=true로 내려가면 브라우저가 응답을 무시할 수 있어 로그아웃이 실패할 위험이 있습니다. 생성/삭제 쿠키 속성을 동일한 설정으로 맞추세요.

✅ 제안 수정안(속성 일관화)
     public ResponseCookie deleteCookie(String name) {
         return ResponseCookie.from(name, "")
                 .httpOnly(true)
-                .secure(true)
-                .sameSite("None")
+                .secure(cookieSecure)
+                .sameSite(cookieSameSite)
                 .path("/")
                 .maxAge(0)
                 .build();
     }

Also applies to: 27-37

🤖 Fix all issues with AI agents
In
`@backend/src/main/java/org/sejongisc/backend/attendance/config/AttendanceQuartzConfig.java`:
- Around line 48-58: The override of
AutowiringSpringBeanJobFactory.createJobInstance currently calls
beanFactory.createBean(jobClass) and bypasses SpringBeanJobFactory's JobDataMap
injection; change it to call Object job = super.createJobInstance(bundle) to
preserve the parent class's JobDataMap wiring, then perform dependency injection
via the AutowireCapableBeanFactory (e.g., beanFactory.autowireBean(job) or
beanFactory.initializeBean(job, jobClass.getName())), and finally return that
job instance so both JobDataMap and Spring DI are applied (references:
AutowiringSpringBeanJobFactory, createJobInstance, SpringBeanJobFactory,
beanFactory).

In
`@backend/src/main/java/org/sejongisc/backend/attendance/service/QrTokenStreamService.java`:
- Line 46: The ScheduledExecutorService field scheduler in QrTokenStreamService
is never shut down; add a lifecycle cleanup to call scheduler.shutdown() (and
optionally scheduler.shutdownNow() after a timeout and awaitTermination) to
avoid thread leaks. Implement this by adding a `@PreDestroy-annotated` method
(e.g., cleanup() in QrTokenStreamService) or by implementing
DisposableBean.destroy(), call scheduler.shutdown(), wait for termination with a
bounded timeout, and log/handle InterruptedException and non-termination cases
so the executor is reliably stopped on application shutdown.

In
`@backend/src/main/java/org/sejongisc/backend/common/auth/controller/AuthCookieHelper.java`:
- Around line 27-37: The createCookie method currently hardcodes ResponseCookie
builder flags (.secure(false), .sameSite("Lax")), which will send auth cookies
in plaintext in production; update AuthCookieHelper.createCookie to read a
configuration flag (e.g., injected boolean like cookie.secure or active profile)
and branch the builder so that in production it sets .secure(true) and
.sameSite("None") (or configurable SameSite) while keeping
.secure(false)/SameSite=Lax for local/dev; add the config property in
application.properties and inject it into AuthCookieHelper (via `@Value` or
Environment) so the cookie settings are driven by configuration rather than
hardcoded values.
🧹 Nitpick comments (3)
backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceRoundStateJob.java (1)

23-30: 예외 처리 방식 검토 권장

현재 모든 예외를 로그만 남기고 무시하고 있습니다. Quartz에서 작업 실패를 추적하려면 JobExecutionException을 던지는 것이 좋습니다.

💡 선택적 개선안
   `@Override`
   public void execute(JobExecutionContext context) throws JobExecutionException {
     try {
       attendanceRoundService.runRoundStatusMaintenance();
     } catch (Exception e) {
       log.error("AttendanceRoundStateJob failed", e);
+      throw new JobExecutionException("Round state maintenance failed", e, false);
     }
   }

유지보수 작업의 특성상 현재 방식도 합리적이지만, Quartz 모니터링을 활용하려면 예외를 전파하는 것을 고려해 주세요.

backend/src/main/java/org/sejongisc/backend/attendance/util/RollingQrTokenUtil.java (1)

105-113: MessageDigest.isEqual() 사용 고려

JDK 표준 라이브러리의 MessageDigest.isEqual()을 사용하면 timing-safe 비교를 더 간결하게 구현할 수 있습니다. 같은 코드베이스의 QrTokenUtil에서도 이 방식을 사용하고 있습니다.

💡 선택적 개선안
+import java.security.MessageDigest;
+
 private static boolean constantTimeEquals(String a, String b) {
-  if (a == null || b == null) return false;
-  if (a.length() != b.length()) return false;
-  int result = 0;
-  for (int i = 0; i < a.length(); i++) {
-    result |= a.charAt(i) ^ b.charAt(i);
-  }
-  return result == 0;
+  if (a == null || b == null) return false;
+  return MessageDigest.isEqual(
+      a.getBytes(StandardCharsets.UTF_8),
+      b.getBytes(StandardCharsets.UTF_8)
+  );
 }
backend/src/main/java/org/sejongisc/backend/attendance/config/AttendanceQuartzConfig.java (1)

19-35: 스케줄 타임존 명시 여부를 확인해주세요.

Quartz 크론은 기본적으로 서버 타임존을 사용하므로, 서버가 UTC 등 다른 TZ이면 실제 실행 시간이 밀릴 수 있습니다. 업무 기준 TZ를 명시하거나 설정값으로 주입하는 편이 안전합니다 (Line 34).

🕒 타임존 명시 예시
+import java.util.TimeZone;
 ...
-        .withSchedule(CronScheduleBuilder.cronSchedule(ROUND_STATE_CRON))
+        .withSchedule(
+            CronScheduleBuilder.cronSchedule(ROUND_STATE_CRON)
+                .inTimeZone(TimeZone.getTimeZone("Asia/Seoul"))
+        )

discipline24
discipline24 previously approved these changes Feb 1, 2026

@discipline24 discipline24 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

고생하셨습니다~

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In
`@backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceRoundStateJob.java`:
- Around line 25-29: The catch in AttendanceRoundStateJob around
attendanceRoundService.runRoundStatusMaintenance() currently only logs and
swallows exceptions; change it to rethrow a JobExecutionException so Quartz
recognizes failures: in the catch(Exception e) block wrap the caught exception
into new JobExecutionException("AttendanceRoundStateJob failed", e) and throw it
(preserving the original exception as the cause and message), while keeping the
existing log if desired.

In
`@backend/src/main/java/org/sejongisc/backend/attendance/service/QrTokenStreamService.java`:
- Around line 97-123: In startRoundTasksIfAbsent wrap both scheduled runnables
(the one passed to tokenTaskByRound via scheduler.scheduleAtFixedRate that calls
broadcastNewToken(rid) and the ping runnable stored in pingTaskByRound) in a
top-level try-catch(Exception) that logs the exception (using the class logger)
and prevents it from escaping; ensure broadcastNewToken/emit loops are invoked
inside that try block so any RuntimeException from findRoundById or
RollingQrTokenUtil.issue() (or emitter.send) is caught and logged and the
scheduled task continues running.
- Around line 172-181: completeAll has the same race as removeEmitter: between
emittersByRound.get(roundId) and emittersByRound.remove(roundId) a new
subscriber can be added; fix by atomically removing the list before iterating —
e.g., call emittersByRound.remove(roundId) (or use emittersByRound.compute to
atomically extract-and-remove) to obtain the List<SseEmitter> and then iterate
and complete each emitter; reference the completeAll method and emittersByRound,
and follow the same atomic approach used in removeEmitter/subscribe
(compute/computeIfAbsent) to avoid lost/duplicated emitters.
- Around line 161-170: removeEmitter has a check-then-act race: concurrently a
subscribe() using computeIfAbsent can add an emitter between emitters.isEmpty()
and emittersByRound.remove(roundId), causing newly added emitters to be lost.
Fix removeEmitter(UUID roundId, SseEmitter emitter) to update the
ConcurrentHashMap atomically using emittersByRound.compute (or
computeIfPresent): inside the remapping function remove the emitter from the
list and return null if the list becomes empty (to delete the key) or return the
list otherwise; then call stopRoundTasksIfNoEmitters only when the compute
result indicates the key was removed (i.e., compute returned null or previous
mapping removed).
🧹 Nitpick comments (1)
backend/src/main/java/org/sejongisc/backend/attendance/service/QrTokenStreamService.java (1)

47-47: 고정 스레드 풀 크기 4가 동시 활성 라운드 수에 비해 부족할 수 있습니다.

각 활성 라운드마다 토큰 브로드캐스트 + 핑 = 2개의 주기적 태스크가 필요합니다. scheduleAtFixedRate는 스레드가 부족하면 실행이 지연되어 토큰 갱신 타이밍이 어긋날 수 있습니다. 동시에 활성화되는 라운드 수가 2개를 초과할 가능성이 있다면 풀 크기를 늘리거나, 구성 가능하게(@Value) 만드는 것을 고려하세요.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In
`@backend/src/main/java/org/sejongisc/backend/attendance/service/QrTokenStreamService.java`:
- Around line 70-92: The subscribe() flow adds the emitter to emittersByRound
but only removes it on IOException; if RollingQrTokenUtil.issue(...) or any
non-IOException throws, the emitter remains orphaned and
startRoundTasksIfAbsent(...) is never called. Fix by wrapping the immediate
token-send and its emitter registration in a try/catch/finally that on any
Exception calls removeEmitter(roundId, emitter) (or removes on failure before
rethrowing) and only calls startRoundTasksIfAbsent(roundId) after a successful
send; reference emittersByRound, removeEmitter(...),
RollingQrTokenUtil.issue(...), emitter, and startRoundTasksIfAbsent(...) when
making the change.

@discipline24
discipline24 merged commit ef6193f into main Feb 7, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants