1. 들어가며
팀 프로젝트로 진행했던 쇼핑몰 배포가 끝난 후, 아쉬웠던 부분들을 고도화해보고 싶어서 로컬로 환경을 옮겨 대대적인 리팩토링과 신규 기능 추가를 시작했다. 특히 관리자나 사용자가 글을 쓸 때 텍스트만 입력되는 게 답답해서 React-Quill 에디터를 도입하고 공지사항 게시판을 새롭게 만들어봤는데, 그 과정에서 고민과 해결 과정을 개발 기록으로 남겨본다.
2. API 주소 하드코딩 제거 및 환경변수 적용
처음 코드를 열어보고 가장 먼저 부딪힌 문제는 수많은 파일에 배포 서버의 IP 주소가 하드코딩되어 있다는 점이었다. "이거 나중에 서버나 포트 바뀌면 다 찾아다니면서 고쳐야 하는데?"라는 생각이 번쩍 들었다.
이런 비효율적인 구조를 해결하기 위해 프론트엔드 최상단에 .env 파일을 만들어서 API 주소를 환경변수로 분리하고, Axios(또는 Fetch)의 기본 주소를 전역에서 쓰도록 리팩토링했다.
# .env
REACT_APP_API_URL=http://localhost:8080
// src/utils/api.js
const API_BASE_URL = `${process.env.REACT_APP_API_URL}/api`;
// 기존 하드코딩 방식 탈피
// const response = await fetch(`http://13.231.28.89:18080/api/products`);
const response = await fetch(`${API_BASE_URL}/products`);
이렇게 세팅해 두니 앞으로 서버 주소가 바뀌더라도 .env 파일 딱 한 줄만 수정하면 전체 앱에 알아서 적용되는 아주 편안한 구조가 되었다.
3. React-Quill 에디터 도입 및 비동기 이미지 업로드
상품 설명이나 공지사항에 서식도 넣고 사진도 마음껏 넣을 수 있도록 react-quill-new 라이브러리를 도입했다.
여기서 가장 큰 난관은 이미지 업로드였다. 에디터에 이미지를 그대로 드래그 앤 드롭하면 이미지가 수만 글자의 Base64 문자열로 변환되어서 텍스트와 뭉쳐버린다. 이대로 DB에 넣었다간 용량 문제도 발생하고 로딩 속도도 크게 저하될 게 뻔했다.
그래서 에디터에 이미지가 올라가는 순간을 가로채서, 이미지만 서버로 몰래 먼저 보내고 서버에서 돌려준 이미지 URL을 본문에 끼워 넣는 커스텀 핸들러를 만들었다.
3-1. 프론트엔드: 이미지 핸들러 및 파일 전송 API
// src/utils/api.js
export const uploadImageWithAuth = async (url, formData) => {
const token = storage.get(STORAGE_KEYS.TOKEN);
const headers = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const response = await fetch(`${process.env.REACT_APP_API_URL}/api${url}`, {
method: 'POST',
headers: headers, // ⚠️ 브라우저가 자동 설정하도록 Content-Type 수동 입력 금지!
body: formData,
});
return response;
};
여기서 꽤 고생했는데, 핵심은 FormData를 보낼 때 Content-Type을 절대 수동으로 적어주면 안 된다는 것이다. 브라우저가 알아서 multipart/form-data로 인식하고 세팅하도록 내버려 둬야 백엔드에서 에러(MultipartException)가 발생하지 않는다.
// src/pages/admin/AdminNoticeNew.js
const imageHandler = () => {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/*');
input.click();
input.addEventListener('change', async () => {
const file = input.files[0];
const formData = new FormData();
formData.append('image', file);
try {
const response = await uploadImageWithAuth('/admin/products/editor/image', formData);
if (response.ok) {
const data = await response.json();
const baseUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080';
const imageUrl = baseUrl + data.imageUrl;
// 에디터 커서 위치를 찾아서 이미지 URL 넣기
const editor = quillRef.current.getEditor();
const range = editor.getSelection();
editor.insertEmbed(range.index, 'image', imageUrl);
}
} catch (error) {
console.error('이미지 업로드 실패:', error);
}
});
};
3-2. 백엔드: 물리적 경로 매핑 및 파일 저장
백엔드(Spring Boot)에서는 프론트가 던져준 이미지를 C드라이브 같은 실제 물리적 경로에 예쁘게 저장하고, 브라우저가 해당 사진을 불러올 수 있도록 접근 경로를 설정해야 했다.
// src/main/java/com/shoppingmallcoco/project/config/WebMvcConfig.java
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Value("${file.upload-dir}")
private String uploadDir;
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// 윈도우 경로(\)를 슬래시(/)로 안전하게 통일
String safePath = uploadDir.replace("\\", "/");
if (!safePath.endsWith("/")) { safePath += "/"; }
String fileLocation = "file:///" + safePath;
registry.addResourceHandler("/images/**").addResourceLocations(fileLocation);
registry.addResourceHandler("/uploads/**").addResourceLocations(fileLocation);
}
}
이제 컨트롤러에서 이미지를 저장한 뒤 /images/editor/사진이름.jpg 형태로 프론트엔드에 돌려주기만 하면, 에디터 본문에 사진이 완벽하게 나타난다.
4. 공지사항 게시판 CRUD 및 HTML 렌더링
에디터 연동에 성공한 기세를 몰아 곧바로 공지사항 게시판 CRUD를 만들었다. 관리자가 에디터로 글을 쓰면 DB의 CLOB 타입 컬럼에 HTML 태그까지 통째로 저장된다.
문제는 유저들이 보는 화면에서 이 태그가 문자 그대로 노출되면 안 된다는 점이었다. 리액트는 보안상(XSS 공격 방지) 태그를 단순 문자열로 취급하기 때문인데, 이를 원래 의도대로 렌더링하기 위해 dangerouslySetInnerHTML을 사용했다.
// src/pages/notice/NoticeDetailPage.js
return (
<div style={{ maxWidth: '800px', margin: '0 auto', padding: '50px 20px' }}>
<h2 style={{ textAlign: 'center', marginBottom: '40px', fontWeight: 'bold' }}>공지사항</h2>
{/* ... 타이틀 및 메타 정보 생략 ... */}
{/* DB에 저장된 HTML 태그 본문을 실제 화면으로 예쁘게 렌더링 */}
<div
style={{ fontSize: '16px', lineHeight: '1.8', paddingBottom: '50px' }}
dangerouslySetInnerHTML={{ __html: notice.content }}
/>
</div>
);
5. 관리자 페이지 UI/UX 개선: 스피너와 페이지네이션
핵심 기능 구현이 끝난 뒤, 관리자 페이지의 디테일을 조금 더 챙겨보기로 했다. 기존에는 목록이 화면 끝까지 고무줄처럼 늘어나서 밸런스가 안 맞았고, 데이터를 불러올 때 화면이 멈춘 것처럼 보이는 현상이 있었다. 이를 해결하기 위해 가로 폭 제한(1200px), 로딩 스피너, 커스텀 페이지네이션을 적용했다.
// src/pages/admin/AdminNoticeList.js
import Spinner from '../../components/admin/Spinner';
import Pagination from '../../components/admin/Pagination';
const AdminNoticeList = () => {
// ... 상태 관리 및 데이터 불러오기 로직 생략 ...
// 로딩 중일 땐 돌아가는 스피너 보여주기
if (loading) {
return (
<div className="admin-layout">
<div className="admin-content" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '600px' }}>
<Spinner />
</div>
</div>
);
}
return (
<div className="admin-layout">
<div className="admin-content">
{/* 가로 폭을 1200px로 제한해서 시선 집중 및 통일성 부여 */}
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
{/* ... 테이블 렌더링 로직 ... */}
{/* 분리해둔 커스텀 페이지네이션 적용 */}
{totalPages > 1 && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
/>
)}
</div>
</div>
</div>
);
};
스피너와 페이지네이션을 별도의 컴포넌트로 분리해 둔 덕분에, 다른 관리자 페이지에서도 재사용하기 너무 편했다.
6. 트러블 슈팅 및 다음 목표
이번 고도화 작업을 진행하면서 꽤 여러 번 벽에 부딪혔는데, 가장 기억에 남는 트러블 슈팅은 단연 DB 엔티티 매핑 에러(ORA-17006)와 권한 문제(403 Forbidden)였다.
공지사항 글을 저장하려는데 자꾸 "ORA-17006: 열 이름이 부적합합니다"라는 에러가 발생했다. 처음엔 단순 오타인 줄 알고 테이블을 한참 들여다봤는데, 콘솔에 찍힌 하이버네이트 쿼리 로그를 뜯어보니 원인이 보였다. 스프링 부트가 글 번호(PK)를 찾을 때 noticeNo라는 자바 변수명을 그대로 오라클에 전송하고 있었던 것이다. DB 테이블 컬럼명은 notice_no로 만들어 두었으니 당연히 매핑이 안 돼서 발생한 에러였다.
결국 엔티티 필드에 @Column(name = "notice_no")라고 명시적으로 적어주니 허무하게도 해결되었다. JPA가 카멜 케이스를 알아서 스네이크 케이스로 잘 변환해주겠지라고 너무 안일하게 믿고 있었던 것 같다. 역시 ORM이 편하긴 해도, 에러가 났을 때는 콘솔에 찍힌 실제 쿼리 로그부터 꼼꼼히 확인해보는 게 가장 확실한 해결책이라는 걸 배웠다.
또 하나는 기껏 올린 공지사항 이미지와 목록이 유저 화면에서만 안 불러와지는 문제였다. 개발자 도구를 켜보니 403 에러가 발생하고 있었다. Spring Security가 "로그인 안 한 사람은 못 봐!" 하고 막아버린 것이다. WebSecurityConfig.java에 들어가서 /api/notices/** 경로의 접근을 허용(permitAll())해 주고 나서야 글과 사진이 정상적으로 화면에 나타났다.
다음 목표 🚀 아직 고도화할 내용이 많이 남아있다. 차후에는 다음 순서로 프로젝트를 더 다듬어 볼 생각이다.
- 이벤트(Event) 게시판 CRUD 추가: 공지사항과 비슷하지만 메인 배너 이미지가 포함되는 이벤트 전용 게시판 구축
- 조회수(View Count) 로직 고도화: 현재 새로고침 할 때마다 무지성으로 오르는 조회수를 쿠키나 세션을 활용해 어뷰징을 방지하도록 개선
- 클라우드 이미지 호스팅 도입: 현재 로컬 폴더에 저장되는 이미지들을 S3나 다른 플랫폼으로 마이그레이션
- 웹 플랫폼 배포: 다듬어진 결과물을 Vercel 등을 활용해 실제 서비스 환경처럼 배포해 보기
시간이 지날수록 코드가 튼튼해지는 걸 보니 꽤나 뿌듯하다. 다음 목표들도 하나씩 적용해봐야겠다.
'Study' 카테고리의 다른 글
| [프로젝트 고도화 3편] 로컬을 넘어 클라우드로: 이미지 호스팅부터 도커 배포까지 (0) | 2026.04.05 |
|---|---|
| [프로젝트 고도화 2편] 이벤트 게시판 구축과 쿠키를 활용한 조회수 어뷰징 방지 (0) | 2026.03.28 |
| [Week 3] 쇼핑몰 백엔드 테스트 완성: Controller 계층 API 검증과 MockMvc 도입기 (0) | 2026.03.12 |
| [Week 2] 쇼핑몰 백엔드 테스트: Repository 계층 검증과 H2 DB 도입기 (0) | 2026.03.07 |
| [Week 1] 쇼핑몰 백엔드 테스트 시작: 계층형 카테고리와 Mockito 단위 테스트 (0) | 2026.03.04 |