같은 숫자는 싫어

 

처음 실행 코드

def solution(arr):
    answer = []
    
    if answer[i] == answer[-1]:
        answer.append(i)
    else:
        countinue
    return answer

 

문제점

1. i가 정의되지 않음

answer[i]

-i를 사용하고 있지만, i는 어디에서도 정의되지 않았음

 

2. 빈 리스트에서 answer[-1]을 바로 사용할 수 없음

answer = []
answer[-1]

 

두번째 코드

def solution(arr):
    answer = []
    for i in arr:
        if answer[-1:] == [i] : 
            answer.append(i)
        else:
            continue
    return answer

 

문제점: 조건문 반대로 작성함

 

최종코드

def solution(arr):
    answer = []

    for i in arr:
        if answer[-1:] != [i]:
            answer.append(i)
        else:
            continue

    return answer
    
//보완코드
def solution(arr):
    answer = []

    for i in arr:
        if answer[-1:] != [i]:
            answer.append(i)

    return answer

 

다른 방식의 코드: 실무에 더 가까움

def solution(arr):
    answer = []

    for i in arr:
        if not answer or answer[-1] != i:
            answer.append(i)

    return answer

 

배운점

첫째, 리스트의 마지막 값을 확인할 때 answer[-1]을 바로 사용하면 빈 리스트에서 오류가 날 수 있다.

둘째, answer[-1:]처럼 슬라이싱을 사용하면 빈 리스트에서도 오류 없이 빈 리스트를 반환한다.

셋째, 조건문을 작성할 때 “언제 추가해야 하는가?”를 정확히 생각해야 한다. 이번 문제에서는 마지막 값과 현재 값이 같을 때 추가하는 것이 아니라, 다를 때 추가해야 했다.

 

'코딩테스트' 카테고리의 다른 글

시간복잡도 공간복잡도  (0) 2026.05.04

입력이 커질 때, 알고리즘이 얼마나 오래 걸리는지와 메모리를 얼마나 쓰는지를 나타내는 기준

 

1. 시간복잡도

 

①의미

 

Ⅰ) 시간복잡도란?

-입력 크기가 커질 때 알고리즘의 실행 시간이 얼마나 증가하는지를 나타냄

-입력 크기는 보통 'n'으로 표현

-예시: 배열에 숫자가 'n'개 있으면, 배열 크기를 'n'으로 봄

 

②쉬운 예시

 

Ⅰ) 배열을 한 번 훑는 경우

  def find(arr, target):
      for x in arr:
          if x == target:
              return True
      return False

-배열에서 원하는 값을 찾는 코드

-최악의 경우 배열 끝까지 전부 확인해야 함

-배열 길이가 'n' 이면 최대 'n'번 확인

-시간복잡도는 'O(n)'

 

) 첫 번째 값만 확인하는 경우

  def get_first(arr):
      return arr[0]

-배열이 몇 개든 첫 번째 값만 확인

-입력 크기와 상관없이 한 번만 실행

-시간복잡도는 'O(1)'

 

2. 공간복잡도

 

①의미

 

Ⅰ) 공간복잡도란?

-입력 크기가 커질 때 알고리즘이 메모리를 얼마나 더 사용하는지를 나타냄

-계산하는 동안 추가로 필요한 저장 공간이 얼마나 되는가?

 

②쉬운 예시

 

Ⅰ) 추가 배열을 만드는 경우

  def copy_array(arr):
      new_arr = []

      for x in arr:
          new_arr.append(x)

      return new_arr

-입력 배열  'arr'의 크기가 'n'이면 새 배열 'new_arr'도 크기가 'n'

-추가로 'n'짜리 저장 곤간이 필요함

-공간복잡도는 'O(n)'

 

) 변수 하나만 쓰는 경우

  def sum_array(arr):
      total = 0

      for x in arr:
          total += x

      return total

-배열 크기가 커져도 추가로 쓰는 변수는 'total' 하나

-입력 크기와 상관없이 추가 메모리가 거의 일정

-공간복잡도는 'O(1)'

 

3. Big-O표기법

 

①의미

 

Ⅰ) Big-O란?

-알고리즘의 정확한 실행 시간을 초 단위로 재는 것이 아님!

-입력이 커질 때 일의 양이 얼마나 빠르게 증가하는지를 표현하는 방식

-코딩 테스트에서는 보통 최악의 경우를 기준으로 Big-O를 많이 생각함

 

② 대표적인 Big-O

 

Ⅰ) O(1)

-입력 크기와 상관없이 거의 일정

-예시: 배열의 첫 번째 값 가져오기

 

) O(log n)

-입력이 커져도 아주 천천히 증가

-예: 이진 탐색

-매번 탐색 범위를 절반씩 줄이는 경우

 

Ⅲ) O(n)

-입력 크기만큼 시간이 늘어남

-예시: 배열 전체를 한 번 확인하기

 

Ⅳ) O(n log n)

-효율적인 정렬에서 자주 나옴

-예시: 병합 정렬, 힙 정렬, 파이썬 기본 정렬

 

Ⅴ)O(n^2)

-입력이 커질수록 시간이 빠르게 늘어남

-예시: 이중 반복문으로 모든 쌍 비교하기

 

Ⅵ) (2^n)

-매우 빠르게 폭발적으로 증가

-예시: 모든 부분집합 확인하기

 

→복잡도 증가 느낌

O(1)  O(log n)  O(n)  O(n log n)  O(n^2)  O(2^n)

 

4. 자주 나오는 코드별 복잡도

 

①O(1)

Ⅰ) 한 번만 처리하는 경우

  arr[0]

-배열의 크기와 상관없이 특정 위치에 바로 접근

 

 O(n)

Ⅰ) 반복문 하나로 전체를 확인하는 경우

  for x in arr:
      print(x)

-원소의 개수만큼 실행

 

 O(n^2)

Ⅰ) 이중 반복문으로 모든 조합을 확인하는 경우

  for i in range(n):
      for j in range(n):
          print(i, j)

-바깥 반복문이 'n'번, 안쪽 반복문도 'n'번 돎

-전체는 'n x n' 번

 

 O(log n)

Ⅰ) 탐색 범위를 절반씩 줄이는 경우

  def binary_search(arr, target):
      left = 0
      right = len(arr) - 1

      while left <= right:
          mid = (left + right) // 2

          if arr[mid] == target:
              return True
          elif arr[mid] < target:
              left = mid + 1
          else:
              right = mid - 1

      return False

-매번 탐색 범위를 절반으로 줄임

-단, 배열이 정렬되어 있어야 함

 

5. 시간복잡도와 공간복잡도의 관계

 

① 시간을 줄이기 위해 공간을 더 쓰는 경우

Ⅰ) 중복 확인 예시

def has_duplicate(arr):
      seen = set()

      for x in arr:
          if x in seen:
              return True
          seen.add(x)

      return False

-'set'을 사용해서 이미 본 값을 빠르게 확인

-시간복잡도는 평균 'O(n)' , 공간복잡도는 'O(n)'

-메모리를 더 쓰는 대신 속도가 빨라짐

 

② 공간을 아끼지만 시간이 오래 걸리는 경우

Ⅰ) 이중 반복문으로 중복 확인

def has_duplicate(arr):
      for i in range(len(arr)):
          for j in range(i + 1, len(arr)):
              if arr[i] == arr[j]:
                  return True
      return False

-추가 저장 공간은 거의 쓰지 않음

-공간복잡도는 'O(1)', 모든 쌍을 비교하므로 시간복잡도는 'O(n^2)'

 

6. 코디에스트에서 자주 쓰는 판단법

 

①입력 크기 확인하기

Ⅰ) n이 작을 때

- `n <= 10` 정도면 완전탐색, 순열, 조합도 가능할 수 있음

- 모든 경우를 다 확인해도 시간이 버틸 가능성이 있음

Ⅱ) n이 중간 정도일 때

- `n <= 1,000` 또는 `n <= 2,000` 정도면 `O(n^2)` 풀이가 가능할 때도 있음

- 하지만 제한 시간이 짧으면 조심해야 함

Ⅲ) n이 클 때

- `n <= 100,000`이면 보통 `O(n)` 또는 `O(n log n)`이 필요
- `O(n^2)`은 대부분 시간초과

 

②제한 조건으로 풀이 예상하기

- `n <= 10` → 완전탐색, 백트래킹 가능
- `n <= 20` → `2^n` 부분집합 탐색 가능할 수도 있음
- `n <= 500` → `O(n^3)` 가능할 수도 있음

- `n <= 2,000` → `O(n^2)` 가능할 수도 있음
- `n <= 100,000` → `O(n log n)` 또는 `O(n)` 필요
- `n <= 1,000,000` → 거의 `O(n)` 수준 필요

 

7. 코딩 테스트에서 자주 쓰는 자료구조

 

①리스트

Ⅰ) 인텍스 접근

 arr[i]

-시간 복잡도 'O(1)'

Ⅱ) 맨 뒤에 추가

arr.append(x)

-평균 시간복잡도 'O(1)'

Ⅲ) 맨 앞에서 삭제

arr.pop(0)

-앞 원소를 삭제하면 뒤 원소가 앞으로 밀려와야 함

-시간복잡도는 'O(n)'

-큐 문제에서 자주 시간초과를 만드는 실수

 

② deque

Ⅰ) 큐를 구현할 때 사용

from collections import deque

  q = deque()
  q.append(1)
  q.popleft()

-오른쪽 추가는 'O(1)', 왼쪽 삭제도 'O(1)', BFS 문제에서 자주 사용

 

③ set

Ⅰ) 중복 확인

seen = set()

-값이 있는지 확인하는 연산이 평균 'O(1)'

-중복제거, 방문 체크에 많이 사용함

 

④ dict

Ⅰ) 개수 세기

count = {}

  for x in arr:
      count[x] = count.get(x, 0) + 1

-key로 값을 빠르게 찾을 수 있음

-평균 조회, 삽입은 'O(1)'

-빈도 계산, 매칭, 해시 문제에 많이 사용

 

⑤ heap

Ⅰ) 우선순위가 필요한 경우

 import heapq

  heap = []
  heapq.heappush(heap, 3)
  heapq.heappop(heap)

- 삽입은 `O(log n)`, 삭제도 `O(log n)`, 최솟값 확인은 `O(1)`
- 가장 작은 값, 가장 큰 값, Top-K 문제에 자주 나옴

 

8. 코딩테스트에서 자주 쓰는 알고리즘 패턴

 

①완전탐색

Ⅰ) 모든 경우를 확인하는 방법

-가능한 경우를 전부 확인

-입력이 작을 때 사용

-시간복잡도는 문제에 따라 `O(n)`, `O(n^2)`, `O(2^n)`, `O(n!)` 등이 될 수 있음

 

②정렬

Ⅰ) 순서를 정리한 뒤 문제를 쉽게 푸는 방법

- 보통 시간복잡도는 'O(n log n)'
- 정렬 후 이진 탐색, 투 포인터, 그리디로 이어지는 경우가 많음

 

③ 이진 탐색

Ⅰ) 정렬된 데이터에서 빠르게 찾는 방법

-시간 복잡도는 'O(log n)'

-단, 데이터가 정렬되어 있어야 함

 

Ⅱ) 답의 범위를 탐색하는 경우

-'최소값을 구하라', '최대값을 구하라' 문제에서도 사용

-어떤 값 'x'가 가능한지 검사하고, 가능하면 범위를 줄이는 방식

-이러한 유형을 파라메트릭 서치라고 함

 

④투 포인터

Ⅰ)두 개의 위치를 움직이며 푸는 방법

-정렬된 배열이나 연속 구간 문제에서 사용

-두 포인터가 한 방향으로만 움직이면 시간복잡도는 'O(n)'

 

⑤ 슬라이딩 윈도우

Ⅰ) 연속된 구간을 유지하며 푸는 방법

-구간 합, 구간 길이, 연속 부분 배열 문제에 자주 나옴

-시간복잡도 'O(n)'

-단, 구간을 늘리고 줄이는 기준이 명확해야 함

 

⑥ BFS / DFS

Ⅰ) 그래프나 미로를 탐색하는 방법

- BFS는 가까운 곳부터 넓게 탐색
- DFS는 한 방향으로 깊게 들어갔다가 돌아옴
- 인접 리스트 기준 시간복잡도는 `O(V + E)` //`V`는 정점 개수, `E`는 간선 개수

 

⑦ DP

Ⅰ) 작은 문제의 답을 저장해서 큰 문제를 푸는 방법

-같은 계산을 반복하지 않기 위해 저장

-경우의 수, 최대값, 최소값 문제에서 자주 나옴

-시간복잡도는 보통 '상태 수 x 각 상태에서의 선택 수'로 계산

 

⑧ 그리디

Ⅰ) 매 순간 가장 좋아 보이는 선택을 하는 방법

-정렬과 함께 자주 사용

-보통 'O(n)' 또는 'O(n log n)' 이 많이 나옴

-단, 지금의 선택이 전체적으로도 최적인지 확인해야 함

 

중복 확인 → set, dict
최단 거리 → BFS
정렬된 배열 탐색 → 이진 탐색
연속 구간 → 슬라이딩 윈도우
모든 조합 → 완전탐색 / 백트래킹
최대·최소·경우의 수 → DP 가능성 확인

 

9. 실전에서 복잡도 계산하는 방법

 

구분 확인할 것 판단 기준 자주 나오는 복잡도 예시
1 반복문 개수 반복문이 몇 겹인지 본다 O(n)O(n^2)O(n^3) for i in range(n) → O(n)
2 중첩 반복문 바깥 반복문과 안쪽 반복문이 모두 n번 도는지 본다 O(n^2) for i in range(n): for j in range(n):
3 반복 횟수 감소 반복할 때마다 범위가 절반씩 줄어드는지 본다 O(log n) 이진 탐색
4 배열 전체 순회 배열이나 리스트를 처음부터 끝까지 한 번 보는지 본다 O(n) 최댓값 찾기, 합 구하기
5 정렬 사용 정렬을 먼저 하는지 확인한다 O(n log n) arr.sort()
6 해시 사용 setdict로 빠르게 찾는지 본다 평균 O(1) 중복 확인, 빈도 계산
7 재귀 사용 함수가 자기 자신을 몇 번 호출하는지 본다 상황에 따라 다름 팩토리얼, DFS
8 추가 배열 사용 입력 외에 새 리스트나 배열을 만드는지 본다 공간복잡도 O(n) 가능 복사 배열 만들기
9 변수만 사용 배열 없이 변수 몇 개만 쓰는지 본다 공간복잡도 O(1) 합계, 최대값 저장
10 /스택 사용 deque, stack 등을 쓰는지 본다 보통 O(n) 또는 연산당 O(1) BFS, 괄호 검사
11 그래프 탐색 정점과 간선을 모두 방문하는지 본다 O(V + E) BFS, DFS
12 모든 경우 탐색 가능한 경우를 전부 확인하는지 본다 O(2^n)O(n!)  부분집합, 순열

 

  • 시간복잡도는 입력이 커질 때 얼마나 빨리 느려지는가를 보는 개념
  • 공간복잡도는 보통 입력 자체보다 추가로 쓰는 메모리를 중심으로 보는 경우가 많음
  • 정렬은 O(n log n) 비용이 들지만, 정렬한 뒤에 문제를 휠씬 쉽게 풀 수 있는 경우가 많음
  • 해시는 메모리를 더 쓰는 대신 탐색을 빠르게 만들어줌
  • 재귀는 코드가 깔끔해질 수 있지만, 깊이가 너무 커지면 스택 문제나 재귀 제한 문제가 생길 수 있음

!Big-O에서 상수는 보통 무시

!리스트의 pop(0)을 큐처럼 쓰면 위험

!큐가 필요하면 deque를 쓰는 게 좋음

!이진 탐색은 아무 배열에나 쓰는 게 아님⇒ 정렬되어 있거나, 답의 가능 여부가 일정한 방향으로 나뉘는 구조가 있어야 함

!슬라이팅 윈도우는 연속 구간 문제에 자주 쓰이지만, 음수가 섞이면 단순하게 적용되지 않을 수 있음

!set, dict 평균적으로 빠르지만, 공간을 추가로 사용함

!재귀 함수는 호출 스택도 공간복잡도에 포함해야 함

 

'코딩테스트' 카테고리의 다른 글

스택  (0) 2026.05.05


→ 이 주제는 "사용자 요청으로 즉시 처리되는 결제 흐름"과 "시간이 지나면 자동으로 정리되는 만료 흐름"이 연결된 구조
→ 핵심은 단순히 결제를 한 번 호출하는 것이 아니라, 자원을 잠깐 잡아두고(HOLD), 외부 결제를 확인한 뒤(CONFIRM), 성공하면 확정하고 실패하거나 방치되면 정리하는 상태 전이 시스템이라는 점

1. 사용자 요청 기반 흐름
① 결제 화면 진입
② 서버 기준 가격 재계산
③ 좌석/쿠폰 임시 점유
④ 외부 PG 승인
⑤ 내부 DB 확정
⑥ 포인트/쿠폰 후처리
⑦ 메일 발송

2. 시간 기반 자동화 흐름
① 임시 점유가 일정 시간 유지됨
② 사용자가 결제를 끝내지 않으면 만료 시각이 지남
③ 스케줄러가 주기적으로 만료 대상을 검사함
④ 좌석과 예약 연결을 해제함

[1] 외부 API 연동

① 개념 정의
→ 외부 API 연동은 "내 애플리케이션 바깥에 있는 시스템"과 정해진 계약에 따라 데이터를 주고받는 것
→ 이때 내 코드가 호출하는 대상은 내 DB가 아니라, 네트워크 밖의 다른 서비스
→ 결제 PG, 메일 서버, 지도/문자/인증 서비스가 대표적인 외부 연동 대상

② 왜 필요한가
- 결제 승인은 내 서버만으로 만들 수 없음
- "돈이 실제로 승인되었는가"는 PG사가 가진 사실
- 따라서 결제 성공 여부를 확정하려면 외부 결제 서비스와 통신해야 함
- 마찬가지로 메일도 사용자의 메일함까지 전달되려면 SMTP 같은 외부 인프라와 연결되어야 함

- 이 개념이 없으면 생기는 문제
1. 내 서버가 혼자 "결제 성공"이라고 적어도 실제 돈이 안 빠졌을 수 있음
2. 반대로 PG는 승인했는데 내 서버 저장이 실패할 수도 있음
3. 즉, 외부 세계의 사실과 내 DB의 사실이 어긋날 수 있음

③ 내부 동작 원리


1. 내 시스템이 요청 데이터를 만듦
2. 인증 정보(비밀키, 토큰 등)를 헤더에 넣음
3. 외부 서비스에 요청을 보냄
4. 응답 성공/실패를 해석
5. 그 결과를 내 도메인 상태에 반영
6. 중간 실패가 있으면 보상 처리나 재시도를 설계

→ 중요한 점은 "외부 API 호출"과 "내 DB 트랜잭션"은 본질적으로 같은 트랜잭션이 아니라는 점
→ 그래서 둘 중 하나만 성공하는 반쪽 성공(partial success)이 생길 수 있음
→ 이 문제를 줄이기 위해 멱등성, 금액 검증, 보상 취소 같은 개념이 같이 따라옴

④ 오해하기 쉬운 포인트
! 외부 API를 호출했다고 해서 내 DB 트랜잭션 안에서 모두 원자적으로 묶이는 것은 아님
! HTTP 200 응답을 받았다고 해서 내 로컬 후처리까지 성공한 것은 아님
! 내 프론트가 백엔드 API를 부르는 것과, 백엔드가 토스 API를 부르는 것은 같은 "API 호출"처럼 보여도 성격이 다름
- 프론트 → 백엔드 호출: 내 서비스 내부 계층 간 통신
- 백엔드 → 토스 호출: 외부 시스템 연동

⑤ 코드에 적용해서 설명

private void executeTossConfirmOrThrow(PaymentDTO.ConfirmRequest request) {
    HttpHeaders headers = createTossHeaders();

    Map<String, Object> body = new HashMap<>();
    body.put("paymentKey", request.getPaymentKey());
    body.put("orderId", request.getOrderId());
    body.put("amount", request.getAmount());

    ResponseEntity<String> resp = restTemplate.postForEntity(
        TOSS_CONFIRM_URL,
        new HttpEntity<>(body, headers),
        String.class
    );
}


- 상태 변화
i) 결제는 아직 내 시스템에서 READY 상태
ii) 외부 PG 승인은 아직 확정되지 않았음

- 처리
i) secret key로 인증 헤더를 만듦
ii) paymentKey, orderId, amount를 묶어 토스 승인 API로 보냄
iii) 성공/실패를 예외로 구분

- 결과
i) 성공이면 "외부 PG 관점에서 승인 완료"라는 사실을 얻음
ii) 실패면 내 시스템은 확정 단계를 진행하지 않고 예외 처리로 빠짐

const res = await preparePayment({...});
await requestPayment({...}, selectedPaymentMethod);


- 이 코드는 "프론트가 자기 서버 API를 호출하는 단계"
- 여기서 보이는 preparePayment, requestPayment는 외부 PG를 직접 부르는 코드가 아니라, 내 백엔드와 연결되는 내부 API 계층

[2] 결제 승인 흐름

① 개념 정의
→ 결제 승인 흐름은 "결제 직전 준비 단계"와 "결제 성공 확정 단계"를 나누어 처리하는 비즈니스 워크플로우
→ 이 프로젝트에서는 특히 "좌석"이라는 한정 자원이 있어서, 결제 전에 자원을 임시 점유하고, 결제가 끝나면 확정하는 구조가 중요

→ 즉,
- 준비 단계: 가격 계산 + 자원 hold
- 확정 단계: 외부 승인 확인 + 내부 상태 commit

② 왜 필요한가
- 영화 좌석은 동시에 여러 사람이 노릴 수 있는 희소 자원
- 결제가 끝나기 전까지 좌석을 완전히 확정해버리면 오결제가 생길 수 있고,
- 반대로 아무 제어 없이 두면 같은 좌석을 여러 명이 결제하려 할 수 있음

- 이 개념이 없으면 생기는 문제
1. 같은 좌석 중복 결제
2. 클라이언트가 금액을 조작해 더 적게 결제
3. 결제 도중 이탈한 사용자가 좌석을 영원히 점유
4. PG 승인 재시도/중복 콜백에서 중복 확정

③ 내부 동작 원리


1. 준비 단계
① 서버가 최종 가격을 다시 계산
② 좌석을 잠시 HELD 상태로 둠
③ 쿠폰도 HELD 상태로 바꿀 수 있음
④ 결제 엔티티를 READY 상태로 만듦
⑤ orderId를 발급

2. 확정 단계
① orderId로 기존 READY 결제를 찾음
② 이미 PAID면 중복 확정을 막음
③ 금액이 맞는지 검증
④ 외부 PG 승인 API를 호출
⑤ 성공하면 reservation/payment/seat 상태를 확정
⑥ 후처리로 쿠폰 USED, 포인트 차감/적립 등을 수행

3. 실패 단계
① 외부 승인 실패면 FAILED 처리
② 필요 시 HELD 쿠폰 해제
③ 로컬 후처리 실패가 외부 승인 뒤에 발생하면 보상 취소를 시도

④ 오해하기 쉬운 포인트
! preparePayment는 결제 성공이 아님
! HELD는 RESERVED가 아님
! 사용자가 화면에서 계산한 금액이 아니라, 서버가 계산한 금액이 기준이어야 함
! 멱등성은 "같은 요청이 여러 번 와도 결과가 한 번만 확정되게 하는 성질"이지, 단순 try-catch가 아님
! 외부 승인 성공 뒤 DB 저장이 실패할 수 있으므로, 보상 취소 로직이 필요

⑤ 코드에 적용해서 설명

payment.setPaymentStatus(PaymentStatus.READY);
payment.setOriginalAmount(originalPrice);
payment.setDiscount(discountAmount);
payment.setUsedPoints(usedPoints);
payment.setFinalAmount(finalVal);

List<ScreeningSeat> seats = screeningSeatRepository.findAllByScreeningIdAndSeatIdsWithLock(...);

for (ScreeningSeat seat : seats) {
    seat.setStatus(SeatStatus.HELD);
    seat.setReservation(reservation);
    seat.setHoldExpiresAt(LocalDateTime.now().plusMinutes(10));
}


- 상태 변화
i) 사용자는 아직 결제 완료 전
ii) 좌석은 아직 최종 예약이 아님
iii) 쿠폰도 아직 최종 사용이 아님

- 처리
i) 서버가 영화 시간대/상영관/요금 타입 기준으로 originalPrice를 계산
ii) 회원일 때만 쿠폰/포인트를 검증
iii) 쿠폰은 AVAILABLE에서 HELD로 바뀔 수 있음
iv) 결제 엔티티는 READY 상태가 됨
v) 좌석은 잠금 의도가 드러나는 조회 후 HELD 상태가 됨
vi) 좌석 만료 시각을 now + 10분으로 설정

- 결과
i) 결제 가능한 주문 단위(orderId)가 생김
ii) 좌석은 "임시 점유" 상태가 됨
iii) 아직 결제 성공은 아님

useEffect(() => {
    const res = await preparePayment({
        reservationId: reservationDetail.reservationId,
        screeningId: reservationDetail.screeningId,
        tickets,
        memberCouponId: safeCouponId,
        usedPoints: safeUsedPoints,
    });

    setCurrentFinalAmount(res.finalAmount);
    updateAmount(res.finalAmount);
}, [reservationDetail?.reservationId, selectedCouponId, usedPoints]);


- 상태 변화
i) 사용자가 쿠폰/포인트를 바꾸기 전에는 화면 금액이 임시 값일 수 있음

- 처리
i) 할인 조건이 바뀔 때마다 프론트가 서버에 preparePayment를 다시 요청
ii) 서버가 다시 계산한 finalAmount를 받아 화면 상태를 갱신

- 결과
i) 최종 결제 금액 기준이 "프론트 자체 계산"이 아니라 "서버 재계산 결과"가 됨
ii) 금액 조작 가능성을 줄임

 

if (payment.getPaymentStatus() == PaymentStatus.PAID) {
    return existingResponse;
}

if (!Objects.equals(payment.getFinalAmount(), request.getAmount())) {
    markPaymentFailed(payment, "amount_mismatch");
    releaseHeldCouponIfAny(reservation);
    throw new IllegalArgumentException("금액 불일치");
}

executeTossConfirmOrThrow(request);

payment.setPaymentStatus(PaymentStatus.PAID);
reservation.setStatus(ReservationStatus.PAID);

screeningSeatRepository.findByReservationId(reservation.getId())
    .forEach(s -> {
        s.setStatus(SeatStatus.RESERVED);
        s.setHoldExpiresAt(null);
    });

applyCouponAndPoints(payment, reservation);
sendPaymentCompleteEmailSafely(payment, reservation, bookingNo);


- 상태 변화
i) 결제는 READY
ii) 좌석은 HELD
iii) 외부 승인 여부는 아직 확정 전

- 처리
i) orderId로 결제 건을 잠금 조회
ii) 이미 PAID면 그대로 반환해 중복 확정을 막음
iii) 서버가 기억한 finalAmount와 요청 amount를 비교
iv) 토스 승인 API를 호출
v) 성공하면 payment는 PAID, reservation은 PAID가 됨
vi) 좌석은 HELD에서 RESERVED로 바뀌고 holdExpiresAt이 제거
vii) 쿠폰/포인트 후처리와 메일 발송이 이어짐

- 결과
i) 이제 이 예매는 임시 상태가 아니라 확정 상태
ii) 좌석은 더 이상 만료 대상이 아님
iii) 사용자에게 예약번호를 줄 수 있음

 

const handlePayment = async () => {
    await requestPayment(
      {
        reservationId: reservationDetail.reservationId,
        screeningId: reservationDetail.screeningId,
        tickets,
        totalPrice: currentFinalAmount,
        memberCouponId: isMember ? selectedCouponId : null,
        usedPoints: isMember ? usedPoints : 0,
      },
      selectedPaymentMethod
    );
};


- 상태 변화
i) 사용자가 결제 버튼을 누르기 전까지는 단지 준비 상태

- 처리
i) 사용자가 현재 서버 기준 금액과 선택한 할인/결제수단을 들고 결제 요청을 시작

- 결과
i) 이후 실제 PG 승인 시작과 confirm API 연결이 진행

[3] 메일 발송

① 개념 정의
→ 메일 발송은 "핵심 비즈니스 처리"가 끝난 뒤 사용자에게 결과를 통지하는 후속 작업
→ 보통 결제 성공 사실을 사용자에게 전달하고, 예약 번호나 내역을 증빙처럼 제공하는 역할

② 왜 필요한가
- 결제 성공은 DB에만 저장되면 사용자가 체감하기 어려움
- 특히 예약 시스템에서는 "내가 정말 예약됐는가"를 사용자가 외부 채널에서도 확인할 수 있어야 함
- 메일은 알림이자 기록

③ 내부 동작 원리
→ 메일 발송은 보통 두 방식

1. 동기식 호출
- 성공 직후 바로 메서드를 호출
- 구현은 단순하지만, 메일 시스템이 느리면 응답에도 영향이 갈 수 있음

2. 비동기/이벤트 방식
- 결제 완료 이벤트를 큐에 넣고 별도 워커가 보냄
- 더 견고하지만 구조가 복잡해짐

→ 현재 보이는 코드는 "동기식으로 호출하되, 실패해도 결제 성공 자체를 뒤집지는 않는" 방식에 가까움

④ 오해하기 쉬운 포인트
! 메일 발송 성공이 결제 성공의 본질은 아님
! 메일 발송 실패를 결제 롤백으로 연결할지 말지는 비즈니스 선택


⑤ 코드에 적용해서 설명

private void sendPaymentCompleteEmailSafely(Payment payment, Reservation reservation, String bookingNo) {
    if (payment.getMember() == null) {
        return;
    }

    try {
        mailService.sendPaymentCompleteEmail(payment.getMember(), reservation, bookingNo);
    } catch (Exception e) {
        log.error("[결제완료메일 실패] ...", e);
    }
}


- 상태 변화
i) 결제와 예약은 이미 성공 확정된 상
ii) 이제 사용자 알림 단계로 넘어감

- 처리
i) 회원인 경우에만 메일 발송을 시도
ii) mailService에 회원, 예약, 예약번호를 넘김
iii) 실패해도 예외를 삼키고 로그만 남김

- 결과
i) 메일은 "best effort" 후처리
ii) 메일 실패가 결제 성공을 뒤집지 않음

[4] 예약 만료 처리

① 개념 정의
→ 예약 만료 처리는 "사용자가 아무 행동을 하지 않아도 시간 조건만으로 상태를 바꾸는 백그라운드 정리 작업"
→ 특히 HOLD 상태의 자원을 영구 점유 상태로 남기지 않기 위한 시간 기반 자동화

② 왜 필요한가
- 사용자가 결제 화면에서 이탈할 수 있음
- 네트워크 오류로 마지막 confirm이 오지 않을 수 있음
- 그렇다고 좌석을 계속 묶어두면 실제 판매 가능 좌석이 점점 줄어듬

- 이 개념이 없으면 생기는 문제
1. 버려진 HELD 좌석이 계속 남음
2. 다른 사용자가 예매할 수 없게 됨
3. 재고와 실제 판매 가능 상태가 어긋남

③ 내부 동작 원리

1. 자원에 만료 시각을 기록
2. 스케줄러가 일정 주기로 실행
3. 현재 시각보다 만료 시각이 지난 자원을 찾음
4. HELD 상태를 해제
5. 예약 연결이나 임시 연관 관계를 정리

→ 이 구조는 "실시간 이벤트"가 아니라 "주기적 폴링" 방식
→ 따라서 정확히 만료 시각 그 순간에 해제되는 것이 아니라, 다음 스케줄 실행 시점에 해제

④ 오해하기 쉬운 포인트
! 스케줄러는 사용자의 요청과 무관하게 돌아감
! 만료 처리와 사용자의 명시적 취소는 다른 개념
! fixedRate 60000은 "1분 주기 실행 의도"를 뜻하지, 초 단위 정밀 타이머를 의미하는 것은 아님

⑤ 코드에 적용해서 설명

@Scheduled(fixedRate = 60000)
@Transactional
public void cancelExpiredReservations() {
    reservationTimeoutService.processUnifiedExpiredReservations();

    int updated = screeningSeatRepository
            .releaseExpiredSeatsAndClearReservation(LocalDateTime.now());

    if (updated > 0) {
        System.out.println("만료 좌석 해제: " + updated + "개");
    }
}


- 상태 변화
i) 일부 좌석은 HELD 상태인데 만료 시각이 이미 지났을 수 있음
ii) 일부 예약도 임시 상태로 남아 있을 수 있음

- 처리
i) 스케줄러가 1분마다 실행
ii) reservationTimeoutService가 만료 예약을 일괄 처리
iii) repository가 now 기준으로 만료 좌석을 해제하고 예약 연결을 지움

- 결과
i) 사용되지 않은 임시 점유 좌석이 다시 판매 가능 상태로 돌아갈 수 있음
ii) 적어도 좌석-예약 연결 정리 작업이 수행됨

seat.setStatus(SeatStatus.HELD);
seat.setReservation(reservation);
seat.setHoldExpiresAt(LocalDateTime.now().plusMinutes(10));


- 이 코드는 스케줄러가 나중에 정리할 "만료 대상"을 만들어내는 단계
- 즉, 만료 스케줄러는 갑자기 생긴 기능이 아니라 prepare 단계에서 심어둔 holdExpiresAt를 기준으로 작동

[3] 이 프로젝트의 구현 선택

1. 결제 흐름을 준비 단계와 확정 단계로 분리했다
- preparePayment에서 READY/HELD 상태를 만들고
- confirmPayment에서 PAID/RESERVED로 확정

2. 좌석을 곧바로 RESERVED로 만들지 않고 HELD로 둠
- holdExpiresAt을 now + 10분으로 부여

3. 할인 계산 기준을 프론트가 아니라 서버에 둠
- 쿠폰/포인트가 바뀔 때마다 PaymentPage가 preparePayment를 다시 호출

4. 회원만 쿠폰/포인트를 사용할 수 있게 함
- 비회원이면 usedPoints를 강제로 0 처리

5. 쿠폰도 좌석처럼 임시 점유 상태를 갖음
- AVAILABLE → HELD → USED 흐름

6. 결제 승인 시 PG사는 토스를 사용
- confirm URL, cancel URL, secret key 기반 인증

7. 결제 성공 뒤 포인트/쿠폰 후처리와 메일 발송을 같은 서비스 안에서 이어서 처리
- 다만 메일 실패는 로그만 남기고 결제 성공을 유지

8. 만료 정리는 실시간 이벤트가 아니라 1분 주기 스케줄링으로 처리
- polling 방식

[4] 최종 핵심 정리

→ 좌석은 바로 예약되는 것이 아니라 HELD를 거쳐 RESERVED로 확정되고, 결제는 READY를 거쳐 PAID로 확정
→ 메일 발송은 결제 성공 이후의 비핵심 후처리이며, 만료 스케줄러는 버려진 HELD 자원을 자동으로 청소하는 백그라운드 안전장치

'팀프로젝트 > KINO' 카테고리의 다른 글

12. WebSocket과 실시간 좌석 처리  (0) 2026.03.29
11. OAuth 로그인  (0) 2026.03.29
10. Spring Security + JWT  (0) 2026.03.29
9. JPA와 DB  (0) 2026.03.26
8. Spring Boot 구조  (0) 2026.03.23


1. HTTP, polling, WebSocket

① 개념 정의
→ HTTP는 기본적으로 클라이언트가 요청을 보내고 서버가 응답하는 요청-응답 통신
→ polling은 클라이언트가 일정 주기로 같은 HTTP 요청을 반복해서 "바뀐 게 있는지" 확인하는 방식이
→ WebSocket은 처음 연결할 때는 HTTP를 사용하지만, 이후에는 프로토콜을 전환해 하나의 연결을 계속 유지하면서 양방향 메시지를 주고받는 방식

② 왜 필요한가
- 좌석처럼 상태가 자주 바뀌는 화면에서는 "지금 내가 보는 정보가 아직도 유효한가"가 중요
- HTTP만 쓰면 서버는 사용자가 다시 요청할 때까지 기다려야 함
- polling은 구현이 단순하지만, 다음 요청 전까지는 화면이 오래된 상태일 수 있음
- 예를 들어 3초마다 polling한다면, A가 0초에 좌석을 잡아도 B의 화면은 최대 3초 동안 그 좌석을 "아직 가능"하다고 볼 수 있음
- WebSocket은 서버가 상태 변화를 감지한 즉시 밀어 넣을 수 있으므로 이런 stale 구간을 줄이기 좋음

③ 내부 동작 원리
1. HTTP
   ① 클라이언트가 요청을 보냄
   ② 서버가 응답을 줌
   ③ 그 요청-응답 한 번이 끝냄

2. polling
   ① 클라이언트가 타이머를 돎
   ② 일정 주기마다 HTTP 요청을 다시 보냄
   ③ 응답을 비교해 바뀐 것이 있으면 화면을 갱신
   ④ 이 과정을 계속 반복

3. WebSocket
   ① 처음에는 HTTP 요청으로 연결을 시작
   ② 서버가 이를 WebSocket으로 업그레이드하면 연결이 열린 상태로 유지
   ③ 이후에는 클라이언트도 보내고 서버도 보낼 수 있음
   ④ 변화가 생기면 서버가 즉시 push할 수 있음

④ 오해하기 쉬운 포인트
! HTTP는 실시간이 "불가능"한 것이 아님
- 다만 서버가 먼저 밀어 넣는 구조가 기본이 아니기 때문에, polling 같은 우회 전략이 필요

! WebSocket을 쓰면 자동으로 좌석 중복 문제가 해결되는 것은 아님
- WebSocket은 "빨리 알리는 통로"
- 실제로 한 좌석을 누가 먼저 차지했는지는 서버의 검증과 저장이 결정

! polling이 항상 나쁜 것은 아님
- 변경 빈도가 낮고, 몇 초 지연이 허용되면 polling이 더 단순하고 충분할 수 있음
- 문제는 좌석처럼 짧은 시간 안에 경쟁이 발생하는 경우

⑤ 코드에 적용해서 설명

const socket = new SockJS('http://localhost:8080/ws-kino');
const stompClient = Stomp.over(socket);

stompClient.connect({}, () => {
  stompClient.subscribe(`/topic/screening/${screeningId}`, (message) => {
    const updatedData = JSON.parse(message.body);
    setCurrentSeats((prevSeats) => {
      return prevSeats.map((s) =>
        Number(s.id) === Number(updatedData.seatId)
          ? { ...s, status: updatedData.status } : s
      );
    });
  });
});


- 상태 변화
  ① 모달이 열리고 screeningId가 있으면, 클라이언트는 실시간 연결을 만들 준비
  ② currentSeats는 우선 initialSeats를 기준으로 시작

- 처리
  ① SockJS 연결을 만들고,
  ② 그 위에 STOMP 클라이언트를 올리고,
  ③ 특정 topic을 구독
  ④ 서버가 메시지를 밀어 보내면 그때만 상태를 갱신

- 결과
  ① 클라이언트가 주기적으로 좌석 API를 다시 호출하지 않아도 됨
  ② 서버가 보내는 이벤트를 받는 순간 currentSeats를 갱신할 수 있음

2. STOMP

① 개념 정의
→ STOMP는 WebSocket 위에서 사용할 수 있는 상위 메시징 프로토콜
→ WebSocket은 텍스트/바이너리를 보낼 수는 있지만, "이 메시지가 어디로 가야 하는가", "구독은 어떻게 하는가", "이것이 요청인지 응답인지" 같은 의미를 스스로 정의하지 않음
→ STOMP는 이런 의미를 destination, command, header, body 같은 규칙으로 정해 줌

② 왜 필요한가
- 순수 WebSocket만 쓰면 "문자열 하나"를 보내는 것은 가능하지만, 메시지를 체계적으로 라우팅하기 어려움
- 좌석 선점처럼
  ① 어떤 사용자는 보냄
  ② 어떤 사용자는 특정 상영에 대해서만 듣고 싶음
  ③ 서버는 같은 상영을 보는 사람들에게만 뿌려야 함

같은 요구가 생기면 메시지의 "목적지" 개념이 필요
- STOMP는 이런 목적지를 기준으로 SEND, SUBSCRIBE, MESSAGE 같은 흐름을 만들 수 있게 함

③ 내부 동작 원리
1. 클라이언트는 WebSocket 연결 위에서 STOMP 세션을 엶
2. 클라이언트가 어떤 destination으로 SEND를 보냄
3. 서버는 그 destination이 애플리케이션 처리 대상인지, broker 전달 대상인지 구분
4. 애플리케이션 처리 대상이면 `@MessageMapping` 메서드가 처리
5. broker 전달 대상이면 해당 destination을 구독한 클라이언트들에게 MESSAGE가 전달

④ 오해하기 쉬운 포인트
! STOMP와 WebSocket은 같은 것이 아님
- WebSocket은 통신 채널
- STOMP는 그 채널 위의 메시지 규칙
! `@MessageMapping`은 HTTP의 `@PostMapping`과 동일한 개념이 아님
- 둘 다 "어디로 들어온 것을 어떤 메서드가 처리하느냐"라는 점은 비슷하지만,
- 하나는 HTTP 요청 라우팅이고,
- 다른 하나는 STOMP destination 라우팅
! destination은 REST URL과 같은 의미가 아님
- `/app/seat/hold`는 "리소스 식별 URL"이라기보다 "이 메시지를 어느 처리 경로로 보낼 것인가"에 가까움

⑤ 코드에 적용해서 설명

@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
    registry.enableSimpleBroker("/topic");
    registry.setApplicationDestinationPrefixes("/app");
}
@MessageMapping("/seat/hold")
public void holdSeat(@Payload SeatSelectRequestDTO request) { ... }
stompClient.subscribe(`/topic/screening/${screeningId}`, ...);


- 상태 변화
  ① 클라이언트가 STOMP 세션을 엶
  ② 이제 클라이언트는 "보내는 목적지"와 "구독하는 목적지"를 구분해서 사용할 수 있음

- 처리
  ① `/app`으로 시작하는 destination은 애플리케이션 메서드로 라우팅
  ② 그래서 클라이언트가 `/app/seat/hold`로 보내면 `@MessageMapping("/seat/hold")`가 받는 구조가 됨
  ③ 반면 `/topic`은 broker가 관리하는 구독용 destination
  ④ 그래서 `/topic/screening/{id}`를 구독하면, 그 topic으로 발행된 메시지를 받게 됨

- 결과
  ① 좌석 선점 요청과 좌석 상태 브로드캐스트가 같은 WebSocket 연결 위에서, 서로 다른 역할의 destination으로 분리

3. SockJS

① 개념 정의
→ SockJS는 브라우저와 서버가 가능한 경우에는 WebSocket을 사용하고, 그렇지 못할 경우에는 HTTP 기반 대체 전송 방식으로 비슷한 효과를 내도록 해 주는 라이브러리/프로토콜 계층

② 왜 필요한가
- 실제 서비스 환경에서는 모든 네트워크 경로가 WebSocket 업그레이드를 깔끔하게 통과시키는 것이 아님
- 어떤 프록시나 네트워크 장비는 `Upgrade` 헤더를 막거나, 오래 열린 연결을 끊어 버릴 수 있음
- 이런 경우 애플리케이션 코드가 "WebSocket이 안 되면 전부 다시 구현"하는 식이 되면 복잡해짐
- SockJS는 이 복잡함을 줄이고, 앱 코드에서는 거의 같은 방식으로 연결을 다루게 해 줌

③ 내부 동작 원리
1. 클라이언트는 우선 WebSocket 사용을 시도
2. 가능하면 그대로 WebSocket을 사용
3. 불가능하면 HTTP streaming 같은 대체 방식으로 내려감
4. 그것도 안 되면 long polling 같은 방식으로 내려감
5. 하지만 애플리케이션 입장에서는 "비슷한 연결 객체"처럼 보이게 만듦

④ 오해하기 쉬운 포인트
! SockJS는 STOMP가 아님
- SockJS는 전송 경로의 호환성 문제를 다룸
- STOMP는 메시지 형식과 목적지 규칙을 다룸


! SockJS가 있으면 항상 WebSocket이 사용되는 것은 아님
- WebSocket이 되면 그걸 쓰고,
- 안 되면 대체 전송을 쓸 수 있다는 뜻


! 모든 프로젝트에서 SockJS가 필수인 것은 아님
- 환경이 단순하고 네이티브 WebSocket만으로 충분하면 생략할 수도 있음
- 다만 Spring 예제나 레거시 호환성 관점에서는 자주 같이 등장

⑤ 코드에 적용해서 설명

registry.addEndpoint("/ws-kino")
        .setAllowedOriginPatterns("*")
        .withSockJS();
const socket = new SockJS('http://localhost:8080/ws-kino');


- 상태 변화
  ① 클라이언트는 `/ws-kino` 엔드포인트로 연결을 시도

- 처리
  ① 서버는 해당 엔드포인트에 SockJS 지원을 열어 둠
  ② 클라이언트는 `new SockJS(...)`로 접속
  ③ 실제 내부 전송은 환경에 따라 WebSocket일 수도 있고, 대체 transport일 수도 있음

- 결과
  ① 프론트는 "실시간 연결 객체"를 얻고,
  ② 그 위에 STOMP를 얹어서 동일한 메시징 구조를 사용할 수 있음

4. pub/sub 구조

① 개념 정의
→ pub/sub은 publish/subscribe의 약자
→ 발행자(publisher)는 특정 주제(destination, topic)에 메시지를 보냄
→ 구독자(subscriber)는 자신이 관심 있는 주제를 미리 구독
→ broker는 "어떤 메시지를 누구에게 전달할지"를 연결해 줌

② 왜 필요한가
- 좌석 변경은 1명에게만 보내는 정보가 아님
- 같은 상영(screening)을 보고 있는 여러 사용자 화면에 동시에 반영되어야 함
- 이때 발행자가 각 사용자 연결을 직접 추적하면 결합도가 너무 높아짐
- pub/sub을 쓰면 "상영 123번 topic에 발행"만 하면 되고, 누가 보고 있는지는 broker가 처리

③ 내부 동작 원리
1. 사용자 A, B, C가 `/topic/screening/10`을 구독
2. 누군가 좌석 변경 이벤트를 발생시킴
3. 서버는 `/topic/screening/10`으로 메시지를 발행
4. broker는 그 topic을 구독 중인 연결들에게 같은 메시지를 fan-out
5. 각 클라이언트는 자기 화면 상태를 갱신

④ 오해하기 쉬운 포인트
! pub/sub은 transport가 아님
- HTTP냐 WebSocket이냐의 문제와 별개로, 메시지를 조직하는 패턴

! topic 설계가 중요
- 너무 넓게 잡으면 불필요한 사용자까지 메시지를 받음
- 너무 잘게 쪼개면 관리가 복잡해짐
- 좌석 예제에서는 screeningId 단위가 자연스러움

! pub/sub은 "누구나 다 받는다"가 아님
- "같은 topic을 구독한 사람들"만 받음

⑤ 코드에 적용해서 설명

registry.enableSimpleBroker("/topic");
messagingTemplate.convertAndSend(
    "/topic/screening/" + request.getScreeningId(), seatIds
);
stompClient.subscribe(`/topic/screening/${screeningId}`, (message) => { ... });


- 상태 변화
  ① 어떤 클라이언트가 특정 상영의 topic을 구독하고 있음

- 처리
  ① 서버가 그 상영 ID에 해당하는 topic으로 메시지를 발행
  ② broker는 그 topic 구독자들에게 메시지를 분배

- 결과
  ① 같은 상영을 보는 사용자만 좌석 변경 이벤트를 받음
  ② 다른 상영 사용자는 불필요한 메시지를 받지 않음

5. WebSocket과 실시간 좌석 처리

① 개념 정의
→ "실시간 좌석 처리"의 핵심은 좌석이라는 공유 자원의 상태 변화를 가능한 빨리 모든 관련 사용자에게 반영하는 것
→ 여기서 좌석 선점(hold)은 보통 "최종 결제 전, 잠시 다른 사람이 못 잡게 막는 임시 잠금"에 가까움
→ 즉, 좌석 처리에는 두 층이 있음
1. 화면 동기화
2. 실제 선점 보장

② 왜 필요한가
- 좌석은 여러 사용자가 동시에 경쟁하는 자원
- 영화 예매에서는 몇 초 차이로도 충돌이 발생
- 실시간 전파가 없으면 같은 좌석을 여러 사용자가 동시에 "가능"하다고 착각할 수 있음
- 그 결과는 다음과 같음
  ① 클릭은 성공한 것처럼 보임
  ② 결제 직전 또는 서버 저장 시점에 실패
  ③ 사용자 경험이 나빠지고, 충돌 처리 비용이 커짐
- 반대로 상태 변화가 즉시 퍼지면, 다른 사용자 화면에서 그 좌석을 곧바로 비활성화할 수 있음
- 즉, 실시간이 필요한 이유는 "화면의 오래된 상태를 줄이기 위해서"

③ 내부 동작 원리
1. 사용자가 현재 좌석 상태를 봄
2. 어떤 사용자가 좌석을 선택
3. 서버는 그 좌석이 아직 선점 가능한지 현재 기준으로 검증
4. 가능하면 선점 상태를 저장
5. 그 결과를 같은 상영을 보고 있는 다른 사용자에게 즉시 브로드캐스트
6. 다른 사용자의 화면은 그 좌석을 더 이상 선택 불가 상태로 바꿈
7. 이미 늦은 사용자의 요청은 서버가 거절


→ 이 흐름에서 핵심은
- 실시간 브로드캐스트는 "화면 동기화"
- 서버 검증/저장은 "정합성 보장"

④ 오해하기 쉬운 포인트
! WebSocket이 있으면 DB 락이 필요 없다는 뜻이 아님
- 통신이 빨라져도, 동시에 같은 좌석 요청이 들어오는 일은 여전히 가능
- 최종 권위(authority)는 서버 저장 로직

! 화면에서 좌석을 회색 처리했다고 선점이 완료된 것은 아님
- 그것은 UI 반영일 뿐
- 서버가 실제로 hold를 저장했는지가 중요

! "실시간"은 0충돌을 의미하지 않는다.
- 충돌 가능성을 줄이고, 늦게 온 사용자가 빨리 상황을 인지하게 만드는 것

⑤ 코드에 적용해서 설명

@MessageMapping("/seat/hold")
public void holdSeat(@Payload SeatSelectRequestDTO request) {

    try {
        List<Long> seatIds = request.getTickets().stream()
                .map(SeatSelectRequestDTO.TicketRequest::getSeatId)
                .collect(Collectors.toList());

        messagingTemplate.convertAndSend(
                "/topic/screening/" + request.getScreeningId(), seatIds
        );

    } catch (IllegalStateException e) {
        messagingTemplate.convertAndSend(
                "/topic/screening/" + request.getScreeningId() + "/error",
                e.getMessage()
        );
    }
}
stompClient.subscribe(`/topic/screening/${screeningId}`, (message) => {
  const updatedData = JSON.parse(message.body);

  setCurrentSeats((prevSeats) => {
    return prevSeats.map((s) =>
      Number(s.id) === Number(updatedData.seatId)
        ? { ...s, status: updatedData.status } : s
    );
  });
});


- 상태 변화
  ① 좌석 선점 요청이 서버로 들어옴
  ② 같은 상영을 구독 중인 사용자들이 존재

- 처리
  ① 서버는 요청에서 seatIds를 추출
  ② 그리고 그 정보를 `/topic/screening/{screeningId}`로 브로드캐스트
  ③ 클라이언트는 그 메시지를 받아 `currentSeats`를 갱신하려고 함

- 결과
  ① 의도상으로는, 한 사용자의 좌석 선택이 다른 사용자 화면에 즉시 반영되는 구조
  ② 즉, 좌석 선점의 "실시간 전파" 자체는 WebSocket + pub/sub으로 해결하려는 설계

[2] 최종 핵심 정리

→ WebSocket은 "실시간 연결 방식"이고, STOMP는 "그 연결 위의 메시지 규칙"이며, pub/sub은 "그 메시지를 누구에게 퍼뜨릴지"를 정하는 구조
→ SockJS는 이 구조를 더 넓은 환경에서 동작하게 만드는 호환 계층
→ 좌석 선점이 실시간이어야 하는 이유는, 좌석이 여러 사용자가 동시에 경쟁하는 공유 자원이기 때문에 stale 화면을 최소화해야 하기 때문
→ 다만 실시간 전파와 실제 선점 보장은 같은 문제가 아님
→ 실시간 전파는 WebSocket이 담당하고, 최종 정합성은 서버의 검증과 저장 로직이 담당

'팀프로젝트 > KINO' 카테고리의 다른 글

13. 외부서비스  (0) 2026.03.29
11. OAuth 로그인  (0) 2026.03.29
10. Spring Security + JWT  (0) 2026.03.29
9. JPA와 DB  (0) 2026.03.26
8. Spring Boot 구조  (0) 2026.03.23

[1] 전체 구조
→ OAuth 소셜 로그인은 "외부 제공자(카카오/네이버/구글)가 사용자를 먼저 인증하고, 우리 서비스는 그 결과를 받아 자기 회원 체계에 연결해 최종 로그인 상태를 만든다"는 흐름

- 이 주제를 이해할 때 가장 먼저 구분해야 할 것
1. 일반 로그인
   ① 사용자가 우리 서비스에 아이디/비밀번호를 직접 보냄
   ② 우리 서버가 그 비밀번호를 직접 검증
   ③ 맞으면 우리 서비스 토큰 또는 세션을 발급

2. 소셜 로그인
   ① 사용자는 카카오/네이버/구글에서 먼저 로그인
   ② 제공자는 우리 서비스에 "인가 코드(code)"를 돌려줌
   ③ 우리 서버가 그 code를 이용해 제공자와 통신
   ④ 제공자 사용자 정보를 확인한 뒤, 우리 회원 DB와 연결
   ⑤ 최종적으로는 역시 "우리 서비스의 토큰" 또는 "우리 서비스의 로그인 상태"가 만들어져야 끝나

[2] 개념별 설명

1. OAuth와 소셜 로그인의 본질

① 개념 정의
→ OAuth는 본래 "권한 위임"을 위한 프로토콜
- 즉, 사용자가 자신의 비밀번호를 우리 서비스에 직접 주지 않고도, 외부 서비스가 제한된 범위의 권한이나 사용자 정보를 우리 서비스에 넘길 수 있게 하는 방식
- 소셜 로그인은 이 구조를 이용해 "외부 제공자에서 이미 확인된 사용자"를 우리 서비스 로그인에 활용하는 형태

② 왜 필요한가
- 우리 서비스가 카카오/네이버/구글 비밀번호를 직접 다루지 않아도 됨
- 사용자는 가입/로그인을 더 빠르게 할 수 있음
- 우리 서비스는 외부 제공자의 인증 결과를 활용할 수 있음


- 이 개념이 없으면 생기는 문제
1. 우리 서비스가 모든 로그인 자격증명을 직접 관리해야 함
2. 사용자 입장에서는 가입 장벽이 높아짐
3. 소셜 계정 기반의 간편 로그인 흐름을 구현하기 어려움

③ 내부 동작 원리
- OAuth 기반 소셜 로그인은 보통 아래처럼 동작
1. 사용자가 제공자에서 인증
2. 제공자가 우리 서비스에 code를 줌
3. 우리 서버가 code를 제공자 쪽 토큰/사용자 정보로 교환
4. 우리 서버가 그 외부 사용자를 우리 회원과 매핑
5. 최종적으로 "우리 서비스 기준" 로그인 상태를 만듦

→ 핵심은 "제공자 인증"과 "우리 서비스 로그인 완료"는 같은 사건이 아니라는 점

④ 오해하기 쉬운 포인트
! OAuth 자체는 원래 "인증 프로토콜"이 아니라 "권한 위임 프로토콜"
! 그래서 소셜 로그인은 엄밀히 말하면 OAuth만으로 끝나는 개념이 아니라, "외부 인증 결과를 우리 서비스 로그인으로 연결하는 설계"까지 포함해야 함
! 제공자에서 로그인에 성공했다고 해서 곧바로 우리 서비스 회원이 되는 것은 아님
! 제공자 토큰과 우리 서비스 JWT는 다른 것
! 특히 구글 계열은 OpenID Connect 개념이 함께 나오는 경우가 많다. 하지만 현재 첨부 코드만으로는 구글 쪽에서 id_token을 쓰는지, access token으로 프로필 API를 조회하는지는 확인할 수 없음

⑤ 코드에 적용해서 설명

  axios.post('/api/auth/kakao', { code })
  axios.post('/api/auth/google', { code })
  axios.post('/api/auth/naver', { code, state })
  @PostMapping("/login")
  public ResponseEntity<?> login(@RequestBody MemberSignupRequestDTO request) {
      MemberLoginResponseDTO response =
          authService.authenticate(request.getUsername(), request.getPassword());
      return ResponseEntity.ok(response);
  }

  @PostMapping("/kakao")
  public ResponseEntity<?> kakaoLogin(@RequestBody KakaoDTO.LoginRequest request) {
      KakaoLoginResponseDTO response = kakaoAuthService.login(request.getCode());
      return ResponseEntity.ok(response);
  }

  @PostMapping("/naver")
  public ResponseEntity<?> naverLogin(@RequestBody NaverDTO.LoginRequest request) {
      NaverLoginResponseDTO response =
          naverAuthService.login(request.getCode(), request.getState());
      return ResponseEntity.ok(response);
  }

  @PostMapping("/google")
  public ResponseEntity<?> googleLogin(@RequestBody GoogleDTO.LoginRequest request) {
      GoogleLoginResponseDTO response = googleAuthService.login(request.getCode());
      return ResponseEntity.ok(response);
  }


- 개념 매핑
1. 일반 로그인 상태
   ① 사용자가 username/password를 직접 보냄
   ② 백엔드가 authService.authenticate(...)로 직접 검증
   ③ 결과로 우리 서비스 로그인 응답이 만들어짐

2. 소셜 로그인 상태
   ① 프론트는 비밀번호를 보내지 않음
   ② 대신 provider가 준 code만 백엔드로 보냄
   ③ 백엔드는 provider별 서비스로 처리를 위임
   ④ 즉, "우리 서비스가 직접 비밀번호를 검증하는 방식"과 "외부 제공자 인증 결과를 받아 처리하는 방식"이 분리되어 있음

→ 이 코드에서 이미 "일반 로그인"과 "소셜 로그인"은 입력값부터 다름
- 일반 로그인 입력값: username, password
- 소셜 로그인 입력값: code, 그리고 네이버는 state

2. 인가 코드(Authorization Code)

① 개념 정의
→ 인가 코드는 제공자가 로그인/동의를 마친 뒤 우리 서비스에 잠깐 전달하는 "임시 교환권"
- 이것은 최종 로그인 토큰이 아님
- 이것은 보통 짧은 수명을 가지며, 한 번만 쓰는 값
- 백엔드가 이 code를 받아 제공자와 다시 통신할 때 사용

② 왜 필요한가
- 브라우저 리다이렉트 과정에서 access token을 직접 노출하지 않기 위해서
- 백엔드가 안전하게 provider와 서버-서버 통신을 하도록 만들기 위해서
- 특히 client secret이 필요한 경우, 그 secret은 브라우저가 아니라 서버에 있어야 함

- 이 개념이 없으면 생기는 문제
1. 민감한 토큰이 브라우저에 직접 노출될 수 있음
2. 프론트가 provider 비밀정보를 다뤄야 할 수 있음
3. 보안 경계가 흐려짐

③ 내부 동작 원리
1. 우리 서비스가 provider에 인증 요청을 보냄
2. 사용자가 provider에서 로그인/동의를 마침
3. provider가 redirect URI로 code를 붙여 돌려보냄
4. 프론트 또는 백엔드가 그 code를 회수
5. 백엔드가 provider에 code를 보내 access token 또는 id token을 받음
6. code는 보통 재사용할 수 없게 됨

④ 오해하기 쉬운 포인트
! code는 로그인 완료 토큰이 아님
! code를 갖고 있다고 해서 우리 서비스 로그인 상태가 바로 생기는 것은 아님
! "인가 코드의 인가"와 "우리 서비스에서 권한(role)을 검사하는 인가"는 다른 맥락
! code는 보통 일회성이므로 중복 요청이 나가면 실패할 수 있음

⑤ 코드에 적용해서 설명

const code = new URLSearchParams(window.location.search).get("code");

  if (code) {
    isProcessed.current = true;
    axios.post('/api/auth/kakao', { code })
  }
  const urlParams = new URLSearchParams(window.location.search);
  const code = urlParams.get("code");
  KakaoLoginResponseDTO response = kakaoAuthService.login(request.getCode());
  GoogleLoginResponseDTO response = googleAuthService.login(request.getCode());


- 개념 매핑
1. 요청이 어떤 상태로 들어오는가
   ① 브라우저가 provider에서 돌아온 직후
   ② 아직 우리 서비스 로그인 상태는 아님
   ③ URL에 code만 붙어 있는 상태

2. 코드가 무엇을 처리하는가
   ① CallbackPage가 URL에서 code를 꺼냄
   ② 그 code를 백엔드 /api/auth/{provider}로 전달
   ③ 백엔드 컨트롤러는 provider별 서비스로 넘김

3. 결과 보안 상태가 어떻게 바뀌는가
   ① 단순 URL 파라미터였던 code가 서버 검증 대상으로 넘어감
   ② 이후 서버가 provider와 실제 교환을 수행할 준비 상태가 된
   ③ 아직 이 시점 자체가 로그인 완료는 아님

- 여기서 중요한 코드 연결

  const isProcessed = useRef(false);


- 이 코드가 왜 의미가 있는가
→ React 18 StrictMode 환경에서는 useEffect가 개발 중 중복 실행될 수 있음
- 인가 코드는 일회성인 경우가 많기 때문에 같은 code를 두 번 보내면 문제가 생길 수 있음
- 그래서 isProcessed.current로 "한 번만 처리"하도록 막는 것은 OAuth 개념과도 맞는 방어

3. Redirect URI와 Callback

① 개념 정의
→ redirect URI는 provider가 인증이 끝난 뒤 사용자의 브라우저를 되돌려 보내는 "사전에 등록된 주소"
- CallbackPage는 그 주소로 돌아왔을 때 실행되는 후속 처리 지점

② 왜 필요한가
- provider는 아무 주소로나 code를 보내면 안 됨
- 반드시 미리 등록된 안전한 주소로만 돌려보내야 함
- 우리 서비스도 "로그인 이후 어디서 흐름을 이어받을지"를 정해 두어야 함

- 이 개념이 없으면 생기는 문제
1. 공격자가 code를 다른 주소로 탈취하게 만들 수 있음
2. 로그인 후 흐름이 어디서 이어지는지 일관성이 깨짐
3. 브라우저 복귀 지점이 모호해짐

③ 내부 동작 원리
1. 로그인 시작 요청에 redirect_uri를 포함
2. provider는 이 값이 사전 등록된 값과 맞는지 확인
3. 인증이 성공하면 브라우저를 그 URI로 리다이렉트 함
4. CallbackPage가 query string에서 code/state를 읽음
5. 이후 백엔드 호출 또는 후속 UI 처리로 넘어감

④ 오해하기 쉬운 포인트
! redirect URI는 "로그인 끝나면 아무 데나 보내는 next page"가 아님
! 이것은 보안 설정의 일부
! 리다이렉트는 서버 내부 호출이 아니라 사용자의 브라우저 이동
! CallbackPage는 로그인 그 자체가 아니라, 로그인 결과를 이어받는 "중계 지점"

⑤ 코드에 적용해서 설명

const code = new URLSearchParams(window.location.search).get("code");

  const urlParams = new URLSearchParams(window.location.search);
  const code = urlParams.get("code");
  const state = urlParams.get("state");
  if (window.opener) {
    window.opener.postMessage({
      type: 'SOCIAL_LINK',
      provider: 'KAKAO',
      code: code
    }, window.location.origin);
    window.close();
    return;
  }


- 개념 매핑
1. 요청이 어떤 상태로 들어오는가
   ① 사용자의 브라우저가 provider에서 돌아온 상태
   ② URL 안에 code/state가 붙어 있음
   ③ 아직 앱 내부 인증 완료 전

2. 코드가 무엇을 처리하는가
   ① CallbackPage가 query string을 읽음
   ② 일반 흐름이면 백엔드에 전달
   ③ 팝업 흐름이면 opener 창에 전달한 뒤 스스로 닫음

3. 결과 보안 상태가 어떻게 바뀌는가
   ① "브라우저에 붙어 있던 provider 반환값"이
   ② "앱 내부에서 후속 인증 처리를 시작하는 입력값"으로 바뀜

- 현재 코드에서 알 수 있는 점
→ provider가 결국 프론트엔드 콜백 페이지로 브라우저를 돌려보내는 구조라는 점은 확인
- 왜냐하면 code/state를 window.location.search에서 읽고 있기 때문

- 현재 코드에서 알 수 없는 점
→ 실제 등록된 redirect URI 문자열 자체는 첨부 코드에 직접 보이지 않음
- 예: 정확히 어느 도메인/경로가 provider 콘솔에 등록되어 있는지는 현재 자료만으로 확정할 수 없음

4. state 값

① 개념 정의
→ state는 로그인 요청을 보낸 쪽이 만든 "요청-응답 연결용 검증 값"
- 주 목적은 CSRF 방어와 요청 상관관계 확인
- provider는 이 값을 그대로 다시 돌려줌

② 왜 필요한가
- 공격자가 자기 code를 피해자 브라우저에 주입하는 상황을 줄이기 위해서
- 우리가 보낸 로그인 요청에 대한 응답이 맞는지 확인하기 위해서
- 로그인 전의 문맥(예: 어느 화면에서 시작했는지)을 복구할 때도 쓸 수 있음

- 이 개념이 없으면 생기는 문제
1. 요청과 응답의 짝이 맞는지 검증하기 어려움
2. 로그인 흐름 위조 공격에 취약해질 수 있음
3. 복수 탭/복수 시도 상황에서 흐름 추적이 어려워짐

③ 내부 동작 원리
1. 로그인 시작 시 랜덤한 state를 만듦
2. 이 값을 요청에 실어 provider로 보냄
3. Callback에서 provider가 되돌려준 state를 받음
4. 원래 저장해 둔 값과 비교
5. 일치하면 정상 흐름, 불일치하면 거부

④ 오해하기 쉬운 포인트
! OAuth의 state와 React의 state는 전혀 다름
! OAuth의 state와 React Router의 navigate(..., { state: ... })도 전혀 다름
! "네이버만 state가 보이니까 state는 네이버 전용이다"라고 이해하면 안 됨
- 보편 개념으로는 여러 provider에서 state 사용이 권장
- 다만 현재 첨부 코드에서 명시적으로 확인되는 것은 네이버 state 처리뿐

⑤ 코드에 적용해서 설명

 const urlParams = new URLSearchParams(window.location.search);
  const code = urlParams.get("code");
  const state = urlParams.get("state");

  if (code && state) {
    axios.post('/api/auth/naver', { code, state })
  } else {
    alert("비정상적인 로그인 접근입니다.");
    navigate('/login', { replace: true });
  }
  @PostMapping("/naver")
  public ResponseEntity<?> naverLogin(@RequestBody NaverDTO.LoginRequest request) {
      NaverLoginResponseDTO response =
          naverAuthService.login(request.getCode(), request.getState());
      return ResponseEntity.ok(response);
  }


- 개념 매핑
1. 요청이 어떤 상태로 들어오는가
   ① 네이버에서 돌아온 URL에 code와 state가 함께 있음

2. 코드가 무엇을 처리하는가
   ① 프론트가 code와 state를 함께 회수
   ② 둘 다 존재할 때만 정상 흐름으로 판단
   ③ 백엔드에 code와 state를 함께 보냄

3. 결과 보안 상태가 어떻게 바뀌는가
   ① 단순 로그인 결과가 아니라
   ② "이 요청이 원래 내가 시작한 요청과 연결되는지" 검증 가능한 상태가 됨

- 추가로 반드시 구분할 것

  navigate('/signup', { state: { ... } })


→ 여기의 state는 OAuth state가 아님
- 이것은 React Router가 페이지 이동하면서 데이터를 전달하는 state
- 초보자가 가장 많이 헷갈리는 지점

5. 소셜 로그인 완료와 회원가입 분기

① 개념 정의
→ 소셜 로그인에서 진짜 마지막 단계는 "외부 사용자 식별자"를 "우리 서비스 사용자"로 연결하는 것
- 기존 회원이면 바로 우리 서비스 로그인 완료
- 신규 사용자면 우리 서비스 회원가입이 필요

② 왜 필요한가
- 우리 서비스는 자체 회원 ID, 권한, 예매 정보, 프로필 등 자기 도메인 모델이 있음
- 제공자 사용자를 알아냈다고 해도, 그 사용자가 우리 DB에서 누구인지 결정해야 함
- 따라서 소셜 로그인은 "외부 인증" + "내부 회원 연결"의 조합

- 이 개념이 없으면 생기는 문제
1. provider 사용자와 우리 회원을 연결할 수 없음
2. 기존 회원인지 신규 회원인지 판단할 수 없음
3. provider 로그인 성공 후에도 앱 내부 기능을 누구에게 부여할지 정할 수 없음

③ 내부 동작 원리
1. 백엔드가 provider 사용자 정보를 얻음
2. providerId를 기준으로 기존 SocialAccount 또는 회원 연동을 찾음
3. 있으면 우리 서비스 토큰 발급
4. 없으면 회원가입에 필요한 정보를 만들어 프론트로 보냄
5. 회원가입 완료 시 로컬 Member와 SocialAccount를 연결함
6. 다음 로그인부터는 곧바로 기존 회원 흐름으로 감

④ 오해하기 쉬운 포인트
! 소셜 로그인 성공과 우리 서비스 회원가입 완료는 같은 일이 아님
! providerId와 memberId는 다름
! provider가 준 name/email/profileImage는 "참고용 사용자 정보"이지, 곧바로 우리 서비스 핵심 식별자가 되는 것은 아님
! 신규 회원 분기에서 넘어가는 데이터는 앱 내부 가입 편의를 위한 값

⑤ 코드에 적용해서 설명

 axios.post('/api/auth/kakao', { code })
    .then(res => {
      const data = res.data;

      if (data.isRegistered) {
        login(data.token, data.username, data.name, data.memberId);
        navigate('/', { replace: true });
      } else {
        navigate('/signup', {
          replace: true,
          state: {
            name: data.name,
            provider: data.provider,
            providerId: data.providerId,
            username: data.username,
            profileImage: data.profileImage
          }
        });
      }
    })
  if (data.isRegistered || data.registered) {
    login(data.token, data.username, data.name, data.memberId);
    navigate('/', { replace: true });
  } else {
    navigate('/signup', {
      replace: true,
      state: {
        provider: data.provider,
        providerId: data.providerId,
        name: data.name,
        username: data.username,
        email: data.email,
        profileImage: data.profileImage,
      }
    });
  }
if (data.isRegistered) {
    login(data.token, data.username, data.name, data.memberId);
    navigate('/', { replace: true });
  } else {
    navigate('/signup', {
      replace: true,
      state: {
        name: data.name,
        provider: data.provider,
        providerId: data.providerId,
        username: data.username,
        profileImage: data.profileImage,
        tel: data.tel,
        birth_date: data.birth_date
      }
    });
  }
  @PostMapping("/signup")
  public ResponseEntity<?> signup(@RequestBody MemberSignupRequestDTO request) {
      authService.signup(request);
      return ResponseEntity.ok(...);
  }


- 개념 매핑
1. 요청이 어떤 상태로 들어오는가
   ① 프론트는 이미 provider callback을 끝냄
   ② 백엔드 응답을 기다리는 상태

2. 코드가 무엇을 처리하는가
   ① data.isRegistered로 기존 회원 여부를 분기
   ② 기존 회원이면 login(...) 호출로 앱 로그인 상태를 만듦
   ③ 신규 회원이면 provider 정보와 함께 /signup으로 보냄

3. 결과 보안 상태가 어떻게 바뀌는가
   ① 기존 회원 분기
      i) 외부 인증 결과가 우리 앱 인증 상태로 변환
      ii) 우리 서비스 토큰을 기반으로 홈 화면으로 이동
   ② 신규 회원 분기
      i) 아직 로그인 완료는 아님
      ii) 소셜 정보만 들고 회원가입 절차로 넘어감

- 여기서 반드시 구분할 것
1. data.token
   → 우리 서비스가 내려준 최종 토큰으로 이해해야 함
   → provider가 URL에 붙여 준 code와는 완전히 다른 값

2. login(...)
   → 프론트 인증 상태를 갱신하는 호출
   → 다만 login 내부가 localStorage인지 context state만 바꾸는지는, 현재 질문에 직접 붙은 콜백 코드만으로는 확정할 수 없음

3. signup으로 넘기는 state
   → OAuth 보안 검사용 state가 아니라, 회원가입 폼을 채우기 위한 화면 이동 데이터

[3] 전체 요청 흐름


1. 사용자가 카카오/네이버/구글 로그인 시작
   ① 우리 서비스가 provider 인증 페이지로 보냄
   ② 이 시작 코드는 첨부되지 않았지만, 개념상 반드시 존재

2. provider가 사용자 인증/동의 처리
   ① 사용자는 provider 쪽에서 로그인
   ② provider는 인증이 끝나면 브라우저를 redirect URI로 돌려보냄

3. 브라우저가 프론트 콜백 페이지에 도착
   ① KakaoCallbackPage / GoogleCallbackPage / NaverCallbackPage가 실행
   ② URL에서 code를 읽음
   ③ 네이버는 state도 함께 읽음

4. 콜백 페이지가 중복 처리를 막음
   ① isProcessed.current로 한 번만 처리하게 함
   ② 같은 code가 중복 전송되지 않도록 방지함

5. 분기 1: 팝업 흐름
   ① window.opener가 있으면 부모 창에 postMessage로 code/state를 넘김
   ② 콜백 창은 닫음
   ③ 이 경우 현재 콜백 페이지에서 직접 axios를 호출하지 않음

6. 분기 2: 일반 전체 페이지 흐름
   ① 콜백 페이지가 axios.post('/api/auth/{provider}', ...) 호출
   ② 카카오는 code
   ③ 구글은 code
   ④ 네이버는 code + state

7. 백엔드 AuthController가 provider별 서비스로 위임
   ① /api/auth/kakao -> kakaoAuthService.login(code)
   ② /api/auth/google -> googleAuthService.login(code)
   ③ /api/auth/naver -> naverAuthService.login(code, state)

8. provider 서비스 내부에서 후속 처리
   ① code를 provider 토큰으로 교환
   ② provider 사용자 정보 조회
   ③ 기존 회원인지 신규 회원인지 확인
   ④ 우리 서비스 응답 DTO 생성

9. 프론트가 백엔드 응답을 받아 최종 분기
   ① 기존 회원이면
      i) login(data.token, data.username, data.name, data.memberId)
      ii) 홈("/")으로 이동
   ② 신규 회원이면
      i) 소셜 정보와 함께 /signup으로 이동
      ii) 가입 폼을 채운 뒤 로컬 회원 + 소셜 계정 연동

10. 이후 다시 같은 소셜 로그인을 하면
   ① providerId로 기존 연동 계정을 찾을 수 있고
   ② 바로 기존 회원 로그인 흐름으로 들어가게 됨

- 일반 로그인과 비교하면
1. 일반 로그인
   ① 사용자가 바로 우리 서비스 /api/auth/login 호출
   ② username/password를 직접 검증
   ③ 곧바로 로그인 응답

2. 소셜 로그인
   ① provider 인증 페이지를 한 번 거침
   ② redirect URI로 code가 돌아옴
   ③ 그 code를 백엔드가 provider와 다시 교환
   ④ 기존 회원 여부를 확인한 후에야 최종 로그인 응답이 나옴

→ 즉, 소셜 로그인은 "외부 인증 왕복 단계"가 하나 더 있는 로그인

[4] 최종 핵심 정리

→ OAuth 소셜 로그인에서 프론트 CallbackPage의 역할은 "로그인을 끝내는 것"이 아니라, provider가 돌려준 code/state를 안전하게 회수해서 백엔드로 넘기는 것

→ 진짜 로그인 완료는 백엔드가 그 code를 provider 사용자 정보로 교환하고, 그 사용자를 우리 서비스 회원과 연결한 뒤, 우리 서비스 토큰을 발급할 때 일어남

→ 프로젝트 정리

- 카카오/네이버/구글 로그인
  → provider 인증
  → redirect URI로 code 복귀
  → CallbackPage가 code/state 추출
  → 백엔드 /api/auth/{provider} 전달
  → provider 사용자 확인
  → 기존 회원이면 우리 서비스 로그인 완료
  → 신규 회원이면 /signup으로 보내 가입 후 연동

일반 로그인과의 본질적 차이

- 일반 로그인
  → 우리 서버가 비밀번호를 직접 검증한다

- 소셜 로그인
  → 외부 제공자가 먼저 사용자를 인증하고,
    우리 서버는 그 결과를 자기 회원 체계에 연결해 최종 로그인 상태를 만든다

'팀프로젝트 > KINO' 카테고리의 다른 글

13. 외부서비스  (0) 2026.03.29
12. WebSocket과 실시간 좌석 처리  (0) 2026.03.29
10. Spring Security + JWT  (0) 2026.03.29
9. JPA와 DB  (0) 2026.03.26
8. Spring Boot 구조  (0) 2026.03.23

[1] Spring Security + JWT를 공부할 때 먼저 잡아야 하는 틀
→ 이 주제는 코드를 먼저 읽는 것이 아니라, "보안 흐름"을 먼저 이해해야 함

→ 로그인은 "처음 신원을 확인하고 토큰을 발급하는 단계"이고,
→ 토큰은 "그 이후 요청에서 신원을 증명하는 자료"이고,
→ 필터는 "요청이 들어올 때 토큰을 검사하는 계층"이고,
→ 인증은 "검사된 토큰으로 현재 요청의 사용자를 복원하는 것"이고,
→ 인가는 "복원된 사용자가 이 API에 접근 가능한지 판단하는 것"

1. 개념 사이의 관계
① 로그인
- 최초 1회 자격 증명 확인
- 성공하면 JWT 발급

② 토큰
- 이후 요청에서 로그인 상태를 대신 증명
- 세션 대신 들고 다니는 인증 자료

③ 필터
- 요청 초입에서 토큰을 검사
- 컨트롤러보다 먼저 실행

④ 인증
- 토큰을 바탕으로 현재 요청의 사용자를 복원
- SecurityContext에 저장

⑤ 인가
- 복원된 사용자가 요청한 API를 사용할 수 있는지 판단

[1] 로그인(Login)
→ 로그인은 "아이디/비밀번호 같은 자격 증명을 서버가 확인하고, 이후 요청에 쓸 인증 수단을 발급하는 과정"

1. 개념 정의
① 로그인은 사용자가 자신의 신원을 처음 증명하는 절차
② JWT 구조에서는 로그인 성공 후 세션을 만드는 대신 토큰을 발급
③ 즉, 로그인은 "매 요청의 인증"이 아니라 "이후 요청에서 사용할 증표를 만드는 시작점"

2. 왜 필요한가
① 서버는 처음 요청한 사용자가 누구인지 모름
② 따라서 최초 1회는 사용자 검증 절차가 필요
③ 이 검증이 끝나야 이후 요청에서 신원을 증명할 수 있는 토큰을 줄 수 있음

3. 내부 동작 원리
① 사용자가 로그인 API에 아이디/비밀번호를 보냄
② 서버가 DB에 저장된 사용자 정보와 비교
③ 비밀번호가 맞으면 로그인 성공
④ 서버는 JWT를 발급
⑤ 클라이언트는 그 JWT를 저장
⑥ 이후부터는 아이디/비밀번호 대신 JWT를 보냄

4. 오해하기 쉬운 포인트
! 로그인과 인증은 같은 말이 아님
! 로그인은 최초 1회 자격 증명 검증 단계
! 인증은 이후 각 요청에서 "현재 사용자가 누구인지" 복원하는 단계까지 포함
! JWT 구조에서는 로그인 이후에도 매 요청마다 인증이 다시 일어남
! 단, 그 재료가 아이디/비밀번호가 아니라 JWT일 뿐

5. 코드에 적용해서 설명

.requestMatchers(
        "/api/reservations/verify/**",
        "/swagger-ui/**",
        "/swagger-ui.html",
        "/v3/api-docs/**",
        "/swagger-resources/**",
        "/webjars/**",
        "/api/main/**",
        "/api/auth/**",
        "/api/movies/**",
        "/api/screenings/**",
        "/api/reviews/**",
        "/api/ticketing/**",
        "/api/timetable/**",
        "/api/theaters/**",
        "/ws-kino/**",
        "/ws-seat/**",
        "/error"
).permitAll()


→ 여기서 "/api/auth/**"가 로그인/회원가입 같은 인증 시작 API에 해당
→ 로그인은 토큰이 없는 상태에서 시작해야 하므로 permitAll()이어야 함
→ 만약 로그인 API까지 authenticated()로 막으면, 로그인하기 위해 먼저 로그인이 필요한 모순이 생김

@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}


→ 이 코드는 로그인 시 비밀번호 검증과 연결되는 기반 설정
→ 현재 비교 로직은 보이지 않지만, 실제 로그인 구현에서는 사용자가 보낸 평문 비밀번호와 DB에 저장된 해시 비밀번호를 비교할 때 이 PasswordEncoder가 사용

[1] 토큰(Token, JWT)

→ 토큰은 "로그인 성공 후 사용자가 이후 요청에서 자신의 신원을 증명하기 위해 들고 다니는 문자열 기반 인증 자료"

1. 개념 정의
① JWT는 사용자 식별 정보와 부가 정보, 만료 정보 등을 담는 토큰 형식
② 서버는 이 토큰을 읽어서 사용자를 다시 식별
③ 세션 기반에서는 서버가 로그인 상태를 저장
④ JWT 기반에서는 클라이언트가 토큰을 들고 다니며 매 요청마다 인증을 증명

2. 왜 필요한가
① 세션을 쓰지 않으면 서버는 로그인 상태를 저장하지 않음
② 그러면 사용자가 매 요청마다 자신의 신원을 직접 증명해야 함
③ 이때 사용하는 것이 토큰
④ 즉, 토큰은 "세션의 대체품"이 아니라 "무상태 인증 구조에서 신원을 증명하는 수단"

3. 내부 동작 원리
① 로그인 성공 시 서버가 JWT를 발급
② 클라이언트는 JWT를 저장
③ 요청 시 Authorization 헤더에 "Bearer 토큰값" 형식으로 보냄
④ 서버는 토큰 서명, 만료 여부, payload를 검사
⑤ 검사에 성공하면 토큰 안 사용자 정보로 인증을 복원

4. 오해하기 쉬운 포인트
! JWT 안에 정보가 들어 있다고 해서 자동으로 신뢰할 수 있는 것은 아님
! 반드시 서버가 서명 검증과 만료 검사를 해야 함
! 토큰이 있다는 사실만으로 접근이 허용되는 것은 아님
! 토큰 검증 후 인증이 복원되어야 하고, 그다음 인가 규칙도 통과해야 함

5. 코드에 적용해서 설명
→ 현재 코드에서 토큰 개념은 JwtFilter의 "토큰 추출 → 검증 → 내용 읽기" 흐름으로 확인할 수 있음

private String resolveToken(HttpServletRequest request) {
    String bearerToken = request.getHeader("Authorization");
    if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
        return bearerToken.substring(7);
    }
    return null;
}


→ 이 코드는 Authorization 헤더에서 실제 JWT를 꺼내는 부분
→ "Bearer "라는 접두사는 전달 형식
→ bearerToken.substring(7)이 실제 토큰 값만 잘라냄

if (StringUtils.hasText(token) && jwtUtil.validateToken(token)) {


→ 이 조건문은 토큰이 있고 유효할 때만 다음 단계로 가겠다는 뜻
→ 즉, 토큰은 "들고 왔다"가 중요하지 않고 "검증을 통과했다"가 중요

Claims claims = jwtUtil.getClaims(token);
Boolean isGuest = claims.get("isGuest", Boolean.class);
String username = claims.getSubject();
Long memberId = claims.get("memberId", Number.class).longValue();


→ 이 코드는 토큰 안의 payload를 실제로 읽는 부분
→ subject, memberId, isGuest 같은 값은 토큰이 "누구를 나타내는지" 알려주는 정보

[1] 필터(Filter)

→ 필터는 "요청이 컨트롤러에 도달하기 전에 먼저 실행되어 공통 처리를 담당하는 계층"

1. 개념 정의
① 필터는 HTTP 요청/응답 흐름 중간에서 동작하는 전처리 장치
② Spring Security는 여러 필터를 체인으로 연결해 인증/인가를 처리
③ JWT 인증은 이런 공통 처리 성격이 강하므로 필터에서 수행하는 것이 자연스러움

2. 왜 필요한가
① 모든 컨트롤러마다 직접 토큰 검사 코드를 넣으면 중복이 심함
② 인증은 거의 모든 보호 요청에 공통으로 필요
③ 따라서 요청 초입에서 한 번 처리하는 것이 구조적으로 맞음

3. 내부 동작 원리
① 요청이 들어오면 필터 체인을 통과
② JWT 필터가 먼저 토큰을 검사
③ 토큰이 유효하면 인증 객체를 만듦
④ 인증 객체를 SecurityContext에 저장
⑤ 이후 다음 필터 혹은 컨트롤러로 요청을 넘김

4. 오해하기 쉬운 포인트
! 필터는 컨트롤러 내부 로직이 아님
! 필터는 컨트롤러보다 먼저 실행
! JWT 필터가 모든 접근 허용/차단을 혼자 결정하는 것은 아님
! JWT 필터는 주로 "인증 상태를 준비"
! 실제 접근 허용/차단은 뒤에서 인가 규칙이 판단

5. 코드에 적용해서 설명

public class JwtFilter extends OncePerRequestFilter {


→ JwtFilter가 요청당 한 번 실행되는 보안 필터라는 뜻

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {


→ 실제 JWT 인증 로직이 수행되는 필터의 본체
→ 요청마다 여기서 토큰 검사와 인증 복원이 일어남

filterChain.doFilter(request, response);


→ 현재 필터 작업이 끝난 뒤 다음 필터 또는 컨트롤러로 요청을 넘김
→ 이 호출이 없으면 요청 흐름이 중단

.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);


→ JwtFilter를 Spring Security 필터 체인에 등록하는 코드
→ 그리고 기본 로그인 필터보다 앞에서 JWT 인증을 먼저 시도하게 만듦

[1] 인증(Authentication)

→ 인증은 "현재 요청을 보낸 사용자가 누구인지 Spring Security가 이해할 수 있는 형태로 복원하는 과정"

1. 개념 정의
① 인증은 신원을 확인하는 절차
② Spring Security는 인증 결과를 막연히 저장하지 않고 Authentication 객체로 표현
③ 그리고 그 Authentication 객체를 SecurityContext에 저장
④ 즉, 인증의 핵심은 "현재 요청의 사용자 정보를 Authentication으로 만들어 SecurityContext에 넣는 것"

2. 왜 필요한가
① 서버는 요청이 들어왔다고 해서 자동으로 사용자를 알지 못함
② 특히 JWT 무상태 구조에서는 서버가 이전 로그인 상태를 기억하지 않음
③ 따라서 매 요청마다 토큰을 보고 "현재 요청 사용자"를 다시 복원해야 함

3. 내부 동작 원리
① 토큰을 꺼냄
② 토큰을 검증
③ 토큰 안의 사용자 정보를 읽음
④ Authentication 객체를 만듦
⑤ SecurityContext에 넣음
⑥ 이후 Spring Security는 이 요청을 "인증된 요청"으로 인식

* 여기서 알아야 하는 핵심 내부 객체
i) Authentication
- 현재 요청의 사용자 정보와 권한 정보를 담는 객체

ii) Principal
- Authentication 안에서 "누구인지"를 식별하는 핵심 값

iii) SecurityContext
- 현재 요청의 인증 상태를 보관하는 공간

4. 오해하기 쉬운 포인트
! 인증은 "로그인 성공 여부"만 의미하지 않음
! JWT 구조에서는 이후 모든 요청에서 인증이 다시 복원
! 인증은 곧바로 권한 허용을 뜻하지 않음
! 인증이 성공해도 인가에서 막힐 수 있음

5. 코드에 적용해서 설명

String token = resolveToken(request);

if (StringUtils.hasText(token) && jwtUtil.validateToken(token)) {
    Claims claims = jwtUtil.getClaims(token);
    Boolean isGuest = claims.get("isGuest", Boolean.class);

    if (Boolean.TRUE.equals(isGuest)) {
        String guestUsername = claims.getSubject();

        UsernamePasswordAuthenticationToken authentication =
                new UsernamePasswordAuthenticationToken(guestUsername, null, Collections.emptyList());

        SecurityContextHolder.getContext().setAuthentication(authentication);

    } else {
        String username = claims.getSubject();
        Long memberId = claims.get("memberId", Number.class).longValue();

        UserDetails userDetails = User.builder()
                .username(username)
                .password("")
                .authorities(Collections.emptyList())
                .build();

        UsernamePasswordAuthenticationToken authentication =
                new UsernamePasswordAuthenticationToken(memberId, null, userDetails.getAuthorities());

        SecurityContextHolder.getContext().setAuthentication(authentication);
    }
}


→ 이 전체 흐름이 바로 인증이
→ 토큰을 검증
→ claims를 읽음
→ Authentication 객체를 만듦
→ SecurityContext에 넣음

SecurityContextHolder.getContext().setAuthentication(authentication);


→ 이 줄이 실행되어야 Spring Security가 "현재 요청은 인증된 요청"이라고 인식할 수 있음

[1] 인가(Authorization)

→ 인가는 "인증된 사용자가 어떤 자원에 접근할 수 있는지 판단하는 과정"

1. 개념 정의
① 인가는 접근 권한을 판별하는 단계
② 인증이 "누구인가"를 확인하는 일이라면,
③ 인가는 "그 사용자가 이 API에 접근해도 되는가"를 판단하는 일

2. 왜 필요한가
① 로그인했다고 해서 모든 기능을 다 사용할 수 있으면 안 됨
② 공개 API와 보호 API를 나눠야 함
③ 예를 들어 영화 조회는 누구나 가능할 수 있지만,
④ 결제나 마이페이지는 인증된 사용자만 가능해야 함

3. 내부 동작 원리
① 요청 URL을 확인
② URL이 어떤 규칙에 해당하는지 봄
③ permitAll()이면 인증 없이 허용
④ authenticated()이면 인증된 사용자만 허용
⑤ 필요하면 role/authority 기반으로 더 세분화할 수 있음

4. 오해하기 쉬운 포인트
! 인가는 인증 이후 단계
! 인가와 로그인은 같은 말이 아님
! authenticated()는 "로그인했는가" 정도만 판단함
! "회원인가 관리자인가 비회원인가" 같은 세부 권한 구분은 role/authority 설계가 있어야 가능

5. 코드에 적용해서 설명

.authorizeHttpRequests(auth -> auth
        .requestMatchers(
                "/api/reservations/verify/**",
                "/swagger-ui/**",
                "/swagger-ui.html",
                "/v3/api-docs/**",
                "/swagger-resources/**",
                "/webjars/**",
                "/api/main/**",
                "/api/auth/**",
                "/api/movies/**",
                "/api/screenings/**",
                "/api/reviews/**",
                "/api/ticketing/**",
                "/api/timetable/**",
                "/api/theaters/**",
                "/ws-kino/**",
                "/ws-seat/**",
                "/error"
        ).permitAll()
        .requestMatchers(
                "/api/mypage/**",
                "/api/reservations/**",
                "/api/payments/**",
                "/api/likes/**"
        ).authenticated()
        .anyRequest().authenticated()
)


→ 이 코드에서 permitAll()은 공개 API
→ 인증이 없어도 접근 가능

→ authenticated()는 보호 API
→ SecurityContext에 인증 정보가 있을 때만 통과

→ anyRequest().authenticated()는 위에서 빠뜨린 나머지 경로도 기본적으로 보호하겠다는 뜻
→ 보안에서 자주 쓰는 "기본 차단, 필요한 것만 허용" 원칙

[1] 전체 요청 흐름

1. 로그인 요청
① 사용자가 /api/auth/** 아래 로그인 API를 호출
② 이 경로는 permitAll()이므로 토큰 없이 접근 가능
③ 서버가 아이디/비밀번호를 검증
④ 성공하면 JWT를 발급

2. 보호된 요청
① 사용자가 Authorization 헤더에 "Bearer JWT"를 담아 요청
② 요청은 Spring Security 필터 체인으로 들어감
③ JwtFilter가 실행
④ resolveToken()으로 토큰을 꺼냄
⑤ validateToken()으로 토큰을 검증
⑥ getClaims()로 사용자 정보를 읽음
⑦ Authentication 객체를 만듦
⑧ SecurityContext에 저장
⑨ 그다음 SecurityConfig의 인가 규칙이 적용
⑩ 요청 URL이 authenticated() 대상이면 인증 정보가 있으므로 통과
⑪ 최종적으로 컨트롤러가 실행

3. 토큰이 없거나 잘못된 요청
① 보호된 API에 요청이 들어옴
② JwtFilter가 토큰을 꺼내지 못하거나 검증에 실패
③ Authentication이 SecurityContext에 저장되지 않음
④ SecurityConfig에서 authenticated() 규칙을 확인
⑤ 인증 정보가 없으므로 접근이 차단

4. 공개 API 요청
① 요청이 들어옴
② permitAll() 대상 경로이면 인증 없이도 통과
③ 이 경우 JWT가 없어도 접근 가능
! 다만 permitAll()은 "접근 허용"을 뜻하는 것이지, 필터 체인을 아예 건너뛴다는 뜻은 아님

→ Spring Security + JWT의 핵심은 "로그인 시 토큰을 발급하는 것 자체가 아니라, 그 이후 모든 요청에서 필터가 토큰을 검사하여 인증을 복원하고, 복원된 인증 정보를 기준으로 인가 규칙이 접근 가능 여부를 판단하는 구조

'팀프로젝트 > KINO' 카테고리의 다른 글

12. WebSocket과 실시간 좌석 처리  (0) 2026.03.29
11. OAuth 로그인  (0) 2026.03.29
9. JPA와 DB  (0) 2026.03.26
8. Spring Boot 구조  (0) 2026.03.23
7. Axios와 API 연결  (0) 2026.03.22

+ Recent posts