Skip to content

[Feat] 기관 광고(Advertisement) 관리 시스템 구현 #27

@Uechann

Description

@Uechann

📌 이슈 유형

  • ✨ 새 기능 추가 (feat)
  • 🐛 버그 수정 (fix)
  • 🔧 기능 개선 (refactor)
  • 📚 문서 작업 (docs)
  • 🧪 테스트 (test)
  • 🏗️ 빌드/배포 (ci/build)
  • 🔥 긴급 수정 (hotfix)
  • 🧹 기타 작업 (chore)

[Feature] 기관 광고(Advertisement) 관리 시스템 구현

📋 이슈 개요

기관이 플랫폼 내에서 광고를 신청하고, 관리자가 승인/거절하여 광고를 게재하는 시스템을 구현합니다.
승인된 광고는 설정된 기간 동안 메인 페이지 등에 노출되어 기관 홍보 효과를 제공합니다.


🎯 핵심 구현 목표

✅ 반드시 구현해야 할 기능

  1. 기관 광고 신청 - 기관이 광고 유형, 기간, 내용을 선택하여 신청
  2. 관리자 광고 심사 - 관리자가 신청된 광고를 승인/거절
  3. 광고 상태 관리 - 광고 대기/진행/종료 상태 자동 관리
  4. 광고 목록 조회 - 기관/관리자별 광고 목록 조회
  5. 광고 취소 - 기관이 진행 전 광고 취소 가능
  6. 광고 연장 - 기존 광고 기간 연장 신청

📊 광고 상태(AdvertisementStatus) 플로우

[기관 신청] → REQUEST_PENDING (승인 대기)
                    ↓
            [관리자 심사]
                ↙        ↘
    REQUEST_APPROVED    REQUEST_REJECTED
      (승인 완료)          (승인 거부)
          ↓                   ↓
  ADVERTISEMENT_PENDING    [종료]
    (광고 대기중)
          ↓
    [시작 시간 도래]
          ↓
  ADVERTISEMENT_ACTIVE
    (광고 진행중)
          ↓
    [종료 시간 도래]
          ↓
  ADVERTISEMENT_ENDED
    (광고 종료)

※ 언제든지 → ADVERTISEMENT_CANCELED (광고 취소) 가능

🎨 광고 유형(AdvertisementType)

유형 설명 노출 위치 권장 크기 가격(예시)
MAIN_BANNER 메인 페이지 최상단 배너 메인 상단 1200x400px 300,000원/주
PREMIUM_LIST 기관 목록 상단 고정 노출 목록 최상위 - 200,000원/주
SIDE_BANNER 사이드바 배너 광고 우측 사이드 300x600px 100,000원/주
SEARCH_TOP 검색 결과 상단 노출 검색 최상위 - 150,000원/주

📋 전체 API 엔드포인트 목록

Part 1: 기관 광고 신청 및 관리

Method Endpoint 설명 권한 상태
POST /api/v1/institutions/{institutionId}/advertisements 광고 신청 OWNER ❌ 구현 필요
GET /api/v1/institutions/{institutionId}/advertisements 내 기관 광고 목록 OWNER, MANAGER ❌ 구현 필요
GET /api/v1/institutions/{institutionId}/advertisements/{adId} 광고 상세 조회 OWNER, MANAGER ❌ 구현 필요
PATCH /api/v1/institutions/{institutionId}/advertisements/{adId}/cancel 광고 취소 OWNER ❌ 구현 필요
POST /api/v1/institutions/{institutionId}/advertisements/{adId}/extend 광고 기간 연장 신청 OWNER ❌ 구현 필요

Part 2: 관리자 광고 심사 및 관리

Method Endpoint 설명 권한 상태
GET /api/v1/admin/advertisements 전체 광고 목록 (심사용) ADMIN ❌ 구현 필요
GET /api/v1/admin/advertisements/pending 승인 대기 광고 목록 ADMIN ❌ 구현 필요
GET /api/v1/admin/advertisements/{adId} 광고 상세 조회 ADMIN ❌ 구현 필요
PATCH /api/v1/admin/advertisements/{adId}/approve 광고 승인 ADMIN ❌ 구현 필요
PATCH /api/v1/admin/advertisements/{adId}/reject 광고 거절 ADMIN ❌ 구현 필요
PATCH /api/v1/admin/advertisements/{adId}/force-end 광고 강제 종료 ADMIN ❌ 구현 필요

Part 3: 공개 광고 조회 (프론트엔드 노출용)

Method Endpoint 설명 권한 상태
GET /api/v1/advertisements/active 현재 진행중인 광고 목록 공개 ❌ 구현 필요
GET /api/v1/advertisements/active/type/{type} 유형별 진행중 광고 조회 공개 ❌ 구현 필요

📊 통계

총 API 개수: 13개
✅ 완료: 0개
❌ 구현 필요: 13개

Part별 개수:
- Part 1 (기관 광고 관리): 5개
- Part 2 (관리자 심사): 6개
- Part 3 (공개 조회): 2개

🔧 상세 구현 사항

1️⃣ 기관 광고 신청

Endpoint

POST /api/v1/institutions/{institutionId}/advertisements

Request DTO

@Schema(description = "광고 신청 요청")
public record AdvertisementCreateRequestDto(
    @Schema(description = "광고 유형", example = "MAIN_BANNER")
    @NotNull(message = "광고 유형은 필수입니다")
    AdvertisementType type,
    
    @Schema(description = "광고 시작 일시", example = "2025-11-20T00:00:00")
    @NotNull(message = "시작 일시는 필수입니다")
    @Future(message = "시작 일시는 현재 이후여야 합니다")
    LocalDateTime startDateTime,
    
    @Schema(description = "광고 종료 일시", example = "2025-11-27T23:59:59")
    @NotNull(message = "종료 일시는 필수입니다")
    @Future(message = "종료 일시는 현재 이후여야 합니다")
    LocalDateTime endDateTime,
    
    @Schema(description = "광고 제목", example = "서울 최고의 요양원")
    @NotBlank(message = "광고 제목은 필수입니다")
    @Size(max = 100, message = "제목은 100자 이하여야 합니다")
    String title,
    
    @Schema(description = "광고 설명", example = "전문 간호사 24시간 상주")
    @Size(max = 500, message = "설명은 500자 이하여야 합니다")
    String description,
    
    @Schema(description = "광고 배너 이미지 URL", example = "https://...")
    String bannerImageUrl
) {
    public void validate() {
        if (endDateTime.isBefore(startDateTime)) {
            throw new BusinessException(ErrorCode.INVALID_ADVERTISEMENT_PERIOD);
        }
        if (endDateTime.isBefore(LocalDateTime.now().plusDays(1))) {
            throw new BusinessException(ErrorCode.ADVERTISEMENT_PERIOD_TOO_SHORT);
        }
    }
}

Response DTO

@Schema(description = "광고 신청 응답")
public record AdvertisementResponseDto(
    @Schema(description = "광고 ID")
    Long id,
    
    @Schema(description = "기관 ID")
    Long institutionId,
    
    @Schema(description = "기관명")
    String institutionName,
    
    @Schema(description = "광고 유형")
    AdvertisementType type,
    
    @Schema(description = "광고 상태")
    AdvertisementStatus status,
    
    @Schema(description = "광고 제목")
    String title,
    
    @Schema(description = "광고 설명")
    String description,
    
    @Schema(description = "배너 이미지 URL")
    String bannerImageUrl,
    
    @Schema(description = "시작 일시")
    LocalDateTime startDateTime,
    
    @Schema(description = "종료 일시")
    LocalDateTime endDateTime,
    
    @Schema(description = "신청 일시")
    LocalDateTime createdAt,
    
    @Schema(description = "승인 일시 (승인된 경우)")
    LocalDateTime approvedAt,
    
    @Schema(description = "거절 사유 (거절된 경우)")
    String rejectionReason
) {
    public static AdvertisementResponseDto from(InstitutionAdvertisement ad) {
        return new AdvertisementResponseDto(
            ad.getId(),
            ad.getInstitution().getId(),
            ad.getInstitution().getName(),
            ad.getType(),
            ad.getStatus(),
            ad.getTitle(),
            ad.getDescription(),
            ad.getBannerImageUrl(),
            ad.getStartDateTime(),
            ad.getEndDateTime(),
            ad.getCreatedAt(),
            ad.getApprovedAt(),
            ad.getRejectionReason()
        );
    }
}

검증 로직

  1. 기관 권한 확인: 요청한 사용자가 해당 기관의 OWNER인지 확인
  2. 기간 유효성 검증:
    • 시작일이 현재보다 미래인지
    • 종료일이 시작일보다 이후인지
    • 최소 광고 기간 충족 (예: 최소 1일)
  3. 중복 광고 확인: 같은 기간에 같은 유형의 광고가 이미 신청되었는지 확인
  4. 광고 유형별 제한 확인:
    • MAIN_BANNER는 동시에 최대 3개까지만 진행 가능
    • PREMIUM_LIST는 동시에 최대 5개까지만 진행 가능

2️⃣ 내 기관 광고 목록 조회

Endpoint

GET /api/v1/institutions/{institutionId}/advertisements?page=0&size=20&status=REQUEST_PENDING

Query Parameters

  • page: 페이지 번호 (default: 0)
  • size: 페이지 크기 (default: 20)
  • status: 광고 상태 필터 (선택)
  • type: 광고 유형 필터 (선택)

Response

@Schema(description = "광고 목록 응답")
public record AdvertisementListResponseDto(
    List<AdvertisementSummaryDto> content,
    int page,
    int size,
    long totalElements,
    int totalPages
) {}

@Schema(description = "광고 요약 정보")
public record AdvertisementSummaryDto(
    Long id,
    AdvertisementType type,
    AdvertisementStatus status,
    String title,
    LocalDateTime startDateTime,
    LocalDateTime endDateTime,
    LocalDateTime createdAt,
    boolean isActive  // 현재 진행중인지 여부
) {}

3️⃣ 관리자 광고 승인

Endpoint

PATCH /api/v1/admin/advertisements/{adId}/approve

Request DTO

@Schema(description = "광고 승인 요청")
public record AdvertisementApproveRequestDto(
    @Schema(description = "승인 메모 (선택)", example = "검토 완료, 승인합니다")
    @Size(max = 500, message = "메모는 500자 이하여야 합니다")
    String memo
) {}

처리 로직

  1. 광고 상태가 REQUEST_PENDING인지 확인
  2. 상태를 REQUEST_APPROVED로 변경
  3. 시작 일시 체크:
    • 시작 일시가 현재 이전이면 즉시 ADVERTISEMENT_ACTIVE로 변경
    • 시작 일시가 미래면 ADVERTISEMENT_PENDING로 설정
  4. 승인 일시(approvedAt) 기록
  5. 관리자 활동 로그 저장

4️⃣ 관리자 광고 거절

Endpoint

PATCH /api/v1/admin/advertisements/{adId}/reject

Request DTO

@Schema(description = "광고 거절 요청")
public record AdvertisementRejectRequestDto(
    @Schema(description = "거절 사유 (필수)", example = "부적절한 이미지 포함")
    @NotBlank(message = "거절 사유는 필수입니다")
    @Size(max = 500, message = "거절 사유는 500자 이하여야 합니다")
    String rejectionReason
) {}

처리 로직

  1. 광고 상태가 REQUEST_PENDING인지 확인
  2. 상태를 REQUEST_REJECTED로 변경
  3. 거절 사유 저장
  4. 기관에 알림 발송 (추후 구현)

5️⃣ 광고 취소

Endpoint

PATCH /api/v1/institutions/{institutionId}/advertisements/{adId}/cancel

Request DTO

@Schema(description = "광고 취소 요청")
public record AdvertisementCancelRequestDto(
    @Schema(description = "취소 사유", example = "예산 부족")
    @Size(max = 500, message = "취소 사유는 500자 이하여야 합니다")
    String cancelReason
) {}

검증 로직

  1. 기관 권한 확인 (OWNER만 가능)
  2. 광고 상태 확인:
    • ADVERTISEMENT_ACTIVE (진행중): 취소 불가 → 관리자에게 문의
    • ADVERTISEMENT_PENDING (대기중): 취소 가능
    • REQUEST_PENDING (승인 대기): 취소 가능
    • REQUEST_APPROVED (승인 완료): 시작 전이면 취소 가능
  3. 상태를 ADVERTISEMENT_CANCELED로 변경

6️⃣ 현재 진행중인 광고 조회 (공개 API)

Endpoint

GET /api/v1/advertisements/active?type=MAIN_BANNER

Query Parameters

  • type: 광고 유형 필터 (선택)

Response

@Schema(description = "활성 광고 목록")
public record ActiveAdvertisementListDto(
    List<ActiveAdvertisementDto> advertisements
) {}

@Schema(description = "활성 광고 정보")
public record ActiveAdvertisementDto(
    Long id,
    Long institutionId,
    String institutionName,
    AdvertisementType type,
    String title,
    String description,
    String bannerImageUrl,
    LocalDateTime endDateTime  // 종료 예정 시간
) {}

조회 조건

  • status = ADVERTISEMENT_ACTIVE
  • startDateTime <= 현재 시간 <= endDateTime
  • deleted = false

🔄 광고 상태 자동 전환 (스케줄러)

스케줄러 구현

@Scheduled(cron = "0 */5 * * * *")  // 5분마다 실행
public void updateAdvertisementStatus() {
    LocalDateTime now = LocalDateTime.now();
    
    // 1. PENDING → ACTIVE (시작 시간 도래)
    List<InstitutionAdvertisement> pendingAds = repository
        .findByStatusAndStartDateTimeBefore(
            AdvertisementStatus.ADVERTISEMENT_PENDING, 
            now
        );
    pendingAds.forEach(ad -> ad.updateStatus(AdvertisementStatus.ADVERTISEMENT_ACTIVE));
    
    // 2. ACTIVE → ENDED (종료 시간 도래)
    List<InstitutionAdvertisement> activeAds = repository
        .findByStatusAndEndDateTimeBefore(
            AdvertisementStatus.ADVERTISEMENT_ACTIVE, 
            now
        );
    activeAds.forEach(ad -> ad.updateStatus(AdvertisementStatus.ADVERTISEMENT_ENDED));
}

🗂️ Entity 수정 사항

InstitutionAdvertisement Entity 추가 필드

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class InstitutionAdvertisement extends BaseEntity {
    
    // 기존 필드...
    
    // 추가 필드
    @Column(nullable = false, length = 100)
    private String title;
    
    @Column(length = 500)
    private String description;
    
    private String bannerImageUrl;
    
    private LocalDateTime approvedAt;  // 승인 일시
    
    @Column(length = 500)
    private String rejectionReason;  // 거절 사유
    
    @Column(length = 500)
    private String cancelReason;  // 취소 사유
    
    @Column(length = 500)
    private String adminMemo;  // 관리자 메모
    
    // 비즈니스 로직
    public void approve(String memo) {
        if (this.status != AdvertisementStatus.REQUEST_PENDING) {
            throw new BusinessException(ErrorCode.INVALID_ADVERTISEMENT_STATUS);
        }
        this.status = AdvertisementStatus.REQUEST_APPROVED;
        this.approvedAt = LocalDateTime.now();
        this.adminMemo = memo;
        
        // 시작 시간이 이미 지났으면 바로 ACTIVE
        if (this.startDateTime.isBefore(LocalDateTime.now())) {
            this.status = AdvertisementStatus.ADVERTISEMENT_ACTIVE;
        } else {
            this.status = AdvertisementStatus.ADVERTISEMENT_PENDING;
        }
    }
    
    public void reject(String reason) {
        if (this.status != AdvertisementStatus.REQUEST_PENDING) {
            throw new BusinessException(ErrorCode.INVALID_ADVERTISEMENT_STATUS);
        }
        this.status = AdvertisementStatus.REQUEST_REJECTED;
        this.rejectionReason = reason;
    }
    
    public void cancel(String reason) {
        if (this.status == AdvertisementStatus.ADVERTISEMENT_ACTIVE) {
            throw new BusinessException(ErrorCode.CANNOT_CANCEL_ACTIVE_ADVERTISEMENT);
        }
        if (this.status == AdvertisementStatus.ADVERTISEMENT_ENDED ||
            this.status == AdvertisementStatus.ADVERTISEMENT_CANCELED) {
            throw new BusinessException(ErrorCode.ADVERTISEMENT_ALREADY_FINISHED);
        }
        this.status = AdvertisementStatus.ADVERTISEMENT_CANCELED;
        this.cancelReason = reason;
    }
    
    public void updateStatus(AdvertisementStatus newStatus) {
        this.status = newStatus;
    }
    
    public boolean isActive() {
        return this.status == AdvertisementStatus.ADVERTISEMENT_ACTIVE &&
               LocalDateTime.now().isAfter(this.startDateTime) &&
               LocalDateTime.now().isBefore(this.endDateTime);
    }
}

🎯 ErrorCode 추가

// 광고 관련 에러 코드
ADVERTISEMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "AD001", "광고를 찾을 수 없습니다"),
INVALID_ADVERTISEMENT_PERIOD(HttpStatus.BAD_REQUEST, "AD002", "광고 기간이 올바르지 않습니다"),
ADVERTISEMENT_PERIOD_TOO_SHORT(HttpStatus.BAD_REQUEST, "AD003", "광고 기간이 너무 짧습니다 (최소 1일)"),
DUPLICATE_ADVERTISEMENT_PERIOD(HttpStatus.BAD_REQUEST, "AD004", "해당 기간에 이미 신청된 광고가 있습니다"),
INVALID_ADVERTISEMENT_STATUS(HttpStatus.BAD_REQUEST, "AD005", "현재 광고 상태에서는 해당 작업을 수행할 수 없습니다"),
CANNOT_CANCEL_ACTIVE_ADVERTISEMENT(HttpStatus.BAD_REQUEST, "AD006", "진행중인 광고는 취소할 수 없습니다. 관리자에게 문의하세요"),
ADVERTISEMENT_ALREADY_FINISHED(HttpStatus.BAD_REQUEST, "AD007", "이미 종료되었거나 취소된 광고입니다"),
ADVERTISEMENT_TYPE_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST, "AD008", "해당 광고 유형의 동시 진행 한도를 초과했습니다"),
ADVERTISEMENT_ACCESS_DENIED(HttpStatus.FORBIDDEN, "AD009", "광고에 접근할 권한이 없습니다");

✅ 구현 우선순위

1단계: 기본 광고 신청 및 승인 (필수)

  1. ✅ Entity 수정 (필드 추가, 비즈니스 로직)
  2. ✅ 광고 신청 API
  3. ✅ 내 기관 광고 목록 조회
  4. ✅ 관리자 승인/거절 API
  5. ✅ 광고 상세 조회

2단계: 광고 관리 및 조회 (중요)

  1. ✅ 광고 취소 API
  2. ✅ 현재 진행중인 광고 조회 (공개)
  3. ✅ 관리자 전체 광고 목록
  4. ✅ 스케줄러로 상태 자동 전환

3단계: 고급 기능 (선택)

  1. 광고 기간 연장 신청
  2. 관리자 광고 강제 종료
  3. 광고 통계 대시보드

📝 추가 고려사항

1. 광고 이미지 업로드

  • 광고 신청 시 배너 이미지 업로드 필요
  • 이미지 크기 및 형식 제한 (예: 최대 5MB, JPG/PNG)
  • 기존 FileService 활용

2. 광고 비용 계산

  • 광고 유형별 가격 정책 (추후 구현)
  • 결제 연동 (추후 확장)

3. 알림 발송

  • 광고 승인/거절 시 기관에 알림
  • 광고 시작/종료 알림

4. 통계 및 분석

  • 광고 조회수 추적
  • 광고 클릭수 추적
  • 광고 효과 분석 대시보드

🧪 테스트 시나리오

기관 테스트

  1. 광고 신청 성공

    • OWNER 권한으로 광고 신청
    • 유효한 기간 입력
    • 광고 제목, 설명, 이미지 포함
  2. 광고 신청 실패

    • 종료일이 시작일보다 이전
    • 시작일이 과거
    • 중복 기간 광고 존재
  3. 광고 취소

    • PENDING 상태 광고 취소 성공
    • ACTIVE 상태 광고 취소 실패

관리자 테스트

  1. 광고 승인

    • 승인 대기 광고 승인
    • 즉시 시작 (시작일이 과거인 경우)
  2. 광고 거절

    • 거절 사유 입력 필수
    • 거절 후 재신청 가능
  3. 광고 목록 조회

    • 상태별 필터링
    • 유형별 필터링

스케줄러 테스트

  1. PENDING → ACTIVE 전환
  2. ACTIVE → ENDED 전환

📌 참고사항

  • 광고 기능은 기관 CRUD 완료 후 진행
  • 관리자 시스템과 연동 필요
  • 프론트엔드와 광고 노출 위치 협의 필요

🎯 배경 / 목적

✅ 완료 조건 (AC: Acceptance Criteria)

  • 조건 1
  • 조건 2
  • 조건 3

🛠️ 해결 방안 / 구현 상세

🧩 작업 범위

  • 변경 예상 파일/모듈:
  • 주요 클래스/메서드:
  • 데이터베이스 영향:
  • API 변경사항:

🧪 테스트 계획

  • 단위 테스트:
  • 통합 테스트:
  • 수동 테스트 시나리오:

🔐 보안/성능 고려사항

🚀 배포/릴리스 체크리스트

  • 환경변수/설정(yml) 변경 없음 또는 문서화 완료
  • 스키마 변경 없음 또는 마이그레이션 스크립트 준비
  • 역호환성 확인
  • API 문서(Swagger) 업데이트

🙋‍♂️ 담당자 정보

📎 참고 자료

Metadata

Metadata

Assignees

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions