웹 개발을 하면서 응답으로 데이터를 보낼 때, 여태까지 해 왔던 작업은 사용자의 요청에 따라 비즈니스 로직을 처리하고, 그 결과에 따라 사용자에게 다른 결과를 보여주는 작업이었다. 반면 저희가 사용자에게 보여 주고 싶은 데이터 중 사용자에게 별다른 변환 과정 없이 보여 주고 싶은 데이터가 있을 수 있습니다. 대표적으로 이미지 파일, CSS 파일 등이 있을 것이다. 웹 개발에서 이런 변환 없이 그대로 사용자에게 전달되어야 하는 파일들을 정적 파일이라고 부른다.
Static File
사용자에게 변환 없이 전달되는 파일이다.
- CSS
- 이미지, 영상 파일
- 몇몇 HTML
새로운 Project를 추가하고 static 폴더에 css 파일을 넣은 뒤 build를 해 보자.
이후 GET 요청을 하면 static 폴더 안에 해당하는 파일이 있다면 그대로 돌려 준다. localhost:8080 뒤에 있는 경로를 static 파일 밑에서 찾기 시작하고, 해당하는 파일이 있다면 해당 파일을 찾아서 반환해 주는 것이다.
url을 localhost:8080/static 으로 썼을 때 파일을 찾고 싶다면 application.yaml 설정을 바꾸면 된다.
spring:
mvc:
static-path-pattern: /static/**
Spring MVC에서 CRUD를 할 때 HTML의 form
이라는 요소를 사용했다. form
은 내부에 input
요소를 여러 개 주고 submit
버튼을 통해 form
요소의 내부 데이터를 수합한 뒤 HTTP 요청을 보내는 것이다.
multipart/form-data
는 요청을 여러 부분으로 구분해서 전송하는 형태이다. 텍스트와 파일이 혼합된 요청이라는 의미이다. form을 이용해 파일을 보낼 경우 선택해야 하는 방식이다. HTTP 요청으로 주고받는 데이터의 형식 중 하나라고 생각하면 된다.
확인해 보기 위해 form.html을 작성한다.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<form
action="/multipart"
method="post"
enctype="multipart/form-data">
<input type="text" name="name">
<input type="file" name="photo">
<input type="submit">
</form>
</body>
</html>
이후 input 값을 받아 오기 위해 Controlle
r를 만들어 준다. 이때 @RequestMapping
의 인자로 consumes
와 produces
가 있다. comsumes
는 클라이언트로부터 데이터를 어떤 형태로 받을 것인지를 명시해 주는 것이다. 반대로 produces는 어떤 데이터 형태로 반환할 것인지를 말해 주는 것이다. 성공 여부를 보기 위해 간단한 Dto
와 Controller
를 만들어 준다.
import lombok.Data;
@Data
public class ResponseDto {
private String message;
}
@Slf4j
@RestController
public class FileController {
@PostMapping(value = "/multipart",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseDto miltipart(
@RequestParam("name") String name,
@RequestParam("photo")MultipartFile multipartFile
) {
ResponseDto response = new ResponseDto();
response.setMessage("success");
return response;
}
}
프로젝트에 파일을 업로드해 보자. Path
변수를 추가해 준다. Path
는 컴퓨터에서 파일 경로를 관리하기 위해 만들어진 인터페이스이다.
@Slf4j
@RestController
public class FileController {
@PostMapping(value = "/multipart",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseDto miltipart(
@RequestParam("name") String name,
@RequestParam("photo")MultipartFile multipartFile
) throws IOException {
// 컴퓨터에서 파일 경로를 관리하기 위해 만들어진 인터페이스
**Path uploadTo = Path.of("filename.png");
multipartFile.transferTo(uploadTo);**
ResponseDto response = new ResponseDto();
response.setMessage("success");
return response;
}
}
Postman으로 요청을 보내면 파일이 제대로 전송되는 것을 볼 수 있다.
만약 파일이 커서 제대로 업로드되지 않으면, yaml파일을 수정하면 된다.
spring:
mvc:
static-path-pattern: /static/**
servlet:
multipart:
**max-file-size: 10MB
max-request-size: 10MB**
똑같은 기능을 Path와 OutputStream을 이용해 byteArray로 전달할 수도 있다.
@Slf4j
@RestController
public class FileController {
@PostMapping(value = "/multipart",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseDto miltipart(
@RequestParam("name") String name,
@RequestParam("photo")MultipartFile multipartFile
) throws IOException {
// 넘겨 받은 파일을 바이트 어레이로 사용할 수 있음
**File file = new File("./filename.png");
try (OutputStream outputStream = new FileOutputStream(file)){
byte[] fileBytes = multipartFile.getBytes();
// 여기에서 byte[]를 활용
outputStream.write(fileBytes);
}**
ResponseDto response = new ResponseDto();
response.setMessage("success");
return response;
}
}
반대로 사용자가 서버의 파일에 유연하게 접근할 수 있는 방법은 무엇일까? 올라간 파일도 정적 파일의 일부라는 점을 기억하고 url로 접근할 수 있도록 한다. multi part의 접근 방식에 대해 정리해 보면 다음과 같다.
폴더를 생성해서 업로드하도록 코드를 수정해 준다.
@Slf4j
@RestController
public class FileController {
@PostMapping(value = "/multipart",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseDto miltipart(
@RequestParam("name") String name,
@RequestParam("photo")MultipartFile multipartFile
) throws IOException {
// 저장할 파일 경로 생성
Files.createDirectories(Path.of("media"));
// 저장할 파일 이름을 포함한 경로를 작성한다.
**Path uploadTo = Path.of("media/filename.png");
multipartFile.transferTo(uploadTo);**
ResponseDto response = new ResponseDto();
response.setMessage("success");
return response;
}
}
사용자가 업로드한 데이터는, 저희가 저장한 이후 다시 사용자에게 보내줄 때, 정적 파일의 형태로 응답하여야 한다. 그렇다면 사용자가 업로드한 파일을 전부 한 경로에 모아둔 뒤, 해당 경로에 존재하는 파일을 정적 파일의 형태로 전송하는 방법을 택할 수 있다. 이를 위해서 application.yaml
을 수정해 준다.
spring:
web:
resources:
static-locations: file:media/,classpath:/static
위의 설정은 Spring Boot에서 응답할 정적 파일들을 어디에서 찾을지에 대한 설정이며, ,
로 두 경로가 구분되어 있는데,
file:media/
: 현재 실행중인 경로의 media
라는 폴더classpath:/static
: 빌드된 어플리케이션의 클래스패스의 /static
경로 (즉, resources/static
)을 의미합니다. 여기에 앞서 설정해 둔 spring.mvc.static-path-pattern
설정과 합쳐지면, 사용자가 /static/<정적 파일 경로>
로 요청을 보내게 될 때 작성한 순서대로 media
폴더 내부, static
폴더 내부에서 <정적 파일 경로>
파일을 응답받게 된다.
Postman으로 확인했을 때 업로드한 파일에 잘 접근할 수 있음을 확인할 수 있다.
이때 파일의 이름이 중복되지 않게 하기 위해 업로드 시각을 기준으로 파일 이름을 formating할 수 있다.
@Slf4j
@RestController
public class FileController {
@PostMapping(value = "/multipart",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseDto miltipart(
@RequestParam("name") String name,
@RequestParam("photo")MultipartFile multipartFile
) throws IOException {
// 저장할 파일 경로 생성
Files.createDirectories(Path.of("media"));
// 저장할 파일 이름을 포함한 경로를 작성한다.
**LocalDateTime now = LocalDateTime.now();
log.info(now.toString());
Path uploadTo = Path.of(String.format("media/%s.png", now));
multipartFile.transferTo(uploadTo);**
ResponseDto response = new ResponseDto();
response.setMessage("success");
return response;
}
}
이제 사용자가 어느 링크로 접속해야 파일을 볼 수 있는지 ResponseDto에 넣어서 응답해 주고자 한다.
@Slf4j
@RestController
public class FileController {
@PostMapping(value = "/multipart",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseDto miltipart(
@RequestParam("name") String name,
@RequestParam("photo")MultipartFile multipartFile
) throws IOException {
// 저장할 파일 경로 생성
Files.createDirectories(Path.of("media"));
// 저장할 파일 이름을 포함한 경로를 작성한다.
LocalDateTime now = LocalDateTime.now();
log.info(now.toString());
Path uploadTo = Path.of(String.format("media/%s.png", now));
multipartFile.transferTo(uploadTo);
****
ResponseDto response = new ResponseDto();
response.setMessage(**String.format("media/%s.png", now)**);
return response;
}
}
/media/{userId}/profile.{파일확장자}
// updateUserAvatar
public UserDto updateUserAvatar(Long id, MultipartFile avatarImage) {
// 2. 사용자가 프로필 이미지를 업로드한다.
// TODO 유저 존재 확인
Optional<UserEntity> optionalUser = repository.findById(id);
if(optionalUser.isEmpty())
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
// TODO 파일을 어디에 업로드할 건지 => /media/{userId}/profile.{파일확장자}
String profileDir = String.format("media/%d/", id);
try {
Files.createDirectories(Path.of(profileDir));
} catch (IOException exception) {
log.error(exception.getMessage());
throw new ResponseStatusException(HttpStatus.NOT_IMPLEMENTED);
}
// TODO 확장자를 포함한 이미지 이름 만들기 (profile.{확장자})
String originalFilename = avatarImage.getOriginalFilename();
// profile.png -> [profile, png]s
String[] filenameSplit = originalFilename.split("\\.");
String extension = filenameSplit[filenameSplit.length - 1];
String profileFilename = "profile." + extension;
log.info(profileFilename);
// TODO 폴더와 파일 경로를 포함한 이름 만들기
String profilePath = profileDir + profileFilename;
// TODO 업로드
try {
avatarImage.transferTo(Path.of(profilePath));
} catch (IOException exception) {
log.error(exception.getMessage());
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR);
}
// TODO User Entity Update
UserEntity userEntity = optionalUser.get();
userEntity.setAvatar(String.format("/static/%d/%s", id, profileFilename));
return UserDto.fromEntity(repository.save(userEntity));
}
이때 static/
과 /static/
은 다른 경로이다. 전자는 절대 경로이고 후자는 상대 경로이다.
이제 컨트롤러와 연결해 준다.
// PUT /user/{id}/avatar
// 사용자 프로필 이미지 설정
@PutMapping(
value = "/{id}/avatar",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE
)
public UserDto avatar(
@PathVariable("id") Long id,
@RequestParam("image") MultipartFile avatarImage
) throws IOException {
return service.updateUserAvatar(id, avatarImage);
}
Postman으로 확인했을 때 링크가 잘 나오는 것을 확인할 수 있다.
// createUser
public UserDto createUser(UserDto dto) {
// 회원가입 => 프로필 이미지가 아직 필요 없다
if (repository.existsByUsername(dto.getUsername())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
}
UserEntity userEntity = new UserEntity();
userEntity.setId(dto.getId());
userEntity.setUsername(dto.getUsername());
userEntity.setEmail(dto.getEmail());
userEntity.setPhone(dto.getPhone());
userEntity.setBio(dto.getBio());
userEntity.setAvatar(null);
return UserDto.fromEntity(repository.save(userEntity));
}
// readUserByUsername
public UserDto readUserByUsername(String username) {
Optional<UserEntity> userEntity = repository.findByUsername(username);
if (userEntity.isEmpty())
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
return UserDto.fromEntity(userEntity.get());
}
// updateUser
public UserDto updateUser(Long id, UserDto dto) {
// update user로 사용자 이름은 업데이트할 수 없음
Optional<UserEntity> optionalUser = repository.findById(id);
if (optionalUser.isEmpty())
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
UserEntity userEntity = optionalUser.get();
userEntity.setEmail(dto.getEmail());
userEntity.setBio(dto.getBio());
userEntity.setEmail(dto.getEmail());
return UserDto.fromEntity(repository.save(userEntity));
}
서비스의 정의에 따라 수정할 수 있는 부분과 수정할 수 없는 부분으로 나눌 수 있다.
정해진 HTTP 응답 말고도 특정한 예외 상황에 대해 내가 처리하고 싶은 동작을 명시할 때 사용하는 Annotation이다. Normal Flow를 유지하면서 상황을 전부 컨트롤하기 위해서는 예외 처리가 매우 중요하다. 에러 상황에 대한 특정한 응답이라고 생각하면 된다.
ExceptionHandler
Controller 내부에서 지정된 예외가 발생됐을 때 실행하는 메소드에 붙이는 Annotation
@ExceptionHandler(IllegalStateException.class)
public void handleIllegalState(IllegalStateException exception) {
log.error(exception.getMessage());
}
이후 서비스에서 Error 전송을 바꿔 주면 된다.
// createUser
public UserDto createUser(UserDto dto) {
// 회원가입 => 프로필 이미지가 아직 필요 없다
if (repository.existsByUsername(dto.getUsername())) {
**throw new IllegalStateException();**
}
UserEntity userEntity = new UserEntity();
userEntity.setId(dto.getId());
userEntity.setUsername(dto.getUsername());
userEntity.setEmail(dto.getEmail());
userEntity.setPhone(dto.getPhone());
userEntity.setBio(dto.getBio());
userEntity.setAvatar(null);
return UserDto.fromEntity(repository.save(userEntity));
}
컨트롤러에 정의한 오류 처리는 해당 컨트롤러에서만 작동한다. 앱 전체에 작용하는 에러 핸들러를 만들기 위해서는 RestControllerAdvice를 사용할 수 있다. 각 Controller에 나누어진 ExceptionHandler 메소드를 모으는 역할을 한다.
@Slf4j
@RestControllerAdvice
// 각 Controller에 나누어진 ExceptionHandler 메소드를 모으는 역ㄸ
public class UserControllerAdvice {
@ExceptionHandler(IllegalStateException.class)
public ResponseDto handleIllegalState(IllegalStateException exception) {
ResponseDto response = new ResponseDto();
response.setMessage("UserControllerAdvice에서 처리한 예외입니다.");
return response;
}
}
내가 원하는 예외를 만들고 싶다는 생각이 들 때도 있다. 이 기능을 구현해 보자. 앞서 구현한 서비스를 살펴보면, NOT_FOUND
, BAD_REQUEST
에러가 발생하는 경우가 있다. 이 경우에 대한 에러를 만들어 보자.
400 에러가 발생하는 경우를 정리해 보자.
이 케이스마다 에러 문구를 다 다르게 띄우기 위해 추상 클래스로 정의한다.
public class UsernameExistException extends Status400Exception{
public UsernameExistException() {
super("username not unique");
}
}
이렇게 하나하나 에러를 새로 정의해서 발생시킬 수 있다. 이 과정이 귀찮기도 하지만 만약 같은 에러가 비슷하게 자주 발생한다면 같은 코드를 반복해서 작성할 필요 없이 예외 클래스의 객체만 생성해 주면 된다. 예외 상황을 조금 더 체계적으로 관리할 수 있게 되는 것이다.