AWS S3를 활용해서 회원의 프로필 사진을 구현할 것이다.
Amazon Simple Storage Service(Amazon S3)는 업계 최고 수준의 확장성, 데이터 가용성, 보안 및 성능을 제공하는 객체 스토리지 서비스입니다. 고객은 규모와 업종에 관계없이 원히는 양의 데이터를 저장하고 보호하여 데이터 레이크, 클라우드 네이티브 애플리케이션 및 모바일 앱과 같은 거의 모든 사용 사례를 지원할 수 있습니다. 비용 효율적인 스토리지 클래스와 사용이 쉬운 관리 기능을 통해 비용을 최적화하고, 데이터를 정리하고, 세분화된 액세스 제어를 구성하여 특정 비즈니스, 조직 및 규정 준수 요구 사항을 충족할 수 있습니다.
(출처:https://aws.amazon.com/ko/s3/)
쉽게 얘기해 AWS S3는 파일 입출력을 위한 클라우드 파일 저장소이다.
AWS S3를 프로젝트에 적용하기 까지 많은 우여곡절이 있었다.
먼저 aws s3를 사용하기 위해 계정을 만들고 버킷과 iam 계정등 세팅이 필요하다.
회원가입을 하고,
버킷을 생성하고,
버킷과 통신을 위한 IAM 계정을 만들고,
버킷 정책설정과, IAM 계정 액세스 키 발급을 해서 AWS S3를 사용하기 위한 준비를 마쳤다.
이제 aws s3를 스프링 부트에서 사용하기 위한 준비 과정을 거쳐야 한다.
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
aws s3를 사용하려면 우선 관련 라이브러리의 의존성을 등록해야 한다.
cloud:
aws:
s3:
bucket: "spartanewsfeed"
stack.auto: false
region.static: "ap-northeast-2"
credentials:
accessKey: ${S3_ACCESSKEY}
secretKey: ${S3_SECREETKEY}
앞서 생성한 s3 버켓과 iam계정 액세스 키, 시크릿 키에 대한 정보를 application.properities(yml)에 등록해야 한다. 단, 액세스키, 시크릿 키가 노출되면 악용의 여지가 있기 때문에 환경 변수에 담아놨다.
AmazonS3Client객체를 Bean으로 등록하기위한 클래스 S3Config를 만들 것이다.
@Configuration
public class S3Config {
@Value("${cloud.aws.credentials.access-key}")
private String accessKey;
@Value("${cloud.aws.credentials.secret-key}")
private String secretKey;
@Value("${cloud.aws.region.static}")
private String region;
@Bean
public AmazonS3Client amazonS3Client() {
BasicAWSCredentials awsCredentials= new BasicAWSCredentials(accessKey, secretKey);
return (AmazonS3Client) AmazonS3ClientBuilder.standard()
.withRegion(region)
.withCredentials(new AWSStaticCredentialsProvider(awsCredentials))
.build();
}
}
@Value 어노테이션으로 application.properities(yml)에 등록해 놓은 액세스, 시크릿키, 리전(지역)정보를 받아올 것이다.
(@Value 어노테이션은 org.springframework.beans.factory.annotation.Value를 사용해야 한다.)
다음은 s3 관련된 컬럼을 회원 테이블에 추가해야 한다.
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
@Entity
@Getter
@Table(name = "users")
public class User extends Timestamped {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "user_seq")
private Long userSeq;
@NotBlank
@Column(name = "user_id")
private String userId;
@NotBlank
@Column(name = "user_password")
private String userPassword;
@Column(name = "user_name")
private String userName;
@NotBlank
@Email
@Column(name = "user_email")
private String userEmail;
@Column(name = "user_intro")
private String userIntro;
@Column(name = "user_status")
private String userStatus;
@Column(name = "profile_image_url")
private String profileImageUrl;
@Column(nullable = false)
@Enumerated(value = EnumType.STRING)
private UserRoleEnum role;
@Column(name = "refresh_token")
private String refreshToken;
@Column(name = "status_modified")
private LocalDateTime statusModified;
@Column(name = "photo_name")
private String photoName;
public User(String userId, String userPassword, UserRoleEnum role) {
this.userId = userId;
this.userPassword = userPassword;
this.role = role;
}
public void updateUserInfo(UserInfoUpdateDto requestDto) {
this.userName = requestDto.getName();
this.userIntro = requestDto.getIntro();
}
public User updateOAuth2Info(String userName, String profileImageUrl) {
this.userName = userName;
this.profileImageUrl = profileImageUrl;
return this;
}
public void updatePassword(String encNewPassword) {
this.userPassword = encNewPassword;
}
public void updateRole(UserRoleEnum role){
this.role = role;
}
public void setRefreshToken(String token) {
this.refreshToken = token;
}
public void setPhotoName(String photoName) {
this.photoName = photoName;
}
}
s3에서 가져온 사진의 정보를 db에 저장하기 위해 photoName이라는 프로퍼티를 만들었다. @RequiredArgsConstructor를 놔두고 구태여 photoName의 Setter를 직접 정의한 이유는 회원가입 할 때 사진을 넣을 수도 있고 안 넣을 수도 있기 때문이다. 다시 말해 사진을 업로드 하지 않으면 기존의 생성자를 통해 user엔티티를 만들고 사진을 업로드하면 그때 생성자로 엔티티를 만들고 그 엔티티에 사진을 넣게 한다.
만약 photoName을 생성자에 넣으면 모든 회원들이 프로필 사진을 업로드 해야만 엔티티가 생성될 것이다.
이제 실질적으로 파일 전송과 출력을 담당하는 s3service클래스를 만들 것이다.
@Service
@RequiredArgsConstructor
public class S3Service {
private final AmazonS3Client amazonS3Client;
@Value("${cloud.aws.s3.bucket}")
private String bucket;
public String uploadFile(MultipartFile file) {
try {
String fileName = file.getOriginalFilename();
String fileUrl = "https://" + bucket + "/test" + fileName;
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentType(file.getContentType());
metadata.setContentLength(file.getSize());
amazonS3Client.putObject(bucket, fileName, file.getInputStream(), metadata);
return fileName;
} catch (IOException e) {
e.printStackTrace();
return null;
}
}
public String readFile(String fileName) {
URL url = amazonS3Client.getUrl(bucket, fileName);
String urltext = "" + url;
return urltext;
}
public void deleteFile(String fileName) {
amazonS3Client.deleteObject(new DeleteObjectRequest(bucket, fileName));
}
}
s3service에는 버킷에 request에 첨부한 파일 저장하기, 버킷으로부터 특정 파일의 퍼블릭url 가져오기, 버킷으로부터 특정 파일 삭제하기 총 3개의 메소드가 포함된다.
파일을 버킷에 저장하려면 버켓 이름, 버켓에 저장할 토큰 이름, 파일을 전송하는 역할의 inputstream, 파일의 메타데이터(파일의 종류, 사이즈)가 필요하다.
파일 전송과 가져오기 기능을 구현 했으니 이제 회원 관련 기능에 프로필 사진을 전송하는 기능을 구현해야 한다.
우선 서비스의 signup()메소드를 수정했다.
public SignupResponseDto signup(SignUpRequestDto request, MultipartFile file) {
String userId = request.getUserId();
String userName = request.getUserName();
String password = passwordEncoder.encode(request.getPassword());
String email = request.getEmail();
// 회원 아이디 중복 확인
Optional<User> checkUsername = userRepository.findByUserId(userId);
if (checkUsername.isPresent()) {
throw new CustomException(ErrorCode.USER_NOT_UNIQUE);
}
Optional<User> checkEmail = userRepository.findByUserEmail(email);
if (checkEmail.isPresent()) {
throw new CustomException(ErrorCode.EMAIL_NOT_UNIQUE);
}else{
joinEmail(email);
}
// 사용자 ROLE 확인
UserRoleEnum role = UserRoleEnum.UNCHECKED; // 아직 이메일 체크 안함.
String fileName = null;
if (file != null) {
fileName = s3Service.uploadFile(file);
}
// 사용자 등록
User user = User.builder()
.userId(userId)
.userName(userName)
.userPassword(password)
.userEmail(email)
.role(role)
.photoName(fileName)
.build();
userRepository.save(user);
return new SignupResponseDto(user);
}
uploadFile()메소드는 업로드한 파일의 토큰을 반환하기 때문에 엔티티에 저장할 파일 토큰을 fileName이라는 변수에 저장하고 photoName컬럼에 추가했다.
signup()메소드는 MultipartFile 인터페이스를 채택한 file 매개변수를 추가했기 때문에 컨트롤러에도 같은 타입의 매개변수를 추가해야 한다.
@Operation(summary = "회원가입",description = "회원가입")
@PostMapping(value = "/signup")
public SignupResponseDto Signup(@RequestPart @Valid SignUpRequestDto requestDto,
@RequestPart(required = false)MultipartFile file){
return authService.signup(requestDto, file);
}
파일을 받기 위해 @RequestPart 어노테이션을 사용했다.
이제 swagger-ui에서 실행하려고 하는데 파일을 첨부하는 방법을 찾을 수 없다.
swagger-ui는 기본적으로 request body는 application/json 타입으로 데이터를 전송한다고 한다.
@PostMapping(value = "/signup", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
그래서 multipart_form방식으로 바꾸기 위해 @PostMapping 어노테이션에 consumes 속성을 추가했다.
조치를 취하고 다시 실행해 보니 이번엔 이런 에러가 나왔다.
조사해보니 Content-type이 올바르지 않은 타입인 ‘application/octet-stream’으로 받아서 생긴 문제였다.
필자가 이해한 바로는 DTO랑 MultipartFile을 @RequestPart로 받기 위해서는 전송할 때 올바른 타입을 명시하고 보내야 한다고 알려져 있다. 그런데 swagger-ui는 request 데이터의 타입을 일일이 명시할 방법이 없다.
이를 해결하기 위해 HttpMessageConverter나 HttpMessageConverter를 구현하는 커스텀 컨버터 컴포넌트를 추가해야 한다. 그리고 ‘application/octet-stream’ 사용을 비활성화해서 에러를 방지할 수 있다고 한다. 여기서는 AbstractJackson2HttpMessageConverter를 상속받아서 구현했다.
@Component
public class MultipartJackson2HttpMessageConverter extends AbstractJackson2HttpMessageConverter {
public MultipartJackson2HttpMessageConverter(ObjectMapper objectMapper) {
super(objectMapper, MediaType.APPLICATION_OCTET_STREAM);
}
@Override
public boolean canWrite(Class<?> clazz, MediaType mediaType) {
return false;
}
@Override
public boolean canWrite(Type type, Class<?> clazz, MediaType mediaType) {
return false;
}
@Override
protected boolean canWrite(MediaType mediaType) {
return false;
}
}
회원 조회, 수정도 회원 가입처럼 사진을 업로드하고 불러오는 코드를 추가해야 한다.
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final AuthService authService;
private final S3Service s3Service;
public UserResponseDto getUser(Long userSeq) {
User user = findById(userSeq);
UserResponseDto responseDto = new UserResponseDto(user);
responseDto.setPhotoUrl(s3Service.readFile(user.getPhotoName()));
return responseDto;
}
public User findById(Long userSeq) {
return userRepository.findById(userSeq).orElseThrow(
() -> new CustomException(ErrorCode.USER_NOT_FOUND)
);
}
@Transactional
public UserInfoUpdateDto updateUser(Long userSeq, UserInfoUpdateDto requestDto, MultipartFile file) {
User user = findById(userSeq);
if (file != null) {
s3Service.deleteFile(user.getPhotoName());
user.setPhotoName(s3Service.uploadFile(file));
}
user.updateUserInfo(requestDto);
return new UserInfoUpdateDto(user);
}
...
회원정보를 수정할 때는 기존의 사진을 삭제한느 코드를 추가했다.
컨트롤러도 파일을 매개변수로 받을 수 있게 api를 수정해야 한다.
@PutMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public UserInfoUpdateDto updateUser(@AuthenticationPrincipal UserDetailsImpl userDetails,
@RequestPart UserInfoUpdateDto requestDto,
@RequestPart(required = false) MultipartFile file) {
return userService.updateUser(userDetails.getUser().getUserSeq(), requestDto, file);
}
회원 탈퇴는 회원 로우를 삭제하는 게 아닌 특정 로우의 role컬럼을 수정하기 때문에 사진을 삭제하는 코드를 추가하지 않았다.
이렇게 aws기능을 구현함으로써 뉴스피드 프로젝트는 마무리되었다. 다음날에는 kpt 회고를 해볼 것이다.
'내일배움캠프' 카테고리의 다른 글
Spring 심화주차 Part1 (0) | 2024.06.13 |
---|---|
뉴스피드 프로젝트 5일차 (0) | 2024.06.11 |
뉴스피드 프로젝트 2일차 (0) | 2024.06.07 |
뉴스피드 프로젝트 1일차 (0) | 2024.06.04 |
Spring 숙련주차 Part4 (2) | 2024.06.03 |