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

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

dnd-kit Sortable + react-query에서 drop 직후 튕김(flicker) = 렌더를 react-query 캐시로 하면, dnd-kit의 drop 측정이 의존하는 React commit phase(setState)를 안 타기 때문. → 렌더는 local state, 캐시는 부수효과로 격리.

현상

drop 직후 1~2프레임 동안 아이템이 원래 위치로 돌아갔다가 새 위치로 점프. onDragEnd에서 setQueryData로 즉시 캐시를 갱신해도 발생하고, DragOverlay에서 더 두드러진다. (내 코드 문제 아님 — dnd-kit 알려진 동작)

원인

dnd-kit의 drop 애니메이션이 destination을 ASAP 측정하는데, react-query 캐시 직접 업데이트는 dnd-kit이 의존하는 React commit 사이클을 안 탄다 → reorder 적용 전 프레임을 catch해 원위치로 애니메이션 후 점프. (#833·#921·#1522 — 답변자들도 결국 별도 local state로 우회)

해결

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

function SortableList() {
  const { data: serverItems = [] } = useQuery({ queryKey: ['items'], queryFn: fetchItems })
  const [items, setItems] = useState<Item[]>(serverItems) // 렌더용 source of truth

  // 드래그 직후 refetch가 옛 순서로 덮어쓰는 race 가드
  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)               // 1) local state 먼저 — dnd-kit이 이걸 보고 측정
    reorderMutation.mutate(next) // 2) 서버 동기화는 별개
  }
  // <DndContext onDragEnd={handleDragEnd}> <SortableContext items={items.map(i=>i.id)}> ...
}

주의:

  • invalidate raceisReorderingRef 없으면 mutation 직후 refetch가 옛 순서를 들고 와 useEffect가 local을 덮음(또 다른 튕김). 가장 깔끔한 변형: mutationFn이 새 순서를 반환 → onSuccess에서 setItems(data), invalidate 제거.
  • stable idSortableContext·자식 key·useSortable({ id })가 모두 같은 id. 인덱스 key 금지.
  • DragOverlay 잔여 flickerdropAnimation={null}로 제거(겸 진단: 사라지면 #833 확정).

교훈

라이브러리가 React commit lifecycle에 암묵적으로 의존하면, 그 옆 채널(캐시 직접 변경)로 상태를 바꿀 때 어긋난다 — 렌더의 source-of-truth를 commit을 타는 한 곳으로 모은다.

참고

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

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

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



Footnotes

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

#68
#69