๐Ÿ‘จโ€๐Ÿ’ป 2ํŽธ React, Express, Spring์œผ๋กœ File๊ณผ JSON ๋™์‹œ์— ์ฃผ๊ณ  ๋ฐ›๊ธฐ

huewilliamsยท2022๋…„ 5์›” 24์ผ
11
post-thumbnail

ํŒŒ์ผ๊ณผ JSON ๋ฐ์ดํ„ฐ๋ฅผ ๋™์‹œ์— ๋ณด๋‚ด๊ธฐ! 1ํŽธ์—์„œ๋Š” ํŒŒ์ผ๊ณผ JSON ๋ฐ์ดํ„ฐ๋ฅผ ๋™์‹œ์— ๋ณด๋‚ด๊ธฐ ์œ„ํ•ด ์ ํ•ฉํ•œ Content-Type์ด ๋ฌด์—‡์ผ์ง€ ๊ณ ๋ฏผํ•ด๋ณด์•˜๋‹ค.
1ํŽธ์—์„œ ๋‚ด๋ฆฐ ๊ฒฐ๋ก ์€ Multipart/form-data๋ฅผ ํ†ตํ•ด ๋‘ ๊ฐ€์ง€ ๋ฐ์ดํ„ฐ๋ฅผ ๋™์‹œ์— ๋ณด๋‚ด๋Š” ๊ฒƒ์ด๋‹ค. ํŒŒ์ผ์€ ์ด๋ฏธ ํ”ํžˆ Multipart/form-data๋กœ ์ฃผ๊ณ  ๋ฐ›๊ณ  ์žˆ๋‹ค. ๊ด€๊ฑด์€ JSON ๋ฐ์ดํ„ฐ๋ฅผ Multipart/form-data๋กœ ๋ณด๋‚ด๋Š” ๊ฒƒ์ด๋‹ค.

์ด๋ฒˆ์—๋Š” Express๋กœ ์„œ๋ฒ„๋ฅผ, React๋กœ ์›น ํด๋ผ์ด์–ธํŠธ๋ฅผ ๊ตฌ์ถ•ํ•˜์—ฌ ์‹ค์ œ ๋™์ž‘์„ ํ™•์ธํ•ด๋ณด๊ฒ ๋‹ค.
๐Ÿ˜… ํŒŒ์ผ ์—…๋กœ๋“œ์— ๋Œ€ํ•ด ์ž˜ ์•„์‹œ๋Š” ๋ถ„์€ ๋ฐ”๋กœ (๐Ÿ”„ ํŒŒ์ผ๊ณผ JSON ๋™์‹œ์— ์ฃผ๊ณ  ๋ฐ›๊ธฐ)๋กœ ์ด๋™ํ•ด์ฃผ์„ธ์š”!

๐Ÿ“„ ํŒŒ์ผ๋งŒ ์ฃผ๊ณ  ๋ฐ›๊ธฐ

์šฐ์„ ์€ ํŒŒ์ผ๋งŒ Multipart/form-data๋กœ ์ฃผ๊ณ ๋ฐ›๋Š” ๊ธฐ๋Šฅ์„ ๊ตฌํ˜„ํ•ด๋ณธ๋‹ค.

์„œ๋ฒ„ ๊ตฌํ˜„

ํ”„๋กœ์ ํŠธ๋ฅผ ์ƒ์„ฑํ•˜๊ณ  ํŒŒ์ผ ์—…๋กœ๋“œ์— ํ•„์š”ํ•œ ๋ชจ๋“ˆ์„ ์ถ”๊ฐ€ํ•œ๋‹ค.

npm init -y
npm i express multer cors -S

์„œ๋ฒ„ ๋ฉ”์ธ ์ฝ”๋“œ๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™๋‹ค.

// index.js
const express = require('express');
const fs = require('fs');
const cors = require('cors');
const fileRouter = require('./routes/file');

const app = express();

app.use(cors());

app.use('/file', fileRouter);

app.listen(4000, () => {
    // ํŒŒ์ผ์ด ์ €์žฅ๋  upload ๋””๋ ‰ํ† ๋ฆฌ๊ฐ€ ์—†์œผ๋ฉด ์ƒ์„ฑ
    const dir = './upload';
    if (!fs.existsSync(dir)) fs.mkdirSync(dir);
    
    console.log('server is running!');
});

ํŒŒ์ผ ์—…๋กœ๋“œ API๊ฐ€ ์ •์˜๋˜์–ด ์žˆ๋Š” ํŒŒ์ผ ๋ผ์šฐํ„ฐ ์ฝ”๋“œ์ด๋‹ค.

// routes/file.js
const express = require('express');
const multer = require('multer');

const storage = multer.diskStorage({
    // ์—…๋กœ๋“œ ๋Œ€์ƒ ๋””๋ ‰ํ† ๋ฆฌ ์ง€์ •
    destination(req, file, cb) {
        cb(null, 'upload')
    },
    // ํด๋”์— ์ €์žฅ๋  ํŒŒ์ผ๋ช…
    filename(req, file, cb) {
        cb(null,  Date.now() + '-' + file.originalname)
    }
});

const upload = multer({ storage: storage });

const router = express.Router();

// upload.singe(fieldname), fieldname์€ ํผ์— ์ •์˜๋œ ํ•„๋“œ๋ช…
router.post('/upload', upload.single('file'), (req, res) => {
    res.status(201).json({message: 'file upload success!'});
})

module.exports = router;

Postman์œผ๋กœ API ํ…Œ์ŠคํŠธํ•ด๋ณด์ž.

ํ•œ ๊ฐ€์ง€ ์ฃผ์˜ํ•  ์ ์€ Header์— ContentType: multipart/form-data๋ฅผ ์ง€์ •ํ•ด์„œ ๋ณด๋‚ด๋ฉด ์•ˆ๋˜๊ณ , ํ—ค๋” ์ž์ฒด๋ฅผ ๋ณด๋‚ด์ง€ ์•Š์•„์•ผ ์ •์ƒ์ ์œผ๋กœ ๋™์ž‘ํ•œ๋‹ค.

fieldname (key)๋ฅผ file๋กœ ์„ค์ •ํ•˜๊ณ  ์ ๋‹นํ•œ ํŒŒ์ผ ํ•˜๋‚˜๋ฅผ ์„ ํƒํ•˜์—ฌ ์š”์ฒญ์„ ๋ณด๋ƒˆ๋‹ค.

์š”์ฒญ์€ ์„ฑ๊ณตํ–ˆ๊ณ , upload ํด๋”์—๋„ ์ œ๋Œ€๋กœ ํŒŒ์ผ์ด ์—…๋กœ๋“œ ๋˜์—ˆ์Œ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค.

์ด์ œ ์„œ๋ฒ„๋Š” ๊ตฌํ˜„๋˜์—ˆ์œผ๋‹ˆ ์›น ํด๋ผ์ด์–ธํŠธ๋ฅผ ๊ตฌํ˜„ํ•ด๋ณด์ž.

์›น ํด๋ผ์ด์–ธํŠธ ๊ตฌํ˜„

์›น๋„ ๋น ๋ฅด๊ฒŒ CRA๋ฅผ ์ด์šฉํ•ด์„œ ๊ตฌ์„ฑํ•ด๋ณด๊ฒ ๋‹ค.

yarn create react-app frontend --template typescript
npm i -S axios

ํด๋ผ์ด์–ธํŠธ ์ฝ”๋“œ๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ž‘์„ฑํ•œ๋‹ค.

// App.tsx
import React, {useCallback, useState} from 'react';
import axios from 'axios';

function App() {
    const [file, setFile] = useState<File | null>(null);

    const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
        if (e.target.files === null) return;

        if (e.target.files[0]) {
            setFile(e.target.files[0]);
        }
    }, []);

    const handleClick = useCallback(async () => {
        if (!file) return;

        const formData = new FormData();
        await formData.append('file', file);

        const res = await axios.post(
            'http://localhost:4000/file/upload',
            formData,
            {
                headers: {
                    'Content-Type': 'multipart/form-data'
                }
            }
        );
        if (res.status === 201) console.log(res.data);
    }, [file]);

    return (
        <div>
            <input type={"file"} onChange={handleChange}/>
            <button onClick={handleClick}>์—…๋กœ๋“œ ์š”์ฒญ</button>
        </div>
    );
}

export default App;

yarn start๋กœ ์›น ํด๋ผ์ด์–ธํŠธ๋ฅผ ์‹คํ–‰ํ•œ๋‹ค.

input์— ํŒŒ์ผ์„ ์—…๋กœ๋“œํ•˜๊ณ  ์—…๋กœ๋“œ ์š”์ฒญ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ์„œ upload API ์š”์ฒญ์„ ๋ณด๋‚ธ๋‹ค. ๋‹ค์Œ๊ณผ ๊ฐ™์ด API๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์ฒ˜๋ฆฌ๋˜๊ณ ,

Payload ํƒญ์—์„œ multipart/form-data ํ˜•์‹์œผ๋กœ ๋ฐ์ดํ„ฐ๊ฐ€ ์ „๋‹ฌ๋œ ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค.

์—ญ์‹œ ์„œ๋ฒ„์˜ upload ํด๋”์— ์ƒˆ๋กœ์šด ์ด๋ฏธ์ง€๊ฐ€ ๋“ฑ๋ก๋œ ๊ฒƒ๋„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์—ˆ๋‹ค.

์ด๋ ‡๊ฒŒ ๊ธฐ๋ณธ์ ์ธ ํŒŒ์ผ์„ ์ฃผ๊ณ  ๋ฐ›๋Š” ๊ธฐ๋Šฅ ๊ตฌํ˜„์ด ์™„๋ฃŒ๋๋‹ค!


๐Ÿ”„ ํŒŒ์ผ๊ณผ JSON ๋™์‹œ์— ์ฃผ๊ณ  ๋ฐ›๊ธฐ

์ด์ œ ๋“œ๋””์–ด ๋ณธ ์ฃผ์ œ์ธ ํŒŒ์ผ๊ณผ JSON์„ ๋™์‹œ์— ์ฃผ๊ณ  ๋ฐ›๋Š” ๊ธฐ๋Šฅ์„ ๊ตฌํ˜„ํ•ด๋ณด๊ฒ ๋‹ค.

์„œ๋ฒ„ ๊ตฌํ˜„

์„œ๋ฒ„๋Š” ์•„๊นŒ ์ž‘์„ฑํ•œ file router์˜ ์ผ๋ถ€๋ถ„๋งŒ ์ˆ˜์ •ํ•˜๋ฉด ๋œ๋‹ค. express๋Š” ์š”์ฒญ Payload์—์„œ binary ๋ฐ์ดํ„ฐ์ผ ๊ฒฝ์šฐ req.files์—, binary ์ด์™ธ์˜ ๋ฐ์ดํ„ฐ๋Š” req.body๋กœ ๋‚˜๋ˆ ์„œ ์ €์žฅ๋œ๋‹ค.
์ด๋Ÿฐ ๊ตฌ์กฐ ๋•๋ถ„์— ํด๋ผ์ด์–ธํŠธ๋กœ๋ถ€ํ„ฐ JSON.stringify()๋กœ json์„ ๋ฌธ์ž์—ด๋กœ ๋ณ€ํ™˜ํ•ด์„œ ๋ฐ›์•„ ๋‹ค์‹œ JSON.parse()๋ฅผ ํ†ตํ•ด JSON ๋ฐ์ดํ„ฐ๋ฅผ ์–ป์„ ์ˆ˜ ์žˆ๋‹ค.

// routes/file.js

(...)

// upload.singe(fieldname), fieldname์€ ํผ์— ์ •์˜๋œ ํ•„๋“œ๋ช…
router.post('/upload', upload.single('file'), (req, res) => {
    console.log('uploader is ', JSON.parse(req.body.uploader).name);
    res.status(201).json({message: 'file upload success!'});
})

module.exports = router;

ํด๋ผ์ด์–ธํŠธ ๊ตฌํ˜„

ํด๋ผ์ด์–ธํŠธ์—์„œ JSON ๋ฐ์ดํ„ฐ๋ฅผ ๋ณด๋‚ผ ๋•Œ๋„ ํŒŒ์ผ๊ณผ ๋งˆ์ฐฌ๊ฐ€์ง€๋กœ formData์— append ํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ ๋ฐ์ดํ„ฐ๋ฅผ ๋„ฃ์–ด์ฃผ์–ด์•ผ ํ•œ๋‹ค.

FormData.append()์˜ ํŒŒ๋ผ๋ฏธํ„ฐ ํƒ€์ž…์„ ๋ณด๋ฉด, value์—๋Š” string๊ณผ Blob(binary large object) ํƒ€์ž…๋งŒ ์ „๋‹ฌํ•  ์ˆ˜ ์žˆ๋‹ค.
๋”ฐ๋ผ์„œ ์•„๋ž˜์—์„œ uploader ๊ฐ์ฒด๋ฅผ append ํ•  ๋•Œ ๋ฐ”๋กœ ๋„ฃ์ง€ ์•Š๊ณ  JSON.stringify()๋กœ ๋ฌธ์ž์—ด๋กœ ๋ณ€ํ™˜ํ•˜์—ฌ ์ „๋‹ฌํ–ˆ๋‹ค.

// App.tsx
import React, {useCallback, useState} from 'react';
import axios from 'axios';

interface Uploader {
    name: string;
}

function App() {
   (...)

    const handleClick = useCallback(async () => {
        if (!file) return;

        const formData = new FormData();
        await formData.append('file', file);
        const uploader: Uploader = {name: 'huewilliams'};
        await formData.append('uploader', JSON.stringify(uploader));

        const res = await axios.post(
            'http://localhost:4000/file/upload',
            formData,
            {
                headers: {
                    'Content-Type': 'multipart/form-data'
                }
            }
        );
        if (res.status === 201) console.log(res.data);
    }, [file]);

   (...)
}

export default App;

์ด๋Œ€๋กœ ์œ„์— ํ–ˆ๋˜ ๊ฒƒ ์ฒ˜๋Ÿผ ํŒŒ์ผ์„ ์—…๋กœ๋“œํ•˜์—ฌ ์š”์ฒญ์„ ๋ณด๋‚ด๋ฉด ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์„œ๋ฒ„์—์„œ JSON ๋ฐ์ดํ„ฐ๋ฅผ ํ•จ๊ป˜ ์ „๋‹ฌ๋ฐ›์€ ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค!!


๐Ÿƒ์„œ๋ฒ„๊ฐ€ Spring์ผ ๊ฒฝ์šฐ์—๋Š”?

์„œ๋ฒ„์—์„œ ์ฑ™๊ฒจ์•ผ ํ•  ๊ฒƒ!

๋‚ด๊ฐ€ ์ž‘์„ฑํ•œ ์˜ˆ์ œ๋Š” ๋น ๋ฅด๊ฒŒ ๊ตฌ์„ฑํ•˜๊ธฐ ์œ„ํ•ด express.js๋กœ ๊ตฌํ˜„ํ–ˆ๋Š”๋ฐ, Spring์œผ๋กœ ๊ตฌํ˜„ํ•  ๊ฒฝ์šฐ์—๋Š” file๊ณผ dto๋ฅผ ํ•จ๊ป˜ ์ „๋‹ฌ ๋ฐ›์œผ๋ ค๋ฉด @RequestPart๋ผ๋Š” ์–ด๋…ธํ…Œ์ด์…˜์„ ์‚ฌ์šฉํ•˜๋ฉด ๋œ๋‹ค. (๊ด€๋ จ๋œ ๋ ˆํผ๋Ÿฐ์Šค: https://emoney96.tistory.com/258)

@PostMapping("")
public SuccessReponse uploadFileWithRq(
	@RequestPart UploaderRq	rq,
    @RequestPart MultipartFile multipartFile
) {
	return fileService.uploadFile(rq, multipartFile);
}

ํด๋ผ์ด์–ธํŠธ์—์„œ ์ฑ™๊ฒจ์•ผ ํ•  ๊ฒƒ!

์„œ๋ฒ„๊ฐ€ Spring Framework ๊ธฐ๋ฐ˜์ผ ๊ฒฝ์šฐ์—๋Š” Express์™€๋Š” ๋‹ค๋ฅด๊ฒŒ ๋™์ž‘ํ•˜๋‹ˆ ์ฃผ์˜ํ•ด์•ผ ํ•œ๋‹ค. ๊ธฐ์กด์— ํ–ˆ๋˜ ๊ฒƒ ์ฒ˜๋Ÿผ JSON.stringify()๋งŒ ์ ์šฉํ•˜์—ฌ ๋ณด๋‚ผ๊ฒฝ์šฐ 415 Unsupported Media Type Error๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. ์ •ํ™•ํžˆ๋Š” ์•„๋ž˜์— ๋‚˜์™€์žˆ๋Š” application/octet-stream ๋…€์„ ๋•Œ๋ฌธ์ด๋‹ค.

appication/octet-stream
8๋น„ํŠธ ๋‹จ์œ„์˜ ๋ฐ”์ด๋„ˆ๋ฆฌ ๋ฐ์ดํ„ฐ๋ฅผ ์˜๋ฏธํ•œ๋‹ค.

์œ„ ๊ฐ™์€ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•œ ์ด์œ ๋Š” ์„œ๋ฒ„์—์„œ๋Š” json ํƒ€์ž…์œผ๋กœ rq๋ฅผ ๋ฐ›๋Š” ๊ฒƒ์œผ๋กœ ์ •์˜ํ–ˆ๋Š”๋ฐ, ํด๋ผ์ด์–ธํŠธ์—์„œ string์œผ๋กœ ๋ณ€ํ™˜ํ•ด์„œ ๋ณด๋ƒˆ๊ธฐ ๋•Œ๋ฌธ์— ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ•œ ๊ฒƒ์ด๋‹ค.

ํ•ด๊ฒฐ๋ฒ•์€ ๋‹ค์Œ๊ณผ ๊ฐ™๋‹ค.
formdata์— appendํ•  ๋•Œ JSON.stringify๋กœ ์ƒ์„ฑํ•œ ๋ฌธ์ž์—ด์„ Blob์œผ๋กœ ๋งŒ๋“ค๊ณ  ๊ทธ ํƒ€์ž…์„ application/json์œผ๋กœ ์ง€์ •ํ•ด์ฃผ๋ฉด ๋œ๋‹ค.

// 415 Error
await formData.append('uploader', JSON.stringify(uploader));

// Good ๐Ÿ˜‹
const uploaderString = JSON.stringify(uploader);
await formData.append('uploader', newBlob([uploaderString], {type: 'application/json'}));

ํ›„๊ธฐ

์ด๋ฒˆ ํฌ์ŠคํŒ…์„ ์ž‘์„ฑํ•˜๋ฉด์„œ ๊ฝค ๋‹ค์–‘ํ•œ Content-Type์„ ์กฐ์‚ฌํ•˜๊ณ  ๋‹ค๋ค„๋ณธ ๊ฒƒ ๊ฐ™๋‹ค.
์‚ฌ์‹ค ๋ฌธ์ œ์— ๋งˆ์ฃผ์ณค์„ ๋•Œ ๋งˆ์ง€๋ง‰ ๋ถ€๋ถ„์˜ ํ•ด๊ฒฐ๋ฒ•๋งŒ ์•Œ๋ฉด ๊ตฌํ˜„์€ ํ•  ์ˆ˜ ์žˆ์ง€๋งŒ, ์—ฌ๊ธฐ๊นŒ์ง€ ๋„๋‹ฌํ•œ ๊ณผ์ •๊ณผ ์ด๊ฒƒ์„ ํ†ตํ•ด ๋ฐฐ์šด ์ง€์‹๋“ค์ด ๋” ๊ฐ’์ง„ ๊ฒƒ ๊ฐ™๋‹ค.

ํฌ์ŠคํŒ…์— ์‚ฌ์šฉ๋œ ์˜ˆ์ œ ์ฝ”๋“œ๋Š” ๊นƒํ—ˆ๋ธŒ์— ๋ชจ๋‘ ์˜ฌ๋ ค๋‘์—ˆ์Šต๋‹ˆ๋‹ค. (https://github.com/huewilliams/velog-examples/tree/main/fileAndJson)
๋๊นŒ์ง€ ์ฝ์–ด์ฃผ์‹  ๋ถ„๋“ค ๊ฐ์‚ฌํ•ฉ๋‹ˆ๋‹ค!! ๐Ÿฅณ๐Ÿฅณ

์ฐธ๊ณ ์ž๋ฃŒ

2๊ฐœ์˜ ๋Œ“๊ธ€

comment-user-thumbnail
2022๋…„ 5์›” 31์ผ

์˜ค ์ €๋Š” ์•ˆ๋˜๋Š”์ค„๋งŒ ์•Œ์•˜๋Š”๋ฐ ์ด๋Ÿฐ ๋ฐฉ๋ฒ•์ด ์žˆ์—ˆ๊ตฐ์š”!

๋‹ต๊ธ€ ๋‹ฌ๊ธฐ
comment-user-thumbnail
2023๋…„ 9์›” 10์ผ

์ •๋ง ๋งŽ์ด ๋„์›€๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ๊ฐ์‚ฌํ•ฉ๋‹ˆ๋‹ค.

๋‹ต๊ธ€ ๋‹ฌ๊ธฐ