Project/토이 프로젝트

[Cinemagram] Image 업로드 및 렌더링 (feat. OSIV) - (7)

Lea Hwang 2022. 12. 14. 16:00

팔로우 기능을 이어서 개발하고 브라우저에서도 테스트하고 싶지만 안타깝게도 이 기능은 유저의 프로필 페이지 안에 있습니다. 이러한 이유로 프로필 페이지를 우선 만들고 그 후에 팔로우 구현 및 Test를 해보도록 하겠습니다.

 

 

 

Image 업로드

 

파일선택 클릭 → 이미지 선택 → 사진 설명 작성 후(caption) 업로드 클릭 → DB 저장

 

 

지금까지 그래 온 것처럼 우선 모델부터 만들어 보겠습니다.

 

Image & ImageRepository

Image

@NoArgsConstructor
@Getter
@Entity
public class Image extends BaseTimeEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;

    private String caption; 
    private String imageUrl; 

    @JoinColumn(name = "userId") 
    @ManyToOne
    private User user; 
}
  • caption
    • 업로드 할 이미지 간단 설명
  • imageUrl
    • 이미지 자체를 DB에 넣는 것이 아닌 이미지를 서버의 특정 폴더에 저장 후 DB에는 그 저장된 '경로'를 INSERT
  • User
    • 이미지를 누가 업로드했는지
    • @JoinColumn(name="FK 이름") 
  • 추가 기능
    • 댓글
    • 좋아요

 

ImageRepository

public interface ImageRepository extends JpaRepository<Image, Integer> {
}

 

 

 

이미지를 업로드하기 위해서는 총 3개의 클래스와 1개의 파일이 필요합니다.

  • ImageController : 사용자로부터 데이터를 받고 서비스를 호출
  • ImageUploadDto : 해당 Controller 메서드의 파라미터에 전달할 (API스펙에 맞는) DTO
  • ImageService : 비즈니스 로직 구현
  • application.yml : multipart타입으로 사진 받기, size 설정

 

ImageController 

@RequiredArgsConstructor
@Controller
public class ImageController {

    private final ImageService imageService;

    @PostMapping("/image")
    public String imageUpload(@AuthenticationPrincipal CustomUserDetails customUserDetails, ImageUploadDto imageUploadDto) { 
        imageService.imageUpload(customUserDetails, imageUploadDto);

        return "redirect:/user/"+customUserDetails.getUser().getId(); 
    }

}
  • 파라미터
    • 로그인 한 유저가 image와 caption을 써서 업로드하는 것이므로
      • 세션 정보와 DTO필요
  • return
    • 이미지 업로드 후 어디로 갈 것인가?
      • 나의 profile페이지

 

ImageUploadDto

package com.photo.web.dto.image;

import com.photo.domain.image.Image;
import com.photo.domain.user.User;
import lombok.*;
import org.springframework.web.multipart.MultipartFile;

@Data
public class ImageUploadDto { 
    private MultipartFile file;
    private String caption;

    @Builder
    public ImageUploadDto(MultipartFile file, String caption) {
        this.file = file;
        this.caption = caption;
    }

    public Image toEntity(String imageUrl, User user){ 
        return Image.builder()
                .caption(caption)
                .imageUrl(imageUrl)
                .user(user)
                .build();

    }
}
  • ImageUploadDto.toEntity( ) ← 파라미터
    • (ImageUpload폴더에 저장된 경로, 어떤 유저가 INSERT 했는지)
    • ImageUpload폴더는 외부에 있음
❓❗ 외부에 ImageUpload폴더 만드는 것을 추천하는 이유 
로직)
서버 실행 시 .java를 컴파일해서 Target폴더로 이동(배포(deploy)) 후 실행합니다.

내부에 ImageUpload폴더 있을 시)
배포될 때 시간이 걸리게 때문에 시간차로 잠깐 액박 표시가 뜰 수 있어 사용자 경험이 좋지 않습니다.

이러한 이유로 프로젝트 외부에 ImageUpload폴더를 만들면 배포할 필요가 없으므로 액박 자체가 뜨지 
않게 됩니다.

 

 

ImageService 

@RequiredArgsConstructor
@Service
public class ImageService {

    private final ImageRepository imageRepository;

    @Value("${file.path}")
    private String imageUploadRoute; 
    
    @Transactional
    public void imageUpload(CustomUserDetails customUserDetails, ImageUploadDto imageUploadDto) {
        UUID uuid = UUID.randomUUID();
        String imageFileName = uuid+"_"+imageUploadDto.getFile().getOriginalFilename(); 
     
        Path imageFilePath = Paths.get(imageUploadRoute+imageFileName);

        // 입출력시 컴파일단계에서는 못 잡아내므로 런타임시 잡아주기 위해 예외처리
        try{
            Files.write(imageFilePath, imageUploadDto.getFile().getBytes());
        }catch (Exception e){
            e.printStackTrace();
        }

        Image image = imageUploadDto.toEntity(imageFileName, customUserDetails.getUser()); 
        imageRepository.save(image);
       
    }
}

 

Service에서 해야 할 작업은 크게 3가지입니다.

  • 이미지 저장할 경로 지정
  • 이미지 고유 이름 생성
  • DB에 저장

 

 

이미지 저장할 경로 지정

경로를 해당 Service에서 적어도 되지만 경로인 만큼 실수할 가능성이 큽니다. 따라서 application.yml파일에 정해두고 Service에서 불러와서 사용하고자 합니다.

 

application.yml

servlet:
  multipart:
    enabled: true
    max-file-size: 100MB
    max-request-size: 100MB

file:
  path: C:/Users/Desktop/Projects/ImageUpload/
  • enable : true
    • multipart타입으로 사진을 받겠다.
  • file
    • path: 컴퓨터에 업로드한 이미지가 저장될 경로(마지막은 꼭 /로 끝내기)

 

이제 이 경로를 Service에서 불러와서 사용하는 코드 부분입니다.

@Value("${file.path}")
    private String imageUploadRoute;

 

 

 

이미지 고유 이름 생성

이미지 이름이 같은 경우가 있습니다. 그럼 최신 것으로 덮어 쓰이는데요, 이를 방지하고자 UUID를 앞에 붙여서(유일성 보장) DB에 저장하는 방안을 생각해냈습니다.

UUID uuid = UUID.randomUUID();
String imageFileName = uuid+"_"+imageUploadDto.getFile().getOriginalFilename();

 

 

DB에 저장

위에서 만든 ImageUploadDto를 바로 Repository.save 하면 좋겠지만 타입이 다릅니다. (Image객체를 넘겨야 합니다.)

따라서 해당 DTO에서 객체로 변환하는 작업을 한 후 (ImageUploadDto.toEntity) 넘기는 작업을 추가하였습니다.

 

 

 

 

Image 업로드 - Validation 체크 및 예외처리

받아야 할 데이터는 이미지(Image)와 설명(caption)입니다. caption은 단순히 view단에서 required="required"으로 처리했습니다. 하지만 Image는 꼭 들어와야 합니다. Validation체크를 해야 하는데요, 문제는 View단에서 enctype="multipart/form-data"를 사용할 경우 Validation 지원이 되지 않는다는 것입니다. 이로써 DTO에 @NotBlank와 같은 어노테이션을 써서 Controller @Valid, BindingResult을 파라미터로 받지 못합니다.

ChatGPT에게 물어본 결과...

ChatGPT에게 답변을 받은 것처럼 별도의 코드를 작성해야 합니다. if문으로 .isEmpty()면 더 이상 INSERT 되지 않게 throw처리를 해주겠습니다.

 

ImageController

@PostMapping("/image")
public String imageUpload(@AuthenticationPrincipal CustomUserDetails customUserDetails, ImageUploadDto imageUploadDto) {
    if(imageUploadDto.getFile().isEmpty()) {
        throw new CustomValidationException("이미지가 첨부되지 않았습니다.",null);
    }
    imageService.imageUpload(customUserDetails, imageUploadDto);
    return "redirect:/user/"+customUserDetails.getUser().getId();
}

 

 

DB 확인

 

 

 

양방향 매핑 (feat. 지연 로딩)

이제 업로드한 사진들을 나의 profile페이지에 뿌려주기만 하면 됩니다. 그럼 해당 페이지로 올 때 이미지만 들고 오면 될까요? 밑의 화면에서 확인할 수 있듯 회원정보, 팔로우 정보, 이미지 정보가 필요합니다.

처리할 순서는

  • 회원정보 + 이미지 
  • 게시물(피드) 개수
  • 팔로우 정보(수, 상태)  ☞ 다음 포스팅

 

회원정보 + 이미지

그럼 다 엮어서 가져오면 되겠네!라고 생각할 수 있지만 User 엔티티를 봤을 때 회원 관련 정보만 있지 image는 없습니다.

@Builder
@AllArgsConstructor
@NoArgsConstructor
@Data
@Entity
public class User extends BaseTimeEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;

    @Column(unique = true, length = 20)
    private String username;

    @Column(nullable = false)
    private String password;
    @Column(nullable = false)
    private String name;
    private String website;
    private String bio;
    @Column(nullable = false)
    private String email;
    private String phone;
    private String gender;

    private String profileImageUrl;
    private String role;
}

 

따라서 User를 가져올 때 (User가 업로드한) image를 가져오게 하기 위해서 양방향 매핑 코드를 추가하겠습니다.

@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
@JsonIgnoreProperties({"user"}) 
private List<Image> images;
  • (mappedBy = "user")
    • 연관관계 주인이 아님을 나타냅니다. (DB에 칼럼 생성 안 함)
  • 즉시 로딩과 지연 로딩
    • fetch = FetchType.LAZY : 지연 로딩
      • User를 SELECT 할 땐 images 가져오지 말고 - getImages().get(int index)를 호출할 때 가져옵니다.
    • fetch = FetchType.EAGER : 즉시 로딩
      • User를 SELECT 할 때 해당 user id로 등록된 images  전부 join 해서 가져옵니다.
    • 지연 로딩 사용, 즉시 로딩 사용하지 않기(N+1문제 발생)
      • @ManyToOne, @OneToOne은 Default가 EAGER이므로, LAZY로 수정
      • @OneToMany, @ManyToMany는 Default가 LAZY
    • 즉시 로딩과 지연 로딩 관련 포스팅
    • Open-(Session)-In-View: true 관련 포스팅
  • @JsonIgnoreProperties({"user"})
    • 응답 시 무한 참조 막기 위한 장치로, Image 엔티티에 있는 user는 무시하고 JSON 파싱해서 응답합니다.

 

 

View단에서 이미지 경로 가져오기

우리는 이전에 application.yml에서 file.path를 정해주었고 ImageService에서 가져다가 사용했습니다.

그럼. html에서는 어떻게 사용하면 될까요?

th:src="${image.postImageUrl}"

단순하게 이렇게 받아오면 xxx.png만 가져오게 됩니다. 우리는 application.yml의 해당 경로도 가져와야 합니다.

 

 

WebMvcConfig 

@Component
public class WebMvcConfig implements WebMvcConfigurer { 

    @Value("${file.path}")
    private String imageUploadRoute;

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        WebMvcConfigurer.super.addResourceHandlers(registry);

        registry
            .addResourceHandler("/upload/**")
            .addResourceLocations("file:///"+imageUploadRoute)
            .setCachePeriod(60*10*6)
            .resourceChain(true)
            .addResolver(new PathResourceResolver());
    }

}

웹 설정 파일입니다. 살펴봐야 할 부분은 registry.addResourceHandler와 .addResourceLocations입니다.

  •  .addResourceHandler("/upload/**")
    • .html페이지에서 /upload/**  패턴 주소가 나오면 
  •  .addResourceLocations("file:///"+imageUploadRoute)
    • WebMvcConfigurer이 낚아채서 해당 코드로 주소를 바꿔줍니다.

 

 

 

양방향 매핑 응답 시 - 무한 참조 방지

ApiCotroller의 경우 클래스 레벨에 @RestController를 사용합니다.

그럼 오브젝트를 return 할 때 오브젝트 내부적으로 모든 getter 함수가 호출되고 JSON으로 파싱 하여 응답하는데요, 

 

이는 양방향 매핑 시 문제가 될 수 있습니다. 예를 들어 User를 보면

@Builder
@AllArgsConstructor
@NoArgsConstructor
@Data
@Entity
public class User extends BaseTimeEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;

    @Column(unique = true, length = 20)
    private String username;

    @Column(nullable = false)
    private String password;
    
    @Column(nullable = false)
    private String name;
    ...


    @OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
    private List<Image> images;

}

다른 getter 함수가 호출되는 건 문제가 없지만 User에서 getImages를 호출할 때 → Image엔티티 안으로 들어가서 user를 호출하고 →  그럼 또 User로 가서 images를 호출하고 .... 무한 반복하게 됩니다. 이를 무한 참조라고 합니다.

 

 

이를 방지하기 위한 여러 방법이 있겠지만 저는 Jackson 어노테이션 중 하나인 @JsonIgnoreProperties 어노테이션을 사용할 예정입니다.

@JsonIgnoreProperties()

 

 

User에 적용하면 User Image 엔티티에 있는 user는 무시하고 JSON 파싱함으로써 무한참조에 빠지지 않게 합니다.

@Builder
@AllArgsConstructor
@NoArgsConstructor
@Data
@Entity
public class User extends BaseTimeEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;

    @Column(unique = true, length = 20)
    private String username;

    ...

    @OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
    @JsonIgnoreProperties({"user"}) 
    private List<Image> images;

}

 

 

 

유저에 따른 버튼 생성

현재 profile페이지를 보면 어떤 유저가 접근을 해도 이미지 추가, 팔로우 버튼이 나란히 보입니다. 

 

하지만 로그인 한 유저의 경우 자신의 페이지이므로 팔로우 기능이 필요가 없고, 타 유저 페이지로 이동시 이미지 추가 버튼이 보이면 안 됩니다.

 

따라서 이렇게 수정하려 합니다.

로그인 한 유저 profile 페이지 - 이미지 추가 버튼 
타 유저 페이지 - 팔로우 버튼

 

 

위에서 짧게 정리한 것에서 알 수 있듯 View페이지로 넘어갈 때 sessionId를 가지고 가면 됩니다.

그럼 sessionId(로그인한 유저)와 pageUserId(페이지 유저)가 같으면 이미지 추가 버튼이 보이게 하고 아니면 팔로우 버튼이 보이게 하면 됩니다.

 

하지만 뷰페이지는 백엔드 부서 사람 뿐만아니라 프론트, 퍼블리셔 분들도 확인이 가능합니다. 따라서 뷰페이지에서는

연산을 최소화하고 단순히 데이터를 뿌리는 정도만 해야한다고 생각합니다.

 

 

 

DTO 생성

또 다른 방법도 존재합니다. API 스펙에 맞는 DTO를 만들어서 넘기는 방법입니다.

UserProfileDto

@Builder
@AllArgsConstructor
@NoArgsConstructor
@Data
public class UserProfileDto {
    private boolean pageUserState;  // 페이지 유저가 세션 유저인지 아닌지 확인용
    private int imageCount;         // 게시물이 몇 개인지 Count
    private User user;
}

 

만든 DTO를 Controller에서 받아서 처리하는 코드를 작성해보겠습니다.

UserController

@GetMapping("/user/{pageUserId}")
public String profile(@PathVariable int pageUserId, Model model, @AuthenticationPrincipal CustomUserDetails customUserDetails) {
    UserProfileDto userProfileDto = userService.profile(pageUserId, customUserDetails.getUser().getId());
    model.addAttribute("dto", userProfileDto);
    model.addAttribute("sessionId", customUserDetails.getUser().getId());
    return "user/profile";
}

 

UserService

@Transactional(readOnly = true)
public UserProfileDto profile(int pageUserId, int sessionId) {
    UserProfileDto dto = new UserProfileDto();

    User userEntity = userRepository.findById(pageUserId).orElseThrow(
            () -> new CustomException("해당 profile 페이지는 없는 페이지입니다."));

    dto.setUser(userEntity);
    dto.setPageUserState(pageUserId == sessionId);      // 같으면 true
    dto.setImageCount(userEntity.getImages().size());

    return dto;
}

 

이제 profile.html 페이지에 뿌려주기만 하면 됩니다.

이미지 추가, 팔로우 버튼 부분입니다.

<button th:if="${dto.pageUserState}" onclick="location.href='/image/upload'" >이미지 추가</button>

<div th:unless="${dto.pageUserState}">
   <button onclick="toggleFollow(this)">팔로우</button>
</div>

 

 

확인

mango로 로그인했을 시 (sessionId)

  • 이미지 추가 버튼
  • 이미지 추가한 부분이 게시물로 Counting 및 밑에 뿌려짐

 

 

apple로 페이지 이동시 (pageUserId)

  • 팔로우 버튼
  • 이미지 추가한 부분이 게시물로 Counting 및 밑에 뿌려짐

 

 

 

 

 

여기까지 구현한 부분을 정리해보자면,

  • Image 업로드 및 렌더링
    • Validation 직접 체크 및 예외처리
  • User-Image 양방향 매핑
    • 무한참조 오류 방지
    • OSIV 설정
  • 유저에 따른 버튼 생성
    • session 유저 : 이미지 추가 버튼
    • page 유저 : 팔로우 버튼

 

Image 업로드 및 렌더링으로 push했습니다.

 

 

 

 

이제 크게는 팔로우 기능 구현, 작게는 좋아요, 댓글 기능 구현과 story페이지 출력이 남았습니다. 최대한 짧게 쓴다고 했는데 역시 길어졌네요. 다음 포스팅은 더 가독성 좋고 이해하기 쉽게 작성해보도록 하겠습니다.