“새로고침하면 해결되는” 프로필 이미지 404를
로직으로 방어하기 (재시도 + 로딩 UX)
[ 오류 현상 ]
프로필 이미지를 변경한 직후 마이페이지에서
이미지가 404로 실패했다가, 새로고침(F5)을 하면
정상 출력되는 문제가 발생했다.
이미지 변경 관련 에러 발생 시
보통 이렇게 생각하게 된다.
- “업로드가 실패한 건가?”
- “DB 경로가 잘못 저장된 건가?”
- “서버에 파일이 없는 건가?”
그런데 이상하게도 새로고침만 하면 해결됐다.
즉, “완전히 망가진 오류”라기보다는
어딘가 타이밍이 어긋난 느낌이었다.
[ 기존 업로드/변경 로직 흐름 ]
이미지 업로드 로직 자체는
다음 순서로 잘 동작하고 있었다.
변경용 업로드 사진을 임시 파일로 저장
↓
변경 완료 클릭 시 임시 파일을 실제 저장 폴더로 이동
↓
기존 프로필 파일은 삭제
즉, 서버 로직은 정상적으로
“저장 → 이동 → DB 업데이트” 흐름을 수행하고 있었다.
[ 1차 가설: 캐시 문제일 것이다 ]
문제가 새로고침으로 해결된다는 점 때문에,
처음에는 브라우저 쪽에서 발생하는
전형적인 캐시 문제를 가장 먼저 의심했다.
- 페이지가 로드될 때 서버의 최신 이미지를 가져오는 게 아니라
- 브라우저가 갖고 있던 이전 이미지를 재사용하거나
- 리소스 갱신이 반영되기 전에 오래된 요청이 먼저 날아가는 상황
즉 서버는 잘 저장했는데, 브라우저가 업데이트를
늦게 따라오는 문제라고 판단한 것이다.
[ 검증: 캐시만의 문제는 아니었다 ]
하지만 동작을 더 자세히 보니,
캐시만으로 설명이 애매한 지점이 있었다.
- 같은 이미지 URL을 브라우저 주소창에 직접 치면 정상 출력
- 어떤 경우엔 처음 로드만 실패하고, 잠시 후엔 정상
결국 결론은“파일이 실제로 없어서”가 아니라,
초기 렌더 타이밍 / 서빙 반영 시점 / 브라우저 요청 타이밍이 겹치면서
첫 요청이 실패하는 레이스 컨디션에 가까운 문제였다.
즉, 로직은 정상인데도
첫 번째 요청이 너무 빠르게 날아가면서 404를 맞고,
그 순간 내가 작성한 로딩 로직이
너무 빨리 포기(기본 이미지 확정) 해버리는 구조였다.
[ 해결 전략: 성공이 확인될 때만 src를 교체 ]
핵심 아이디어는 단순했다.
이미지를 바로 src로 박아버리지 말고,
preload로 성공 여부를 확인한 뒤에만 src를 교체한다.
그리고 될 때까지 기다리는 동안 사용자가
불안해하지 않게 로딩 UX를 함께 제공한다.
정리하면 해결 방향은 4가지다.
- preload(new Image)로 실제 정상 로드가
확인될 때만 <img src> 교체 - 실패하면 시간 기반 재시도로 될 때까지 재호출
- 재시도 동안은 로딩 UX(검은 배경 + 흰 로딩바) 유지
- 브라우저 캐시로 옛 이미지를 물고 올 수 있으니
cache-bust(v=timestamp) 적용
[ 해결 방안 코딩 ]
1) JSP
“진짜 경로”는 data-real-src로 보관하고, src는 1px로 시작
페이지가 뜨자마자 실제 이미지 URL을 src로 넣으면
404가 발생하는 순간 깨진 이미지 아이콘이 뜨거나,
기본 이미지로 확정되어 버린다.
그래서 처음에는 투명 1px 이미지로 시작하고,
실제 경로는 data-real-src에만 저장해둔다.
<div class="profile-img-wrap is-loading" id="profileWrap">
<img id="profilePreview"
alt="프로필 이미지"
data-real-src="${ctx}${memberData.memberProfileImage}"
data-default-src="${ctx}/img/profile-default.jpg"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==">
<div class="profile-loader" aria-hidden="true">
<div class="loader-bar"></div>
</div>
</div>
2) CSS
이미지가 아무것도 보이지 않으면 사용자가
어떤 상황인지 알지 못해 불쾌하기 때문에
로딩 중엔 이미지를 숨기고(투명), 로딩바만 보여준다
이미지 로드가 성공할 때까지 사용자에게는
“검은 배경 + 흰 로딩바”가 유지되도록 했다.
.profile-img-wrap {
width: 256px;
height: 256px;
margin: 0 auto 16px;
border-radius: 18px;
overflow: hidden;
position: relative;
background: #0b0c2a;
}
.profile-img-wrap img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
opacity: 1;
transition: opacity .2s ease;
}
/* 로딩 중엔 이미지 숨김 */
.profile-img-wrap.is-loading img { opacity: 0; }
/* 로딩 오버레이 */
.profile-loader {
position: absolute;
left: 0; top: 0;
width: 100%; height: 100%;
display: none;
align-items: center;
justify-content: center;
background: rgba(11, 12, 42, 0.92);
}
.profile-img-wrap.is-loading .profile-loader { display: flex; }
.loader-bar {
width: 68%;
height: 10px;
border-radius: 999px;
background: rgba(255,255,255,0.10);
overflow: hidden;
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08);
}
.loader-bar::after {
content:"";
display:block;
height:100%;
width:40%;
background: rgba(255,255,255,0.95);
border-radius: 999px;
animation: loaderMove 1s infinite ease-in-out;
}
@keyframes loaderMove {
0% { transform: translateX(-120%); opacity: .35; }
50% { opacity: 1; }
100% { transform: translateX(220%); opacity: .35; }
}
3) JS
preload + 재시도 + cache-bust (성공할 때까지 로딩 유지)
핵심은 “실제로 로드 성공한 이미지”만 화면에 반영하는 것이다.
- new Image()로 preload
- 성공하면 그 순간 <img src> 교체
- 실패하면 일정 간격으로 재시도
- 재시도 동안은 .is-loading 상태 유지
- 캐시 방지를 위해 ?v=timestamp를 붙여 강제 갱신
(function () {
function addCacheBust(url) {
const sep = url.includes("?") ? "&" : "?";
return url + sep + "v=" + Date.now();
}
document.addEventListener("DOMContentLoaded", function () {
const wrap = document.getElementById("profileWrap");
const img = document.getElementById("profilePreview");
if (!wrap || !img) return;
const real = img.dataset.realSrc || "";
const fallback = img.dataset.defaultSrc || "";
// 로더 ON
wrap.classList.add("is-loading");
// 프로필 미설정이면 기본이미지로 종료
if (!real) {
img.src = addCacheBust(fallback);
wrap.classList.remove("is-loading");
return;
}
const INTERVAL_MS = 250;
const MAX_WAIT_MS = 15000; // 15초까지는 "될 때까지" 시도
const startAt = Date.now();
function tryLoad() {
const pre = new Image();
pre.onload = function () {
img.src = pre.src; // preload 성공한 src만 반영
wrap.classList.remove("is-loading"); // 로딩 종료
};
pre.onerror = function () {
const elapsed = Date.now() - startAt;
// 너무 오래 기다렸으면 fallback
if (elapsed >= MAX_WAIT_MS) {
img.src = addCacheBust(fallback);
wrap.classList.remove("is-loading");
return;
}
// 아직 시간 남았으면 재시도 (로딩 UX는 유지)
setTimeout(tryLoad, INTERVAL_MS);
};
pre.src = addCacheBust(real);
}
// 페이지 뜬 직후 너무 성급한 첫 요청도 피하기 위해 약간 딜레이
setTimeout(tryLoad, 1000);
});
})();
[ 결과 ]
Before
- 마이페이지 진입 → 이미지 404 → 기본 이미지로 확정
- 새로고침(F5)하면 정상
After
- 마이페이지 진입 → 로딩바 유지
- 실제 이미지가 준비되는 순간 자동 교체
- 새로고침 없이도 안정적으로 출력
▼ 서빙 반영이 완료될 때 까지 로딩 적용

▼ 완료 후 호출 시 이미지 변경 적용

마무리
이번 문제는 “서버 로직이 틀려서”가 아니라
정상 흐름 사이에서 발생한 ‘첫 요청 타이밍 문제’에 가까웠다.
그래서 해결도 서버를 억지로 느리게 만드는 방식이 아니라,
프론트에서 성공이 확인될 때까지 기다리고
그 과정에서 사용자에게는
불안하지 않은 UX를 제공하는 방향으로 마무리했다.
- preload로 성공 확인 후 src 반영
- 실패 시 시간 기반 재시도
- 로딩 UX 유지
- cache-bust로 브라우저 캐시 방어
“새로고침하면 되긴 하는데…”로 남겨두면
사용자 경험은 결국 깨진다.
이번처럼 로직으로 방어해두면,
같은 문제가 다시 생겨도 UX가 흔들리지 않는다.