enum LogLevel {
DEBUG = 'debug',
INFO = 'info',
WARN = 'warn',
ERROR = 'error',
}
type Message = string
class Logger {
private level: LogLevel
constructor(level: LogLevel = LogLevel.DEBUG) {
this.level = level
}
private log(level: LogLevel, message: Message) {
if (this.level === LogLevel.DEBUG || level !== LogLevel.DEBUG) {
const label = level.toUpperCase()
console.log(`[${label}] ${message}`)
}
}
/**
* - 개발 혹은 테스트 단계
* - 운영 환경에서는 남기고 싶지 않은 로그 메세지
*/
public debug(message: Message) {
this.log(LogLevel.DEBUG, message)
}
/**
* - 정상 작동에 대한 정보
* - 시스템을 파악하는데 유익한 정보
*/
public info(message: Message) {
this.log(LogLevel.INFO, message)
}
/**
* - 잠재적으로 문제가 될 수 있는 상황
* - 언제든 발생할 수 있는 일반적인 문제 상황
* - 사용자에게 노출되는 메세지에 상세한 가이드가 필요
*/
public warn(message: Message) {
this.log(LogLevel.WARN, message)
}
/**
* - 심각한 오류나 예외 상황
* - 즉시 조치가 필요할때
*/
public error(message: Message) {
this.log(LogLevel.ERROR, message)
}
}
요청 본문은 한 번만 읽을 수 있다. 누가 미리 읽었는지는 읽기 메서드를 Proxy 로 감싸서 찾는다.
const bodyReadingMethods = ['arrayBuffer', 'blob', 'formData', 'text', 'json']
bodyReadingMethods.forEach((methodName) => {
request[methodName] = new Proxy(request[methodName], {
apply(...args) {
console.trace(`Premature "request.${methodName}" call!`)
return Reflect.apply(...args)
},
})
})
URL의 공백이 %2520으로 깨져 들어온다. 원인은 iOS 17.0/17.1 WebKit pasteboard가 복사할 때 URL을 다시 인코딩하는 버그(WebKit Bug 261936)였다. Claude Code라면 깨진 값 해독 → 경로 좁히기 → 우리 코드 배제 → 방어 → 방어 확인 순으로 간다.
1. 깨진 값을 해독한다
decodeURIComponent('%2520')을 한 번 돌리면 %20이 남는다 — %25(= %) 뒤에 원래의 20, 두 번 인코딩된 것이다. 같은 URL의 한글(%EC%88%98)은 한 번 디코딩으로 풀리니 공백만 다시 인코딩됐다. URL 인코딩은 멱등이 아니다.
2. 어느 경로에서 깨지나
서버 액세스 로그에서 %2520이 든 요청만 모아 User-Agent·Referer를 센다. iOS에 몰려 있고 Referer가 비어 있다 — 링크 클릭(JS redirect)이 아니라 주소를 복사해 붙여넣은 경로다. 클릭 경로의 같은 URL은 정상이다.
3. 우리 코드인가, 밖인가
링크를 만드는 코드에서 encodeURIComponent 호출을 찾아 출력이 %20 한 번인지 본다. 한 번이면 이중 인코딩은 우리 밖에서 일어난다. iOS 버전으로 좁혀 검색하면 WebKit Bug 261936이 나온다.
- iOS 16↓:
NSURL URLWithString:이 invalid char에nil - iOS 17.0/17.1: invalid char를 자동 percent-encode (regression)
- iOS 17.2: 수정
4. 고칠 수 없으니 막는다
원인이 OS 버그면 추적보다 방어가 싸다.
- 화이트리스트 라우트 패턴에서만 한 번 더 디코딩
- 디코딩 후 위험 패턴(
..,//,\) 차단 - segment별
encodeURIComponent후 301 redirect
무조건 이중 디코딩 금지 — ..%252f..%252f → ../../ path traversal 우회 벡터.
5. 방어를 확인한다
%2520이 든 URL과 ..%252f..%252f가 든 URL을 curl -I로 보낸다. 앞의 것은 301로 정상 경로에 가고, 뒤의 것은 막혀야 한다.
교훈
- URL은 디코딩된 원본으로 보관하고, 인코딩은 출력 직전 한 번 (single source of truth)
- “방어적으로 한 번 더”가 함정 — 멱등이 아닌 연산엔 통하지 않는다
참고
dnd-kit drop 직후 튕김 — setQueryData 는 화면을 한 틱 늦게 바꾼다
dnd-kit Sortable + react-query에서 drop 직후 아이템이 원래 자리로 돌아갔다가 새 자리로 점프한다. 1~2프레임짜리 튕김이고 onDragEnd에서 setQueryData로 즉시 캐시를 갱신해도 그대로다.
③에서 확정되는 원인 — drop 애니메이션은 놓는 즉시 목적지를 재는데1, setQueryData의 리렌더는 notifyManager의 setTimeout(0)을 거쳐 한 틱 늦다.2
④의 코드:
function SortableList() {
const { data: serverItems = [] } = useQuery({
queryKey: ['items'],
queryFn: fetchItems,
})
const [items, setItems] = useState<Item[]>(serverItems)
const isReorderingRef = useRef(false)
useEffect(() => {
if (!isReorderingRef.current) setItems(serverItems)
}, [serverItems])
const reorderMutation = useMutation({
mutationFn: reorderItems,
onSettled: () => {
isReorderingRef.current = false
queryClient.invalidateQueries({ queryKey: ['items'] })
},
})
const handleDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) return
const next = arrayMove(items, idxOf(active.id), idxOf(over.id))
isReorderingRef.current = true
setItems(next)
reorderMutation.mutate(next)
}
// <DndContext onDragEnd={handleDragEnd}> <SortableContext items={items.map(i=>i.id)}> ...
}
라이브러리가 렌더 직후 DOM을 재면, 기준은 상태가 바뀌는 시점이 아니라 화면이 바뀌는 시점이다.
v6.3.1 기준. v10+는
OptimisticSortingPlugin이 기본 활성화라 기제가 다르다 → 재검증 필요.
Footnotes
-
“the drop animation for DragOverlay measures the destination position ASAP, and so it catches the 1 or 2 frames before my reorder is applied, and the item animates back to its original position.” (dnd-kit #833) ↩
-
notifyManager.schedule은 “schedules a function to be run on the next batch. By default, the batch is run with a setTimeout” —setScheduler로queueMicrotask·requestAnimationFrame으로 바꿀 수 있다. (notifyManager — TanStack Query) ↩
CORS error 원인 찾기
| 언제 | 왜 |
|---|---|
Console·Network 에 CORS error |
preflight 에 Access-Control-Allow-Origin(ACAO) 이 없으면 브라우저는 5xx·404·타임아웃을 전부 CORS 로 뭉갠다 |
preflight 가 server: awselb/2.0 의 5xx |
LB 가 타깃에 닿기 전에 직접 만든 응답이다 — 앱을 안 거쳤으니 CORS 헤더가 붙을 수 없다 |
preflight 의 status 와 server 헤더 둘로 가른다.
| preflight | server | ACAO | 갈래 | 원인 |
|---|---|---|---|---|
503 |
awselb/2.0 | 없음 | 인프라 | healthy target 0 — 서비스 다운·배포 실패·CrashLoop |
502 |
awselb/2.0 | 없음 | 인프라 | 타깃이 끊거나 잘못 응답 — 크래시·OOM·포트 불일치 |
504 |
awselb/2.0 | 없음 | 인프라 | LB idle timeout 초과 — 느린 쿼리·외부 연동 지연 |
404 |
앱 | 없음 | 경로 | path 오타, ingress rule 불일치 |
204·200 |
앱 | 없음 | CORS | CORS 미설정 |
204 |
앱 | 있음 | CORS | access-control-allow-headers 에 요청 헤더가 빠짐 |
204 |
앱 | * |
CORS | credentials: 'include' 와 * 는 병용 불가 — 오리진을 명시한다 |
204 |
앱 | 정상 | 정상 | CORS 아님. 본 요청의 본문·인증·에러를 본다 |
localtunnel은 쉽게 테스트하고 공유할 수 있도록 로컬 호스트를 공개합니다! 다른 사람들이 변경 사항을 테스트하도록 하기 위해 DNS를 엉망으로 만들거나 배포할 필요가 없습니다.
app.listen(PORT, async () => {
const tunnel = await localtunnel({
port: PORT,
subdomain: name,
})
하지만 너무 느려서 ngrok 쓰는게 현실적일수도 있겠다. -20220917
Footnotes
-
로컬 환경에서 웹훅 테스트를 위해, API 엔드포인트를 만들고 localtunnel이나 ngrok을 사용하기. ↩
charles
- The Android Emulator and Charles Proxy: A Love Story | by Mark Dappollone | Medium
- Is it possible to rewrite a status code with Charles Proxy? - Stack Overflow
fiddler
mitmproxy
- mitmproxy로 iOS 기기의 네트워크 트래픽 살펴보기 :: Outsider’s Dev Story
- Android nougat 이상 emulator에서 mitmproxy 사용하기 | by Jungwook Park | kjcoop | Medium
Footnotes
redux 에서 trace 가 필요하면 redux-devtools 의 trace 설정을 켠다. 다만 메모리 릭 가능성이 있어서 상시로 켜두긴 어렵다 — 우회한다면 무식하지만 의심 지점에 console.trace()를 직접 넣는다.