첨부를 준비하는 동안 같은 프로세스의 콜백이 기다린다

대화 API와 스트림 처리 중에 readFileSync나 writeFileSync를 부르면 그 파일 작업이 끝날 때까지 같은 Node 프로세스가 다른 JavaScript 콜백을 실행하지 못한다. 첨부 읽기, 디렉터리 탐색, 생성 파일 저장이 요청 경로에 있으면 모델 응답을 기다리는 시간과 별개로 이런 대기가 생길 수 있다.

AI Chat에서는 해당 경로의 파일 헬퍼를 Promise 반환으로 바꾸고 호출부가 await하도록 맞췄다. 파일 저장의 경로·크기·MIME을 받은 뒤 GeneratedFile 메타데이터를 생성하므로, 경로가 아직 없는 Promise를 저장하거나 파일 쓰기보다 완료 알림을 먼저 보내지 않는다. 별도의 내보내기·경로 조회 헬퍼에는 동기 코드가 남아 있어 서버 전체의 동기 I/O를 제거한 변경은 아니다.

첨부 이름과 크기만 저장할 때는 디렉터리 목록과 stat만 읽는다. base64 본문을 만들 때는 지원 확장자 필터를 먼저 적용해 버릴 파일을 읽지 않는다. 여러 UUID와 파일은 순서대로 처리해 모든 큰 파일의 읽기를 동시에 시작하지 않는다. 최종 결과에는 읽은 본문이 모이므로 전체 메모리 사용이 파일 한 개 크기로 제한되는 구조는 아니다.

파일 부재와 파일 읽기 실패를 같은 값으로 바꾸지 않는다

readFileIfExists()는 존재 여부를 먼저 검사하지 않고 비동기 읽기 한 번을 시도한다. 검사 후 읽기 사이에 파일이 사라질 수 있는 간격을 피하고, 읽기가 실패한 이유로 부재를 판정한다. 실제 함수의 오류 처리는 다음과 같다.

try {
  return await fs.promises.readFile(fullPath);
} catch (error) {
  if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') return null;
  throw error;
}
파일 API마다 다른 실패 의미
경로실패 처리
직접 파일 읽기ENOENT만 null. 권한 등 다른 읽기 오류는 전달한다.
업로드 디렉터리 목록부재·목록 조회 오류를 기록하고 빈 배열을 돌려준다.
목록의 개별 파일 stat·본문 읽기그 파일의 오류를 기록하고 해당 항목을 건너뛴다.
파일 저장디렉터리를 만든 뒤 쓰기를 기다린다. 쓰기 실패는 호출 경로에서 처리한다.

Promise를 반환하는 방식으로 바꿔도 오류를 모두 정상 부재로 바꾸면 실패 계약이 달라진다. 특히 직접 읽기의 권한 오류를 null로 숨기면 저장소 장애가 첨부 없는 요청처럼 보일 수 있다. 반대로 목록 처리의 개별 항목 오류는 나머지 첨부를 처리하기 위해 좁게 흡수한다. 어느 API가 어느 오류를 전달할지 유지하는 일이 await 추가만큼 중요하다.

비동기 저장은 파일과 DB를 한 작업으로 되돌려 주지 않는다. 디렉터리 생성 후 쓰기가 실패하면 디렉터리나 일부 바이트가 남을 수 있고, 저장 성공 후 GeneratedFile 생성이 실패하면 저장된 파일이 남을 수 있다. 이 변경에는 그때의 보상 삭제가 추가되지 않았다. base64 인코딩·Buffer 변환은 여전히 CPU와 메모리를 쓰고 비동기 파일 작업도 런타임의 공유 자원을 사용한다.

실제 파일의 값과 모킹한 호출 순서를 구분한다

파일 유틸리티 테스트는 임시 디렉터리에 파일을 만들어 Promise 반환, 저장한 바이트·경로·크기·MIME, 파일 부재와 다른 읽기 오류를 확인한다. 메타데이터 조회에서 본문을 읽지 않는지, 필터로 제외한 파일을 읽지 않는지, 여러 UUID의 요청 순서를 유지하는지도 검사한다. 생성 파일 호출부 테스트는 저장 Promise를 기다린 뒤 받은 경로로 메타데이터를 만드는 계약을 확인하며 외부 SDK는 모킹한다.

테스트의 rmSync는 임시 fixture 정리다. 제품에서 DB 실패 후 파일을 지우는 동작을 검증한 근거가 아니다. 이 테스트들에는 이벤트 루프 지연이나 처리량의 전후 측정도 없다. AI Chat / Tutor의 이 변경은 요청 중 동기 파일 작업을 줄이면서 입력 값과 오류 의미를 유지하는 범위로 평가할 수 있다.