개발일지 2026-06-06
피아노 학원 프랜차이즈 플랫폼 개발일지 (2026-06-06)
Spring Boot 백엔드 + React(admin/brand) 프론트로 구성된 피아노 학원 프랜차이즈 플랫폼. 오늘은 (1) 로컬 백엔드 500 에러 트러블슈팅, (2) "연습실 관리" 메뉴 풀스택 신규 구현 + dev 서버 배포를 진행했다.
1. 로컬 백엔드 500 에러 트러블슈팅
증상
admin에서 GET http://localhost:8080/api/v1/admin/trial-requests 호출 시 500 발생.
{"success":false,"code":"INTERNAL_ERROR","message":"서버 오류가 발생했습니다."}
원인
- 로컬 백엔드 JVM이 새 코드 작성·컴파일 시점보다 먼저 기동되어 있었다.
- 즉, 실행 중인 프로세스에는 새로 추가한
TrialRequestAdminController/ MyBatis 매퍼가 아예 로드되어 있지 않았다. - 컨트롤러/핸들러가 없으니 디스패치 과정에서 예외 → 500.
진단 과정 (핵심 팁)
ps로 프로세스 기동 시각을 확인 → 코드 컴파일 시각(build/classes타임스탬프)과 비교해 "스테일(stale)" 여부 판단.public/locations같은 기존 엔드포인트는 200 → 백엔드 자체는 살아있고 "옛 코드"라는 결론.- 더미 토큰으로 admin 엔드포인트 호출 시 401(인증이 핸들러보다 먼저 동작) → 인증 레이어는 정상.
해결
- 스테일 백엔드 프로세스 종료 (단, SSH 터널 프로세스는 보존 — 로컬은 터널
127.0.0.1:3307경유로 dev RDS에 접속). - 새 코드로 백엔드 재기동.
- 검증: admin 로그인으로 JWT 발급 후 엔드포인트 호출 → 200 OK, 실제 데이터 반환 확인.
배운 점
- "코드는 고쳤는데 안 먹힌다" → 실행 중인 프로세스가 새 코드인지부터 확인.
- 로컬/원격 포트가 겹쳐 보일 때(여기선 8080 LISTEN + 3307 ESTABLISHED가 같은 JVM),
lsof/ps로 프로세스의 정체(메인 클래스)를 직접 확인해 백엔드와 터널을 헷갈리지 않도록 한다.
2. "연습실 관리" 메뉴 풀스택 신규 구현
문제 정의
admin 사이드바에 연습실 관리(/practice-rooms) 메뉴는 보이는데 클릭하면 빈 화면.
원인 분석:
- 메뉴는 DB(
menus테이블, id=21)에만 등록되어 있어 사이드바엔 노출. - 그러나 admin 프론트의 라우트/페이지가 없고, admin 전용 백엔드 API도 없음.
- 기존 연습실 API는 학원(원장) 전용(
/api/v1/academy/rooms)으로, 로그인한 원장의academyId로 본인 학원만 조회하도록 스코프가 걸려 있어 본사 admin(전 학원 조회)엔 부적합.
참고: admin 사이드바는
/admin/menus/my로 메뉴를 동적 로딩하므로, 메뉴 추가 시 사이드바 코드 수정 없이 메뉴 DB row + 프론트 라우트만 있으면 된다. 이번엔 메뉴 row는 이미 있었고 라우트/페이지/ API가 비어 있던 케이스.
설계 방향
- 기존
PracticeRoom엔티티/매퍼를 재사용하되, 본사(admin) 전용 메서드를 추가. - admin 목록은 전 학원 연습실 + 학원명을 보여주고 학원별 필터를 지원.
- 쓰기(생성/수정/삭제)는 academy 스코프 검증 없이 학원을 지정해 처리.
백엔드 변경 (piano-backend)
엔티티 — 조인 전용 필드 추가
// PracticeRoom.java
private String academyName; // admin 목록 조회 시 학원명(조인 전용, DB 컬럼 아님)
매퍼 인터페이스
// PracticeRoomMapper.java
// admin(본사) 전용: 전 학원 연습실 + 학원명, academyId null이면 전체
List<PracticeRoom> findAllForAdmin(@Param("academyId") Long academyId);
매퍼 XML — 학원 조인 + 동적 필터
<resultMap id="roomMap" type="com.piano.api.academy.room.PracticeRoom">
<id property="id" column="id"/>
<result property="academyId" column="academy_id"/>
<result property="name" column="name"/>
<result property="capacity" column="capacity"/>
<result property="instrument" column="instrument"/>
<result property="status" column="status"/>
<result property="academyName" column="academy_name"/>
</resultMap>
<select id="findAllForAdmin" resultMap="roomMap">
SELECT r.id, r.academy_id, r.name, r.capacity, r.instrument, r.status,
a.name AS academy_name
FROM practice_rooms r
LEFT JOIN academies a ON a.id = r.academy_id
<where>
<if test="academyId != null">r.academy_id = #{academyId}</if>
</where>
ORDER BY r.academy_id, r.id
</select>
update에는academy_id도 SET하도록 추가해 admin이 연습실의 소속 학원을 변경할 수 있게 했다.
서비스 — admin 전용 메서드 (스코프 제한 없음)
@Transactional(readOnly = true)
public List<PracticeRoom> listForAdmin(Long academyId) { ... } // academyId null = 전체
@Transactional(readOnly = true)
public PracticeRoom getForAdmin(Long id) { ... } // 없으면 ROOM_NOT_FOUND
@Transactional
public PracticeRoom createForAdmin(PracticeRoomAdminRequest req) { ... }
@Transactional
public PracticeRoom updateForAdmin(Long id, PracticeRoomAdminRequest req) { ... }
@Transactional
public void deleteForAdmin(Long id) { ... }
요청 DTO — admin은 학원 지정 필수
public record PracticeRoomAdminRequest(
@NotNull(message = "학원은 필수입니다.") Long academyId,
@NotBlank(message = "연습실 이름은 필수입니다.") String name,
Integer capacity,
String instrument,
String status // AVAILABLE/IN_USE/MAINTENANCE
) {}
컨트롤러 — 본사 admin 엔드포인트 신설
@RestController
@RequestMapping("/api/v1/admin/practice-rooms")
@RequiredArgsConstructor
public class PracticeRoomAdminController {
private final PracticeRoomService roomService;
@GetMapping
public ApiResponse<List<PracticeRoom>> list(@RequestParam(required = false) Long academyId) {
return ApiResponse.ok(roomService.listForAdmin(academyId));
}
@GetMapping("/{id}") ... // 단건
@PostMapping ... // 생성
@PutMapping("/{id}") ... // 수정
@DeleteMapping("/{id}")...// 삭제
}
보안 설정상
/api/v1/admin/**는 SUPER_ADMIN / OPS_CS 역할이 필요(미인증 시 401). 새 경로도 별도 설정 없이 이 규칙에 자동 포함.
프론트 변경 (piano-academy/admin)
API 클라이언트 — api/practiceRooms.ts
export interface PracticeRoom {
id: number; academyId: number; name: string; capacity: number;
instrument?: string; status: string; academyName?: string;
}
export const getPracticeRooms = async (academyId?: number) => { /* GET, params 필터 */ };
export const createPracticeRoom = async (form) => { /* POST */ };
export const updatePracticeRoom = async (id, form) => { /* PUT */ };
export const deletePracticeRoom = async (id) => { /* DELETE */ };
페이지 — pages/PracticeRooms.tsx
- 학원 필터(Select), 목록 테이블(학원/연습실/수용인원/악기/상태/액션).
- 추가·수정 Dialog, 삭제 확인.
- 상태 코드 → 한글 라벨/색상 매핑:
AVAILABLE→ 사용가능(success)IN_USE→ 사용중(warning)MAINTENANCE→ 점검중(default)
- 데이터 로딩/에러/로딩 스피너 등 기존 페이지 패턴(Academies/Inquiries) 재사용.
라우트 — App.tsx
<Route path="practice-rooms" element={<PracticeRooms />} />
3. 검증 & 배포 (dev / EC2)
로컬 E2E
- 백엔드 컴파일 성공, admin
tsc --noEmit통과. - admin 로그인 토큰으로:
GET /admin/practice-rooms→ 200, 학원명 조인 정상.?academyId=2필터 → 200 빈 배열(해당 학원에 연습실 없음).- CREATE → UPDATE → DELETE 전 과정 200 (테스트 데이터 정리 완료).
dev 서버 배포 (EC2)
백엔드
./gradlew clean bootJar -x test로 jar 빌드.- EC2로 scp → 기존 jar 백업 → 교체 →
systemctl restart. - 운영 검증:
GET /api/v1/admin/practice-rooms→ 200.
admin 프론트
CI=false npm run build.- tar로 묶어 scp → nginx 디렉터리(
/var/www/piano/piano-admin) 백업 → 교체 →nginx -t→reload. - 검증: 정적 번들 200, 번들 내
practice-rooms라우트 포함 확인.
회귀 확인: 기존 trial-requests 엔드포인트도 200 정상.
DB 테이블(
practice_rooms)과 메뉴(id=21)는 이미 dev RDS에 존재 → 별도 DDL 적용 불필요.
4. 트러블슈팅 메모 (삽질 기록)
- 한글 경로 + scp 실패: 키 파일 경로에 한글(
공통/)이 포함되어 일부 셸 컨텍스트에서 scp가 조용히 실패(파일이 원격에 도착 안 함). → 글롭 패턴(*/piano-key.pem)으로 경로를 잡고, scp 직후 원격에서 파일 존재를 즉시 확인하는 습관으로 해결. - 빌드 후 작업 디렉터리 리셋: 프론트 빌드 등으로 셸 cwd가 바뀌면 상대 경로 키가 깨진다 → 절대경로/글롭 사용.
- macOS tar의 xattr 경고: 원격 추출 시
LIBARCHIVE.xattr.com.apple.provenance경고가 뜨지만 무해 →COPYFILE_DISABLE=1 tar --exclude='._*'로 최소화.
5. 오늘의 결론
- stale process 이슈는 "코드는 맞는데 동작이 다르다"의 단골 원인 — 실행 중 프로세스의 기동 시각/메인 클래스부터 확인.
- "메뉴는 보이는데 빈 화면" 패턴은 보통
메뉴 DB 등록 ↔ 라우트/페이지/ API 구현사이의 누락에서 발생. 동적 메뉴 구조에선 더 흔하다. - 기존 도메인(academy 스코프) 코드를 깨지 않으면서 본사(admin) 전용 메서드를 분리해 권한 모델을 분리한 게 핵심 설계 포인트.
변경 파일 요약
- 백엔드:
PracticeRoom.java,PracticeRoomMapper.java,PracticeRoomMapper.xml,PracticeRoomService.java,dto/PracticeRoomAdminRequest.java(신규),admin/room/PracticeRoomAdminController.java(신규) - 프론트(admin):
api/practiceRooms.ts(신규),pages/PracticeRooms.tsx(신규),App.tsx(라우트 추가)
댓글 0
- 첫 번째 댓글을 남겨보세요.