우당탕탕 개발_𝒍𝒐𝒈

회원 ) 프로필 사진 ) 프로필 사진 저장 및 수정 구현 과정 본문

𝐩𝐫𝐨𝐣𝐞𝐜𝐭/𝐓𝐢𝐦𝐞𝐁𝐞𝐚𝐧

회원 ) 프로필 사진 ) 프로필 사진 저장 및 수정 구현 과정

hojeong01 2024. 9. 25. 15:52
더보기

'timebean' 회원 부분을 구현하며 고민했던 점과 전체적인 진행 과정을 남겨보려고 한다. 

#1 . 프로필 사진을 어떤 방식으로  저장할까?

처음 프로필 구현을 시작했을때 예상치 못한 부분에서 고민이 되었다.

'프로필 사진 당연히 사진 파일로 저장하면되는거 아니야?'라고 생각했지만, OAuth2 유저는 url로 사진 정보를 담는 것을 발견했다. 

그래서 프로필 사진을 저장하는 다양한 방법들을 알아보고 비교해본 후 우리의 상황에 맞는 최적의 방법을 선택해보고자 했다. 

 

간단하게 방식을 설명하면 다음과 같다.

 

첫번째 방법

<내부 스토리지 서버에 저장하기>

 업로드된 이미지를 서버 내 디렉토리에 저장하고, 해당 파일의 경로/url을 데이터 베이스에 저장하는 방식

두번째 방법

<외부 스토리지 서버에 저장하기>

이미지를 바이너리 데이터로 변환 후 BLOB 타입으로 필드에 저장하는 방식 

세번째 방법

<데이터베이스에 BLOB 타입으로 저장하기>

aws s3, google cloud storage등 외부 스토리지에 이미지를 저장하고 이미지의 url만 데이터베이스에 저장하는 방식

 

위 방식들의 장점과 단점을 분석한 후, 해당 단점을 해결할 수 있는 방법은 없을까를 고민해 보았다. 

 

우선 내부 스토리지 서버에 프로필 사진 파일을 저장하게 된다면 DB의 부하는 줄어들겠지만 

우리 프로젝트 특성 상 추후 서버분리를 계획을 하고 있던 단계이기 때문에 

프로필 사진 파일을 서버안에 저장을 하였을 때 동기화 문제가 발생될 가능이 있다는 것과 

서버에 부하를 줄 수 있다는 점이 우려되었다.

 

두번째로  외부 스토리지를 사용하면 웹서버, 데이터 서버 모두 부하를 줄일 수 있다는 장점이 있지만 비용 문제를 무시 할 수 없었다.

 

세번째 데이터 베이스의 BLOB 타입으로 저장하는 방법은 데이터 베이스와 계속 상호작용이 일어나기 때문에 성능 및 데이터베이스 부하 문제가 발생할 수 있다는 점이 고민이었다.

 

세 방식의 단점을 비교해 보면서, 프로필 사진은 일반적으로 자주 수정되지 않는 데이터라는 것을 떠올렸다 . 그럼 브라우저 캐싱을 적절히 활용한다면, 세 번째 방식인 데이터베이스에 BLOB 타입으로 저장하는 방법이 서버 분리 시 발생할 동기화 문제나 비용적인 측면에서 더 효율적이지 않을까? 하는 판단에 세번째 방법으로 진행하기로 결정 하였다. 

 


 

#2. 프로필 사진 등록 / 수정 과정 

1. 기본 프로필 : 로그인하지 않은 기본 유저는 기본 이미지 URL을 제공한다. / OAuth2 유저는 구글/네이버 기본 프로필로 제공하기

2. 프로필 등록 및 변경 : 마이페이지 > 프로필 변경 버튼 > 파일 업로드 창을 통해 서버로 전달 > MultipartFile로 전송된 이미지를 받아 byte[]로 변환하기  -> DB에 BLOB 형태로 저장하기

3. 로그인한 유저의 프로필 이미지 처리 : DB에서 해당 유저의 프로필 이미지를 가져오기 -> 브라우저 캐시로 저장하기

 

레디스 캐시 적용 안한 버전

 


 <entity> 

@Lob
@Column(name = "profile_image")
private byte[] profileImage;

@Column(name = "profile_url")
private String profileUrl;

- member엔티티에 속성 추가 

 

-@Lob 어노테이션 사용 

: JPA에서 사용하는 애노테이션으로, 데이터베이스에  대용량 객체를 저장할 때 사용한다. @Lob을 통해 Java 객체에서 텍스트나 바이너리 데이터를 데이터베이스의 BLOB 필드와 매핑할 수 있다.

 

- 데이터 타입 byte[]

: 이미지는 종류(gif, jpeg,png..)와 상관 없이 이진 데이터의 집합으로 구성된다. 따라서 이진 데이터 집합의 처리를 위해 자바에서는 byte[] 타입을 사용하고 있다. -> [0x22, 0x3F, ...]

 

- 추후 운용 / OAuth2유저를 위한 VARCHAR / string 타입의 url 속성도 추가

 

 <html> 

 

<form action="/member/updateProfile" method="post" enctype="multipart/form-data">
<div class="profile-pic-container">
  <!-- 프로필 사진 -->
  <div class="profile-image">
    <img class="profile-url" th:src="${profile != null ? profile : '../images/profile_image.png'}" alt="Image"/>
  </div>
  <div class="overlay">
    <span>프로필 변경하기</span>
    <input type="file" id="profilePicInput" name="file" style="display: none;">
  </div>
</div>

<!-- 파일 선택 후 나타나는 업로드 버튼 -->
    <button type="submit" class="button-myPage" id="uploadBtn" style="display: none;">업로드</button>
  </form>

- 데이터를 보낼 url 지정 / 보내는 방식 지정 : post 

action="/member/updateProfile" method="post"

- 기본 프로필 : thymeleaf 삼한 연산자 사용하여 프로필 등록이 되어있지 않으면 기본 이미지 url 제공 구현 

th:src="${profile != null ? profile : '../images/profile_image.png'

 - 타입은 당연하게? "file" 로 지정하기 

 type="file"

 

 <controller> 

@PostMapping("/updateProfile")
public String updateProfile(@RequestParam("file") MultipartFile file, Principal principal) throws IOException {
    byte[] profileImg;
    String accountId = principal.getName();
    try {
         profileImg = file.getBytes();
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
    memberService.updateProfileImg(accountId,profileImg);
    return "redirect:/member/myPage";
}

 

- @PostMaping 으로 /updateProfile 으로 들어온  Post 요청을 처리

- @RequestParam("file") 을 통해 요청 파라미터 중 파일의 값을 받아  MultipartFile 타입의 객체에 담음

- 인증된 객체 정보 중 acountId(username)을  가져오기 위해 선언

 

<MultipartFile 인터페이스 이해하기>

public interface MultipartFile extends InputStreamSource {

    // 업로드된 파일의 폼 필드 이름을 반환
    String getName();

    // 사용자가 설정한 파일의 실제 이름
    @Nullable
    String getOriginalFilename();

    // 업로드된 파일의 콘텐츠타입을 반환("image/png", "application/pdf")
    @Nullable
    String getContentType();

    // null 인지 아닌지 체크
    boolean isEmpty();

    // 바이트 단위로 반환(크기)
    long getSize();

    // 바이트 배열로 반환(내용)
    byte[] getBytes() throws IOException;

    // 파일의 내용을 InputStream으로 반환
    InputStream getInputStream() throws IOException;

    // Resource 객체로 반환.
    default Resource getResource() {
        return new MultipartFileResource(this);
    }

    // 업로드된 파일을 지정한 File 객체로 저장
    void transferTo(File dest) throws IOException, IllegalStateException;

    // 업로드된 파일을 지정한 Path 위치로 저장
    default void transferTo(Path dest) throws IOException, IllegalStateException {
        FileCopyUtils.copy(this.getInputStream(), Files.newOutputStream(dest));
    }
}

 

- getBytes()메서드를 사용하여 파일의 내용을 바이트 배열로 반환 후 profileImg에 담기

 

- accountId,profileImg/ 인증 객체의 아이디와 프로필 파일이 담긴 변수를 메서드의 인자로 넘겨서 update 수행 

 

 <service> / <repository>

@Transactional
@Override
public void updateProfileImg(String accountId,byte[] profileImg) {
    memberRepository.updateProfileImg(accountId,profileImg);
}
@Modifying
@Query("UPDATE Member SET profileImage = :profileImg WHERE accountId = :accountId")
void updateProfileImg(@Param("accountId") String accountId,
                      @Param("profileImg") byte[] profileImg);

 

<어노테이션의 쓰임 이유>

- @Query만으로는 JPA가 해당 쿼리가 어떤 역할을 하는지 명확히 알기 어렵기 때문에, 데이터 변경 작업(INSERT, UPDATE, DELETE)을 수행하는 쿼리에는 @Modifying 어노테이션을 함께 사용하여 데이터 변경 쿼리임을 알려준다.

 

- @Modifying을 사용하는 메서드는 기본적으로 트랜잭션이 필요하다. 이는 데이터 변경 작업이 트랜잭션 내에서 실행되어야 하기 때문인데 따라서 @Transactional 어노테이션을 사용하여 updateProfileImg() 메서드 전체가 하나의 트랜잭션으로 실행되도록 하여, JPA의 쓰기 작업이 정상적으로 실행될 수 있게 해야한다. 

 

 

프로필 사진 화면으로 반환하기 

 <controller> / <service> 

@GetMapping("/myPage")
public String userPage(Model model, Principal principal) {
    String accountId = principal.getName();
    //멤버의 프로필 이미지
    String profile = memberService.getMemberPicture(accountId);
    model.addAttribute("profile", profile);

    //멤버의 정보
    Member member = memberService.findByAccountId(accountId);
    //html 에서 엔티티에 담긴 속성을 꺼내오기 위해 model 사용
    model.addAttribute("member", member);
    return "userPage/myPage";
}
@Override
public String getMemberPicture(String accountId) {
    Member member = memberRepository.findByAccountId(accountId);

    byte[] profile = member.getProfileImage();
    //Oauth 유저와 기본 프로필 유저일 경우 프로필 형식이 URL로 db에 저장 되있을 수 있다.
    if (profile != null) {
        String base64 = Base64.getEncoder().encodeToString(profile);
        base64 = "data:image/png;base64," + base64;
        return base64;
    } else {
        return member.getProfileUrl();
    }
}

 

 

마지막으로 화면으로 멤버의 프로필을 전달하기 위해 사용한 컨트롤러와 서비스 로직이다. 

 

여기에서 주의해야할 점? 이 과정에서 만약 사용자가 저장해둔 프로필 파일이 있다면 이를 인코딩 과정을 통해 꺼내와야하는데 

처음에는 

if (profile != null) {
        String base64 = Base64.getEncoder().encodeToString(profile);
        return base64;
    } else {
        return member.getProfileUrl();
    }

이렇게 문자열 변환만 진행해주었더니.. 프로필 사진이 화면에 나오지 않는 문제가 발생했었다. 

즉 브라우저가 이미지  데이터 인식을 못해서 나타나는 문제였는데 ...

 

이를 해결하기 위해 열심히 구글링을 해본 결과 

이미지 데이터를 Base64로 인코딩하여 html 에서 직접 사용하려면 반드시 date URL 형식을 사용해야한다고 한다. 

base64 = "data:image/png;base64," + base64;

즉 이부분이 핵심이라고 생각하면 되는데 

data:image/png;base64, 이 부분이 브라우저에게 "이 문자열은 Base64로 인코딩된 PNG 이미지 데이터입니다." 라는 것을 알려주는 역할을 한다고 한다. 


<추가 js 코드>

// 프로필 사진 클릭 시 파일 업로드 입력창 표시
  document.querySelector(".profile-pic-container .overlay").addEventListener("click", function () {
    document.getElementById("profilePicInput").click();
  });

  // 파일이 선택되면 업로드 버튼을 표시
  document.getElementById("profilePicInput").addEventListener("change", function () {
    const uploadBtn = document.getElementById("uploadBtn");
    if (this.files && this.files.length > 0) {
      uploadBtn.style.display = "inline-block"; // 파일이 선택되면 버튼을 표시
    }
  });

<시연 영상>


처음에는 금방 구현을 할 줄 알았는데 의외로 시간이 걸려 당황했지만 그래도 몰랐던 부분을 알고 가는 것들이 많아서 즐거웠다. 

추후에 추가할 기능은 1. 브라우저 세션 사용 2.랜덤 아바타 api 적용해보기 이다.

 

 

 

수정해야하는 부분이나 더 추가해야 되는 부분에 대한 조언은 언제든 환영합니다.