필자는 이전 포스팅에서 Spring Security + JWT 로그인 기능 구현에 대해 작성해보았다. 프로젝트 마감 기한까지 얼마 남지 않아서... Refresh 토큰 생성 및 기능 구현은 팀 프로젝트가 끝나고 나서 해보기로 하고... 회원정보 수정을 위한 마이페이지 기능을 구현해보려 한다.

 

테스트

회원 정보를 수정할 수 있는 페이지이다. 회원 프로필 사진을 등록할 수 있고, 비밀번호나 주소 등 개인정보를 수정할 수 있는 영역이다.

프로필 업로드

먼저 본인 프로필 사진을 업로드하는 기능을 구현해 보자. 필자는 이 포스팅에서 JavaScript를 이용하여 파일 업로드 및 미리 보기 기능을 구현해 둔 상태다. 이제 업로드된 파일을 활용하여 데이터베이스에 저장하고 로그인한 회원이 마이페이지로 이동할 때마다 볼 수 있게 작업하려 한다.

 

1. HTML 작성

버튼 태그에 onclick 속성을 부여하고 uploadProfile() 메서드가 실행되도록 작성한다.

<div>
    <inputid="fileName" type="file" name="fileName" onchange="profile()"/>
    <button onclick="uploadProfile()" id="fileUpload">업로드</button>&nbsp;
</div>

 

2. JavaScript 작성

비동기 처리를 이용해 컨트롤러로 데이터를 전송한다. 데이터를 전송할 때 HTML로부터 업로드한 파일의 이름을 가져오고 마침표를 기준으로 하여 그 뒤 문자열을 가져오는 함수를 작성하여 파일의 확장자까지 같이 전송한다.

// 업로드파일 미리보기
function profile() {
        let selectFile = document.querySelector('#fileName').files[0];
        console.log(selectFile);    
        const file = URL.createObjectURL(selectFile);
        document.querySelector('#showFile').src = file;
}

// 파일 업로드
function uploadProfile() {
        // 업로드할 파일 이름
        const uploadFileName = document.getElementById("fileName").value;
        // 확장자
        const uploadFileType = getFileExtension(uploadFileName);

        $.ajax({
           type: "POST",
           url: "/uploadProfile",
           data: {
                   uploadFileName : uploadFileName,
                   uploadFileType : uploadFileType,
           },
           success: function success(res) {
                   if(res === "업로드 성공") {
                           swal("업로드 성공", "", "success");
                   } else {
                           swal("업로드 실패", "", "error");
                   }
           },
           error: function error(err) {

           }
        });
}

// .을 기준으로 확장자 추출하는 함수
function getFileExtension(filename) {
        // 파일 이름에서 마지막 '.'의 위치 찾기
        const lastDotIndex = filename.lastIndexOf(".");

        // '.'이 존재하지 않으면 빈 문자열 반환
        if (lastDotIndex === -1) return "";

        // '.' 뒤에 있는 문자열(확장자) 반환
        return filename.substring(lastDotIndex + 1);
}

3. Controller 작성

일단 파일명과 확장자를 잘 가져오는지 확인하기 위해 아래와 같이 코드를 작성하였다. 

package kr.co.vibevillage.user.controller;

import kr.co.vibevillage.user.model.dto.UserDTO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;

@Slf4j
@Controller
@RequiredArgsConstructor
public class MyPageController {

    @PostMapping("/uploadProfile")
    public String uploadProfile(UserDTO userDTO) {
        log.info("파일명: " + userDTO.getUploadFileName());
        log.info("확장자: " + userDTO.getUploadFileType());

        return null;
    }
}

 

이렇게 코드를 작성한 뒤 마이페이지에 파일을 업로드하고 업로드 버튼을 누르면... 아래와 같이 로그가 남는다.

파일명: C:\fakepath\smile_one.jpg
확장자: jpg

 

확장자는 잘 가져오는데 파일명은 정말 이상하다. 

1. 필자는 Mac OS 개발환경이다. 파일 위치가 C: 로 시작할 수 없다.
2. fakepath란 무엇일까..? 필자는 이런 경로를 설정한 적이 없다.

 

열심히 구글링을 해본다... 브라우저는 보안상의 이유로 <input type="file"> 요소를 통해 파일을 선택할 때, 실제 파일 경로 대신 가짜 경로 (C:\fakepath\)를 반환하며 이 경로는 실제 파일 경로를 숨기기 위한 브라우저의 표준 동작이라고 한다. 파일의 실제 이름만 가져오기 위해 input 요소의 value에서 경로를 제외하고 확장자를 추출하는 코드를 다시 작성해 주면 된다.

 

As-Is

// 파일 업로드
function uploadProfile() {
        // 업로드할 파일 이름
        const uploadFileName = document.getElementById("fileName").value;
        // 확장자
        const uploadFileType = getFileExtension(uploadFileName);

        $.ajax({
           type: "POST",
           url: "/uploadProfile",
           data: {
                   uploadFileName : uploadFileName,
                   uploadFileType : uploadFileType,
           },
           success: function success(res) {
                   if(res === "업로드 성공") {
                           swal("업로드 성공", "", "success");
                   } else {
                           swal("업로드 실패", "", "error");
                   }
           },
           error: function error(err) {

           }
        });
}

 

To-Be

// 파일 업로드
function uploadProfile() {
        const uploadFileElement = document.getElementById("fileName");
        const fullPath = uploadFileElement.value;
        // 업로드할 파일 이름
        const uploadFileName = fullPath.split('\\').pop().split('/').pop();  // 경로에서 파일명만 추출
        // 확장자
        const uploadFileType = getFileExtension(uploadFileName);

        $.ajax({
           type: "POST",
           url: "/uploadProfile",
           data: {
                   uploadFileName : uploadFileName,
                   uploadFileType : uploadFileType,
           },
           success: function success(res) {
                   if(res === "업로드 성공") {
                           swal("업로드 성공", "", "success");
                   } else {
                           swal("업로드 실패", "", "error");
                   }
           },
           error: function error(err) {

           }
        });
}

 

이제 다시 파일을 업로드하고 버튼을 눌러보면 아래와 같이 잘 가져오는 모습을 볼 수 있다.

파일명: smile_one.jpg
확장자: jpg

업로드

이제 데이터베이스와 파일 경로로 업로드하는 로직을 작성해 보자. 우선 컨트롤러와 JavaScript 코드의 수정이 필요하다. 실제 파일을 경로에 저장하기 위해서는 SpringFramework에서 제공하는 Multipart 인터페이스를 활용해야 한다. 이는 파일 업로드와 관련된 작업을 단순화하고, 안전하고 효율적으로 처리할 수 있으며 파일 업로드 기능을 구현할 때 발생할 수 있는 복잡성을 크게 줄여줄뿐더러, 개발자가 핵심 로직에 집중할 수 있게 도와주기 때문이다. 수정된 코드를 보자.

 

1. JavaScript 수정

FormData 객체는 multipart/form-data 형식으로 데이터를 전송할 수 있도록 도와준다. 이 형식은 특히 파일 업로드와 같은 작업에 적합하다. Controller에서 Multipart 인터페이스를 활용하기에 아래와 같이 코드를 수정해 주었다.

// 업로드파일 미리보기
function profile() {
        // querySelector 이용하여 selectFile 변수에 업로드된 파일 주소 초기화
        let selectFile = document.querySelector('#fileName').files[0];
        console.log(selectFile);    

        // createObjectURL 이용하여 파일주소를 file 변수에 초기화
        const file = URL.createObjectURL(selectFile);

        // querySelector 이용하여 img 태그 src 변경
        document.querySelector('#showFile').src = file;
}

// 파일 업로드
function uploadProfile() {
        const uploadFileElement = document.getElementById("fileName").files[0]; // 파일 객체를 가져옴

    // 파일이 선택되지 않았을 경우 처리
        if (!uploadFileElement) {
            swal("파일을 선택해주세요.", "", "info");
            return; // 함수 종료
        }

        const fullPath = uploadFileElement.name;
        const uploadFileName = fullPath.split('\\').pop().split('/').pop();  // 파일명 추출
        const uploadFileType = getFileExtension(uploadFileName); // 확장자 추출


        // FormData 객체 생성
        const formData = new FormData();
        formData.append("uploadFileName", uploadFileName); // 파일명 추가
        formData.append("uploadFileType", uploadFileType); // 파일타입 추가
        formData.append("uploadFileElement", uploadFileElement); // 실제 파일 추가

        $.ajax({
           type: "POST",
           url: "/uploadProfile",
           processData: false,
           contentType: false,
           data: formData,
           success: function success(res) {
                   if(res === "업로드 성공") {
                           swal("업로드 성공", "", "success");
                   } else if (res === "기존파일 삭제실패") {
                           swal("기존 프로필 삭제에 실패했습니다. ", "관리자에게 문의해주세요.", "error");
                   } else {
                           swal("업로드 실패", "", "error");
                   }
           },
           error: function error(err) {

           }
        });
}

// .을 기준으로 확장자 추출하는 함수
function getFileExtension(filename) {
        // 파일 이름에서 마지막 '.'의 위치 찾기
        const lastDotIndex = filename.lastIndexOf(".");

        // '.'이 존재하지 않으면 빈 문자열 반환
        if (lastDotIndex === -1) return "";

        // '.' 뒤에 있는 문자열(확장자) 반환
        return filename.substring(lastDotIndex + 1);
}

2. Controller 수정

uploadProfile 메서드의 매개변수에 MultipartFile 객체를 선언해 주어 Ajax로부터 전송받은 데이터를 담는다. AWS 배포를 할 예정이라면 S3라는 서버에 저장하는 메서드를 작성해야 하지만 일단은 로컬 환경에 저장하는 방식으로 작성해 보겠다. 기존에 프로필을 업데이트했던 유저라면 기존 파일을 삭제하고 새로운 프로필을 업데이트해주는 코드를 작성했다. 여기서 기존 파일을 삭제한다는 건 데이터 베이스에 저장되는 파일의 이름과 경로를 지워야 함과 동시에 로컬 저장소에 있는 파일 또한 삭제를 진행해야 한다.

package kr.co.vibevillage.user.controller;

import kr.co.vibevillage.user.model.dto.UserDTO;
import kr.co.vibevillage.user.model.service.LoginServiceImpl;
import kr.co.vibevillage.user.model.service.MyPageServiceImpl;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Random;

@Slf4j
@Controller
@RequiredArgsConstructor
public class MyPageController {

    private final MyPageServiceImpl myPageService;
    private final LoginServiceImpl loginService;
    private final PasswordEncoder passwordEncoder;

    @GetMapping("/getUserInfo")
    public String myPageForm(Model model) {
        .
        .
        .
        (생략)
    }

    // 프로필 사진 업로드
    @PostMapping("/uploadProfile")
    @ResponseBody
    public String uploadProfile(UserDTO userDTO, @RequestParam(value="uploadFileElement") MultipartFile uploadFile) {
        // 업로드 파일 있는지 확인하기
        UserDTO loginUser = loginService.getLoginUserInfo();// 로그인한 회원 정보 가져오기
        int loginUserNo = loginUser.getUserNo(); // 회원 번호 가져오기
        UserDTO profileResult = myPageService.getProfileInfo(loginUserNo); // 회원 프로필 정보 가져오기

        // 업로드 프로필 파일이 없다면
        if(profileResult == null) {
            uploadProfileMethod(userDTO, uploadFile);
        } else { // 업로드 파일이 있다면
            // 기존 파일 삭제 진행
            int result = myPageService.deleteProfile(loginUserNo);
            if (result == 1) {
                uploadProfileMethod(userDTO, uploadFile);
            } else {
                return "기존파일 삭제실패";
            }
        }
        return "업로드 성공";
    }
    
    // 랜덤한 알파벳 문자열 생성 함수
    private String getRandomAlphabets(int length) {
        Random random = new Random();
        StringBuilder sb = new StringBuilder();

        for (int i = 0; i < length; i++) {
            // 'a'부터 'z'까지의 랜덤한 알파벳을 추가
            char randomChar = (char) (random.nextInt(26) + 'a');
            sb.append(randomChar);
        }
        return sb.toString();
    }

    // 파일 업로드 메서드
    private String uploadProfileMethod(UserDTO userDTO, @RequestParam(value="uploadFileElement") MultipartFile uploadFile) {
        // 업로드 파일 있는지 확인하기
        UserDTO loginUser = loginService.getLoginUserInfo();// 로그인한 회원 정보 가져오기
        int loginUserNo = loginUser.getUserNo(); // 회원 번호 가져오기
        UserDTO profileResult = myPageService.getProfileInfo(loginUserNo); // 회원 프로필 정보 가져오기

        // 업로드할 파일 경로 설정
        String uploadPath = "/Users/keem/finalProject/vibevillage/src/main/resources/static/images/userProfile";
        // userDTO 객체에 경로 초기화
        userDTO.setUploadFilePath(uploadPath);

        log.info(uploadFile.getOriginalFilename());

        // 유니크한 파일명 생성하기
        String originalFileName = userDTO.getUploadFileName(); // 원본 파일명

        String currentDate = new SimpleDateFormat("yyyyMMdd").format(new Date()); // 현재 날짜를 "yyyyMMdd" 형식으로 포맷팅
        String randomAlphabets = getRandomAlphabets(6); // 랜덤한 6개의 알파벳 생성
        String uniqueFileName = currentDate + randomAlphabets + originalFileName; // 새로운 유니크한 파일명 생성

        // 파일명을 userDTO에 설정
        userDTO.setUploadFileUniqueName(uniqueFileName);

        // 가져온 회원번호를 userDTO 객체에 초기화
        userDTO.setUserNo(loginUserNo);

        log.info(userDTO.toString());

        // service 객체를 이용한 파일 업로드
        int result = myPageService.uploadProfile(userDTO, uploadFile);

        if (result == 1) {
            return "업로드 성공";
        } else {
            return "업로드 실패";
        }
    }
}

3. Service 작성

컨트롤러에서 myPageService 객체를 이용하여 매개변수가 담긴 uploadProfile메서드를 호출하고 있다. 실제 데이터베이스에 저장해야 하고, 설정해 둔 로컬 환경의 저장소에도 저장하는 로직을 작성하면 된다.

package kr.co.vibevillage.user.model.service;

import kr.co.vibevillage.user.model.dto.UserDTO;
import org.springframework.web.multipart.MultipartFile;

public interface MyPageService {

    // 프로필 업로드
    public int uploadProfile(UserDTO userDTO, MultipartFile uploadFile);

    // 프로필 조회
    public UserDTO getProfileInfo(int loginUserNo);

    // 프로필 삭제
    public int deleteProfile(int loginUserNo);

    // 프로필 변경
    public int updateProfile(UserDTO userDTO);
}
package kr.co.vibevillage.user.model.service;

import kr.co.vibevillage.user.model.dto.UserDTO;
import kr.co.vibevillage.user.model.mapper.MyPageMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;

@Service
@RequiredArgsConstructor
public class MyPageServiceImpl implements MyPageService{

	// mapper 객체 생성
    private final MyPageMapper myPageMapper;

    // 프로필 업로드
    @Override
    public int uploadProfile(UserDTO userDTO, MultipartFile uploadFile) {
        // 유저가 업로드한 파일을 저장할 위치
        String uploadPath = "C:\\dev\\File\\FinalProject\\Vibevillage\\TeamProject_VibeVillage\\vibevillage\\src\\main\\resources\\static\\images\\userProfile\\";
        // 위치 + 유니크 파일명
        String filePathName = uploadPath + userDTO.getUploadFileUniqueName();
        // Path 타입으로 형변환
        Path filePath = Paths.get(filePathName);

        try {
            // 기존 파일 삭제
            if (userDTO.getUploadFileUniqueName() != null) {
                deleteExistingFile(userDTO.getUploadFileUniqueName(), uploadPath);
            }

            // 새로운 파일 업로드
            uploadFile.transferTo(filePath);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
        return myPageMapper.uploadProfile(userDTO);
    }

    // 기존 파일 삭제
    private void deleteExistingFile(String fileName, String uploadPath) {
        File file = new File(uploadPath + fileName);
        if (file.exists()) {
            boolean deleted = file.delete();
            if (!deleted) {
                throw new RuntimeException("기존 파일 삭제에 실패했습니다.");
            }
        }
    }

    // 프로필 조회
    @Override
    public UserDTO getProfileInfo(int loginUserNo) {
        return myPageMapper.getProfileInfo(loginUserNo);
    }

    // 프로필 삭제
    @Override
    public int deleteProfile(int loginUserNo) {
        UserDTO profile = myPageMapper.getProfileInfo(loginUserNo);
        if (profile != null) {
            deleteExistingFile(profile.getUploadFileUniqueName(), "/Users/keem/finalProject/vibevillage/src/main/resources/static/images/userProfile/");
        }
        return myPageMapper.deleteProfile(loginUserNo);
    }

    // 프로필 변경
    @Override
    public int updateProfile(UserDTO userDTO) {
        System.out.println(userDTO.toString());
        return myPageMapper.updateProfile(userDTO);
    }

}

4. Mapper 작성

프로필 파일의 이름과 경로를 데이터 베이스에 저장할 수 있도록 mapper를 작성한다.

mapper.xml 파일을 보면 U_NAME = #{userName} 이처럼 작성했는데 UserDTO 클래스에 선언해 둔 변수명을 중괄호 안에 입력해 주면 Spring이 인식하여 매개변수로 받은 userDTO 객체에 있는 값을 꺼내준다.

package kr.co.vibevillage.user.model.mapper;

import kr.co.vibevillage.user.model.dto.UserDTO;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface MyPageMapper {

    // 프로필 업로드
    public int uploadProfile(UserDTO userDTO);

    // 프로필 조회
    public UserDTO getProfileInfo(int loginUserNo);

    // 프로필 삭제
    public int deleteProfile(int loginUserNo);

    // 프로필 변경
    public int updateProfile(UserDTO userDTO);
}
<?xml version="1.0" encoding="UTF-8"?>

<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="kr.co.vibevillage.user.model.mapper.MyPageMapper">

    <resultMap id="loginUserInfo" type="kr.co.vibevillage.user.model.dto.UserDTO">
        <result column="UU_NO" property="uploadFileNo"></result>
        <result column="UU_NAME" property="uploadFileName"></result>
        <result column="UU_UNIQUE_NAME" property="uploadFileUniqueName"></result>
        <result column="UU_LOCAL_PATH" property="uploadFilePath"></result>
        <result column="UU_FILE_TYPE" property="uploadFileType"></result>
    </resultMap>

    <insert id="uploadProfile" parameterType="kr.co.vibevillage.user.model.dto.UserDTO">

        <selectKey keyProperty="uploadFileNo" resultType="_int" order="BEFORE">
            SELECT USER_UPLOAD_SEQ.nextval FROM dual
        </selectKey>

        INSERT INTO USER_UPLOAD VALUES(#{uploadFileNo}, #{uploadFileName}, #{uploadFileUniqueName}, #{uploadFilePath}, null, #{uploadFileType}, #{userNo})
    </insert>

    <select id="getProfileInfo" resultMap="loginUserInfo">
        SELECT U_NO, UU_NO, UU_NAME, UU_UNIQUE_NAME, UU_LOCAL_PATH, UU_SERVER_PATH, UU_FILE_TYPE FROM USER_UPLOAD WHERE U_NO = #{loginUserNo}
    </select>

    <delete id="deleteProfile">
        DELETE USER_UPLOAD WHERE U_NO = #{loginUserNo}
    </delete>

    <update id="updateProfile" parameterType="kr.co.vibevillage.user.model.dto.UserDTO">
    UPDATE USER_VIBEVILLAGE
        SET
            U_NAME = #{userName},
            U_NICKNAME = #{userNickName},
            U_PHONE = #{userPhone},
            U_POSTCODE = #{userPostCode},
            U_ADDRESS = #{userAddress},
            U_DETAIL_ADDRESS = #{userDetailAddress},
            U_EXTRA_ADDRESS = #{userExtraAddress},
            U_BIRTHDATE = #{userBirthDate}
        WHERE
            U_NO = #{userNo}
    </update>
</mapper>

 

이제 테스트를 진행해 보자. 기본 프로필 화면이던 계정에서.... 파일을 선택하고 업로드 버튼을 클릭하면...!!

기본 이미지가 적용된 모습.

 

업로드 성공 문구와 함께 데이터 베이스에도 저장된 모습을 확인할 수 있다.

파일명, 경로가 데이터베이스에 저장된 모습

 

이렇게 프로필 업로드 기능을 구현해 보았다. 프로필이 존재하는 경우 기존 파일을 삭제한 뒤 새로운 파일로 업데이트해주는 과정이 필요한데, 이렇게 하나의 기능에 여러 가지 비즈니스 로직이 실행되는 경우 트랜잭션(Transaction)을 활용하여 기능을 관리해 주는 것이 안전하다. 트랜잭션은 프로젝트의 기본적인 구현이 완료되고 나서 리펙토링을 진행할 때 작업해 볼 생각이다. 이 또한 포스팅으로 남기겠다. 비밀번호 및 회원 정보 변경 관련된 코드는 회원가입과 크게 다르지 않기에 포스팅은 따로 진행하지 않고 다음 포스팅에는 카카오 로그인에 관련된 글을 작성하겠다. 

김현중 (keemhing)