[프로젝트 고도화] API 주소 환경변수 분리부터 에디터 도입, 공지사항 게시판 구현까지

2026. 3. 22. 23:30·Study

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())해 주고 나서야 글과 사진이 정상적으로 화면에 나타났다.

 

다음 목표 🚀 아직 고도화할 내용이 많이 남아있다. 차후에는 다음 순서로 프로젝트를 더 다듬어 볼 생각이다.

  1. 이벤트(Event) 게시판 CRUD 추가: 공지사항과 비슷하지만 메인 배너 이미지가 포함되는 이벤트 전용 게시판 구축
  2. 조회수(View Count) 로직 고도화: 현재 새로고침 할 때마다 무지성으로 오르는 조회수를 쿠키나 세션을 활용해 어뷰징을 방지하도록 개선
  3. 클라우드 이미지 호스팅 도입: 현재 로컬 폴더에 저장되는 이미지들을 S3나 다른 플랫폼으로 마이그레이션
  4. 웹 플랫폼 배포: 다듬어진 결과물을 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
'Study' 카테고리의 다른 글
  • [프로젝트 고도화 3편] 로컬을 넘어 클라우드로: 이미지 호스팅부터 도커 배포까지
  • [프로젝트 고도화 2편] 이벤트 게시판 구축과 쿠키를 활용한 조회수 어뷰징 방지
  • [Week 3] 쇼핑몰 백엔드 테스트 완성: Controller 계층 API 검증과 MockMvc 도입기
  • [Week 2] 쇼핑몰 백엔드 테스트: Repository 계층 검증과 H2 DB 도입기
bugbite
bugbite
저의 개인적인 개발 일지를 기록하는 공간입니다.
  • bugbite
    bugbite의 개발 기록
    bugbite
  • 전체
    오늘
    어제
    • 분류 전체보기 (181)
      • Study (7)
        • 코딩테스트 (1)
      • 코멘트 (7)
        • TIL (7)
      • 멀티캠퍼스 (166)
        • TILㆍWIL (142)
        • TIL 챌린지 (24)
  • 공지사항

  • hELLO· Designed By정상우.v4.10.4
bugbite
[프로젝트 고도화] API 주소 환경변수 분리부터 에디터 도입, 공지사항 게시판 구현까지
상단으로

티스토리툴바