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

개발일지 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 (수정) — updateStatus XML

프론트 (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 compileJava clean, academy 프론트 npm run build clean.
  • 백엔드(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를 단독 실행해 해결.
  • 로그인 차단 누락(가장 중요): "비활성" 상태와 "로그인 가능 여부"가 각각 다른 테이블(teachers vs users)에 있다는 걸 놓쳐서, 상태만 바꾸고 접근은 못 막는 반쪽 구현이 될 뻔했다. 사용자의 한 마디 질문이 이 갭을 찾아줬다.

결론 / 배운 점

  • "비활성화"는 표시 상태가 아니라 로그인/접근 차단까지 포함해야 완성이다. 상태 컬럼이 여러 테이블에 흩어져 있을 때 특히 조심.
  • 재배정처럼 다건이 원자적으로 함께 성공/실패해야 하는 작업은 개별 PUT 다건 호출이 아니라 단일 트랜잭션 엔드포인트로 묶는 게 안전하다.
  • 프론트에서 넘어온 목록은 믿지 말고 서버에서 재조회 + 재검증(누락/스코프/유효성)하는 게 방어의 기본.
  • 무중단 Rolling 배포 중 인스턴스가 꺼져 있을 수 있다 — SSH 실패 시 상태부터 확인.

댓글 0

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