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

개발일지 2026-08-12

M forêt 개발일지 (2026-08-12)

개요

오늘은 두 가지 작업을 진행했다.

  1. 범용 인앱 알림(Notification) 시스템을 새로 구축하고, 그 첫 발송 기능으로 "미작성 레슨 일지 작성 요청" 을 붙였다. 원장이 학원 레슨 일지 화면의 "미작성" 탭에서 버튼 한 번으로, 아직 일지를 안 쓴 강사 전원에게 앱 안에서 확인하는 알림(읽음/안읽음 관리 포함)을 보낼 수 있다.
  2. 관리자 추가 시 이메일 중복 에러 문구 개선. 그동안 중복 이메일로 저장 실패하면 "저장에 실패했습니다."만 떠서 원인을 알 수 없었는데, 서버가 이미 내려주던 상황별 메시지("이미 사용 중인 이메일입니다.")를 그대로 노출하도록 고쳤다.

수정/생성 파일 목록

백엔드 (piano-backend)

  • src/main/resources/migration-20260812b-notifications.sql (신규) — 범용 notifications 테이블 DDL
  • src/main/java/com/piano/api/notification/Notification.java (신규) — 알림 DTO
  • src/main/java/com/piano/api/notification/NotificationMapper.java (신규) — 매퍼 인터페이스
  • src/main/resources/mapper/NotificationMapper.xml (신규) — 매퍼 XML
  • src/main/java/com/piano/api/notification/NotificationService.java (신규) — 알림 서비스(발송 + 수신함)
  • src/main/java/com/piano/api/notification/NotificationController.java (신규) — /api/v1/notifications 공용 엔드포인트
  • src/main/java/com/piano/api/academy/lesson/LessonJournalMapper.java (수정) — 미작성 강사 user_id 조회 메서드 추가
  • src/main/resources/mapper/LessonJournalMapper.xml (수정) — 위 조회 쿼리 추가
  • src/main/java/com/piano/api/academy/lesson/LessonJournalService.java (수정) — 작성 요청 발송 로직 추가
  • src/main/java/com/piano/api/academy/lesson/LessonJournalController.java (수정) — POST /write-request 엔드포인트 추가

프론트 (academy)

  • src/api/notifications.ts (신규) — 알림 API 클라이언트(수신함/읽음/발송)
  • src/pages/LessonJournals.tsx (수정) — "전체 작성 요청 발송" 버튼(미작성 탭 전용)
  • src/components/AppBarHeader.tsx (수정) — 상단 종 아이콘 실동작(안읽음 배지 + 드롭다운 + 읽음 처리)

프론트 (admin)

  • src/components/UserManagement.tsx (수정) — 저장 실패 시 서버 메시지(이메일 중복 등) 노출

파일별 상세

1. 알림 테이블 설계 — 왜 (user_type, recipient_id) 인가

가장 먼저 부딪힌 건 "누구에게 보낼 것인가" 의 데이터 모델 문제였다. 이 플랫폼은 사용자가 두 종류의 테이블에 나뉘어 산다.

  • 본사 관리자(SUPER_ADMIN / OPS_CS)는 별도의 admin_users 테이블에 있고 academy_id 가 없다.
  • 서비스 유저(OWNER / TEACHER / STUDENT)는 users 테이블에 있고 academy_id 가 NOT NULL 이다.

즉 글로벌하게 유일한 user_id 하나로 수신자를 못 잡는다. 그래서 수신자를 (user_type, recipient_id) 조합으로 식별하도록 설계했다.

  • user_type = 'ADMIN' → recipient_id = admin_users.id (academy_id NULL)
  • user_type = 'SERVICE' → recipient_id = users.id (academy_id 있음)
CREATE TABLE IF NOT EXISTS notifications (
    id            BIGINT       NOT NULL AUTO_INCREMENT,
    user_type     VARCHAR(20)  NOT NULL,       -- 'ADMIN' | 'SERVICE'
    recipient_id  BIGINT       NOT NULL,       -- admin_users.id 또는 users.id
    academy_id    BIGINT       NULL,           -- SERVICE 는 값, ADMIN 은 NULL
    type_code     VARCHAR(50)  NOT NULL,       -- 'JOURNAL_WRITE_REQUEST' 등
    title         VARCHAR(200) NOT NULL,
    content       VARCHAR(1000) NULL,
    link_path     VARCHAR(200) NULL,           -- 클릭 시 이동 경로
    is_read       TINYINT(1)   NOT NULL DEFAULT 0,
    read_at       DATETIME     NULL,
    created_at    DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    INDEX idx_notif_recipient (user_type, recipient_id, is_read, created_at)
);

인덱스는 "특정 유저의 안읽음 알림을 최신순으로" 뽑는 쿼리에 맞춰 (user_type, recipient_id, is_read, created_at) 로 잡았다. 이번 발송은 강사(SERVICE) 대상이지만, 테이블 자체는 관리자/원장/강사/학생 전부를 수용하는 범용 인프라로 만들었다.

2. Notification DTO / Mapper / XML

DTO 는 Lombok @Getter @Setter @NoArgsConstructor + @Builder 조합. is_read/read_at 등 컬럼과 매핑되는 필드를 그대로 뒀다.

매퍼는 발송용 bulkInsert 와 수신함용 4개 메서드로 구성:

@Mapper
public interface NotificationMapper {
    int bulkInsert(@Param("list") List<Notification> list);
    List<Notification> findByRecipient(@Param("userType") String userType, @Param("recipientId") Long recipientId);
    long countUnread(@Param("userType") String userType, @Param("recipientId") Long recipientId);
    int markRead(@Param("userType") String userType, @Param("recipientId") Long recipientId, @Param("id") Long id);
    int markAllRead(@Param("userType") String userType, @Param("recipientId") Long recipientId);
}

XML 은 <foreach> 로 한 방에 여러 수신자를 insert 한다.

<insert id="bulkInsert">
    INSERT INTO notifications
        (user_type, recipient_id, academy_id, type_code, title, content, link_path)
    VALUES
    <foreach collection="list" item="n" separator=",">
        (#{n.userType}, #{n.recipientId}, #{n.academyId}, #{n.typeCode},
         #{n.title}, #{n.content}, #{n.linkPath})
    </foreach>
</insert>

읽음 처리(markRead/markAllRead)는 SET is_read = 1, read_at = NOW() 에 AND is_read = 0 조건을 걸어 이미 읽은 건 건드리지 않게 했다. 또한 조회/읽음 쿼리 모두 user_type/recipient_id 로 스코프를 걸어 본인 알림만 다루도록 했다(교차 유저 노출 방지).

3. NotificationService — 발송 헬퍼 + 본인 수신함

notify(List<Notification>) 는 다른 도메인에서도 재사용할 범용 발송 헬퍼다. 조회/읽음 메서드는 로그인 유저(UserPrincipal)를 받아 (userType, userId) 로 자기 것만 처리한다.

public int notify(List<Notification> notifications) {
    if (notifications == null || notifications.isEmpty()) return 0;
    mapper.bulkInsert(notifications);
    return notifications.size();
}

private String userType(UserPrincipal principal) {
    UserType type = principal.getUserType();
    return type != null ? type.name() : UserType.SERVICE.name();
}

4. NotificationController — 앱 공용 엔드포인트

academy/admin/student 모든 앱이 호출하는 공용 컨트롤러. @AuthenticationPrincipal 로 로그인 유저를 받는다.

@RestController
@RequestMapping("/api/v1/notifications")
public class NotificationController {
    @GetMapping("/me")               // 내 알림 목록
    @GetMapping("/me/unread-count")  // 내 안읽음 개수
    @PatchMapping("/{id}/read")      // 1건 읽음
    @PatchMapping("/me/read-all")    // 전체 읽음
}

5. 미작성 강사 조회 — 화면 목록과 동일 조건 재사용

"미작성 일지가 있는 강사"의 판정 기준은 이미 레슨 일지 목록(findForAcademy, written=false)에서 쓰던 조건과 정확히 같아야 화면과 알림 대상이 일치한다. 그래서 그 조건을 그대로 복제해 강사의 user_id 만 뽑는 쿼리를 추가했다.

<select id="findTeacherUserIdsWithUnwrittenJournals" resultType="long">
    SELECT DISTINCT t.user_id
    FROM lesson_bookings lb
    JOIN teachers t          ON t.id = lb.teacher_id
    LEFT JOIN lesson_notes n ON n.booking_id = lb.id
    WHERE lb.academy_id = #{academyId}
      AND lb.status &lt;&gt; 'CANCELLED'
      AND TIMESTAMP(lb.lesson_date, CONCAT(lb.start_time, ':00')) &lt; #{now}
      AND n.id IS NULL
      AND t.user_id IS NOT NULL
</select>

포인트 두 가지:

  • 알림 수신자는 teachers.id 가 아니라 teachers.user_id(로그인 계정)여야 한다. t.user_id IS NOT NULL 로 로그인 계정 없는 강사는 대상에서 제외.
  • TIMESTAMP(lesson_date, start_time) 이 현재(KST)보다 과거이고, 취소 아님, 노트 없음 — 3조건이 화면 미작성 목록과 동일.

6. LessonJournalService — 발송 로직

NotificationService 를 주입받아, 위 쿼리로 뽑은 강사 user_id 마다 알림 1건을 만들어 발송한다. 사용자 확정 사항대로 중복 방지 없이 매번 새로 생성한다.

public int sendJournalWriteRequests(Long academyId) {
    List<Long> teacherUserIds =
            journalMapper.findTeacherUserIdsWithUnwrittenJournals(academyId, KstClock.now());
    if (teacherUserIds.isEmpty()) return 0;
    List<Notification> notifications = teacherUserIds.stream()
            .map(userId -> Notification.builder()
                    .userType(UserType.SERVICE.name())
                    .recipientId(userId)
                    .academyId(academyId)
                    .typeCode("JOURNAL_WRITE_REQUEST")
                    .title("미작성 레슨 일지 확인 요청")
                    .content("작성하지 않은 레슨 일지가 있습니다. 확인 후 작성해주세요.")
                    .linkPath("/lesson-journals")
                    .build())
            .toList();
    return notificationService.notify(notifications);
}

컨트롤러엔 원장용 발송 엔드포인트를 추가했다.

@PostMapping("/write-request")
public ApiResponse<Map<String, Integer>> sendWriteRequest(@AuthenticationPrincipal UserPrincipal principal) {
    Long academyId = AcademyScope.require(principal);
    int sent = service.sendJournalWriteRequests(academyId);
    return ApiResponse.ok(Map.of("sent", sent));
}

7. 프론트 — 알림 API 클라이언트

academy/src/api/notifications.ts 에 수신함/읽음/발송 함수를 모았다. 기존 언랩 패턴(data.data)을 그대로 따랐다.

export const getMyNotifications = async (): Promise<Notification[]> => {
  const { data } = await client.get<ApiResult<Notification[]>>('/notifications/me');
  return data.data ?? [];
};
export const sendJournalWriteRequest = async (): Promise<number> => {
  const { data } = await client.post<ApiResult<{ sent: number }>>(
    '/academy/lesson-journals/write-request',
  );
  return data.data?.sent ?? 0;
};

8. 프론트 — "전체 작성 요청 발송" 버튼

LessonJournals.tsx 검색줄 오른쪽(ml: 'auto')에, 미작성 탭(tab===2)에서만 보이는 버튼을 넣었다. confirm → 발송 → 결과 alert.

const handleSendWriteRequest = async () => {
  const ok = await fx.showConfirm('미작성 일지가 있는 강사 전원에게 작성 요청 알람을 발송할까요?');
  if (!ok) return;
  fx.isLoadingbar(true);
  try {
    const sent = await sendJournalWriteRequest();
    fx.showAlert(sent > 0 ? `${sent}명에게 작성 요청을 발송했습니다.` : '미작성 일지가 있는 강사가 없습니다.');
  } catch {
    fx.showAlert('작성 요청 발송에 실패했습니다.');
  } finally {
    fx.isLoadingbar(false);
  }
};

9. 프론트 — 상단 종 아이콘 실동작

기존 종 아이콘은 badgeContent={3} 하드코딩에 onClick 도 없는 껍데기였다. 이걸 실제 데이터로 살렸다.

  • 마운트 시 + 창 포커스 시 getUnreadCount() 로 안읽음 배지 갱신
  • 종 클릭 → 드롭다운 Menu 로 getMyNotifications() 목록 표시(안읽음은 굵게 + 배경 강조)
  • 항목 클릭 → markRead(id) 후 linkPath 로 navigate
  • 하단 "모두 읽음" → markAllRead()

시간 표기는 방금/N분 전/N시간 전/MM.DD 로 간단히 포맷했다.

10. 관리자 이메일 중복 문구 — 진짜 원인은 프론트의 빈 catch

관리자 추가 시 이메일이 중복되면 서버는 이미 이렇게 정확히 응답하고 있었다.

{"success":false,"code":"EMAIL_DUPLICATED","message":"이미 사용 중인 이메일입니다."}

(백엔드 ErrorCode.EMAIL_DUPLICATED = HTTP 409 + 해당 메시지, AdminUserService.create 에서 countByEmail > 0 이면 throw.)

그런데 프론트 UserManagement.tsx 의 handleSave 가 에러를 통째로 버리고 항상 일반 문구만 띄우고 있었다.

// Before
} catch {
  toast.error('저장에 실패했습니다.');
}

바로 아래 handleToggleStatus 는 e?.response?.data?.message 를 잘 쓰고 있었는데 handleSave 만 빠져 있던 것. 동일 패턴으로 맞췄다.

// After
} catch (e: any) {
  // 서버가 내려준 상황별 메시지(예: 이메일 중복)를 우선 노출. 없으면 일반 문구.
  toast.error(e?.response?.data?.message || '저장에 실패했습니다.');
}

이 컴포넌트는 관리자/원장/강사/학생 추가 화면이 공유하므로, 한 줄 수정으로 네 화면 모두 상황별 문구가 뜨게 됐다.


검증 & 배포

DB 마이그레이션 (dev RDS)

migration-20260812b-notifications.sql 를 db-migrate 로 개발 RDS(piano_dev)에 멱등 적용. notifications 테이블 11개 컬럼 생성 확인.

백엔드 (도커 → ECR → EC2 #1)

  1. git commit/push (da87fc9)
  2. docker build --platform linux/arm64 → ECR push (:da87fc9, :latest)
  3. EC2 #1(단독) 컨테이너 교체 → 부팅 확인
    • sec-on ping = 200, Started PianoApiApplication in 10.5s, HikariPool-1 - Start completed
    • Target Group #1 HEALTHY
  4. E2E(CloudFront 경유):
    • GET /api/v1/public/ping → 200
    • GET /api/v1/auth/me → 401(정상)
    • GET /api/v1/notifications/me → 401(정상, 새 라우트 동작)

프론트 (S3 + CloudFront)

  • admin/academy 각각 빌드 → dev URL 스캔 게이트 통과 → S3 업로드(index.html no-cache / assets immutable) → CloudFront /admin/*, /academy/* 무효화(약 18초 Completed)
  • 검증: 두 앱 index.html 이 방금 올린 번들 해시를 참조, 페이지 200

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

  • tsc --noEmit 가 프로젝트 전체에선 통과 안 됨: MUI 타입 엄격성(PaperProps, inputProps, Stack 등) + 테스트 파일 때문에 기존 에러가 잔뜩 나온다. 실제 배포 빌드는 Vite(esbuild)라 타입체크를 안 하므로, 내 수정 파일만 grep 으로 필터해 "내 파일엔 에러 없음"을 확인하고 진행했다.
  • 수신자 식별: 처음엔 user_id 하나로 잡으려다, 본사 관리자가 admin_users 라는 별도 테이블에 있다는 걸 발견하고 (user_type, recipient_id) 복합키 설계로 선회. 강사는 teachers.id 가 아니라 teachers.user_id 를 recipient 로 넣어야 하는 것도 주의점.
  • 이메일 중복 문구는 백엔드 문제인 줄 알았는데, 서버는 이미 정확한 메시지를 주고 있었고 프론트의 빈 catch {} 가 범인이었다. 백엔드는 손댈 필요 없었다.

결론 / 배운 점

  • 알림 같은 횡단(cross-cutting) 기능은 처음부터 특정 역할(강사)에 묶지 말고, 유저 모델의 실제 저장 구조(users vs admin_users)를 반영한 범용 스키마로 설계하는 게 나중 확장에 유리하다. NotificationService.notify(...) 헬퍼 하나로 앞으로 공지/결제 등 다른 알림도 재사용할 수 있다.
  • "화면 목록"과 "발송 대상"의 판정 조건은 반드시 같은 SQL 조건을 재사용해야 사용자 혼란(화면엔 있는데 알림은 안 감/그 반대)을 막는다.
  • 에러 메시지는 백엔드가 잘 내려줘도 프론트가 삼키면 무용지물이다. 성공/실패 양쪽 UX 를 같은 수준으로 챙기자. (같은 컴포넌트 안에서도 메서드마다 에러 처리 편차가 있으면 사용자 입장에선 들쭉날쭉하게 느껴진다.)

댓글 0

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