Repository navigation
[Server] feat: QR 출석 관리 및 사용자 출석 API 구현 - #701
Conversation
🚀 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 deploymentPreview Deployments by commit
|
There was a problem hiding this comment.
코드 리뷰 결과
🔴 심각한 문제
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에서 springdocurls까지 제거한 이유와 함께 정리해야 합니다.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 이름 검증(예:_testsuffix)을 추가하는 것이 안전합니다.
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로 강제하는 이유가 불명확.PresenceStatusenum에@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 = "";- 필드 초기화와
V13DDL의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번은 배포 전 반드시 확인을 권장합니다.
There was a problem hiding this comment.
코드 리뷰
전체적으로 테스트와 문서화가 매우 잘 갖춰진 인상적인 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 presentCountLEFT 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 |
전반적으로 설계 문서·통합 테스트·동시성 테스트가 인상적이며, 위 이슈들은 대부분 국소적 수정으로 해결 가능합니다.
변경 내용
설계 문서만 있던 QR 출석 기능의 서버 로직을 구현합니다. 관리자는 행사를 생성하고 진행 상태를 직접 변경하며, 사용자는 유효한 QR로 본인의 출석을 등록할 수 있습니다.
초기 API 명세와 달라진 점 및 추가된 기능
비교 기준: main의 초기 openapi.yaml — 커밋 2a7450c 고정 링크. 이 PR에서 정적 YAML을 삭제하므로 병합 후에도 확인할 수 있는 링크를 사용했습니다.
ONGOINGSCHEDULED를 DB에 저장하며, 날짜가 바뀌어도 자동 전환하지 않음PATCH /admin/attendances/{attendanceId}에{"status":"ONGOING"}전송.SCHEDULED,ONGOING,ENDED중 지정하며 종료 후 재시작·예정 상태 복귀도 허용.200과 변경된 행사 요약·인원 반환ONGOING이면 행사 날짜와 무관하게 가능. 토큰 만료 시간 60초와 대상자 검증은 유지DELETE /admin/attendances/{attendanceId}. ADMIN만 모든 상태의 행사를 삭제할 수 있으며 대상자 출석 기록·비고도 함께 삭제. 요청 본문 없이 성공 시204, 없거나 이미 삭제된 행사에는404반환GET /attendances의status는ONGOING,ENDED만 허용status=SCHEDULED추가. 본인이 대상자인 예정 행사도 조회 가능하며, 생략 시 기본값은ONGOING/openapi.yaml의 별도 설계 문서를 Swagger UI에서 선택/v3/api-docs에 실제 구현 명세를 통합생성 요청의
title·date형식과 한국 시간 기준 오늘/미래 날짜 검증, 기존 응답 JSON 필드 구조는 유지합니다.expiresAt·checkedInAt은 ISO 8601 UTC(Z)로 반환하며, 초기 명세의+09:00시각 예시와 시간대 표기가 다릅니다.관련 이슈
Closes #687
검증 결과
AttendanceControllerTest,SwaggerAccessTest를 다시 실행했습니다.git diff --check origin/main...HEAD도 통과했습니다.리뷰 참고 사항
V13__create_attendances.sql,V14__persist_attendance_status.sql이 적용됩니다. V14는 기존 행사의 상태를 적용 시점의 한국 날짜 기준으로 한 번만 계산해 저장합니다. 이후에는 관리자 변경 전까지 유지되며 새 행사는 항상SCHEDULED입니다.ONGOING을 지정한 뒤 QR을 발급하고, 종료 시ENDED를 지정해야 합니다. 상태 변경은 기존 대상자 출석 기록·비고를 유지하며, 삭제는 해당 행사와 기록을 함께 제거합니다.DELETE /admin/attendances/{attendanceId}입니다. 기존 사용자 경로에는 삭제 API를 제공하지 않습니다.attendanceId·qrToken을 반환합니다. QR 이미지 생성과 카메라 스캔은 초기 명세대로 프런트엔드에서 처리합니다.2026-10-10요청이 그대로 저장·조회되는 회귀 테스트를 포함합니다.체크리스트