
학교행사는 일반적으로 서명부를 이용해서 참석을 인증하고,
경품 추첨을 위해 스탬프를 모으는 방식을 사용합니다.
하지만 이 방식은 물품 비용과 기록/관리 인적 비용이 많이 발생해서 비효율적입니다.
이 문제를 개선하기 위해 비용과 인적자원을 최소화할 수 있는
QR 코드 인증로직을 개발했습니다.
QR코드는 정보를 나타내는 매트릭스 형식의 이차원 코드로,
URL 링크를 포함한 정보 제공, 결제 및 인증 시스템 등 다양한 목적으로 활용됩니다.

QR코드를 활용하는 주요 상황은 다음과 같습니다.
기존 QR코드 방식은 사용자가 카메라로 QR코드를 인식하면
등록된 URL로 이동하는 방식입니다.
이 방법은 사용자들이 웹앱과 추가적인 상호작용을 하는 불편함을 겪기 때문에,
다른 방법을 고민했습니다.
고민하던 도중 페이앱을 통해 PC화면의 QR코드를 카메라로 인식해서
자동으로 결제하는 서비스가 떠올랐습니다

해당 서비스 동작방식을 활용한다면
추가적인 상호작용없이 바로 인증할 수 있습니다!
QR코드 인증 로직을 다음과 같이 정리했습니다.
1. 웹앱에서 제공하는 QR코드 카메라를 통해 QR코드를 인식
2. POST요청으로 QR코드에 담긴 정보를 서버로 전달
3. 서버는 검증과정을 거친 뒤, 사용자의 이벤트 참여를 DB에 기록
이제 QR코드만 인식된다면 추가 상호작용없이 이벤트 참여 인증이 가능할 것입니다!
이어서 담당할 부분을 역할별로 분류했습니다.
이어서 QR코드 인증 로직을 설계했습니다.
먼저 QR코드에 포함될 데이터를 어떻게 구성할지 고민했습니다.
QR코드만으로는 POST요청을 보낼 수 없습니다.
FE에서 파싱해서 서버로 POST요청을 보내야합니다.
FE에서 QR코드의 정보를 편하게 다루기 위해 쿼리스트링을 활용했습니다.
쿼리스트링은 이벤트정보를 포함합니다
QR코드를 인증하면 FE에서 쿼리스트링의 이벤트 정보를 추출하고,
서버로 POST요청을 합니다.

위 사진처럼 동작합니다!
BE는 FE가 요청한 POST요청의 정보를 토대로 이벤트 정보를 검증합니다.
검증 유형은 다음과 같습니다
만약 이벤트 참여기록이 있으면, 참여자 데이터를 새로 생성하고
이벤트 참여기록이 없으면, 참여횟수와 참여이벤트 유형을 업데이트합니다.
만약 참여자 기록 데이터가 있는 사용자면 다음 검증을 추가로 진행합니다.
설계한 QR코드 인증방식으로 서비스한다면,
기존 서명부 방식보다 비용을 절감하고 사용자도 편하게 이용할 수 있습니다!
하지만 한가지 치명적인 보안 취약점이 존재합니다.
QR코드 이미지를 한 사용자가 촬영하고 다른 사용자에게 공유하면
이벤트에 참여하지 않고도 인증이 가능합니다.
따라서 기존의 보안 취약점을 개선할 새로운 방법을 고민했습니다.
선택한 솔루션은 OTP앱에서 인증번호를 일정 시간이 지나면 교체하는 것처럼
QR코드 이미지도 일정 시간마다 갱신하는 방법입니다.
QR코드 이미지를 악용한다면 다음과 같은 프로세스로 진행할 것입니다

이 프로세스로 진행했을 때, 예상되는 최소 지연시간은 20초입니다.
촬영하는 시간, 네트워크 접속시간등을 고려했을 때, 예상한 최소 시간입니다.
이러한 지연시간을 역이용한다면, QR코드 인증 방식의 보안 취약점을 개선할 수 있습니다!
바로 QR코드 이미지를 더 짧은 시간마다 갱신하는 것입니다!
팀에서 정한 시간은 15초였습니다.
너무 짧게 하면 정상적인 사용자가 인증할 때 불편함을 느낄 수 있기 때문입니다.
QR코드 이미지를 15초마다 갱신하고,
이전에 이미지를 유효하지 않게 한다면 QR코드 이미지를 공유해도 악용할 수 없습니다!
하지만 이벤트정보 데이터로는 앞서 생각한 솔루션을 적용할 수 없습니다.

이벤트정보는 변화하지 않는 정적데이터라서 QR코드 이미지를 갱신하더라도
기존 이미지의 유효성을 파괴할 수 없습니다.
따라서 동적으로 변화를 줄 수 있고,
이 데이터를 바탕으로 검증할 수 있는 만료시간을 추가하기로 결정했습니다.

QR코드 이미지를 갱신할 때마다 현재 시간을 기준으로 15초 뒤의 만료시간을 계산합니다.
만료시간을 쿼리스트링에 추가하면 동적 데이터가 포함된 QR코드를 만들 수 있습니다!
QR코드에 담긴 URL은 사용자가 자유롭게 확인할 수 있습니다
따라서 중요한 정보는 반드시 암호화해야합니다.
이벤트 정보는 BE에서 암호화해서 전달합니다.
만료시간도 이벤트 정보와 마찬가지로 암호화해야합니다.

만약 만료시간을 조작해서 서버로 요청하면,
서버는 만료시간의 조작여부를 판단할 수 없기 때문입니다
이어서 만료시간 갱신의 주체를 고민했습니다.
가장 안전한 방법은 BE에서 담당하는 것입니다.
사용자에게 노출되지 않는 환경에서 모든 보안처리를 할 수 있기 때문이죠!
하지만 BE에서 갱신을 담당하면,
사용자와 연결을 유지하고 15초마다 갱신데이터를 전달해야합니다

연결을 유지한다는 것은 서버의 스레드를 점유하고 있다는 것이고,
이벤트가 많아진다면 서버에 부하를 줍니다.
이어서 FE에서 갱신을 담당한다면,
사용자에게 노출된 환경에서 보안처리를 하기 때문에, 안전하지 않습니다
하지만 서버의 부하를 줄일 수 있기 때문에, 성능적으로는 더 좋은 선택지입니다.
먼저 만료시간 갱신과 암호화는 FE에서 담당하기로 결정했습니다.
서버에 부하를 주는 것이 더 치명적이라고 판단했기 때문입니다
대신 암호화 키 관리는 BE에서 담당하기로 결정했습니다.
그리고 FE에서 필요할 때, 최소한으로만 요청해서 사용하기로 결정했습니다.
이렇게 한다면 서버부하 문제도 해결하면서, 보안적인 문제도 해결할 수 있습니다!
앞서 설계한 내용을 바탕으로 완성한 QR코드 생성 FLOW입니다!

FE는 최초 QR코드 이미지를 생성할 때,
BE에 요청해서 암호화된 이벤트 정보와 만료시간 암호화키를 받습니다!
다음은 QR코드 기반 이벤트 참여 인증 FLOW입니다

사용자가 QR코드 이미지를 인식하면 인증 로직으로 이어지고,
15초마다 만료시간을 갱신 및 암호화해서 QR코드 이미지를 새로 생성합니다!
담당한 파트는 BE였기 때문에, BE의 개발 내용만 다루겠습니다!
QR코드 생성 로직은 다음과 같이 개발했습니다
/**
* 1. 요청한 사용자 ROLE 검증 - ADMIN, SUPER_ADMIN 아니면 예외발생
* 2. Event PK 조회 - 없으면 예외 발생
* 3. Event PK 암호화
* 4. 4번과 만료시간 암호화 키 전달
*
* @param token - member token
* @param id - Event PK
* @return QrcodeCreateResponseDto - 암호화 Event PK/만료시간 암호화 키
*/
@Transactional
public QrcodeCreateResponseDto createQrcode(String token, Long id){
// 1번 로직
Member member = memberRepository.findById(jwtUtil.getMemberId(token))
.orElseThrow(() -> new ApiException(MEMBER_NOT_FOUND));
if(!member.getMemberType().equals(MemberType.ADMIN)
&& !member.getMemberType().equals(MemberType.SUPER_ADMIN)){
throw new ApiException(ROLE_ACCESS_DENIED);
}
// 2번 로직
Event event = eventRepository.findById(id)
.orElseThrow(() -> new ApiException(EVENT_NOT_FOUND));
log.info("event 조회: {}", event.getName());
// 3번 로직
String secureId = cryptoUtil.encrypt(id);
String secretKey = cryptoUtil.getSECRET_KEY();
// 4번 로직
return QrcodeCreateResponseDto.builder()
.secureId(secureId)
.secretKey(secretKey)
.build();
}
해당 요청은 암호화 키가 전달되기 때문에,
관리자 이상의 사용자만 접근할 수 있도록 개발했습니다.
위 로직으로 FE에서 QR코드 이미지를 생성하는데 필요한 데이터를 제공합니다!
QR코드 인증 로직은 다음과 같이 개발했습니다
/**
* * QR코드 인증 로직 Flow
* 1. Event PK 복호화
* 2. 만료시간 복호화 및 검증 - 지정 시간을 벗어나거나 복호화 실패할 경우 예외 발생
* 3. Member, Event 조회
* 4. EventType 체크 - CHECKING 타입인지 확인 / 아닐 경우 QR_NOT_VALID 예외 발생
* 5. Event와 연관관계 맺은 Contest 정보 조회
* 6 Event와 Contest EVENT_PROGRESS 상태인지 확인 / 아닐 경우 EVENT_NOT_PROGRESS 예외 발생
* 7. Member, Contest 정보로 participateContest 조회 - 없을 경우 null Return
* 8. participateContest이 null인 경우, participateContest객체 생성 및 연관관계 설정 후 SAVE
* 9. participateContest이 null이 아닌 경우, Event의 타입 포함여부 확인 - 포함된 경우 EVENT_ALREADY_PARTICIPATE 예외 발생
* 10. 8번 이후, 참여대회 수, 참여 대회 타입 저장 - 변경감지로 DB UPDATE 반영
*
* @param token - 사용자 정보
* @param qrcodeRequestDto - 암호화된 Event 엔티티 PK
* @return QrcodeResponseDto - 학생명/학번/이름명 Return
*/@Transactional
public QrcodeResponseDto createParticipation(String token, QrcodeRequestDto qrcodeRequestDto){
// 1번 로직
Long id = cryptoUtil.decrypt(qrcodeRequestDto.getSecureCode());
// 2번 로직
LocalDateTime expireTime = cryptoUtil
.decryptExpireTime(qrcodeRequestDto.getSecureExpireTime());
if(LocalDateTime.now().isBefore(expireTime.plusSeconds(3))
&& LocalDateTime.now().isAfter(expireTime.minusMinutes(16))){
throw new ApiException(EXPIRED_QR_CODES);
}
// 3번 로직
Member member = memberRepository.findById(jwtUtil.getMemberId(token))
.orElseThrow(() -> new ApiException(MEMBER_NOT_FOUND));
Event event = eventRepository.findById(id)
.orElseThrow(() -> new ApiException(EVENT_NOT_FOUND));
// 4번 로직
if(event.getEventType().equals(EventType.NO_CHECKING)){
throw new ApiException(QR_NOT_VALID);
}
// 5번 로직
Contest contest = event.getEventContest();
// 6번 로직
if(!contest.getCons().equals(ContestCons.EVENT_PROGRESS)
|| !event.getEventCondition().equals(ContestCons.EVENT_PROGRESS)){
throw new ApiException(EVENT_NOT_PROGRESS);
}
// 7번 로직
ParticipateContest participateContest = participateContestStateRepository
.findByMemberParticipateContestState_IdAndContestParticipateContestState_Id(
member.getId(), contest.getId())
.orElseGet(() -> createNewParticipateContest(contest, member, event)); // 8번 로직
if(participateContest != null){
// 9번 로직
if(participateContest.getEventTypeSet().contains(event.getEventCategory())){
throw new ApiException(EVENT_ALREADY_PARTICIPATE);
}
// 10번 로직
updateParticipateContest(participateContest, event, member);
}
return QrcodeResponseDto.builder()
.studentName(member.getName())
.studentId(member.getStudentId())
.eventName(event.getName())
.build();
}
// 10번 로직
private static void updateParticipateContest(ParticipateContest participateContest, Event event, Member member) {
participateContest.addCounts();
participateContest.getEventTypeSet().add(event.getEventCategory());
log.info("이벤트 참여정보 갱신 - 참여자 = {}, 참여 이벤트 = {}, 전체 이벤트 참여횟수 = {}, 참여 이벤트 유형 = {}",
member.getName(),
event.getName(), participateContest.getEventsCount(),
participateContest.getEventTypeSet());
}
// 8번 로직
private ParticipateContest createNewParticipateContest(Contest contest, Member member, Event event) {
ParticipateContest participateContest = ParticipateContest.builder()
.eventsCount(1)
.meritType(MeritType.PRIZE_NON_TARGET)
.receiveCons(ReceiveCons.PRIZE_NOT_RECEIVED)
.build();
participateContest.setContestParticipateContestStates(contest);
participateContest.setMemberParticipateContestStates(member);
participateContest.getEventTypeSet().add(event.getEventCategory());
participateContestStateRepository.save(participateContest);
log.info("이벤트 참여정보 생성 - 참여자 = {}, 참여 이벤트 = {}, 참여 이벤트 유형 = {}", member.getName(),
event.getName(), event.getEventType());
return null;}
QR코드 인증은 학생 이상의 권한을 가진 사용자만 이용할 수 있습니다
동작 FLOW는 다음과 같습니다

해당 과정을 통해 모든 검증을 통과한 경우, DB에 참여자 정보를 등록합니다!
기능 테스트 결과 정상적으로 작동하는 것을 확인했습니다
이제 원하는 서비스를 제공하면서 보안 취약점도 개선한 로직을 완성했습니다!
앞서 개발한 부분 중, QR코드 인증의 경우 총 7번의 쿼리가 발생합니다.
쿼리 발생 지점은 다음과 같습니다.
적은 요청에는 큰 문제가 되지 않지만,
동시 사용자가 많은 환경이라면 응답 지연시간이 커질 것입니다
따라서 쿼리 횟수를 줄여서 성능을 개선하기로 결정했습니다
성능 개선의 결과를 비교하기 위해 현재 상태에서의 응답속도를 테스트했습니다
Apache Jmeter를 통해 테스트를 진행했습니다.
테스트 환경은 다음과 같습니다

테스트 결과 평균 응답시간은 232ms로 기록되었습니다

응답시간 그래프를 보면, 전반적으로 200~270ms 응답시간인 것을 확인할 수 있습니다
쿼리 횟수를 개선할 수 있는 부분은 어디일까요?
Event와 Contest를 조회할 때입니다
둘은 1대 N 연관관계이기 때문에,
Event를 불러올 때 fetch join으로 Contest를 함께 불러온다면 쿼리 횟수를 줄일 수 있습니다!
따라서 QueryDSL로 쿼리문을 작성해서
Event를 불러올 때 Contest를 fetch join해서 함께 불러오도록 개발했습니다.
그 결과 쿼리의 횟수는 7회 -> 6회로 줄어들었지만
테스트 결과는 개선되지 않았습니다
테스트 환경은 앞선 테스트 환경과 동일합니다

테스트 결과 평균 응답시간은 282ms로 기록되었습니다

응답시간 그래프에서는 전반적인 응답시간이 240 ~ 320ms로 증가했습니다
쿼리의 횟수는 줄어들었지만 응답시간은 더 느린 결과를 얻었습니다.
이런 문제가 발생한 이유는 불필요한 조인을 추가했기 떄문입니다.
DB에서 조인의 비용은 비쌉니다.
fetch join을 하면서 불필요한 조인이 발생했고
따라서 응답시간이 더 느린 결과를 얻은 것입니다.
더 자세한 내용은 이후 JPA/hibernate와 DB에 대해 정리할 때 작성하겠습니다!
테스트 결과 더 좋지 않았기 때문에, 총 쿼리가 7번 발생하는 로직으로 원복했습니다.
비록 좋은 결과는 얻지 못했지만,
관련된 내용을 짧게나마 학습할 수 있었기 때문에 해당 과정을 정리했습니다!
QR코드 인증 로직 개발이 마무리되었습니다.
이제 해당 서비스를 통해 행사 전체적인 비용을 절감하고
안전하면서 사용자에게 편리한 환경을 제공할 수 있게 되었습니다!
QR코드 인증 로직 설계와 개발과정 정리글을 읽어주셔서 감사합니다!