개발일지 2026-08-04
음악학원 플랫폼 개발일지 (2026-08-04)
개요
와이어프레임(학원·04-1 강사 관리)에는 설계돼 있었지만 실제 앱에는 빠져 있던 "강사 비활성화 + 담당 수강생 재배정" 기능을 백엔드 API와 프론트 UI로 구현했다. 핵심은 담당(ENROLLED) 수강생이 있는 강사를 비활성화하려면 그 수강생 전원을 다른 강사에게 재배정해야만 한다는 것. 여기에 더해 배포 직전 "강사를 비활성화하면 그 계정으로 로그인이 막히나?"라는 물음에서 로그인 차단이 안 되는 구멍을 발견하고, 강사 비활성화 시 연결된 로그인 계정(users)도 함께 비활성화하도록 보강했다.
수정/생성 파일 목록
백엔드 (piano-backend) — commit a948a68
.../academy/teacher/dto/TeacherDeactivateRequest.java(신규) — 비활성화 요청 DTO(재배정 목록).../academy/teacher/AcademyTeacherController.java(수정) —PATCH /{id}/deactivate엔드포인트.../academy/teacher/AcademyTeacherService.java(수정) —deactivate()트랜잭션 로직(재배정 검증 + 상태 변경).../academy/teacher/AcademyTeacherMapper.java(수정) —updateStatus,reassignStudentTeacher.../resources/mapper/AcademyTeacherMapper.xml(수정) — 위 두 쿼리 XML.../user/UserMapper.java(수정) —updateStatus(로그인 계정 상태 변경).../resources/mapper/UserMapper.xml(수정) —updateStatusXML
프론트 (piano-academy / academy) — commit bdc39d7
academy/src/api/academyTeachers.ts(수정) —deactivateTeacher()API +TeacherReassignment타입academy/src/pages/TeacherDeactivateDialog.tsx(신규) — 비활성화 + 재배정 팝업academy/src/pages/Teachers.tsx(수정) — 목록 행 [비활성] 버튼 + 다이얼로그 연결
프론트 (piano-academy / brand) — commit 0d16e89
brand/src/pages/Locations.tsx(수정) — 지점찾기 드롭다운 라벨 테두리 겹침 수정(이전 작업분)
파일별 상세
1) DTO — TeacherDeactivateRequest.java
재배정 목록만 담는 단순 record. 담당 수강생이 없을 때를 위해 리스트 자체는 null/빈 값 허용.
public record TeacherDeactivateRequest(
List<Reassignment> reassignments
) {
public record Reassignment(
@NotNull(message = "수강생 ID는 필수입니다.") Long studentId,
@NotNull(message = "재배정 강사 ID는 필수입니다.") Long newTeacherId
) {}
}
2) 컨트롤러 — PATCH /api/v1/academy/teachers/{id}/deactivate
기존 강사 API와 동일하게 AcademyScope.require(principal)로 학원 스코프를 확보한다.
@PatchMapping("/{id}/deactivate")
public ApiResponse<Void> deactivate(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable Long id,
@Valid @RequestBody TeacherDeactivateRequest request) {
Long academyId = AcademyScope.require(principal);
service.deactivate(academyId, id, request);
return ApiResponse.ok();
}
3) 서비스 — deactivate() (핵심)
원자성을 위해 재배정 + 상태 변경을 한 트랜잭션으로 처리한다. 프론트에서 넘어온 재배정 요청을 그대로 믿지 않고 서버에서 담당 수강생 목록을 다시 조회해 누락/유효성을 재검증한다.
@Transactional
public void deactivate(Long academyId, Long teacherId, TeacherDeactivateRequest req) {
Teacher teacher = teacherMapper.findById(teacherId);
if (teacher == null) throw new BusinessException(ErrorCode.NOT_FOUND, "강사를 찾을 수 없습니다.");
AcademyScope.verifySameAcademy(academyId, teacher.getAcademyId());
if (Boolean.TRUE.equals(teacher.getOwner()))
throw new BusinessException(ErrorCode.FORBIDDEN, "원장 겸임 강사는 비활성화할 수 없습니다.");
if ("INACTIVE".equals(teacher.getStatus()))
throw new BusinessException(ErrorCode.BAD_REQUEST, "이미 비활성화된 강사입니다.");
// 서버측 재검증: 실제 담당(ENROLLED) 수강생 목록
List<Long> enrolledStudentIds = teacherMapper.findStudentsByTeacherId(teacherId).stream()
.filter(s -> "ENROLLED".equals(s.getStatus()))
.map(Student::getId).collect(Collectors.toList());
// 요청 재배정 매핑
Map<Long, Long> reassignMap = new HashMap<>();
if (req != null && req.reassignments() != null)
for (var r : req.reassignments())
if (r.studentId() != null && r.newTeacherId() != null)
reassignMap.put(r.studentId(), r.newTeacherId());
if (!enrolledStudentIds.isEmpty()) {
// 담당 수강생 전원이 재배정 대상에 포함돼야 함
if (!enrolledStudentIds.stream().allMatch(reassignMap::containsKey))
throw new BusinessException(ErrorCode.INVALID_INPUT, "재배정되지 않은 담당 수강생이 있습니다.");
// 이관 대상 강사는 같은 학원의 ACTIVE 강사여야 하고, 본인 제외
Map<Long, Teacher> activeTeachers = teacherMapper.findByAcademy(academyId, null, "ACTIVE").stream()
.collect(Collectors.toMap(Teacher::getId, t -> t, (a, b) -> a));
for (Long studentId : enrolledStudentIds) {
Long newTeacherId = reassignMap.get(studentId);
if (newTeacherId.equals(teacherId))
throw new BusinessException(ErrorCode.INVALID_INPUT, "본인에게 재배정할 수 없습니다.");
if (!activeTeachers.containsKey(newTeacherId))
throw new BusinessException(ErrorCode.INVALID_INPUT, "재배정 대상 강사가 유효하지 않습니다.");
teacherMapper.reassignStudentTeacher(studentId, newTeacherId, academyId);
}
}
teacherMapper.updateStatus(teacherId, "INACTIVE");
// 연결된 로그인 계정도 비활성화하여 로그인을 차단한다(계정관리 비활성화와 동일 동작).
if (teacher.getUserId() != null) {
userMapper.updateStatus(teacher.getUserId(), "INACTIVE");
}
}
포인트:
- 원장 겸임(owner) 강사는 비활성 금지 — 원장 로그인 계정이 같이 죽는 사고 방지.
- 재배정은 프론트 값 신뢰 X → 서버에서 담당 목록 재조회 후 전원 커버 여부 확인.
- 이관 대상은 같은 학원 + ACTIVE + 본인 아님만 허용.
- 재배정 UPDATE에
academy_id조건을 걸어 다른 학원 학생을 건드리지 못하게 방어.
4) 매퍼
// AcademyTeacherMapper.java
int updateStatus(@Param("id") Long id, @Param("status") String status);
int reassignStudentTeacher(@Param("studentId") Long studentId,
@Param("teacherId") Long teacherId,
@Param("academyId") Long academyId);
<!-- AcademyTeacherMapper.xml -->
<update id="updateStatus">UPDATE teachers SET status = #{status} WHERE id = #{id}</update>
<update id="reassignStudentTeacher">
UPDATE students SET teacher_id = #{teacherId}
WHERE id = #{studentId} AND academy_id = #{academyId}
</update>
기존 StudentMapper.update는 전체 필드를 갱신하는 무거운 쿼리라, 담당 강사만 바꾸는 가벼운 전용 UPDATE를 따로 만들었다.
5) 로그인 차단을 위한 UserMapper.updateStatus
로그인/리프레시는 users.status == 'ACTIVE'(user.isActive())를 체크한다. 강사 비활성화가 이 계정 상태를 건드리지 않으면 비활성 강사가 계속 로그인 가능해진다. 그래서 계정 상태만 바꾸는 얇은 메서드를 추가.
// UserMapper.java
int updateStatus(@Param("id") Long id, @Param("status") String status);
<!-- UserMapper.xml -->
<update id="updateStatus">UPDATE users SET status = #{status} WHERE id = #{id}</update>
6) 프론트 API — academyTeachers.ts
export interface TeacherReassignment { studentId: number; newTeacherId: number; }
export const deactivateTeacher = async (id: number, reassignments: TeacherReassignment[]): Promise<void> => {
await client.patch(`/academy/teachers/${id}/deactivate`, { reassignments });
};
담당 수강생 목록은 기존 getTeacher(id) 응답의 students를 재사용하고, ENROLLED 필터는 화면단에서 처리.
7) 재배정 팝업 — TeacherDeactivateDialog.tsx (신규)
MUI Dialog. 열릴 때 getTeacher(id)(담당 수강생)와 getTeachers(undefined, 'ACTIVE')(이관 대상, 자기 자신 제외)를 병렬 로드한다.
- 일괄 이관:
AppSelect로 강사 하나 고르고 "전체 적용" → 모든 행에 일괄 세팅. - 개별 지정: 수강생 행마다 담당 강사
AppSelect. - 모든 담당 수강생에 강사가 지정되기 전엔 [비활성화] 버튼 disabled(
allAssigned). - 담당 수강생 0명이면 안내만 띄우고 바로 비활성화 가능.
const allAssigned = useMemo(
() => students.length === 0 || students.every((s) => !!assignMap[s.id]),
[students, assignMap],
);
AppSelect는 common/index.ts에 re-export가 안 돼 있어서 직접 default import(../components/common/AppSelect)로 가져와야 했다.
8) 목록 화면 — Teachers.tsx
테이블에 "관리" 컬럼(우측 정렬)을 추가하고, 각 행에 [비활성] 버튼을 넣었다. 이미 비활성이거나 원장 겸임 강사면 버튼을 숨긴다.
<TableCell align="right">
{t.status === 'ACTIVE' && !t.owner && (
<Button size="small" color="error" variant="outlined"
onClick={() => { setDeactivateTarget(t); setDeactivateOpen(true); }}>비활성</Button>
)}
</TableCell>
빈 목록 행의 colSpan={6} → {7}로 조정하고, 다이얼로그 종료 콜백에서 토스트 + 목록 새로고침.
문제 정의 / 원인 분석 (로그인 차단 구멍)
배포 직전, "계정관리에서 비활성화하면 로그인 안 되게 한 거 맞지?"라는 물음이 트리거였다.
- 로그인 로직:
AuthService.login()은 비밀번호 일치 후user.isActive()(=users.status == 'ACTIVE')를 검사, 아니면ACCOUNT_INACTIVE(403). - 내가 만든 강사 비활성화:
teachers.status = 'INACTIVE'만 바꾸고users는 손대지 않았다. - 결론: 강사를 "비활성"으로 만들어도 그 사람은 여전히 로그인 가능. 즉 UI상 비활성인데 실제 접근 차단은 안 되는 반쪽짜리 상태.
→ 확인 후 강사 비활성화 시 연결된 users 계정도 INACTIVE로 함께 변경하도록 서비스에 한 줄(+매퍼)을 보강. 계정관리 비활성화와 동일한 로그인 차단 동작을 갖게 됨.
검증 & 배포
- 백엔드
./gradlew compileJavaclean, academy 프론트npm run buildclean. - 백엔드(ECR 도커 이미지
piano-api:248072a-tdeact): ALB 뒤 EC2 2대에 무중단 Rolling — 한 대씩 TG에서 빼고(deregister→draining) 컨테이너 교체 후 다시 넣기(register→healthy). 도중 EC2 #2가 stopped 상태여서 boto3로 start 후 배포. 두 대 모두Started PianoApiApplication+ Hikari 정상, 로컬 검증 시크릿 헤더 O=200/X=403. - E2E(CloudFront 경유):
GET /api/v1/public/ping= 200,GET /api/v1/auth/me= 401(정상). - 프론트(academy): env.local 게이트 + dev URL 스캔 통과 → S3
mforet-frontend-873961272731의academy/prefix 업로드(index.html no-cache / assets immutable) → CloudFront 무효화/academy/*Completed(~18s). 번들index-DPkSi39n.js,/academy/dashboard= 200. - DB 스키마 변경 없음(기존
teachers.status,students.teacher_id,users.status재사용) → 마이그레이션 불필요.
트러블슈팅 메모 (삽질 기록)
- AppSelect import 실패:
common/index.ts에 re-export가 없어 named import가 안 됨 → default 직접 import로 해결. - EC2 #2 SSH 타임아웃: 알고 보니 인스턴스가 stopped. boto3로 start + running 대기 후 SSH 재접속해 배포. (퍼블릭 IP는 유지됨.)
[ -f ] && ... || ...한 줄 체크가 zsh에서 빌드를 단락시킨 문제: env.local 게이트 라인이 종료코드를 물고 늘어져 빌드를 건너뜀 →npm run build를 단독 실행해 해결.- 로그인 차단 누락(가장 중요): "비활성" 상태와 "로그인 가능 여부"가 각각 다른 테이블(
teachersvsusers)에 있다는 걸 놓쳐서, 상태만 바꾸고 접근은 못 막는 반쪽 구현이 될 뻔했다. 사용자의 한 마디 질문이 이 갭을 찾아줬다.
결론 / 배운 점
- "비활성화"는 표시 상태가 아니라 로그인/접근 차단까지 포함해야 완성이다. 상태 컬럼이 여러 테이블에 흩어져 있을 때 특히 조심.
- 재배정처럼 다건이 원자적으로 함께 성공/실패해야 하는 작업은 개별 PUT 다건 호출이 아니라 단일 트랜잭션 엔드포인트로 묶는 게 안전하다.
- 프론트에서 넘어온 목록은 믿지 말고 서버에서 재조회 + 재검증(누락/스코프/유효성)하는 게 방어의 기본.
- 무중단 Rolling 배포 중 인스턴스가 꺼져 있을 수 있다 — SSH 실패 시 상태부터 확인.
댓글 0
- 첫 번째 댓글을 남겨보세요.