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

현상

  • 공백이 %2520으로 깨짐, 한글은 %EC%88%98 정상 → 부분 재인코딩
  • 복사-붙여넣기 경로만 영향, 클릭(JS redirect)은 정상
  • iOS 집중 + referrer 누락 패턴과 일치

원인

%2520 = %25(= %) + 원래 있던 20. 공백(%20)이 한 번 더 인코딩된 것.

공백 → %20 → (% 만 재인코딩) → %2520

URL 인코딩은 멱등(idempotent)이 아니다 — encodeURIComponent를 두 번 하면 값이 달라진다.

진범은 WebKit Bug 261936:

  • iOS 16↓: NSURL URLWithString:이 invalid char에 nil
  • iOS 17.0/17.1: invalid char를 자동 percent-encode (regression)
  • iOS 17.2: 수정

pasteboard가 NSURL을 만들 때 %20은 invalid로 잘못 판단해 재인코딩, 한글 percent-encoding(%EA%B0%80)은 valid UTF-8로 통과 → 비대칭의 원인.

해결

원인이 외부(OS 버그)면 추적보다 방어가 ROI 높음.

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

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

교훈

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

참고

#539

시뮬레이터 Safari를 GUI로 디버깅하는 정도는 그냥 macOS Safari 개발자용 메뉴. inspect-webkit(CDP 브리지)으로 우회하려다 막혔다.

이유: inspect-webkit은 README부터 “for AI agents and CI” 타겟 — 사람 GUI 디버깅이 애초에 주력 시나리오가 아니었다.

교훈: 도구의 의도된 사용자를 README에서 먼저 확인한다.

참고

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

왜 그런가

dnd-kit의 drop 애니메이션은 놓는 즉시 목적지를 잰다. 그 순간 화면이 아직 옛 순서면 옛 자리를 목적지로 재고, 거기로 애니메이션한 다음 점프한다.1

그러니 문제는 새 순서가 언제 화면에 닿느냐다. 두 경로의 시점이 다르다.

  • onDragEnd 안에서 부른 setState — 그 이벤트 처리 안에서 반영된다.
  • queryClient.setQueryData — 캐시는 즉시 바뀌지만, 구독자에게 알리는 건 react-query의 notifyManager를 거친다. 기본 스케줄러가 setTimeout(fn, 0)이라 리렌더가 다음 매크로태스크로 밀린다.2

캐시는 바로 바뀌는데 화면은 한 틱 뒤에 바뀐다. 그 사이에 dnd-kit이 잰다.

그래서 이렇게 한다

렌더의 source-of-truth를 local state에 두고, 캐시 갱신은 서버 동기화 부수효과로 분리한다.

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

주의:

  • invalidate raceisReorderingRef 없으면 mutation 직후 refetch가 옛 순서를 들고 와 useEffect가 local을 덮는다(또 다른 튕김). 서버가 새 순서를 반환한다면 onSuccess에서 바로 받아 이 가드를 통째로 없앨 수 있다.
  • stable idSortableContext·자식 key·useSortable({ id })가 전부 같은 id여야 한다. 인덱스를 key로 쓰지 않는다.
  • DragOverlay 잔여 튕김dropAnimation={null}로 없앨 수 있다. 진단도 겸한다: 이걸로 사라지면 위 원인이 맞다.

교훈

라이브러리가 렌더 직후 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 setTimeoutsetSchedulerqueueMicrotask·requestAnimationFrame 으로 바꿀 수 있다. (notifyManager — TanStack Query)

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