URLcut
2025-07-10 ~ 유지보수 중
Spring BootPostgreSQLJPARedisLuaVueDockerGitHub Actions
[개요]
- 긴 URL을 단축하고 QR 코드로 공유할 수 있는 사이드 프로젝트
- 실제 집중 개발은 수일간 진행했으며, 이후 시간을 내어 배포 및 기능 개선을 비정기적으로 진행
- 서비스명은 URLcut으로, 회원에게 커스텀 주소, 만료 시각, 최대 클릭 수 설정 및 클릭 통계 제공
- URL 생성부터 리다이렉트, 통계 조회, 실제 서버 배포까지 구현하고 이후 캐시 정합성 및 동시성 문제를 지속적으로 개선
- 담당
- 백엔드 전반 설계 및 구현
- 미니 PC 서버 구축, 도메인 연결 및 HTTPS 적용
- GitHub Actions CI/CD 파이프라인 작성
- 프론트엔드 구현 및 인증·통계 오류 수정, 컴포넌트 분리와 리디자인
[사용 기술 스택 및 선정 이유]
- BE
- Java 17 / Spring Boot 3.5.3
- URL과 사용자 도메인을 분리하고 Controller → Service → Repository 계층으로 구성
- 데이터 변경은 서비스 계층의 트랜잭션으로 관리하고, 요청 검증 및 예외 응답을 공통 처리
- Spring Data JPA / PostgreSQL 14
- 사용자, URL, 클릭 이벤트 간 관계와 영속 데이터 관리
- 통계 조회는 엔티티 전체 로딩 대신 집계 쿼리를 사용하도록 개선
- Spring Security / JWT
- 비회원 단축 기능과 회원 전용 관리 기능의 접근 권한 분리
- Access Token과 Refresh Token을 분리하고 Redis로 재발급 및 로그아웃 상태 관리
- ZXing
- 생성한 단축 주소를 PNG QR 코드로 변환하여 함께 제공
- Cache
- Redis 7
- 반복되는 슬러그 조회를 캐시하고, 클릭 제한이 설정된 URL의 카운터 관리
- URL 캐시에는 리다이렉트에 필요한 필드만 담은 전용 DTO 사용
- Lua
- 클릭 한도 확인과 증가 사이에 다른 요청이 끼어들지 않도록 Redis 내부에서 하나의 연산으로 처리
- FE
- Vue 3 / JavaScript / Vite
- 단축 폼, 마이페이지, 통계 패널을 제공하는 SPA 구현
- 리디자인 과정에서 Composition API와 컴포넌트 단위로 구조 정리
- 현재 화면 규모에서는 별도 상태 관리 라이브러리를 추가하지 않고 composable로 인증 및 마이페이지 상태 공유
- Axios / Chart.js
- 인증 헤더 주입과 토큰 재발급을 공통 처리하고 일별 클릭 통계 시각화
- Infra / DevOps
- 미니 PC / Ubuntu Server 26.04 LTS
- 미니 PC에 Ubuntu Server를 직접 설치하고 SSH로 원격 관리
- 포트 포워딩, 프로세스 재시작, 도메인 연결 등 서비스 공개에 필요한 운영 환경 구축
- Docker Compose / Nginx / Certbot
- 백엔드, DB, Redis를 내부 네트워크로 연결하고 Nginx의 80·443 포트만 외부에 공개
- 프론트엔드 정적 파일 제공, API 프록시, HTTPS 인증서 발급 및 갱신 구성
- GitHub Actions
- 백엔드 빌드·테스트와 프론트엔드 빌드·린트를 자동 검증하고, main 반영 시 SSH 배포 단계로 연결
- Testing
- JUnit 5 / Mockito
- URL 검증, 슬러그 충돌, 링크 만료, 클릭 한도 및 소유권 검증에 대한 단위 테스트 작성
[주요 담당 기능]
- URL 단축 및 커스텀 주소 생성
- Snowflake ID를 Base62로 인코딩하여 자동 슬러그 생성
- 회원별 동일 원본 URL 재사용 및 비회원 URL 별도 재사용 처리
- 커스텀 슬러그 중복, 허용 문자, 길이 및 애플리케이션 예약 경로 검증
- 단축 주소와 QR 코드 생성
- 리다이렉트 및 만료 조건 처리
- 유효한 단축 주소 접근 시 원본 URL로 302 리다이렉트
- 만료 시각 또는 최대 클릭 수에 도달한 경우 안내 화면으로 이동
- Redis 캐시 조회 후 캐시가 없을 때 DB를 조회하는 구조 적용
- 클릭 한도 검사와 카운터 증가를 Lua 스크립트로 통합
- 인증 및 URL 관리
- 회원가입, 로그인, 토큰 재발급, 로그아웃 및 회원 탈퇴 구현
- Refresh Token 저장과 Access Token 블랙리스트를 Redis로 관리
- 본인 URL만 상세 조회 및 삭제할 수 있도록 소유권 검증
- 클릭 통계 조회 개선
- 클릭 시각, 유입 경로, User-Agent 등 클릭 이벤트 저장
- 전체 클릭 이벤트를 반환하던 API를 총 클릭 수와 일별 집계 결과를 반환하는 형태로 변경
- 마이페이지 URL별 클릭 수를 한 번의 집계 쿼리로 조회하여 반복 쿼리 제거
- 배포 환경 구성 및 자동화
- 미니 PC에 Docker 기반 실행 환경을 구축하고
urlcut.kr 도메인과 HTTPS 적용
- 프론트엔드 빌드 컨테이너와 Nginx를 분리하여 정적 파일을 공유 볼륨으로 전달
- GitHub Actions에서 PostgreSQL·Redis 서비스 컨테이너를 사용한 백엔드 검증 구성
- 백엔드와 프론트엔드 검증이 모두 성공한 main 변경에 대해 서버의 Docker 이미지를 재빌드하도록 배포 작업 작성
[트러블슈팅 & 문제 해결]
- BE
- 클릭 수 제한을 넘기는 동시성 문제
- 기존에는 Redis에서 클릭 수를 읽고 Java에서 한도를 확인한 뒤, 클릭 이벤트 저장 이후 별도로 카운터 증가
- 최대 5회인 링크의 현재 클릭 수가 4일 때, 두 요청이 동시에 4를 읽으면 모두 통과하여 6회까지 허용될 수 있는 구조
- Redis의 개별 명령이 원자적이어도 GET → 조건 검사 → INCR 전체가 원자적인 것은 아니라는 점을 확인
- 한도 확인과 증가를 하나의 Lua 스크립트로 묶어, 카운터가 존재하는 경우 두 연산 사이의 경쟁 조건 제거
- 카운터가 없으면 DB에서 기존 클릭 수를 조회하고
setIfAbsent로 초기화한 뒤 스크립트를 재실행
- 한도 도달, 정상 허용, 카운터 초기화 후 재시도 경로를 단위 테스트로 확인
- 다만 Redis 카운터 증가와 DB 클릭 이벤트 저장은 하나의 트랜잭션이 아니므로, DB 저장 실패나 카운터 재초기화 시점의 정합성은 추가 과제로 남김
- DB 커밋 전 캐시 적재 문제
- URL 저장 직후 Redis 캐시를 채우면 이후 DB 트랜잭션이 롤백되어도 캐시에 URL이 남을 수 있음
- 캐시 적중률뿐 아니라 DB에 확정된 데이터가 캐시에 반영되는 순서를 먼저 보장해야 한다고 판단
- URL 생성 시 캐시 적재와 개별 URL 삭제 시 캐시 무효화를
TransactionSynchronization#afterCommit으로 이동
- DB 커밋 이후 Redis 작업 자체가 실패하는 경우의 복구까지 해결한 것은 아니므로 재시도 및 장애 대응은 별도로 고려할 필요가 있음
- 클릭 통계 조회 비용 증가
- 처음에는 클릭 이벤트 목록을 응답으로 전달하여 프론트엔드에서 통계 처리
- 클릭이 쌓일수록 전송 데이터와 메모리 사용이 함께 늘어나는 구조라, 총 클릭 수와 일별 집계 결과만 반환하도록 변경
- 이후 백엔드에서 이벤트 전체를 읽어 집계하던 방식도 DB의 GROUP BY와 COUNT를 사용하는 방식으로 개선
- 마이페이지에서 URL마다 클릭 수를 조회하던 방식은 사용자 소유 URL 전체를 한 번에 집계하여 개선
- 클릭 제한이 없는 URL은 Redis 카운터를 만들 필요가 없어, 제한이 설정된 URL만 카운터를 관리하도록 범위 축소
- Snowflake ID의 전달 및 슬러그 충돌 처리
- Java의 Long ID를 JSON 숫자로 전달했을 때 JavaScript에서 정밀도가 유지되지 않는 문제 발생
- ID는 계산용 값이 아닌 식별자이므로 응답 DTO에서 String으로 변환하여 전달
- 자동 생성 슬러그가 커스텀 주소와 겹쳤을 때 같은 ID를 다시 인코딩하면 결과가 바뀌지 않아 반복문을 벗어날 수 없는 문제 확인
- 충돌 시 ID부터 새로 발급하도록 수정하고 해당 경로에 대한 테스트 추가
- 캐시 직렬화 및 긴 URL 인덱스 재검토
- 캐시에는 URLCacheDto만 저장하는데 모든 Object 하위 타입의 다형적 역직렬화를 허용하고 있어 설정 범위가 과도하다고 판단
Jackson2JsonRedisSerializer<URLCacheDto>로 저장 타입 명시
- 긴 원본 URL의 TEXT 컬럼에 B-tree 인덱스를 두면 인덱스 항목 크기 제한으로 저장이 실패할 수 있는 위험 확인
- 해당 인덱스를 제거하여 긴 URL 저장 제약을 피하고, 원본 URL 중복 조회의 인덱스 설계는 후속 개선 대상으로 남김
- URL 검증 및 인증 경계 보완
- URL 전체 문자열에 차단 도메인이 포함되는지만 검사하면, 정상 URL의 경로에 해당 문자열이 있어도 거부할 수 있음
- URI 파싱 후 호스트를 비교하고 자체 서비스 도메인도 환경 설정에서 추출하도록 변경
- 커스텀 슬러그가
auth, me, urls 등 API 경로와 충돌하지 않도록 예약어 차단
- 보호할 API 경로를 SecurityConfig에 명시하고, 잘못된 인증 헤더에 대해 500 대신 401 응답을 반환하도록 개선
- FE
- 동시에 발생한 토큰 재발급 요청의 경쟁 조건
- 여러 API가 동시에 401을 받으면 각각 Refresh Token 재발급을 시도하여 인증 상태가 불안정해지는 문제
- 진행 중인 재발급 Promise를 공유하여 한 번만 갱신하고, 나머지 요청은 같은 결과를 기다린 뒤 재시도하도록 변경
- 통계 패널 비동기 처리 및 차트 렌더링 문제
- 다른 URL을 빠르게 선택하면 이전 요청이 늦게 도착하여 현재 선택한 URL의 통계를 덮어쓰는 문제
- 요청 버전 번호를 비교하여 최신 요청 결과만 반영
- 패널 애니메이션 종료 시점을 고정 타이머로 추측하던 차트 생성을 transition 완료 훅으로 변경하여 폭이 확정된 뒤 렌더링하도록 개선
- 프론트엔드 리디자인 시 기존 동작 보존
- 2,371줄의 App.vue에 화면, 인증, 통계 로직이 모여 있어 변경 영향 범위를 파악하기 어려운 상태
- UI 컴포넌트, API 클라이언트, 인증·마이페이지 composable로 분리
- 재작성 전에 엔드포인트와 응답 필드명을 문서화하고,
originUrl과 originURL처럼 API마다 다른 대소문자까지 대조
- 이미 수정한 토큰 재발급 및 통계 요청 경쟁 조건 처리 로직을 유지하며 화면 구조와 스타일 변경
- Infra
- 서버 OS 교체 및 원격 관리 환경 구성
- 초기에는 Windows의 WSL2에 Docker를 설치했으나, 서버 용도로 사용하기에 무겁고 원격 관리가 불편했음
- 미니 PC의 OS를 Ubuntu Server 26.04 LTS로 교체하고 SSH로 관리하도록 변경
- OS 재설치로 SSH 호스트 키가 바뀌어 접속이 차단된 문제는 기존 known_hosts 항목을 제거한 뒤 재접속하여 해결
- Netplan 고정 IP 설정과 공유기 포트 포워딩을 적용하여 외부에서도 서버에 접속할 수 있도록 구성
- 배포 후 코드 변경이 반영되지 않는 문제
docker compose up -d만 실행하면 기존 이미지를 재사용하여 최신 코드가 반영되지 않음
- 프론트엔드 빌드 결과 복사 시각이 바뀌어도 실제 내용은 이전 빌드일 수 있어, 파일 시각만으로 배포 성공을 판단할 수 없었음
- 배포 명령에
--build를 명시하고 문서에 재빌드 절차를 정리한 뒤 CI/CD 배포 작업에도 반영
[성과]
- URL 단축, 회원 인증, 만료 조건, 클릭 통계부터 도메인·HTTPS 배포까지 서비스 전체 흐름 구현
- Redis Lua 스크립트로 클릭 한도 확인과 증가를 원자적으로 처리하고, DB와 캐시 간 반영 순서 개선
- 클릭 통계를 원본 이벤트 전달 → 백엔드 집계 → DB 집계로 단계적으로 개선하고 마이페이지 반복 조회 제거
- 단일 프론트엔드 파일을 역할별로 분리하고 기존 API 계약을 유지하며 리디자인
- 백엔드 테스트와 프론트엔드 빌드·린트를 배포 전 검증하는 GitHub Actions 파이프라인 작성
- 다만 실제 사용자 확보와 충분한 운영 트래픽을 바탕으로 개선 효과를 검증하는 단계까지는 이어가지 못함
[아키텍처 구조]
사용자
│ HTTPS
▼
미니 PC / Ubuntu Server 26.04 LTS (직접 설치)
└─ Docker Compose
├─ Nginx : 80 / 443
│ ├─ Vue 정적 파일 제공 ← fe-build → 공유 볼륨
│ └─ API / 단축 슬러그 요청 → Spring Boot
│ ├─ PostgreSQL : 사용자, URL, 클릭 이벤트
│ └─ Redis : URL 캐시, 클릭 제한 카운터, JWT 상태
└─ Certbot : 인증서 발급 및 갱신
GitHub Actions
├─ Backend : Gradle 빌드·테스트 + PostgreSQL / Redis
├─ Frontend : ESLint + Vite 빌드
└─ main 변경 및 검증 성공 → SSH → Docker Compose 재빌드
- 외부 요청은 Nginx를 통해서만 들어오며 백엔드, PostgreSQL, Redis는 Docker 내부 네트워크에서 통신
- 리다이렉트는 Redis 캐시를 먼저 확인하고, 캐시 미스 시 PostgreSQL 조회 후 캐시 적재
- 캐시에 적중하더라도 클릭 이벤트는 PostgreSQL에 저장하므로 리다이렉트 전체가 DB 접근 없이 끝나는 구조는 아님
[ERD]
users : 사용자 ID, 이메일, 닉네임, 비밀번호 해시
url : 원본 URL, 단축 슬러그, QR 코드, 생성 시각, 만료 시각, 최대 클릭 수
- 사용자와 1:N 관계이며 비회원 URL은 사용자 참조 없이 저장
- 단축 슬러그에 UNIQUE 제약 적용
click_event : 클릭 시각, 유입 경로, User-Agent, IP 주소
- URL과 1:N 관계로 연결하여 클릭 이력 및 통계 관리
[회고]
- Best Practice
- AI를 활용한 바이브 코딩으로 개발 생산성을 크게 높일 수 있었음
- 원하는 기능과 동작을 설명하고 AI와 구현 결과를 확인하며 수정하는 방식으로 개발 진행
- 화면 구성, 반복 코드 작성, 리팩터링, 오류 원인 분석에 AI를 활용하면서 혼자 개발할 때 드는 구현 부담과 시간을 크게 줄였음
- 특히 프론트엔드 컴포넌트 분리와 리디자인, 백엔드 개선을 짧은 시간 안에 함께 진행할 수 있었고, 생각한 개선안을 실제 코드로 확인하는 속도에서 큰 이득을 봄
- 줄어든 구현 시간을 기술 선택과 변경 범위 검토에 활용할 수 있어, 1인 개발에서도 여러 영역을 다루는 데 도움이 되었음
- 단축 주소를 만드는 기능에서 시작했지만, 배포 후 코드를 다시 보면서 동시성, 캐시 정합성, 조회 비용처럼 처음에는 놓쳤던 문제를 찾아 개선할 수 있었음
- Redis를 사용하면 동시성 문제가 해결되는 것으로 생각하기 쉬웠는데, 개별 명령의 원자성과 여러 명령으로 구성된 업무 로직의 원자성은 다르다는 점을 배움
- AI 도구와 함께 코드 진단 및 리디자인을 진행하면서도, 기존 API 계약과 보존할 동작을 먼저 문서화하고 변경 범위를 정하는 과정이 중요했음
- 악성 URL 탐지나 공개 API 같은 새 기능도 검토했지만, 이미 확인한 클릭 제한 오류를 먼저 해결하는 쪽으로 우선순위를 정함
- 미니 PC에서 직접 서비스를 공개하면서 애플리케이션 구현 외에도 네트워크, 인증서, 컨테이너 재시작 및 배포 절차를 함께 관리해야 한다는 것을 경험함
- Lesson Learned
- 홍보와 사용자 모집까지 이어가지 못한 점이 가장 아쉬움
- 도메인을 연결하고 검색 노출을 위한 메타 태그, 사이트맵, 구조화 데이터를 추가했지만 적극적인 홍보와 사용자 모집은 충분히 진행하지 못했음
- 서비스를 공개하는 것과 실제 사용자를 확보하는 것은 다른 과제였고, 구현과 고도화에 비해 누구에게 어떤 이유로 필요한 서비스인지 확인하는 활동이 부족했음
- 사용자 피드백과 반복 사용 데이터를 바탕으로 기능의 필요성을 검증하지 못해, 기술적으로 개선한 내용이 실제 사용 경험에 얼마나 도움이 되었는지 확인하는 데 한계가 있었음
- 다음에는 기능 개발과 함께 대상 사용자를 정하고, 소규모 사용자 모집과 피드백 수집까지 개발 일정에 포함할 필요가 있다고 느낌
- 캐시를 추가하면 조회는 줄일 수 있지만 DB와 Redis라는 두 저장소의 실패 시점까지 고려해야 함을 배움. 커밋 이후 캐시 반영이나 Lua 적용만으로 모든 정합성 문제가 해결되는 것은 아니었음
- 단위 테스트로 분기와 예외 처리를 확인했지만, 실제 Redis·DB를 연결한 동시 요청 및 장애 상황 검증은 더 필요함. 성능 개선도 부하 테스트 수치로 확인하는 과제가 남아 있음
- 인덱스를 추가할 때 조회 속도만 고려하기보다 저장할 데이터의 길이와 쓰기 실패 가능성도 함께 검토해야 한다는 점을 배움
- 서비스 규모에 맞는 구현 범위를 정하는 것이 중요했음. 다크 모드나 새 상태 관리 라이브러리를 추가하기보다 기존 구조를 정리하고 오류를 수정하는 데 집중함