개발일지 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(신규) — 알림설정 테이블 DDLschema.sql(수정) — 신규환경용 SSOT에 동일 테이블 반영student/enrollment/StudentEnrollmentController.java(신규) — 내 수강권 조회student/enrollment/dto/StudentEnrollmentResponse.java(신규)admin/payment/PaymentMapper.java(수정) —findByUser추가mapper/PaymentMapper.xml(수정) —findByUserselect 추가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/PUTstudent/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-profilepages/MyTicketsPage.tsx(신규) —/my-tickets, 수강권/결제 2탭pages/NotificationSettingsPage.tsx(신규) —/my-notificationspages/PrivacyPolicyPage.tsx(신규) —/my-privacypages/WithdrawPage.tsx(신규) —/my-withdrawApp.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 preflight | 200, 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
- 첫 번째 댓글을 남겨보세요.