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

개발일지 2026-07-08

개발일지 2026-07-08

개요

수강생 앱(student) 마이페이지의 "dead link" 8개를 전수 조사해 전부 실연동했다. 클릭하면 네비게이션만 걸려 있고 실제 화면·라우트·백엔드가 없어서 빈 화면으로 빠지던 메뉴들을, 실제 화면 + 라우트 + (필요 시) 백엔드 API 까지 붙여 정상 동작시켰다. 백엔드는 신규 엔드포인트 4종을 만들고 blue-green 배포, 프론트는 화면 5종을 만들어 EC2에 배포했다.

Dead Link 전수 조사 결과

메뉴네비 경로처리
회원정보 수정/my-profile신규 화면 (기존 API 재사용)
내 수강권/my-tickets신규 화면 + 백엔드 (수강권/결제 2탭)
알림 설정/my-notifications신규 화면 + 백엔드 (신규 테이블)
개인정보 처리방침/my-privacy신규 화면 (기존 약관 API)
회원 탈퇴/my-withdraw신규 화면 + 백엔드 (비번확인 soft delete)
결제 내역(수강권 탭)수강권 화면 탭으로 통합
고객센터·1:1 문의/my-support이미 연동돼 있어 작업 불필요

조사 과정에서 SupportPage.tsx(고객센터)는 이미 /student/qnas 로 실연동돼 있었고, commonCodes.ts 에도 paymentStatus/paymentMethod 코드그룹이 이미 존재해서 그만큼 작업이 줄었다. "일단 다 만들자" 전에 기존 구현부터 확인하는 게 중요하다는 걸 다시 느꼈다.

수정/생성 파일 목록

백엔드 (piano-backend)

  • migration-20260707d-notification-settings.sql (신규) — 알림설정 테이블 DDL
  • schema.sql (수정) — 신규환경용 SSOT에 동일 테이블 반영
  • student/enrollment/StudentEnrollmentController.java (신규) — 내 수강권 조회
  • student/enrollment/dto/StudentEnrollmentResponse.java (신규)
  • admin/payment/PaymentMapper.java (수정) — findByUser 추가
  • mapper/PaymentMapper.xml (수정) — findByUser select 추가
  • student/payment/StudentPaymentController.java (신규) — 내 결제 내역
  • student/payment/dto/StudentPaymentResponse.java (신규)
  • student/notification/NotificationSettings.java (신규, Entity)
  • student/notification/NotificationSettingsMapper.java (신규) + mapper/NotificationSettingsMapper.xml (신규)
  • student/notification/NotificationSettingsService.java (신규)
  • student/notification/StudentNotificationController.java (신규) — GET/PUT
  • student/notification/dto/NotificationSettingsDto.java (신규)
  • student/profile/StudentWithdrawController.java (신규) — 회원 탈퇴
  • student/profile/dto/WithdrawRequest.java (신규)

프론트 (student)

  • api/enrollments.ts (신규) — getMyEnrollments()
  • api/payments.ts (신규) — getMyPayments()
  • api/notifications.ts (신규) — getNotificationSettings() / updateNotificationSettings()
  • api/auth.ts (수정) — withdraw(password) 추가
  • pages/ProfileEditPage.tsx (신규) — /my-profile
  • pages/MyTicketsPage.tsx (신규) — /my-tickets, 수강권/결제 2탭
  • pages/NotificationSettingsPage.tsx (신규) — /my-notifications
  • pages/PrivacyPolicyPage.tsx (신규) — /my-privacy
  • pages/WithdrawPage.tsx (신규) — /my-withdraw
  • App.tsx (수정) — 라우트 5개 추가
  • pages/MyPage.tsx (수정) — 회원 탈퇴 onClick 연결
  • pages/SupportPage.tsx (수정) — 문의 수신 대상(앱관리자/학원) 선택 UI 추가

파일별 상세

1. 알림 설정 — 신규 테이블부터

알림 설정은 저장할 테이블이 없어서 새로 만들었다. 멱등하게 CREATE TABLE IF NOT EXISTS 로 작성.

CREATE TABLE IF NOT EXISTS user_notification_settings (
    user_id         BIGINT      NOT NULL,
    lesson_reminder TINYINT(1)  NOT NULL DEFAULT 1,
    ticket_expiry   TINYINT(1)  NOT NULL DEFAULT 1,
    notice          TINYINT(1)  NOT NULL DEFAULT 1,
    created_at      DATETIME    NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at      DATETIME    NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (user_id)
);

매퍼는 조회 + upsert 두 개. INSERT ... ON DUPLICATE KEY UPDATE 로 한 번에 처리해서 "없으면 insert, 있으면 update" 를 단순화했다.

<insert id="upsert">
    INSERT INTO user_notification_settings (user_id, lesson_reminder, ticket_expiry, notice)
    VALUES (#{userId}, #{lessonReminder}, #{ticketExpiry}, #{notice})
    ON DUPLICATE KEY UPDATE
        lesson_reminder = #{lessonReminder},
        ticket_expiry   = #{ticketExpiry},
        notice          = #{notice}
</insert>

서비스는 조회 시 레코드가 없으면 기본값(전부 ON) 을 돌려준다. 저장 안 한 사용자도 자연스럽게 "다 켜짐" 으로 보인다.

2. 내 수강권 / 결제 내역 — 기존 매퍼 재사용

수강권은 이미 EnrollmentMapper.findByAcademy(academyId, studentId) 가 있어서(강사명/코스명/가격 조인 포함) student 스코프 컨트롤러만 새로 얹었다. StudentScope.requireUserId(principal) → StudentMapper.findByUserId(userId) 로 academyId/studentId 를 resolve 하는 흐름.

@GetMapping
public ApiResponse<List<StudentEnrollmentResponse>> myEnrollments(
        @AuthenticationPrincipal UserPrincipal principal) {
    Long userId = StudentScope.requireUserId(principal);
    Student student = studentMapper.findByUserId(userId);
    if (student == null) return ApiResponse.ok(Collections.emptyList());
    List<StudentEnrollmentResponse> result = enrollmentMapper
        .findByAcademy(student.getAcademyId(), student.getId())
        .stream().map(StudentEnrollmentResponse::from).toList();
    return ApiResponse.ok(result);
}

결제는 Payment 에 user_id 컬럼이 있어서 PaymentMapper.findByUser(userId) 만 추가했다.

<select id="findByUser" resultMap="paymentMap">
    SELECT * FROM payments WHERE user_id = #{userId} ORDER BY paid_at DESC, id DESC
</select>

프론트에선 이 둘을 MyTicketsPage 한 화면의 2탭으로 합쳤다. 결제 상태/수단 라벨은 이미 있던 getCodeLabel('paymentStatus'|'paymentMethod', code) 로 표시.

useEffect(() => {
  Promise.all([getMyEnrollments(), getMyPayments()])
    .then(([e, p]) => { setEnrollments(e); setPayments(p); })
    .catch(() => {})
    .finally(() => setLoading(false));
}, []);

3. 회원 탈퇴 — soft delete + 비밀번호 재확인

하드 삭제는 위험하니 status='INACTIVE' 로 비활성화(soft delete) 하고, 그 전에 비밀번호를 다시 확인하게 했다. 비번 검증은 기존 로그인에서 쓰는 passwordEncoder.matches 재사용.

@PostMapping
public ApiResponse<Void> withdraw(@AuthenticationPrincipal UserPrincipal principal,
                                  @Valid @RequestBody WithdrawRequest request) {
    Long userId = StudentScope.requireUserId(principal);
    User user = userMapper.findById(userId);
    if (user == null) throw new BusinessException(ErrorCode.USER_NOT_FOUND);
    if (!passwordEncoder.matches(request.password(), user.getPassword()))
        throw new BusinessException(ErrorCode.PAYMENT_METHOD_PASSWORD_MISMATCH, "비밀번호가 일치하지 않습니다.");
    user.setStatus("INACTIVE");
    userMapper.update(user);
    return ApiResponse.ok();
}

로그인 로직이 이미 INACTIVE 계정을 ErrorCode.ACCOUNT_INACTIVE 로 거부하고 있어서, 탈퇴 후 재로그인은 자동으로 막힌다. 프론트는 useAppAlert 이 알림 전용(confirm 없음)이라 탈퇴 확인은 로컬 MUI Dialog 로 한 번 더 물어보게 했고, 성공하면 logout() → /login.

4. 화면 헤더/스타일 — 기존 패턴 재사용

새 화면 5개 모두 PhoneChangePage/ConsultPage 패턴을 그대로 따랐다. ArrowBack IconButton + 타이틀, 완료/취소 시 navigate('/mypage'), 저장 버튼은 다크 스타일(bgcolor: '#1a1a2e').

검증 & 배포

DB 마이그레이션 (db-migrate 스킬)

RDS는 sql.init.mode=never 라 마이그레이션이 자동 실행되지 않는다. SSH 터널(localhost:3306 → RDS)을 열고 db-migrate 러너로 migration-20260707d 만 신규 적용, schema_migrations 에 이력 기록. user_notification_settings 컬럼 생성 확인.

백엔드 blue-green 배포에서 삽질

./gradlew clean bootJar -x test 로 빌드는 성공(BUILD SUCCESSFUL). jar 를 /tmp/piano-api-new.jar 로 올리고 sudo bash /opt/piano/deploy.sh 를 백그라운드로 돌렸는데 여기서 막혔다.

증상:

  • HTTPS ping 은 200 인데 /student/enrollments 는 504
  • 8081 포트로 직접 curl 하면 신규 엔드포인트가 000(타임아웃)
  • systemctl show 의 ExecMainStartTimestamp 는 21:30 인데 jar 는 22:03 에 복사됨 → 돌고 있는 JVM 이 옛날 바이트코드

원인: deploy.sh 안의 systemctl restart piano-api@8081 스텝이 백그라운드 SSH 로 실행되면서 hang. md5sum 으로 디스크의 jar 는 새 빌드가 맞다는 걸 확인한 뒤, 멈춘 태스크를 죽이고 활성 서비스를 직접 재시작(sudo systemctl restart piano-api@8081)했다. 이후 health 폴링 OK, 4개 신규 엔드포인트 전부 401(인증 보호, 정상) 응답 확인.

교훈: 백그라운드 SSH 로 도는 deploy 스크립트는 hang 할 수 있다. 배포 후엔 반드시 엔드포인트 스모크(401 이지 404/504 아님)로 실제 반영을 검증하고, 필요하면 활성 systemd 서비스를 수동 재시작한다.

프론트 배포

vite build → build/ 를 tar 로 묶어 EC2 로 scp → /var/www/piano/student/ 교체 → nginx 소유권. https://mforet.kr/student/ HTTP 200 + 번들 해시 교체 확인.

트러블슈팅 메모 (삽질 기록)

  • Edit "File has not been read yet": 세션 중 파일을 읽었어도 Edit 직전에 다시 Read 를 요구하는 경우가 있었다. 그냥 해당 구간을 다시 Read 하고 Edit.
  • 이미 있는 걸 또 만들 뻔함: SupportPage(고객센터), commonCodes 의 결제 코드그룹이 이미 존재. 계획 단계에서 기존 구현을 먼저 훑었으면 더 빨랐다.

추가 작업 — 악보 프론트(sheets) 배포 + 로그인 실연동

수강생 마이페이지 작업과 별개로, 악보실 프론트(sheets, Next.js SSG)를 운영에 올리고 로그인만 실제 백엔드 API로 연동했다.

1. sheets 프론트 첫 배포 (/sheets)

sheets 는 output: 'export' 정적 사이트라 빌드 산출물(out/)을 nginx 정적 루트에 얹으면 된다. basePath: '/sheets' 를 baked 한 빌드를 tar → scp → EC2 /var/www/piano/sheets/ 로 전개하고, nginx conf 에 location 을 추가했다.

location /sheets {
    try_files $uri $uri/ /sheets/index.html;
}

/student 블록 뒤에 삽입, nginx -t 통과 후 reload. 홈/CSS/카테고리/상세/사이트맵 전부 200, 기존 /·/admin 무회귀 확인.

2. 로그인만 실연동 — AUTH_LIVE 별도 스위치

요구사항은 "악보는 아직 mock 이어도 되지만, 로그인은 원래 우리 백엔드(/api/v1/auth/login)를 타게" 였다. 문제는 정적 export 라 NEXT_PUBLIC_* env 가 빌드타임에 인라인된다는 것. 그래서 악보용 USE_MOCK 과 완전히 독립된 인증 전용 스위치를 새로 뒀다.

// client.ts — 악보(USE_MOCK)와 별개. AUTH_LIVE=true 면 로그인만 실API.
export const AUTH_LIVE = (process.env.NEXT_PUBLIC_AUTH_LIVE ?? 'false') === 'true';

auth.ts 는 이 플래그로 분기한다. live 면 실제 백엔드 호출 + 토큰 저장, mock 이면 데모 토큰.

export async function login(req: LoginRequest): Promise<TokenResponse> {
  if (AUTH_LIVE) {
    const res = await apiRequest<TokenResponse>('/auth/login', { method: 'POST', body: req });
    setAccessToken(res.accessToken); setRefreshToken(res.refreshToken); return res;
  }
  // ... 데모 토큰 발급 (mock)
}

백엔드 스펙 확인 중 알게 된 점: 서비스 사용자용 /auth/logout 이 없어서(admin 전용만 존재) 로그아웃은 클라이언트에서 토큰 clear 만 하도록 했다. 세션 복원은 부팅 시 GET /auth/me 로 처리.

3. 모달 로그인 UI + 세션 복원

헤더 "로그인" 클릭 → 이메일/비밀번호 모달(LoginModal.tsx, 기존 preview-modal·.field CSS 재사용). 제출 시 useAuth().login(email, password) → 성공하면 getMe() 로 유저 정보 저장. AUTH_LIVE=false 면 "데모 모드" 안내문을 띄우되 동일 폼으로 동작. AuthContext 는 login 시그니처를 () => void 에서 (email, password) => Promise<void> 로 바꾸고 모달 상태(loginModalOpen)를 추가했다.

4. 실연동 모드 재배포 & 검증

NEXT_PUBLIC_AUTH_LIVE=true NEXT_PUBLIC_API_BASE_URL=https://mforet.kr npm run build (Node 20.20.2) 로 재빌드해 EC2 재배치. 검증 결과:

항목결과
번들에 /auth/login·/auth/me 인라인O (layout/library/print 청크)
https://mforet.kr + api/v1 인라인O (공유 청크 234)
실제 로그인 API (오답 자격증명)401 LOGIN_FAILED
CORS preflight200, ACAO https://mforet.kr, Allow-Credentials true

삽질 메모: 초기 스모크 테스트에서 "auth/login 청크 없음(no)" 이 떴는데, 홈 페이지 초기 <script> 태그만 스캔한 오탐이었다. auth 코드는 code-split 되어 layout/library/print 청크에 들어가 있었고, EC2 직접 grep 으로 존재를 확인했다. 정적 사이트 검증은 초기 청크만 보면 안 되고 서빙되는 청크 전체를 봐야 한다.

결론 / 배운 점

  • 마이페이지 미연동 메뉴(dead link)를 백엔드까지 포함해 전부 실동작시켰다. 신규 백엔드 4종(수강권/결제/알림설정/탈퇴)은 기존 매퍼·스코프·에러코드를 최대한 재사용해서 신규 코드를 최소화했다.
  • 회원 탈퇴 같은 비가역 동작은 soft delete + 재인증(비번확인)으로 안전장치를 뒀다.
  • blue-green 배포가 hang 하는 상황에선 "빌드 성공 = 반영 완료" 가 아니다. JVM 시작 시각 vs jar mtime 비교, md5sum, 엔드포인트 스모크로 실제 반영을 검증하는 습관이 필요하다.
  • 악보 프론트(sheets)를 운영 배포하고 로그인만 실API 로 붙였다. 정적 export 는 env 가 빌드타임 고정이라, 악보(mock)와 로그인(live)을 독립적으로 켜려면 별도 빌드 플래그(AUTH_LIVE)가 필요했다.

댓글 0

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