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

개발일지 2026-06-25

피아노 학원 플랫폼 개발일지 (2026-06-25)

⭐ 오늘의 하이라이트 — 시도/시군구 지역 검색 완성

학원(academies)에 넣어둔 sido_code/sigungu_code를 활용해, brand 지점찾기에 "광역시도 → 시군구" cascading 드롭다운 필터를 붙여 지역 검색을 완성했다.

  • 시도를 고르면 해당 시도의 시군구 옵션이 자동으로 채워진다 (sigungu_{시도코드} 그룹).
  • 공개 공통코드 API(/public/common-codes)로 시도/시군구를 한 번에 받아 프론트에서 맵으로 인덱싱.
  • 필터 변경 시 /public/locations?sidoCode=&sigunguCode=로 서버 재조회.
  • 검증: ?sidoCode=11(서울) → 서울 지점만 정상 필터링 ✅

개요

이번 작업은 지역(시도/시군구) 기반 검색 기능 마무리와 대리점(지사) 관리 기능 신규 구축 두 가지였다.

  1. 앞서 학원(academies)에 추가한 sido_code/sigungu_code 컬럼을 활용해, brand 지점찾기 페이지에 광역시도→시군구 cascading 드롭다운 필터를 붙여 지역 검색을 완성했다.
  2. 본사(admin)에 대리점(지사) 도메인을 백엔드프론트DB까지 풀스택으로 신규 구축했다. 추후 "학원이 지사에 종속되는" 구조를 염두에 두고, 지금 단계에서는 대리점 마스터(이름/담당 영업자/연락처/상태)만 먼저 만들었다.

마지막으로 백엔드 jar + admin/brand 프론트를 dev(EC2)에 배포하고 E2E 검증까지 마쳤다.


수정/생성 파일 목록

백엔드 (piano-backend) — 대리점 도메인 신규

  • .../agency/Agency.java (신규) — 대리점 엔티티
  • .../agency/dto/AgencyRequest.java (신규) — 등록/수정 요청 DTO (record)
  • .../agency/AgencyMapper.java (신규) — MyBatis 매퍼 인터페이스 (검색/CRUD)
  • .../agency/AgencyService.java (신규) — 서비스 (트랜잭션/존재검증)
  • .../admin/AgencyAdminController.java (신규) — /api/v1/admin/agencies CRUD
  • .../resources/mapper/AgencyMapper.xml (신규) — resultMap·동적검색·insert·update·delete
  • .../common/ErrorCode.java (수정) — AGENCY_NOT_FOUND 추가
  • .../resources/schema.sql (수정) — agencies 테이블 정의 추가
  • .../resources/data.sql (수정) — 대리점 샘플 4건 + 사이드바 메뉴/권한 매핑 추가

프론트 (admin)

  • admin/src/api/agencies.ts (신규) — 대리점 CRUD API 클라이언트
  • admin/src/pages/Agencies.tsx (수정) — 목업 → 실 API 연동 페이지로 교체

프론트 (brand)

  • brand/src/api/publicApi.ts (수정) — getLocations(params) 지역필터 + getPublicCommonCodes() 추가
  • brand/src/pages/Locations.tsx (수정) — 시도→시군구 cascading 드롭다운 필터 추가

DB (RDS dev)

  • agencies 테이블 생성 + 샘플 4건 삽입
  • 대리점 메뉴-역할 매핑 확인 (SUPER_ADMIN / OPS_CS)

파일별 상세

1. brand 지점찾기 — 시도→시군구 필터

기존 지점찾기는 텍스트 검색(지점명/지역/주소 부분일치)만 있었다. 여기에 광역시도 선택 → 해당 시도의 시군구 옵션이 채워지는 연동 드롭다운을 추가했다.

공개용 공통코드 조회 API와 지점 지역 필터 파라미터를 API 레이어에 먼저 정의했다.

// brand/src/api/publicApi.ts
export const getLocations = (params?: { sidoCode?: string; sigunguCode?: string }): Promise<Location[]> =>
  client.get<ApiResult<Location[]>>('/public/locations', { params })
    .then((r) => r.data.data ?? []);

export interface PublicCodeItem { code: string; label: string; sortOrder: number; enabled?: boolean }
export interface PublicCodeGroup { groupCode: string; groupName: string; codes: PublicCodeItem[] }

export const getPublicCommonCodes = (): Promise<PublicCodeGroup[]> =>
  client.get<ApiResult<PublicCodeGroup[]>>('/public/common-codes')
    .then((r) => r.data.data ?? []);

페이지에서는 공통코드를 1회 로드해 groupCode별 맵으로 보관하고, 시도 선택값이 바뀌면 sigungu_{시도코드} 그룹에서 시군구 옵션을 꺼내 쓴다. 필터가 바뀔 때마다 서버에 재조회한다.

// brand/src/pages/Locations.tsx
const sidoList = codeMap['sido'] ?? [];
const sigunguList = sidoCode ? codeMap[`sigungu_${sidoCode}`] ?? [] : [];

// 공통코드 1회 로드 → groupCode 맵으로 변환
useEffect(() => {
  getPublicCommonCodes().then((groups) => {
    const map: Record<string, PublicCodeItem[]> = {};
    groups.forEach((g) => { map[g.groupCode] = g.codes; });
    setCodeMap(map);
  }).catch(() => setCodeMap({}));
}, []);

// 지역 필터가 바뀌면 재조회
useEffect(() => {
  fx.isLoadingbar(true);
  getLocations({ sidoCode: sidoCode || undefined, sigunguCode: sigunguCode || undefined })
    .then(setLocations).catch(() => setLocations([])).finally(() => fx.isLoadingbar(false));
}, [sidoCode, sigunguCode]);

여기서 한 가지 주의점 — 공개 공통코드 엔드포인트(/public/common-codes)는 특정 group_code 필터 파라미터가 없고 전체(enabled=true) 그룹을 한 번에 반환한다. 그래서 프론트에서 한 번 받아 맵으로 인덱싱해 쓰는 방식이 맞았다. 시도(sido)와 시군구(sigungu_11 등)가 같은 응답에 모두 들어있다.

2. 대리점(지사) 백엔드 도메인

학원(academy) 도메인을 레퍼런스로 동일한 레이어 패턴(엔티티→Mapper→XML→Service→DTO→Controller)을 따랐다. 다만 이번엔 필드를 최소화했다. 시도 지정은 하지 않았다 — 나중에 학원이 지사에 종속되는 구조로 갈 것이라, 지사 자체에 지역을 박아두지 않기로 했다.

엔티티와 요청 DTO:

// Agency.java
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class Agency {
    private Long id;
    private String name;         // 대리점(지사)명
    private String managerName;  // 담당(영업)자명
    private String phone;        // 연락처
    private String status;       // ACTIVE/INACTIVE
    private LocalDateTime createdAt;
}

// AgencyRequest.java
public record AgencyRequest(
        @NotBlank(message = "대리점명은 필수입니다.")
        String name,
        String managerName,
        String phone,
        String status
) {}

매퍼 XML은 키워드(이름/담당자)와 상태로 거르는 동적 검색을 넣었다.

<!-- AgencyMapper.xml -->
<select id="search" resultMap="agencyMap">
    SELECT <include refid="cols"/> FROM agencies
    <where>
        <if test="keyword != null and keyword != ''">
            AND (name LIKE CONCAT('%', #{keyword}, '%') OR manager_name LIKE CONCAT('%', #{keyword}, '%'))
        </if>
        <if test="status != null and status != ''">
            AND status = #{status}
        </if>
    </where>
    ORDER BY id
</select>

컨트롤러는 admin 표준 CRUD 형태(/api/v1/admin/agencies). /api/v1/admin/**는 SecurityConfig에서 이미 SUPER_ADMIN/OPS_CS로 묶여 있어 별도 권한 설정은 불필요했다.

@RestController
@RequestMapping("/api/v1/admin/agencies")
@RequiredArgsConstructor
public class AgencyAdminController {
    private final AgencyService agencyService;

    @GetMapping
    public ApiResponse<List<Agency>> list(
            @RequestParam(required = false) String keyword,
            @RequestParam(required = false) String status) {
        return ApiResponse.ok(agencyService.search(keyword, status));
    }
    // get / create / update / delete ...
}

테이블은 추후 학원이 종속될 부모 테이블이므로 academies보다 앞에 정의했다.

CREATE TABLE agencies (
    id            BIGINT       NOT NULL AUTO_INCREMENT,
    name          VARCHAR(100) NOT NULL,
    manager_name  VARCHAR(50),
    phone         VARCHAR(30),
    status        VARCHAR(20)  NOT NULL DEFAULT 'ACTIVE',
    created_at    TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    INDEX idx_agencies_status (status)
);

3. 사이드바 메뉴 등록

이 프로젝트는 admin 사이드바가 DB(menus + menu_roles) 기반이다. 학원관리(parent) 하위에 "대리점 관리"(/agencies)를 추가하고, SUPER_ADMIN/OPS_CS 역할에 매핑했다.

-- 학원관리 하위
(23, '대리점 관리', '/agencies', 'Storefront', 3, 4, TRUE),
-- menu_roles: SUPER_ADMIN(1), OPS_CS(2)에 23 매핑 추가

(라우트 /agencies는 App.tsx에 이미 등록돼 있었다.)

4. admin 대리점 페이지 — 목업 → 실 API 연동

원래 Agencies.tsx는 static 배열 + 목업 다이얼로그였다. 이를 실제 CRUD로 교체했다. API 클라이언트는 academies.ts 패턴 그대로 Agency/AgencyForm/AgencySearch 인터페이스 + CRUD 함수로 구성했다.

페이지는 공통 컴포넌트(useFeedback의 toast/confirm, fx.isLoadingbar, DataTable, PhoneInput)를 재사용했다. KPI 카드는 "가맹학원 수/이번달 수수료"처럼 아직 데이터가 없는 항목을 빼고, 전체/활성 대리점 수만 남겼다. 상태는 ACTIVE/INACTIVE 두 가지뿐이라 공통코드 대신 로컬 라벨 맵으로 칩을 그렸다.

const STATUS_LABEL: Record<string, string> = { ACTIVE: '활성', INACTIVE: '비활성' };
// ...
<Chip size="small" label={STATUS_LABEL[v] || v}
      color={v === 'ACTIVE' ? 'success' : 'default'}
      variant={v === 'ACTIVE' ? 'filled' : 'outlined'} />

검증 & 배포

DB는 로컬(H2)과 dev(RDS MySQL)가 다르다. schema.sql/data.sql은 H2 부팅용 시드이므로, RDS에는 SSH 터널 + Python(pymysql)로 직접 DDL/DML을 적용했다.

  • agencies 테이블 CREATE TABLE IF NOT EXISTS + 샘플 4건 삽입
  • 대리점 메뉴(/agencies)는 RDS에 이미 등록돼 있었고(id 312), menu_roles에 SUPER_ADMIN/OPS_CS 매핑도 존재함을 확인

배포는 DEPLOY-DEV.md 절차대로:

# 백엔드
./gradlew clean bootJar -x test
scp ... build/libs/*.jar ec2-user@<EC2>:/home/ec2-user/piano-api.jar
ssh ... 'sudo mv .../piano-api.jar /opt/piano/piano-api.jar && sudo systemctl restart piano-api'

# 프론트 (admin/brand)
GENERATE_SOURCEMAP=false CI=false npm run build
# build/ → tar → scp → /var/www/piano/{piano-admin,piano} 에 풀고 nginx reload

E2E 검증 결과:

  • admin/brand 페이지 HTTP 200
  • GET /admin/agencies → 대리점 4건 정상 반환
  • GET /admin/menus/my → '대리점 관리' 메뉴 노출 확인
  • GET /public/locations?sidoCode=11 → 서울 지점만 필터링 정상
  • GET /public/common-codes → sido/sigungu_11 그룹 존재 확인

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

  • RDS 자격증명 파싱 실수: 비번 파일에 DB 블록이 여러 개("데이터베이스 계정", "[DB - 앱 계정 (dev)]" 등)라, 단순 부분일치로 파싱했더니 엉뚱한 블록 값(이메일 계정)이 user로 잡혀 Access denied가 났다. → "앱 계정 + dev" 블록만 정확히 찾아 그 다음 줄부터 빈 줄까지만 읽도록 고쳐 해결.
  • PhoneInput props: 커스텀 PhoneInput은 fullWidth를 받지 않는다(내부적으로 3칸 TextField 조합). 무심코 fullWidth를 넘겼다가 제거.
  • 공개 공통코드는 group 필터가 없다: /public/common-codes가 group_code 파라미터를 안 받고 전체를 반환하는 구조라, 프론트에서 한 번 받아 맵으로 인덱싱하는 방식으로 맞췄다.
  • tar xattr 경고: macOS에서 tar로 묶어 EC2(Linux)에서 풀 때 LIBARCHIVE.xattr.com.apple.provenance 경고가 뜨지만 무해(메타데이터일 뿐).

결론 / 배운 점

  • 지역 데이터를 common_code의 group_code 네이밍 규칙(sido, sigungu_{시도코드})으로 풀어두니, 공개 API 하나로 시도/시군구를 모두 내려받아 프론트에서 cascading 드롭다운을 쉽게 구성할 수 있었다.
  • 새 도메인을 추가할 때 기존(academy) 레이어 패턴을 그대로 따르면 빠르고 일관적이다. 다만 미래 구조(학원→지사 종속)를 고려해 지금 불필요한 필드(시도/수수료 등)는 과감히 빼는 것이 더 깔끔했다.
  • 로컬(H2)과 dev(RDS)의 스키마 동기화는 시드파일만으론 부족하다. 운영 DB엔 별도로 DDL을 적용해야 한다는 점을 다시 확인.

추가 정리 (14:30) — 대리점별 현황(집계) + 카카오 알림톡 템플릿 관리

오전에 만든 대리점(지사) 도메인을 한 단계 더 끌어올렸다. 단순 CRUD였던 대리점에 "이 대리점이 학원 몇 개를 거느리고, 그 학원들의 학생 수·누적 매출이 얼마인지"를 한눈에 보는 현황(대시보드) 탭을 붙였다. 그러려면 먼저 학원과 대리점을 실제로 연결(academies.agency_id)해야 했다. 그리고 별개로 카카오 알림톡 템플릿 관리 화면도 신규로 구축했다.

수정/생성 파일 목록

A. 대리점별 현황 + 학원-대리점 연결

DB (piano-backend)

  • .../resources/schema.sql (수정) — academies에 agency_id 컬럼 + 인덱스
  • .../resources/migration-20260625-academy-agency.sql (신규) — RDS ALTER

백엔드 (학원↔대리점 연결)

  • .../academy/Academy.java (수정) — agencyId 필드
  • .../academy/dto/AcademyRequest.java (수정) — agencyId 추가
  • .../resources/mapper/AcademyMapper.xml (수정) — resultMap/insert/update에 agency_id
  • .../academy/AcademyService.java (수정) — create/update에 agencyId 반영

백엔드 (대리점 현황 집계 신규)

  • .../agency/dto/AgencyStats.java (신규) — 대리점별 집계 행 DTO
  • .../agency/dto/AgencyAcademyRow.java (신규) — 드릴다운(소속 학원) 행 DTO
  • .../agency/AgencyMapper.java (수정) — findStats, findAcademiesByAgency
  • .../resources/mapper/AgencyMapper.xml (수정) — 집계 쿼리 2개 + resultMap 2개
  • .../agency/AgencyService.java (수정) — stats, academies 메서드
  • .../admin/AgencyAdminController.java (수정) — GET /stats, GET /{id}/academies

프론트 (admin)

  • admin/src/api/agencies.ts (수정) — AgencyStats/AgencyAcademyRow + 조회 함수
  • admin/src/api/academies.ts (수정) — Academy/AcademyForm에 agencyId
  • admin/src/pages/Agencies.tsx (수정) — 목록/현황 탭 개편 + 드릴다운
  • admin/src/pages/Academies.tsx (수정) — 학원 폼에 "소속 대리점" select
  • admin/src/components/DataTable.tsx (수정) — onRowClick prop 추가

B. 카카오 알림톡 템플릿 관리

백엔드 (piano-backend)

  • .../admin/kakaotemplate/KakaoTemplate.java (신규) — 엔티티
  • .../admin/kakaotemplate/KakaoTemplateRequest.java (신규) — 요청 DTO(record)
  • .../admin/kakaotemplate/KakaoTemplateMapper.java (신규) — 매퍼
  • .../resources/mapper/KakaoTemplateMapper.xml (신규) — SQL
  • .../admin/kakaotemplate/KakaoTemplateService.java (신규) — 서비스(코드 중복검증)
  • .../admin/kakaotemplate/KakaoTemplateController.java (신규) — /api/v1/admin/kakao-templates
  • .../resources/migration-20260625-kakao-templates.sql (신규) — RDS 마이그레이션
  • .../resources/schema.sql + data.sql (수정) — kakao_templates 테이블 + 시드

프론트 (admin)

  • admin/src/api/kakaoTemplates.ts (신규) — API
  • admin/src/pages/KakaoTemplates.tsx (신규) — 관리 페이지
  • admin/src/App.tsx (수정) — /kakao-templates 라우트

문서

  • docs/messaging-guide.md (신규) — 메시징(SMS+알림톡) 가이드

파일별 상세 — A. 대리점별 현황

A-1. 학원-대리점 연결 (academies.agency_id)

가장 먼저 풀어야 할 매듭은 "학원과 대리점이 연결되어 있지 않다"는 것이었다. academies에는 agency_id가 없었다. 그래서 NULL 허용 컬럼을 추가했다(NULL=미배정). FK는 기존 컨벤션대로 걸지 않고 인덱스만 뒀다.

-- migration-20260625-academy-agency.sql
ALTER TABLE academies ADD COLUMN agency_id BIGINT NULL AFTER id;
ALTER TABLE academies ADD INDEX idx_academies_agency (agency_id);

여기서 배포 순서가 중요했다. 코드가 먼저 배포되면 insert/update 쿼리가 agency_id를 참조하는데 실제 컬럼이 없어 즉시 깨진다. 그래서 RDS ALTER를 먼저 적용한 뒤 코드를 배포했다. (이 프로젝트 dev는 sql.init.mode=never라 schema.sql이 자동 반영되지 않는다.)

엔티티/요청 DTO/매퍼/서비스에 agencyId를 흘려보내는 건 기존 필드 추가와 동일한 패턴이다.

// AcademyService.create()
Academy academy = Academy.builder()
        .agencyId(req.agencyId())   // ← 추가
        .name(req.name())
        // ...
        .build();

A-2. 현황 집계 — ★카티전곱(Cartesian product) 회피가 핵심★

대리점별로 [소속 학원 수 / 학생 수 / 누적 매출]을 한 방에 뽑아야 했다. 그런데 여기 함정이 있다. 한 SQL에서 JOIN students 와 JOIN payments 를 동시에 하면 학생수 × 결제건수만큼 행이 뻥튀기되어 매출이 학생 수만큼 부풀려진다.

해결책은 각 지표를 독립 서브쿼리에서 GROUP BY로 선집계한 뒤, 대리점에 1:1로 LEFT JOIN하는 것이다. NULL은 COALESCE(...,0)으로 0 처리.

<!-- AgencyMapper.xml: findStats -->
SELECT ag.id, ag.code, ag.name, ag.status,
    COALESCE(ac.academy_count, 0) AS academy_count,
    COALESCE(st.student_count, 0) AS student_count,
    COALESCE(pm.total_sales,   0) AS total_sales
FROM agencies ag
LEFT JOIN ( SELECT agency_id, COUNT(*) AS academy_count
            FROM academies WHERE agency_id IS NOT NULL GROUP BY agency_id ) ac
       ON ac.agency_id = ag.id
LEFT JOIN ( SELECT a.agency_id, COUNT(s.id) AS student_count
            FROM academies a JOIN students s
              ON s.academy_id = a.id AND s.status = 'ENROLLED'
            WHERE a.agency_id IS NOT NULL GROUP BY a.agency_id ) st
       ON st.agency_id = ag.id
LEFT JOIN ( SELECT a.agency_id, SUM(p.amount) AS total_sales
            FROM academies a JOIN payments p
              ON p.academy_id = a.id AND p.status = 'PAID'
            WHERE a.agency_id IS NOT NULL GROUP BY a.agency_id ) pm
       ON pm.agency_id = ag.id
ORDER BY ag.id
  • 학생수 = students.status='ENROLLED'(재원생) 카운트
  • 누적매출 = payments.status='PAID' 의 amount 합 (백엔드 long, TS number)
  • students/payments에는 agency_id가 없으므로 academies를 경유해 끌어온다.

A-3. admin 컨트롤러 — 경로 우선순위 주의

/stats를 /{id} 보다 위에 선언했다. 안 그러면 stats가 {id}로 잡혀 엉뚱하게 매핑될 위험이 있다(리터럴 경로 우선).

@GetMapping("/stats")          // ← /{id} 보다 먼저
public ApiResponse<List<AgencyStats>> stats(@RequestParam(required=false) String status) { ... }

@GetMapping("/{id}/academies") // 드릴다운
public ApiResponse<List<AgencyAcademyRow>> academies(@PathVariable Long id) { ... }

A-4. 프론트 — 목록/현황 탭 + 행 클릭 드릴다운

Agencies.tsx를 목록 / 현황 두 탭으로 나눴다. 현황 탭은 진입 시점에만 lazy 로드한다.

const [tab, setTab] = useState(0);
const [stats, setStats] = useState<AgencyStats[]>([]);
const [statsLoaded, setStatsLoaded] = useState(false);

useEffect(() => {
  if (tab === 1 && !statsLoaded) loadStats();  // 현황 탭 최초 진입 시만
}, [tab, statsLoaded, loadStats]);

현황 탭은 KPI 4개(전체 대리점 / 소속학원 합 / 학생 합 / 누적매출 합) + 집계 테이블 + 행 클릭 시 하단 카드에 소속 학원 드릴다운을 보여준다. 행 클릭을 위해 공용 DataTable에 onRowClick prop을 새로 추가했다.

// DataTable.tsx
<TableRow
  hover key={row[rowKey]}
  onClick={onRowClick ? () => onRowClick(row) : undefined}
  sx={onRowClick ? { cursor: 'pointer' } : undefined}
>

학원 등록/수정 폼(Academies.tsx)에는 "소속 대리점" select를 달았다. 옵션은 ACTIVE 대리점만 로드하고, "미배정"은 빈 값 → undefined로 매핑한다.

<TextField select label="소속 대리점"
  value={formData.agencyId ?? ''}
  onChange={(e) => setFormData({ ...formData,
    agencyId: e.target.value === '' ? undefined : Number(e.target.value) })}>
  <MenuItem value="">미배정</MenuItem>
  {agencies.map((a) => <MenuItem key={a.id} value={a.id}>{a.name}</MenuItem>)}
</TextField>

파일별 상세 — B. 카카오 알림톡 템플릿 관리

본사가 카카오 알림톡 템플릿(코드/내용/발송유형/심사상태/변수매핑)을 직접 관리할 수 있는 화면이다. 학원(academy) 도메인과 동일한 레이어 패턴으로 빠르게 구성했다.

보안 메모: 발송 플랫폼 계정/API Key/발신번호/실제 수신번호/외부 템플릿코드 실값 등 민감정보는 이 글에서 모두 제외했다. (코드 구조와 설계 의도만 정리)

B-1. 엔티티 / DTO

// KakaoTemplate.java (요약)
public class KakaoTemplate {
    private Long id;
    private String code;          // 내부 코드 (예: NOTI-xx)
    private String name;          // 템플릿명
    private String templateCode;  // 외부 발송 플랫폼 템플릿코드 (값은 비공개)
    private String content;       // 메시지 본문
    private String sendType;      // AUTO / MANUAL / TRIGGER
    private String sendCondition; // 발송 조건 설명
    private String status;        // APPROVED / PENDING / REJECTED
    private String variables;     // 변수 매핑 설명
    private String senderProfile; // 발신프로필 (값 마스킹)
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
}

요청 DTO는 record로, 필수값은 @NotBlank로 검증한다(code/name/templateCode/content/sendType).

B-2. 서비스 — 코드 중복 검증

내부 코드(code)와 외부 템플릿코드(templateCode) 둘 다 유니크여야 해서 생성/수정 시 중복을 검증한다. 수정 시에는 "자기 자신은 예외" 처리.

public KakaoTemplate create(KakaoTemplateRequest req) {
    if (mapper.findByCode(req.code()) != null)
        throw new BusinessException(ErrorCode.KAKAO_TEMPLATE_CODE_DUPLICATED);
    if (mapper.findByTemplateCode(req.templateCode()) != null)
        throw new BusinessException(ErrorCode.KAKAO_TEMPLATE_CODE_DUPLICATED);
    // status 기본 PENDING, senderProfile 기본값 적용 ...
}

DB 레벨에서도 안전망으로 유니크 제약을 걸었다.

-- migration-20260625-kakao-templates.sql (구조만, 시드 값 생략)
CREATE TABLE IF NOT EXISTS kakao_templates (
    id BIGINT NOT NULL AUTO_INCREMENT,
    code VARCHAR(30) NOT NULL,
    name VARCHAR(100) NOT NULL,
    template_code VARCHAR(80) NOT NULL,
    content VARCHAR(2000) NOT NULL,
    send_type VARCHAR(20) NOT NULL DEFAULT 'AUTO',
    send_condition VARCHAR(100),
    status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
    variables VARCHAR(500),
    sender_profile VARCHAR(50) NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    CONSTRAINT uk_kakao_templates_code UNIQUE (code),
    CONSTRAINT uk_kakao_templates_tpl  UNIQUE (template_code)
);

B-3. 프론트 — 관리 페이지

KakaoTemplates.tsx는 발송유형(AUTO/MANUAL/TRIGGER)·심사상태(승인/심사중/반려)를 칩으로 시각화하고, 내용 미리보기(말줄임), 사용 가능한 변수 안내([*이름*], [*1*]~[*8*])를 함께 보여준다. 등록/수정 다이얼로그에서 코드는 수정 불가(생성 후 고정)로 막았다.

const STATUS_OPTIONS = [
  { value: 'APPROVED', label: '승인',  color: 'success' },
  { value: 'PENDING',  label: '심사중', color: 'warning' },
  { value: 'REJECTED', label: '반려',  color: 'error' },
];

알림톡 변수는 SMS의 #{변수명} 형식과 달리 [*이름*], [*1*]~[*8*] 형식을 쓴다는 점이 포인트다.


검증

오전 작업과 같은 절차로 검증했다.

  • 백엔드 ./gradlew compileJava ✅ / admin npx tsc --noEmit ✅
  • 대리점 현황 API 스모크: GET /admin/agencies/stats → 4건 정상
  • 카티전곱 정합성 검증: 한 대리점에 학원 2개(학생 5명·매출 합)를 배정한 뒤 집계가 곱셈 중복 없이 정확히 합산되는지 확인. 학생수만큼 매출이 부풀려지지 않고 각 학원 매출의 단순 합으로 떨어지는 것을 확인 ✅
  • 드릴다운(/admin/agencies/{id}/academies) → 소속 학원 행 정상

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

  • 로그인 응답 토큰 키: 스모크 테스트에서 data.token으로 파싱하려다 KeyError. 실제 키는 data.accessToken이었다(refreshToken/tokenType도 함께 내려옴).
  • 마이그레이션 파일 위치: 카카오 마이그레이션 SQL은 repo 루트가 아니라 src/main/resources/ 아래에 있었다. 루트에서 글롭하다 못 찾아서 find로 위치를 다시 확인.
  • 공용 컴포넌트 확장의 파급: 드릴다운을 위해 DataTable에 onRowClick을 추가했는데, 공용 컴포넌트라 다른 페이지에 영향이 없도록 prop이 있을 때만 onClick/cursor를 적용하도록 옵셔널하게 짰다.

결론 / 배운 점

  • 집계 쿼리에서 다중 1:N JOIN은 무조건 의심하자. 학생·결제를 한 쿼리에서 같이 JOIN하면 카티전곱으로 매출이 부풀려진다. 지표별로 서브쿼리 선집계 후 LEFT JOIN하는 패턴이 정석이다.
  • 스키마 변경이 끼면 RDS DDL을 코드 배포보다 먼저 적용해야 insert/update가 깨지지 않는다.
  • 단순 CRUD에 "현황(집계)" 한 겹을 얹는 것만으로 데이터의 활용도가 확 올라간다. 다만 그 전제는 **도메인 간 실제 연결(FK 성격의 컬럼)**이 먼저 갖춰져 있어야 한다는 것.

댓글 0

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