서론
집에 계신 어느 분께 갑자기 이런 메시지가 왔다. "학교에서 웹사이트를 만들고 이미지를 크롤링하래 좀 해줘"
나: ?????
그러면서 보내온 파일 하나. "이 프로젝트야"

Teachable Machine에서 뇌 CT 이미지를 분석해 정상인지, 뇌출혈이 있는지 예측하는 모델을 만들었나보다.
이 모델을 가지고 웹사이트를 만들어야 하는 건데... 요즘은 학교에서도 머신러닝이랑 개발을 시키는구나,,,,,
내 생각에는
① 이미지 크롤링을 통해 모델을 학습시키고
② 이를 웹사이트에서 실행할 수 있는 환경을 구축
해야 할 것 같아서 우선 내 생각대로 진행했다. (결론은 정답!)
그렇게 팔자에 없던 크롤링도 해 보고... 모델을 학습시킨 후 이를 추출했다.

다음으로 웹사이트 제작
이 4년 전 영상를 따라 웹사이트를 구축하면 될 것 같은데, 코드를 보니 머신에서 제공하는 HTML 및 자바스크립트 코드, 파일 입력 템플릿의 HTML 및 자바스크립트 코드를 모두 index.html에 때려 넣는 것이었다.
심지어 자바스크립트 코드는 jquery...!
사실 그냥 코드만 복사해서 만들어줘도 상관은 없지만 Tensorflow 모델도 활용해 볼 겸 React+TypeScript로 만들기로 했고, 이 과정에서 구현한 파일 드래그 앤 드롭 기능을 포스팅해보려 한다. 자세한 개발 내용은 리드미에 작성해볼 예정!
| 우여곡절(?) 끝에 완성된 사이트 |
|---|
![]() |
서론이 좀 길어졌다...ㅎ
구현
사용자가 업로드 한 이미지를 AI 모델로 분석해야 하기 때문에 이미지를 업로드할 수 있는 UI를 구현하는 것이 두 번째 핵심 과제였다. (첫 번째는 모델 불러오기)
기존 프로젝트에서 파일 업로드 버튼을 눌러 이미지를 추가하는 방식은 많이 경험해 보았기에, 이번에는 버튼 클릭 뿐만 아니라 드래그 앤 드롭 방식으로 한 번 구현해 보고자 했다.
디자인 따위 없이,,,, 빠른 개발을 위해 만든 UI. ㅎ 이를 바탕으로 마크업을 작성하고 기능을 추가하였다.
1. HTML 마크업
먼저 마크업을 구성했다. 파일을 업로드하기 위해 <input type="file" /> 태그를 이용할 수 있는데, 이 input은 아래와 같이 기본 제공 스타일이 있고, CSS를 이용해 all: unset으로 스타일을 지정해도 적용되지 않는 문제가 있다.

따라서 <label> 태그 내부에 <input>을 배치하는 방법을 선택했다. <label>과 <input>을 같이 사용은 했었으나, <label> 태그 내부에 <input>을 두어도 되는지 잘 몰랐는데, mdn을 확인해보니 가능하며, 이 경우에는 <label>과 <input>의 연관성이 이미 보장되었으므로 따로 <label htmlFor="">, <input id="">를 지정하지 않아도 되었다.
<label>
<input
css={css`display: none;`} // emotion을 사용하고 있기 때문에 다음과 같이 CSS 속성을 지정
type="file"
accept="image/*"
/>
클릭하거나 이 곳에 이미지를 드래그 앤 드롭하세요
</label>
<input>에 display: none 속성을 지정하여 화면 상에서 제거하였고, 대신 연관된 <label>이 존재하므로 이 <label>을 클릭했을 때 기존의 파일 input과 동일하게 동작함을 알 수 있다.
추가적으로, 이미지가 존재할 때는 input이 아닌 이미지를 보여주고, 이미지 삭제 버튼을 보여주기 위해 다음과 같이 분기처리를 진행했다.
const [image, setImage] = useState<string | null>(null);
...
<div>
{image ? (
<div css={imgContainerStyle}>
<img src={image} alt="업로드 이미지" />
<button type="button">이미지 삭제</button>
</div>
) : (
<label>
<input
css={css`display: none;`}
type="file"
accept="image/*"
/>
클릭하거나 이 곳에 이미지를 드래그 앤 드롭하세요
</label>
)}
</div>
2. 로직
이제 만든 요소에 이미지 업로드 로직을 달아보자.
① 클릭을 통한 이미지 업로드
먼저 클릭을 통한 이미지 업로드 기능을 구현하기 위해 input에 onChange 이벤트 핸들러를 달아주었다. 내부 파일 핸들링 코드는 아래에서 소개할 예정이다.
const handleUpload = (e: React.ChangeEvent<HTMLInputElement>) => {};
...
<input
css={css`display: none;`}
type="file"
accept="image/*"
onChange={handleUpload}
/>
② 드래그 시 스타일 변경
다음으로, 파일을 input 영역으로 드래그해서 이동했을 때 이벤트를 처리하기 위해 다음 세 가지 이벤트 핸들러를 추가하였다.
| 이벤트 종류 | 특징 |
|---|---|
dragenter | 마우스가 대상 객체의 위로 처음 진입할 때 |
dragleave | 드래그가 끝나서 마우스가 대상 객체의 위에서 벗어날 때 |
dragover | 드래그하면서 마우스가 대상 객체의 영역 위에 자리 잡고 있을 때 |
const [isActive, setIsActive] = useState<boolean>(false);
const handleDragEnter = () => setIsActive(true);
const handleDragLeave = () => setIsActive(false);
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
};
...
<label
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
css={labelStyle(isActive)}
>
...
클릭하거나 이 곳에 이미지를 드래그 앤 드롭하세요
</label>
isActive라는 state를 생성하여
1) dragenter 이벤트가 발생했을 때 isActive를 true로
2) dragleave 이벤트가 발생했을 때 isActive를 false로 변경한다.
※ 주의
dragOver의 경우 주의가 필요한데, dragOver 이벤트가 발생하면 브라우저는 드래그된 항목을 "드롭 가능" 상태로 설정하고, 드래그된 파일이나 데이터를 서버에 자동으로 전송하려고 하기 때문에, e.preventDefault()를 활용해 기본 동작을 방지해야 한다.
③ 선택/드롭한 파일 처리
마지막으로, 이미지를 finder나 파일 탐색기에서 선택한 경우, 또는 드래그 한 이미지를 drop할 때 이미지를 처리하는 로직이 필요하다.
이미지를 finder나 파일 탐색기에서 선택
앞서 작성한 handleUpload 코드에 파일을 처리하는 코드를 추가한다.
const handleUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files ? e.target.files[0] : null;
...
};
드래그 한 이미지를 drop
handleDrop 이벤트 핸들러를 추가해 이미지를 영역에 drop 했을 때 파일을 처리하는 코드를 추가한다. 마찬가지로 drop 이벤트에도 기본 동작 방지를 위해 e.preventDefault()를 추가하고, drop 이벤트 이후에는 드래그 상태가 아니므로 isActive state를 false로 변경한다.
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsActive(false);
const file = e.dataTransfer.files[0];
...
};
...
<label
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
css={labelStyle(isActive)}
>
...
클릭하거나 이 곳에 이미지를 드래그 앤 드롭하세요
</label>
④ 업로드 한 파일 미리보기
마지막으로 업로드 한 이미지를 사용자가 바로 확인할 수 있도록 구현해야 한다.
1) 사용자가 추가한 이미지를 FileReader API를 사용하여 읽는다.
2)FileReader가 선택한 파일을 읽고, 이미지를 Base64로 인코딩한 URL dataURL 형식으로 변환한다.
3) 해당 이미지의 URL을 처음에 선언한 image 상태에 저장한다.
이를 구현하는 코드는 다음과 같다.
const reader = new FileReader();
reader.onload = (e) => {
setImage(e.target?.result as string);
};
reader.readAsDataURL(file);
FileReader가 비동기적으로 동작하여 파일 객체가 reader에 load된 이후에 파일을 성공적으로 읽고 나면 onload 콜백 함수에서 setImage를 호출하여 이미지 URL을 상태에 저장하는 방식이다.
위 코드를 handleUpload, handleDrop 이벤트 핸들러에 추가하여 미리보기까지 구현하면 끝!
const handleUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files ? e.target.files[0] : null;
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
setImage(e.target?.result as string);
};
reader.readAsDataURL(file);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsActive(false);
const file = e.dataTransfer.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
setImage(e.target?.result as string);
};
reader.readAsDataURL(file);
}
};
⑤ 업로드한 이미지 제거
이미지 제거는 간단하다. image라는 상태로 관리하고 있던 이미지의 URL을 제거하는 핸들러를 "이미지 삭제" 버튼의 클릭 이벤트에 달아주면 된다.
const handleRemove = () => {
setImage(null);
};
...
const [image, setImage] = useState<string | null>(null);
...
<div>
{image ? (
<div css={imgContainerStyle}>
<img src={image} alt="업로드 이미지" />
<button type="button" onClick={handleRemove}>이미지 삭제</button>
</div>
) : (
<label>
<input
css={css`display: none;`}
type="file"
accept="image/*"
/>
클릭하거나 이 곳에 이미지를 드래그 앤 드롭하세요
</label>
)}
</div>
3. 완성본
최종 완성본이다! 그렇게 복잡한 기능은 없었지만 워낙 빠르게 만들다보니 디자인도 엉성하고... UI/UX 측면에서 놓친 부분이 분명 있을 것이다. 예를 들면 이미지 변경을 할 때 반드시 이미지를 삭제해야 한다던가,,,,

그래도 우연한 기회로 드래그 앤 드롭을 구현해 봤다는 것에 의의를 두며... 오늘 포스팅은 끝!
참고