[TIL] 나만무 6/30 ~ 7/6

6/30(월): Frontend 개발

오늘은 Tailwind CSS v4로 넘어가면서 겪은 트러블슈팅에 대해 간략히 적어봅니다. 목표는 git clone 받은 파일에 있는 tailwind.config.cjs 파일을 전역에서 사용할 수 있도록 조치하는 것입니다. 생각보다 간단할 줄 알았지만, 버전 호환 이슈로 많은 문제가 있었네요.

처음엔 기존 방식대로 tailwind.config.js 설정만 잘 해주면 되겠지 싶었는데, v4부터는 방식이 확 바뀌었습니다. 이제는 config에서 직접 스타일을 정의하는 게 아니라, CSS 파일에서 import해서 쓰는 방식으로 변경됐어요.

그래서 처음에 tailwind가 아예 안 먹히길래 한참을 헤맸죠. 알고 보니 v4에서는 아래처럼 설정해줘야 제대로 적용이 되었습니다. 참고로, 기존의 tailwindcss/postcss를 사용하는 방식이 아닌 tailwind/vite로 설정해주었습니다.

👇 참조 Documentation

https://tailwindcss.com/blog/tailwindcss-v4#css-first-configuration

✅ 내가 했던 조치들

먼저 기존 최신 버전 패키지들을 전부 지워줬어요:

npm uninstall tailwindcss@latest postcss@latest autoprefixer@latest

그다음, v4 맞게 필요한 패키지들 다시 설치:

npm install tailwindcss @tailwindcss/vite
npm install -D @tailwindcss/postcss

🛠️ 설정 변경

vite.config.js

기존과 달리 @tailwindcss/vite 플러그인을 직접 넣어줘야 해요.

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from "@tailwindcss/vite"

export default defineConfig({
  plugins: [
    react(),
    tailwindcss(),
  ],
});

postcss.config.cjs

PostCSS도 v4에 맞게 이렇게 바꿨어요:

module.exports = {
  plugins: [
    require('@tailwindcss/postcss')(),
    require('autoprefixer'),
  ],
}

index.css

이제는 CSS 파일 안에 직접 아래처럼 작성했어요:

참고로 @template으로 직접 css를 import 해오는 방식도 가능하지만, 이미 작성해둔 config 파일을 사용하는 방식을 택했습니다.

@config "../tailwind.config.cjs";
@import "tailwindcss";

7/1(화) : FE 무한 스크롤 / BE PATCH 처리

오늘은 프론트에서 무한 스크롤 기능을 구현해보다가 겪었던 실패와 백엔드에서 DTO 업데이트 관련해서 헷갈렸던 부분을 정리해봤다.

FE - 무한 스크롤

원래 API 설계 기반으로는 커서 기반 페이징 방식으로 무한 스크롤을 구현했다. 페이지네이션 방식이 아니라 스크롤 끝에 도달하면 nextCursor와 lastProductId를 기반으로 다음 데이터를 불러오는 방식이다.

  • 처음 요청: /products?limit=10 → 서버가 [1~10]을 주고, nextCursor = 10 같이 커서를 같이 넘겨줌
  • 다음 요청: /products?limit=10&lastProductId=10 → 이후 [11~20] 반환

이 방식은 "마지막으로 본 데이터"의 기준점을 클라이언트가 서버에 다시 넘겨주는 구조다.

useInfiniteQuery 도전기

React Query의 useInfiniteQuery를 써서 자동 로딩 기능을 구현하려고 했다. 결과론적으로는 타입 불일치와 넘기는 인자 개수가 맞지 않다는 에러를 잡지 못하고 실패하였다. 아무리 생각해도 나는 2개의 인자를 넘기고 있는데, 자꾸 프론트에서는 3개의 인자를 받았다고 하며 에러가 났다. Documentation을 따라해보았지만, 내가 이 방식의 동작 방식을 정확하게 이해하지 못하고 있는 것 같다.

 

참고 링크 :

https://oliveyoung.tech/2023-10-04/useInfiniteQuery-scroll/

https://arc.net/l/quote/aireewjn

 

결국 조금 더 잡고 있을지, 아니면 그냥 기존 수동 방식으로 갈지 고민했다. useInfiniteQuery는 React Native와 연동하기 위해서는 별도의 3rd party 라이브러리를 추가로 설치해야 하기 때문에 우선은 기존의 방식대로 진행하기로 결정 했다. 아쉽지만, 나중에 다시 도전해 봐야겠다.

👎 실패한 코드

// src/PracticeRoomPage.tsx

// -- mock data의 type 선언 --
export interface PracticeRoom {
  post_id: number;
  user_id: number;
  user_name: string;
  title: string;
  main_image_url: string;
  location: {
    location_id: number;
    region_level_1: string;
    region_level_2: string;
  };
  view_count: number;
  created_at: string;
}

const PracticeRoomPage: React.FC = () => {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
    isError,
  } = useInfiniteQuery<PracticeRoom[], Error, PracticeRoom[], string[], Cursor | null>({
    queryKey: ['practiceRooms'],
    queryFn: ({ pageParam }: QueryFunctionContext<string[], Cursor | null>) =>
      fetchPracticeRooms({ pageParam }),
    initialPageParam: null,
    getNextPageParam: (lastPage) => {
      const last = lastPage[lastPage.length - 1];
      return last
        ? { lastProductId: last.post_id, lastCreatedAt: last.created_at }
        : undefined;
    },
  });

  // ✅ 이제 page는 타입이 명확하므로 에러 없음
  const rooms = data ? data.flat() : [];

  return (
      ...
        {hasNextPage && (
          <div className="flex justify-center mt-8">
            <button
              onClick={() => fetchNextPage()}
              disabled={isFetchingNextPage}
              className="bg-brand-primary text-button text-color-brand-text-inverse px-8 py-3 rounded-[10px]"
            >
              {isFetchingNextPage ? "불러오는 중..." : "더보기"}
            </button>
          </div>
        )}
        ...
  )
}

BE : PartialType 으로 PATCH

NestJS에서는 DTO 업데이트 시 PartialType(CreateDto) 을 활용할 수 있다.

export class UpdateUsedProductDto extends PartialType(CreateUsedProductDto) {}

이렇게 하면 Create 에서 필수였던 필드들이 Update에서는 전부 선택적(optional)로 바뀐다. 즉 PATCH에서 필요한 필드만 보내도 처리 가능해진다.

❓데이터 무결성은?

여기서 드는 의문:

그럼 필드가 누락되면 DB에서 NOT NULL 속성에 위반되는거 아닌가?

정답은, 맞다! 실제로 create(dto) + save() 만 하게 되면 누락된 필드는 undefined로 들어가고, 그 필드가 DB에서 NOT NULL 이라면 QueryFailedError가 발생한다.

 

안전하게 처리하기 위해서는 아래 처럼 merge 속성을 활용하여 기존 값과 새로운 값을 합쳐주는 작업이 필요하다.

const existing = await this.repo.findOneBy({ id });
if (!existing) throw new NotFoundException();

const updated = this.repo.merge(existing, dto);
await this.repo.save(updated);

7/2(수) : Kakao Map API

5팀 발표 피드백

2주차 시연 발표에 대한 피드백을 받은 내용이다.

  • 채팅방 자동 완성 기능
    • 키워드 기반 자동 완성 기능 기획 중
    • 예측 입력 기능 및 사용자 선호도 기반 추천도 고려
  • S3 직접 업로드 시 데이터 보안 처리
    • 클라이언트에서 S3로 직접 업로드 시 보안 취약점 우려
    • 해결 방향
      • Lambda를 통한 중간 유효성 검증 고려 중
      • Presigned URL 발급 시 토큰 방식 및 업로드 기준(용량 제한, MIME 타입 확인 등) 적용 예정
      • 이상 데이터(예: 스크립트 삽입 등) 업로드 방지 로직 필요
  • 동영상 병합 과정에서의 실패 대응
    • 클라이언트에서 두 개의 영상 처리 중 하나라도 실패할 경우 동기화 문제 발생
    • 해결 방향
      • 각 처리 단계별 상태 값 설정: 영상 처리중, 오디오 처리중, 인코딩중, 전송 성공
      • 모든 상태가 true일 경우에만 서버로 최종 전송
      • 불완전한 상태에서의 premature 전송 방지 로직 강화 필요
  • 다운로드 UX 개선
    • 사용자에 따라 영상/이미지 자동 다운로드에 대한 거부감 존재
    • 대안 제안
      • 다운로드 버튼 제공 (사용자가 선택적으로 저장)
      • 디바이스/OS 환경에 따라 사용자 알림 및 권한 설정 옵션 제공
      • UX 관점에서 자동 저장보다는 사용자 제어권 우선시

Kakao Map REST API로 위치 검색 기능 구현

오늘은 사용자로부터 장소를 검색받고, 그 위치 정보를 기반으로 행정구역 정보를 파싱해서 전역 상태로 저장하는 기능을 구현했다. React + Zustand + Kakao Map REST API를 활용했고, 위치는 게시글 등록 시 필요한 데이터로 활용될 예정이다.

🧭 전체 흐름 요약

  • 사용자가 장소 키워드를 입력하고 "검색" 클릭
  • Kakao 키워드 검색 API를 호출 → 장소 목록 출력
  • 특정 장소 클릭 → Kakao 역지오코딩 API 호출
  • 결과를 파싱해서 전역 상태(useLocationStore)에 저장
  • 이후 저장된 위치 데이터를 백엔드에 보내 DB에 저장

어려웠던 점: 전역 상태 관리

장소 정보를 전역 상태로 관리하려 했던 이유는 다음과 같다.

  1. 선택한 위치 정보를 여러 컴포넌트에서 공유하기 위해
    • 사용자가 LocationSearch 에서 선택한 장소 정보는, 게시글 작성 폼, 예약 폼 등에서 공통으로 참조된다
    • 이걸 매번 props 로 넘기거나 다시 DB에서 조회하는 것은 비효율적이라 판단했다.
  2. DB 저장 전의 위치 정보를 임시로 유지해야 한다
    • 장소 선택은 폼 작성 중간 단계에서 일어난다.
    • → 사용자가 폼을 완전히 제출(submit)하기 전까지는 DB에 저장되지 않은 상태.
    • 그래서 일단 선택된 위치 정보를 클라이언트 전역 상태에 임시 저장해두고,
    • 사용자가 폼을 제출할 때 POST /api/locations로 먼저 DB에 위치 정보를 저장한다.
    • 서버는 locationId를 응답으로 주고, 최종 게시글은 이 외래키를 참조하여 나머지 폼 데이터를 해당 게시판에 저장한다.

여기서 생각하지 못했던 점은, 이 전역 상태를 언제 초기화할지 모호했다는 점이다. 새 글 작성하는 페이지와 같이기존의 상태가 남아 있으면 안되는 곳에서는, resetLocation()을 호출하여 페이지 진입 시 명시적으로 전역 상태를 초기화하도록 설정해주었다.


7/3(목) : Locations 저장방식 리팩토링

📍 위치 저장 방식 (Insert or Reuse)

  • 사용자가 위치 선택 시:→ 있으면 기존 locationId 반환
  • → 없으면 새로 insert 후 그 locationId 반환
  • → 위도/경도 기반으로 location 테이블에 이미 동일한 위치가 있는지 SELECT
// pseudo-code
const existing = await locationRepo.findOne({ where: { lat, lng } });
if (existing) return existing.locationId;

const newLocation = await locationRepo.save({ ... });
return newLocation.locationId;

🗑️ 게시글 삭제 시, location은 남긴다

  • 이유:→ 실수로 데이터 손실 방지
  • → 동일한 locationId가 다른 게시글에서 참조될 수 있음

🧹 주기적 정리 (= Scheduler가 필요한 이유)

  • 어떤 locationId도 더 이상 참조하지 않게 된 경우→ 그래서 스케줄러로 “고아 location 정리” 필요
  • → 불필요하게 location row만 계속 쌓임

스케줄링 구현 방식

방법 설명 장점 단점
🔢 방법 1 직접 SQL에서 모든 참조 테이블 join해서 unused rows 정리 성능 최적화 쿼리 복잡도↑, 테이블 추가되면 수정 필요
🔄 방법 2 각 서비스(UsedProductService, StoreService 등)에서 참조 중인 locationId들을 수집하고 비교 유지보수 용이 LocationService가 모든 서비스에 의존하게 됨 (= 느슨한 아키텍처가 무너짐)

 

결국 sql 쿼리문으로 직접 정리하면서 모든 테이블을 다 넣지 않으려고 이 방식을 택했는데, location service는 이를 참조하는 모든 테이블에 대한 의존성 주입을 해야하는거면, 이게 더 비효율적인거 아닌가?

 

🧠 이럴 때는 방법 2가 유리

  • 서비스 수가 적고 안정적으로 관리되고 있을 때
  • 도메인 간 결합도를 어느 정도 용인할 수 있을 때
  • SQL을 유지보수하는 게 팀에게 더 부담일 때

⚠️ 이럴 땐 방법 1이 낫다

  • 참조 테이블이 5개 이상으로 많아지고, 서비스간 의존성을 최소화해야 할 때
  • location 정리 작업을 백그라운드 배치로 수행하고 싶을 때
  • 새 테이블이 추가되어도 쿼리 하나만 수정하면 되도록 하고 싶을 때

7/4(금) : Map BE 연동 & 조회수 증가 전역 훅 추가

🗺️ Map API 연동 및 게시판 위치 미리보기 기능 추가

이번 작업에서는 Practice Room 게시판에 카카오맵을 연동하여 사용자가 더 직관적으로 위치를 확인할 수 있도록 개선했습니다.

🔍 주요 변경 사항

  • 카카오맵 주소 검색 기능 추가
  • 사용자는 Practice Room 등록 시 원하는 주소를 직접 검색할 수 있으며, 선택한 주소에 대한 상세 정보를 확인할 수 있습니다.
  • 선택한 주소에 대한 미리보기 기능
  • 사용자가 선택한 주소는 위도/경도로 변환되어, 카카오맵 상의 마커(marker)로 시각적으로 표시됩니다. 이를 통해 등록 전 위치 정확성을 높일 수 있습니다.

👀 조회수 증가 전역 훅(useViewCounter) 구현

게시판 상세 페이지 진입 시마다 서버에 조회수를 증가시키는 기능을 프론트엔드 전역 훅으로 구현했습니다.

✅ 동작 방식

  • 각 게시판 상세 페이지에 진입하면, useViewCounter() 훅이 자동으로 실행됩니다.
  • 훅 내부에서는 현재 페이지의 ID와 게시판 타입을 기준으로 1회에 한해 조회수를 증가시키는 POST 요청을 백엔드에 보냅니다.
  • 중복 요청 방지를 위해 localStorage에 조회 여부를 기록하여, 동일한 사용자가 반복 진입해도 중복 카운팅되지 않도록 처리했습니다.

📝 적용 대상

  • 중고장터
  • 합주실 예약
  • 합주 인원 모집

🎸 기타

저녁에 우리팀끼리 번개 편의점 간맥! 이 있었다. 사실 이번주 내내 컨디션이 좋지 않다.. 왜인지는 모르겠다. 그냥 좋지 않다. 그래서 맥주 한 캔도 다 못마셨는데 그~~냥 취해버렸다. 나에게 이런일은 참 흔치 않는데 말이다.. 이번주는 적절한 휴식이 필요하다.


7/5(토): 지역 드롭다운 구상

이번에는 Lazy Load 방식의 지역 선택 드롭다운 기능을 구현하려고 했고, 가능한 한 DB에 불필요한 지역 데이터를 저장하지 않으면서 유지보수가 쉬운 구조를 고민했다.

🧩 문제 정의

보통 시/도, 시/군/구, 읍/면/동 같은 행정구역 정보를 선택하는 UI를 구현할 때,

모든 지역 데이터를 한 번에 가져와서 클라이언트에서 처리하는 방식이 일반적이다.

하지만 이 방식에는 몇 가지 단점이 있다:

  • ❌ 수십 MB에 달하는 방대한 JSON을 처음부터 내려받아야 함
  • ❌ DB에 전체 행정구역을 미리 저장해야 함 (초기 세팅과 유지보수 부담 큼)
  • ❌ 지역구 재편 등 변경사항 대응 어려움

✅ 해결 방향: Lazy Load 기반 계층형 드롭다운

그래서 내가 택한 방식은 다음과 같다:

  • 클라이언트는 처음부터 모든 지역을 로드하지 않고
  • 사용자의 선택에 따라 필요한 데이터만 단계적으로 요청해서 가져옴
  • 백엔드는 정적 JSON 또는 캐시된 메모리 기반 응답으로 가볍게 처리 가능

✅ 구조 요약 – 단계별 API 호출 방식

Lazy Load 방식의 계층형 드롭다운. DB 저장 없이 지역 선택 기능을 구현.

  1. 처음 페이지 진입 시["서울특별시", "경기도", "부산광역시", ...]
  2. GET /locations/regions
  3. 시/도 선택 시→ ex: 서울특별시 선택 시 → ["강남구", "서초구", ...]
  4. GET /locations/regions/:regionName/subregions
  5. 군/구 선택 시→ ex: 서울특별시/강남구 선택 시 → ["삼성동", "논현동", ...]
  6. GET /locations/regions/:regionName/:subregionName/subregions
  7. 동 선택 완료 시→ ex: GET /ensembles?region=서울특별시&district=강남구&neighborhood=삼성동
  8. → 해당 지역에 해당하는 게시글 API 호출

🤔 고민했던 지점

  • 처음에는 모든 행정구역 정보를 DB에 넣고 관리할까 했는데,
  • → 지역 정보는 변경 가능성도 크고, 관리도 복잡하다.
  • 그래서 가능한 한 DB 의존도를 줄이고 정적 JSON 기반의 API로 구성하는 쪽으로 정리했다.
  • 정적 JSON 기반 API는 유지보수도 쉽고, Git 버전 관리도 가능하다.
  • 이제는 원하는 JSON을 찾아서 구현만 하면 된다!

🎸 기타

갑자기 2조와의 소소한 회식이 대규모 회식으로 바꼈다! 우리조 친구들은 참 인싸인것 같다.. 갑자기 14명의 회식으로 변해버린 이번주의 고기 파뤼~ 점심 안먹기를 잘했다! 나는 컨디션 난조로 인해 사실 고기만 먹고 1차에서 빠졌다. 다음날 들어보니 2차부터 우리반 전체 회식 대통합이 되어버린것 같았다ㅋㅋ