개발일지 2026-08-12
M forêt 개발일지 (2026-08-12)
개요
오늘은 두 가지 작업을 진행했다.
- 범용 인앱 알림(Notification) 시스템을 새로 구축하고, 그 첫 발송 기능으로 "미작성 레슨 일지 작성 요청" 을 붙였다. 원장이 학원 레슨 일지 화면의 "미작성" 탭에서 버튼 한 번으로, 아직 일지를 안 쓴 강사 전원에게 앱 안에서 확인하는 알림(읽음/안읽음 관리 포함)을 보낼 수 있다.
- 관리자 추가 시 이메일 중복 에러 문구 개선. 그동안 중복 이메일로 저장 실패하면 "저장에 실패했습니다."만 떠서 원인을 알 수 없었는데, 서버가 이미 내려주던 상황별 메시지("이미 사용 중인 이메일입니다.")를 그대로 노출하도록 고쳤다.
수정/생성 파일 목록
백엔드 (piano-backend)
src/main/resources/migration-20260812b-notifications.sql(신규) — 범용notifications테이블 DDLsrc/main/java/com/piano/api/notification/Notification.java(신규) — 알림 DTOsrc/main/java/com/piano/api/notification/NotificationMapper.java(신규) — 매퍼 인터페이스src/main/resources/mapper/NotificationMapper.xml(신규) — 매퍼 XMLsrc/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 <> 'CANCELLED'
AND TIMESTAMP(lb.lesson_date, CONCAT(lb.start_time, ':00')) < #{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)
- git commit/push (
da87fc9) docker build --platform linux/arm64→ ECR push (:da87fc9,:latest)- EC2 #1(단독) 컨테이너 교체 → 부팅 확인
sec-on ping = 200,Started PianoApiApplication in 10.5s,HikariPool-1 - Start completed- Target Group
#1 HEALTHY
- E2E(CloudFront 경유):
GET /api/v1/public/ping→ 200GET /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) 기능은 처음부터 특정 역할(강사)에 묶지 말고, 유저 모델의 실제 저장 구조(
usersvsadmin_users)를 반영한 범용 스키마로 설계하는 게 나중 확장에 유리하다.NotificationService.notify(...)헬퍼 하나로 앞으로 공지/결제 등 다른 알림도 재사용할 수 있다. - "화면 목록"과 "발송 대상"의 판정 조건은 반드시 같은 SQL 조건을 재사용해야 사용자 혼란(화면엔 있는데 알림은 안 감/그 반대)을 막는다.
- 에러 메시지는 백엔드가 잘 내려줘도 프론트가 삼키면 무용지물이다. 성공/실패 양쪽 UX 를 같은 수준으로 챙기자. (같은 컴포넌트 안에서도 메서드마다 에러 처리 편차가 있으면 사용자 입장에선 들쭉날쭉하게 느껴진다.)
댓글 0
- 첫 번째 댓글을 남겨보세요.