마이 페이지 - 프로필 이미지 변경 기능 구현 (23.08.24)

·2023년 8월 24일
1

Spring

목록 보기
21/36
post-thumbnail

🌷 프로필 이미지 변경

오늘은 마이 페이지에서 프로필 이미지를 변경(추가, 수정, 삭제)하는 기능을 구현해 보려고 한다.


👀 코드로 살펴보기

🌼 VS Code

파일을 제출할 때는 무조건 POST 방식이다. 파일이 제출될 때 인코딩 되는 방법을 지정해 주어야 하는데, 이것이 바로 enctype 속성이다.

💭 enctype 속성

form 태그 데이터가 서버로 제출될 때 인코딩 되는 방법을 지정하는 속성
(POST 방식일 때만 사용 가능)

  • application/x-www-form-urlencoded : 모든 문자를 서버로 전송하기 전에 인코딩
    (form 태그 기본값)
  • multipart/form-data : 모든 문자를 인코딩하지 않음
    (원본 데이터가 유지되어 이미지, 파일 등을 서버로 전송할 수 있음)

따라서 이미지 파일을 제출하기 위해서는 multipart/form-data로 지정해 주어야 한다.

🌱 myPage-profile.jsp

<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"  %>

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">

    <title>My Page</title>

    <link rel="stylesheet" href="/resources/css/myPage/myPage-style.css">
</head>
<body>
    <main>
       <jsp:include page="/WEB-INF/views/common/header.jsp"/>

        
        <!-- 마이페이지 - 내 정보 -->
        <section class="myPage-content">
            
			<!-- 사이드메뉴 include -->
			<!-- jsp 액션 태그 -->
			<jsp:include page="/WEB-INF/views/myPage/sideMenu.jsp"/>


            <!-- 오른쪽 마이페이지 주요 내용 부분 -->
            <section class="myPage-main">

                <h1 class="myPage-title">프로필</h1>
                <span class="myPage-subject">프로필 이미지를 변경할 수 있습니다.</span>

                <%--
                - multipart/form-data : 모든 문자를 인코딩하지 않음
				(원본 데이터가 유지되어 이미지, 파일 등을 서버로 전송할 수 있음)
               	--%>

                <form action="profile" method="POST" name="myPageFrm" id="profileFrm" enctype="multipart/form-data">

                    <div class="profile-image-area">

                        <%-- 프로필 이미지가 없으면 기본 이미지 --%>
                        <c:if test="${empty loginMember.profileImage}" >
                            <img src="/resources/images/user.png" id="profileImage">
                        </c:if>

                        <%-- 프로필 이미지가 있으면 있는 이미지 --%>
                        <c:if test="${!empty loginMember.profileImage}" >
                            <img src="${loginMember.profileImage}" id="profileImage">
                        </c:if>

                    </div>
                    <span id="deleteImage">x</span>

                    <div class="profile-btn-area">
                        <label for="imageInput">이미지 선택</label>
                        <input type="file" name="profileImage" id="imageInput" accept="image/*">
                        <button>변경하기</button>
                    </div>
                    
                    <div class="myPage-row">
                        <label>이메일</label>
                        <span>${loginMember.memberEmail}</span>
                    </div>
                    
                    <div class="myPage-row">
                        <label>가입일</label>
                        <span>${loginMember.enrollDate}</span>
                    </div>
                    
                </form>

                

            </section>

        </section>

    </main>

	<jsp:include page="/WEB-INF/views/common/footer.jsp"/>

    <%-- myPage.js 연결 --%>
    <script src="/resources/js/myPage/myPage.js"></script>

</body>
</html>

🌱 myPage-style.css

💭 width / height 관련 속성

  • width / height
    - 지정된 크기로 고정

  • min-width / min-height
    - 내부 요소가 부모 크기보다 작아도 지정된 최소 크기를 유지
    -> 단, 내부 요소가 부모 크기를 초과화면 부모의 크기가 늘어남

  • max-width / max-height
    - 내부 요소가 부모 크기보다 커도 지정된 크기를 유지
    -> 단, 내부 요소가 부모 크기보다 작다면 부모의 크기가 줄어듦
/* 회원 페이지 전체를 감싸고 있는 요소 */
.myPage-content{
    display: flex;
    width: 1000px;
    min-height: 700px;
    margin: 50px auto;
}

/* 사이드메뉴 */
.left-side{
    width: 25%;
    border-right: 2px solid #ddd;
}

.list-group{
    width: 100%;
    list-style: none;
    padding-right: 20px;
}

.list-group > li{
    height: 50px;
    font-size: 18px;
}

.list-group > li > a{
    color:black;
    text-decoration: none;

    display: flex;
    height: 100%;

    justify-content: center;
    align-items: center;

    border-bottom : 2px solid #ddd;
}

.list-group > li > a:hover{
    background-color: #ccc;
}

/* ********************************* */
/* 마이페이지 공통 */
.myPage-main{
    width: 75%;
    padding: 0 50px;
}

/* 마이페이지 제목 */
.myPage-title{
    margin-bottom: 10px;
    font-size: 30px;
}

/* 마이페이지 부제 */
.myPage-subject{
    display: block;
    margin-bottom: 30px;

    font-size: 14px;
    letter-spacing: -1px;
}

/* 마이페이지 행 단위 스타일 지정 */
.myPage-row{
    width: 500px;
    height: 50px;
    margin-top: 20px;

    display: flex;
    align-items: center;
    border-bottom : 2px solid #ddd;
}

.myPage-row > * {
    font-size: 18px;
    font-weight: bold;
}

/* 행 제목 */
.myPage-row > label{
    width: 30%;
    color: #455ba8;
}

.myPage-row > span{
    width: 70%;
    color: #455ba8;
}

/* 행 내부 input 태그 */
.myPage-row > input{
    width: 100%;
    height: 100%;
    border: none;
    outline: none;
    font-weight: normal;
}

/* 제출 버튼 */
.myPage-submit{
    width: 100%;
    padding: 10px;
    margin: 50px 0;
    
    border: none;
    font-size: 20px;
    font-weight: bold;

    background-color: #455ba8;
    color: white;
    cursor: pointer;
}

/* form태그 */
form[name='myPageFrm']{
    display: flex;
    justify-content: center;
    align-items: center;
    flex-direction: column;
}

/* 내부 input 요소 focus 시 */
.myPage-row:focus-within{ 
    border-bottom-color: #455ba8;
}


/* ******************************************* */
/* 내 정보 페이지 전용 스타일 */

.info-title{
    border: none;
}

.info-address{
    margin: 0;
}

.info-address > button{
    width: 30%;
    height: 70%;

    font-size: 14px;
    font-weight: normal;

    background-color: white;
    border : 1px solid gray;
    cursor: pointer;
}

/* *********비밀번호 변경 화면*********** */
.myPage-row > input[type='password']{
    width: 70%;
} 

/* **********회원 탈퇴 약관********** */
.secession-terms{
    width: 500px;
    height: 300px;
    border: 1px solid black;
    
    overflow: auto;
    /* 내용이 요소를 벗어나는 경우 방향에 맞춰서 자동으로 스크롤 추가 */

    font-family: sans-serif; /* 돋움체 */
    font-size: 14px;
}

/* ************* 프로필 화면 ************* */
.profile-image-area{
    width: 150px;
    height: 150px;
    border: 3px solid #ccc;
    border-radius: 50%;

    position: relative;

    overflow: hidden;
    display: flex;
    justify-content: center;
    align-content: center;
}

#profileImage{
    height: 100%;
}

/* 삭제버튼 */
form[name='myPageFrm']{position: relative;}

#deleteImage{
    position: absolute;
    top: 0px;
    right: 240px;
    cursor: pointer;
}

/* 이미지 버튼 영역 */
.profile-btn-area{
    width: 230px;
    margin: 20px 0;

    display: flex;
    justify-content: center;
    align-items: center;
}

.profile-btn-area > *{
    width: 110px;
    height: 33px;
    padding: 5px 10px;

    border: 1px solid black;
    background-color: white;
    font-size: 14px;
    cursor: pointer;
    text-align: center;
}

#imageInput{ display: none;}

.profile-btn-area > button{
    background-color: #455ba8;
    color : white;
    margin-left: 2px;
}

🌱 myPage.js

💭 change 이벤트

  • input type="file", "checkbox", "radio"에서 많이 사용
  • text/number 형식 사용 가능
    -> 이때 input값 입력 후 포커스를 잃었을 때 이전 값과 다르면 change 이벤트 발생
...
// 프로필 이미지 추가/변경/삭제
const profileImage = document.getElementById("profileImage"); // img 태그
const deleteImage = document.getElementById("deleteImage"); // x 버튼
const imageInput = document.getElementById("imageInput"); // input 태그

let initCheck; // 초기 프로필 이미지 상태를 저장하는 변수
               // false == 기본 이미지, true == 이전 업로드 이미지

let deleteCheck = -1;
// 프로필 이미지가 새로 업로드되거나 삭제되었음을 나타내는 변수
// -1 == 초기값, 0 == 프로필 삭제(x버튼), 1 == 새 업로드 이미지

let originalImage; // 초기 프로필 이미지 파일 경로 저장

if(imageInput != null){ // 화면에 imageInput이 있을 경우

    // 프로필 이미지가 출력되는 img 태그의 src 속성을 저장
    originalImage = profileImage.getAttribute("src");

    // 회원 프로필 화면 진입 시 
    // 현재 회원의 프로필 이미지 상태를 확인
    if(originalImage == "/resources/images/user.png"){
        // 기본 이미지인 경우
        initCheck = false;

    } else {
        initCheck = true;
    }

    // change 이벤트 : 값이 변했을 때
    // - input type="file", "checkbox", "radio"에서 많이 사용
    // - text/number 형식 사용 가능
    //  -> 이때 input값 입력 후 포커스를 잃었을 때
    //     이전 값과 다르면 change 이벤트 발생

    imageInput.addEventListener("change", e => {

        // 2MB로 최대 크기 제한
        const maxSize = 1 * 1024 * 1024 * 2; // 파일의 최대 크기 지정(바이트 단위)

        console.log(e.target); // input
        console.log(e.target.value); // 업로드된 파일 경로
        console.log(e.target.files); // 업로드된 파일의 정보가 담긴 배열

        const file = e.target.files[0]; // 업로드한 파일의 정보가 담긴 객체

        // 파일을 한 번 선택한 후 취소했을 때
        if(file == undefined){
            console.log("파일 선택이 취소됨");
            deleteCheck = -1; // 취소 == 파일 없음 == 초기 상태

            // 취소 시 기존 프로필 이미지로 변경
            profileImage.setAttribute("src", originalImage);

            return;
        }

        if(file.size > maxSize){ // 선택된 파일의 크기가 최대 크기를 초과한 경우
            alert("2MB 이하의 이미지를 선택해 주세요.");
            imageInput.value = "";
            // input type="file" 태그에 대입할 수 있는 value는 ""(빈칸) 뿐이다!
            
            deleteCheck = -1; // 취소 == 파일 없음 == 초기 상태

            // 기존 프로필 이미지로 변경
            profileImage.setAttribute("src", originalImage);
            
            return;
        }

        // JS에서 파일을 읽는 객체
        // - 파일을 읽고 클라이언트 컴퓨터에 파일을 저장할 수 있음
        const reader = new FileReader();

        reader.readAsDataURL(file);
        // 매개변수에 작성된 파일을 읽어서 저장 후
        // 파일을 나타내는 URL을 result 속성으로 얻어올 수 있게 함

        // 다 읽었을 때
        reader.onload = e => {
            // console.log(e.target);
            // console.log(e.target.result); // 읽은 파일의 URL

            const url = e.target.result;

            // 프로필 이미지(img) 태그에 src 속성으로 추가
            profileImage.setAttribute("src", url);

            deleteCheck = 1;
        }
    
    });

    // x 버튼 클릭 시
    deleteImage.addEventListener("click", () =>{

        // 프로필 이미지를 기본 이미지로 변경
        profileImage.setAttribute("src", "/resources/images/user.png");

        imageInput.value = ""; // input type="file"의 value 삭제

        deleteCheck = 0;
        
    });

    // #profileFrm이 제출되었을 때
    document.getElementById("profileFrm").addEventListener("submit", e=>{

        // let initCheck;
        // 초기 프로필 이미지 상태를 저장하는 변수
        // false == 기본 이미지, true == 이전 업로드 이미지

        // let deleteCheck = -1;
        // 프로필 이미지가 새로 업로드되거나 삭제되었음을 나타내는 변수
        // -1 == 초기값, 0 == 프로필 삭제(x버튼), 1 == 새 업로드 이미지

        let flag = true;

        // 프로필 이미지가 없다 -> 있다
        if(!initCheck && deleteCheck == 1) flag = false;

        // 이전 프로필 이미지가 있다 -> 삭제
        if(initCheck && deleteCheck == 0) flag = false;

        // 이전 프로필 이미지가 있다 -> 새 이미지
        if(initCheck && deleteCheck == 1) flag = false;

        if(flag) { // flag == true -> 제출하면 안 되는 경우
            e.preventDefault(); // form 기본 이벤트 제거
            alert('이미지 변경 후 클릭하세요.');
        }
    })
}

🌼 Spring

💭 파일 업로드를 위해서 어떻게 해야 할까?

먼저 파일 업로드 관련 라이브러리를 pom.xml에 추가해야 한다.

🌱 pom.xml

...
		<!-- 파일 업로드 관련 라이브러리 -->
		<!-- https://mvnrepository.com/artifact/commons-fileupload/commons-fileupload -->
		<dependency>
			<groupId>commons-fileupload</groupId>
			<artifactId>commons-fileupload</artifactId>
			<version>1.5</version>
		</dependency>
...

또한 root-context.xml에서 multipartResolver를 bean으로 등록해야 한다.

💡 파일 업로드를 위한 MutipartResolver 구현체 CommonsMultipartResolver bean 등록

CommonsMultipartResolver를 bean으로 등록하면 multipart/form-data 형식으로 요청 시
input type="file" 태그를 자동적으로 인식하여 MultipartFile 객체로 반환하고,
파일 외의 데이터(정수, 문자열 등의 텍스트 데이터)는 기존처럼 사용 가능
(MultipartRequest 필요 없음! 😉)

🌱 root-context.xml

...
	<bean id="multipartResolver" class="org.springframework.web.multipart.commons.CommonsMultipartResolver">
	    <property name="maxUploadSize" value="104857600"/>
	    <property name="maxUploadSizePerFile" value="104857600"/>
	    <property name="maxInMemorySize" value="104857600"/>
	</bean>
...
  • 104857600 byte == 100MB
  • maxUploadSize
    한 요청당 업로드가 허용되는 최대 용량을 바이트 단위로 설정
    -1은 제한이 없다는 뜻으로 이 property를 지정하지 않을 때의 기본값
  • maxUploadSizePerFile
    한 파일당 업로드가 허용되는 최대 용량을 바이트 단위로 설정
    -1은 제한이 없다는 뜻으로 이 property를 지정하지 않을 때의 기본값
  • maxInMemorySize
    디스크에 저장하지 않고 메모리에 유지하도록 허용하는 바이트 단위의 최대 용량을 설정
    사이즈가 이보다 클 경우 이 사이즈 이상의 데이터는 파일에 저장됨
    -> 기본값 : 10240 byte

🌱 MyPageController.java

💭 MultipartFile란?

input type="file"로 제출된 파일을 저장한 객체

  • 제공하는 메소드
    - transferTo() : 파일을 지정된 경로에 저장(메모리 -> HDD/SSD)
    - getOriginalFileName() : 파일 원본명
    - getSize() : 파일 크기
...
	// 프로필 이미지 수정
	@PostMapping("/profile")
	public String updateProfile(
			@RequestParam("profileImage") MultipartFile profileImage // 업로드 파일
			, @SessionAttribute("loginMember") Member loginMember // 로그인한 회원
			, RedirectAttributes ra // 리다이렉트 시 메시지 전달
			, HttpSession session // 세션 객체
			) throws IllegalStateException, IOException {
		
		// 웹 접근 경로
		String webPath = "/resources/images/member/";
		
		// 실제로 이미지 파일이 저장되어야 하는 서버 컴퓨터 경로
		String filePath = session.getServletContext().getRealPath(webPath);
		
		// 프로필 이미지 수정 서비스 호출
		int result = service.updateProfile(profileImage, webPath, filePath, loginMember);
		
		String message = null;
		
		if(result > 0) message = "프로필 이미지가 변경되었습니다.";
		else		   message = "프로필 변경 실패";
		
		ra.addFlashAttribute("message", message);
		
		return "redirect:profile";
		
	}

🌱 MyPageService.java

...
	/** 프로필 이미지 수정 서비스
	 * @param profileImage
	 * @param webPath
	 * @param filePath
	 * @param loginMember
	 * @return result
	 */
	int updateProfile(MultipartFile profileImage,
			String webPath, String filePath, Member loginMember) throws IllegalStateException, IOException;

🌱 MyPageServiceImpl.java

...
	// 프로필 이미지 수정 서비스
	@Override
	public int updateProfile(MultipartFile profileImage, String webPath, String filePath, Member loginMember) throws IllegalStateException, IOException {
		
		// 프로필 이미지 변경 실패 대비
		String temp = loginMember.getProfileImage(); // 이전 이미지 저장
		
		String rename = null; // 변경 이름 저장 변수

		if(profileImage.getSize() > 0) { // 업로드된 이미지가 있을 경우 
			
			// 1) 파일 이름 변경
			rename = fileRename(profileImage.getOriginalFilename());
			
			// 2) 바뀐 이름 loginMember에 세팅
			loginMember.setProfileImage(webPath + rename);
										// resources/images/member/20230824114510_12345.jpg
		} else { // 없는 경우 (X 버튼)
			loginMember.setProfileImage(null);
			// 세션 이미지를 null로 변경해서 삭제
		}
		
		// 프로필 이미지 수정 DAO 메소드 호출
		int result = dao.updateProfile(loginMember);
		
		if(result > 0) { // 성공

			// 새 이미지가 업로드된 경우
			if(rename != null) {
				profileImage.transferTo(new File(filePath + rename));
			}
			
		} else { // 실패
			
			// 이전 이미지로 프로필 다시 세팅
			loginMember.setProfileImage(temp);
		}
		
		return result;
	}
	
	// 파일명 변경 메소드
	public static String fileRename(String originFileName) {
		SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss");
		String date = sdf.format(new java.util.Date(System.currentTimeMillis()));
		
		int ranNum = (int) (Math.random() * 100000); // 5자리 랜덤 숫자 생성
		
		String str = "_" + String.format("%05d", ranNum);
		
		String ext = originFileName.substring(originFileName.lastIndexOf("."));
		
		return date + str + ext;
	}

🌱 MyPageDAO.java

...
	/** 프로필 이미지 수정
	 * @param loginMember
	 * @return result
	 */
	public int updateProfile(Member loginMember) {
		return sqlSession.update("myPageMapper.updateProfile", loginMember);
	}

🌱 myPage-mapper.xml

...
	<!-- 프로필 이미지 수정 -->
	<update id="updateProfile">
		UPDATE MEMBER SET
		PROFILE_IMG = #{profileImage}
		WHERE MEMBER_NO = #{memberNo}
	</update>

💻 구현 화면

프로필 변경 화면이다. 이미지 선택을 눌러 원하는 이미지를 열어 보자.

원하는 이미지가 프로필 사진 영역에 나타난 모습을 볼 수 있다.
이때 2MB 이상의 이미지를 업로드하면 어떻게 될까?

설정한 maxSize(2MB)를 초과한 이미지를 업로드하자 위와 같은 alert 창이 출력된다. 👍

profile
풀스택 개발자 기록집 📁

0개의 댓글