본문 바로가기
Develop/React

붕어빵 지도 프론트 + 백엔드 (1)

by ys2ys2 2024. 12. 14.

환경설정이 다 됐으니 기본적인 프론트 작업 시작!

기본적인 프론트 + 백엔드를 다 해놓고 웹으로 배포하면서 핸드폰으로 돌아다니면서 데이터를 저장하려고 한다.

 

그렇다면 오늘 목표는

 

1. 기본 프론트 잡기

2. 가게 정보 백엔드 로직 짜기

3. 저장된 DB 가져오는 로직 짜기

 

로 정해놓고 시작!

 

 


 

 

1️⃣ 프론트 (React + Vite)

 

간단한 헤더를 추가하고 그 밑에 지도가 배치되게 했다.

지도가 메인인 상태에서 내 위치 찾기 버튼, 데이터 저장 부분으로 나눴다.

일단 화면 좌측 아래에 내 위치 버튼을 뒀다.

이번에도 지도는 카카오 지도 사용!

프로젝트에 .env라는 환경 변수 파일을 만들어서 거기서 kakaomap key를 추가했다.

 

🎯여기서 알게 된 포인트!

CRA로만 리액트 프로젝트를 설정할땐 REACT_APP_ 이런식으로 설정하지만

Vite로 설정할 땐 앞에 VITE를 붙여줘야 한다!

REACT_APP_KAKAOMAP_KEY= "KEY"		//React
VITE_REACT_APP_KAKAOMAP_KEY= "KEY"	//Vite
 useEffect(() => {
    const apiKey = import.meta.env.VITE_REACT_APP_KAKAOMAP_KEY;

    loadKakaoMapScript(
      apiKey,
      () => {
        if (window.kakao && window.kakao.maps) {
          getUserLocation(
            (position) => {
              const { latitude, longitude } = position.coords;

              const map = initializeMap("map", latitude, longitude);
              mapRef.current = map;

              const markerOverlay = createCustomOverlay(map, latitude, longitude);
              markerRef.current = markerOverlay;

              map.setCenter(new window.kakao.maps.LatLng(latitude, longitude));
              
              startTracking();
            },
            (error) => {
              console.error("위치 가져오기 실패:", error.message);
            }
          );
        }
      },
      () => {
        console.error("Kakao Maps API 스크립트 로드 중 오류 발생");
      }
    );
  }, []);

 

초기 화면

 

 


 

 

그 다음으로는 geolocation을 이용해서 사용자한테 위치 정보를 제공받고, 현재 위치를 커스텀 마커로 표시하게 했다.

그리고 만들어둔 내 위치 버튼을 통해 내 위치 버튼을 누르면 현재 위치로 지도 중심을 이동하게 했다.

커스텀 마커에는 내 위치를 시각적으로 쉽게 알 수 있게 파란색 원을 계속 나타나게 CSS 처리했다.

(3차 프로젝트때 누가 잘 만들어둔게 있어서 그대로 쓰기만 하면 돼서 편했어요!)

export const getUserLocation = (onSuccess, onError) => {
  if (navigator.geolocation) {
    navigator.geolocation.getCurrentPosition(onSuccess, onError);
  } else {
    onError({
      code: "NOT_SUPPORTED",
      message: "이 브라우저에서는 위치 정보를 지원하지 않습니다.",
    });
  }
};

export const createCustomOverlay = (map, latitude, longitude) => {
  const { kakao } = window;

  const markerContent = document.createElement("div");
  markerContent.className = "custom-marker";

  markerContent.innerHTML = `
    <div class="radar-effect"></div>
    <img 
      src="https://ssl.pstatic.net/static/maps/m/pin_rd.png" 
      class="location-icon" 
      alt="현재 위치">
  `;

  const markerOverlay = new kakao.maps.CustomOverlay({
    position: new kakao.maps.LatLng(latitude, longitude),
    content: markerContent,
    map: map,
    zIndex: 1,
  });

  return markerOverlay;
};
radar-effect {
    position: absolute;
    width: 50px;
    height: 50px;
    border-radius: 50%;
    background: rgba(0, 150, 255, 0.3);
    animation: radar 1.5s infinite linear;
  }
  
  @keyframes radar {
    0% {
      transform: scale(1);
      opacity: 0.7;
    }
    100% {
      transform: scale(2.5);
      opacity: 0;
    }
  }

 

  const handleMyLocationClick = () => {
    const map = mapRef.current;

    if (!map) {
      alert("지도 객체를 찾을 수 없습니다.");
      return;
    }

    const { lat, lng } = currentPosition;

    if (lat && lng) {
      const newCenter = new window.kakao.maps.LatLng(lat, lng);
      map.setCenter(newCenter);

      if (markerRef.current) {
        markerRef.current.setPosition(newCenter);
      } else {
        const marker = new window.kakao.maps.Marker({
          position: newCenter,
          map: map,
        });
        markerRef.current = marker;
      }
    } else {
      alert("현재 위치 정보를 가져올 수 없습니다.");
    }
  };

 

 

 

 

 


 

 

다음으로는 내 위치가 이동될 때 마다 새롭게 마커 위치를 업데이트 되게 구현했다.

  const startTracking = () => {
    if (navigator.geolocation) {
      navigator.geolocation.watchPosition(
        (position) => {
          const { latitude, longitude } = position.coords;

          setCurrentPosition({ lat: latitude, lng: longitude });

          if (markerRef.current) {
            markerRef.current.setPosition(
              new window.kakao.maps.LatLng(latitude, longitude)
            );
          }
        },
        (error) => {
          console.error("위치 추적 실패:", error.message);
        },
        { enableHighAccuracy: true, maximumAge: 0 }
      );
    } else {
      console.error("이 브라우저는 Geolocation API를 지원하지 않습니다.");
    }
  };

 

 


 

 

이렇게 기본적인 틀을 잡고 이제 데이터를 저장할 입력 폼 부분을 만들었다.

 

가게 이름, x/y, 메뉴, 기타 정보들, 이미지 첨부(가게 사진) 이렇게 기본적인 정보들을 저장할 수 있게 했다.

좌표는 알 수 없기 때문에 현재 좌표가 실시간으로 나오게 구성했다.

최종적으로 저장하기 누르면 클라이언트에서 axios 요청을 통해 서버로 전달!

이미지는 BLOB 으로 데베에 저장되게 했다.

 

🎯포인트!

React에서 사용하고 있는 file 타입의 input은 기본 CSS 스타일이 적용되어 있는데 이걸 className을 주고 따로 CSS해도 적용이 되질 않는다. 그래서 따로 span으로 custom-file-button라는 className을 줘서 해당 span을 클릭하면 숨겨둔 파일 첨부 버튼이 클릭되게 했다.

 

원래는 해당 span을 클릭하면 onClick={handleButtonClick}이 실행돼서 숨겨진 첨부 버튼이 클릭되게 했었는데

그럴때마다 파일 첨부 창이 2번씩 뜨는 문제가 발생됐다.

 

그 이유는 label에 for 속성이 없는 경우, label을 클릭하면 기본적으로 포함된 input 요소를 트리거 하기 때문이였다.

그래서 그냥 span으로 커스텀 파일 첨부를 두고 기본 input type = file은 display:none으로 숨겨두고 같은 label로 묶었다.

 

최종적으로 저장하기 버튼을 누르면 입력된 데이터들은 axios를 통해 서버로 넘겨지게 구현했다.

  <form className="inputDB" onSubmit={handleFormSubmit}>
    <label>
      가게 이름 :
      <input
        type="text"
        name="storeName"
        value={formData.storeName}
        onChange={handleChange}
        placeholder="붕어빵 가게 이름"
        required
      />
    </label>
    <label>
      X좌표 (위도) :
      <input
        type="text"
        name="storeX"
        value={formData.storeX}
        onChange={handleChange}
        placeholder="예: 37.5665"
        required
      />
    </label>
    <label>
      Y좌표 (경도) :
      <input
        type="text"
        name="storeY"
        value={formData.storeY}
        onChange={handleChange}
        placeholder="예: 126.9780"
        required
      />
    </label>
    <label>
      메뉴 :
      <input
        name="storeMenu"
        value={formData.storeMenu}
        onChange={handleChange}
        placeholder="예: 팥 붕어빵, 슈크림 붕어빵"
      />
    </label>
    <label>
      기타 정보 :
      <input
        name="storeInfo"
        value={formData.storeInfo}
        onChange={handleChange}
        placeholder="예: 영업시간, 기타 정보"
      />
    </label>
    <label>
      이미지 첨부 :

      <span
        className="custom-file-button"
        // onClick={handleButtonClick}
      >
        파일 선택
      </span>

      <input
        type="file"
        ref={fileInputRef}
        className="hidden-file-input"
        onChange={(e) => {
          handleFileChange(e);
          setSelectedFileName(e.target.files[0]?.name || "선택된 파일 없음");
        }}
      />
    </label>
    <span className="file-name">{selectedFileName}</span>
    <div className="current-coordinates">
      현재 좌표 : {" "}
      {currentPosition.lat && currentPosition.lng
        ? `${currentPosition.lat.toFixed(6)}, ${currentPosition.lng.toFixed(6)}`
        : "위치 정보를 가져오는 중..."}
    </div>
    <button type="submit">저장하기</button>
  </form>

 

axios를 통한 데이터들 전송, 저장하기 버튼 후 입력 필드 초기화

const handleFormSubmit = async (e) => {
    e.preventDefault(); // 기본 폼 동작 방지

    try {
      const requestData = new FormData();
      requestData.append("storeName", formData.storeName);
      requestData.append("storeX", formData.storeX);
      requestData.append("storeY", formData.storeY);
      requestData.append("storeMenu", formData.storeMenu);
      requestData.append("storeImage", formData.storeImage);
      requestData.append("storeInfo", formData.storeInfo);

      const response = await apiClient.post("/fishstore", requestData, {
        headers: { "Content-Type": "multipart/form-data" },
      });

      alert("가게 정보가 저장되었습니다!");
      console.log("저장된 데이터:", response.data);

      setFormData({
        storeName: "",
        storeX: "",
        storeY: "",
        storeMenu: "",
        storeImage: null,
        storeInfo: "",
      });

      if (fileInputRef.current) {
        fileInputRef.current.value = "";
      }

    } catch (error) {
      console.error("데이터 저장 실패:", error);
      alert("서버로 데이터 전송에 실패했습니다. 다시 시도해주세요.");
    }
  };

 

 

 


 

2️⃣ 백엔드 (Spring boot + JPA)

 

 

 

 

✅ DB에 저장하기

이제 받은 값들을 서버에서 처리해서 데이터베이스에 저장되게 해야 한다.

백엔드 설정은 sts3에서 Spring boot 기반으로 구현했으며, DB와의 상호작용은 JPA를 통해 처리했다.

(어떤게 맞는 방법인지 몰라서 배운대로 1차 프로젝트 때 리액트를 spring boot로 백엔드 구현한것과, 3차 프로젝트 때 JPA를 접해봐서 이렇게 구성했다.)


✔️Controller

Axios 요청을 통해 들어온 값들을 controller에서 처리하게 했다.

axios를 통해 formdata로 데이터를 전송한 것들을 RequestParam을 통해 받았다.

메뉴랑 정보 사진은 없는 경우가 있을수도 있으니 required = false를 통해 파라미터가 없어도 될 경우도 설정했다.

MultipartFile을 통해 이미지 파일에 대한 처리를 했다. 이미지 파일은 BLOB 형태로 DB에 저장되게 했다.

이렇게 처리한 데이터들을 service에 전달!

@RestController
@RequestMapping("/fishstore")
public class FishstoreController {

    @Autowired
    private FishstoreService service;

    @PostMapping
    public ResponseEntity<FishstoreEntity> saveFishstore(
            @RequestParam("storeName") String storeName,
            @RequestParam("storeX") Double storeX,
            @RequestParam("storeY") Double storeY,
            @RequestParam(value = "storeMenu", required = false) String storeMenu,
            @RequestParam(value = "storeInfo", required = false) String storeInfo,
            @RequestParam(value = "storeImage", required = false) MultipartFile storeImage) {

        FishstoreEntity savedFishstore = service.saveFishstore(storeName, storeX, storeY, storeMenu, storeInfo, storeImage);
        return ResponseEntity.ok(savedFishstore);
    }
}

 

 

 


 

 

✔️Service(interface)

어떤 메서드를 사용할지 정의하기

public interface FishstoreService {
    FishstoreEntity saveFishstore(String storeName, Double storeX, Double storeY, String storeMenu, String storeInfo, MultipartFile storeImage);
}

 

 

✔️ServiceImpl(구현체)

파일을 byte 배열로 바꾸고 FishstoreEntity를 생성해서 해당 엔티티의 필드에 저장하게 구현했다.

그러고 repository.save(fishstore)를 통해 fishstore의 객체를 분석해서 매핑된 테이블에 SQL INSERT 쿼리를 생성한다.

저장된 엔티티의 필드에 대한 값들이 저장!

@Service
public class FishstoreServiceImpl implements FishstoreService {

    @Autowired
    private FishstoreRepository repository;

    @Override
    public FishstoreEntity saveFishstore(String storeName, Double storeX, Double storeY, String storeMenu, String storeInfo, MultipartFile storeImage) {
        byte[] imageBytes = null;

        try {
            // MultipartFile을 byte[]로 변환
            if (storeImage != null && !storeImage.isEmpty()) {
                imageBytes = storeImage.getBytes();
            }
        } catch (Exception e) {
            throw new RuntimeException("이미지 변환 중 오류 발생: " + e.getMessage());
        }

        FishstoreEntity fishstore = new FishstoreEntity();
        fishstore.setStoreName(storeName);
        fishstore.setStoreX(storeX);
        fishstore.setStoreY(storeY);
        fishstore.setStoreMenu(storeMenu);
        fishstore.setStoreInfo(storeInfo);
        fishstore.setStoreImage(imageBytes);

        return repository.save(fishstore);
    }
}

 

 


 

 

✔️Entity

@Entity
@Data
public class FishstoreEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY) 
    private Long id;

    @Column(nullable = false)
    private String storeName;

    @Column(nullable = false)
    private Double storeX;

    @Column(nullable = false)
    private Double storeY;

    @Column(length = 500)
    private String storeMenu;

    @Column(length = 1000)
    private String storeInfo;
    
    @Lob // BLOB으로 설정
    private byte[] storeImage;
}

 

 


 

 

✔️Repository

@Repository
public interface FishstoreRepository extends JpaRepository<FishstoreEntity, Long> {
}

 

 

 

 

 


 

 

최종 프론트 + 백엔드 상태

(웹으로 서비스 할때는 실제 내 위치가 다르게 한참 떨어진 곳으로 잡힌다. 폰으로 확인할때가 더 정확하다.)

 

저장된 값

 

이미지도 BLOB으로 잘 들어오는거 까지 확인!

 

이제 이 저장된 값들을 화면에 출력하게 하면 될 것 같다.

 


 

 

✅ DB값 출력하기

이제 저장된 값들을 JPA 레포지토리의 findAll()을 통해 가져오면 된다.

 

✔️Controller

GetMapping을 통해 serviceImpl의 service.getAllFishstores()을 호출!

@GetMapping
public ResponseEntity<List<FishstoreEntity>> getAllFishstores() {
    List<FishstoreEntity> fishstores = service.getAllFishstores();
    return ResponseEntity.ok(fishstores);
}

 

 


 

 

✔️Service(interface)

 

public interface FishstoreService {

    List<FishstoreEntity> getAllFishstores(); 

}

 

✔️Service(구현체)

BLOB라는 바이너리 데이터를 텍스트 문자열로 인코딩 하기 위해 Base64를 사용했다.

클라이언트랑 서버간 데이터 전달을 JSON으로 하고 있기 때문에 byte[]는 사용할 수 없다. 이걸 문자열인 base64로 바꿔야 한다.

@Service
public class FishstoreServiceImpl implements FishstoreService {

    @Autowired
    private FishstoreRepository repository;
    
    @Override
    public List<FishstoreEntity> getAllFishstores() {
        List<FishstoreEntity> fishstores = repository.findAll();

        for (FishstoreEntity store : fishstores) {
            if (store.getStoreImage() != null) {
                // byte[] 데이터를 Base64 문자열로 변환
                String base64Image = Base64.getEncoder().encodeToString(store.getStoreImage());
                store.setStoreImageBase64("data:image/jpeg;base64," + base64Image);
            }
        }

        return fishstores;
    }
    
}

 

 


 

 

✔️Entity

엔티티에 이미지 처리할 부분을 추가했다.

storeImage가 BLOB로 되어있어서 JSON으로 직렬화 하면 문제가 생길 수 있기 때문에, 이를 방지하고 클라이언트로 응답 시 Base64 문자열(storeImageBase64)만 보내기 위해 JsonIgnore 어노테이션을 사용했다.

 

또 storeImageBase64라는 Base64로 변환된  문자열을 저장하는 임시 필드를 추가했다. 그리고 데이터베이스에 저장되지 않게

Transient 어노테이션을 사용했다.

@Entity
@Data
@Table(name = "fishstore")
public class FishstoreEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String storeName;

    @Column(nullable = false)
    private Double storeX;

    @Column(nullable = false)
    private Double storeY;

    @Column(length = 500)
    private String storeMenu;

    @Column(length = 1000)
    private String storeInfo;
    
    //이미지 처리 추가
    @Lob
    @JsonIgnore
    private byte[] storeImage;

    @Transient
    private String storeImageBase64;
}

 

 


 

 

✔️Repository

레포지토리는 그대로!

@Repository
public interface FishstoreRepository extends JpaRepository<FishstoreEntity, Long> {
}

 

 


 

그 후 클라이언트에서 처음 지도 설정할 때 get요청을 /fishstore에 보내서 가게 데이터들을 가져왔다.

배열로 가져온 데이터에 저장된 x,y값을 이용해서 붕어빵 마커를 지도에 생성!

붕어빵 마커 클릭 시 카카오 지도 API에서 제공하는 kakao.maps.event.addListener를 이용해서 저장된 값을 출력했다.

  useEffect(() => {
    const apiKey = import.meta.env.VITE_REACT_APP_KAKAOMAP_KEY;
  
    loadKakaoMapScript(
      apiKey,
      () => {
        if (window.kakao && window.kakao.maps) {
          getUserLocation(
            (position) => {
              const { latitude, longitude } = position.coords;
  
              const map = initializeMap("map", latitude, longitude);
              mapRef.current = map;
  
              const markerOverlay = createCustomOverlay(map, latitude, longitude);
              markerRef.current = markerOverlay;
  
              map.setCenter(new window.kakao.maps.LatLng(latitude, longitude));
  
              startTracking();
  
              // 서버에서 가게 데이터 가져오기
              apiClient
                .get("/fishstore")
                .then((response) => {
                  const fishstores = response.data;
                  console.log("가게 데이터:", fishstores);
  
                  addStoreMarkers(fishstores, map);
                })
                .catch((error) => {
                  console.error("가게 데이터를 가져오는 중 오류 발생:", error);
                });
            },
            (error) => {
              console.error("위치 가져오기 실패:", error.message);
            }
          );
        }
      },
      () => {
        console.error("Kakao Maps API 스크립트 로드 중 오류 발생");
      }
    );
  }, []);
  const addStoreMarkers = (fishstores, map) => {
  
    markersRef.current.forEach(marker => marker.setMap(null));
    markersRef.current = [];
  
    fishstores.forEach((store, index) => {
      const markerPosition = new window.kakao.maps.LatLng(store.storeX, store.storeY);
  
      const imageSrc = "public/images/markerlogo.png";
      const imageSize = new window.kakao.maps.Size(40, 40);
      const markerImage = new window.kakao.maps.MarkerImage(imageSrc, imageSize);
  
      const marker = new window.kakao.maps.Marker({
        position: markerPosition,
        map: map,
        image: markerImage,
        zIndex: 30,
      });
  
      markersRef.current.push(marker);
  
      const overlayContent = document.createElement("div");
      overlayContent.className = "bbangstore";
      overlayContent.innerHTML = `
      <div class="storeinfo-container">
        <strong class="storeinfo-name">${store.storeName}</strong><br />
        <span class="storeinfo-menu">메뉴: ${store.storeMenu || "정보 없음"}</span><br />
        <span class="storeinfo-info">정보: ${store.storeInfo || "정보 없음"}</span><br />
        ${
          store.storeImageBase64
            ? `<img src="${store.storeImageBase64}" alt="${store.storeName}" style="width:100px;height:100px;" />`
            : ""
        }
      </div>
    `;

      const customOverlay = new window.kakao.maps.CustomOverlay({
        position: markerPosition,
        content: overlayContent, 
        yAnchor: 1.3,
        zIndex: 30,
      });

      window.kakao.maps.event.addListener(marker, "click", () => {
        customOverlay.setMap(map);
      });

      overlayContent.addEventListener("click", () => {
        customOverlay.setMap(null);
      });

    });
  };

 

붕어빵 로고 + 팝업창

 


 

 

일단 이렇게 저장하고 출력하고까지는 된 것 같다!

이제 어떻게 저장된 값을 어떻게 분류할건지, 어떤 값들을 중점으로 저장되고 출력할건지 더 생각해 봐야 한다.

또 좌표 뿐만 아니라 도로명 주소와 길 찾기, 로드뷰 등 더 많은 기능들을 추가해야한다.

 

그리고 최종적으로는 웹으로 프론트 + 백엔드까지 가능하게 배포해서 실제로 돌아다니면서 저장이 가능하게 구현해야 한다!

 

 

에어팟 케이스랑 키링을 샀는데 너무 귀여워서 여기다가 투척하기,,

헣,,