이미지 유효성 검사 로직에 테스트 적용하기

이미지 유효성 검사 로직에 테스트 적용하기

액션, 계산, 데이터 구분으로 시작해 테스트로 끝나는 글

·

8 min read

들어가며

얼마 전부터 <쏙쏙 들어오는 함수형 코딩> 책을 읽기 시작했다. 그러다 문득 이전에 수강했던 원티드 챌린지 강의에서 준 과제가 생각났다. 당시에는 이것저것 하는게 많아서 과제는 못 하고 넘어갔다. 이 부분이 내심 아쉬웠는데 이참에 함께 하기로 했다.

과제는 본인이 작성한 코드에서 액션/계산/데이터를 구분하고 느낀 점을 정리하는 것이다. 책에서도 해당 파트를 읽었던 터라 바로 시작했다.

실습할 코드는 조금씩 리팩토링하고 있던 작심삼칩 프로젝트다. 코드를 쭉 보다가 계산으로 분리할 수 있을 것 같은 느낌이 확 드는 함수를 하나 골라잡았다. 그리고 나름대로 책과 강의에서 본 내용을 떠올리며 리팩토링을 시도했다. 그럼, 이제부터 함수를 액션/계산/데이터로 구분해 보자.


그 전에…액션, 계산, 데이터란 무엇일까?

<쏙쏙 들어오는 함수형 코딩>에서는 액션, 계산, 데이터를 아래와 같이 설명하고 있다.

액션

  • 외부 세계에 영향을 주거나 받는 것으로 실행 시점, 횟수에 의존하고 부수 효과를 일으킨다.

  • 예시: 이메일 보내기, ajax 요청 보내기, 전역 변수의 값 바꾸기

계산

  • 입력값으로 출력값을 만드는 것으로 순수 함수, 수학 함수라고 부르기도 한다.

  • 예시: 문자열 합치기, 이메일 주소가 올바른지 확인하기

데이터

  • 이벤트에 대한 사실을 기록한 것을 의미하고, 객체나 문자열 등 단순한 값 그 자체다.

  • 예시: 이름, 전화번호, 이메일

액션, 계산, 데이터에 대한 정의를 간단히 알아봤다. 액션은 부수 효과를 일으키니 코드 전체에 영향을 주지 않도록 최대한 격리하고, 가급적 계산을 사용하는 게 좋다고 한다.


왜 가급적 계산을 사용하는게 좋을까?

액션은 외부 세계와 소통하기 때문에 부수 효과를 일으킨다. 여기서 외부 세계와 소통하는 것은 API 요청, 데이터베이스 읽기 등을 의미하는데, 이런 행동은 항상 동일한 결과를 보장하기 힘들다. 반면 계산은 항상 동일한 결과를 보장하고 외부 세계에 영향을 주지 않는다.

그래서 액션에서 계산을 분리하면 다루기 쉬워진다. 또 계산은 외부 세계에 영향을 주지 않기 때문에 쉽게 테스트가 가능하고, 여러 번 테스트 해도 문제가 생기지 않는다.


적당한 코드 골라잡기

액션, 계산, 데이터가 무엇인지 그리고 왜 가급적이면 계산을 사용하는 게 좋은지 간단하게 알아보았다. 이제 리팩토링할 함수를 골라보자.

코드를 쭉 보다 ‘이건 계산으로 추출할 수 있을 것 같은데?’라는 느낌이 드는 함수를 발견했다. 바로 handleFileInputChange 함수다.

const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (!e.target.files?.length) return;

    const fileName = e.target.files[0].name;
    const allowedExtensions = ['.png', '.jpg', '.jpeg'];
    const fileExtension = fileName.slice(fileName.lastIndexOf('.'));

    if (!allowedExtensions.includes(fileExtension.toLowerCase())) {
      return notifyExtensionsBlockErr();
    }

    if (e.target.files[0].size > FILE_SIZE_LIMIT_10MB) {
      return notifyImgSizeLimitErr();
    }

    const file = e.target.files[0];
    setImage({ name: file.name, file });
    setImageUrl(URL.createObjectURL(file));
  };

handleFileInputChange는 input의 change 이벤트 핸들러에 전달해 주는 함수다. 이 함수가 하는 일은 아래와 같다.

  1. 사용자로부터 파일을 받아 유효성을 검사한다.

  2. 유효하지 않으면 토스트 메시지를 띄워 사용자에게 알려준다.

  3. 유효하다면 setImage에 값을 전달해 요청할 때 보낼 데이터를 만들고, setImageUrl을 호출해 미리보기 화면에 사용자가 선택한 이미지를 보여준다.

이 함수를 자세히 보면 몇 가지 문제를 발견할 수 있다.

  • 함수의 동작을 바로 파악하기 어렵다. 이미지 유효성을 검사하는 부분이 늘어져 있어서 내부 로직을 읽어봐야 어떤 기능을 하는지 알 수 있다.

  • 이미지 유효성 검사 로직의 재사용이 어렵다. handleFileInputChange 함수 내부에서 바로 검사를 진행하고, 이 외의 다른 기능도 수행하고 있다. 그래서 이 로직을 다른 곳에서 쓰고 싶어도 그대로 가져가서 쓸 수 없다.

  • 이미지 유효성 검사만 테스트 하기 힘들다. 하나의 함수에서 여러 가지 일을 하고 있기 때문에 테스트 코드를 작성할 때 이미지 유효성 검사 외에도 고려할 점이 많아서 복잡해진다.


함수에서 액션, 계산, 데이터 구분하기

이제 handleFileInputChange 함수를 액션, 계산, 데이터로 구분해 보자.

액션

  • 파일 객체를 읽는 부분: 사용자가 입력한 파일을 읽기 때문에 액션이라고 판단했다.

  • setImage, setImageUrl: 컴포넌트 외부 상태를 변경한다.

  • notifyExtensionsBlockErr(), notifyImgSizeLimitErr() : 토스트 메시지를 보여주는 부분. 외부 상태인 UI를 변경한다.

계산

  • fileName.slice(fileName.lastIndexOf('.')): 사용자가 입력한 파일 이름에서 확장자 부분만 추출한다.

  • allowedExtensions.includes(fileExtension.toLowerCase()): 소문자로 변환된 파일 확장자가 허용된 확장자 목록에 있는지 확인한다.

  • e.target.files[0].size > FILE_SIZE_LIMIT_10MB: 사용자가 입력한 파일 사이즈가 10MB를 넘는지 확인한다.

데이터

  • ['.png', '.jpg', '.jpeg']: 허용할 확장자 문자열이 들어있는 배열

구분한 코드를 바탕으로 handleFileInputChange 함수를 리팩토링했다. 해결하고 싶은 부분은 이미지 유효성 검사 로직을 분리하는 것이다. 그래서 이 작업을 먼저 시작했다.

유효한 이미지 파일 조건은 아래와 같다.

  1. 확장자는 png, jpeg, jpg만 허용한다.

  2. 파일 사이즈가 10mb를 초과하지 않는다.

isExtensionValid

우선 원래 코드에서 파일 확장자 검사 부분만 따로 분리했다. isExtensionValid 함수는 파일 이름, 허용할 파일 확장자 배열을 파라미터로 받는다. 그리고 파일 이름에서 확장자 부분만 추출한 다음, 허용한 확장자 배열에 있으면 true를, 아니면 false를 반환한다.

const isExtensionValid = (fileName: string, allowedExtensions: string[]): boolean => {
  const fileExtension = fileName.slice(fileName.lastIndexOf('.')).toLowerCase();
  return allowedExtensions.includes(fileExtension);
};

validateImage

이어서 이미지 유효성 검사 함수를 만들었다. validateImage 함수는 파일 객체, 허용할 확장자 배열, 제한할 파일 사이즈 값을 파라미터로 받는다. 그리고 통과 여부와 에러 메시지를 객체로 묶어 리턴한다.

function validateImage(file: File, allowedExtensions: string[], fileSizeLimit: number) {
  if (!isExtensionValid(file.name, allowedExtensions)) {
    return {
      isValid: false,
      errorMessage: 'INVALID_FILE_EXTENSIONS',
    };
  }

  if (file.size > fileSizeLimit) {
    return {
      isValid: false,
      errorMessage: 'INVALID_FILE_SIZE',
    };
  }

  return { isValid: true, errorMessage: null };
}

아쉬운 점은 개별검사 조건을 파라미터로 받는 부분이다. 만약 여기서 검사 조건이 더 추가된다면 파라미터 개수도 늘어난다. 또 내부 if 문도 계속 길어진다. 더 괜찮은 방법이 있을 것 같은데…지금은 잘 모르겠다. 이 부분은 다른 방법을 찾는 게 좋을 것 같다.

showToast

검사를 통과하지 못했을 경우 토스트 메시지를 띄워 사용자에게 피드백을 줘야 한다. showToast 함수는 toastType 문자열 값을 받아 해당하는 토스트 메시지를 띄운다.

function showToast(toastType: string | null) {
  switch (toastType) {
    case 'INVALID_FILE_EXTENSIONS':
      return notifyExtensionsBlockErr();
    case 'INVALID_FILE_SIZE':
      return notifyImgSizeLimitErr();
    default:
      return;
  }
}

handleFileInputChange

handleFileInputChange 함수에서 방금 만든 계산, 액션 함수를 조합해 보자. 사용자가 파일을 입력하면 validateImage 함수를 호출해 이미지의 유효성 검사를 진행한다. 검사를 통과하면 setImage와 setImageUrl을 호출하고, 통과하지 못하면 showToast를 호출해 토스트 메시지로 사용자에게 피드백을 준다.

const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  if (!e.target.files?.length) return;
  const file = e.target.files[0];

  // 유효성 검사 함수 호출
  const { isValid, errorMessage } = validateImage(file, allowedExtensions, SIZE_10MB);

  if (isValid) {
    setImage({ name: file.name, file });
    setImageUrl(URL.createObjectURL(file));
  } else {
    showToast(errorMessage);
  }
};

🔽 전체 코드

const allowedExtensions = ['.png', '.jpg', '.jpeg'];
const SIZE_10MB = 10485760;

// 계산: 유효한 확장자 검사 함수
const isExtensionValid = (fileName: string, allowedExtensions: string[]): boolean => {
  const fileExtension = fileName.slice(fileName.lastIndexOf('.')).toLowerCase();
  return allowedExtensions.includes(fileExtension);
};

// 계산: 이미지 유효성 검사 함수
function validateImage(file: File, allowedExtensions: string[], fileSizeLimit: number) {
  if (!isExtensionValid(file.name, allowedExtensions)) {
    return {
      isValid: false,
      errorMessage: 'INVALID_FILE_EXTENSIONS',
    };
  }

  if (file.size > fileSizeLimit) {
    return {
      isValid: false,
      errorMessage: 'INVALID_FILE_SIZE',
    };
  }

  return { isValid: true, errorMessage: null };
}

// 액션: 토스트 보여주는 함수
function showToast(toastType: string | null) {
  switch (toastType) {
    case 'INVALID_FILE_EXTENSIONS':
      return notifyExtensionsBlockErr();
    case 'INVALID_FILE_SIZE':
      return notifyImgSizeLimitErr();
    default:
      return;
  }
}

// handleFileInputChange 함수
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  if (!e.target.files?.length) return;
  const file = e.target.files[0];

  const { isValid, errorMessage } = validateImage(file, allowedExtensions, SIZE_10MB);

  if (isValid) {
    setImage({ name: file.name, file });
    setImageUrl(URL.createObjectURL(file));
  } else {
    showToast(errorMessage);
  }
};

분리한 계산에 테스트 코드 작성하기

<쏙쏙 들어오는 함수형 코딩> 책에서 테스트를 담당하는 조지가 테스트하는 시간이 오래 걸려서 일주일째 집을 못 간다고 불평하는 장면이 나온다. 이어서 조지의 테스트 방법을 보여주는데 코드가 수정될 때마다 매번 DOM을 조작하는 게 이제까지 내가 handleFileInputChange 함수 테스트하는 방법과 너무 비슷했다..😅 이렇게 수동으로 테스트를 하면 귀찮기도 하고 사람이 하는 거라 실수할 가능성이 있다. 또는 귀찮다고 그냥 넘어가 버릴 수도 있다…

아무튼! 이대로 마무리하면 아쉬우니 테스트 코드도 작성해 보자.

프로젝트는 CRA로 만들어져서 기본 세팅된 Jest를 사용했다. 계산 코드인 isExtensionValid validateImage 함수 테스트를 해보자.

isExtensionValid 테스트

isExtensionValid는 파일이 유효한 확장자인지 검사하는 함수다. 허용할 확장자 목록에 입력한 파일 확장자가 있다면 true, 없다면 false를 반환한다.

describe('isExtensionValid Function', () => {
  it('유효한 확장자라면 true를 리턴함', () => {
    const fileName = 'image.jpeg';
    const result = isExtensionValid(fileName, allowedExtensions);
    expect(result).toBe(true);
  });

  it('대소문자 구분 없이 유효한 확장자라면 true를 리턴함', () => {
    const fileName = 'image.JPEG';
    const result = isExtensionValid(fileName, allowedExtensions);
    expect(result).toBe(true);
  });

  it('유효하지 않은 확장자라면 false를 리턴함', () => {
    const fileName = 'document.pdf';
    const result = isExtensionValid(fileName, allowedExtensions);
    expect(result).toBe(false);
  });

  it('확장자가 없는 경우 false를 리턴함', () => {
    const fileName = 'image';
    const result = isExtensionValid(fileName, allowedExtensions);
    expect(result).toBe(false);
  });

  it('파일 이름에 점이 여러 개 있는 경우 제일 마지막 .을 기준으로 확장자를 판단하고 유효하면 true를 반환함', () => {
    const fileName = 'archive.01.png';
    const result = isExtensionValid(fileName, allowedExtensions);
    expect(result).toBe(true);
  });
});

validateImage 테스트

validateImage는 이미지 유효성 검사를 하는 함수다. 조건은 1) 허용한 확장자여야 하고, 2) 제한한 사이즈를 넘지 않아야 한다.

describe('validateImage Function', () => {
  const fileSizeLimit = 10 * 1024 * 1024; // 10MB

  it('유효한 확장자와 허용 가능한 파일 크기를 가진 파일에 대해 true를 반환해야 함', () => {
    const mockFile = createMockFile('image.jpg', 5 * 1024 * 1024, 'image/jpeg'); // 5MB
    const validation = validateImage(mockFile, allowedExtensions, fileSizeLimit);
    expect(validation).toEqual({ isValid: true, errorMessage: null });
  });

  it('허용되지 않는 확장자를 가진 파일에 대해 false와 "INVALID_FILE_EXTENSIONS" 오류 메시지를 반환해야 함', () => {
    const mockFile = createMockFile('document.pdf', 3 * 1024 * 1024, 'application/pdf'); // 3MB
    const validation = validateImage(mockFile, allowedExtensions, fileSizeLimit);
    expect(validation).toEqual({ isValid: false, errorMessage: 'INVALID_FILE_EXTENSIONS' });
  });

  it('허용된 확장자를 가지지만 파일 크기가 제한을 초과하는 경우, false와 "INVALID_FILE_SIZE" 오류 메시지를 반환해야 함', () => {
    const mockFile = createMockFile('large-image.jpg', 12 * 1024 * 1024, 'image/jpeg'); // 12MB
    const validation = validateImage(mockFile, allowedExtensions, fileSizeLimit);
    expect(validation).toEqual({ isValid: false, errorMessage: 'INVALID_FILE_SIZE' });
  });

  // 경계 테스트
  it('파일 크기가 정확히 제한값인 경우 (10MB), 유효한 파일로 처리해야 함', () => {
    const mockFile = createMockFile('exact-size-image.jpg', 10 * 1024 * 1024, 'image/jpeg'); // 정확히 10MB
    const validation = validateImage(mockFile, allowedExtensions, fileSizeLimit);
    expect(validation).toEqual({ isValid: true, errorMessage: null });
  });

  it('파일 크기가 제한값을 약간 초과하는 경우 (10MB + 1byte), 유효하지 않은 파일로 처리해야 함', () => {
    const mockFile = createMockFile('slightly-large-image.jpg', 10 * 1024 * 1024 + 1, 'image/jpeg'); // 10MB + 1byte
    const validation = validateImage(mockFile, allowedExtensions, fileSizeLimit);
    expect(validation).toEqual({ isValid: false, errorMessage: 'INVALID_FILE_SIZE' });
  });
});

이렇게 두 계산 함수의 테스트를 작성했다. 처음이라 그런지 ‘이렇게 하는 게 맞는 걸까..’하는 생각이 계속 들었다. 그리고 어디까지 테스트를 해야 하는지, 어떤 걸 테스트 하는 게 좋을지도 고민이 되었다. 우선 여기서 마무리하고 앞으로 더 알아봐야겠다.


느낀점

액션/계산/데이터에 대해 몰랐을 때는 어떻게 리팩토링해야 할 지 막막했는데 이제는 기준이 생긴 것 같다. 또 코드에서 계산과 액션 부분이 조금은 보인다..😮 하지만 아직은 이들을 분리하는 게 익숙하지 않다. 다른 코드로 더 연습 하는게 좋겠다.

액션에서 계산을 분리하니까 이전에 비해 동작이 명확하게 보였다. 또 이미지 유효성 검사 로직을 다른 곳에서 재사용할 수 있게 되었다. 토스트를 보여주는 코드와 분리되어서 이미지 유효성 검사만 필요한 경우 validateImage 함수를 가져다 사용하면 된다.

추가로 테스트 코드도 작성했는데 시도하길 잘했다. 테스트 코드의 장점 중 하나가 문서화가 가능하다는 건데 왜 장점으로 꼽는지 체감했다. 이전에는 코드를 보고 파악하거나 조건이 적힌 문서를 찾아야 해서 조금 번거로운데 이제는 테스트 코드로 파악할 수 있다.

또 조건에 대해 꼼꼼히 생각할 수 있는 것도 좋았다. 함수를 작성할 땐 생각 못 했던 실패 케이스가 테스트를 작성할 때 떠올랐다. 아마 테스트를 작성하지 않았다면 앞으로도 쭉 생각이 안 났을 것 같다.

이런저런 장점을 느꼈지만 지금 가장 크게 와닿는 건 직접 DOM을 조작하지 않고 이미지 유효성 검사를 테스트할 수 있는 것이다. 이제까지 테스트에는 별 관심이 없었는데 이걸 계기로 더 알아보고 싶은 마음이 생겼다..😇


마무리

이렇게 handleFileInputChange 함수 리팩토링을 마무리했다. 책을 읽고 프로젝트에 적용하는 건 정말 오랜만인데, 확실히 실습을 하면 여러 고민을 할 수 있어서 많은 도움이 된다. 아직 개선할 부분도 남았고 ‘이게 최선인가?’ 하는 고민도 여전히 있지만 내 의지로 함수형 프로그래밍의 세계에 한 발짝 내디딘 것 같아 두근거린다. 리팩토링하며 발견한 개선점은 좀 더 고민해서 다음 리팩토링 때 반영해야지.