1. 전체 코드

import * as d3 from 'd3';
import { useRef, useEffect } from 'react';
import { feature } from 'topojson-client';
import seoul from './data/seoulmap.json'; // 파일 경로, 이름에 맞게 수정

const featureData = feature(seoul, seoul.objects['seoulmap']); // objects 이름 맞게 수정

function App() {
  const chart = useRef(null); 

  const printD3 = () => {
    const width = 700; // 지도 넓이
    const height = 700; // 지도 높이

    // 메르카토르 투영법 설정
    const projection = d3.geoMercator().scale(1).translate([0, 0]);
    const path = d3.geoPath().projection(projection);
    const bounds = path.bounds(featureData);

    const dx = bounds[1][0] - bounds[0][0];
    const dy = bounds[1][1] - bounds[0][1];
    const x = (bounds[0][0] + bounds[1][0]) / 2;
    const y = (bounds[0][1] + bounds[1][1]) / 2;
    const scale = 0.9 / Math.max(dx / width, dy / height);
    const translate = [width / 2 - scale * x, height / 2 - scale * y];

    projection.scale(scale).translate(translate);

    const svg = d3
      .select(chart.current)
      .append('svg')
      .attr('width', width)
      .attr('height', height);

    const mapLayer = svg.append('g');

    mapLayer
      .selectAll('path')
      .data(featureData.features)
      .enter()
      .append('path')
      .attr('d', path)
      .style('fill', '#5b5ba0') // 배경 색상
      .style('stroke', '#ffffff') // 경계선 색상
      .style('stroke-width', 0.2) // 경계선 굵기
      .on('mouseover', function () {
        d3.select(this).style('fill', '#212168'); // 마우스 올라갔을 때 색상
      })
      .on('mouseout', function () {
        d3.select(this).style('fill', '#5b5ba0'); // 마우스 뗐을 때 색상
      })
      .on('click', function (event, d) {
        alert('클릭된 요소: ' + d.properties.SIGUNGU_NM); // 클릭했을 때 이벤트
      });

    mapLayer
      .selectAll('.gu-label')
      .data(featureData.features)
      .enter()
      .append('text')
      .attr('class', 'gu-label') // 클래스 이름
      .attr('x', (d) => path.centroid(d)[0])
      .attr('y', (d) => path.centroid(d)[1])
      .attr('dx', '-0.9em') // x 좌표 위치 조정
      .attr('dy', '0.4em') // y 좌표 위치 조정
      .text((d) => d.properties.SIGUNGU_NM) // 표시할 라벨 텍스트 항목 설정
      .style('fill', '#ffffff') // 텍스트 색상 설정
      .style('font-size', '13px') // 텍스트 크기 설정
      .on('click', function (event, d) {
        alert('클릭된 요소: ' + d.properties.SIGUNGU_NM); // 클릭했을 때 이벤트
      });
  };
  
  useEffect(() => {
    printD3();

    // 정리 함수
    return () => {
      d3.select(chart.current).select('svg').remove();
    };
  }, []);

  return <div ref={chart}></div>;
}

export default App;

2. 코드 설명

npm install d3
npm install topojson-client

 

  먼저 d3, topojson-client 라이브러리를 설치해 줍니다.

import * as d3 from 'd3';
import { useRef, useEffect } from 'react';
import { feature } from 'topojson-client';
import seoul from './data/seoulmap.json'; // 파일 경로, 이름에 맞게 수정

 

  위의 요소들을 import 해주세요. 저는 소스 파일이 있는 폴더 안에 data 폴더를 만들고 그 안에 seoulmap.json 파일을 넣어서 경로를 이렇게 설정했습니다.

const featureData = feature(seoul, seoul.objects['seoulmap']); // objects 이름 맞게 수정

 

  seoul은 위에서 json 파일을 import한 이름, objects의 프로퍼티 이름은 이전 글을 참고해서 적어줍니다. 저는 프로퍼티 이름이 seoulmap으로 되어 있어서 이렇게 입력했습니다.

function App() {
  const chart = useRef(null);
  
  const printD3 = () => {
    const width = 700; // 지도 넓이
    const height = 700; // 지도 높이

    // 메르카토르 투영법 설정
    const projection = d3.geoMercator().scale(1).translate([0, 0]);
    const path = d3.geoPath().projection(projection);
    const bounds = path.bounds(featureData);

 

  지도의 기본 넓이와 높이를 설정하는 부분입니다. 원하시는 값을 설정하시면 됩니다. 그 아래 부분은 메르카토르 투영법을 사용해서 구형 지도를 평면에 표시하는 코드입니다. 따로 값을 수정하지 않아도 됩니다.

    const dx = bounds[1][0] - bounds[0][0];
    const dy = bounds[1][1] - bounds[0][1];
    const x = (bounds[0][0] + bounds[1][0]) / 2;
    const y = (bounds[0][1] + bounds[1][1]) / 2;
    const scale = 0.9 / Math.max(dx / width, dy / height);
    const translate = [width / 2 - scale * x, height / 2 - scale * y];

    projection.scale(scale).translate(translate);

 

  이 부분은 지도 데이터를 SVG 크기에 맞춰서 크기를 조정하고 지도 중앙에 배치하는 코드입니다. 이 부분도 따로 수정할 부분은 없습니다.

    const svg = d3
      .select(chart.current)
      .append('svg')
      .attr('width', width)
      .attr('height', height);

 

  지도가 그려질 캔버스를 그려주는 코드입니다. <svg width="700" height="700"> 이런 코드가 만들어집니다.

  const mapLayer = svg.append('g');

    mapLayer
      .selectAll('path')
      .data(featureData.features)
      .enter()
      .append('path')
      .attr('d', path)
      .style('fill', '#5b5ba0') // 배경 색상
      .style('stroke', '#ffffff') // 경계선 색상
      .style('stroke-width', 0.2) // 경계선 굵기
      .on('mouseover', function () {
        d3.select(this).style('fill', '#212168'); // 마우스 올라갔을 때 색상
      })
      .on('mouseout', function () {
        d3.select(this).style('fill', '#5b5ba0'); // 마우스 뗐을 때 색상
      })
      .on('click', function (event, d) {
        alert('클릭된 요소: ' + d.properties.SIGUNGU_NM); // 클릭했을 때 이벤트
      });

 

  지도가 그려지는 코드입니다. mouseover와 mouseout 이벤트를 이용해서 마우스 호버 시 동작을 설정할 수 있고, click 이벤트를 사용해서 클릭했을 때 이벤트를 설정할 수 있습니다. 위 코드는 지도를 클릭했을 때 구 이름이 경고창으로 뜨도록 설정을 했습니다. 구마다 경계가 나누어져 있기 때문에 구마다 다른 이벤트가 발생합니다.

    mapLayer
      .selectAll('.gu-label')
      .data(featureData.features)
      .enter()
      .append('text')
      .attr('class', 'gu-label') // 클래스 이름
      .attr('x', (d) => path.centroid(d)[0])
      .attr('y', (d) => path.centroid(d)[1])
      .attr('dx', '-0.9em') // x 좌표 위치 조정
      .attr('dy', '0.4em') // y 좌표 위치 조정
      .text((d) => d.properties.SIGUNGU_NM) // 표시할 라벨 텍스트 항목 설정
      .style('fill', '#ffffff') // 텍스트 색상 설정
      .style('font-size', '13px') // 텍스트 크기 설정
      .on('click', function (event, d) {
        alert('클릭된 요소: ' + d.properties.SIGUNGU_NM); // 클릭했을 때 이벤트
      });
  };

 

  다음은 지도의 라벨이 그려지는 코드입니다. 

 

  위와 같이 text 태그가 생성되는 것을 볼 수 있습니다. 라벨의 위치를 조정할 수 있고, 라벨 텍스트 색상과 크기를 설정할 수 있습니다.

  useEffect(() => {
    printD3();

    // 정리 함수
    return () => {
      d3.select(chart.current).select('svg').remove();
    };
  }, []);

  return <div ref={chart}></div>;
}

 

  useEffect를 이용해 랜더링 될 때 printD3 함수를 그려줍니다. 지도가 중복 생성되는 것을 방지하기 위해서 정리함수를 이용해 이전의 svg 요소를 삭제해 줍니다.

 

  여기까지 코드를 입력했다면 위와 같이 기본적인 서울시 지도가 만들어진 것을 볼 수 있습니다.

 

  CSS나 React 기능들을 이용해서 지도를 여러 가지 방식으로 커스텀할 수 있습니다. 이 글을 바탕으로 자신만의 멋진 지도를 만들어보시길 바랍니다. 읽어주셔서 감사합니다.


참고한 글

  • D3.js 를 사용하여 데이터 시각화하기 #7 우리나라 지도 그리기(svg) (링크)

1. GeoJSON 파일 만들기

 

  이전 글에서 설명한 대로 shp 파일을 QGIS로 드래그해서 서울시 지도 파일을 열어줍니다.

 

  레이어 창의 항목을 우클릭한 후 내보내기 → 피처를 다른 이름으로 저장을 눌러주세요.

 

  파일 이름은 원하시는 대로 설정해 주시면 되고, 포맷은 GeoJSON, 좌표계는 기본좌표계 EPSG: 4326 - WGS 84로 설정한 후에 확인을 눌러주세요. 약 2.2 MB 크기의 geojson 파일이 생생될 것입니다. 


2. GeoJSON 파일 단순화 후 TopoJSON 파일로 변환하기

 

  mapshaper 홈페이지(링크)에 접속한 후 select를 눌러서 위에서 생성한 geojson 파일을 불러와주세요.

  

  Simplify를 누른 후 Apply를 누릅니다.

 

  Settings의 값을 조절해 줍니다. 값이 작아질수록 경계선의 디테일이 점점 떨어지기 때문에 값을 조절하면서 원하는 값을 찾아줍니다. 

 

  메뉴에서 Export를 누르고 TopoJSON을 선택한 후 Export 버튼을 누르면 json 파일이 생성이 됩니다. 생성된 파일의 용량을 보면 앞에서 만든 2.2 MB 크기의 geojson파일보다 용량이 많이 줄어든 것을 볼 수 있습니다. (약 16 kb)

 

  생성된 json 파일을 열어보면 좌표값들과 이전 글에서 본 SIGUNGU_NM, SIGUNGU_CD와 같은 값들이 보일 것입니다. 

 

  Prettier로 코드를 정리하면 구조를 더 쉽게 볼 수 있습니다. Objects로 검색을 해서 위의 파일에 있는 부분을 찾아주세요. 저는 파일명을 seoulmap.geojson으로 해놔서 TopoJSON 파일을 생성할 때 프로퍼티로 seoulmap이 들어가 있습니다. 이 프로퍼티 명을 잘 기억해 주세요. React에서 json 파일을 불러올 때 프로퍼티 이름이 필요합니다. 원하는 이름으로 변경하거나 이 이름을 그대로 써도 됩니다. 이 부분은 다음 글에서 설명하도록 하겠습니다.


참고한 글

  • D3.js 를 사용하여 데이터 시각화하기 #7 우리나라 지도 그리기(svg) (링크)

1. QGIS 설치하기

 

  이전 글에서 다운로드한 지도 파일을 사용하기 위해서는 QGIS를 설치해야 합니다. QGIS는 데이터 뷰, 편집, 분석을 제공하는 크로스 플랫폼 자유-오픈 소스 데스크톱 지리 정보 체계(GIS) 응용 프로그램이라고 합니다. QGIS 공식 홈페이지(링크)에 들어가서 Download를 눌러주세요. Donate 창이 뜨면 Skip it and go to download를 누릅니다.

  

  사용하고 있는 운영체제에 맞는 Long Term Version을 다운로드합니다. 파일 용량은 3.34 버전 기준 1.2 GB로 큰 편입니다. 다운로드 속도가 느린 편이라 시간이 오래 걸릴 수도 있습니다.

 

  다운로드가 완료되었으면 설치 파일을 실행해 주세요. 계속 Next를 누르고 Install을 누르면 설치가 시작됩니다. 따로 설정해 줄 항목이 없으므로 설치가 완료되면 Finish를 눌러서 종료하면 됩니다.

 

  설치가 완료되면 프로그램을 실행해 봅니다.


2. QGIS 간단한 기능 소개

  • 지도 불러오기

 

  본격적으로 지도 데이터를 추출하기 전에 QGIS의 간단한 기능들을 소개하겠습니다. 먼저 다운로드한 파일 중 shp 확장자 파일을 끌어서 QGIS 프로그램에 붙여 넣어주세요.

 

  위와 같이 서울시 지도가 표시됩니다. 여기에는 지도뿐만 아니라 동 이름, 동 코드 등의 데이터가 함께 들어가 있습니다. 이 데이터들을 이용해 서울시 열린데이터광장과 같은 오픈 API 사이트에서 받은 데이터와 연결하여 다양한 지도를 만들 수 있습니다.

  • 라벨 표시하기

 

  다음은 지도에 라벨을 표시해 보겠습니다. QGIS창의 레이어 항목에서 'bnd_sigungu_11_2023_2023_2Q' 텍스트를 더블클릭하거나 마우스 우 클릭 후 '속성'을 누르면 지도를 표시하는 창이 뜹니다. 라벨 메뉴에서 단일 라벨을 선택해 줍니다. 값 항목에 있는 'SIGUNGU_NM'은 시군구 이름, 'SIGUNGU_CD'는 시군구 코드입니다. 'SIGUNGU_NM'을 선택하고 적용을 눌러줍니다.

 

  적용을 누르면 위와 같이 지도에 구 이름이 표시됩니다. 글꼴과 스타일, 크기 등을 원하는 대로 설정할 수 있습니다.

  • 지도 색상 변경하기

 

  다음은 지도의 색상을 바꿔보겠습니다. 다시 '속성' 창에 들어가서 심볼 메뉴를 선택하고 단순 채우기를 클릭합니다. 채우기 색상을 눌러서 색상을 변경하면 지도의 색상이 변경됩니다.

 

  적용을 누르면 위와 같이 지도의 색상이 변경된 것을 확인할 수 있습니다.

  • 속성 테이블 (데이터 확인 및 변경)

 

  다음으로 레이어 창의 'bnd_sigungu_11_2023_2023_2Q' 항목을 우클릭해서 '속성 테이블 열기'를 누르면 속성 테이블이 열립니다. 여기서 연필 모양의 버튼을 누르거나 Ctrl+E를 누르면 데이터를 편집할 수 있습니다.

  • 표현식을 이용해 피처 선택 (조건에 맞는 데이터 선택)

 

  '표현식을 이용해 피처 선택' 기능을 이용해 특정 조건에 맞는 데이터를 선택할 수 있습니다. 예를 들어 length(SIGUNGU_NM)=4와 같이 조건식을 입력하고 피쳐 선택을 누르면 구 이름이 네 글자인 구를 선택할 수 있습니다. 이 기능을 사용해서 오픈 API 데이터를 가져온 후 지도 데이터와 연결하여 특정 조건을 충족하는 구만 추출할 수 있습니다.

 

  여기까지 QGIS의 간단한 기능 소개를 마치겠습니다. 오픈 API 데이터를 가져와서 연동하거나 범례를 추가하는 등 여러 가지 기능이 있지만 리액트에서 지도를 표시할 때 필요한 기능은 아니기 때문에 생략하고 다음 글에서부터 본격적으로 D3.js를 이용하여 지도를 표시하는 방법을 소개하겠습니다. 

1. 들어가기 전에

 

  2차 프로젝트를 진행할 때 서울시 지도를 그려서 구별로 다른 정보를 표시하도록 구현을 하고자 했습니다. 처음에는 서울시 지도 이미지를 넣고 좌표 값마다 링크를 다르게 주는 방식으로 구현을 하고자 했으나 현업에서 일하고 있는 친구가 D3.js를 추천해 주었고 직접 구현을 해보니 너무 만족스러웠습니다. 3D 디자인은 프론트엔드 하시는 다른 팀원분께서 지도를 여러 번 겹쳐서 만들어주셔서 멋진 지도를 구현할 수 있었습니다. 앞으로 위 이미지와 같이 QGIS와 D3.js를 이용해서 서울시 지도를 구현하는 방법을 설명드리겠습니다.


2. 통계지리정보서비스 홈페이지에서 지도 데이터 다운 받기

 

  먼저 통계지리정보서비스 홈페이지(링크)에 접속한 후에 회원가입을 합니다. 회원가입 완료 후 자료제공 → 자료 신청 메뉴로 들어가 주세요.

 

  기본 정보에 소속과 연락처, 메일주소, 요청 목적, 활용 목적, 수행과제를 적습니다. 저는 소속은 민간, 요청목적은 지도 작성을 선택했습니다. 활용 목적과 수행과제는 대충 적으면 됩니다. 어차피 자동 승인되기 때문에 대충 적어도 승인이 됩니다. 제출 동의 및 자료제공기간에 동의를 하고 자료 선택에서는 통계지역경계, 자료대상은 센서스용 행정구역경계(시군구)를 선택해 주세요. 경계년도는 가장 최신 것을 선택하고 시도/시군구는 서울, 전체를 선택한 후 추가 버튼을 눌러주세요. 추가가 끝났으면 신청 완료를 눌러주세요.

 

  10분 정도 기다린 후 신청 내역을 보면 진행상태가 승인으로 바뀝니다. 신청자료 다운로드 메뉴로 가서 다운로드를 눌러주세요. 다운로드가 완료되면 압축을 풀어줍니다. QGIS를 설치하고 다운로드한 지도 데이터를 불러오는 방법은 다음 글에서 설명드리겠습니다.

  학원에서 수행했던 세 개의 프로젝트를 하나의 도메인에서 subpath로 구분해 서비스를 하려고 했는데 example.com/projectname 이런 방식으로 subpath를 넣으려고 하니 제대로 작동하지 않았습니다. Vanilla JS를 사용한 프로젝트도 있고 React를 사용해서 제작한 서비스도 있어서 해결 방법이 서로 달랐습니다. 원래는 문제를 해결한 다음 바로 글을 쓰려고 했는데 귀찮아서 미루다 보니 지금은 그때만큼 기억이 생생하지 않아서 전에 정리한 것 위주로 간략하게 적어보겠습니다.


1. Vanilla JS + Express

  1차 프로젝트는 템플릿 엔진은 ejs를 사용했고 프론트에서는 Vanilla JS, 백엔드에서는 Express를 사용해서 프로젝트를 제작했습니다.

  • Nginx 설정 수정
    location /<subPathName> {
        proxy_pass http://localhost:<포트 번호>;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

 

  먼저 .env 파일을 열어 포트 번호가 겹치지 않게 수정해 준 후에 pm2 라이브러리를 이용해서 프로젝트를 실행했습니다. 그러고 나서 /etc/nginx/sites-enabled 폴더에 있는 사이트 설정 파일을 에디터로 열고 원하는 subpath와 앞에서 설정한 포트번호를 추가했습니다. proxy_pass 부분에서 주소 뒤에 '/'을 붙였다가 링크나 이미지가 제대로 작동을 하지 않았었는데 '/'를 지우니 제대로 작동했습니다.

  • proxy_pass http://localhost:3000;
    → /subPathName/img/logo.png로 접속 시 http://localhost:3000/subPathName/img/logo.png로 전달
  • proxy_pass http://localhost:3000/;
    → /subPathName/img/logo.png로 접속 시 http://localhost:3000/img/logo.png로 전달
  • 프론트 코드 수정 (JavaScript, HTML)
async function getLoginStatus() {
  try {
    const response = await fetch(`/<subPathName>/auth/status`);
    if (!response.ok) {
      throw new Error("데이터를 불러오는 중에 문제가 발생했습니다.");
    }
    const data = await response.json();
    return data;
  } catch (error) {
    console.error(error);
  }
}

async function goToUserPage() {
  try {
    const logintrue = await getLoginStatus();

    if (logintrue.status === true) {
      window.location.href = `/<subPathName>/userpage?user=${logintrue.data.userId}`;
    } else {
      alert("로그인이 필요한 서비스입니다.");
      window.location.href = `/<subPathName>/login`;
    }
  } catch (error) {
    console.error(error);
    alert("로그인이 필요한 서비스입니다.");
    window.location.href = `/<subPathName>login`;
  }
}

 

  그 후에 JaveScript 코드에 있는 fetch, window.location.href의 경로 등에 subpath를 앞에 추가했습니다.

<html>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Network</title>

    <link rel="stylesheet" href="/<subPathName>/main.css" />
    <link rel="stylesheet" href="/<subPathName>/layout/header.css" />
    <link rel="stylesheet" href="/<subPathName>/network/network.css" />
    <link rel="icon" href="/<subPathName>/img/favicon.png" type="image/x-icon" />
  </head>
  <body>
    <div class="container">
      <!-- header -->
      <div class="header">
        <div class="logo">
          <a href="/<subPathName>">
            <img class="logo-image" src="/<subPathName>/img/logo.png" alt="logo" />
          </a>
        </div>
    <script src="/<subPathName>/layout/header.js"></script>
    <script src="/<subPathName>/network/network.js"></script>
  </body>
</html>

 

  html 파일 역시 경로 앞에 subpath를 추가했습니다.

  • 백엔드 코드 수정
app.set("views", __dirname + "/views");

app.use("/<subPathName>", express.static(path.join(__dirname, "/views")));
app.use("/<subPathName>", express.static(path.join(".", "uploads/profileImg")));

app.get("/<subPathName>", (req, res) => {
  res.render("network/network.html");
});

app.get("/<subPathName>/login", (req, res) => {
  res.render("login/login.html");
});

app.use("/<subPathName>/auth", authRouter);
app.use("/<subPathName>/users", userRouter);

app.use((req, res, next) => {
  res.redirect("/<subPathName>/404");
});

 

  index.js 파일을 위와 같이 수정했습니다. router 파일의 get, post와 같은 경로에는 subpath를 추가하지 않습니다. 이미 route로 접속할 때 subpath가 추가되도록 설정을 해주었기 때문입니다.


2. React + Express

  2차 프로젝트는 React와 Express를 이용해서 서비스를 제작했습니다. React는 CRA를 이용해서 초기 세팅을 했습니다.

  • Nginx 설정 수정
    location /<subPathName> {
        alias /var/www/project2/client/build;
        index index.html;
        try_files $uri $uri/ /<subPathName>/index.html;
    }

    location /<subPathName>/api {
        proxy_pass http://localhost:<포트 번호>;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

 

  프론트 코드의 경우 npm run build를 이용해서 build를 해준 뒤 static 파일로 연결을 해주었고, 백엔드 코드의 경우 pm2로 프로젝트를 실행했습니다. static 경로를 설정할 때 root가 아닌 alias를 사용해야 location에서 설정한 subpath로 접속 시 alias에서 설정해 준 경로로 변환해서 접속을 하게 됩니다.

  • 프론트 코드 수정 (package.json, App.js)
{
  "name": "my-app",
  "version": "0.1.0",
  "private": true,
  "homepage": "/<subPathName>",
  "dependencies": {
  ...
  }
}

 

  먼저 package.json 파일을 열어 homepage 항목을 추가하고 subpath를 설정했습니다.

function App() {
  return (
    <PageWrapper className='App'>
      <Router basename='/<subPathName>'>
        <AppContent />
      </Router>
    </PageWrapper>
  );
}

 

  App.js를 열어 Router basename에 subpath를 추가했습니다. Routh path에는 추가하지 않아도 됩니다.

const OptionContainer = styled.div`
  span {
    font-size: 32px;
    line-height: 60px;
    font-weight: bold;
    margin: 0 15px;
  }
  select {
    margin-left: 15px;
    width: 150px;
    font-size: 25px;
    border-style: none;
    border-bottom: 3px solid #5fc3c8;
    appearance: none;
    background: url('./img/recommendInput/polygon1.png') no-repeat right 10px
      center;
    background-size: 15px;
  }
}

 

  App.js까지 수정해 주었다면 따로 수정해 줄 부분은 없지만 만약 이미지 url 경로가 '../img'로 되어 있다면 부모 디렉터리의 주소가 기준이 되기 때문에 이미지가 제대로 표시되지 않습니다. './img'로 바꿔줘야 현재 디렉터리를 기준으로 해서 이미지가 제대로 표시됩니다.

  • 백엔드 코드 수정 (1차와 동일)
app.use('/<subPathName>',express.static(path.join(__dirname, 'client')));

app.use('/<subPathName>/api/allResearch', allResearchRouter);

 

  1차 프로젝트에서 했던 것과 같이 static, router 경로에 subpath를 추가했습니다.


3. React (Vite) + Express, TypeScript

  3차 프로젝트는 TypeScript, React (Vite), Express를 이용해서 제작했습니다.

  • Nginx 설정 수정

  2차 프로젝트와 다른 점은 build 할 때 npm run build가 아닌 vite build를 사용했다는 것입니다. 다른 설정은 동일합니다.

  • 프론트 코드 수정 (vite.config.ts, App.tsx)
export default defineConfig({
  base: '/<subPathName>',
  plugins: [react(), svgr()],
  resolve: {
    alias: {
      '/img': '/src/assets/img'
    }
  }
});

 

  2차에서는 package.json 파일을 수정했지만 이번에는 vite.config.ts 파일을 수정했습니다. base 항목에 subpath를 추가했습니다.

const App = () => {
  return (
    <Router basename="/<subPathName>">
      <Routes>
        <Route path='/' element={<Home />} />
        <Route path='/search' element={<Search />} />
        <Route path='*' element={<NotFound />} />
      </Routes>
    </Router>
  );
};

 

  2차에서 했던 것과 같이 App.tsx 파일의 Router basename에 subpath를 추가했습니다.

const refreshApi = axios.create({
  baseURL: '/',
  withCredentials: true
});

// 토큰 갱신
const refreshAuthToken = async () => {
  try {
    const response = await refreshApi.post(`/<subPathName>/api/auth/token`);
    return response.data.token;
  } catch (err) {
    console.error('Refresh token failed:', err);
    throw err;
  }
};

 

  만약 fetch나 axios 경로가 있다면 여기에도 subpath를 추가해 줍니다.

  • 백엔드 코드 수정 (1차, 2차와 동일)

  앞에서 했던 것과 동일하게 static, router 경로에 subpath를 추가했습니다.

1. PostgreSQL 설치하기

sudo apt-get update
sudo apt install postgresql

 

  위와 같이 입력해서 PostgreSQL을 설치해 줍니다.

sudo systemctl is-enabled postgresql

# disabled일 경우
sudo systemctl enable postgresql

 

  설치가 완료되면 서버를 재시작할 때마다 PostgreSQL이 자동으로 실행되도록 설정이 되어 있을 것입니다. 위의 명령어를 입력해서 확인한 후에 만약 disabled라고 뜬다면 자동으로 시작되도록 설정해 주세요.


2. 포트 변경하고 방화벽 설정하기

  PostgreSQL은 기본적으로 5432 포트를 사용합니다. 5432 포트는 잘 알려져 있는 포트이기 때문에 보안 상 좋지 않으므로 다른 포트를 사용해 주는 것이 좋습니다.

sudo vim /etc/postgresql/<Version>/main/postgresql.conf # 16 버전이면 /etc/postgresql/16/main

port = 5432 # 다른 포트 번호로 수정

 

  위와 같이 에디터를 열어서 'port=5432' 부분을 찾아 포트를 수정해 줍니다.

sudo systemctl restart postgresql

 

  포트를 수정한 후 PostgreSQL을 재시작해주세요.

sudo ufw allow 5432/tcp # 다른 포트로 수정한 경우 다른 포트 입력
sudo ufw status
sudo ufw reload

 

  위와 같이 입력해서 방화벽에서 포트를 허용해 줍니다. 5432 포트 대신 다른 포트를 설정했다면 변경한 포트 번호를 입력합니다.


3. 외부 접속 허용하기

  DBeaver와 같은 외부 프로그램을 이용해서 접속하려면 외부 접속을 허용해주어야 합니다. 이후 SSL 인증서나 특정 IP만 접속 가능하게 하는 등 절차를 추가하는 것이 좋습니다.

sudo vim /etc/postgresql/<Version>/main/postgresql.conf

listen_addresses = '*'

 

  PostgreSQL 설정 파일을 에디터로 열고 listen_addresses 항목을 '*'로 설정한 후 저장해 주세요. 기본값은 'localhost'로 되어 있어 localhost만 접속이 가능하지만 '*'으로 설정하면 모든 ip의 연결을 허용합니다. 모든 IP가 아닌 특정 IP의 연결만 허용하고 싶다면 특정 IP를 적어주면 됩니다. 여러 IP의 연결을 허용하고 싶다면 콤마(,)로 구분하면 됩니다.

sudo vim /etc/postgresql/16/main/pg_hba.conf

 

  listen_addresses 설정이 끝났으면 접근 권한을 설정해주어야 합니다. pg_hba.conf 파일을 에디터로 열어주세요.

# 모든 IP를 허용하려면
host all all 0.0.0.0/0 scram-sha-256

# 특정 IP를 허용하려면
host all all <허용할 IP>/32 scram-sha-256

 

  모든 IP를 허용하려면 0.0.0.0/0을 입력하면 되지만 당연히 보안 상 좋지 않으므로 특정 IP만 허용하는 것이 좋습니다. IP 뒤의 '/0', '/32'는 서브넷 마스크로 '/24'는 0부터 255까지, '/32'는 한 개의 IP만을 의미합니다. 예를 들어 '192.168.0.0/24'라고 입력한다면 192.168.0.0부터 192.168.0.255까지의 범위를 의미하고, '192.168.0.1/32'라고 입력한다면 192.168.0.1 하나를 의미합니다.

 

  host 뒤의 첫 번째 all 부분은 허용할 Database, 두 번째 all 부분은 허용할 사용자를 의미합니다. 두 부분을 모두 all로 설정하면 모든 데이터베이스에 모든 사용자가 접근을 할 수 있습니다.

 

  scram-sha-256 부분은 인증 방법을 설정할 수 있습니다. trust, password, md5 등으로 설정을 할 수도 있습니다. trust는 암호나 인증 없이 접속이 가능하고, password는 암호화되지 않은 암호로 접속이 가능합니다. md5는 md5로 암호화된 암호를 요구하지만 오래된 암호화 방식으로 보안상 취약점이 있으므로 scram-sha-256으로 설정하는 것이 좋습니다.


4. postgres 계정 비밀번호 설정하기

sudo -u postgres psql
ALTER USER postgres WITH PASSWORD '변경할 password';

# exit or \q 로 psql을 종료한 후
sudo systemctl restart postgresql

 

  위와 같이 psql에 접속한 후 비밀번호를 변경합니다. 만약 기본 비밀번호를 입력하라고 나와서 변경할 수가 없다면 pg_hba.conf에서 잠깐 trust로 변경하여 모든 접속을 허용을 한 후에 비밀번호를 변경하고 다시 scram-sha-256으로 변경합니다.


5. 데이터베이스, 테이블, 사용자 생성 후 권한 부여

1) 데이터베이스 생성

sudo -i -u postgres
psql

CREATE DATABASE example1;

\l # 데이터베이스 목록 확인
\q # psql 종료

 

  위와 같이 psql에 접속을 한 후 데이터베이스를 생성해 줍니다. 데이터베이스의 목록을 보려면 '\l'을 입력하면 됩니다.

 

2) 테이블 생성

\c example1 # example1 데이터베이스 접속

CREATE TABLE example1_table (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100)
);

\dt # 테이블 목록 확인

 

  psql에 접속 후 '\c 데이터베이스명'을 입력하면 데이터베이스에 접속할 수 있습니다. CREATE TABLE 명령어를 이용하여 테이블을 생성한 후 '\dt'를 입력하여 제대로 생성되었는지 확인할 수 있습니다.

 

3) 사용자 생성 및 권한 부여

CREATE USER username1 WITH PASSWORD 'password';
GRANT CONNECT ON DATABASE example1 TO username1;

\du # 유저 목록 확인

 

  위와 같이 유저를 생성하고 특정 데이터베이스에 접근할 수 있는 권한을 부여할 수 있습니다. 유저 생성 후 '\du'를 입력하여 제대로 생성이 되었는지 확인할 수 있습니다.

\c example1 # example1 데이터베이스 접속

# 앞으로 생성될 테이블에도 권한 부여
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO username1;

# 이미 존재하는 모든 테이블에만 권한 부여
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO username1;

# 특정 테이블에만 권한 부여
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.example1_table TO username1;

# 부여된 권한 조회
SELECT * FROM information_schema.role_table_grants WHERE grantee = 'username1';

 

  데이터베이스에 접속한 후 위와 같이 입력하면 특정 유저에게 테이블과 관련된 권한을 부여할 수 있습니다. GRANT 명령어만 쓰면 이미 존재하는 테이블에만 권한이 부여되므로 앞으로 생성될 테이블에도 권한이 부여가 되도록 하려면 ALTER DEFAULT PRIVILEGES 명령어를 사용하면 됩니다.


6. 기타 유용한 보안 설정 (로그 활성화, 환경 설정 파일 권한 변경, public 사용 제한)

1) 로그 활성화하기

sudo vim /etc/postgresql/16/main/postgresql.conf

# all(모든 로그), ddl(CREATE, ALTER, DROP 등), mod(ddl + INSERT, UPDATE, DELETE 등)
log_statement = 'all'

# 100 milliseconds 이상 걸리는 모든 로그 저장, 0으로 설정하면 모든 로그 저장
log_min_duration_statement = 100 

logging_collector = on
log_directory = 'log'
log_filename = 'postgresql-%Y-%m-%d_%H%M%S.log'

sudo systemctl restart postgresql

 

  로그를 활성화하여 침입 유무를 확인하거나 침입 시도 의심 사례를 분석할 수 있습니다. PostgreSQL 설정 파일을 에디터로 열고 위와 같이 수정해 주세요. log_statement를 'ddl'로 설정하면 CREATE, ALTER , DROP과 같은 DDL(Data Definition Language) 쿼리만 로그에 기록되고, 'mod'로 설정하면 DDL 쿼리와 함께 INSERT, UPDATE, DELETE 같은 DML(Data Manipulation Language)도 함께 저장이 됩니다. 'all'로 설정하면 SELECT, REVOKE와 같은 모든 로그를 저장합니다. 

sudo -i -u postgres
cd <PostgreSQL Version>/main/log

 

  postgres 계정으로 접속해서 로그 폴더에 들어간 후 ls 명령어로 파일 목록을 확인해 보면 로그 파일이 생성되는 것을 볼 수 있습니다. cat 명령어 등을 사용하여 로그 내용을 확인할 수 있습니다.

 

2) 환경 설정 파일 권한 변경

cd /etc/postgresql/<Version>/main

ls -l # 권한 확인

sudo chmod 600 pg_hba.conf
sudo chmod 600 pg_ident.conf
sudo chmod 600 postgresql.conf
sudo chmod 600 pg_ctl.conf

 

  환경 설정 파일이 허가되지 않은 사용자에 의해서 변경되지 않도록 설정 파일의 권한을 600으로 변경합니다.

 

3) public 사용 제한

 

  PostgreSQL에서 DB를 생성할 경우 Default로 public 스키마가 생성됩니다. 다른 스키마를 생성하지 않고 테이블을 생성할 경우 기본적으로 public 스키마 안에 생성되고, public 스키마는 모든 개체에서 접근이 가능하므로 정보 유출 등의 가능성이 있습니다.

\c example1 # example1 데이터베이스 접속

# public 스키마에서 모든 사용자의 권한 제거
REVOKE ALL ON SCHEMA public FROM public;

# public schemad에서 특정 사용자에게 스키마 사용 권한 부여
GRANT USAGE ON SCHEMA public TO specific_user;

 

  위와 같은 절차는 DB별로 해주어야 하며, 추후에 DB가 생성되면 생성된 DB에도 해주어야 합니다. 이 설정들은 한국인터넷진흥원(KISA)의 클라우드 취약점 점검 가이드의 PostgreSQL 항목 중 일부를 발췌했습니다. 이외에도 유용한 설정들이 많으니 클라우드 취약점 점검 가이드를 참고해 주세요.

 

  미니 PC를 웹서버 용으로 쓰고 있어서 전원을 끄지 않고 24시간 동안 계속 가동하고 있습니다. 가끔 SSH로 접속해서 온도를 확인하는데 CPU는 평상시 40도 정도로 유지가 되고 있었지만 NVMe SSD의 온도는 항상 70도 이상으로 나왔습니다. NVMe SSD가 기본으로 장착되어서 나오는 모델들은 방열판이 달려있는 경우가 많은데, 제가 구입한 FIREBAT AM02 모델은 M.2 SATA SSD가 기본으로 장착되어 있어서 방열판이 따로 없었습니다. 전에 NVMe SSD로 교체하고 기본으로 달려있었던 Wicgtyp M.2 SATA SSD를 FIREBAT SSD 인클로저에 장착했었는데, 그때 장착하지 않은 방열판이 남아있어서 달아보기로 했습니다.

 

  FIREBAT SSD 인클로저의 기본 구성품에 있던 서멀 패드와 알루미늄 바디입니다. 방열판은 Aliexpress에서 1~2달러 정도면 구입할 수 있는 저가형 방열판입니다. 솔직히 효과는 크지 않을 것이라고 생각하지만 그래도 안 달아주는 것보다는 나을 것 같습니다.

 

  방열판을 장착하기 전 사진입니다. 기존에는 Wicgtyp M.2 SATA SSD가 달려있었지만 제거하고 WD BLACK SN770 NVMe SSD 500GB 모델을 장착했습니다.

 

  먼저 서멀 패드를 붙여주었습니다.

 

  그다음 방열판을 올려주었습니다. 어차피 닫아놓고 쓸 것이긴 하지만 서멀 패드 크기가 더 커서 삐져나오는 부분은 아쉽네요.

 

  방열판을 설치하고 난 후 약 1.5cm 정도 여유 공간이 있었습니다. 공간이 넉넉해서 더 큰 방열판을 장착할 수 있을 것 같습니다.

 

  방열판을 장착하고 나서 어느 정도 시간이 지난 후에 온도를 확인해 봤습니다. 72.8도에서 65.8도로 7도 정도 낮아졌네요. 예상한 대로 저가형 방열판이라 극적인 효과는 없었지만 65도 정도면 나쁘지 않은 것 같습니다. 당분간은 이 상태로 사용해 보고 나중에 필요하면 더 좋은 방열판으로 교체해야겠습니다.

1. Nginx 버전 정보 감추기

  미니 PC를 웹서버로 쓰고 있는데 웹페이지를 로드할 때 개발자모드로 Network 탭을 보면 서버의 IP주소와 어떤 툴을 이용해서 서비스를 하고 있는지 다 나와 있어서 보안 쪽으로 걱정이 되었습니다. 검색을 해보니 IP만 가지고는 해킹을 하기 어렵다고 해서 안심이 되었지만, Nginx의 어느 버전을 쓰고 있는지 노출이 되면 그 버전의 취약점을 이용해서 서버를 공격할 수 있다고 해서 최소한 버전만이라도 숨겨야겠다고 생각했습니다.

 

  아무런 작업도 해주지 않는다면 위와 같이 Remote Address에서 서버의 IP주소와 Response Headers의 Server에서 Nginx 버전이 그대로 노출되고 있는 것을 볼 수 있습니다. 

# server_tokens를 on으로 변경 후 저장
sudo vim /etc/nginx/nginx.conf

# Nginx 재시작
sudo nginx -t
sudo systemctl restart nginx

 

  에디터로 nginx.conf 파일을 열어주고 server_tokens가 off로 되어 있는 것을 on으로 바꿔주고 저장을 합니다.

 

  수정을 마쳤으면 sudo nginx -t로 nginx 설정 파일이 제대로 작동하는지 확인하고 재시작을 합니다.

 

  재시작을 하고 다시 웹사이트에 들어가서 개발자 모드로 확인을 해보면 Nginx의 버전이 사라진 것을 확인할 수 있습니다. Nginx를 사용하는 것까지 숨기고 다른 임의의 서버명을 표시하고 싶다면 Nginx의 Headers-More라는 모듈을 사용해야 하지만 모듈을 설치하고 다시 컴파일해줘야 하는 번거로움이 있습니다. 그리고 여전히 서버의 IP 주소가 노출되고 있어서 프록시를 적용하는 방법을 찾아봤는데 Cloudflare를 사용하면 쉽게 IP 주소와 서버의 정보가 숨겨진다고 해서 적용을 해보았습니다.


2. Cloudflare 적용하기

  Cloudflare에 가입한 후에 기존 도메인을 추가해 줍니다.

  

  도메인을 입력하고 DNS 레코드 빠른 스캔을 선택해 주세요.

 

  요금제는 Free를 선택해 주세요.

 

  도메인을 구입한 곳의 네임 서버 페이지에 들어가서 네임 서버를 수정해 줍니다. 

 

  저는 가비아에서 도메인을 구매했는데 기존 네임 서버 3개가 적혀 있는 것을 지운 후에 Cloudflare의 네임 서버를 입력했습니다.

 

  네임 서버를 수정해 주면 Cloudflare에 도메인 등록이 완료가 됩니다. Dashboard의 DNS 메뉴를 눌러보면 DNS가 자동으로 추가되고 프록시가 적용된 것을 확인할 수 있습니다. 참고로 무료 요금제를 선택했을 때 프록시 설정이 적용되어 있으면 사이트 속도가 다소 느려집니다. 무료 요금제는 망 사용료 때문에 한국의 CDN을 이용할 수 없어서 그렇다고 합니다.

 

  Dashboard에서 SSL/TLS 메뉴를 누른 후에 구성을 클릭해 주세요.

 

  기본값은 가변으로 되어있을 것입니다. '가변'은 클라이언트와 Cloudflare 사이에서는 HTTPS, Cloudflare와 Server 사이에서는 HTTP로 작동합니다. 서버가 HTTPS 인증서를 발급받지 않은 경우 선택합니다. '전체'는 클라이언트와 Cloudflare, Server 모두 HTTPS를 적용하고 서버의 인증서가 만료되거나 자체 인증된 인증서라도 허용합니다. '전체(엄격)'은 서버의 인증서가 유효해야 합니다. 저는 certbot을 이용해서 Let's Encrypt의 인증서를 발급받았기 때문에 '전체(엄격)'을 선택했습니다.

 

  다른 설정은 따로 안 건드려주고, 보안의 봇 메뉴를 클릭해서 봇 차단모드 정도만 켜주었습니다.

 

  모든 설정이 완료되었더라도 다시 웹페이지로 들어가서 개발자 모드로 확인을 해보면 여전히 서버의 정보가 그대로 표시되고 있을 것입니다. 그 이유는 DNS 설정이 바로 적용되는 것이 아니라 시간이 걸리기 때문입니다. 최대 24시간 정도 걸린다고 하는데 저의 경우는 1시간쯤 후에 적용됐습니다.

 

  Cloudflare 설정이 적용된 후에 개발자 모드의 Network 탭을 확인해 보면 서버의 IP가 노출되었던 Remote Address에는 Cloudflare의 프록시 IP 주소가 적혀있고, Server에는 nginx가 아닌 cloudflare가 적혀있는 것을 볼 수 있습니다.

 

  Cloudflare 연결 전 웹사이트의 인증서를 확인해 보면 certbot으로 발급받은 Let's Encrypt 인증서가 표시되고 있었습니다.

 

  Cloudflare 연결 후에는 발급 기관이 Google Trust Services로 바뀐 것을 볼 수 있습니다.

ping <도메인 이름>

 

  마지막으로 터미널에서 ping 도메인 이름을 입력하면 전에는 서버의 IP 주소가 그대로 노출이 됐었습니다. Cloudflare 적용 후에는 서버의 IP 주소가 아닌 Cloudflare의 프록시 주소가 표시가 됩니다.

 

  터미널에서 표시된 주소를 직접 브라우저에 입력해 보면 'Direct IP access not allowed'라는 Cloudflare 페이지가 표시됩니다.

 

  그나저나 서버를 열어둔 지 하루 밖에 안 됐는데 벌써 요청 수가 1,000번 가까이 되네요. 국적도 다양하고 벌써 차단된 공격도 보입니다. 앞으로 보안 공부를 더 철저히 해야겠습니다.

+ Recent posts