멀티모달 모델의 생성 오류를 살필 때는 모델에 전달한 입력이 생성 준비 과정에서도 원래 타입을 유지하는지 확인할 필요가 있다. Transformers v5.20.2는 generate 내부에서 일부 입력의 타입이 유지되지 않아 멀티모달 모델에 문제가 생길 수 있던 부분을 수정한 패치이다.

무엇이 바뀌었나

릴리스 설명이 주요 수정으로 꼽은 지점은 generate 안에서 호출되는 prepare_inputs_for_generation이다. 이 과정에서 일부 입력이 원래 타입을 잃을 수 있었으며, 관련 변경은 Fix inputs dict casting in generate라는 이름으로 포함됐다. 연결된 PR 번호는 #49731이다.

핵심은 새로운 생성 기능의 추가가 아니라 입력을 다루는 동작의 수정이다. 멀티모달 생성 경로를 점검하고 있다면, 모델에 넣기 전의 입력뿐 아니라 생성 준비 과정에서 타입이 유지되는지도 살펴볼 만하다. 다만 릴리스 설명에는 영향을 받는 모델 목록이나 구체적인 재현 입력이 나와 있지 않다.

개발자가 확인할 점

기존 환경에서 멀티모달 generate 문제가 있었다면 사용 중인 Transformers 버전과 오류가 발생한 위치를 먼저 정리하는 편이 좋다. 특히 입력을 준비하는 단계에서 타입과 관련된 문제가 보였다면 이번 수정과 연결해 검토할 근거가 된다. 모든 멀티모달 생성 오류가 이 문제 때문이라고 단정할 수는 없다.

업데이트를 검토할 때는 같은 모델과 같은 입력을 기준으로 생성 경로를 비교하는 방법이 유용하다. 이는 릴리스가 보장하는 테스트 결과가 아니라, 입력 타입 수정이 자신의 코드에 영향을 주는지 살펴보기 위한 점검 방법이다. 릴리스 본문에는 재현 코드가 없어 실행 예시는 생략한다.

함께 실린 수정은 구분해서 보기

같은 패치에는 텍스트만 입력받는 것으로 보고하던 VLM의 입력 모달리티 선언 수정도 포함됐다. 다만 이 변경은 별도 항목이므로 generate의 입력 타입 수정과 같은 원인으로 묶어 해석할 필요는 없다.

그 밖에 bfloat16 전문가 병렬화에서 ep_size < fsdp_size일 때 전문가 그래디언트가 0이 되는 문제와, eager_mask의 호스트에서 디바이스로의 복사를 피하는 변경도 들어갔다. 이번 글에서 살펴본 멀티모달 생성 문제와 직접 연결되는 확인 지점은 prepare_inputs_for_generation의 입력 타입 처리이다.

참고한 원문

같은 카드 컴포넌트를 본문과 사이드바에 넣으면 확보되는 너비가 달라진다. 화면 너비만 기준으로 스타일을 나누면 카드가 실제로 차지하는 공간을 반영하기 어렵다. 이럴 때 크기 컨테이너 쿼리를 사용하면 카드를 감싼 영역에 맞춰 스타일을 정할 수 있다.

화면 대신 컨테이너를 기준으로 한다

미디어 쿼리는 뷰포트 크기나 기기 특성을 기준으로 스타일을 적용한다. 크기 컨테이너 쿼리는 컨테이너의 치수를 기준으로 자손 요소의 스타일을 바꾼다. 카드를 어디에 배치할지 미리 정하지 않아도, 주어진 공간에 따라 제목 크기를 조절하는 식이다.

먼저 부모 요소에 container-type을 지정해 크기를 조회할 수 있는 컨텍스트를 만든다. inline-size는 인라인 방향의 크기를, size는 인라인과 블록 양쪽 크기를 조회할 때 사용한다. 기본값인 normal은 크기 쿼리용 컨테이너를 만들지 않는다.

부모를 지정하고 자손 스타일을 바꾼다

다음은 MDN의 카드 구조와 조건을 간추린 예시이다. 직접 실행해 검증한 코드는 아니다.

<div class="post">
  <article class="card">
    <h2>카드 제목</h2>
    <p>카드 내용</p>
  </article>
</div>
.post {
  container-type: inline-size;
}

.card h2 {
  font-size: 1em;
}

@container (width > 700px) {
  .card h2 {
    font-size: 2em;
  }
}

이 조건은 뷰포트가 아니라 조회 대상 컨테이너의 너비를 확인한다. 컨테이너가 700px보다 넓으면 제목이 커지고, 그렇지 않으면 기본 크기를 유지한다. 따라서 같은 화면 안에서도 카드가 놓인 영역에 따라 제목 크기가 달라질 수 있다.

중첩된 구조에서는 조회 대상을 확인한다

위처럼 이름을 생략한 쿼리는 조건에 맞는 컨텍스트를 가진 가장 가까운 조상을 기준으로 동작한다. 레이아웃 안에 컨테이너가 여러 개 있다면 어느 조상을 조회하는지 살펴봐야 한다.

특정 컨테이너를 대상으로 삼으려면 container-name을 지정한다. 예를 들어 부모에 container: sidebar / inline-size를 선언하고, @container sidebar (width > 700px)로 조건을 작성할 수 있다. 여기서 container는 이름과 타입을 함께 선언하는 단축 속성이다.

상대 단위도 기준이 필요하다

컨테이너 크기에 비례하는 길이가 필요하면 cqi 같은 단위도 사용할 수 있다. 1cqi는 조회 컨테이너 인라인 크기의 1%이다. 다만 적합한 컨테이너가 없으면 해당 축의 작은 뷰포트 단위인 sv*를 기준으로 계산된다.

컨테이너 쿼리를 적용할 때는 조건식뿐 아니라 부모의 선언도 함께 확인하는 편이 좋다. 미지원 브라우저를 위한 대체 구성이 필요하다면 MDN이 안내하는 Grid·Flex와 미디어 쿼리 조합을 검토할 수 있다.

참고한 원문

상태를 바꾼 직후 값을 읽었는데 이전 값이 나오면 실행 순서부터 의심하기 쉽다. 타이머로 읽는 시점을 늦춰도 같은 값이 보일 수 있다. React 공식 가이드의 ‘상태는 스냅샷’이라는 설명으로 이 동작을 정리해 본다.

setter는 현재 변수를 바꾸지 않는다

useState가 반환하는 상태는 해당 렌더에서 사용하는 값이다. setter를 호출하면 React에 다음 렌더를 요청하지만, 이미 실행 중인 이벤트 핸들러의 상태 변수까지 바뀌지는 않는다.

React는 컴포넌트 함수를 다시 호출하고 새 상태로 JSX를 계산한다. 이벤트 핸들러도 그 렌더의 상태를 기준으로 만들어진다. 따라서 상태를 읽을 때는 ‘지금 화면에 어떤 값이 있는가’와 함께 ‘이 함수는 어느 렌더에서 만들어졌는가’를 봐야 한다.

세 번 호출해도 같은 값을 전달한다

초깃값이 0인 렌더에서 아래 호출을 연달아 실행한다고 하자.

setNumber(number + 1);
setNumber(number + 1);
setNumber(number + 1);

세 줄 모두 같은 number를 읽는다. 각각 setNumber(0 + 1)이므로 다음 렌더의 값은 3이 아니라 1이다. 첫 호출 뒤에 number가 1로 바뀌고, 다음 줄에서 다시 증가하는 흐름으로 읽으면 예상이 어긋난다.

기다린다고 최신 상태를 읽지는 않는다

다음은 공식 가이드의 타이머 예시를 줄인 코드이다. 별도로 실행 검증한 코드는 아니다.

import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  function handleClick() {
    setNumber(number + 5);
    setTimeout(() => {
      alert(number);
    }, 3000);
  }

  return <button onClick={handleClick}>{number}</button>;
}

초기 화면에서 클릭하면 다음 렌더의 버튼에는 5가 표시되지만, 예약한 알림은 0을 읽는다. 콜백이 클릭 당시 렌더의 상태를 사용하기 때문이다. 실행을 늦추는 것과 읽는 상태를 최신 값으로 바꾸는 것은 별개이다.

지연 작업은 어느 시점의 값이 필요한가

공식 가이드에는 메시지를 보낸 뒤 수신자를 바꾸는 예시도 있다. Alice에게 보내기를 누른 다음 Bob으로 선택을 바꿔도, 먼저 예약한 알림은 Alice를 기준으로 표시된다. 제출 당시의 입력을 유지하는 동작이다.

실무에서 지연 작업을 검토할 때도 먼저 필요한 시점을 정하면 좋다. 클릭 당시의 값이 필요한지, 나중에 실행할 때의 값이 필요한지 구분해야 한다. 예상과 다른 값이 보이면 setter 뒤에 대기 시간을 붙이기보다, 해당 콜백이 만들어진 렌더의 상태를 실제 값으로 대입해 읽어 보는 편이 원인을 이해하기 쉽다.

참고한 원문

API에서 검증 오류를 가공하고 있다면 NestJS 12.2.0의 ValidationPipe 변경을 살펴볼 필요가 있다. DTO나 검증 규칙을 바꾸지 않아도 오류 객체와 응답이 달라질 수 있기 때문이다. 특히 직접 만든 exceptionFactory가 있다면 업그레이드 점검 대상으로 잡아두자.

오류 객체에서 target이 기본 제외된다

2026년 10월 19일 공개된 12.2.0에서는 별도 설정이 없을 때 validationError.target이 false로 적용된다. 따라서 exceptionFactory 안에서 error.target을 읽으면 undefined를 받는다.

기존 오류 가공 코드가 이 값을 참조한다면 해당 의존성을 먼저 확인해야 한다. 이전처럼 검증 대상 객체가 필요하면 validationError.target: true를 명시할 수 있다. 다만 기존 동작을 복원할지, 대상 객체 없이 오류를 구성할지는 응답에 필요한 정보를 기준으로 결정하는 편이 좋다.

검증 오류 메시지에도 길이 제한이 생긴다

새 옵션인 maxErrorMessagesLength의 기본값은 1,000,000자이다. 한도를 넘어서는 메시지는 Further validation errors were omitted 항목으로 대체된다.

오류 메시지를 항상 전부 받을 수 있다고 가정한 클라이언트나 테스트가 있다면 이 생략 항목을 고려해야 한다. 한도의 단위는 바이트가 아니라 문자 수이며, 이 설정을 요청 본문 크기 제한과 혼동하지 않는 것도 중요하다.

아래는 릴리스에 나온 옵션을 조합한 설정 예시이다. 실행 검증한 코드는 아니며, 메시지 한도는 기본값을 그대로 적었다.

import { ValidationPipe } from '@nestjs/common';

const validationPipe = new ValidationPipe({
  validationError: { target: false },
  maxErrorMessagesLength: 1_000_000,
});

이 예시는 바뀐 기본 동작을 설정에 드러내는 용도이다. 기존 exceptionFactory가 target을 요구한다면 해당 옵션을 true로 바꾸는 방식으로 이전 오류 객체를 사용할 수 있다.

클래스 검증에 배열이나 원시값을 보내면 400이다

검증 대상이 클래스인 경우 배열이나 원시값을 보내면 이제 400으로 응답한다. 해당 입력을 보내는 호출부가 있다면 정상적인 요청인지부터 확인할 필요가 있다.

업그레이드 검증에서는 정상 DTO 요청과 함께 배열·원시값 요청, 커스텀 오류 변환, 긴 오류 메시지를 나눠 살펴보면 된다. 핵심은 검증 규칙 자체뿐 아니라 오류를 만드는 코드와 받는 코드의 가정까지 확인하는 것이다.

참고한 원문

+ Recent posts