15 Oct 2026
|
debug
nextjs
performance
안녕하세요. 미용 재료 쇼핑몰 아몬드영에서 프론트와 백엔드를 오가며 개발하고 있는 정중식입니다.
저희는 Microsoft Clarity를 쓰고 있어요. 실제 손님들의 방문을 녹화해 보여주고, 페이지별 로딩 속도나 클릭 반응 속도 같은 성능 지표도 모아 주는 무료 분석 도구입니다.
얼마 전 Clarity에서 URL별 성능 카드를 정렬해 보다가 손이 멈췄어요. 상품 상세 페이지 하나의 INP가 3,500ms로 찍혀 있었거든요. 버튼을 누르고 3.5초 동안 아무 반응이 없다는 뜻입니다.
| 화면 |
LCP |
INP |
CLS |
| 카테고리 A |
18초 |
약 1,400ms |
0.36 |
| 상품 상세 B |
1.3초 |
약 3,500ms |
0.24 |
| 상품 상세 C |
3.5초 |
160ms |
7.4 |
| 좋은 경험 기준 |
2.5초 이하 |
200ms 이하 |
0.1 이하 |
최근 3일간 실제 방문 기록을 모은 값이에요. 방문을 느린 순서로 줄 세웠을 때 상위 25% 지점(p75)이라, 방문 4번 중 1번은 이 숫자보다 더 느렸다는 뜻입니다.
저희 방문의 74%가 모바일이고, 그중 대부분이 iPhone Safari예요. 그 손님들이 이런 화면을 보고 있다면 그냥 넘길 수 없었습니다.
이 글은 이 숫자들을 따라간 기록인데요. 먼저 말씀드리면 세 숫자 중 그대로 재현된 건 하나도 없었어요. 대신 재현하려고 이것저것 찍어 보다가, 상품 상세에 들어갈 때마다 페이지를 두 번 받고 있던 걸 찾았습니다.
14 Jul 2026
|
life
최근 회사에서 마이페이지 리팩토링을 진행했다.
마이페이지를 수정하면서 가장 중요하게 여긴 건 UI와 편리함이었다.
고객이 로그인해서 제일 먼저, 제일 자주 보러 오는 건 배송인 만큼
마이페이지에 들어오자마자 배송 정보부터 보여주도록 바꿔줬다.
또 하나 신경 쓴 건, 마이페이지는 결국 고객이 가질 수 있는 플랫폼 내의 자기 공간인 만큼, 마이페이지 내에서 고객이 원치 않는 메뉴가 상단에 있을 수 있는데, 이 부분을 어떻게 표현할 것인가를 중점적으로 고민했다.
물론 이게 정답인지는 모르겠다.
그래서 이번 것도 끝냈다기보단, 일단 이렇게 해보고 반응 보면서 계속 수정해나갈 것 같다.
21 Apr 2026
|
oauth2.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로 훑어서 다 폐기하는 로직이있는데 이해가 좀 부족함
02 Feb 2026
|
algorithm
연결 리스트를 구현하면서 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을 동시에 수정하게되는 셈인거다.
26 Jan 2026
|
algorithm
next는 꼬리쪽, prev는 머리쪽?
강의를 보고 문제를 푸는데 이해가 잘 안갔다.
찾아보니 한국어의 앞,뒤 라는 표현이 상황에따라 중의적으로 표현되기 떄문이라고한다.
그래서 헷갈리지않기위해 next는 꼬리(Tail) 쪽, prev는 머리(Head) 쪽이라고 명확히 정해둔다고 한다.
내 상대적 위치에따라 변한다.
그래도 이해가 안갔고 게속 탐구한 끝에..
내 현재 위치에따라 prev,next 개념이 바뀌는거라고 이해했다.
현재 this.head = A 라고 생각하고,
연결리스트 노드가
A - B - C 일때
B의 prev(이전)는 A고 Next(다음)는 C다.
오른쪽 진행방향이라고생각햇을때 현재 위치에따라 바뀐다.