Skip to content

[Server] feat: QR 출석 관리 및 사용자 출석 API 구현 - #701

Merged
psvm203 merged 14 commits into
mainfrom
feat/687-qr-attendance
Oct 7, 2026
Merged

psvm203 merged 14 commits into
mainfrom
feat/687-qr-attendance

Conversation

@psvm203

@psvm203 psvm203 commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

변경 내용

설계 문서만 있던 QR 출석 기능의 서버 로직을 구현합니다. 관리자는 행사를 생성하고 진행 상태를 직접 변경하며, 사용자는 유효한 QR로 본인의 출석을 등록할 수 있습니다.

  • 관리자 행사 생성·목록·상세 조회, 진행 상태 변경·삭제, 대상자 출석 상태·비고 수정, QR 발급 API를 구현했습니다.
  • 사용자 상태별 행사 조회와 QR 출석 API를 구현했습니다. 생성 시점의 전체 사용자를 출석 대상자로 고정하고, 사용자 조회·출석에는 JWT의 사용자 ID를 사용합니다.
  • 60초 QR 만료·재발급, 중복 출석 시 최초 시각 보존, 출석 인원 집계 및 행사별 쓰기 잠금으로 동시 요청을 처리합니다.
  • 컨트롤러·DTO 어노테이션으로 기존 Swagger 문서에 통합하고, JDBC 연결 전에 시간대를 설정해 요청한 출석 날짜가 하루 앞당겨 저장되는 문제를 수정했습니다.

초기 API 명세와 달라진 점 및 추가된 기능

비교 기준: main의 초기 openapi.yaml — 커밋 2a7450c 고정 링크. 이 PR에서 정적 YAML을 삭제하므로 병합 후에도 확인할 수 있는 링크를 사용했습니다.

항목 초기 명세 이번 구현
생성 상태·자동 전환 한국 날짜와 행사 날짜를 비교해 자동으로 시작·종료. 오늘 행사 생성 시 즉시 ONGOING 생성 시 항상 SCHEDULED를 DB에 저장하며, 날짜가 바뀌어도 자동 전환하지 않음
추가: 진행 상태 변경 엔드포인트 없음 PATCH /admin/attendances/{attendanceId}에 {"status":"ONGOING"} 전송. SCHEDULED, ONGOING, ENDED 중 지정하며 종료 후 재시작·예정 상태 복귀도 허용. 200과 변경된 행사 요약·인원 반환
QR 발급·출석 가능 시간 행사 날짜의 한국 시간 00:00부터 다음 날 00:00 전까지 저장된 상태가 ONGOING이면 행사 날짜와 무관하게 가능. 토큰 만료 시간 60초와 대상자 검증은 유지
추가: 상태 변경 시 QR 무효화 재발급 시 이전 토큰 무효화 재발급 외에도 상태가 실제로 변경되면 기존 QR을 무효화. 같은 상태를 재전송하면 QR 유지, 재시작하면 새 QR 발급 필요
추가: 행사 삭제 엔드포인트 없음 DELETE /admin/attendances/{attendanceId}. ADMIN만 모든 상태의 행사를 삭제할 수 있으며 대상자 출석 기록·비고도 함께 삭제. 요청 본문 없이 성공 시 204, 없거나 이미 삭제된 행사에는 404 반환
사용자 행사 조회 GET /attendances의 status는 ONGOING, ENDED만 허용 status=SCHEDULED 추가. 본인이 대상자인 예정 행사도 조회 가능하며, 생략 시 기본값은 ONGOING
API 문서 제공 /openapi.yaml의 별도 설계 문서를 Swagger UI에서 선택 정적 YAML과 별도 문서 선택 설정을 제거하고 기존 /v3/api-docs에 실제 구현 명세를 통합

생성 요청의 title·date 형식과 한국 시간 기준 오늘/미래 날짜 검증, 기존 응답 JSON 필드 구조는 유지합니다. expiresAt·checkedInAt은 ISO 8601 UTC(Z)로 반환하며, 초기 명세의 +09:00 시각 예시와 시간대 표기가 다릅니다.

관련 이슈

Closes #687

검증 결과

  • 확인 방법: JDK 17과 격리된 MySQL 8.0 DB, 테스트 전용 환경변수·설정으로 전체 Gradle 테스트를 실행했습니다. 최종 삭제 경로 이동 후 AttendanceControllerTest, SwaggerAccessTest를 다시 실행했습니다.
  • 결과: 삭제 기능 추가까지 전체 218개 통과, 최종 경로 이동 후 관련 12개 통과. 두 실행 모두 실패·오류·스킵 0건입니다. git diff --check origin/main...HEAD도 통과했습니다.
  • 검증 범위: 관리자 권한·입력 검증·Swagger 명세, 생성 대상자 고정·상태별 조회·집계, QR 위조·재발급·만료·중복 출석, 동시 상태 변경·삭제·출석 요청, 삭제 롤백, Flyway SQL 적용, UTC/미국 서부 시간대에서 JDBC 날짜 보존을 확인했습니다.

리뷰 참고 사항

  • 배포 시 Flyway V13__create_attendances.sql, V14__persist_attendance_status.sql이 적용됩니다. V14는 기존 행사의 상태를 적용 시점의 한국 날짜 기준으로 한 번만 계산해 저장합니다. 이후에는 관리자 변경 전까지 유지되며 새 행사는 항상 SCHEDULED입니다.
  • 프런트엔드는 생성 후 상태 변경 API로 ONGOING을 지정한 뒤 QR을 발급하고, 종료 시 ENDED를 지정해야 합니다. 상태 변경은 기존 대상자 출석 기록·비고를 유지하며, 삭제는 해당 행사와 기록을 함께 제거합니다.
  • 삭제 API의 최종 경로는 DELETE /admin/attendances/{attendanceId}입니다. 기존 사용자 경로에는 삭제 API를 제공하지 않습니다.
  • 서버는 QR에 넣을 attendanceId·qrToken을 반환합니다. QR 이미지 생성과 카메라 스캔은 초기 명세대로 프런트엔드에서 처리합니다.
  • 시간대 초기화는 JDBC 연결이 기본 시간대를 캐시하기 전에 실행되도록 변경했습니다. 2026-10-10 요청이 그대로 저장·조회되는 회귀 테스트를 포함합니다.

체크리스트

  • 변경 내용을 직접 검토했습니다.
  • 변경 범위에 맞게 검증하고, 위에 결과를 기록했습니다.
  • 비밀 값이나 개인정보가 포함되지 않았는지 확인했습니다.
  • (해당하는 경우) 변경 사항에 맞춰 관련 문서를 최신화했습니다.

@psvm203
psvm203 requested a review from Jun3610 as a code owner October 7, 2026 05:18
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

🚀 Deploying Preview to Cloudflare 🚀

Preview URL: https://feat-687-qr-attendance.dev.waps.im, https://feat-687-qr-attendance-waps-dev.leekus1001.workers.dev (commit de1c7ad)

This URL reflects your latest Preview deployment

Preview Deployments by commit

Status Deployment URL Commit Updated (UTC) See this deployment's details
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://44078d02.dev.waps.im, https://44078d02-waps-dev.leekus1001.workers.dev de1c7ad 2026-10-07T06:54:57.228Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://d3537d34.dev.waps.im, https://d3537d34-waps-dev.leekus1001.workers.dev 03c8be0 2026-10-07T05:17:46.289Z Visit the dashboard ↗

@github-actions github-actions 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.

코드 리뷰 결과

🔴 심각한 문제

1. AttendanceService.listAdmin에서 메모리 필터링 + 정렬 순서 무시 가능성

public Content<Summary> listAdmin(AttendanceStatus status) {
    return new Content<>(attendances.findAllWithCounts().stream()
        .map(...)
        .filter(a -> status == null || a.status() == status).toList());
}
  • DB에서 status 필터를 적용할 수 있는데 애플리케이션 레벨에서 필터링하여 불필요한 I/O와 성능 저하를 유발합니다.
  • findAllWithCounts()에 WHERE 절을 추가하거나 파라미터화된 쿼리를 사용하는 것이 좋습니다.

2. Attendance.acceptsQr — qrExpiresAt null-check 누락 위험

public boolean acceptsQr(String token, Instant now) {
    return qrToken != null && token != null && now.isBefore(qrExpiresAt) && ...
  • qrToken != null이어도 qrExpiresAt이 null인 상태(예: 리플렉션으로 필드만 세팅)라면 NPE 발생 가능. 방어적 null 체크가 필요합니다. 또한 changeStatus에서 두 필드를 동시에 null로 만들지만, 향후 리팩터링 시 취약합니다.

3. TimeZoneConfig.initializeTimeZone() — 정적 BeanFactoryPostProcessor에서 전역 상태 변경

@Bean
public static BeanFactoryPostProcessor initializeTimeZone() {
    return beanFactory -> TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul"));
}
  • 전역 JVM 상태(TimeZone.setDefault)를 프로세스 전체에 걸쳐 변경합니다. 서버 환경에서 테스트 격리 실패, 동일 JVM 내 다른 애플리케이션 컨텍스트에 영향 등이 발생할 수 있습니다.
  • Clock 빈은 Clock.systemUTC()인데, JDBC 시간대만 KST로 강제하는 것은 일관성 결여. LocalDate/Instant 변환 시 혼선 위험이 있습니다.

4. V14__persist_attendance_status.sql에서 DATE(UTC_TIMESTAMP() + INTERVAL 9 HOUR) 하드코딩

  • 마이그레이션 SQL에 KST 오프셋을 직접 계산하는 것은 DST/정책 변경에 취약합니다.
  • MySQL의 CONVERT_TZ(UTC_TIMESTAMP(), '+00:00', 'Asia/Seoul') 또는 마이그레이션 시점의 서버 TZ 설정을 활용해야 합니다.
  • 이후 애플리케이션의 Clock.systemUTC()와 마이그레이션의 9시간 오프셋이 서로 다른 규칙을 사용하므로 드리프트 가능.

5. AttendanceRepository.findByIdForUpdate의 광범위한 PESSIMISTIC_WRITE 락

  • 코드 주석에도 명시했지만, checkIn, issueQr, update, changeStatus, delete 모든 쓰기가 단일 로우 락을 획득하여 직렬화됩니다.
  • 실제 대규모 출석(수백 명 동시 QR 체크인) 시 처리량 병목. 특히 checkIn은 QR 토큰 검증만 필요하고 참가자 개별 로우 락만으로 충분한데, 매번 부모 attendance 로우를 잠급니다.
  • 테스트에서도 이 락에 의한 대기 시나리오만 검증되고 있어, 실 운영 부하 특성은 검증되지 않았습니다.

6. AttendanceControllerTest 및 AttendanceIntegrationTest의 취약한 Clock 목 처리

when(clock.instant()).thenAnswer(ignored -> now.plusNanos(ticks.incrementAndGet() * 1000));
  • 여러 스레드가 동시에 AtomicLong.incrementAndGet() 결과에 따라 서로 다른 시각을 받지만, truncatedTo(MICROS)로 인해 어느 호출이 "첫 번째"인지 결정론적이지 않습니다.
  • 테스트의 first = requests.get(0).get(...)은 완료 순서를 보장하지 못하므로 잠재적으로 flaky.

🟠 주요 개선 필요

7. AttendanceService.checkIn — 검증 순서 문제

var attendance = lockAttendance(attendanceId);  // 락 획득
requireOngoing(attendance);                     // 상태 검증
var participant = ...orElseThrow(ForbiddenException);
if (!attendance.acceptsQr(qrToken, now)) throw BadRequestException;
participant.update(...);
  • 비대상자(ForbiddenException)를 먼저 검증하지 않으면, 외부인이 임의 attendanceId로 요청해 락을 잡고 상태를 관찰할 수 있습니다. (DoS 소지) 대상자 여부를 우선 확인하는 편이 안전합니다.
  • 상태 검증(requireOngoing)과 락 획득 순서도 문서화된 계약과 일치하지만, 잘못된 상태에서도 락을 획득하는 비용이 큽니다.

8. @JsonCreator(DELEGATING) + validateFields 패턴의 중복 코드

  • 네 개의 record에서 거의 동일한 validateFields/text 로직을 반복 사용하고 있습니다. 공통 유틸(예: JsonRequestSupport)로 추출하거나 @JsonTypeInfo 기반 접근이 유지보수에 유리합니다.
  • 특히 text()는 필드 존재 여부와 타입 검증만 하고 넘어가는데, ChangeStatus, Update에서 PresenceStatus.valueOf(status)가 잘못된 값에 대해 IllegalArgumentException을 던집니다. 이는 BadRequestException이 아니므로 상위 GlobalExceptionHandler에서 500으로 처리될 가능성이 있습니다. (테스트는 400을 기대)
return new ChangeStatus(status == null ? null : AttendanceStatus.valueOf(status));

→ valueOf 실패 시 IllegalArgumentException → 명시적으로 catch해서 BadRequestException으로 래핑 필요.

9. AttendanceRequests.Create compact constructor 순서 이슈

public Create {
    if (title != null) title = title.strip();
}
  • @NotBlank 검증은 bean validation에 위임되지만, strip() 후 검증되도록 하려면 커스텀 검증이 필요합니다. @NotBlank는 공백만 있는 문자열도 거부하므로 현재 코드로는 우연히 동작하지만, "앞뒤 공백 제거 후 1~100자" 계약을 만족하는지는 strip 후 검증이 아님에 유의. JsonCreator로 생성된 경로에서는 @Size 검증은 100자 검증을 하지만, strip 이전 값 기준입니다.

10. AttendanceParticipant.update — note와 status의 부분 업데이트 시맨틱

public void update(PresenceStatus status, String note, Instant now) {
    if (status != null && status != this.status) { ... }
    if (note != null) this.note = note;
}
  • note == ""일 때 비고를 지우는 것은 명세에 부합하나, 명세에는 "빈 문자열이면 지운다"라고 되어 있을 뿐 null 허용 여부는 불명확합니다. @JsonCreator에서 text(body, "note")가 null을 반환할 수 있으므로 안전하지만, Swagger에는 minProperties = 1만 있고 status/note 중 하나가 반드시 non-null이어야 한다는 것은 DTO 검증에만 의존합니다.

11. attendance.title 길이와 AttendanceParticipant.userName 길이 불일치

  • DTO는 @Size(max = 100)이지만 엔티티는 @Column(length = 100), DB는 VARCHAR(100). 좋습니다.
  • 하지만 userName은 VARCHAR(255)이고 별도의 검증이 없습니다. 사용자 이름이 매우 긴 경우 DB 예외로 500 응답이 발생할 수 있습니다.

12. SwaggerConfig.attendanceRequestSchemas — 취약한 커스터마이저

var declaration = request.getAnnotation(Schema.class);
var schema = schemas.get(declaration.name());
  • declaration.name()이 명시되어 있지 않으면 NPE. 네 DTO 모두 name이 있지만, 리팩터링 시 런타임 NPE 위험.
  • springdoc 라이브러리 버전 업그레이드 시 이 워크어라운드가 조용히 어긋날 수 있고, 테스트는 spring-security-test를 띄우기 때문에 매번 통합 테스트를 돌리는 비용이 큽니다.

13. application.yml에서 springdoc urls 제거 후 /v3/api-docs/swagger-config 응답 회귀 테스트

  • SwaggerAccessTest에 urls가 없음을 단언하고 있지만, 이는 springdoc 기본 동작에 의존합니다. 버전 업그레이드 시 회귀 위험.
  • openapi.yaml 제거는 정적 문서를 통한 공개 API 계약을 제거하는 것이므로, 프런트엔드 참조 여부를 확인해야 합니다.

14. SecurityConfig에서 /openapi.yaml 화이트리스트 제거

  • 좋은 변경이지만, 위 application.yml에서 springdoc urls까지 제거한 이유와 함께 정리해야 합니다. Swagger UI에서 다른 문서로 이동할 경로가 없어졌는지 확인 필요.

15. AttendanceService.now()의 truncatedTo(ChronoUnit.MICROS)

  • MySQL DATETIME(6)과 매칭을 위한 것으로 보이지만, JVM이 이미 micros 미만 precision을 반환할 수 있어 불필요한 연산일 수 있습니다.
  • Clock이 systemUTC()인 상황에서 Instant는 항상 nanosecond precision까지 존재하므로 절단은 필요합니다. 다만 LocalDate today = today(now())에서도 truncate를 수행해 무의미합니다.

16. changeStatus — 트랜잭션 내에서 participants.findByAttendanceId 재조회

var attendance = lockAttendance(attendanceId);
attendance.changeStatus(request.status());
var targets = participants.findByAttendanceId(attendanceId);
  • 상태 변경만으로 참가자 전체를 Lazy 로딩해 집계하는 것은 대상자가 많으면 부담. @Query로 카운트만 뽑거나, 이미 DTO 프로젝션(Counts)을 재사용하는 편이 낫습니다.

17. AttendanceIntegrationTest의 setup()에서 DB 파괴적 스키마 조작

jdbc.execute("DROP TABLE attendance_participant");
jdbc.execute("DROP TABLE attendance");
  • 환경변수로 임시 DB를 지정하도록 강제했지만, 오지정 시 실제 개발 DB를 삭제할 위험이 있습니다. EnabledIfEnvironmentVariable 외에 DB 이름 검증(예: _test suffix)을 추가하는 것이 안전합니다.

18. AttendanceIntegrationTest의 트랜잭션 프록시 수동 구성

var proxy = new ProxyFactory(new AttendanceService(...));
proxy.addAdvice(new TransactionInterceptor(manager, new AnnotationTransactionAttributeSource()));
  • 이는 Spring의 AOP 프록시 생성 로직을 손으로 재현한 것으로, @Transactional(readOnly=true) 클래스 레벨 어노테이션 적용 여부가 취약합니다. Spring Boot Test + @DataJpaTest 등의 표준 방식을 권장. 또한 이 테스트는 Clock을 Mockito로 목킹해 시각 의존 로직을 검증하지만, @Transactional 동작 자체는 별도 검증이 필요합니다.

🟡 사소한 문제 및 스타일

19. import wap.web2.server.attendance.dto.AttendanceResponses.*; (와일드카드)

  • AdminAttendanceController, AttendanceController, AttendanceIntegrationTest 등에서 와일드카드 임포트 사용. Detail, Summary 등 중첩 record와 외부 클래스 이름 충돌 가능성이 있어 명시적 임포트를 권장합니다.

20. @Schema(implementation = String.class, allowableValues = "PRESENT")

@Schema(implementation = String.class, allowableValues = "PRESENT", example = "PRESENT") PresenceStatus status,
  • PresenceStatus가 enum인데 implementation = String.class로 강제하는 이유가 불명확. PresenceStatus enum에 @Schema가 붙어 있으므로 제거 가능합니다.

21. AttendanceResponses.Content<T> — @Schema(requiredProperties = "content")

  • requiredProperties에 content만 지정했지만, 필드가 null일 수 있다는 시맨틱이 반영되지 않습니다. nullable = false가 더 적절합니다.

22. 에러 응답의 path 예시가 실제와 불일치

  • ErrorResponse 예시가 /admin/attendances인데, 실제 서버 경로 접두사(예: context path)나 프록시 경로와 다를 수 있습니다.

23. AttendanceService 클래스의 @Transactional(readOnly = true) + 개별 @Transactional

  • 좋은 패턴이나, lockAttendance를 호출하는 모든 메서드가 @Transactional인지 확인 필요. checkIn, update, issueQr, changeStatus, delete는 그렇습니다. listMine, listAdmin, detail은 readOnly로 상속되지만, listMine은 join fetch 쿼리로 N+1은 회피했으나 filter가 애플리케이션 레벨임에 주의.

24. AttendanceParticipant.note 컬럼 초기값과 DTO 표현 불일치

@Column(nullable = false, length = 500)
private String note = "";
  • 필드 초기화와 V13 DDL의 DEFAULT '' 모두 일관되지만, 생성자에서 note를 명시하지 않고 필드 초기자에 의존합니다. 리플렉션/JPA dirty checking 관점에서는 문제 없지만, 다른 생성 경로(예: 프로토타입 복사) 추가 시 실수 여지가 있습니다.

25. AttendanceTest.creationIsAlwaysScheduledRegardlessOfDate — 로직 없음

  • new Attendance("발표", day).getStatus()가 항상 SCHEDULED. 이는 엔티티 필드 초기화 값이며, @PrePersist 등이 없으므로 사실상 enum 기본값 테스트. 실질적 검증 가치 낮음.

26. 테스트 클래스 간 Clock 목/실제 시각 혼용

  • AttendanceControllerTest는 Instant.parse(...) 상수로 응답 포맷만 확인하고, AttendanceServiceTest는 Mockito Clock을 사용합니다. 실제 KST 경계(예: 자정, DST 없음) 검증은 AttendanceIntegrationTest에만 의존. 리팩터링으로 KST 경계 로직이 바뀌면 회귀 포착이 어렵습니다.

27. V13, V14 마이그레이션 — 다운그레이드 스크립트 부재

  • Flyway는 기본적으로 forward-only이지만, 롤백 전략이 문서화되지 않았습니다. 특히 V14는 status 컬럼을 추가하면서 기존 로우 상태를 date 기준으로 정하는데, 이후 애플리케이션이 수동 제어로 바뀌었으므로 마이그레이션 시점 이후 데이터에 대한 가정이 코드와 다릅니다.

28. AttendanceRequests.Create의 어노테이션 처리와 from(JsonNode)

  • @JsonCreator(mode = DELEGATING)을 사용해 JsonNode로 우회하는 이유는 additionalProperties: false 강제 목적으로 보이지만, Jackson의 정식 DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES로 해결 가능합니다. DTO마다 커스텀 팩토리를 유지하는 유지보수 부담을 고려해야 합니다. (특히 Swagger 문서화 워크어라운드 SwaggerConfig.attendanceRequestSchemas로 이어짐)

29. AdminAttendanceController 클래스 레벨 @ApiResponses의 403/401 예시 없음

  • 개별 응답 예시는 있지만, 클래스 레벨에서 403에 대한 예시가 부족해 Swagger UI에서 확인이 어려울 수 있습니다. (마이너)

30. AttendanceRepository.findAllWithCounts — PresenceStatus FQN 쿼리

sum(case when p.status = wap.web2.server.attendance.entity.PresenceStatus.PRESENT then 1 else 0 end)
  • JPQL에서 enum FQN을 직접 쓰는 것은 지저분하고 IDE 지원이 약합니다. 파라미터 바인딩(:present)이 낫습니다.

✅ 잘한 점

  • Clock 주입으로 시간 의존성 역전, truncatedTo 처리 명확.
  • QR 토큰 32바이트 SecureRandom + MessageDigest.isEqual로 상수 시간 비교.
  • checkIn 멱등성(present 재요청 시 기존 시각 반환) 명확.
  • 201 Location, 204 noContent, 200 등 HTTP 시맨틱 준수.
  • 동시성을 고려한 테스트(CountDownLatch, 타임아웃 검증) 스캐폴딩 인상적.
  • 엔티티 스냅샷(참가자 명단 고정) 및 soft FK 정책 문서화.

📌 우선순위 요약

순위 이슈 영향
1 AttendanceExceptions의 Status.valueOf 실패 시 500 응답 가능 사용자 오류 처리
2 TimeZone.setDefault 전역 변경 및 Clock.systemUTC와의 불일치 서버 전역 부작용
3 PESSIMISTIC_WRITE 락의 광범위 적용 → 동시성 병목 확장성
4 V14 마이그레이션의 KST 9시간 하드코딩 데이터 정합성
5 checkIn 검증 순서(락 먼저, 대상자 검증 나중) DoS/정보 노출
6 listAdmin 메모리 필터링 성능
7 AttendanceIntegrationTest DB 파괴 위험 실수 시 데이터 유실
8 flaky 가능성이 있는 concurrency 테스트 CI 신뢰성

수고 많으셨습니다. 위 항목 중 1, 2, 3번은 배포 전 반드시 확인을 권장합니다.

@github-actions github-actions 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.

코드 리뷰

전체적으로 테스트와 문서화가 매우 잘 갖춰진 인상적인 PR입니다. 다만 몇 가지 설계·보안·정합성 관련 이슈가 보입니다.


🔴 보안 / 정합성 이슈

1. QR 토큰이 평문으로 DB에 저장됨

Attendance 엔티티에서 qrToken, previousQrToken이 평문 문자열로 저장됩니다. MessageDigest.isEqual로 타이밍 공격만 방어했을 뿐, DB 유출 시 유효 시간 내 토큰을 그대로 재사용할 수 있습니다.

권고: 토큰은 해시(SHA-256 등)하여 저장하고, 검증 시 입력값을 해시하여 비교하는 방식이 안전합니다. 길이가 43자 고정이므로 char(64)로 해시 저장도 가능합니다.

2. Attendance.title에 대한 XSS/출력 인코딩 검토 필요

title은 사용자(관리자) 입력이며 @NotBlank @Size(max=100)만 걸려 있습니다. 저장 시 그대로 저장하고 응답도 그대로 반환합니다. 프런트엔드에서 안전하게 렌더링한다는 전제가 있겠지만, 공백/개행/제어문자 strip이 없습니다.

권고: 최소한 제어 문자 제거 및 \n, \r 정규화를 고려하세요.

3. AttendanceRequests.from(JsonNode)에서 AttendanceStatus.valueOf 직접 호출

return new ChangeStatus(status == null ? null : AttendanceStatus.valueOf(status));

이 부분은 IllegalArgumentException을 던지며, 이는 @ControllerAdvice가 잡지 못하면 500으로 이어질 수 있습니다. PresenceStatus.valueOf도 동일.

권고: 명시적으로 try/catch 후 BadRequestException으로 변환하거나, 컨트롤러의 enum 바인딩과 동일한 예외 처리 경로를 타도록 하세요. (테스트에서는 INVALID 같은 경우 @RequestParam enum 변환은 Spring이 400으로 처리하지만, request body의 enum valueOf는 그렇지 않습니다.)

4. delete에 대한 인가(authorization) 체크 없음

DELETE /admin/attendances/{id}는 ADMIN 역할로 제한되지만, 다른 관리자의 행사를 삭제할 수 있습니다. 정책상 문제 없다면 무시 가능하나, 감사 로그(audit log) 부재는 리스크입니다.


🟠 로직 / 설계 이슈

5. 상태 변경 시 attendance.changeStatus()가 동일 상태면 QR을 유지

public void changeStatus(AttendanceStatus status) {
    if (this.status == status) return;  // QR 유지
    ...
}

문서상으로는 의도된 동작입니다. 다만 SCHEDULED → SCHEDULED 로 반복 호출해도 QR이 유지되는데, 이 경우 "재시작 시 새 QR 필요"라는 오퍼레이션 문서와 약간 어긋날 수 있습니다. 테스트가 커버하므로 문제는 아니지만, 문서와 코드의 의도를 명확히 하는 게 좋습니다.

6. now()가 clock.instant().truncatedTo(ChronoUnit.MICROS)

Instant.plusSeconds(60) 결과와 truncatedTo(MICROS) 간 순서 문제는 없지만, issueQr에서 now.plusSeconds(60)을 그대로 저장하여 트렁케이션이 적용되지 않습니다. checkedInAt만 트렁케이션되는 비대칭이 있습니다.

권고: qrExpiresAt도 동일한 정밀도로 정규화하거나, issueQr 진입 시 트렁케이션된 now를 사용하세요. (실제로 now()가 issueQr에 전달되므로 일관성은 있음. 다만 명시적입니다.)

7. create()에서 users.findAll() 후 저장

가입자 전체를 대상자로 고정하는 스펙은 이해되지만, 사용자가 많아지면 한 트랜잭션에서 대량 insert가 발생합니다. saveAll은 IDENTITY 전략으로 인해 batch insert가 비활성화됩니다.

권고: 스케일이 우려되면 jdbcTemplate.batchUpdate 또는 Hibernate SEQUENCE/TABLE 전략을 고려하세요.

8. listAdmin의 absentCount 계산

a.getTotalCount() - a.getPresentCount()

쿼리에서 totalCount를 count(p.id)로 가져오는데, sum(...)이 NULL인 경우 coalesce가 있어 안전합니다. 다만 findAllWithCounts에서는 coalesce가 없습니다:

sum(case when p.status = PRESENT then 1 else 0 end) as presentCount

LEFT JOIN으로 인해 참가자가 0명인 행사는 sum이 NULL이 되고, JPA가 long으로 매핑 시 예외 발생 가능성이 있습니다.

권고: AttendanceRepository.findAllWithCounts의 sum에도 coalesce(..., 0)을 추가하세요. AttendanceParticipantRepository.countForAttendance에는 이미 있으므로 비대칭입니다.

9. Migration V13에서 status 컬럼 부재 + V14가 NOT NULL DEFAULT 'SCHEDULED' 추가

V14의 UPDATE 문이 기존 row에 대해 상태를 채우지만, UPDATE 이후 신규 insert는 DEFAULT로 SCHEDULED가 됩니다. 이는 문서 스펙("생성 시 항상 SCHEDULED")과 일치하므로 OK.

10. TimeZoneConfig.initializeTimeZone이 BeanFactoryPostProcessor로 등록됨

static 메서드 + BeanFactoryPostProcessor는 컨텍스트 초기화 이전에 실행되어 Hikari/JDBC 초기화 시점 이전에 타임존을 설정한다는 의도로 보입니다. 다만 **동일 앱 내 다른 JVM 전역 상태(예: 로깅 타임존)**에는 영향이 없어, 로그에 UTC가 찍힐 수 있습니다. 로깅 정책도 확인하세요.


🟡 스타일 / 유지보수성

11. 와일드카드 static import 남용

여러 테스트에서 import static wap.web2.server.attendance.dto.AttendanceResponses.*; 등 와일드카드를 사용합니다. IDE 설정에 따라 lint 위반이 될 수 있습니다.

12. AttendanceService 내부 SEOUL 상수

private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul");

이미 TimeZoneConfig가 Asia/Seoul을 하드코딩하고 있어, 여러 곳에 문자열이 흩어집니다.

권고: 공통 상수(ZoneIds.SEOUL)로 통일하거나, 설정 프로퍼티로 외부화하세요.

13. SwaggerConfig.attendanceRequestSchemas가 리플렉션으로 @Schema.name()을 읽음

var declaration = request.getAnnotation(Schema.class);
var schema = schemas.get(declaration.name());

declaration이 null이거나 name이 비면 NPE입니다. 컴파일 타임에 어노테이션이 반드시 존재해야 한다면 OK지만, 방어 코드를 추가하는 편이 안전합니다.

14. AdminAttendanceController의 produces = "application/json"

DELETE는 204(no content)이므로 produces가 무의미할 수 있습니다. 또한 ExceptionHandler가 반환하는 ErrorResponse에 대해서는 produces가 강제되지 않습니다.

15. AttendanceParticipant.update()의 note 처리

if (note != null) this.note = note;

빈 문자열은 통과되어 "지움" 처리가 되는데, 이는 문서와 일치합니다. 다만 @Size(max=500)만 있고 strip()은 하지 않아, 앞뒤 공백이 그대로 저장됩니다. title은 strip()하는 것과 비대칭입니다.

권고: note도 정규화(strip 또는 그대로) 정책을 명확히 하고 일관되게 적용하세요.

16. AttendanceController.list의 status 기본값 ONGOING

@RequestParam(defaultValue = "ONGOING") AttendanceStatus status에서, 유효하지 않은 값은 Spring이 400으로 처리합니다. 테스트도 커버하므로 OK.

17. application.yml에서 springdoc.swagger-ui.urls 제거

/openapi.yaml 정적 문서를 삭제하는 것은 좋은 정리입니다. 다만 외부 클라이언트가 여전히 참조하고 있다면 breaking change입니다. 릴리스 노트에 명시 필요.

18. Attendance의 인덱스 명명

idx_attendance_date는 date,id 복합 인덱스인데 이름이 date만 가리킵니다. idx_attendance_date_id가 더 명확합니다.


🔵 테스트 관련

19. AttendanceIntegrationTest가 실제 MySQL에 의존

@EnabledIfEnvironmentVariable로 게이트되어 좋습니다. 다만 DROP TABLE attendance_participant / DROP TABLE attendance를 조건부로 수행하므로, 환경변수 오설정 시 운영 DB를 파괴할 위험이 있습니다. requireTestDatabase가 _test suffix를 검사하지만, getCatalog()이 null인 경우(일부 프록시) 우회 가능합니다.

권고: 추가로 SHOW DATABASES의 결과에서 현재 DB의 이름이 _test로 끝나는 것을 한 번 더 검증하거나, spring.flyway.clean-disabled=false 같은 명시적 플래그를 요구하세요.

20. AttendanceIntegrationTest에서 clock을 Mockito mock으로 사용

when(clock.instant()).thenAnswer(...);

Clock 인터페이스를 mock으로 만드는 것보다는, 실제 Clock.fixed나 테스트용 가변 Clock을 사용하는 편이 부작용이 적습니다. 다만 이 경우 동시성 시뮬레이션을 위해 어쩔 수 없는 선택으로 보입니다.

21. participant 첫 번째 대상자 가정

여러 테스트에서 detail.participants().content().get(0).note()와 같이 첫 번째 원소를 가정합니다. userName,asc 정렬이라 "가"가 항상 첫 번째인 점은 의존적입니다. 유지보수에 취약하므로 stream().filter(...)로 명시적 조회가 안전합니다.


요약 (우선순위)

순위 이슈 조치
🔴 높음 QR 토큰 평문 저장 해시 저장으로 변경
🔴 높음 valueOf 예외 → 500 가능 try/catch로 400 변환
🟠 중간 findAllWithCounts의 sum NULL 가능 coalesce 추가
🟠 중간 테스트 DB 파괴 방어 강화 이중 검증 or 플래그
🟠 중간 note strip 정책 불일치 title과 정책 통일
🟡 낮음 SwaggerConfig NPE 가능 null 방어
🟡 낮음 다수 와일드카드 import 명시적 import

전반적으로 설계 문서·통합 테스트·동시성 테스트가 인상적이며, 위 이슈들은 대부분 국소적 수정으로 해결 가능합니다.

@psvm203
psvm203 merged commit 78af2fe into main Oct 7, 2026
6 checks passed
@psvm203
psvm203 deleted the feat/687-qr-attendance branch October 7, 2026 08:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Server] feat: QR 인증 기능 추가

1 participant