개요

휴대폰의 타오바오, 위챗, 틱톡 등 대부분의 앱에서 “QR 코드로 로그인”은 PC + 모바일을 오가는 서비스의 표준 기능이 되었습니다. 사용자는 계정/비밀번호를 입력하지 않고도 휴대폰으로 한 번 스캔해 승인만 하면 되고, 기업은 PC 트래픽을 모바일로 유도하며 DAU와 리텐션을 높일 수 있습니다.

그렇다면 이 “한 번 스캔하고 한 번 누르는” 동작 뒤에는 어떤 기술 원리가 숨어 있을까요? 프런트엔드와 백엔드는 어떻게 맞물려 동작할까요?

1. 왜 QR 코드 로그인이 필요한가

전통적인 계정/비밀번호 로그인에는 다음과 같은 문제가 있습니다.

  1. 입력 비용이 높다: 공용/낯선 기기에서 긴 비밀번호를 입력하기가 불편합니다.
  2. 보안 위험: PC방이나 공용 PC에서 입력한 비밀번호가 키로거 등에 의해 탈취될 수 있습니다.
  3. 다중 단말 단절: 모바일에서 이미 로그인 상태여도 PC에서 다시 인증을 거쳐야 합니다.

QR 코드 로그인은 이를 해결합니다. 본질은 이미 인증이 끝난 모바일 앱이, 아직 인증되지 않은 PC 단말을 “대신 승인”하는 것입니다.

2. QR 코드 로그인 핵심 라이프사이클

위챗/QQ/사내 시스템 등 어떤 QR 로그인이라도 핵심 논리는 크게 다르지 않습니다. 전체 과정을 5단계로 보면 다음과 같습니다.

  1. QR 생성: PC에 유효기간이 있는 QR 코드 표시
  2. 스캔: 사용자가 앱으로 QR 스캔
  3. 로그인 확인: 앱에서 “PC 로그인 허용?” 확인 버튼
  4. PC 폴링/리스닝: PC가 승인 성공을 감지
  5. 로그인 성공: PC가 토큰을 획득해 서비스 이용

엔지니어링 관점에서는 QR 생성 → 스캔 → 확인의 3단계로 압축해 설계하는 경우가 많습니다. 아래에서 단계별로 구현 관점에서 깊게 뜯어보겠습니다.


3. 핵심 단계 분해와 구현

QR 로그인에서 가장 중요한 중간 저장소는 Redis입니다. PC 브라우저와 모바일 앱 사이에서 상태를 공유해야 하고, Redis는 고성능 메모리 읽기/쓰기 + TTL(만료)로 상태 전이를 다루기에 적합합니다.

3.1 단계 1: QR 생성 (권한 부여용 자격 증명 생성)

PC에서 “QR 로그인”을 클릭하면 시작됩니다.

1) PC가 서버에 QR 생성 요청

프런트엔드가 백엔드에 “로그인용 QR을 주세요”라고 요청합니다.

2) 백엔드가 전역 유일 ID(QR_Code_ID) 생성

보통 UUID로 생성합니다. 이 ID가 QR의 “영혼”이며 전체 흐름을 관통합니다.

3) Redis에 상태 저장

QR_Code_ID를 Redis에 저장하며 상태 머신을 구성합니다. 초기 상태는 NEW(대기)입니다. 보안과 메모리 누수를 막기 위해 반드시 TTL을 설정합니다(보통 1~5분).

Redis 예시

  • Key: qrcode:status:{QR_Code_ID}
  • Value: NEW (또는 0 같은 enum)
  • Expire: 300s

4) 프런트엔드에 응답 & QR 렌더링

백엔드는 QR_Code_ID를 반환합니다. QR 이미지 생성 방식은 두 가지가 흔합니다.

  • 백엔드 생성: ZXing 등 라이브러리로 이미지 생성 후 Base64로 반환
  • 프런트엔드 생성(권장): 백엔드는 QR_Code_ID + 딥링크(예: myapp://login/auth?qrcodeId=xxx)만 주고, 프런트엔드가 qrcode.js 등으로 브라우저에서 생성

    → 서버 CPU 부담이 크게 줄어듭니다.


3.2 단계 2: 앱 스캔 (상태 전이 + 임시 승인)

QR이 생성되면 PC는 “스캔됐는지”를 알아야 합니다. 이를 감지하는 대표적 방법은 아래 3가지입니다.

  • 짧은 폴링(Short Polling): 1~2초마다 HTTP 요청. 구현은 쉽지만 서버 부하 큼.
  • WebSocket: 양방향. 상태가 바뀌면 서버가 즉시 push. 실시간성은 최고지만 아키텍처 복잡.
  • SSE: 서버 → 클라이언트 단방향 push. “서버가 PC에 알려주기”에 적합.

앱 스캔 흐름

  1. 사용자는 로그인된 상태의 앱에서 QR을 스캔합니다.
  2. 앱은 QR에서 QR_Code_ID를 파싱합니다.
  3. 앱은 서버에 “스캔 완료” 요청을 보냅니다. 이 요청에는
    • QR_Code_ID
    • 현재 사용자 인증 토큰(예: App_User_Token)

    이 반드시 포함되어야 합니다.

  4. 서버는 App_User_Token을 검증하고, Redis 상태를 NEW → SCANNED로 변경합니다.
  5. 동시에 임시 토큰(Temp_Token) 을 생성해 QR_Code_ID와 묶어 Redis에 저장하고 앱에 반환합니다.

왜 바로 로그인시키지 않고 Temp_Token이 필요한가?

스캔은 “의도치 않게” 발생할 수도 있기 때문입니다. 최종 승인(확인) 단계에서 사용자의 명시적 동의를 받아야 하며, Temp_Token은 확인 요청이 방금 스캔한 동일 세션/기기에서 왔음을 보장하는 방지 장치입니다.

PC는 상태가 SCANNED로 바뀐 것을 감지하면, 보통 “스캔됨, 휴대폰에서 확인하세요” 같은 안내를 표시합니다.


3.3 단계 3: 로그인 확인 (정식 자격 증명 발급)

앱에서 “PC 로그인 허용”을 누르면 최종 승인 단계로 들어갑니다.

  1. 앱은 Temp_Token을 포함해 확인 요청을 보냅니다.
  2. 서버는 Redis에서 Temp_Token을 검증하고, QR_Code_ID와 일치하는지 확인합니다.
  3. 검증 성공 즉시 Temp_Token을 삭제(또는 무효화)해 1회용을 보장합니다.
  4. Redis 상태를 SCANNED → VERIFIED로 변경합니다.
  5. PC 측에서 사용할 정식 토큰(PC_User_Token) 을 발급합니다.
  6. PC가 토큰을 가져갈 수 있도록, 해당 토큰을 Redis에 잠시 저장합니다(보통 키는 QR_Code_ID).

PC는 폴링/리스닝을 통해 상태가 VERIFIED가 된 것을 확인하고 PC_User_Token을 수신하여 쿠키/로컬스토리지에 저장한 뒤, 홈으로 리다이렉트합니다. 이로써 흐름이 닫힙니다.


4. 핵심 코드 구조(의사코드)

4.1 enum: QR 상태 정의

public enum QRCodeStateEnum {
    NEW(0, "대기"),
    SCANNED(1, "스캔됨, 확인 대기"),
    VERIFIED(2, "로그인 승인 완료"),
    EXPIRED(3, "만료");

    // ... 생략
}

4.2 백엔드 확인 API

@PostMapping("/qrcode/confirm")
public Result confirmLogin(@RequestParam("tempToken") String tempToken,
                           @RequestHeader("App-Token") String appToken) {

    // 1. App 토큰 검증
    UserInfo user = authService.verifyAppToken(appToken);
    if (user == null) {
        return Result.error(ErrorCodeEnum.LOGIN_FAIL, "App 로그인 만료, 재로그인 필요");
    }

    // 2. Temp 토큰 검증 및 QR ID 추출
    String qrCodeId = redisService.get(RedisKey.TEMP_TOKEN_PREFIX + tempToken);
    if (StringUtils.isBlank(qrCodeId)) {
        return Result.error(ErrorCodeEnum.EXPIRED, "작업이 만료되었습니다. 다시 스캔하세요.");
    }

    // 3. 1회용 보장: Temp 토큰 삭제
    redisService.delete(RedisKey.TEMP_TOKEN_PREFIX + tempToken);

    // 4. PC용 정식 토큰 발급
    String pcToken = jwtUtils.generateToken(user.getUserId());

    // 5. QR 상태 업데이트 및 pcToken 저장
    Map<String, Object> stateData = new HashMap<>();
    stateData.put("status", QRCodeStateEnum.VERIFIED.getCode());
    stateData.put("pcToken", pcToken);

    redisService.set(
        RedisKey.QR_CODE_STATUS_PREFIX + qrCodeId,
        stateData,
        60,
        TimeUnit.SECONDS
    );

    return Result.success("로그인 성공");
}

4.3 PC 폴링 API

@GetMapping("/qrcode/status")
public Result checkStatus(@RequestParam("qrCodeId") String qrCodeId) {

    Object data = redisService.get(RedisKey.QR_CODE_STATUS_PREFIX + qrCodeId);
    if (data == null) {
        return Result.error(ErrorCodeEnum.QRCODE_EXPIRED, "QR 코드가 만료되었습니다");
    }

    Map<String, Object> stateData = (Map<String, Object>) data;
    Integer status = (Integer) stateData.get("status");

    switch (status) {
        case 0: // NEW
            return Result.success(data).setMessage("휴대폰으로 스캔하세요");
        case 1: // SCANNED
            return Result.success(data).setMessage("스캔됨, 휴대폰에서 확인하세요");
        case 2: // VERIFIED
            return Result.success(data).setMessage("로그인 성공");
        default:
            return Result.error(ErrorCodeEnum.SERVER_ERROR, "알 수 없는 상태");
    }
}

5. 예외 상황과 보안 설계

1) QR 코드 탈취: 통신은 반드시 HTTPS로, QR_Code_ID는 충분히 랜덤(UUIDv4 등)해야 합니다. 필요시 스캔 시점에 IP/단말 환경 검사를 추가할 수 있습니다.

2) “스캔됨”과 “확인됨”을 분리하는 이유: CSRF 관점에서 매우 중요합니다. “스캔 즉시 로그인”이면 공격자가 QR을 유도 이미지로 보내 계정을 탈취할 수 있습니다. 확인 단계를 통해 최종 결정권을 사용자에게 돌려줘야 합니다.

3) 만료 설계: Redis TTL로 3분 같은 유효기간을 관리합니다. PC 폴링이 키를 찾지 못하면 만료로 판단하고 UI에 “만료됨, 새로고침”을 표시합니다.