본문으로 건너뛰기
면접 학습
복습세션 →

이력서 기반

토픽 3 · Phase 1

3D 뷰어 텍스처 처리 성능 최적화 — 2단계 캐싱

질문 5개
  1. Q3-1

    어떤 문제 상황이었나요? 왜 캐시가 필요했나요?

    핵심 포인트

    • 3D 뷰어에서 소재(Material) 변경 시 동일 텍스처 이미지 URL을 서버에서 중복 요청
    • 단일 화면에 동일 텍스처가 여러 곳에서 거의 동시에 요청되는 상황 (예: 같은 패턴의 소재가 여러 슬롯에 들어감)
    • 네트워크 트래픽 + 서버 비용 + 체감 응답 지연이 누적
    • 단순 결과 캐시만으론 부족. 요청이 동시에 들어오는 race가 핵심 문제
    모범 답안먼저 답해보고 펼치기

    3D 뷰어에서 소재를 바꿀 때마다 텍스처 이미지를 서버에서 받아오는 흐름이 있었어요. 화면에 동일한 패턴의 소재가 여러 슬롯에 동시에 들어가는 경우가 흔했는데, 그 때 같은 텍스처 URL이 거의 동시에 4~5번씩 요청되는 일이 자주 일어났습니다. 네트워크 패널을 보면 같은 url이 동시에 떠 있고, 서버 응답을 기다리는 동안 사용자에게 보이는 첫 프레임이 의도보다 늦게 그려졌어요.

    처음엔 "결과 캐시(Map)만 있으면 두 번째부터는 캐시가 잡혀서 괜찮겠지"라고 생각했는데, 실제로는 동시에 첫 요청이 진행 중일 때 두 번째·세 번째가 들어오면 모두 캐시 미스로 분류되어 똑같은 fetch가 4번 떠버렸어요. 결과 캐시는 "이미 끝난 작업"만 막을 뿐, "지금 진행 중인 작업"은 못 막는다는 게 문제의 본질이었습니다.

    이 답변, 어땠나요?

    꼬리 질문
    • Q-a. 백엔드에 캐시 헤더(Cache-Control)를 다는 게 더 단순하지 않나요?

      브라우저 HTTP 캐시는 같은 url 동시 요청을 dedupe해 주는 보장이 없습니다. 캐시 헤더가 떠 있어도 동시 요청은 그대로 4번 나갈 수 있어요. 클라이언트 레이어의 dedupe가 별도로 필요했습니다.

    • Q-b. 이미 React Query 같은 라이브러리가 dedupe를 해 주지 않나요?

      맞습니다. 다만 우리 텍스처 로딩은 React 컴포넌트가 호출하는 게 아니라 3D 엔진 내부에서 호출하는 흐름이라 React Query에 통합하기가 부자연스러웠어요. 엔진 모듈 안에 직접 캐시 레이어를 두는 게 응집도가 높았습니다.

    • Q-c. 서비스 워커에서 dedupe하는 옵션은 검토하셨나요?

      검토했지만 SW는 도입 자체가 별도 의사결정이고, 디버깅 비용도 커서 단순한 인메모리 캐시 레이어가 비용 대비 효과가 가장 컸어요.

    CS · 이론
    • Thundering herd / Cache stampede: 캐시 만료·미스 직후 동일 요청이 동시에 백엔드로 몰리는 현상. 분산 시스템에서 잘 알려진 문제
    • Request coalescing / Request deduplication: 같은 요청을 하나로 합치는 기법. 본 토픽의 핵심
    • HTTP 캐싱과 동시 요청: HTTP 캐시는 응답 후 저장이 핵심이지 진행 중 요청 dedupe는 보장 안 함
    • React Query / SWR의 dedupingInterval: 라이브러리가 같은 문제를 어떻게 푸는지 비교 사례
  2. Q3-2

    2단계 캐싱(결과 캐시 + 인플라이트 캐시)의 동작 흐름을 코드 레벨로 설명해 주세요.

    핵심 포인트

    • 결과 캐시: Map<url, Texture> — 이미 로드 완료된 텍스처 인스턴스 보관
    • 인플라이트 캐시: Map<url, Promise<Texture>> — 진행 중인 fetch Promise 공유
    • 흐름: 결과 캐시 hit → 즉시 반환 / 인플라이트 캐시 hit → Promise await / 둘 다 miss → fetch 시작·인플라이트에 등록·완료 시 결과로 이동
    • "같은 url을 동시에 요청한 N개 호출자가 모두 같은 Promise를 await"
    모범 답안먼저 답해보고 펼치기

    loadTexture 함수가 들어왔을 때 세 갈래로 분기합니다.

    const resultCache = new Map<string, Texture>();
    const inflightCache = new Map<string, Promise<Texture>>();
    
    async function loadTexture(url: string): Promise<Texture> {
      // 1. 결과 캐시: 이미 끝난 작업
      const cached = resultCache.get(url);
      if (cached) return cached;
    
      // 2. 인플라이트 캐시: 진행 중인 작업의 Promise 공유
      const inflight = inflightCache.get(url);
      if (inflight) return inflight;
    
      // 3. 둘 다 miss: 새로 시작하고 인플라이트에 등록
      const promise = fetchAndDecode(url)
        .then((tex) => {
          resultCache.set(url, tex);
          inflightCache.delete(url); // 완료된 인플라이트는 정리
          return tex;
        })
        .catch((err) => {
          inflightCache.delete(url); // 실패해도 정리해야 다음 시도 가능
          throw err;
        });
    
      inflightCache.set(url, promise);
      return promise;
    }
    

    핵심은 단계 3에서 fetch를 시작하기 전에 Promise 객체를 인플라이트 Map에 먼저 등록한다는 점이에요. 그러면 같은 url을 동시에 요청한 다른 호출자가 단계 2에서 그 Promise를 발견하고 같이 await 합니다. fetch는 한 번만 나가고, N명의 호출자가 결과 한 개를 공유하는 구조가 만들어져요.

    이 패턴 도입 후 같은 텍스처가 4~5번 동시에 요청되던 시나리오에서 네트워크 호출이 1번으로 줄었고, 체감 첫 프레임 시간이 눈에 띄게 빨라졌습니다.

    이 답변, 어땠나요?

    꼬리 질문
    • Q-a. 실패한 Promise도 캐시되면 다음 시도가 막히지 않나요?

      그래서 catch에서 인플라이트 Map에서 명시적으로 삭제합니다. 결과 캐시에는 성공한 것만 들어가요. 실패는 캐시되지 않으니 다음 호출자가 fresh하게 재시도할 수 있어요.

    • Q-b. AbortController를 어떻게 처리하나요? 한 호출자가 취소하면 다른 호출자도 취소되나요?

      단순 구현에선 AbortController를 안 썼습니다. 한 명이 취소한다고 다른 호출자의 await까지 끊으면 안 되니까요. 정말 필요하면 reference counting을 두고 모든 호출자가 abort했을 때만 진짜 abort 하는 방식으로 갑니다.

    • Q-c. 메모리 사용량은 안 폭주하나요?

      결과 캐시는 텍스처 인스턴스를 Map에 들고 있어서 GC가 안 됩니다. 그래서 LRU 정책을 옵션으로 두고 일정 개수 초과 시 가장 오래 안 쓴 텍스처를 제거하도록 했습니다. (※ 실제 구현에 따라 톤 조정)

    CS · 이론
    • Promise는 한 번만 settle된다: resolve든 reject든 단 한 번. N명이 await해도 결과는 동일. 이게 인플라이트 캐시의 안전성의 근거
    • Memoization과 Async memoization: 동기 메모이제이션은 흔하지만 비동기 버전은 인플라이트 처리가 추가됨
    • LRU (Least Recently Used) 캐시: 메모리 상한이 있을 때 evict 대상을 결정하는 표준 정책
    • WeakMap·WeakRef: GC 친화적 캐싱이 필요할 때의 대안. url(string) 키에는 못 쓰지만 객체 키 시나리오에서 유용
  3. Q3-3

    인플라이트 캐시는 정확히 어떻게 "Promise를 공유"하는 건가요? 더 깊게 설명해 주세요.

    핵심 포인트

    • Promise는 자바스크립트 객체. Map에 그 객체 참조 자체를 저장
    • N명이 같은 Promise 인스턴스에 .then 또는 await 하면 그 인스턴스의 단일 결과가 N명에게 동일하게 전달됨
    • "Promise를 공유한다"는 표현은 결국 "같은 객체 참조를 N명이 같이 await 한다"는 의미
    • fetch 자체는 한 번만 일어나고, Promise는 fan-out 됨
    모범 답안먼저 답해보고 펼치기

    "Promise를 공유한다"가 처음엔 추상적으로 들릴 수 있는데, 자바스크립트 모델에서는 매우 구체적인 동작이에요. Promise는 그냥 자바스크립트 객체고, Map에 저장하는 건 그 객체의 참조 자체입니다.

    호출자 A가 단계 3에서 Promise를 만들고 인플라이트 Map에 넣어요. 호출자 B가 직후에 들어와서 단계 2에서 같은 Promise 객체를 꺼내 갑니다. 이제 A와 B는 같은 Promise 객체에 각자 await을 걸고 있는 상태가 돼요. fetch 호출은 그 Promise 안에서 단 한 번만 일어났고, fetch가 끝나면 Promise는 한 번만 resolve 되는데, await 걸어 둔 모든 callback이 마이크로태스크 큐에 들어가서 차례로 같은 결과를 받습니다.

    핵심 통찰은 **"Promise는 결과의 채널이지 작업의 트리거가 아니다"**라는 거예요. 새 호출자가 같은 Promise를 await한다고 해서 fetch가 다시 트리거되지 않아요. fetch는 Promise 생성 시점에 이미 한 번 시작됐고, await은 단지 "끝나면 알려달라"는 구독 신호일 뿐입니다.

    이 답변, 어땠나요?

    꼬리 질문
    • Q-a. Observable이나 RxJS subject로도 비슷하게 만들 수 있는데, 차이는요?

      Observable은 일반적으로 cold(구독 시 새 작업 시작)예요. share()나 shareReplay() 같은 multicast 연산자가 있어야 비슷한 동작이 됩니다. Promise는 본질적으로 hot이라 별도 multicast 없이도 자연스럽게 fan-out 돼요. 본 케이스에선 단순 Promise로 충분했어요.

    • Q-b. 같은 결과를 여러 호출자가 받으면 변경 시 서로 간섭하지 않나요?

      Texture 인스턴스를 immutable하게 다루면 문제없습니다. 만약 mutable하다면 클론을 반환하거나 변경 추적을 layer로 한 번 더 둬야 해요. 본 케이스에선 텍스처가 read-only라 안전했습니다.

    CS · 이론
    • Promise 모델 vs Observable 모델: hot/cold, single/multi-value, push/pull 차이
    • JavaScript 객체 참조 동등성: Map의 키/값으로 객체를 넣을 때 참조가 같으면 같은 것으로 취급
    • Microtask queue: Promise 콜백은 마이크로태스크로 큐에 들어가 같은 tick 안에서 실행됨
    • Multicast pattern: 한 개의 source가 여러 subscriber에게 같은 값을 전달하는 패턴
  4. Q3-4

    enum 기반 DisplayMode 리팩토링은 어떤 문제를 풀었나요? "코드 50% 감소"가 정확히 뭘 의미하나요?

    핵심 포인트

    • 컬러라이즈 모드 판별 로직이 약 400줄의 중첩 if/else로 얽혀 있던 상태 (개별 boolean prop 여러 개를 조합으로 분기)
    • enum DisplayMode 단일 값으로 상태를 표현 → 분기 자체가 한 단계로 평탄화
    • TypeScript exhaustive check로 모든 enum 케이스를 컴파일 타임에 강제 — 누락된 분기는 빌드 에러
    • 결과: ~200줄로 감소, 새 모드 추가 시 컴파일러가 모든 분기 지점을 알려줌
    모범 답안먼저 답해보고 펼치기

    원래 컬러라이즈 모드 판별이 isColorize && !isPattern && hasTexture 같은 boolean prop 여러 개의 조합으로 표현되어 있었어요. 가능한 조합이 많아지니까 자연스럽게 if/else가 깊어지고, 한 모드의 동작을 바꾸려면 분기 여러 군데를 동시에 손대야 하는 상태였어요. 약 400줄짜리 함수가 됐고, 동료들이 손대길 꺼리는 코드였습니다.

    리팩토링 핵심은 상태 자체를 enum 한 개로 표현한 것이었어요. 가능한 모드를 나열한 enum DisplayMode { Colorize, Pattern, Plain, Mixed, ... }를 만들고, 입력 boolean들을 단 한 번 enum 값으로 변환하는 normalize 단계를 둡니다. 그 이후의 모든 분기는 switch (mode) 한 단계 평탄화. 400줄이 200줄로 줄었어요.

    더 큰 효과는 TypeScript의 exhaustive check였어요. switch 문에 default 자리에 _exhaustive: never = mode 같은 패턴을 두면, enum에 새 값이 추가됐는데 분기가 빠진 곳이 있으면 컴파일 에러가 납니다. 새 모드를 추가했을 때 컴파일러가 손봐야 할 모든 곳을 정확히 알려주니까, 누락 버그가 원천적으로 차단됐어요. 이게 enum 기반 설계의 진짜 가치라고 봅니다.

    이 답변, 어땠나요?

    꼬리 질문
    • Q-a. TypeScript 커뮤니티는 enum 대신 as const + literal union을 권장하지 않나요?

      맞아요. 런타임 객체 생성과 트리쉐이킹 이슈가 있어서요. 본 케이스는 string enum이라 그 이슈가 약했고, 도메인 표현(자동완성, 네이밍)이 명확해서 enum으로 갔지만 신규 코드에선 as const literal union으로 가는 게 더 일반적인 선택입니다.

    • Q-b. exhaustive check를 강제하는 다른 방법은요?

      satisfies 연산자로 객체 키 누락을 잡거나, ts-pattern 같은 라이브러리로 패턴 매칭을 쓰는 방법도 있어요. switch + never 어서션이 가장 가볍고 zero-runtime이어서 본 케이스에 적합했습니다.

    • Q-c. enum 도입이 부적절한 케이스도 있나요?

      직렬화·외부 API와 주고받는 값이라면 string literal union이 더 안전합니다. enum은 reverse mapping 같은 미묘한 동작이 있어서요. 외부와 무관한 내부 분기에만 enum을 권장합니다.

    CS · 이론
    • State explosion (조합 폭발): boolean N개를 조합하면 가능한 상태가 2^N. 명시적 state machine으로 줄이는 게 정석
    • Exhaustive check / Total function: 모든 입력 케이스를 빠뜨리지 않고 처리하는 함수. TypeScript의 never 타입과 결합하면 정적 검증 가능
    • TypeScript enum vs as const literal union: 런타임 코드 생성, 트리쉐이킹, reverse mapping, 자동완성 면에서 트레이드오프 존재
    • Discriminated union: enum 대신 여러 타입을 union하고 공통 discriminator 필드로 분기. switch + exhaustive check와 잘 어울림
    • Cyclomatic complexity: 분기 수가 많은 함수는 복잡도 메트릭이 폭증. 정규화로 평탄화하면 복잡도가 선형으로 떨어짐
  5. Q3-5

    결과 캐시·인플라이트 캐시 설계가 메모리에 미치는 영향은 어떻게 통제하셨나요?

    핵심 포인트

    • 결과 캐시는 GC가 안 되므로 명시적 evict 정책 필요 (LRU·TTL·수동 invalidate)
    • 텍스처는 GPU 메모리도 점유 — disposal까지 신경 써야 함
    • 인플라이트 캐시는 자체 정리 (resolve/reject 시 delete)
    • 캐시 키 정규화 (URL 쿼리 순서, 대소문자) — 안 하면 같은 리소스가 다른 키로 캐시됨
    모범 답안먼저 답해보고 펼치기

    캐시는 도입할 때부터 회수 정책을 함께 설계해야 한다는 게 제 원칙이에요. 세 가지 측면에서 통제했습니다.

    첫째, 결과 캐시 evict 정책. 단순 Map은 GC 후보가 될 수 없으니 무한히 자라요. 텍스처 개수 상한을 두고 LRU 정책으로 가장 오래 안 쓴 항목을 제거했습니다. 둘째, GPU 자원 정리. 텍스처는 단순 자바스크립트 객체가 아니라 GPU 메모리도 점유하니까, evict될 때 엔진의 dispose 메서드를 명시적으로 호출해서 GPU 메모리도 회수했어요. 셋째, 캐시 키 정규화. ?a=1&b=2?b=2&a=1이 다른 키로 들어가면 같은 리소스가 두 번 캐시되는 비효율이 생기니까, URL을 정규화한 뒤 키로 사용했습니다.

    인플라이트 캐시는 자체 정리예요. fetch가 resolve나 reject로 끝나는 순간 delete 하는 게 패턴이라 별도 회수 정책이 필요 없습니다. 다만 fetch가 영원히 끝나지 않는 좀비 케이스를 막기 위해 timeout을 걸어 fail로 처리하는 안전장치를 같이 뒀어요.

    이 답변, 어땠나요?

    꼬리 질문
    • Q-a. WeakMap을 쓰면 GC가 알아서 정리해 주지 않나요?

      WeakMap은 키가 객체일 때만 동작합니다. URL 문자열은 키가 될 수 없어서 WeakMap이 적합하지 않았어요. 대신 텍스처 인스턴스를 WeakRef로 감싸는 옵션은 검토했지만 복잡도 대비 이득이 작아 LRU로 갔습니다.

    • Q-b. 캐시 invalidate 트리거는 무엇이었나요?

      사용자가 직접 텍스처를 업로드/교체할 때, 그리고 일정 시간 동안 미사용일 때 두 가지였어요. 시간 기반 TTL은 안 썼습니다 — 텍스처는 한 번 받으면 거의 변하지 않는 데이터라 시간 기반 무효화의 효과가 적었어요.

    • Q-c. 캐시 hit ratio는 어떻게 측정하셨나요?

      개발 모드에서 hit/miss 카운터를 콘솔에 찍고, 운영에선 sentry breadcrumb로 일부 샘플링해서 봤습니다. 도입 직후 hit ratio가 70% 넘게 나와서 효과가 즉시 확인됐어요.

    CS · 이론
    • Cache replacement policy: LRU, LFU, FIFO, ARC 등. 워크로드에 따라 적합한 정책이 다름
    • GPU resource management: 그래픽스 컨텍스트의 텍스처/버퍼는 명시적 dispose가 일반적
    • WeakMap·WeakRef·FinalizationRegistry: GC와 협력하는 캐시 설계의 도구. 단점은 동작이 비결정적이라는 점
    • Cache key canonicalization: 같은 의미 다른 표현을 같은 키로 매핑하는 작업. URL의 경우 쿼리 정렬·trailing slash·대소문자 등