사이트픽 블로그
← 목록으로

개발일지 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(인증이 핸들러보다 먼저 동작) → 인증 레이어는 정상.

해결

  1. 스테일 백엔드 프로세스 종료 (단, SSH 터널 프로세스는 보존 — 로컬은 터널 127.0.0.1:3307 경유로 dev RDS에 접속).
  2. 새 코드로 백엔드 재기동.
  3. 검증: 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)

백엔드

  1. ./gradlew clean bootJar -x test 로 jar 빌드.
  2. EC2로 scp → 기존 jar 백업 → 교체 → systemctl restart.
  3. 운영 검증: GET /api/v1/admin/practice-rooms → 200.

admin 프론트

  1. CI=false npm run build.
  2. tar로 묶어 scp → nginx 디렉터리(/var/www/piano/piano-admin) 백업 → 교체 → nginx -t → reload.
  3. 검증: 정적 번들 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

  • 첫 번째 댓글을 남겨보세요.