개발팀장님이 구현한 OAuth 2.0 엿보기🐸

|

OAuth 2.0 Authorization Code + PKCE 정리

사내 user-service에 OAuth IdP 기능을 붙이는 PR을 구경하다가, 그동안 막연히 “카카오 로그인 같은 거”로만 알던 OAuth를 처음으로 제대로 뜯어봤다. 들여다보면서 막혔던 부분 위주로 정리.

먼저, 뭘 하려는 건가

우리 서비스(almondyoung)에 다른 서비스(다뷰) 가 붙어서, 다뷰 사용자가 “almondyoung으로 로그인” 버튼을 누르면 우리 계정으로 로그인되게 하는 것. 카카오 로그인에서 “카카오” 자리에 우리가 앉는 구조다. 우리 user-service가 IdP(Identity Provider) 역할을 한다.

일반 로그인과 뭐가 다른가

가장 먼저 잡고 가야 할 개념.

  일반 로그인 OAuth
주체 유저가 직접 로그인 다른 서비스가 유저 대신
왕복 1번 (POST /login) 최소 2번
보내는 것 ID/비밀번호 code, verifier, clientId…
목적 이 서비스에 로그인 다른 서비스에 내 계정으로 로그인

OAuth가 복잡해 보이는 이유는 “비밀번호를 제3자에게 절대 주지 않으면서 권한만 위임” 해야 하기 때문이다. 이 전제를 먼저 깔고 나면 나머지 설계가 납득된다.

등장 인물 3명

┌──────────┐ ┌──────────┐ ┌──────────────┐
│ 다뷰      │───▶auth-web│───▶user-service│
│ (외부)    │ │ (BFF)    │ │ (IdP)        │
└──────────┘ └──────────┘ └──────────────┘
  • 다뷰: 로그인을 붙이고 싶은 외부 서비스
  • auth-web: 우리 쪽 웹서버. 유저 브라우저를 받아주고 로그인 여부 체크
  • user-service: 계정/토큰의 최종 권한자

PKCE라는 자물쇠

OAuth 공부하면서 제일 헷갈렸던 부분. 천천히 정리.

문제 상황

OAuth 흐름 중에 code라는 일회용 티켓을 유저 브라우저 URL로 주고받는 구간이 있다.

https://daview.com/callback?code=abc123

브라우저 주소창에 찍히면 히스토리, 프록시 로그, 악성 확장프로그램 등 여러 경로로 새어나갈 수 있다. 이 code를 공격자가 훔쳐도 토큰은 못 받게 해야 한다. 이걸 위한 장치가 PKCE.

어떻게 막나

값 두 개를 만든다.

verifier = 랜덤 원본 문자열
challenge = SHA256(verifier)

해시 함수는 일방향이라 challenge로는 verifier를 역산할 수 없다.

누가 뭘 쥐고 있나

  verifier challenge
다뷰 서버 숨겨 보관 보냄
user-service 모름 DB에 저장
공격자 없음 URL에서 가로챔

공격자가 code를 훔치고, URL에 있던 challenge까지 본다 해도, verifier 원본은 다뷰 서버 안에만 있다. 나중에 토큰 교환 시 verifier 원본을 제시하라고 요구하는데, 공격자는 이걸 만들 수 없다.


전체 흐름

1차 왕복 — code 받기

  [다뷰]              [auth-web]           [user-service]
    │                      │                      │
    │  ?challenge=...      │                      │
    ├─────────────────────▶│                      │
    │                      │ 로그인 쿠키 확인         │
    │                      │                      │
    │                      │ POST /internal/      │
    │                      │      issue-code      │
    │                      ├─────────────────────▶│
    │                      │              ┌───────┴────────┐
    │                      │              │ DB 저장:        │
    │                      │              │  - code        │
    │                      │              │  - challenge   │
    │                      │              │  (TTL 60s)     │
    │                      │              └───────┬────────┘
    │                      │      { code }        │
    │                      │◀─────────────────────┤
    │  302 redirect        │                      │
    │  ?code=abc123        │                      │
    │◀─────────────────────┤                      │
    │                      │                      │
  • 다뷰가 challenge를 보냄 (해시로 전환된 값)
  • auth-web이 로그인 쿠키로 “이 유저 지금 로그인돼 있나” 확인
  • user-service가 code를 만들어 DB에 저장 (challenge와 묶어서)
  • 다뷰로 code를 리다이렉트

여기서 code가 URL에 노출된다. 그래서 TTL을 60초로 짧게 잡는다.

2차 왕복 — 토큰 받기

  [다뷰]                                    [user-service]
    │ POST /oauth/token                           │
    │ { code, verifier, clientId, secret }        │
    ├────────────────────────────────────────────▶│
    │                                        ┌────┴──────┐
    │                                        │ 검증 :     │
    │                                        │  code유효  │
    │                                        │  만료전     │
    │                                        │  PKCE:    │
    │                                        │   SHA(ver)│
    │                                        │   ==DB    │
    │                                        │   challenge│
    │                                        │ code소모    │
    │                                        │ 토큰발급     │
    │                                        └────┬──────┘
    │ { accessToken, refreshToken }               │
    │◀────────────────────────────────────────────│
  • 다뷰 서버가 직접 user-service를 호출 (브라우저 안 거침)
  • code와 함께 verifier 원본을 보냄
  • user-service는 verifier를 해시해서 DB의 challenge와 비교
  • 일치하면 토큰 발급

    왕복을 두 번 나누는 이유

    브라우저 경유          서버-서버 직통
    ───────────           ────────────
       [code]             [access/refresh]
    (PKCE로 방어)           (노출 불가)
  • code는 브라우저 URL을 거쳐야 하므로 유출 리스크가 있다 → PKCE로 방어
  • 토큰은 노출되면 바로 계정 탈취 → 무조건 서버-서버로만

이 두 레이어를 분리하는 게 OAuth 설계의 핵심 아이디어다.


code를 보호하는 3중 방어

방어 내용
TTL 60초 시간이 짧아서 훔쳐도 쓸 시간 없음
1회성 (consumedAt) 한 번 쓰면 DB에 소모 표시, 재사용 불가
PKCE verifier 없으면 교환 실패

셋 다 뚫어야 공격 성공. 실질적으로 불가능에 가깝다.

핵심 검증 코드

user-service의 토큰 교환 로직.

async exchangeCodeForToken(input: TokenRequestDto) {
  return this.inTx(async (tx) => {
    const row = await this.repo.findUnconsumedCode(input.code, tx);
    if (!row) throw new Error('invalid or already used code');
    if (row.expiresAt < new Date()) throw new Error('code expired');
    if (row.clientId !== input.clientId) throw new Error('client mismatch');
    if (row.redirectUri !== input.redirectUri) throw new Error('redirect_uri mismatch');

    // PKCE 검증
    const computed = crypto
      .createHash('sha256')
      .update(input.codeVerifier)
      .digest('base64url');
    if (computed !== row.codeChallenge) {
      throw new Error('PKCE verification failed');
    }

    await this.repo.markCodeConsumed(row.code, tx);
    return this.mintTokenPair(row.userId, row.clientId, row.scope, undefined, tx);
  });
}

검증 6개를 다 통과해야 토큰이 나간다. 그리고 code 소모 처리까지 하나의 트랜잭션으로 묶여 있다 — 동시 요청이 들어와도 한쪽만 성공하도록.


발급되는 토큰 두 종류

  ┌──────────────┬───────────────────┬──────────────────┬──────────┐
  │     종류     │       형태        │    저장 위치     │   수명         │
  ├──────────────┼───────────────────┼──────────────────┼──────────┤
  │ accessToken  │ JWT (서명된 토큰) │ 없음 (stateless) │ 15분          │
  ├──────────────┼───────────────────┼──────────────────┼──────────┤
  │ refreshToken │ 랜덤 문자열       │ DB               │ 2주~90일     │
  └──────────────┴───────────────────┴──────────────────┴──────────┘
  • accessToken: JWT라서 서명만 검증하면 되고, DB 조회가 필요 없다 (stateless). 대신 유출 시 만료까지 막을 방법이 없어서 수명을 짧게.
  • refreshToken: DB에 저장된 값이라 언제든 폐기 가능. 그래서 수명이 길어도 안전.

공부하고 나니 납득된 것들

  • OAuth가 “복잡해 보이는” 이유 : 비밀번호 노출 없이 권한을 위임하려다 보니 불가피하게 단계가 늘어난 것
  • code / token을 따로 두는 이유 : 브라우저 경유 vs 서버 직통을 분리하기 위함
  • PKCE가 왜 “열쇠와 자물쇠” 비유로 설명되는지 : verifier(열쇠)는 다뷰만, challenge(자물쇠)는 IdP만 가짐

그럼에도 아직은 부족한 부분

  • 개발팀장님이 작성하신 로직중에 이미 폐기된 refreshToken이 또 들어오면 “공격이다”로 판단하고 체인 전체를 BFS로 훑어서 다 폐기하는 로직이있는데 이해가 좀 부족함

연결리스트에서 head,tail는 객체참조다.

|

연결 리스트를 구현하면서 append() 메서드를 만들 때, 혼란을 겪는 지점이 있었다. 바로 tail을 수정했는데 왜 head.next가 바뀌는가?에 대한 부분이다.

문제의 코드 (LinkedList.js)

class LinkedList {
    constructor(value) {
        this.head = { value: value, next: null };
        this.tail = this.head; // 초기 상태: head와 tail이 같은 객체를 참조
        this.length = 1;
    }
    
    append(value) {
        const newNode = new Node(value);
        this.tail.next = newNode; // [질문 포인트] 왜 head.next도 newNode가 될까?
        this.tail = newNode;      // tail을 새 노드로 교체
        this.length++;
    }
}

의문점: head.next는 계속 null 아닌가?

리스트에 데이터가 하나(length: 1)일 때 append(2)를 하면, head.next는 생성자에서 정한 대로 계속 null이어야 할 거로 생각했다. 하지만 실제로는 head.next에 자동으로 2번 노드가 연결됬다.

원인: 자바스크립트의 ‘객체 참조(Reference)’

그 이유는 this.tail = this.head가 복사가 아닌 참조이기 때문이였다.

초기 상태: this.head와 this.tail은 메모리상의 동일한 노드 객체(A)를 가리키는 이정표가된다.

this.tail.next = newNode: tail이 가리키는 객체(A)의 next를 수정한다. 그런데 head도 객체(A)를 보고 있으므로, head.next 역시 자연스럽게 newNode를 가리키게 된다!

여기서 this.tail은 이제 객체(A)를 버리고 새 노드 객체(B)를 가리키게 된다.

하지만 this.head는 여전히 객체(A)를 잡고 있다.

결과적으로 head(A) -> B(tail) 라는 연결 구조가 완성된다.

즉, this.length = 1 일 때 append를하게되면 this.head.next와 this.tail을 동시에 수정하게되는 셈인거다.

이중연결리스트 next,prev 개념이 헷갈린다.

|

next는 꼬리쪽, prev는 머리쪽?

강의를 보고 문제를 푸는데 이해가 잘 안갔다. 찾아보니 한국어의 앞,뒤 라는 표현이 상황에따라 중의적으로 표현되기 떄문이라고한다.

그래서 헷갈리지않기위해 next는 꼬리(Tail) 쪽, prev는 머리(Head) 쪽이라고 명확히 정해둔다고 한다.

내 상대적 위치에따라 변한다.

그래도 이해가 안갔고 게속 탐구한 끝에..

내 현재 위치에따라 prev,next 개념이 바뀌는거라고 이해했다.

현재 this.head = A 라고 생각하고, 연결리스트 노드가 A - B - C 일때

B의 prev(이전)는 A고 Next(다음)는 C다. 오른쪽 진행방향이라고생각햇을때 현재 위치에따라 바뀐다.

Medusa.js 트러블슈팅과 디버깅의 자세

|

회사 쇼핑몰 개발에 Medusa.js를 활용하던 중, 상품 장바구니 추가 버튼을 누르면 calculate_amount를 찾을 수 없다는 에러 메시지가 발생했다. 원인을 찾기 위해 고군분투하다 결국 팀장님께 도움을 요청하게 되었다. 이 과정을 통해 기술적인 해결책을 넘어 에러를 대하는 개발자의 원초적인 접근법에 대해 큰 배움을 얻었다.

node_modules라는 블랙박스를 여는 용기

예전에 팀장님께서 “프레임워크를 사용하다 원인을 알 수 없는 에러를 마주치면 node_modules를 직접 까서 하나씩 접근해 보는 방법이 있다”고 말씀하신 적이 있었다. 당시에는 그 말이 크게 와닿지 않았으나, 이번에 팀장님이 에러에 접근하는 과정을 지켜보며 그 의미를 제대로 체감할 수 있었다.

팀장님은 에러 로그를 확인한 뒤, 망설임 없이 node_modules 내의 라이브러리 경로를 따라 들어갔다. 부끄럽게도, 라이브러리 내부 코드를 직접 수정하거나 분석할 수 있다는 사실을 처음으로 실감한 순간이었다. 팀장님은 코드의 흐름을 파악하며 의심 가는 지점에 console.log()를 찍어 데이터의 변화를 확인했는데, 라이브러리를 단순히 ‘가져다 쓰는 도구’가 아닌 ‘내가 분석하고 제어할 수 있는 코드’로 대하는 모습이 뭐랄까.. 이게 진짜 개발자구나 나는 아직 코더에서 벗어나려면 멀었구나를 느꼈다.

복잡한 구조 속에서 단서를 좁혀가는 과정

Medusa는 워크플로우(Workflow)와 스텝(Step) 단위로 로직이 구성되어 있고, 내부적으로 SQL 쿼리와 Join이 복잡하게 얽혀 있다. 이 때문에 특정 지점에서 값이 어긋나면 원인을 파악하기가 매우 까다로웠다.

한정된 시간 내에 모든 원인을 완벽히 규명하기는 어려워 디버깅을 잠정 중단해야 했지만, 팀장님이 로그를 통해 좁혀놓은 범위와 단서들은 나에게 결정적인 힌트가 되었다. 흐름을 타고 들어가며 원인을 좁혀가는 디버깅의 정석을 엿본 기분이었다.

결국 범인은 ‘대소문자’ 하나 (with Claude Code)

팀장님과 함께 찾아낸 단서들을 정리하여 클로드(Claude)에 전달했고, 마침내 근본적인 원인을 파악할 수 있었다. 원인은 정말이지 단순하게도 country_code의 대소문자 문제였다.

데이터베이스에 국가 코드를 넣을 때 대문자(KRW)로 저장해 두었는데, 정작 Medusa 내부 로직은 소문자를 기대하고 있었다. 이 사소한 불일치가 워크플로우를 꼬이게 만들었고, 결과적으로 calculate_amount를 참조해야 할 객체를 undefined로 만들어버린 것이었다.

디버깅은 결국 흐름을 쫓는 일이다

이번 트러블슈팅을 통해 두 가지 큰 깨달음을 얻었다.

첫째, 라이브러리 내부를 들여다보는 것을 두려워하지 말아야 한다는 점이다. node_modules는 성역이 아니라 분석 대상일 뿐, 그 안에 암호같은 코드들도 결국 하나씩 차근히 들여다보면 눈에 들어오게되어있다.

둘째, 데이터 정합성의 중요성이다. 대소문자 하나가 거대한 프레임워크의 워크플로우를 멈추게 할 수 있다는 사실에 소름이 좀 돋았다.

이번 경험은 정말 내 개발자 인생에 큰 도움이 되는 경험인것 같다.
팀장님의 디버깅 방식을 곁에서 배우며 한 단계 더 성장할 수 있었던 의미 있는 시간이였고, 역시 에러를 해결하는 과정이야말로 개발자에게 가장 큰 공부가 된다는 것을 느꼈다. 그렇지만 마주치고 싶지 않은것도 에러인것같다..

프로젝트에 첫 별을 얻다.

|

이미지

프로젝트에 첫 별을 얻었다.

숫자로 보면 별것 아니다.
별 하나. 그저 누군가 버튼을 한 번 눌렀을 뿐이다.
하지만 그 버튼 하나가 생각보다 오래 마음에 남았다.

사실 이 프로젝트는
처음부터 거창한 목표를 가지고 시작한 건 아니었다.

완전히 새로운 걸 만들어보겠다는 생각보다는,
이미 누군가 잘 만들어놓은 프로젝트를 보면서
연습 삼아 따라 만들어본 작업에 가깝다.

주제만 조금 바꿨고,
디자인과 레이아웃 구조는 참고했다.
“이런 구성은 왜 이렇게 했을까”,
“이 흐름은 어떤 의도일까”를 하나씩 따라가 보려는 목적이었다.

어쩌면 누군가 보기엔
창작이라기보다는 모방에 가까울 수도 있다.
나 스스로도 그걸 알고 시작했다.

그래도 괜찮다 생각헀다.
무언가를 처음부터 잘 만들어내는 단계라기보다
잘 만들어진 것을 이해하는 단계에 더 가깝다고 느꼈으니까.

레이아웃을 쪼개보고,
구조를 다시 정리해보고,
“나라면 여기서 어떻게 할까?”를 계속 물었다.

그 과정에서
이 프로젝트는 단순한 연습을 넘어서
조금씩 내 것이 되어갔다.

그래서일까.
이 프로젝트에 찍힌 첫 별은
‘대단한 창작물에 대한 인정’이라기보다
이 과정을 지나온 나 자신에 대한 표시처럼 느껴졌다.

완벽하지 않아도,
처음부터 독창적이지 않아도,
누군가는 이 저장소를 열어봤고
그 안에서 뭔가를 느꼈다는 사실.

그게 좋았다.