일반적으로 사용하는 HTML Form을 통한 파일 업로드를 이해하려면...
먼저 '폼을 전송하는' 다음 두 가지 방식의 차이를 이해해야 한다.
application/x-www-form-urlencodedmultipart/form-data
- HTML 폼 데이터를 서버로 전송하는 가장 기본적인 방법
Form 태그에 별도의 'enctype옵션'이없으면, 웹 브라우저는 '요청 HTTP 메시지의 헤더'에 다음 내용을 추가한다.
->Content-Type: application/x-www-form-urlencoded- 이런 형태로 전송(요청) _ HTTP Body에 '문자'로 담아서..
->username=kim&age=20
->&으로 구분한다.
[예시 사진]

- 이름, 나이 => '문자'
- 첨부파일 => '바이너리 문자'
=> 🤪(문제) 문자, 바이너리 문자를 동시에 전송해야 한다.
앞의 2번 문제를 해결하고자 HTTP에서는 이 전송 방식을 제공한다.

enctype="multipart/form-data" 를 지정해야 한다.multipart 이다.Content-Dispositionusername, age, file1이 각각 분리되어 있다.'파일 이름'과 'Content-Type'이 추가되고 -> (바이너리 데이터가 전송).multipart/form-data 는 application/x-www-form-urlencoded 와 비교하면 매우 복잡하고 각각의 부분(Part)로 나누어져 있다.@Slf4j
@Controller
@RequestMapping("/servlet/v1")
public class ServletUploadControllerV1 {
@GetMapping("/upload")
public String newFile() {
return "upload-form";
}
@PostMapping("/upload")
public String saveFileV1(HttpServletRequest request) throws ServletException, IOException {
log.info("request={}", request);
String itemName = request.getParameter("itemName");
log.info("itemName={}", itemName);
//HTTP 메시지에서 ----XXX 별로 나뉘어져 있던 것 하나하나가 다 parts 들임
//각각 받아들일 수 있음
Collection<Part> parts = request.getParts();
log.info("parts={}", parts);
return "upload-form";
}
}
request.getParts() :multipart/form-data 전송 방식에서 각각 나누어진 부분을 받아서 확인할 수 있다.
enctype="multipart/form-data" 를 지정해야 한다.테스트 하기 전에 application.properties에 추가해주자.
logging.level.org.apache.coyote.http11=debug
이 로그를 통해 multipart/form-data 방식으로 전송된 것이 확인 가능하다.
------WebKitFormBoundarygtjoK4iYwVYZtyDj
Content-Disposition: form-data; name="itemName"
aaaaa
------WebKitFormBoundarygtjoK4iYwVYZtyDj
Content-Disposition: form-data; name="file"; filename="ìì§1.jpg"
Content-Type: image/jpeg
ÿØÿà JFIF H H ÿáèExif MM * b j( 1 r2 i È H H Adobe Photoshop 7.0 2020:11:23 23:09:15 ÿÿ ( & º H H ÿØÿà JFIF H H [중략]
ÿ Rþ*XßK©Yâê[>@Z?øÓkLÂxÖÞA>°ÇýJ¥þ+³±ª9ø¶ØÚì³Ó}aÄàßQ¶mÜ$¦·øÑkGYÅpã>0û°Oø¯ÏèÜðõqøËÌÆÈët×E°Ñ@m
¤\÷ìÓó¶«¹â»5Ç/oÃÖǯþ©)¡þ.±±ò>°dTËÚæ¶Æ àYß½]kÀÑÀ ^Yþ-?ñ: [false], parsingRequestLine: [false], RI$¤I%)$ItÏü)Gþ{bàÿ ÆgTËTgM-Ū¶¼Ö=Äïýí¿½¸X8ølq{qªe-qä4W¸ÿ ¼óüeôÆõuV0¿ÊÛ[Þì{gGþë\ßÎIM^
þ/²z¿L¯¨¶c¶íƺËÌ5Æ¿{·3ní»? u{M¾V®a{iíVøú[ü`ft_Oû-w²Â§q/èíÞç,FÓÔ~°õ{E^¦V]±Í`ö·q\2¦~óK׫þÓÓ:F@.²<72§ÿ oñUý¨ÿ ^¯ÉbøÊ ctÎCëú¬©ÁüUGê?׫òXþ5£ôïëÛù+\ßLÿ ÄwZÿ Åÿ ªré?Ưô~ý{%kéøë_ñØ¿õNIMo«Y9öæáâ3Ô¾ìW5$ú¤âÖýn&UØ·@·îªÀÌ%ýf®üZâßø^ÏúªÖ'Öù©ÿ á»ÿ óãÒJ¿ø¡Ëÿ ÿ çº×¬ý^ÿ :gþ£ÿ =±y7ÖßüPåÿ Öÿ l½õÔï æx¬«XúhËs2q10+me͹ÀÖovç}=o}rÿ ÄÞgýoÿ >Ô¼÷¥bàdä:¼ü¯±Ô\Û6Ë¥£fÑüäsÎQÍÔÒYð_æ÷ê9%Àg9B<8åú¼Qþ´)$IJI$RI$¤I%)$IJI$RI$¤I%)^TUä¤I%)$IJ\gøÕÿ Äö?þgþzÈ]ã?Ưþ'±ÿ ðã?óÖBJ|$IJI$SÿÕãRI$÷ßâúWQÿ «òؽ%y·ø¦þÔâêü¶/IIJI$Rïþiß/ÊóNù~TÕI$RI$¤I%)$IJI$RI$¤I%)$IJI$RI$¤I%)$IJI$RI$ù?^½ÛuqfØÿ 5%4ÿ ÅÝ;'"Ú+²æ_µ¶½sØÃµ¯pÜÞVÿ ×oüKgÿ Q¿õlUþ£ô,þÓò1óEÝê7cÚÖÿ ßU®ßøêÔoý[Sçÿ âóÿ 8ßÔ·þ¡Ë×ÿ ÏüTãRßú/\IO}vÿ ÅOPþ»ê¨gau<l|KsC
5ïÄ.xp,öýµÎôùoÒWþ»â§¨]¿õW>¶È¿W?ð¡ÿ ÑI)êÅüÿ ßÿ ñÓ~¹Óðqð±j³0¼ÙcL8VͲÀïÍõbñ_ÿ dá·ÿ ç¼uþ5¤tïê[ùkIO5Ó¾guÕ©{8[Úâw;c}[6CK}µñwÖò±ºÅ}1Ï.Ä˳¨kÃ]c^ÏÝÝ·cÕü@u¿ý
h
Ê^ã
^&-bªj¬k|zGIÂèø5àá3eU{¸þûÔ¤I%)$IJI$RI$¥W¨ô̧ìlê[}NÕ?$(úÑþ*oÇÝÐn«RqôÇü[¿=yíÔ]k©½®Æs ü
úeb}`úÑzýDeÒæä3Gÿ +I$RI$ÿ ÿÕãRI$÷ßâúWQÿ «òؽ%y·ø¦þÔâêü¶/IIJI$Rïþiß/ÊóNù~TÕI$RI$¤I%)$IJI$RI$¤I%)$IJI$RI$¤I%)$IJI$RI$¤<q¨~EïÕX.{ÝÀIïelsÞCXÑ.qà ¼oü`ýy³¬^îòÞS¡îz®÷ÄÒúóõË#ëi¢têEUþñá
®åÛ#äþû¿Õá}K¨åõ<Ë3slºÓ$ßÉj?\ëy½o>ÌìÇn{µ½ÞÌjÏIJ[ÿ Sþ©å}d϶YQ"ïûþ[_«W³~°uaã4ìºÞÌowîý¢átN^v±Üîîwç=É)7NéØ3¼,6
é¨C@ÿ ª*ÒI$¥$Ìêÿ X0:CéfVòo6 `6=Ï7÷EA~,Y2ÌCLæn£ôõ:i*}{¤fÀÇÊÎ<1Çc¿Ì³kôPAòVLsÆxrBPiÿ 9I$+I$¥$I)I$JRå~¿fzx4a´û¯y{òX?òo]=Ö²u[KÜ|79pýjéÝDÖ:kmFÙS{Gæ»ÿ Pó<&\&{[§ð~_$³ñÄrùF<|Ràá÷%.¿ÕcÐC²W5ËÃ[Sliåîõ#ônú?¶íú¡õ<oéÙ^<
ö÷{¿ò+a$RI$¥õ¡tìúë+v5N;Øè[ºÏ àæ-¥õÓ7ìÝõÉp¨xÇÓýmLËÃÁ#!`mréæ±G'ñG÷OÏÿ 5àºv!ÍÏÇÅá¬kO'ܲÕëMkZÐÖkD ; ¸¨~·U~IÜZÉùoö7þª»õ)ïú.übÏÇÌÃ:bïåõÐàR¡o]ée»
ì¦UÈÜ×Ë@¹¿¤pôþ¿}_\?Ö﫵㺰É.7YüË9yú,{Oæ7ù
|×ûrø3æö³ÎXøÇ.|YIÒý2ô½³Ë[ÚxsLó
qÝüÛ¿©þyRé]C¥ä»>Qk{8h|ÚïÎ_I*=W¢ôίA£¨PÛDG¸Qÿ SµÎcK\5hBï>´Üì ü®N^0i?Î4y¤\¾·ìicÚaÍp))í¾¬þ§Ó6cu9ÍÄn?£Çèáù¿Êq·ñ>¹åRÿ ×1)È êðÑ[ÿ Îhsð5³oÖÞÔð.Ä6»ÛØkßcw4nÐû«þJæ.ê]J§z]S¹vÊ®,ðþ|zYø"¥n¥®Å¡Øç_Q
þ£éîk^ßí¾ÄyÄÅÅýY
üõo1q>¢àüLææ9óKÞÁ
¿ù,bÚú÷ßµãtúô¯¹Ú;hÑýØÕZ eÊ4¿LÂv9^9%Èò3>ç¶=ÜÚëÁâåNGÖ>»YM¶Üñî-kö1¢~,¹ëWBsl¶Ë«a:;©Y>Õõÿ º_¨ø_gèç!ÂòéþKFÏÈõÖ>Ïû+,äê
O.È{cù[¾8 ÇÆg!28®Úþ+sg/|¼eìðú¥ú2áýúgÕ¯¬íêÀãd4WÁ:}ùÌý×~óúòÿ «-µÝ{Uô
ªõ?ð=ËÔ¼¶INÁ«h|kÅËs bÒ9#îp~æ¼:UI$Ëp3~¥ô\uM~3Ïzݤÿ RÍÿ ôVgÔ.¡T»æd4p×Mnÿ ¿3þîÒQKÇ/ѯîèè`øÇ;AÎ?»õó¯þ{æ¶Ùõ¥³ÓÊe¾å´]W-«üÄ·t¬2ð}'ð,ÿ À-õjÿ 7Ó^¡Ê¡Aè÷Ú۬įÔihØdq»ÓÙ¿ûj)rÒýØí1mü_ÂuËËrýþZ^ßþ´}ó§6xìÄé)sxøÕËøÝûÎÝ·óÜãù«Ïz§PÌúÁÕ[éÿ ü¾oý½gþ^J|ãþiÿ ÿ ü¾oý½gþKþiÿ ÿ ü¾oý½gþ^J|ãþiÿ ÿ ü¾oý½gþKþiÿ ÿ ü¾oý½gþ^J|ãþiÿ ÿ ü¾oý½gþKþiÿ ÿ ü¾oý½gþ^]
Iú¯Ôú¦OQ©»11¹ö»Ü¯÷:JR꾦ýCËúÈNE¯û>:È8þím\ª÷?ñjúõSWÙø¤¦×Iúõo¥0
w]ôí±í]zI)å~²¾Ö«ßKSD6Ú¬bò¬T:×@´ºK¨fC5a÷ÕôøôdÔêocm©âÇ |
¿§õ+},!õXuÜÝ\
ïñ[õfÎÑN^S6eçCÈ<~c\»dÀ ht¤I%)$IJI$RI$¤I%)$IJI$RI$¤I%)$IJI$RI$¤I%)$IOtÿ ,Ù_øc/þ¢Õì+Ǻÿ l¯ü1ÿ Qjö¤I%)$IM>«öüqíM¹cúlþÍX-[org.apache.tomcat.util.net.NioEndpoint$NioSocketWrapper@3b6531e2:org.apache.tomcat.util.net.NioChannel@7d4ef57e:java.nio.channels.SocketChannel[connected local=/0:0:0:0:0:0:0:1:8080 remote=/0:0:0:0:0:0:0:1:10956]], Status in: [OPEN_READ], State out: [OPEN]
------WebKitFormBoundarygtjoK4iYwVYZtyDj--
spring.servlet.multipart.max-file-size=1MBspring.servlet.multipart.max-request-size=10MB
- 큰 파일을 무제한 업로드하게 둘 수는 없으므로 업로드 사이즈를 제한할 수 있다.
-> 사이즈를 넘으면 예외(SizeLimitExceededException)가 발생max-file-size
: 파일 하나의 최대 사이즈, 기본 1MBmax-request-size
: 멀티파트 요청 하나에 여러 파일을 업로드 할 수 있는데, 그 전체 합이다. (기본 10MB)

-> 그래서 결과 로그를 보면
- request.getParameter("itemName")
- request.getParts()의 결과가 비어있다.
request.getParts() 에도 요청한 두 가지 멀티파트의 부분 데이터가 포함된 것을 확인할 수 있다.

HttpServletRequest 객체가RequestFacade ⇨ StandardMultipartHttpServletRequest 로 변한 것을 확인할 수 있다참고
spring.servlet.multipart.enabled옵션을 켜면 스프링의DispatcherServlet에서 '멀티파트 리졸버'( MultipartResolver )를 실행한다.
'멀티파트 리졸버'는 멀티파트 요청인 경우 서블릿 컨테이너가 전달하는 일반적인
HttpServletRequest를MultipartHttpServletRequest로 변환해서 반환한다.
MultipartHttpServletRequest는HttpServletRequest의 자.식. 인터페이스이고, 멀티파트와 관련된 추가 기능을 제공한다.스프링이 제공하는 기본 멀티파트 리졸버는
MultipartHttpServletRequest인터페이스를 구현한StandardMultipartHttpServletRequest를 반환한다.이제 컨트롤러에서 HttpServletRequest 대신에
MultipartHttpServletRequest를 주입받을 수 있는데
-> 이것을 사용하면 멀티파트와 관련된 여러가지 처리를 편리하게 할 수 있다.
=> (결론) 그.러.나. 뒷부분의 강의에서 설명할MultipartFile이라는 것을 사용하는 것이 더 편해서MultipartHttpServletRequest를 잘 사용하지는 않는다.
서블릿이 제공하는 Part 에 대해 알아보고 실제 파일도 서버에 업로드 해보자
application.properties
file.dir=파일 업로드 경로 설정
ex) file.dir=C:/Users/jmg98/Desktop/spring-study/imagege/
주의
1. 꼭 실제 폴더를 해당 경로에 만들어 두어야 함
2. 설정 시, 마지막에/는 꼭 달아야 한다.
@Slf4j
@Controller
@RequestMapping("/servlet/v2")
public class ServletUploadControllerV2 {
@Value("${file.dir}")
private String fileDir;
@GetMapping("/upload")
public String newFile() {
return "upload-form";
}
@PostMapping("/upload")
public String saveFileV1(HttpServletRequest request) throws ServletException, IOException {
log.info("request={}", request);
String itemName = request.getParameter("itemName");
log.info("itemName={}", itemName);
//HTTP 메시지에서 ----XXX 별로 나뉘어져 있던 것 하나하나가 다 parts 들임
//각각 받아들일 수 있음
Collection<Part> parts = request.getParts();
log.info("parts={}", parts);
for (Part part : parts) {
log.info("==== PART ====");
log.info("name={}", part.getName());
Collection<String> headerNames = part.getHeaderNames();
for (String headerName : headerNames) {
log.info("header {}: {}", headerName,
part.getHeader(headerName));
}
//편의 메서드
//content-disposition; filename
log.info("submittedFileName={}", part.getSubmittedFileName());
log.info("size={}", part.getSize()); //part body size
//데이터 읽기
InputStream inputStream = part.getInputStream();
//받아온 바이너리 부분을 String으로 변환
String body = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
log.info("body={}", body);
//파일에 저장하기
if (StringUtils.hasText(part.getSubmittedFileName())) { //실제 파일이 있나?
String fullPath = fileDir + part.getSubmittedFileName();
log.info("파일 저장 fullPath={}", fullPath);
part.write(fullPath); //해당경로에 저장
}
}
return "upload-form";
}
}
@Value("${file.dir}") 로 application.properties에 설정한 값을 주입.parts 에는 이렇게 나누어진 데이터가 각각 담긴다.Part는 멀티파트 형식을 편리하게 읽을 수 있는 다양한 메서드를 제공
해당 폴더에 실제로 제출한 파일이 저장되어 들어가있다.
part.getSubmittedFileName() : 클라이언트가 전달한 파일명part.getInputStream(): Part의 전송 데이터를 읽을 수 있다.part.write(...) : Part를 통해 전송된 데이터를 저장할 수 있다.참고
- 큰 용량의 파일을 업로드를 테스트 할 때는 로그가 너무 많이 남아서 다음 옵션을 끄는 것이 좋다.
logging.level.org.apache.coyote.http11=debug- 다음 부분도 파일의 바이너리 데이터를 모두 출력하므로 끄는 것이 좋다.
log.info("body={}", body);
서블릿이 제공하는 Part 는 편하기는 하지만,
1. HttpServletRequest 를 사용해야 됨
2. 추가로 파일 부분만 구분하려면 여러가지 코드를 넣어야 한다.
이번에는 스프링이 이 부분을 얼마나 편리하게 제공하는지 확인해보자.
스프링은 MultipartFile 이라는 인터페이스로 멀티파트 파일을 매우 편리하게 지원한다.
@Slf4j
@Controller
@RequestMapping("/spring")
public class SpringUploadController {
@Value("${file.dir}")
private String fileDir;
@GetMapping("/upload")
public String newFile() {
return "upload-form";
}
@PostMapping("/upload")
public String saveFile(@RequestParam String itemName,
@RequestParam MultipartFile file,
HttpServletRequest request) throws IOException {
// 굳이 안 넣어도 됨.
log.info("request={}", request);
log.info("itemName={}", itemName);
log.info("multipartFile={}", file);
if (!file.isEmpty()) {
String fullPath = fileDir + file.getOriginalFilename();
log.info("파일 저장 fullPath={}", fullPath);
file.transferTo(new File(fullPath));
}
return "upload-form";
}
}
앞에 부분과 다르게 필요한 부분의 코드만 작성하면 된다
@RequestParam MultipartFile file@RequestParam 을 적용하면 된다.@ModelAttribute 에서도 MultipartFile 을 동일하게 사용할 수 있다MultipartFile 주요 메서드file.getOriginalFilename() : 업로드 파일 명file.transferTo(...) : 파일 저장
@Data
public class Item {
private Long id;
private String itemName;
private UploadFile attachFile;
private List<UploadFile> imageFiles;
}
@Data
public class UploadFile {
//고객이 업로드한 파일명
private String uploadFileName;
//서버 내부에서 관리하는 파일명 -> 파일이름이 겹치지 않도록 관리하는
private String storeFileName;
public UploadFile(String uploadFileName, String storeFileName) {
this.uploadFileName = uploadFileName;
this.storeFileName = storeFileName;
}
}
uploadFileName: 고객이 업로드한 파일명
storeFileName: 서버 내부에서 관리하는 파일명 (uuid)
- (왜 이렇게 분리를 해놓느냐?)
=> 왜냐하면 서로 다른 고객이 '같은 파일이름'을 업로드 하는 경우 기존 파일 이름과 충돌이 날 수 있다.
-> 그러니 서버에서는 저장할 파일명이겹치지 않도록내부에서 관리하는 별도의 파일명이 필요
@Component
public class FileStore {
@Value("${file.dir}")
private String fileDir;
public String getFullPath(String filename) {
return fileDir + filename;
}
//다중 파일의 경우
public List<UploadFile> storeFiles(List<MultipartFile> multipartFiles) throws IOException {
List<UploadFile> storeFileResult = new ArrayList<>();
for (MultipartFile multipartFile : multipartFiles) {
if (!multipartFile.isEmpty()) {
storeFileResult.add(storeFile(multipartFile));
}
}
return storeFileResult;
}
//파일 한 개의 경우
public UploadFile storeFile(MultipartFile multipartFile) throws IOException {
if (multipartFile.isEmpty()) {
return null;
}
String originalFilename = multipartFile.getOriginalFilename();//본래 파일명
String storeFileName = createStoreFileName(originalFilename); //uuid 파일명
//저장
multipartFile.transferTo(new File(getFullPath(storeFileName)));
return new UploadFile(originalFilename, storeFileName);
}
//서버 내부에ㅓㅅ 관리하는 파일명 uuid를 통해 충돌 안 나게 함
private String createStoreFileName(String originalFilename) {
String ext = extractExt(originalFilename);
String uuid = UUID.randomUUID().toString();
return uuid + "." + ext;
}
//확장자를 별도로 추출 -> 서버에서 관리하는 파일명에도 붙여줌
private String extractExt(String originalFilename) {
int pos = originalFilename.lastIndexOf(".");
return originalFilename.substring(pos + 1);
}
멀티파트 파일을 서버에 저장하는 역할을 담당한다.
createStoreFileName()extractExt()a.png 라는 이름으로 업로드 하면 51041c62-86e4-4274-801d-614a7d994edb.png 와 같이 저장한다.@Data
public class ItemForm {
private Long itemId;
private String itemName;
private List<MultipartFile> imageFiles;
private MultipartFile attachFile;
}
List<MultipartFile> imageFilesMultipartFile 를 사용했다.MultipartFile attachFile@ModelAttribute 에서 사용할 수 있다.
multiple="multiple"옵션로 인해 파일을 여러 개 받을 수 있다.
=> 이로인해 ItemForm에서 이 코드는 여러 이미지 파일을 받았다.
private List<MultipartFile> imageFiles;
@Slf4j
@Controller
@RequiredArgsConstructor
public class ItemController {
private final ItemRepository itemRepository;
private final FileStore fileStore;
@GetMapping("/items/new")
public String newItem(@ModelAttribute ItemForm form) {
return "item-form";
}
@PostMapping("/items/new")
public String saveItem(@ModelAttribute ItemForm form, RedirectAttributes redirectAttributes) throws IOException {
UploadFile attachFile = fileStore.storeFile(form.getAttachFile());
List<UploadFile> storeImageFiles = fileStore.storeFiles(form.getImageFiles());
//데이터베이스에 저장
Item item = new Item();
item.setItemName(form.getItemName());
item.setAttachFile(attachFile);
item.setImageFiles(storeImageFiles);
itemRepository.save(item);
redirectAttributes.addAttribute("itemId", item.getId());
return "redirect:/items/{itemId}";
}
@GetMapping("/items/{id}")
public String items(@PathVariable Long id, Model model) {
Item item = itemRepository.findById(id);
model.addAttribute("item", item);
return "item-view";
}
/**
* 이미지 다운 받는 방법
* */
//URI Resource가 직접 접근해서 찾아옴
//보안에 취약함 , but 단순하고 간단
@ResponseBody
@GetMapping("/images/{filename}")
public Resource downloadImage(@PathVariable String filename) throws MalformedURLException {
return new UrlResource("file:" + fileStore.getFullPath(filename));
}
@GetMapping("/items/new") @PostMapping("/items/new")return new UrlResource("file:" + fileStore.getFullPath(filename));
} - ```<img> ``` 태그로 이미지를 조회할 때 사용하며 ```UrlResource``` 로 **이미지 파일(uuid)을 읽어서** ```@ResponseBody``` 로 이미지 바이너리를 반환한다. //사진 눌렀을 때 바로 들어오는 법
@GetMapping("/attach/{itemId}")
public ResponseEntity<Resource> downloadAttach(@PathVariable Long itemId) throws MalformedURLException {
Item item = itemRepository.findById(itemId);
//서버에서 관리하는 이름, 사용자가 저장한 이름
String storeFileName = item.getAttachFile().getStoreFileName();
String uploadFileName = item.getAttachFile().getUploadFileName();
UrlResource resource = new UrlResource("file:" + fileStore.getFullPath(storeFileName));
log.info("uploadFileName={}", uploadFileName);
//파일명이 깨질 수 있어서 인코딩 해줌
String encodedUploadFileName = UriUtils.encode(uploadFileName, StandardCharsets.UTF_8);
//
String contentDisposition = "attachment; filename=\"" + encodedUploadFileName + "\"";
return ResponseEntity.ok()
//헤더 넣어야 함 이를 보고 첨부파일이란 것을 인식
.header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
.body(resource);
}
@GetMapping("/attach/{itemId}")Content-Disposition 헤더에 attachment; filename="업로드 파일명" 설정
- 첨부 파일은 링크로 걸어두었고, 이미지는
태그로
item.imageFiles에서imageFile로 하나씩 꺼내서 반복 출력해주었다.