개주 훈련일지/🏋️ 전집중 호흡 훈련

프로필 이미지 변경 최종 확정 구현: temp→final 이동 + 캐시 차감 + 롤백 설계

lshfood2 2025. 12. 25. 23:12

[ 구현 목표 ]

어제 포스팅에서는 프로필 이미지 업로드를

비동기로 처리해서 리사이징된 결과물을

'미리보기'로 먼저 보여주는 UX를 만들었다.

 

오늘 포스팅은 그 다음 단계다.

  • 사용자가 “수정 완료”를 눌렀을 때만
  • 임시 파일을 최종 폴더로 커밋하고
  • DB 업데이트 + 캐시 차감을 수행한다.

즉, 유료 기능(캐시 소모)에서 가장 중요한 원칙

“미리보기는 무료, 확정만 유료”

 

이 원칙을 서버 정책으로 완성하는 로직이

바로 ChangeProfileAction 이다.


왜 “커밋 단계”가 따로 필요했나

미리보기 업로드(ProfileImageUploadAction)까지만 있으면,

이미지 업로드는 완료 로 끝난 것처럼 보인다.

하지만 유료 기능이면 여기서 체크할 사항이 있다.

  • 미리보기 단계에서 캐시가
    먼저 빠지면 사용자 신뢰가 깨진다.
  • 반대로 최종 확정 없이 final 폴더/DB에
    바로 반영되면 관리가 꼬인다.
  • token 검증이 없으면 다른 사용자의
    임시 파일을 탈취할 가능성도 생긴다.

그래서 구조는 반드시 2단계여야 한다.

임시 업로드(무료) → 최종 확정(유료)

전체 흐름: “마이페이지 수정 완료”는 최종 확정(커밋)이다

1) 닉네임 변경 판정

- 파라미터 없거나 공백이면 “변경 안 함(유지)”

- DB 값과 다르면 변경으로 판정

 

2) 프로필 이미지 변경 판정(토큰 기반 커밋)

- token이 없으면 이미지 변경 안 함

- token이 있으면 3단 검증 통과해야만 변경 확정

  → 세션 token/path 존재

  →  요청 token == 세션 token

  →  임시파일이 실제 존재

 

3) 비용 정책(서버 확정)

- 닉네임만: 300

- 이미지: 500

- 둘 다: 800(합산)

 

4) 잔액 검증 → 부족하면 실패

 

5) 이미지 커밋

- 임시파일(temp) → 최종폴더(final)로 이동

- 파일명 timestamp로 캐시 갱신 문제 완화

- DB에는 웹 상대경로 저장

 

6) DB 업데이트 실패 시 롤백(최종파일 삭제 시도)

 

7) 성공 후 정리

- 세션 token/path 제거

- 남아있는 임시파일 정리(안전장치 포함)

- 기존 프로필 파일 삭제(이미지 변경 성공일 때만)

 

8) PRG 패턴

- redirect로 myPage.do 재진입(중복 차감 방지)


흐름 다이어그램 (한눈에 보기)

[마이페이지 수정완료 POST]
  ↓
[ChangeProfileAction]
  - 로그인 체크
  - DB 현재 회원정보 조회
  - 변경 판정(닉네임/이미지)
    · 이미지는 token/세션/파일존재 3단 검증
  - 비용 계산(300/500/800)
  - 잔액 검증
  - (이미지 변경이면) temp → final 이동 + timestamp 파일명
  - DB 업데이트(케이스별 condition 분기)
  - DB 실패 시 최종파일 롤백 삭제
  - 성공 후 세션/임시/기존파일 정리
  - redirect /myPage.do (PRG)

핵심은 “업로드 = 결제”가 아니라
“미리보기(무료) → 확정(유료)” 로 책임을 분리한 것이다.


[ 구현 포인트 (기술적으로 중요했던 것들) ]

아래부터는 “핵심 포인트” 별로 실제 코드가
어디에서 그걸 구현하고 있는지 정리한다.

(전체 코드는 글 맨 마지막에 참고용으로 첨부)

 

1) 변경 여부는 서버가 확정한다 (DB 값과 요청 값을 비교)

닉네임은 사용자가 아무 값이나 보낼 수 있다.
그래서 서버가 “현재 닉네임(DB)”과

“요청 닉네임”을 비교해 변경 여부를 확정한다.

String requestMemberNicknameParameter = request.getParameter(PARAM_MEMBER_NICKNAME);
// 사용자가 V에서 입력한 변경 희망 닉네임
String requestMemberNickname = currentMemberNickname; // 기본은 유지
// 사용자 변경 희망 닉네임을 담을 변수

if (requestMemberNicknameParameter != null) {
    String trimmedNickname = requestMemberNicknameParameter.trim();
    if (!trimmedNickname.isEmpty()) {
        requestMemberNickname = trimmedNickname;
    }
}

boolean isMemberNicknameChanged = false;
// 닉네임 변경 유무 - 기본값은 F
if (!requestMemberNickname.equals(currentMemberNickname)) {
    // 현재 닉네임과 요청 닉네임이 같지 않으면 변경 희망
    isMemberNicknameChanged = true;
}

여기서 포인트는 “공백이면 실패”가 아니라 “유지”로 처리한 점이다.
(사용자 입력 실수로 전체 기능이 깨지지 않게 UX를 잡았다.)


2) 이미지 변경은 “토큰 기반 커밋”으로만 허용한다 (3단 검증)

이미지 변경은 단순히 경로 문자열을 받아서 바꾸면 위험하다.
그래서 미리보기 단계에서 생성된 token을 기반으로,

아래 3개를 모두 통과해야만 커밋을 허용한다.

- 1. 세션 token/path 존재

- 2. 요청 token == 세션 token

- 3. 임시 파일이 실제 존재

String requestTemporaryProfileImageTokenParameter = request.getParameter(PARAM_TEMPORARY_PROFILE_IMAGE_TOKEN);
// 사용자가 V에서 입력한 변경 희망 프로필 이미지 토큰

String requestTemporaryProfileImageToken = null;
// 사용자 변경 희망 프로필 이미지를 담을 변수

if (requestTemporaryProfileImageTokenParameter != null) {
    // V에서 넘어온 이미지 토큰이 있다면
    String trimmedToken = requestTemporaryProfileImageTokenParameter.trim();
    // 공백 제거해서 담고
    if (!trimmedToken.isEmpty()) {
        // 공백 제거했는데 "비어있지 않다면"
        requestTemporaryProfileImageToken = trimmedToken;
        // 사용자 변경 희망 프로필 이미지에 담아 - 커밋할 수 있다.
    }
}

// ProfileImageUploadAction이 세션에 저장해둔 “임시 업로드 결과” 꺼내기
String sessionTemporaryProfileImageToken = (String) session.getAttribute(SESSION_PROFILE_IMAGE_TOKEN);
// 임시 업로드 결과에 따른 토큰
String sessionTemporaryProfileImageAbsolutePath = (String) session.getAttribute(SESSION_PROFILE_IMAGE_ABSOLUTE_PATH);
// 임시 업로드 결과에 따른 임시 저장 경로(절대경로)

boolean isMemberProfileImageChanged = false;
// 프로필 이미지 변경 유무 - 기본값은 F

if (requestTemporaryProfileImageToken != null) {

    // (1) 세션 값 존재해야 함
    if (sessionTemporaryProfileImageToken == null || sessionTemporaryProfileImageAbsolutePath == null) {
        request.setAttribute("msg", "임시 이미지 정보가 없습니다. 다시 업로드 해주세요.");
        forward.setRedirect(false);
        forward.setPath("/mypage.jsp");
        return forward;
    }

    // (2) 토큰 일치 검증
    if (!requestTemporaryProfileImageToken.equals(sessionTemporaryProfileImageToken)) {
        request.setAttribute("msg", "프로필 이미지 토큰이 일치하지 않습니다.");
        forward.setRedirect(false);
        forward.setPath("/mypage.jsp");
        return forward;
    }

    // (3) 임시 파일 존재 검증
    File temporaryProfileImageFile = new File(sessionTemporaryProfileImageAbsolutePath);
    if (!temporaryProfileImageFile.exists() || !temporaryProfileImageFile.isFile()) {
        request.setAttribute("msg", "임시 이미지 파일이 존재하지 않습니다. 다시 업로드 해주세요.");
        forward.setRedirect(false);
        forward.setPath("/mypage.jsp");
        return forward;
    }

    isMemberProfileImageChanged = true;
}

이 검증이 있어야 “내 임시 업로드 결과만 확정”이 가능해진다.

 

  • token 조작/세션 끊김/임시파일 삭제 상태에서 커밋 불가
  • 다른 사용자의 임시파일 탈취 시도 방지

3) 캐시 차감 타이밍은 “최종 확정”에서만 계산/검증한다

미리보기 단계는 캐시 0.
커밋 단계에서만 비용을 계산하고,

잔액을 확인후 DB 업데이트에 반영한다.

int cashPayAmount = 0;

if (isMemberNicknameChanged) {
    // 닉네임 변경 시 300 더하기
    cashPayAmount += COST_NICKNAME_ONLY; // 300
}

if (isMemberProfileImageChanged) {
    // 이미지 변경 시 500 더하기
    cashPayAmount += COST_PROFILE_IMAGE; // 500
}

if (cashPayAmount == 0) {
    request.setAttribute("msg", "변경된 내용이 없습니다.");
    forward.setRedirect(false);
    forward.setPath("/mypage.jsp");
    return forward;
}

// 잔액 검증
if (currentMemberCash < cashPayAmount) {
    request.setAttribute("msg", "캐쉬가 부족합니다. (필요: " + cashPayAmount + ", 보유: " + currentMemberCash + ")");
    forward.setRedirect(false);
    forward.setPath("/mypage.jsp");
    return forward;
}

유료 기능에서 “확정 전에 결제되는 느낌”을 없애는 핵심 설계다.


4) temp → final 이동 + timestamp 파일명으로 캐시 갱신 문제를 줄인다

프로필 이미지는 브라우저 캐시 영향을 많이 받는다.
그래서 최종 저장 시 파일명에 timestamp를 섞어 URL이 바뀌도록 했다.

String newMemberProfileImage = null; // DB에는 웹 상대경로 저장
Path temporaryProfileImagePath = null;
Path finalProfileImagePath = null;

if (isMemberProfileImageChanged) {
    ServletContext servletContext = request.getServletContext();

    temporaryProfileImagePath = Paths.get(sessionTemporaryProfileImageAbsolutePath);

    String finalProfileImageDirRealPath = servletContext.getRealPath(FINAL_PROFILE_IMAGE_DIR);

    if (finalProfileImageDirRealPath == null) {
        request.setAttribute("msg", "최종 저장 경로를 확인할 수 없습니다.");
        forward.setRedirect(false);
        forward.setPath("/mypage.jsp");
        return forward;
    }

    Path finalProfileImageDirPath = Paths.get(finalProfileImageDirRealPath);
    Files.createDirectories(finalProfileImageDirPath);

    // 캐시/브라우저 갱신 문제 방지 위해 파일명에 시간값 포함
    String finalProfileImageFileName = "profile_" + memberId + "_" + System.currentTimeMillis() + ".jpg";

    newMemberProfileImage = FINAL_PROFILE_IMAGE_DIR + "/" + finalProfileImageFileName;

    String finalProfileImageRealPath = servletContext.getRealPath(newMemberProfileImage);

    if (finalProfileImageRealPath == null) {
        request.setAttribute("msg", "최종 파일 경로를 확인할 수 없습니다.");
        forward.setRedirect(false);
        forward.setPath("/mypage.jsp");
        return forward;
    }

    finalProfileImagePath = Paths.get(finalProfileImageRealPath);

    Files.move(temporaryProfileImagePath, finalProfileImagePath, StandardCopyOption.REPLACE_EXISTING);
}

5) DB 업데이트는 케이스별 condition 분기 + 실패 시 파일 롤백

커밋은 “파일 이동 + DB 반영 + 캐시 차감”이 세트다.
파일만 이동되고 DB가 실패하면 깨진 상태가 된다.

 

그래서 DB 실패 시 최종 파일을 삭제해 롤백을 시도한다.

MemberDTO memberData2 = new MemberDTO();
memberData2.setMemberId(memberId);
memberData2.setMemberPayCash(cashPayAmount);

if (isMemberNicknameChanged && isMemberProfileImageChanged) {
    memberData2.setCondition("MEMBER_INFORM_UPDATE");
    memberData2.setMemberNickname(requestMemberNickname);
    memberData2.setMemberProfileImage(newMemberProfileImage);

} else if (isMemberNicknameChanged) {
    memberData2.setCondition("MEMBER_NICKNAME_UPDATE");
    memberData2.setMemberNickname(requestMemberNickname);

} else if (isMemberProfileImageChanged) {
    memberData2.setCondition("MEMBER_PROFILE_UPDATE");
    memberData2.setMemberProfileImage(newMemberProfileImage);
}

boolean isUpdated = memberDAO.update(memberData2);

if (!isUpdated) {
    // DB 실패 시, 이동한 파일이 있으면 삭제 시도(롤백)
    if (finalProfileImagePath != null) {
        try {
            boolean isDeleted = Files.deleteIfExists(finalProfileImagePath);
            System.out.println("[프로필 변경 액션 로그] 최종파일 롤백 삭제 path = ["+finalProfileImagePath+"], deleted = ["+isDeleted+"]");
        } catch (Exception e) {
            System.out.println("[프로필 변경 액션 로그] 최종파일 롤백 삭제 실패 path = ["+finalProfileImagePath+"], msg = ["+e.getMessage()+"]");
        }
    }

    request.setAttribute("msg", "회원정보 수정에 실패했습니다.");
    forward.setRedirect(false);
    forward.setPath("/mypage.jsp");
    return forward;
}

6) 성공 후 정리: 세션/임시파일/기존파일을 안전하게 정리한다

성공 이후에도 정리 로직이 중요하다.

  • 세션 token/path 제거(재사용/누적 방지)
  • 남아있는 임시파일 삭제(안전장치 포함)
  • 기존 프로필 파일 삭제(이미지 변경 성공일 때만)
String remainTemporaryAbsolutePath = sessionTemporaryProfileImageAbsolutePath;

session.removeAttribute(SESSION_PROFILE_IMAGE_TOKEN);
session.removeAttribute(SESSION_PROFILE_IMAGE_ABSOLUTE_PATH);

// 임시 파일 정리(폴더 + 파일명 안전장치)
if (remainTemporaryAbsolutePath != null) {
    File temporaryRemainFile = new File(remainTemporaryAbsolutePath);

    if (temporaryRemainFile.exists() && temporaryRemainFile.isFile()) {

        boolean isSafeTempPath = remainTemporaryAbsolutePath.replace("\\", "/").contains(TEMP_PROFILE_IMAGE_DIR);
        String temporaryRemainFileName = temporaryRemainFile.getName();

        if (isSafeTempPath
                && temporaryRemainFileName.startsWith("temporary_profile_")
                && temporaryRemainFileName.endsWith(".jpg")) {

            boolean isDeleted = temporaryRemainFile.delete();
            System.out.println("[프로필 임시파일 정리] path=[" + remainTemporaryAbsolutePath + "], deleted=[" + isDeleted + "]");
        }
    }
}

// 기존 파일 삭제는 “이미지 변경 성공”일 때만
if (isMemberProfileImageChanged) {
    deleteOldProfileImage(
        request.getServletContext(),
        currentMemberProfileImage,
        newMemberProfileImage
    );
}

deleteOldProfileImage()도 아무 파일이나

지우지 않게 안전장치를 걸어둔다.

  • /upload/profile/ 아래만 허용
  • .. 포함 시 차단
  • 새 파일과 동일하면 차단
  • 실존 파일일 때만 delete 시도

(삭제 실패는 기능 실패로 보지 않음)


7) PRG 패턴: redirect로 중복 차감 방지

유료 기능에서 가장 위험한 건

새로고침(F5)으로 POST가 재전송되는 상황이다.


그래서 성공 후에는 redirect로

myPage.do 재진입(PRG)을 강제한다.

(POST → Redirect to → GET)

forward.setRedirect(true);
forward.setPath(request.getContextPath() + "/myPage.do");
return forward;

PRG 쓰면 생기는 장점

  • 새로고침/뒤로가기 시 POST 재전송 방지
  • 유료 기능에서 중복 차감 방지
  • 최종 화면이 /myPage.do라서 DB 최신값으로 다시 그려짐

예외/보안 체크(기본이지만 필수)

1) 세션/로그인 체크

 비로그인 상태에서 커밋 못 하게 차단

2) 토큰/세션 매칭 검증

 요청 token == 세션 token 일 때만 커밋 허용

3) 임시파일 존재 검증

 세션 경로가 가리키는 파일이 실존해야 이동 가능

4) 경로 조작 방지(정리/삭제 단계 포함)

 temp 정리도 “폴더 + 파일명 패턴” 안전장치 후 삭제

 기존파일 삭제도 /upload/profile/ 아래만 허용 + .. 차단

 

결론: “결정권을 사용자에게” 주는 설계가 커밋 단계에서 완성된다

어제 글이 “미리보기(무료)”였다면,

오늘 글은 그 미리보기가 의미를 가지게 하는

“최종 확정(유료)”이다.

  • 커밋은 토큰 기반으로만
  • 비용 계산/잔액 검증은 확정에서만
  • temp→final 이동 + timestamp 파일명
  • DB 실패 롤백
  • 성공 후 정리 + PRG 패턴

결국 “유료 기능”에서 사용자가 손해 보지 않게 만드는 UX는
커밋 단계에서 정책으로 완성된다.


[ 최종 코드 ]

ChangeProfileAction

이 액션은 “최종 확정(커밋) 단계”를 담당한다.

 

즉, 미리보기 단계(ProfileImageUploadAction)에서

만들어진 결과물을 사용자가 “수정 완료”를 눌렀을 때만

최종 반영(final 저장 + DB 업데이트 + 캐시 차감) 하는 역할만 수행한다.

 

이 액션이 처리하는 것(커밋 단계 핵심 기능)

  • 로그인 체크 (비로그인/세션 만료 차단)
  • DB에서 현재 회원정보 조회(닉네임/캐시/프로필 경로)
  • 닉네임 변경 판정(공백 입력은 “변경 없음”으로 처리)
  • 이미지 변경 판정(토큰 기반 커밋)
    • 요청 token 존재
    • 세션 token/path 존재
    • 요청 token == 세션 token
    • 임시 파일이 실제로 존재
  • 비용 계산(300/500/800) + 잔액 검증(부족하면 실패)
  • 임시 파일(temp) → 최종 폴더(final) 이동(커밋)
    • 파일명에 timestamp 포함(브라우저 캐시 갱신 문제 완화)
    • DB에는 웹 상대경로(/upload/profile/xxx.jpg) 저장
  • DB 업데이트(케이스별 condition 분기)
    • 닉네임만 / 이미지만 / 둘 다 변경
  • DB 업데이트 실패 시 롤백(이동한 최종 파일 삭제 시도)
  • 성공 후 정리(운영 안정성)
    • 세션 token/path 제거(누적/재사용 방지)
    • 남아있는 임시파일 정리(폴더 + 파일명 패턴 안전장치)
    • 기존 프로필 파일 삭제(이미지 변경 성공일 때만)
  • PRG 패턴 적용(redirect)
    • 새로고침 POST 재전송 방지
    • 중복 차감/중복 커밋 방지
public class ChangeProfileAction implements Action {

	/*
	[ChangeProfileAction (닉네임/프로필이미지/캐쉬차감 “커밋”) 흐름 정리]
	  0) 전제
	     - ProfileImageUploadAction에서 “임시 업로드”를 먼저 수행함
	       · 세션 저장 값:
	         (1) temporaryProfileImageToken          : 임시 이미지 식별 토큰(UUID)
	         (2) temporaryProfileImageAbsolutePath   : 임시 이미지 파일 절대경로
	       · 프론트는 업로드 응답으로 받은 token을 hidden input에 저장해두었다가
	         “내 정보 수정 완료” 제출 시 함께 전송함

	  1) 로그인 체크
	     - request.getSession(false)로 세션이 없으면 null이므로 NPE 방지 체크 필요
	     - 세션 또는 memberId가 없으면 비로그인 → 로그인 페이지로 리다이렉트

	  2) 현재 회원 정보 조회(DB)
	     - member_id로 member_nickname / member_cash / member_profile_image 조회
	     - 변경 여부는 서버가 DB 값과 요청 값(그리고 세션 임시값)을 비교하여 최종 판단

	  3) 변경 판정
	     3-1) 닉네임 변경
	          - 파라미터 없으면 “변경 안함(유지)”
	          - 파라미터 있으면 trim 후 공백이면 “변경 안함(유지)”
	          - 요청 닉네임 != DB 닉네임 → 변경

	     3-2) 이미지 변경(토큰 기반 커밋)
	          - token 파라미터가 없거나 공백이면 “변경 안함(유지)”
	          - token이 있으면 아래 3개 검증을 모두 통과해야 변경으로 확정
	            (1) 세션 token/path 존재
	            (2) 요청 token == 세션 token
	            (3) 세션 absolutePath의 임시파일이 실제 존재

	  4) 비용 정책
	     - 닉네임만 변경: 300 차감
	     - 이미지 변경: 500 차감
	     - 둘 다 변경: 800 차감(합산)

	  5) 잔액 검증
	     - member_cash < 차감금액이면 실패

	  6) 이미지 커밋(이동)
	     - 임시파일(절대경로) → 최종폴더(/upload/profile)
	     - DB에는 웹 상대경로(/upload/profile/xxx.jpg) 저장

	  7) DB 업데이트
	     - 닉네임/프로필이미지/캐쉬차감 수행
	     - 구현 난이도 낮추기 위해 condition 3종으로 분기해서 update 수행
	       · MEMBER_NICKNAME_UPDATE : 닉네임 변경 + 300 차감
	       · MEMBER_PROFILE_UPDATE  : 프로필 이미지 변경 + 500 차감
	       · MEMBER_INFORM_UPDATE   : 닉네임+이미지 변경 + 800 차감

	  8) 실패 롤백(성격)
	     - 파일 move까지 끝났는데 DB 업데이트 실패하면
	       최종 파일을 deleteIfExists로 삭제 시도(실패해도 기능 종료는 동일)

	  9) 성공 후 정리(옵션 A)
	     - 성공하면 “무조건” 세션의 임시 token/path 제거(누적 방지)
	     - (선택) 세션에 남아있던 임시파일 경로가 있다면 임시파일 삭제 시도
	     - 기존 프로필 파일 삭제는 “이미지 변경 성공”일 때만 수행
	*/

	// ===== ProfileImageUploadAction과 "완전 동일" 세션 키 =====
	private static final String SESSION_PROFILE_IMAGE_TOKEN = "temporaryProfileImageToken";
	private static final String SESSION_PROFILE_IMAGE_ABSOLUTE_PATH = "temporaryProfileImageAbsolutePath";

	// ===== 최종 저장 폴더(웹 경로) =====
	private static final String FINAL_PROFILE_IMAGE_DIR = "/upload/profile";

	// ===== 임시 저장 폴더(경로 안전장치 체크용, ProfileImageUploadAction과 동일) =====
	private static final String TEMP_PROFILE_IMAGE_DIR = "/upload/profile_temp";

	// ===== 폼 파라미터 이름 =====
	private static final String PARAM_MEMBER_NICKNAME = "memberNickname";
	private static final String PARAM_TEMPORARY_PROFILE_IMAGE_TOKEN = "temporaryProfileImageToken";

	// ===== 비용 정책 =====
	private static final int COST_NICKNAME_ONLY = 300;
	private static final int COST_PROFILE_IMAGE = 500;

	@Override
	public ActionForward execute(HttpServletRequest request, HttpServletResponse response) {
		ActionForward forward = new ActionForward();
		MemberDAO memberDAO = new MemberDAO();

		try {
			request.setCharacterEncoding("UTF-8");

			// 1) 로그인 체크
			HttpSession session = request.getSession(false);
			if (session == null) {
				forward.setRedirect(true);
				forward.setPath(request.getContextPath() + "/loginPage.do");
				return forward;
			}

			Object sessionMemberIdObject = session.getAttribute("memberId");
			if (sessionMemberIdObject == null) {
				forward.setRedirect(true);
				forward.setPath(request.getContextPath() + "/loginPage.do");
				return forward;
			}

			Integer memberId = (Integer) sessionMemberIdObject;

			// 2) DB에서 현재 회원정보 조회
			MemberDTO selectMemberDTO = new MemberDTO();
			selectMemberDTO.setCondition("MEMBER_MYPAGE");
			selectMemberDTO.setMemberId(memberId);

			MemberDTO memberData = memberDAO.selectOne(selectMemberDTO);
			if (memberData == null) {
				forward.setRedirect(true);
				forward.setPath(request.getContextPath() + "/mainPage.do");
				return forward;
			}

			String currentMemberNickname = memberData.getMemberNickname(); // DB에서 가져온 회원 닉네임

			Integer currentMemberCashInteger = memberData.getMemberCash(); // DB에서 가져온 회원 보유 캐쉬
			if (currentMemberCashInteger == null) {
				currentMemberCashInteger = 0; // 매핑 누락/이상 케이스 방어
			}
			int currentMemberCash = currentMemberCashInteger;

			String currentMemberProfileImage = memberData.getMemberProfileImage(); // DB member_profile_image

			// 3) 닉네임 변경 판정 (서버는 최소 처리만)
			// - 파라미터 없으면: 변경 안함(유지)
			// - 파라미터 있으면: trim
			// - trim 결과가 공백이면: 변경 안함(유지)  ← 실패시키지 않음
			String requestMemberNicknameParameter = request.getParameter(PARAM_MEMBER_NICKNAME);
			// 사용자가 V에서 입력한 변경 희망 닉네임
			String requestMemberNickname = currentMemberNickname; // 기본은 유지
			// 사용자 변경 희망 닉네임을 담을 변수
			if (requestMemberNicknameParameter != null) {
				String trimmedNickname = requestMemberNicknameParameter.trim();
				if (!trimmedNickname.isEmpty()) {
					requestMemberNickname = trimmedNickname;
				}
			}

			boolean isMemberNicknameChanged = false;
			// 닉네임 변경 유무 - 기본값은 F
			if (!requestMemberNickname.equals(currentMemberNickname)) {
				// 현재 닉네임과 요청 닉네임이 같지 않으면 변경 희망
				isMemberNicknameChanged = true;
			}

			// 4) 이미지 변경 판정 (토큰도 동일 스타일: null/공백이면 변경 안함)
			String requestTemporaryProfileImageTokenParameter = request.getParameter(PARAM_TEMPORARY_PROFILE_IMAGE_TOKEN);
			// 사용자가 V에서 입력한 변경 희망 프로필 이미지 토큰

			String requestTemporaryProfileImageToken = null;
			// 사용자 변경 희망 프로필 이미지를 담을 변수

			if (requestTemporaryProfileImageTokenParameter != null) {
				// V에서 넘어온 이미지 토큰이 있다면
				String trimmedToken = requestTemporaryProfileImageTokenParameter.trim();
				// 공백 제거해서 담고
				if (!trimmedToken.isEmpty()) {
					// 공백 제거했는데 "비어있지 않다면"
					requestTemporaryProfileImageToken = trimmedToken;
					// 사용자 변경 희망 프로필 이미지에 담아 - 커밋할 수 있다.
				}
			}
			// 결과:
			// - requestTemporaryProfileImageToken == null  → 이미지 변경 안함
			// - requestTemporaryProfileImageToken != null  → 이미지 변경 시도(커밋)

			// ProfileImageUploadAction이 세션에 저장해둔 “임시 업로드 결과” 꺼내기
			String sessionTemporaryProfileImageToken = (String) session.getAttribute(SESSION_PROFILE_IMAGE_TOKEN);
			// 임시 업로드 결과에 따른 토큰
			String sessionTemporaryProfileImageAbsolutePath = (String) session.getAttribute(SESSION_PROFILE_IMAGE_ABSOLUTE_PATH);
			// 임시 업로드 결과에 따른 임시 저장 경로(절대경로)

			boolean isMemberProfileImageChanged = false;
			// 프로필 이미지 변경 유무 - 기본값은 F

			// token이 있으면: 이미지 변경(커밋) 요청으로 간주하고 검증 시작
			if (requestTemporaryProfileImageToken != null) {

				// (1) 세션 값 존재해야 함
				if (sessionTemporaryProfileImageToken == null || sessionTemporaryProfileImageAbsolutePath == null) {
					request.setAttribute("msg", "임시 이미지 정보가 없습니다. 다시 업로드 해주세요.");
					forward.setRedirect(false);
					forward.setPath("/mypage.jsp");
					return forward;
				}

				// (2) 토큰 일치 검증
				if (!requestTemporaryProfileImageToken.equals(sessionTemporaryProfileImageToken)) {
					request.setAttribute("msg", "프로필 이미지 토큰이 일치하지 않습니다.");
					forward.setRedirect(false);
					forward.setPath("/mypage.jsp");
					return forward;
				}

				// (3) 임시 파일 존재 검증
				// - 세션 absolutePath가 가리키는 파일이 실제로 존재해야 “커밋” 가능
				// - 존재하지 않으면 업로드가 안 됐거나, 서버에서 임시파일이 삭제된 상태
				File temporaryProfileImageFile = new File(sessionTemporaryProfileImageAbsolutePath);
				if (!temporaryProfileImageFile.exists() || !temporaryProfileImageFile.isFile()) {
					request.setAttribute("msg", "임시 이미지 파일이 존재하지 않습니다. 다시 업로드 해주세요.");
					forward.setRedirect(false);
					forward.setPath("/mypage.jsp");
					return forward;
				}

				isMemberProfileImageChanged = true;
				// 위의 검증 절차 3개 모두 통과 시 프로필 이미지 변경 희망으로 설정 - T
			}

			// 5) 비용 계산
			// - 닉네임 변경: 300
			// - 이미지 변경: 500
			// - 둘 다 변경: 800(합산)
			int cashPayAmount = 0;

			if (isMemberNicknameChanged) {
				// 닉네임 변경 시 300 더하기
				cashPayAmount += COST_NICKNAME_ONLY; // 300
			}

			if (isMemberProfileImageChanged) {
				// 이미지 변경 시 500 더하기
				cashPayAmount += COST_PROFILE_IMAGE; // 500
			}

			if (cashPayAmount == 0) {
				// 변경값을 다 더했는데 0이면 변경한 것 없는 상황
				request.setAttribute("msg", "변경된 내용이 없습니다.");
				forward.setRedirect(false);
				forward.setPath("/mypage.jsp");
				return forward;
			}

			// 6) 잔액 검증
			if (currentMemberCash < cashPayAmount) {
				// 지불해야하는 cashPayAmount 보다 현재 보유 캐쉬가 작으면 실패
				request.setAttribute("msg", "캐쉬가 부족합니다. (필요: " + cashPayAmount + ", 보유: " + currentMemberCash + ")");
				forward.setRedirect(false);
				forward.setPath("/mypage.jsp");
				return forward;
			}

			// 7) 이미지 커밋(임시 → 최종 이동)
			// - 이미지 변경이 확정된 경우에만 move 수행
			// - DB에는 웹 상대경로(/upload/profile/xxx.jpg) 저장
			String newMemberProfileImage = null; // DB에는 웹 상대경로 저장
			Path temporaryProfileImagePath = null;
			// → 임시 업로드된 파일(프로필_temp)의 “실제 파일 경로”
			Path finalProfileImagePath = null;
			// → 최종 폴더(/upload/profile)에 저장될 “실제 파일 경로” (= move의 목적지)

			if (isMemberProfileImageChanged) {
				ServletContext servletContext = request.getServletContext();
				// getRealPath()로 웹 경로(/upload/profile)를 서버 디스크 실제 경로로 바꾸기 위해 필요

				temporaryProfileImagePath = Paths.get(sessionTemporaryProfileImageAbsolutePath);
				// 세션에 저장된 임시 이미지 “절대경로”를 Path로 변환

				String finalProfileImageDirRealPath = servletContext.getRealPath(FINAL_PROFILE_IMAGE_DIR);
				// upload/profile라는 “웹 경로”를 실제 디스크 경로로 변환

				if (finalProfileImageDirRealPath == null) {
					request.setAttribute("msg", "최종 저장 경로를 확인할 수 없습니다.");
					forward.setRedirect(false);
					forward.setPath("/mypage.jsp");
					return forward;
				}

				// 최종 폴더가 없으면 생성
				Path finalProfileImageDirPath = Paths.get(finalProfileImageDirRealPath);
				Files.createDirectories(finalProfileImageDirPath);

				// 캐시/브라우저 갱신 문제 방지 위해 파일명에 시간값 포함
				String finalProfileImageFileName = "profile_" + memberId + "_" + System.currentTimeMillis() + ".jpg";

				// DB에 저장할 값(웹에서 접근 가능한 상대경로)
				newMemberProfileImage = FINAL_PROFILE_IMAGE_DIR + "/" + finalProfileImageFileName;

				String finalProfileImageRealPath = servletContext.getRealPath(newMemberProfileImage);
				// 위에서 만든 웹 경로를 다시 실제 디스크 경로로 변환

				if (finalProfileImageRealPath == null) { //실제 디스크 경로가 비어있으면 실패
					request.setAttribute("msg", "최종 파일 경로를 확인할 수 없습니다.");
					forward.setRedirect(false);
					forward.setPath("/mypage.jsp");
					return forward;
				}

				finalProfileImagePath = Paths.get(finalProfileImageRealPath);
				// 실제 디스크 목적지 파일 경로(Path)로 만든다

				Files.move(temporaryProfileImagePath, finalProfileImagePath, StandardCopyOption.REPLACE_EXISTING);
				// 임시 파일을 최종 위치로 “이동”
			}

			// 8) DB 업데이트 (변경 케이스별로 condition 분기해서 update 수행)
			MemberDTO memberData2 = new MemberDTO();
			memberData2.setMemberId(memberId);
			memberData2.setMemberPayCash(cashPayAmount);
			// 차감 금액(이미 위에서 300/500/800으로 계산된 cashPayAmount)

			if (isMemberNicknameChanged && isMemberProfileImageChanged) {
				// 닉네임 + 프로필이미지 둘 다 변경
				memberData2.setCondition("MEMBER_INFORM_UPDATE");
				memberData2.setMemberNickname(requestMemberNickname);
				memberData2.setMemberProfileImage(newMemberProfileImage);

			} else if (isMemberNicknameChanged) {
				// 닉네임만 변경
				memberData2.setCondition("MEMBER_NICKNAME_UPDATE");
				memberData2.setMemberNickname(requestMemberNickname);

			} else if (isMemberProfileImageChanged) {
				// 프로필이미지만 변경
				memberData2.setCondition("MEMBER_PROFILE_UPDATE");
				memberData2.setMemberProfileImage(newMemberProfileImage);

			} else {
				// (안전장치) 여기로 오면 변경 없음
				request.setAttribute("msg", "변경된 내용이 없습니다.");
				forward.setRedirect(false);
				forward.setPath("/mypage.jsp");
				return forward;
			}

			boolean isUpdated = memberDAO.update(memberData2);

			if (!isUpdated) {
				// DB 실패 시, 이동한 파일이 있으면 삭제 시도(롤백)
				if (finalProfileImagePath != null) {
					try {
						boolean isDeleted = Files.deleteIfExists(finalProfileImagePath);
						System.out.println("[프로필 변경 액션 로그] 최종파일 롤백 삭제 path = ["+finalProfileImagePath+"], deleted = ["+isDeleted+"]");
					} catch (Exception e) {
						System.out.println("[프로필 변경 액션 로그] 최종파일 롤백 삭제 실패 path = ["+finalProfileImagePath+"], msg = ["+e.getMessage()+"]");
					}
				}

				request.setAttribute("msg", "회원정보 수정에 실패했습니다.");
				forward.setRedirect(false);
				forward.setPath("/mypage.jsp");
				return forward;
			}

			// 9) 성공 후 세션 임시정보 제거 + 임시파일 정리 + 기존 파일 삭제
			
			// 성공하면 “무조건” 세션 token/path 정리(누적 방지)
			// 성공 처리 전에 세션 임시 경로를 지역변수로 보관(삭제 시도용)
			String remainTemporaryAbsolutePath = sessionTemporaryProfileImageAbsolutePath;

			// 9-1) 세션에 남아있던 임시 업로드 값들 제거 (토큰 재사용/누적 방지)
			session.removeAttribute(SESSION_PROFILE_IMAGE_TOKEN);
			session.removeAttribute(SESSION_PROFILE_IMAGE_ABSOLUTE_PATH);

			// 9-2) 임시 파일 정리
			// 닉네임만 바꾼 경우에도, 예전에 올려둔 임시파일이 남아있을 수 있음 (올려두고 취소하고 나갔다가 다시 들어와서 닉네임만 변경)
			// → 임시폴더(profile_temp) 아래 + 파일명 규칙(temporary_profile_*.jpg)일 때만 삭제 시도
			if (remainTemporaryAbsolutePath != null) {
				// 사용자가 올린 임시 업로드 파일이 있을 때만 실행
				File temporaryRemainFile = new File(remainTemporaryAbsolutePath);

				if (temporaryRemainFile.exists() && temporaryRemainFile.isFile()) {
					// 삭제 시도할 가치가 있는 진짜 파일이 맞는지 체크하는 조건
					// 경로에 뭔가(파일/폴더)가 실제로 존재하는지 확인
					// isFile()로 “폴더가 아닌 일반 파일이 맞냐”를 확인

					boolean isSafeTempPath = remainTemporaryAbsolutePath.replace("\\", "/").contains(TEMP_PROFILE_IMAGE_DIR);
					// 안전장치) profile_temp 폴더 아래인지 확인
					// 윈도우 경로(\)도 있으니 /로 치환 후 "/upload/profile_temp" 포함 여부 체크

					String temporaryRemainFileName = temporaryRemainFile.getName();
					// 안전장치) 파일명을 2차로 확인한 후 최종 삭제 진행
					// 파일명 패턴까지 체크 - temporary_profile_로 시작 / .jpg로 끝
					if (isSafeTempPath
							&& temporaryRemainFileName.startsWith("temporary_profile_")
							&& temporaryRemainFileName.endsWith(".jpg")) {

						boolean isDeleted = temporaryRemainFile.delete();
						System.out.println("[프로필 임시파일 정리] path=[" + remainTemporaryAbsolutePath + "], deleted=[" + isDeleted + "]");
					}
				}
			}

			// 9-3) 기존 파일 삭제
			// - 프로필 이미지를 실제로 변경한 경우에만 “기존 이미지 파일” 삭제
			// 닉네임만 변경한 경우엔 기존 파일을 지우면 현재 사용 중인 이미지가 깨질 수 있으므로 삭제하면 안 됨
			// 삭제는 DB 업데이트/커밋 성공 이후에 수행해야 정합성이 맞음(실패했는데 먼저 지우면 깨짐)
			if (isMemberProfileImageChanged) {
			    deleteOldProfileImage(
			        request.getServletContext(),
			        currentMemberProfileImage,   // DB에 있던 기존 프로필 이미지 경로
			        newMemberProfileImage        // 이번에 커밋한 새 프로필 이미지 경로
			    );
			}

			// - 성공 후에는 redirect로 마이페이지 재진입 (PRG 패턴)
			// 새로고침(F5) 시 POST 재전송/중복 차감 방지
			// myPage.do에서 DB를 다시 조회하여 “최신 닉네임/캐쉬/프로필” 상태로 화면을 갱신
			forward.setRedirect(true);
			forward.setPath(request.getContextPath() + "/myPage.do");
			return forward;

		} catch (Exception exception) {
			exception.printStackTrace();
			request.setAttribute("msg", "처리 중 오류가 발생했습니다.");
			forward.setRedirect(false);
			forward.setPath("/mypage.jsp");
			return forward;
		}
	}

	// 기존 프로필 이미지 파일 삭제 메서드 정의
	// - “이미지 변경 커밋 성공” 후, 서버에 남아있는 이전 이미지 파일을 정리(용량 누적 방지)
	// - 단, 아무 파일이나 지우면 위험하므로 아래 조건을 모두 만족할 때만 삭제 시도
	private static void deleteOldProfileImage(ServletContext servletContext,
	                                          String oldMemberProfileImage,
	                                          String newMemberProfileImage) {

	    // 1) 기존 경로 자체가 없으면 삭제할 대상이 없음
	    if (oldMemberProfileImage == null) return;

	    // 2) 공백/빈 문자열이면 삭제 대상이 없음
	    if (oldMemberProfileImage.trim().isEmpty()) return;

	    // 3) 새 이미지 경로가 있고, 기존 경로와 동일하면 “교체가 아닌 상태”
	    //    → 같은 파일을 지울 필요가 없고, 오히려 현재 사용 중 파일을 삭제할 위험이 있으므로 중단
	    if (newMemberProfileImage != null && oldMemberProfileImage.equals(newMemberProfileImage)) return;

	    // 4) 안전장치: 우리가 관리하는 폴더(/upload/profile/) 아래의 파일만 삭제 허용
	    //    - 외부 URL(http://...), 다른 폴더(/upload/board/...) 등은 삭제하면 안 됨
	    if (!oldMemberProfileImage.startsWith("/upload/profile/")) return;

	    // 5) 경로 조작 방지: “..” 포함되면 상위 경로로 탈출할 수 있으니 즉시 중단
	    if (oldMemberProfileImage.contains("..")) return;

	    try {
	        // 6) 웹 경로(/upload/profile/xxx.jpg)를 서버 디스크 실제 경로로 변환
	        //    - getRealPath가 null이면(환경/배포 방식에 따라) 물리 경로를 알 수 없으므로 중단
	        String oldProfileImageRealPath = servletContext.getRealPath(oldMemberProfileImage);
	        if (oldProfileImageRealPath == null) return;

	        // 7) 실제 파일 객체 생성(존재 여부/파일 여부 확인을 위해)
	        File oldProfileImageFile = new File(oldProfileImageRealPath);

	        // 8) “실제로 존재하는 파일”인 경우에만 삭제 시도
	        //    - exists(): 존재 여부
	        //    - isFile(): 폴더가 아닌 일반 파일인지 확인(폴더 삭제 방지)
	        if (oldProfileImageFile.exists() && oldProfileImageFile.isFile()) {

	            // 9) 삭제 시도(실패해도 서비스 흐름은 유지)
	            //    - 운영 환경에서는 권한/잠금 등의 이유로 delete가 실패할 수 있으므로
	            //      실패했다고 해서 전체 프로필 수정 기능을 실패로 만들지는 않음
	            boolean deleted = oldProfileImageFile.delete();

	            // 10) 운영 로그(삭제 성공/실패 추적용)
	            System.out.println("[기존 프로필 이미지 삭제] path=[" + oldProfileImageRealPath + "], deleted=[" + deleted + "]");
	        }

	    } catch (Exception ignore) {
	        // 11) 삭제 실패는 “치명 기능”이 아니라 “정리 작업”이므로 예외를 삼키고 종료
	        //     (삭제 실패로 인해 프로필 수정 전체가 실패하는 것을 막기 위함)
	    }
	}
}