전체 소스코드 7개 파일 · 630+ 라인 분석 · 33개 개선 항목 도출
팝업 렌더링 시 popupRef.current.innerHTML = `...${feature.get('systemId')}...` 형태로
서버에서 받은 systemId, address 값을 직접 HTML에 삽입합니다.
DB에 <img src=x onerror="alert('XSS')"> 같은 값이 저장된 경우 실행됩니다.
이는 OWASP Top 10 A03:2021 인젝션에 해당하는 취약점입니다.
// ❌ 현재 코드 (취약)
popupRef.current.innerHTML = `
<strong>${feature.get('systemId')}</strong>
<p>${feature.get('address')}</p>
`;
// ✅ 수정 방법 1: textContent 분리 설정
const idEl = popup.querySelector('#systemId');
if (idEl) idEl.textContent = feature.get('systemId');
// ✅ 수정 방법 2: React Portal로 팝업을 React DOM에서 렌더링
textContent 분리 설정 또는 React Portal을 사용하여 안전하게 렌더링
mt0.google.com, map.daumcdn.net 등의 타일 서버 URL을 API 키 없이 직접 호출합니다.
Google Maps Platform ToS 및 Kakao Maps ToS 위반으로 IP 차단 또는 서비스 중단 위험이 있으며,
특히 운영 환경에서 갑작스러운 지도 미표시가 발생할 수 있습니다.
style={(feature) => new Style({ image: new CircleStyle({...}) })} 형태로
VectorLayer에 인라인 스타일 함수가 전달됩니다. 지도에 피처가 렌더링될 때마다
Style, CircleStyle, Fill, Stroke, Text
객체가 새로 생성되어 피처 수가 많을수록 GC(Garbage Collection) 압박이 증가합니다.
// ❌ 현재 (매 렌더마다 객체 생성)
style={(feature) => new Style({ image: new CircleStyle(...) })}
// ✅ 개선 (컴포넌트 외부에서 캐시 함수 정의)
const styleCache = new Map<number, Style>();
const getFeatureStyle = (feature: Feature) => {
const count = feature.get('lightSourceCount') ?? 0;
if (!styleCache.has(count)) {
styleCache.set(count, new Style({...}));
}
return styleCache.get(count)!;
};
lightSourceCount 값 기반 스타일 캐시 맵을 컴포넌트 외부 상수로 추출
(feature.getGeometry() as any).getCoordinates() 형태로 OpenLayers의 Geometry 타입을
any로 캐스팅합니다. 타입 정보가 소실되어 자동완성이 동작하지 않고,
런타임에 다른 타입의 Geometry가 들어올 경우 에러를 감지할 수 없습니다.
// ❌ 현재
const coords = (feature.getGeometry() as any).getCoordinates();
// ✅ 개선
import Point from 'ol/geom/Point';
const geom = feature.getGeometry();
if (!(geom instanceof Point)) return;
const coords = geom.getCoordinates();
instanceof Point 가드로 타입을 좁힌 후 접근
(res.data as any).imported 형태로 API 응답을 any로 캐스팅합니다.
오타 또는 백엔드 응답 변경 시 컴파일 타임에 감지되지 않고 런타임에서만 에러가 발생합니다.
// types/index.ts에 추가
export interface ImportResponse {
imported: number;
skipped?: number;
errors?: string[];
}
// api/index.ts 수정
importLocations: (file: File) =>
api.post<ImportResponse>('/import/locations', form).then(r => r.data),
// App.tsx 수정
const data = await importApi.importLocations(file); // data: ImportResponse
setImportStatus(`${data.imported}개 임포트 완료`);
ImportResponse 인터페이스 정의 후 API 제네릭 타입으로 지정
<LocationForm onCreated={() => {}} /> — onCreated 콜백이 빈 함수입니다.
위치를 성공적으로 등록해도 지도에 새 핀이 추가되지 않아 사용자가 등록 성공 여부를 알 수 없습니다.
onCreated에서 locationApi.getAll()을 재호출하거나,
RadiusSearch 결과를 refresh하는 콜백으로 교체
탭 UI에 role="tablist", role="tab", aria-selected,
aria-controls 속성이 없습니다. 스크린 리더 사용자는 현재 어떤 탭이 선택되었는지 알 수 없으며,
키보드 화살표 키 내비게이션도 지원되지 않습니다. WCAG 2.1 SC 4.1.2 위반입니다.
// ✅ 접근성 있는 탭 구조
<div role="tablist">
{tabs.map(tab => (
<button
role="tab"
aria-selected={activeTab === tab.id}
aria-controls={`panel-${tab.id}`}
tabIndex={activeTab === tab.id ? 0 : -1}
>{tab.label}</button>
))}
</div>
업로드가 진행 중일 때 파일 <input>이 비활성화되지 않습니다.
업로드 중 다른 파일을 선택하면 중복 요청이 발생할 수 있으며,
로딩 중 상태가 시각적으로 표시되지 않아 사용자가 완료 여부를 알 수 없습니다.
const [importing, setImporting] = useState(false) 상태 추가 후
<input disabled={importing}> 및 로딩 스피너 표시
사용자가 좌표 필드를 비워두면 Number('') = 0이 되어 위도/경도 0,0 (아프리카 기니만)으로
등록됩니다. 의도하지 않은 위치에 핀이 생성되고 사용자는 에러 메시지를 받지 못합니다.
// ✅ 유효성 검사 추가
const xNum = Number(x);
const yNum = Number(y);
if (isNaN(xNum) || isNaN(yNum) || x === '' || y === '') {
setError('유효한 좌표를 입력해주세요.');
return;
}
if (xNum < -180 || xNum > 180 || yNum < -90 || yNum > 90) {
setError('경도는 -180~180, 위도는 -90~90 범위여야 합니다.');
return;
}
src/utils/validation.ts 생성 후 좌표 범위 유효성 검사 함수 공유
스크린 리더는 placeholder를 레이블로 인식하지 않습니다. WCAG 2.1 SC 1.3.1 위반입니다.
또한 좌표 입력 필드가 type="text"로 설정되어 있어 모바일에서 숫자/소수점 키패드가 표시되지 않습니다.
// ✅ label 연결 + 숫자 입력
<label htmlFor="coord-x">경도 (X)</label>
<input
id="coord-x"
type="number"
inputMode="decimal"
step="any"
min="-180"
max="180"
placeholder="126.978"
/>
htmlFor/id 연결 + type="number" + inputMode="decimal"
catch { setError('오류가 발생했습니다.') } 형태로 에러 객체를 무시합니다.
서버가 반환하는 중복 ID 에러, 주소 변환 실패 등의 구체적인 메시지를 사용자에게 전달하지 못합니다.
// ✅ 에러 메시지 추출
} catch (err) {
const msg = err instanceof AxiosError
? err.response?.data?.message ?? err.message
: '알 수 없는 오류가 발생했습니다.';
setError(msg);
}
AxiosError 타입 가드로 서버 응답 메시지를 추출하여 표시
반경 입력이 비어있거나 음수인 경우에도 API를 호출합니다. 좌표 모드에서는 LocationForm과 동일하게
Number('') = 0 변환 문제가 발생합니다. 백엔드에서 radius=0으로 요청되면
의미 없는 빈 결과가 반환됩니다.
src/utils/validation.ts의 공유 유효성 함수로 좌표·반경 모두 검증
locationApi.addMeasurement(locationId, { systemId, ... }) 호출 시
URL 경로에 위치 ID(locationId)를 사용하면서 바디에도 별도의 systemId를 전달합니다.
함수 시그니처의 첫 번째 파라미터가 systemId로 명명되어 위치 ID와 측정값 ID가 혼동됩니다.
api/index.ts에서 addMeasurement(systemId, ...) →
addMeasurement(locationId, ...) 로 파라미터명 일관성 통일
동일한 inputStyle, buttonStyle 객체가 3개 컴포넌트에 각각 복사되어 있습니다.
스타일 변경 시 3곳을 모두 수정해야 하며, 실수로 불일치가 발생할 수 있습니다.
// ✅ src/styles/common.ts 생성
export const inputStyle = {
width: '100%',
padding: '8px',
marginBottom: '8px',
border: '1px solid #ccc',
borderRadius: '4px',
fontSize: '14px',
} as const satisfies React.CSSProperties;
src/styles/common.ts에 공유 스타일 상수 추출, 3개 컴포넌트에서 import
HTTP 401, 403, 500 등 서버 에러가 각 컴포넌트의 catch 블록으로 버블링됩니다.
모든 컴포넌트에서 에러를 개별 처리하므로 공통 에러(네트워크 끊김, 서버 다운 등)에 대한
일관된 UX를 제공하기 어렵습니다.
// ✅ Axios 인터셉터 추가
api.interceptors.response.use(
response => response,
error => {
const status = error.response?.status;
if (status === 401) { /* 인증 처리 */ }
if (status >= 500) {
console.error('서버 오류:', error.response?.data);
}
return Promise.reject(error);
}
);
api.interceptors.response.use()로 HTTP 상태 코드별 공통 에러 처리 중앙화
import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1' —
환경변수 미설정 시 로컬호스트로 폴백됩니다. 배포 환경에서 이 상태로 배포하면
브라우저가 localhost:8080에 요청을 보내 모든 API가 실패합니다.
.env.example 파일도 없어 신규 팀원이 설정을 모를 수 있습니다.
.env.example 생성 + 환경변수 누락 시 앱 시작을 막는 early validation 추가
.logo, .card, .read-the-docs, @keyframes logo-spin 등
Vite 초기 템플릿 CSS가 전부 잔존합니다. 이 클래스들은 실제 앱에서 사용되지 않으며,
번들에 불필요한 CSS가 포함됩니다.
App.css 전체 삭제 후 실제 앱에 필요한 스타일만 재작성
const tabs = [{ id: 'map', label: '지도' }, ...]가 컴포넌트 본문 안에 선언되어
렌더링마다 새 배열이 생성됩니다. 탭 항목은 정적 데이터이므로 컴포넌트 외부 상수로 이동해야 합니다.
// ✅ 컴포넌트 외부 상수
const TABS = [
{ id: 'map', label: '🗺 지도' },
{ id: 'add', label: '📍 위치 추가' },
{ id: 'measure', label: '📏 측정값' },
{ id: 'import', label: '📂 데이터 가져오기' },
] as const;
const TABS = [...]로 이동
브라우저 탭 제목이 "frontend"로 표시되고, 스크린 리더가 문서 언어를 영어로 인식합니다.
lang="ko", <title>측정 시스템</title>
현재 axios의 고급 기능(취소 토큰 등)을 사용하지 않습니다. 네이티브 fetch로 교체 시 ~2.5MB 의존성 제거 가능합니다.
App.tsx에서 MapView에 centerCoords를 전달하지 않아 기능이 동작하지 않습니다.
100ms 타임아웃은 레이아웃 완료를 보장하지 않습니다. 느린 디바이스에서 지도가 잘릴 수 있습니다.
ResizeObserver + map.updateSize()
자체 정의한 GeoJsonFeature, GeoJsonFeatureCollection을 npm의 @types/geojson 표준 타입으로 교체하면 GeoJSON 스펙 전체를 커버하고 유지보수 부담이 줄어듭니다.
npm i -D @types/geojson 후 import type { Feature, FeatureCollection } from 'geojson'