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)
  }
}

1. 효율적으로 로그 모니터링하기 - 로그 레벨 구분하기

#281

요청 본문은 한 번만 읽을 수 있다. 누가 미리 읽었는지는 읽기 메서드를 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)
    },
  })
})

https://redd.one/blog/debugging-like-a-pro-xy

#294

URL의 공백이 %2520으로 깨져 들어온다. 원인은 iOS 17.0/17.1 WebKit pasteboard가 복사할 때 URL을 다시 인코딩하는 버그(WebKit Bug 261936)였다. Claude Code라면 깨진 값 해독 → 경로 좁히기 → 우리 코드 배제 → 방어 → 방어 확인 순으로 간다.

① 깨진 값 해독: decodeURIComponent 를 한 번 돌려도 %20 이 남으면 이중 인코딩이고, 한글은 풀리니 공백만 다시 인코딩됐다. ② 경로 좁히기: 액세스 로그의 %2520 요청이 iOS 에 몰리고 Referer 가 비어 있으면 복사-붙여넣기 경로다. ③ 우리 코드 배제: 링크 생성이 인코딩을 한 번만 하면 원인은 밖이고, iOS 버전으로 좁혀 WebKit Bug 261936 을 찾는다. ④ 고칠 수 없으니 막는다: 화이트리스트 라우트에서만 한 번 더 디코딩, 디코딩 뒤 .. // \ 차단, segment 별 재인코딩 후 301. 무조건 이중 디코딩은 ..%252f 를 ../ 로 만들어 path traversal 이 된다. ⑤ 방어 확인: curl -I 로 %2520 URL 은 301 로 정상 경로, ..%252f URL 은 차단되는지 본다.

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: 수정

공백 → 인코딩 1회 → %20 → iOS 17.0·17.1 pasteboard 가 NSURL 을 만들며 %20 을 invalid 로 판단해 % 를 다시 인코딩 → %2520. 한글 가 → 인코딩 1회 → %EA%B0%80 → 유효한 UTF-8 이라 통과 → %EA%B0%80 그대로. %2520 은 %25(= %) 뒤에 원래 20 이 붙은 것으로, 공백만 한 번 더 인코딩됐다. 클릭(JS redirect)은 pasteboard 를 거치지 않아 정상이다.

4. 고칠 수 없으니 막는다

원인이 OS 버그면 추적보다 방어가 싸다.

  • 화이트리스트 라우트 패턴에서만 한 번 더 디코딩
  • 디코딩 후 위험 패턴(.., //, \) 차단
  • segment별 encodeURIComponent 후 301 redirect

무조건 이중 디코딩 금지 — ..%252f..%252f → ../../ path traversal 우회 벡터.

5. 방어를 확인한다

%2520이 든 URL과 ..%252f..%252f가 든 URL을 curl -I로 보낸다. 앞의 것은 301로 정상 경로에 가고, 뒤의 것은 막혀야 한다.

교훈

  • URL은 디코딩된 원본으로 보관하고, 인코딩은 출력 직전 한 번 (single source of truth)
  • “방어적으로 한 번 더”가 함정 — 멱등이 아닌 연산엔 통하지 않는다

참고

#539

dnd-kit drop 직후 튕김 — setQueryData 는 화면을 한 틱 늦게 바꾼다

dnd-kit Sortable + react-query에서 drop 직후 아이템이 원래 자리로 돌아갔다가 새 자리로 점프한다. 1~2프레임짜리 튕김이고 onDragEnd에서 setQueryData로 즉시 캐시를 갱신해도 그대로다.

① 최소 재현: Sortable 리스트에 useQuery 를 붙이고 CDP Input.dispatchMouseEvent 로 드래그한다. ② 숫자로 잡기: requestAnimationFrame 마다 DragOverlay·아이템의 top 을 기록하는 프로브를 먼저 걸고, drop 뒤 최종 배치와 다른 프레임이 있으면 튕김이다. ③ 가설을 하나씩 끈다: dropAnimation={null} 로 사라지면 drop 애니메이션이 즉시 재는 탓, setState 와 setQueryData 를 번갈아 돌려 후자만 튕기면 캐시 알림 경로, notifyManager.setScheduler(queueMicrotask) 로 사라지면 원인 확정. ④ 고친다: 렌더는 local state, 캐시 갱신은 부수효과로. ⑤ 회귀 확인: ② 와 같은 프로브를 refetch 뒤까지 돌린다. 다시 튕기면 refetch 가 옛 순서를 덮음 → isReorderingRef 가드, id 흔들림 → stable id, DragOverlay 만 남음 → dropAnimation={null}.

③에서 확정되는 원인 — drop 애니메이션은 놓는 즉시 목적지를 재는데1, setQueryData의 리렌더는 notifyManager의 setTimeout(0)을 거쳐 한 틱 늦다.2

drop 하면 onDragEnd 가 돌고, dnd-kit 의 drop 애니메이션은 곧바로(ASAP) 목적지를 잰다. setState 로 바꾸면 그 이벤트 처리 안에서 새 순서가 렌더되어, 잴 때 이미 새 자리라 새 자리로 애니메이션한다. setQueryData 로 바꾸면 캐시는 즉시 바뀌지만 구독자 알림은 notifyManager 의 기본 스케줄러 setTimeout(0) 을 거쳐 다음 매크로태스크에 리렌더된다. 그 사이 dnd-kit 이 옛 순서를 재서 옛 자리로 애니메이션하고, 리렌더 순간 새 자리로 점프한다 — 1~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

  1. “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) ↩

  2. 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 헤더가 붙을 수 없다

정상이면 OPTIONS 가 LB 를 지나 앱에 닿고 앱이 204 와 access-control-allow-origin 을 돌려준다. 장애면 healthy 타깃이 0 이라 LB 가 CORS 헤더 없는 503 을 직접 만들어 돌려주고, 브라우저는 그걸 CORS error 로 표시한다.

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 아님. 본 요청의 본문·인증·에러를 본다
#599

localtunnel은 쉽게 테스트하고 공유할 수 있도록 로컬 호스트를 공개합니다! 다른 사람들이 변경 사항을 테스트하도록 하기 위해 DNS를 엉망으로 만들거나 배포할 필요가 없습니다.

app.listen(PORT, async () => {
  const tunnel = await localtunnel({
    port: PORT,
    subdomain: name,
  })

하지만 너무 느려서 ngrok 쓰는게 현실적일수도 있겠다. -20220917



Footnotes

  1. 로컬 환경에서 웹훅 테스트를 위해, API 엔드포인트를 만들고 localtunnel이나 ngrok을 사용하기. ↩

#68
#69

redux 에서 trace 가 필요하면 redux-devtools 의 trace 설정을 켠다. 다만 메모리 릭 가능성이 있어서 상시로 켜두긴 어렵다 — 우회한다면 무식하지만 의심 지점에 console.trace()를 직접 넣는다.

#71