Techit 10th 5th

Huisu·2023년 6월 23일
0

Techit

목록 보기
22/42
post-thumbnail

File System

Static File

웹 개발을 하면서 응답으로 데이터를 보낼 때, 여태까지 해 왔던 작업은 사용자의 요청에 따라 비즈니스 로직을 처리하고, 그 결과에 따라 사용자에게 다른 결과를 보여주는 작업이었다. 반면 저희가 사용자에게 보여 주고 싶은 데이터 중 사용자에게 별다른 변환 과정 없이 보여 주고 싶은 데이터가 있을 수 있습니다. 대표적으로 이미지 파일, 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/**

Multipart

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 값을 받아 오기 위해 Controller를 만들어 준다. 이때 @RequestMapping의 인자로 consumesproduces가 있다. comsumes는 클라이언트로부터 데이터를 어떤 형태로 받을 것인지를 명시해 주는 것이다. 반대로 produces는 어떤 데이터 형태로 반환할 것인지를 말해 주는 것이다. 성공 여부를 보기 위해 간단한 DtoController를 만들어 준다.

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;
    }
}

Server → Client File Download

반대로 사용자가 서버의 파일에 유연하게 접근할 수 있는 방법은 무엇일까? 올라간 파일도 정적 파일의 일부라는 점을 기억하고 url로 접근할 수 있도록 한다. multi part의 접근 방식에 대해 정리해 보면 다음과 같다.

  1. 사용자가 파일을 multi part로 보낸다.
  2. 서버가 multi part를 사용해 파일을 서버에 저장한다.
  3. 서버가 사용자에게 해당 파일에 도달할 수 있는 url을 응답한다.

폴더를 생성해서 업로드하도록 코드를 수정해 준다.

@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;
    }
}

Contents Skeleton

Scenario

  1. 회원가입 => 프로필 이미지가 아직 필요 없다.
  2. 사용자가 프로필 이미지를 업로드한다.

User Avatar Update

  1. 있는 유저인지 확인
  2. 파일을 어디에 업로드할 건지 → **/media/{userId}/profile.{파일확장자}
  3. 업로드 후 객체에 저장

    // 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으로 확인했을 때 링크가 잘 나오는 것을 확인할 수 있다.

Create User

		// 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));
    }

Read User By Username

		// 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());
    }

Update User

		// 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));
    }

서비스의 정의에 따라 수정할 수 있는 부분과 수정할 수 없는 부분으로 나눌 수 있다.

Exceptions

ExceptionHandler

정해진 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

컨트롤러에 정의한 오류 처리는 해당 컨트롤러에서만 작동한다. 앱 전체에 작용하는 에러 핸들러를 만들기 위해서는 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;
    }
}

Custom Exceptions

내가 원하는 예외를 만들고 싶다는 생각이 들 때도 있다. 이 기능을 구현해 보자. 앞서 구현한 서비스를 살펴보면, NOT_FOUND, BAD_REQUEST 에러가 발생하는 경우가 있다. 이 경우에 대한 에러를 만들어 보자.

400 에러가 발생하는 경우를 정리해 보자.

  1. username 중복
  2. email 오류
  3. phone 오류
  4. bio 의 길이 초과

이 케이스마다 에러 문구를 다 다르게 띄우기 위해 추상 클래스로 정의한다.

public class UsernameExistException extends Status400Exception{
    public UsernameExistException() {
        super("username not unique");
    }
}

이렇게 하나하나 에러를 새로 정의해서 발생시킬 수 있다. 이 과정이 귀찮기도 하지만 만약 같은 에러가 비슷하게 자주 발생한다면 같은 코드를 반복해서 작성할 필요 없이 예외 클래스의 객체만 생성해 주면 된다. 예외 상황을 조금 더 체계적으로 관리할 수 있게 되는 것이다.

0개의 댓글