From 73d6d031bf85b9a3dcfb8872cb0565120559f96e Mon Sep 17 00:00:00 2001
From: Doi Kim <154426332+d0ikim@users.noreply.github.com>
Date: Mon, 6 Jul 2026 21:04:17 +0900
Subject: [PATCH 1/5] =?UTF-8?q?fix:=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20?=
=?UTF-8?q?=EA=B0=80=EB=8F=85=EC=84=B1,=20=ED=84=B0=EC=B9=98=20=ED=81=AC?=
=?UTF-8?q?=EA=B8=B0=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EA=B3=B5=EC=97=B0?=
=?UTF-8?q?=EC=83=81=EC=84=B8=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20?=
=?UTF-8?q?=EA=B9=A8=EC=A7=90=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/src/styles/global.css | 48 +++++++++++++++++++++++++++-------
1 file changed, 38 insertions(+), 10 deletions(-)
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index fbe1fbf..9ac7f9f 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -519,14 +519,6 @@ button.pill:hover { opacity: 0.85; }
.cc-body { padding: 16px 18px; flex: 1; display: flex; flex-direction: column; }
.cc-lineup { margin-top: auto; display: flex; align-items: center; gap: 10px; padding-top: 12px; border-top: 1px solid var(--line); }
-.concert-hero { display: flex; gap: 22px; align-items: center; padding: 26px 30px; }
-.concert-hero .ch-date {
- flex: 0 0 auto; width: 92px; text-align: center;
- background: var(--wine-soft); border: 1px solid var(--wine-line); border-radius: 12px; padding: 14px 8px;
-}
-.concert-hero .ch-date b { display: block; font-family: 'Noto Serif KR', serif; font-size: 40px; font-weight: 900; color: var(--wine); line-height: 1; margin: 4px 0; }
-.concert-hero .ch-date span { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--wine); display: block; }
-
/* ─── Admin ───────────────────────────────────────────── */
.sidebar.admin a .count {
font-family: 'JetBrains Mono', monospace; font-size: 11px; font-weight: 600;
@@ -901,11 +893,47 @@ button.pill:hover { opacity: 0.85; }
background: rgba(20, 14, 12, 0.45); z-index: 55;
}
- /* ── 본문 여백·타이포 축소 ── */
+ /* ── 본문 여백·타이포 ── */
.main .pad { padding: 20px 16px; }
.h1 { font-size: 28px; }
.h2 { font-size: 22px; }
- .lead { font-size: 15px; }
+ /* 긴 영문 단어(밴드명 등)가 포함된 제목이 화면 밖으로 삐져나가지 않게 강제 줄바꿈 */
+ .h1, .h2, .h3 { overflow-wrap: anywhere; }
+ .lead { font-size: 16px; }
+ /* 데스크톱 기준(14px)은 폰에서 너무 작음 — 모바일 기본 글자를 16px로 키움 */
+ body { font-size: 16px; }
+
+ /* ── 터치 크기 확대 ──
+ iOS 휴먼 인터페이스 가이드라인의 최소 터치 영역은 44px.
+ 데스크톱용 28~36px 버튼은 폰에서 누르기 어려우므로 전부 키운다 */
+ .btn { height: 46px; padding: 0 18px; font-size: 15px; border-radius: 8px; }
+ .btn.lg { height: 50px; font-size: 16px; }
+ .btn.sm { height: 40px; padding: 0 14px; font-size: 14px; }
+ .btn.icon { width: 44px; height: 44px; }
+ .chip { height: 40px; padding: 0 16px; font-size: 15px; }
+ .chip.sm { height: 34px; font-size: 13px; }
+ .tabs button { font-size: 15px; padding: 14px 0; }
+ .pill { height: 26px; font-size: 13px; }
+ /* .pill.lg는 뮤지션 목록의 악기 필터 등 주요 터치 대상이라 더 크게 */
+ .pill.lg { height: 38px; font-size: 14px; padding: 0 16px; }
+
+ /* ── 입력창 확대 ──
+ 폰트가 16px 미만인 input에 포커스하면 iOS Safari가 화면을 강제 확대하므로
+ 반드시 16px 이상으로 키운다 */
+ .field input[type=text], .field input[type=date], .field input[type=email],
+ .field input[type=url], .field textarea, .field select {
+ font-size: 16px; padding: 12px 14px;
+ }
+ .field label { font-size: 14px; }
+ .field .help { font-size: 13px; }
+ .field .prefix span { font-size: 14px; }
+
+ /* ── 드로어 메뉴 링크: 손가락으로 누르기 좋게 ── */
+ .sidebar a { font-size: 16px; padding: 13px 16px; }
+ .sidebar .section { font-size: 12px; }
+
+ /* ── 표·기타 텍스트 ── */
+ .admin-table { font-size: 14px; }
/* ── 아직 1열로 안 바뀐 그리드들 전환 ── */
.form-grid { grid-template-columns: 1fr; }
From 108a02fd6e78b950d5b30883f6ef10dedc837404 Mon Sep 17 00:00:00 2001
From: Doi Kim <154426332+d0ikim@users.noreply.github.com>
Date: Mon, 6 Jul 2026 21:10:06 +0900
Subject: [PATCH 2/5] =?UTF-8?q?fix:=20=EC=A7=80=EB=8F=84=20=EB=A7=90?=
=?UTF-8?q?=ED=92=8D=EC=84=A0=20=EC=A3=BC=EC=86=8C=EC=82=90=EC=A0=B8?=
=?UTF-8?q?=EB=82=98=EC=98=B4=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=ED=99=88?=
=?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=EB=A7=81=ED=81=AC=20=EC=B6=94?=
=?UTF-8?q?=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/src/pages/venues/VenueMap.jsx | 44 +++++++++++++++++++++++---
1 file changed, 39 insertions(+), 5 deletions(-)
diff --git a/frontend/src/pages/venues/VenueMap.jsx b/frontend/src/pages/venues/VenueMap.jsx
index ea5c215..2544463 100644
--- a/frontend/src/pages/venues/VenueMap.jsx
+++ b/frontend/src/pages/venues/VenueMap.jsx
@@ -11,12 +11,29 @@ function escapeHtml(text) {
return div.innerHTML;
}
-// 마커 위에 표시할 인포윈도우(말풍선) HTML 내용을 만듦
-function buildInfoWindowContent(venue) {
+// URL을 href="..." 속성 안에 넣기 전에 따옴표를 이스케이프 (속성이 중간에 끊기는 것 방지)
+function escapeAttr(url) {
+ return String(url ?? '').replace(/"/g, '"');
+}
+
+// 마커 위에 표시할 인포윈도우(말풍선) HTML 내용을 만듦 (export: 테스트에서 단독 확인 가능하도록)
+export function buildInfoWindowContent(venue) {
+ // 홈페이지 URL이 등록된 공연장만 말풍선에 링크 표시 (클릭 시 새 탭으로 열림)
+ const homepageLink = venue.homepageUrl
+ ? `
+ 홈페이지 방문 ↗
+ `
+ : '';
+
+ // width를 고정(200px)하고 줄바꿈을 허용해야 긴 주소가 말풍선 밖으로 삐져나가지 않음
+ // (max-width만 주면 카카오맵이 말풍선 크기를 잘못 계산해 텍스트가 넘칠 수 있음)
return `
-
+
${escapeHtml(venue.name)}
${escapeHtml(venue.location)}
+ ${homepageLink}
`;
}
@@ -25,6 +42,7 @@ export default function VenueMap({ venues = [], selected, onSelect }) {
const boxRef = useRef(null); // 카카오맵이 실제로 그려질 div DOM 요소를 가리킴
const mapRef = useRef(null); // 생성된 카카오 지도 객체 (한 번만 만들고 계속 재사용)
const markersRef = useRef([]); // 지도 위에 그려둔 마커 목록 (venues가 바뀔 때 지우고 다시 그리기 위해 보관)
+ const pinnedRef = useRef(null); // 클릭으로 "고정"된 말풍선 — 고정된 동안엔 마우스가 떠나도 닫지 않음 (안의 링크를 클릭할 수 있도록)
const [ready, setReady] = useState(false); // 카카오맵 SDK 로드 + 지도 생성이 끝났는지 여부
// 1) 컴포넌트가 처음 화면에 나타날 때 딱 한 번 — 카카오맵 SDK로 지도를 생성
@@ -42,6 +60,15 @@ export default function VenueMap({ venues = [], selected, onSelect }) {
center: new window.kakao.maps.LatLng(SEOUL_CENTER.lat, SEOUL_CENTER.lng),
level: 7, // 지도 확대 레벨 — 숫자가 작을수록 확대됨 (1~14)
});
+
+ // 마커가 아닌 지도 빈 곳을 클릭하면, 고정해둔 말풍선을 닫음
+ window.kakao.maps.event.addListener(mapRef.current, 'click', () => {
+ if (pinnedRef.current) {
+ pinnedRef.current.close();
+ pinnedRef.current = null;
+ }
+ });
+
setReady(true); // 아래 useEffect들이 이제 지도를 다뤄도 된다는 신호
});
}, []);
@@ -56,6 +83,7 @@ export default function VenueMap({ venues = [], selected, onSelect }) {
marker.setMap(null);
});
markersRef.current = [];
+ pinnedRef.current = null; // 마커를 다시 그리므로 고정 상태도 초기화
// 좌표값이 있는 공연장만 필터링 (아직 좌표를 못 구한 곳은 지도에 표시할 수 없음)
const withCoords = venues.filter((v) => v.latitude != null && v.longitude != null);
@@ -72,11 +100,17 @@ export default function VenueMap({ venues = [], selected, onSelect }) {
});
window.kakao.maps.event.addListener(marker, 'mouseover', () => infowindow.open(mapRef.current, marker));
- window.kakao.maps.event.addListener(marker, 'mouseout', () => infowindow.close());
+ // 마우스가 마커를 떠나면 닫되, 클릭으로 고정된 말풍선은 그대로 둠 (링크 클릭 기회 보장)
+ window.kakao.maps.event.addListener(marker, 'mouseout', () => {
+ if (pinnedRef.current !== infowindow) infowindow.close();
+ });
- // 마커 클릭 시 말풍선도 띄우고, 부모 컴포넌트(ScreenVenues)에게 선택된 공연장 id를 알려줌
+ // 마커 클릭(모바일에선 탭) 시:
+ // ① 이전에 고정된 말풍선이 있으면 닫고 ② 이 말풍선을 열어서 고정 ③ 부모에게 선택된 공연장 id 전달
window.kakao.maps.event.addListener(marker, 'click', () => {
+ if (pinnedRef.current && pinnedRef.current !== infowindow) pinnedRef.current.close();
infowindow.open(mapRef.current, marker);
+ pinnedRef.current = infowindow;
onSelect(v.id);
});
From d9c8f9776371c6325764c80b852b5716c5b2ffe6 Mon Sep 17 00:00:00 2001
From: Doi Kim <154426332+d0ikim@users.noreply.github.com>
Date: Mon, 6 Jul 2026 21:14:25 +0900
Subject: [PATCH 3/5] =?UTF-8?q?fix:=20=EC=95=85=EA=B8=B0=ED=95=84=ED=84=B0?=
=?UTF-8?q?=20=EC=84=A0=ED=83=9D=EC=8B=9C=20=EC=8A=A4=ED=81=AC=EB=A1=A4?=
=?UTF-8?q?=EC=9C=84=EC=B9=98=20=EA=B3=A0=EC=A0=95=20=EB=B0=8F=20=EB=AA=A8?=
=?UTF-8?q?=EB=B0=94=EC=9D=BC=20=ED=9E=88=EC=96=B4=EB=A1=9C=20=EC=B6=95?=
=?UTF-8?q?=EC=86=8C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/pages/visitor/ScreenDirectory.jsx | 26 ++++++++++++++++---
frontend/src/styles/global.css | 4 +++
2 files changed, 26 insertions(+), 4 deletions(-)
diff --git a/frontend/src/pages/visitor/ScreenDirectory.jsx b/frontend/src/pages/visitor/ScreenDirectory.jsx
index a8e38e5..4bf0a31 100644
--- a/frontend/src/pages/visitor/ScreenDirectory.jsx
+++ b/frontend/src/pages/visitor/ScreenDirectory.jsx
@@ -1,5 +1,5 @@
// 뮤지션 디렉토리 페이지 — 서비스 메인 화면이자 뮤지션 목록을 보여주는 화면
-import { useState, useEffect } from 'react'; // useState: 상태 관리 / useEffect: 마운트 시 API 호출
+import { useState, useEffect, useRef } from 'react'; // useState: 상태 관리 / useEffect: 마운트 시 API 호출 / useRef: 스크롤 기준점·마운트 여부 기억
import Icon from '../../components/Icon'; // 아이콘 컴포넌트
import { api } from '../../api/client'; // 백엔드 API 호출 함수 모음
@@ -19,6 +19,23 @@ export default function ScreenDirectory({ navigate, auth, onLoginClick }) {
// filter: 선택된 악기 필터 ('all' 또는 'Piano' 등)
const [filter, setFilter] = useState('all');
+ // 필터 변경 시 스크롤을 맞출 기준점 (디렉토리 헤더 div를 가리킴)
+ const listTopRef = useRef(null);
+ // 첫 렌더인지 여부 — 페이지에 처음 들어왔을 때는 스크롤을 건드리면 안 되므로 구분
+ const didMountRef = useRef(false);
+
+ // 악기 필터를 바꾸면 목록 길이가 확 줄면서 페이지 높이가 짧아지고,
+ // 브라우저가 스크롤 위치를 잃어 화면이 최상단(히어로)으로 튀는 문제가 있었음.
+ // → 필터가 바뀔 때마다 "디렉토리 헤더"가 화면 상단에 오도록 스크롤을 고정해서
+ // 히어로를 다시 지나칠 필요 없이 바로 목록을 볼 수 있게 함
+ useEffect(() => {
+ if (!didMountRef.current) {
+ didMountRef.current = true; // 첫 렌더는 건너뜀 (페이지 진입 시에는 맨 위부터 보여야 함)
+ return;
+ }
+ listTopRef.current?.scrollIntoView({ block: 'start' });
+ }, [filter]); // filter가 바뀔 때만 실행 (검색어 입력 중에는 스크롤하지 않음)
+
// 마운트 시 전체 뮤지션 목록을 백엔드에서 불러옴
useEffect(() => {
api.getMusicians() // GET /api/musicians
@@ -55,7 +72,8 @@ export default function ScreenDirectory({ navigate, auth, onLoginClick }) {
K-JAZZ INSIGHT NAVIGATOR · MVP
-
+ {/* 제목 크기는 인라인이 아니라 CSS(.hero-title)로 지정 — 모바일 미디어 쿼리로 줄일 수 있게 */}
+
한국재즈 뮤지션을 뮤지션이 직접 정리합니다.
@@ -93,8 +111,8 @@ export default function ScreenDirectory({ navigate, auth, onLoginClick }) {
- {/* 디렉토리 헤더 — 뮤지션 수 + 검색창 */}
-
+ {/* 디렉토리 헤더 — 뮤지션 수 + 검색창 (ref: 필터 변경 시 이 위치로 스크롤) */}
+
뮤지션 디렉토리
활동중인 뮤지션 {list.length}명
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index 9ac7f9f..bee519d 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -314,6 +314,7 @@ button.pill:hover { opacity: 0.85; }
position: relative;
overflow: hidden;
}
+.hero-title { font-size: 44px; } /* 모바일에서는 아래 768px 미디어 쿼리가 줄임 */
.hero .stamp {
position: absolute; right: -40px; top: -40px;
width: 200px; height: 200px; border-radius: 999px;
@@ -899,6 +900,9 @@ button.pill:hover { opacity: 0.85; }
.h2 { font-size: 22px; }
/* 긴 영문 단어(밴드명 등)가 포함된 제목이 화면 밖으로 삐져나가지 않게 강제 줄바꿈 */
.h1, .h2, .h3 { overflow-wrap: anywhere; }
+ /* 히어로 제목·여백 축소 — 히어로가 화면을 덜 차지해 목록까지 스크롤이 짧아짐 */
+ .hero { padding: 32px 24px; }
+ .hero-title { font-size: 30px; }
.lead { font-size: 16px; }
/* 데스크톱 기준(14px)은 폰에서 너무 작음 — 모바일 기본 글자를 16px로 키움 */
body { font-size: 16px; }
From 6ad8298cccece365c67696c673b83d5e1efe43e5 Mon Sep 17 00:00:00 2001
From: Doi Kim <154426332+d0ikim@users.noreply.github.com>
Date: Mon, 6 Jul 2026 23:05:44 +0900
Subject: [PATCH 4/5] =?UTF-8?q?feat:=20SNS,=EC=9B=B9=EC=82=AC=EC=9D=B4?=
=?UTF-8?q?=ED=8A=B8=20URL=ED=98=95=EC=8B=9D=20=EA=B2=80=EC=A6=9D=20?=
=?UTF-8?q?=EC=B6=94=EA=B0=80=20(=ED=94=84=EB=A1=A0=ED=8A=B8=20=ED=8F=BC+?=
=?UTF-8?q?=EB=B0=B1=EC=97=94=EB=93=9C=20Bean=20Validation)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 1 +
backend/build.gradle | 1 +
.../again/domain/admin/AdminController.java | 9 ++---
.../domain/musician/MusicianController.java | 3 +-
.../domain/musician/MusicianRequest.java | 15 +++++++++
.../musician/MusicianUpdateRequest.java | 6 ++++
.../dazz/again/domain/venue/VenueRequest.java | 12 +++++++
.../global/error/GlobalExceptionHandler.java | 33 +++++++++++++++++++
.../src/pages/dashboard/ScreenProfileEdit.jsx | 16 ++++++++-
.../src/pages/onboarding/ScreenOnboarding.jsx | 17 +++++++++-
frontend/src/styles/global.css | 1 +
frontend/src/utils/validators.js | 22 +++++++++++++
12 files changed, 129 insertions(+), 7 deletions(-)
create mode 100644 backend/src/main/java/com/dazz/again/global/error/GlobalExceptionHandler.java
create mode 100644 frontend/src/utils/validators.js
diff --git a/README.md b/README.md
index 8425513..c5f3401 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,7 @@
- **어드민 관리** — 뮤지션·공연장·공연 등록/수정(출연 라인업 지정 포함), 뮤지션 인증 대기 목록 관리
- **공연장 좌표 크롤링** — Kakao Local API로 기존 공연장 주소를 위도/경도로 변환해 저장
- **모바일 반응형 UI** — 768px 이하에서 햄버거 메뉴 + 슬라이드 드로어로 전환, 그리드 1열 재배치
+- **요청 데이터 검증** — URL 형식을 프론트 폼 + 백엔드 Bean Validation(`@Pattern`/`@Valid`)으로 이중 검사, 전역 예외 핸들러가 필드별 메시지로 400 응답
---
diff --git a/backend/build.gradle b/backend/build.gradle
index 48e90c6..06564f3 100644
--- a/backend/build.gradle
+++ b/backend/build.gradle
@@ -22,6 +22,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-h2console'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
+ implementation 'org.springframework.boot:spring-boot-starter-validation' // Bean Validation — DTO 필드에 @Pattern 등 검증 어노테이션 사용 가능하게 함
runtimeOnly 'com.h2database:h2' // 테스트용 인메모리 DB (테스트 코드에서만 사용)
runtimeOnly 'org.postgresql:postgresql' // 실제 PostgreSQL 연결 드라이버
implementation 'me.paulschwarz:spring-dotenv:4.0.0' // .env 파일을 읽어서 환경변수로 등록해주는 라이브러리
diff --git a/backend/src/main/java/com/dazz/again/domain/admin/AdminController.java b/backend/src/main/java/com/dazz/again/domain/admin/AdminController.java
index 2a3c0cc..a356b39 100644
--- a/backend/src/main/java/com/dazz/again/domain/admin/AdminController.java
+++ b/backend/src/main/java/com/dazz/again/domain/admin/AdminController.java
@@ -10,6 +10,7 @@
import com.dazz.again.domain.venue.Venue; // 공연장 엔티티 — 등록/수정 응답에 사용
import com.dazz.again.domain.venue.VenueRequest; // 공연장 등록/수정 요청 DTO
import com.dazz.again.domain.venue.VenueService; // 공연장 등록/수정 비즈니스 로직
+import jakarta.validation.Valid; // @RequestBody DTO에 붙은 검증 어노테이션(@Pattern 등)을 실제로 실행시키는 스위치
import io.swagger.v3.oas.annotations.Operation; // Swagger에서 각 API의 요약 설명을 표시하는 어노테이션
import io.swagger.v3.oas.annotations.responses.ApiResponse; // Swagger에서 HTTP 응답 코드별 설명을 표시하는 어노테이션
import io.swagger.v3.oas.annotations.security.SecurityRequirement; // Swagger에서 "이 API는 JWT 인증 필요"를 자물쇠 아이콘으로 표시
@@ -98,7 +99,7 @@ public ResponseEntity> reject(@PathVariable Long id) {
)
@ApiResponse(responseCode = "200", description = "등록 성공")
@PostMapping("/musicians")
- public ResponseEntity
createMusician(@RequestBody MusicianRequest request) {
+ public ResponseEntity createMusician(@Valid @RequestBody MusicianRequest request) {
// 요청 바디의 JSON을 MusicianRequest 객체로 받아 MusicianService에 등록 요청
return ResponseEntity.ok(musicianService.createByAdmin(request));
}
@@ -111,7 +112,7 @@ public ResponseEntity createMusician(@RequestBody MusicianRequest requ
@ApiResponse(responseCode = "200", description = "수정 성공")
@ApiResponse(responseCode = "400", description = "존재하지 않는 뮤지션")
@PutMapping("/musicians/{id}")
- public ResponseEntity> updateMusician(@PathVariable Long id, @RequestBody MusicianRequest request) {
+ public ResponseEntity> updateMusician(@PathVariable Long id, @Valid @RequestBody MusicianRequest request) {
// {id}로 수정할 뮤지션을 특정하고, 요청 바디로 새 값을 받아 MusicianService에 수정 요청
try {
return ResponseEntity.ok(musicianService.updateByAdmin(id, request));
@@ -130,7 +131,7 @@ public ResponseEntity> updateMusician(@PathVariable Long id, @RequestBody Musi
)
@ApiResponse(responseCode = "200", description = "등록 성공")
@PostMapping("/venues")
- public ResponseEntity createVenue(@RequestBody VenueRequest request) {
+ public ResponseEntity createVenue(@Valid @RequestBody VenueRequest request) {
// 요청 바디의 JSON을 VenueRequest 객체로 받아 VenueService에 등록 요청
return ResponseEntity.ok(venueService.create(request));
}
@@ -143,7 +144,7 @@ public ResponseEntity createVenue(@RequestBody VenueRequest request) {
@ApiResponse(responseCode = "200", description = "수정 성공")
@ApiResponse(responseCode = "400", description = "존재하지 않는 공연장")
@PutMapping("/venues/{id}")
- public ResponseEntity> updateVenue(@PathVariable Long id, @RequestBody VenueRequest request) {
+ public ResponseEntity> updateVenue(@PathVariable Long id, @Valid @RequestBody VenueRequest request) {
// {id}로 수정할 공연장을 특정하고, 요청 바디로 새 값을 받아 VenueService에 수정 요청
try {
return ResponseEntity.ok(venueService.update(id, request));
diff --git a/backend/src/main/java/com/dazz/again/domain/musician/MusicianController.java b/backend/src/main/java/com/dazz/again/domain/musician/MusicianController.java
index 7a3520c..ec4e548 100644
--- a/backend/src/main/java/com/dazz/again/domain/musician/MusicianController.java
+++ b/backend/src/main/java/com/dazz/again/domain/musician/MusicianController.java
@@ -12,6 +12,7 @@
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping; // HTTP PUT 메서드 매핑 어노테이션
+import jakarta.validation.Valid; // @RequestBody DTO에 붙은 검증 어노테이션(@Pattern 등)을 실제로 실행시키는 스위치
import org.springframework.web.bind.annotation.RequestBody; // 요청 body의 JSON을 Java 객체로 변환하는 어노테이션
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@@ -38,7 +39,7 @@ public class MusicianController {
@ApiResponse(responseCode = "401", description = "토큰 없음 또는 만료")
@ApiResponse(responseCode = "403", description = "MUSICIAN 역할이 아님")
@PutMapping("/me")
- public ResponseEntity updateMyProfile(@RequestBody MusicianUpdateRequest request) {
+ public ResponseEntity updateMyProfile(@Valid @RequestBody MusicianUpdateRequest request) {
// JwtFilter가 SecurityContextHolder에 저장한 userId를 꺼냄
Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
return ResponseEntity.ok(musicianService.updateMyProfile(userId, request));
diff --git a/backend/src/main/java/com/dazz/again/domain/musician/MusicianRequest.java b/backend/src/main/java/com/dazz/again/domain/musician/MusicianRequest.java
index 0865dea..794cd42 100644
--- a/backend/src/main/java/com/dazz/again/domain/musician/MusicianRequest.java
+++ b/backend/src/main/java/com/dazz/again/domain/musician/MusicianRequest.java
@@ -1,6 +1,7 @@
// ADMIN이 뮤지션을 등록하거나 수정할 때 클라이언트에서 받는 요청 데이터를 담는 파일
package com.dazz.again.domain.musician;
+import jakarta.validation.constraints.Pattern; // Bean Validation — 필드값이 정규식과 일치하는지 검사
import lombok.Getter; // 모든 필드에 getter 메서드를 자동 생성
import lombok.NoArgsConstructor; // 파라미터 없는 기본 생성자 자동 생성 — Spring이 JSON을 이 객체로 변환할 때 필요
@@ -9,12 +10,26 @@
@NoArgsConstructor
public class MusicianRequest {
+ // URL 형식 검사용 정규식:
+ // ^$ → 빈 문자열 허용 (선택 입력 필드이므로)
+ // (https?://)? → http:// 또는 https:// 는 있어도 되고 없어도 됨
+ // [^\s]+\.[^\s]+ → 공백 없이 점(.)이 포함된 주소 (예: instagram.com/dazz)
+ // ※ @Pattern은 값이 null이면 검사를 건너뜀 → 빈 문자열("")까지 허용하려고 ^$ 를 추가
+ static final String URL_PATTERN = "^$|^(https?://)?[^\\s]+\\.[^\\s]+$";
+ static final String URL_MESSAGE = "URL 형식이 아닙니다 (예: https://instagram.com/dazz)";
+
private String stageName; // 활동명 (필수)
private String realName; // 본명 (선택)
private String position; // 악기 (필수)
private String bio; // 소개 (선택)
+
+ @Pattern(regexp = URL_PATTERN, message = URL_MESSAGE) // 형식이 틀리면 컨트롤러 진입 전에 400 에러
private String snsUrl; // 인스타그램 URL (선택)
+
+ @Pattern(regexp = URL_PATTERN, message = URL_MESSAGE)
private String profileImageUrl; // 프로필 사진 URL (선택)
+
+ @Pattern(regexp = URL_PATTERN, message = URL_MESSAGE)
private String sourceUrl; // 출처 URL (선택)
// sourceType, userId는 요청에 포함하지 않음
diff --git a/backend/src/main/java/com/dazz/again/domain/musician/MusicianUpdateRequest.java b/backend/src/main/java/com/dazz/again/domain/musician/MusicianUpdateRequest.java
index adbf39c..a84f7bd 100644
--- a/backend/src/main/java/com/dazz/again/domain/musician/MusicianUpdateRequest.java
+++ b/backend/src/main/java/com/dazz/again/domain/musician/MusicianUpdateRequest.java
@@ -2,6 +2,7 @@
// DTO: 클라이언트가 보낸 JSON 데이터를 Java 객체로 변환해서 받는 그릇
package com.dazz.again.domain.musician;
+import jakarta.validation.constraints.Pattern; // Bean Validation — 필드값이 정규식과 일치하는지 검사
import lombok.Getter; // 모든 필드의 getter 메서드를 자동 생성
import lombok.NoArgsConstructor; // 파라미터 없는 기본 생성자 자동 생성 (JSON 역직렬화에 필요)
@@ -13,6 +14,11 @@ public class MusicianUpdateRequest {
private String realName; // 본명 — null이면 기존 값 유지
private String position; // 악기 — null이면 기존 값 유지
private String bio; // 소개 — null이면 기존 값 유지
+
+ // URL 정규식/메시지는 같은 패키지의 MusicianRequest에 정의된 상수를 재사용
+ @Pattern(regexp = MusicianRequest.URL_PATTERN, message = MusicianRequest.URL_MESSAGE)
private String snsUrl; // 인스타그램 URL — null이면 기존 값 유지
+
+ @Pattern(regexp = MusicianRequest.URL_PATTERN, message = MusicianRequest.URL_MESSAGE)
private String profileImageUrl; // 프로필 사진 URL — null이면 기존 값 유지
}
\ No newline at end of file
diff --git a/backend/src/main/java/com/dazz/again/domain/venue/VenueRequest.java b/backend/src/main/java/com/dazz/again/domain/venue/VenueRequest.java
index 00c542b..1de45cc 100644
--- a/backend/src/main/java/com/dazz/again/domain/venue/VenueRequest.java
+++ b/backend/src/main/java/com/dazz/again/domain/venue/VenueRequest.java
@@ -1,6 +1,7 @@
// ADMIN이 공연장을 등록하거나 수정할 때 클라이언트에서 받는 요청 데이터를 담는 파일
package com.dazz.again.domain.venue;
+import jakarta.validation.constraints.Pattern; // Bean Validation — 필드값이 정규식과 일치하는지 검사
import lombok.Getter; // 모든 필드에 getter 메서드를 자동 생성
import lombok.NoArgsConstructor; // 파라미터 없는 기본 생성자 자동 생성 — Spring이 JSON을 이 객체로 변환할 때 필요
@@ -9,9 +10,20 @@
@NoArgsConstructor
public class VenueRequest {
+ // URL 형식 검사용 정규식 — musician 패키지의 것과 같지만,
+ // venue 도메인이 musician 도메인 클래스에 의존하지 않도록 따로 정의 (도메인 간 결합 방지)
+ // ^$: 빈 문자열 허용 / (https?://)?: 스킴 생략 가능 / [^\s]+\.[^\s]+: 공백 없이 점 포함
+ static final String URL_PATTERN = "^$|^(https?://)?[^\\s]+\\.[^\\s]+$";
+ static final String URL_MESSAGE = "URL 형식이 아닙니다 (예: https://example.com)";
+
private String name; // 공연장 이름 (필수)
private String location; // 공연장 위치 (필수)
+
+ @Pattern(regexp = URL_PATTERN, message = URL_MESSAGE) // 형식이 틀리면 컨트롤러 진입 전에 400 에러
private String instagramUrl; // 인스타그램 URL (선택)
+
+ @Pattern(regexp = URL_PATTERN, message = URL_MESSAGE)
private String homepageUrl; // 홈페이지 URL (선택)
+
private String description; // 공연장 설명 (선택)
}
\ No newline at end of file
diff --git a/backend/src/main/java/com/dazz/again/global/error/GlobalExceptionHandler.java b/backend/src/main/java/com/dazz/again/global/error/GlobalExceptionHandler.java
new file mode 100644
index 0000000..4e7c1bf
--- /dev/null
+++ b/backend/src/main/java/com/dazz/again/global/error/GlobalExceptionHandler.java
@@ -0,0 +1,33 @@
+// 모든 컨트롤러에서 발생하는 예외를 한 곳에서 받아 일관된 형식의 에러 응답으로 변환하는 파일
+package com.dazz.again.global.error;
+
+import org.springframework.http.ResponseEntity; // HTTP 상태코드 + 응답 바디를 함께 반환하는 클래스
+import org.springframework.web.bind.MethodArgumentNotValidException; // @Valid 검증 실패 시 스프링이 던지는 예외
+import org.springframework.web.bind.annotation.ExceptionHandler; // "이 예외가 발생하면 이 메서드가 처리한다"고 지정하는 어노테이션
+import org.springframework.web.bind.annotation.RestControllerAdvice; // 모든 @RestController의 예외를 가로채는 전역 처리기임을 선언
+
+import java.util.LinkedHashMap; // 필드 순서가 유지되는 Map (에러 메시지를 필드 선언 순서대로 보여주기 위함)
+import java.util.Map;
+
+// @RestControllerAdvice: 프로젝트의 모든 컨트롤러에 공통으로 적용되는 "예외 처리 전담반"
+// 컨트롤러마다 try-catch를 쓰는 대신, 여기 한 곳에서 예외 → 에러 응답 변환을 담당한다
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+ // @Valid 검증 실패 처리 — DTO의 @Pattern 등 검증에 걸리면 스프링이
+ // MethodArgumentNotValidException을 던지는데, 그걸 여기서 받아서
+ // { "필드명": "에러 메시지" } 형태의 400 응답으로 바꿔준다
+ // (이 핸들러가 없으면 클라이언트는 알아보기 힘든 기본 에러 응답을 받게 됨)
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ public ResponseEntity> handleValidationError(MethodArgumentNotValidException e) {
+ Map errors = new LinkedHashMap<>();
+
+ // 검증에 실패한 필드가 여러 개일 수 있으므로 전부 모아서 담음
+ e.getBindingResult().getFieldErrors().forEach(
+ fieldError -> errors.put(fieldError.getField(), fieldError.getDefaultMessage())
+ );
+
+ // 400 Bad Request + 예: { "snsUrl": "URL 형식이 아닙니다 (예: https://instagram.com/dazz)" }
+ return ResponseEntity.badRequest().body(errors);
+ }
+}
diff --git a/frontend/src/pages/dashboard/ScreenProfileEdit.jsx b/frontend/src/pages/dashboard/ScreenProfileEdit.jsx
index acbb5ad..3e782ff 100644
--- a/frontend/src/pages/dashboard/ScreenProfileEdit.jsx
+++ b/frontend/src/pages/dashboard/ScreenProfileEdit.jsx
@@ -1,6 +1,7 @@
import { useState } from 'react';
import Icon from '../../components/Icon';
import PresetPicker from './PresetPicker';
+import { isValidUrl, isValidHandle } from '../../utils/validators'; // URL/핸들 형식 검사
export default function ScreenProfileEdit({ me, navigate, onUpdate, onToast }) {
const [form, setForm] = useState({
@@ -17,6 +18,14 @@ export default function ScreenProfileEdit({ me, navigate, onUpdate, onToast }) {
const set = (k, v) => setForm((p) => ({ ...p, [k]: v }));
+ // 링크 필드 형식 검사 — 틀린 필드에만 오류 메시지를 담음 (통과하면 null)
+ const linkErrors = {
+ instagram: isValidHandle(form.instagram) ? null : '@ 뒤의 아이디만 입력해주세요 (예: dazz_pianist)',
+ youtube: isValidHandle(form.youtube) ? null : '@ 뒤의 채널명만 입력해주세요 (예: dazzjazz)',
+ website: isValidUrl(form.website) ? null : 'URL 형식이 아니에요 (예: example.com 또는 https://example.com)',
+ };
+ const linksOk = !linkErrors.instagram && !linkErrors.youtube && !linkErrors.website;
+
const save = () => {
onUpdate && onUpdate(form);
onToast && onToast('프로필이 저장됐습니다');
@@ -61,14 +70,18 @@ export default function ScreenProfileEdit({ me, navigate, onUpdate, onToast }) {
Instagram
@ set('instagram', e.target.value)} />
+ {/* 형식이 틀렸을 때만 오류 안내 표시 */}
+ {linkErrors.instagram &&
{linkErrors.instagram} }
YouTube
@ set('youtube', e.target.value)} />
+ {linkErrors.youtube &&
{linkErrors.youtube} }
웹사이트
set('website', e.target.value)} />
+ {linkErrors.website &&
{linkErrors.website} }
@@ -80,7 +93,8 @@ export default function ScreenProfileEdit({ me, navigate, onUpdate, onToast }) {
navigate('dashboard')}>취소
- 저장
+ {/* 링크 형식 오류가 있으면 저장 버튼 비활성화 */}
+ 저장
diff --git a/frontend/src/pages/onboarding/ScreenOnboarding.jsx b/frontend/src/pages/onboarding/ScreenOnboarding.jsx
index 88d6a60..34efc0a 100644
--- a/frontend/src/pages/onboarding/ScreenOnboarding.jsx
+++ b/frontend/src/pages/onboarding/ScreenOnboarding.jsx
@@ -1,5 +1,6 @@
import { useState } from 'react';
import Icon from '../../components/Icon';
+import { isValidUrl, isValidHandle } from '../../utils/validators'; // URL/핸들 형식 검사
const INSTRUMENTS = ['Piano', 'Bass', 'Drums', 'Guitar', 'Sax', 'Vocal', 'Trumpet', 'Trombone', 'Violin', 'Vibraphone', 'Percussion', 'Other'];
@@ -20,6 +21,15 @@ export default function ScreenOnboarding({ navigate, onSubmitRequest }) {
const prev = () => setStep((s) => Math.max(s - 1, 0));
const submit = () => onSubmitRequest({ kind: 'new', ...form });
+ // 링크 단계(step 3) 형식 검사 — 틀린 필드에만 오류 메시지를 담음 (통과하면 null)
+ const linkErrors = {
+ instagram: isValidHandle(form.instagram) ? null : '@ 뒤의 아이디만 입력해주세요 (예: dazz_pianist)',
+ youtube: isValidHandle(form.youtube) ? null : '@ 뒤의 채널명만 입력해주세요 (예: dazzjazz)',
+ website: isValidUrl(form.website) ? null : 'URL 형식이 아니에요 (예: example.com 또는 https://example.com)',
+ };
+ // 세 필드 모두 통과해야 다음 단계로 진행 가능
+ const linksOk = !linkErrors.instagram && !linkErrors.youtube && !linkErrors.website;
+
return (
@@ -71,14 +81,18 @@ export default function ScreenOnboarding({ navigate, onSubmitRequest }) {
Instagram
@ set('instagram', e.target.value)} />
+ {/* 형식이 틀렸을 때만 오류 안내 표시 */}
+ {linkErrors.instagram &&
{linkErrors.instagram} }
YouTube
@ set('youtube', e.target.value)} />
+ {linkErrors.youtube &&
{linkErrors.youtube} }
개인 웹사이트
set('website', e.target.value)} />
+ {linkErrors.website &&
{linkErrors.website} }
)}
@@ -98,8 +112,9 @@ export default function ScreenOnboarding({ navigate, onSubmitRequest }) {
이전
+ {/* 다음 버튼: 악기 미선택(step 0) 또는 링크 형식 오류(step 3)면 비활성화 */}
{step < STEPS.length - 1 ? (
-
다음
+
다음
) : (
인증 신청하기
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index bee519d..5901159 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -211,6 +211,7 @@ button.pill:hover { opacity: 0.85; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field label { font-size: 12px; font-weight: 600; color: var(--ink-2); }
.field .help { font-size: 12px; color: var(--mute); }
+.field .err { font-size: 12px; color: #c0392b; } /* 형식 오류 등 검증 실패 안내문 */
.field input[type=text], .field input[type=date], .field input[type=email], .field input[type=url], .field textarea, .field select {
width: 100%;
border: 1px solid var(--line); background: var(--paper);
diff --git a/frontend/src/utils/validators.js b/frontend/src/utils/validators.js
new file mode 100644
index 0000000..6e29968
--- /dev/null
+++ b/frontend/src/utils/validators.js
@@ -0,0 +1,22 @@
+// 폼 입력값 형식 검사 유틸 — 온보딩(새 뮤지션 등록)·프로필 편집 등 여러 폼에서 공통 사용
+
+// URL 형식인지 검사 (빈 값은 통과 — 선택 입력 필드이므로 "입력했다면 형식이 맞는지"만 본다)
+// "example.com"처럼 스킴(https://) 없이 입력해도 허용
+export function isValidUrl(value) {
+ if (!value) return true;
+ // 스킴이 없으면 https://를 붙여서 검사 (사용자가 도메인만 입력하는 경우가 많음)
+ const candidate = /^https?:\/\//i.test(value) ? value : `https://${value}`;
+ try {
+ const url = new URL(candidate); // 브라우저 내장 URL 파서로 형식 검사 (공백 등이 있으면 예외 발생)
+ return url.hostname.includes('.'); // "abc" 같은 단어 하나짜리 입력은 도메인이 아니므로 거름
+ } catch {
+ return false; // URL 파서가 못 읽으면 형식 오류
+ }
+}
+
+// SNS 핸들(@ 뒤에 붙는 아이디) 형식인지 검사 (빈 값은 통과)
+// 영문/숫자/점/밑줄/하이픈만 허용 — URL 전체나 공백이 들어오면 형식 오류로 안내
+export function isValidHandle(value) {
+ if (!value) return true;
+ return /^[A-Za-z0-9._-]+$/.test(value);
+}
From aee803a5240807b4eb0a52869ecaee3813ed31b8 Mon Sep 17 00:00:00 2001
From: Doi Kim <154426332+d0ikim@users.noreply.github.com>
Date: Mon, 6 Jul 2026 23:14:37 +0900
Subject: [PATCH 5/5] =?UTF-8?q?fix:=20=EC=9D=B8=EC=A6=9D=EC=8B=AC=EC=82=AC?=
=?UTF-8?q?=EC=A4=91=20=ED=99=94=EB=A9=B4=EC=97=90=EC=84=9C=20=EB=82=B4?=
=?UTF-8?q?=EB=B6=80=EC=8B=9D=EB=B3=84=EC=9E=90(user=5Fid)=20=EB=85=B8?=
=?UTF-8?q?=EC=B6=9C=20=EC=A0=9C=EA=B1=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/src/App.jsx | 2 +-
frontend/src/pages/auth/ScreenPending.jsx | 6 +++---
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 93e977c..b317082 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -207,7 +207,7 @@ export default function App() {
case 'become-musician': return ;
case 'claim-existing': return ;
case 'onboarding': return ;
- case 'pending': return ;
+ case 'pending': return ;
case 'dashboard': return ;
case 'profile-edit': return setMe((p) => ({ ...p, ...f }))} onToast={showToast} />;
diff --git a/frontend/src/pages/auth/ScreenPending.jsx b/frontend/src/pages/auth/ScreenPending.jsx
index ba827aa..84c5de8 100644
--- a/frontend/src/pages/auth/ScreenPending.jsx
+++ b/frontend/src/pages/auth/ScreenPending.jsx
@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react'; // useEffect: 내 신청 정보 로
import Icon from '../../components/Icon'; // 아이콘
import { api } from '../../api/client'; // 백엔드 API 호출 함수 모음
-export default function ScreenPending({ navigate, auth }) {
+export default function ScreenPending({ navigate }) { // auth prop 제거 — user_id 표시를 없애면서 더 이상 필요 없음
// req: 내 인증 신청 단건 정보 — GET /api/verify/musician/me
const [req, setReq] = useState(null);
@@ -33,8 +33,8 @@ export default function ScreenPending({ navigate, auth }) {
신청 일시 {req.requestedAt?.slice(0, 10)}
>
)}
- {/* userId는 auth에서 바로 가져옴 */}
- user_id {auth.userId}
+ {/* user_id는 내부 DB 식별자라 사용자에게 보여줄 필요 없음 — 동료 피드백으로 제거.
+ 문의 대응용 식별자는 위의 "요청 ID"로 충분 */}
상태 PENDING · 승인 대기