Skip to content

[REFACTOR] 기관 상담 예약 리팩토링 - 요일별 운영 시간 관리 설계 #37

@Uechann

Description

@Uechann

기관 상담 예약 리팩토링 - 요일별 운영 시간 관리 설계

📌 이슈 유형

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

🎯 배경 / 목적


요구사항 분석

핵심 요구사항

기관이 요일별로 상담 예약 가능한 시작 시간과 종료 시간을 설정할 수 있어야 함

예시 시나리오

월요일: 09:00 ~ 18:00 (9시간, 18슬롯 @ 30분 단위)
화요일: 09:00 ~ 18:00
수요일: 09:00 ~ 18:00
목요일: 09:00 ~ 18:00
금요일: 09:00 ~ 18:00
토요일: 09:00 ~ 13:00 (4시간, 8슬롯 @ 30분 단위)
일요일: 휴무 (0슬롯)

제약사항

✅ 비트마스킹 방식은 유지 (성능 최적화)
✅ 요일별로 다른 운영 시간 설정 가능
✅ 특정 요일 휴무 처리 가능
✅ 기존 시스템과 호환


설계 개요

핵심 아이디어

  1. 요일별 운영 시간 설정 엔티티 생성: CounselOperatingHours
  2. 비트마스크는 항상 48비트 유지: 전체 하루(00:00~23:30)를 표현
  3. 운영 시간 외는 자동으로 예약 불가 처리: 비트마스크 생성 시 운영 시간 외는 '0'으로 초기화
  4. 날짜별 상세 생성 시 요일 확인: 해당 날짜의 요일에 맞는 운영 시간 적용

아키텍처

InstitutionCounsel (상담 서비스)
  ├── CounselOperatingHours (요일별 운영 시간 설정) x 7개
  │     ├── dayOfWeek (월~일)
  │     ├── startTime (시작 시간)
  │     ├── endTime (종료 시간)
  │     └── isClosed (휴무 여부)
  │
  └── InstitutionCounselDetail (날짜별 시간 슬롯)
        ├── serviceDate
        └── timeSlotsBitmask (48비트)
              └── 운영 시간 내에서만 '1' 가능

데이터베이스 스키마

새 테이블: counsel_operating_hours

CREATE TABLE counsel_operating_hours (
    id BIGSERIAL PRIMARY KEY,
    institution_counsel_id BIGINT NOT NULL,
    day_of_week VARCHAR(10) NOT NULL, -- MONDAY, TUESDAY, ..., SUNDAY
    start_time TIME NOT NULL DEFAULT '09:00:00',
    end_time TIME NOT NULL DEFAULT '18:00:00',
    is_closed BOOLEAN NOT NULL DEFAULT false,
    time_slot_unit VARCHAR(20) NOT NULL DEFAULT 'MINUTES_30', -- MINUTES_30 or MINUTES_60
    created_at TIMESTAMP NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
    
    CONSTRAINT fk_counsel_operating_hours_counsel 
        FOREIGN KEY (institution_counsel_id) 
        REFERENCES institution_counsel(id) 
        ON DELETE CASCADE,
    
    -- 하나의 상담 서비스에 대해 각 요일당 하나의 운영 시간만 설정 가능
    CONSTRAINT uk_counsel_day_of_week 
        UNIQUE (institution_counsel_id, day_of_week),
    
    -- 종료 시간이 시작 시간보다 늦어야 함
    CONSTRAINT chk_operating_hours_time_range 
        CHECK (end_time > start_time OR is_closed = true)
);

-- 인덱스
CREATE INDEX idx_operating_hours_counsel_id 
    ON counsel_operating_hours(institution_counsel_id);

CREATE INDEX idx_operating_hours_day_of_week 
    ON counsel_operating_hours(institution_counsel_id, day_of_week);

기존 테이블 수정 불필요

  • institution_counsel: 변경 없음
  • institution_counsel_detail: 변경 없음 (48비트 비트마스크 유지)

엔티티 설계

Enum: DayOfWeek 활용

// Java 기본 제공 java.time.DayOfWeek 사용
// MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY

새 엔티티: CounselOperatingHours

package com.caring.caringbackend.domain.institution.counsel.entity;

import com.caring.caringbackend.global.model.BaseEntity;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.time.DayOfWeek;
import java.time.LocalTime;

/**
 * 상담 서비스의 요일별 운영 시간 설정
 * 
 * 각 상담 서비스는 요일별로 다른 운영 시간을 가질 수 있습니다.
 * - 월~금: 09:00 ~ 18:00
 * - 토: 09:00 ~ 13:00
 * - 일: 휴무
 */
@Entity
@Table(name = "counsel_operating_hours")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class CounselOperatingHours extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "institution_counsel_id", nullable = false)
    private InstitutionCounsel institutionCounsel;

    @Enumerated(EnumType.STRING)
    @Column(name = "day_of_week", nullable = false, length = 10)
    private DayOfWeek dayOfWeek;

    @Column(name = "start_time", nullable = false)
    private LocalTime startTime;

    @Column(name = "end_time", nullable = false)
    private LocalTime endTime;

    @Column(name = "is_closed", nullable = false)
    private boolean isClosed;

    @Enumerated(EnumType.STRING)
    @Column(name = "time_slot_unit", nullable = false, length = 20)
    private TimeSlotUnit timeSlotUnit;

    @Builder(access = AccessLevel.PRIVATE)
    public CounselOperatingHours(
            InstitutionCounsel institutionCounsel,
            DayOfWeek dayOfWeek,
            LocalTime startTime,
            LocalTime endTime,
            boolean isClosed,
            TimeSlotUnit timeSlotUnit) {
        this.institutionCounsel = institutionCounsel;
        this.dayOfWeek = dayOfWeek;
        this.startTime = startTime;
        this.endTime = endTime;
        this.isClosed = isClosed;
        this.timeSlotUnit = timeSlotUnit != null ? timeSlotUnit : TimeSlotUnit.MINUTES_30;
        
        validateOperatingHours();
    }

    public static CounselOperatingHours create(
            InstitutionCounsel counsel,
            DayOfWeek dayOfWeek,
            LocalTime startTime,
            LocalTime endTime,
            boolean isClosed,
            TimeSlotUnit timeSlotUnit) {
        return CounselOperatingHours.builder()
                .institutionCounsel(counsel)
                .dayOfWeek(dayOfWeek)
                .startTime(startTime)
                .endTime(endTime)
                .isClosed(isClosed)
                .timeSlotUnit(timeSlotUnit)
                .build();
    }

    /**
     * 기본 운영 시간으로 생성 (09:00 ~ 18:00)
     */
    public static CounselOperatingHours createDefault(
            InstitutionCounsel counsel,
            DayOfWeek dayOfWeek,
            TimeSlotUnit timeSlotUnit) {
        return create(
                counsel,
                dayOfWeek,
                LocalTime.of(9, 0),
                LocalTime.of(18, 0),
                false,
                timeSlotUnit
        );
    }

    /**
     * 휴무일로 생성
     */
    public static CounselOperatingHours createClosed(
            InstitutionCounsel counsel,
            DayOfWeek dayOfWeek) {
        return create(
                counsel,
                dayOfWeek,
                LocalTime.of(0, 0),
                LocalTime.of(0, 0),
                true,
                TimeSlotUnit.MINUTES_30
        );
    }

    /**
     * 운영 시간 수정
     */
    public void updateOperatingHours(LocalTime startTime, LocalTime endTime, boolean isClosed) {
        this.startTime = startTime;
        this.endTime = endTime;
        this.isClosed = isClosed;
        validateOperatingHours();
    }

    /**
     * 시간 단위 변경
     */
    public void updateTimeSlotUnit(TimeSlotUnit newUnit) {
        this.timeSlotUnit = newUnit;
    }

    /**
     * 특정 시간이 운영 시간 내인지 확인
     */
    public boolean isWithinOperatingHours(LocalTime time) {
        if (isClosed) {
            return false;
        }
        return !time.isBefore(startTime) && time.isBefore(endTime);
    }

    /**
     * 특정 슬롯이 운영 시간 내인지 확인
     */
    public boolean isSlotWithinOperatingHours(int slotIndex) {
        if (isClosed) {
            return false;
        }

        String timeStr = timeSlotUnit.slotIndexToTime(slotIndex);
        String[] parts = timeStr.split(":");
        LocalTime slotTime = LocalTime.of(Integer.parseInt(parts[0]), Integer.parseInt(parts[1]));

        return isWithinOperatingHours(slotTime);
    }

    /**
     * 운영 시간에 해당하는 비트마스크 생성
     * 운영 시간 내: '1' (예약 가능)
     * 운영 시간 외: '0' (예약 불가)
     */
    public String generateBitmaskForOperatingHours() {
        if (isClosed) {
            // 휴무일: 모든 슬롯 예약 불가
            return "0".repeat(timeSlotUnit.getSlotsPerDay());
        }

        char[] bitmask = new char[timeSlotUnit.getSlotsPerDay()];

        for (int i = 0; i < timeSlotUnit.getSlotsPerDay(); i++) {
            bitmask[i] = isSlotWithinOperatingHours(i) ? '1' : '0';
        }

        return new String(bitmask);
    }

    /**
     * 운영 시간 슬롯 개수 반환
     */
    public int getOperatingSlotCount() {
        if (isClosed) {
            return 0;
        }

        int count = 0;
        for (int i = 0; i < timeSlotUnit.getSlotsPerDay(); i++) {
            if (isSlotWithinOperatingHours(i)) {
                count++;
            }
        }
        return count;
    }

    private void validateOperatingHours() {
        if (!isClosed && (startTime == null || endTime == null)) {
            throw new IllegalArgumentException("운영일인 경우 시작/종료 시간은 필수입니다.");
        }

        if (!isClosed && !endTime.isAfter(startTime)) {
            throw new IllegalArgumentException("종료 시간은 시작 시간보다 늦어야 합니다.");
        }
    }
}

수정: InstitutionCounsel

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class InstitutionCounsel extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "institution_id", nullable = false)
    private Institution institution;

    @Column(nullable = false, length = 100)
    private String title;

    @Column(length = 500)
    private String description;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private CounselStatus status = CounselStatus.ACTIVE;

    // ✨ 추가: 요일별 운영 시간 설정
    @OneToMany(mappedBy = "institutionCounsel", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<CounselOperatingHours> operatingHours = new ArrayList<>();

    @Builder(access = AccessLevel.PRIVATE)
    public InstitutionCounsel(
            Institution institution,
            String title,
            String description) {
        this.institution = institution;
        this.title = title;
        this.description = description;
    }

    public static InstitutionCounsel createWithDefaultOperatingHours(
            Institution institution,
            String title,
            String description,
            TimeSlotUnit timeSlotUnit) {
        InstitutionCounsel counsel = InstitutionCounsel.builder()
                .institution(institution)
                .title(title)
                .description(description)
                .build();

        // 기본 운영 시간 생성 (월~금: 09:00-18:00, 토: 09:00-13:00, 일: 휴무)
        counsel.initializeDefaultOperatingHours(timeSlotUnit);

        institution.addCounsel(counsel);
        return counsel;
    }

    /**
     * 기본 운영 시간 초기화
     * 월~금: 09:00 ~ 18:00
     * 토: 09:00 ~ 13:00
     * 일: 휴무
     */
    private void initializeDefaultOperatingHours(TimeSlotUnit timeSlotUnit) {
        // 월~금: 09:00 ~ 18:00
        for (DayOfWeek day : List.of(DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, 
                                      DayOfWeek.THURSDAY, DayOfWeek.FRIDAY)) {
            operatingHours.add(CounselOperatingHours.create(
                    this, day, LocalTime.of(9, 0), LocalTime.of(18, 0), false, timeSlotUnit));
        }

        // 토: 09:00 ~ 13:00
        operatingHours.add(CounselOperatingHours.create(
                this, DayOfWeek.SATURDAY, LocalTime.of(9, 0), LocalTime.of(13, 0), false, timeSlotUnit));

        // 일: 휴무
        operatingHours.add(CounselOperatingHours.createClosed(this, DayOfWeek.SUNDAY));
    }

    /**
     * 특정 요일의 운영 시간 조회
     */
    public CounselOperatingHours getOperatingHoursForDay(DayOfWeek dayOfWeek) {
        return operatingHours.stream()
                .filter(oh -> oh.getDayOfWeek() == dayOfWeek)
                .findFirst()
                .orElseThrow(() -> new BusinessException(ErrorCode.OPERATING_HOURS_NOT_FOUND));
    }

    /**
     * 특정 날짜의 운영 시간 조회
     */
    public CounselOperatingHours getOperatingHoursForDate(LocalDate date) {
        DayOfWeek dayOfWeek = date.getDayOfWeek();
        return getOperatingHoursForDay(dayOfWeek);
    }

    /**
     * 모든 운영 시간 업데이트
     */
    public void updateAllOperatingHours(Map<DayOfWeek, OperatingHoursUpdateDto> updates) {
        updates.forEach((day, dto) -> {
            CounselOperatingHours hours = getOperatingHoursForDay(day);
            hours.updateOperatingHours(dto.getStartTime(), dto.getEndTime(), dto.isClosed());
        });
    }

    // ...기존 메서드들
}

Enum: TimeSlotUnit (기존 유지 + 개선)

package com.caring.caringbackend.domain.institution.counsel.entity;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum TimeSlotUnit {
    MINUTES_30(30, 48, "30분 단위"),
    MINUTES_60(60, 24, "1시간 단위");

    private final int minutes;
    private final int slotsPerDay;
    private final String description;

    /**
     * 시간과 분을 슬롯 인덱스로 변환
     */
    public int timeToSlotIndex(int hour, int minute) {
        if (this == MINUTES_30) {
            return hour * 2 + (minute >= 30 ? 1 : 0);
        } else { // MINUTES_60
            return hour;
        }
    }

    /**
     * LocalTime을 슬롯 인덱스로 변환
     */
    public int timeToSlotIndex(LocalTime time) {
        return timeToSlotIndex(time.getHour(), time.getMinute());
    }

    /**
     * 슬롯 인덱스를 시간 문자열로 변환 ("09:00", "09:30" 등)
     */
    public String slotIndexToTime(int slotIndex) {
        if (this == MINUTES_30) {
            int hour = slotIndex / 2;
            int minute = (slotIndex % 2) * 30;
            return String.format("%02d:%02d", hour, minute);
        } else { // MINUTES_60
            return String.format("%02d:00", slotIndex);
        }
    }

    /**
     * 슬롯 인덱스를 LocalTime으로 변환
     */
    public LocalTime slotIndexToLocalTime(int slotIndex) {
        if (this == MINUTES_30) {
            int hour = slotIndex / 2;
            int minute = (slotIndex % 2) * 30;
            return LocalTime.of(hour, minute);
        } else { // MINUTES_60
            return LocalTime.of(slotIndex, 0);
        }
    }

    public boolean isValidSlotIndex(int slotIndex) {
        return slotIndex >= 0 && slotIndex < slotsPerDay;
    }

    public String createEmptyBitmask() {
        return "1".repeat(slotsPerDay);
    }

    public String createFullBitmask() {
        return "0".repeat(slotsPerDay);
    }
}

비즈니스 로직

Repository: CounselOperatingHoursRepository

package com.caring.caringbackend.domain.institution.counsel.repository;

import com.caring.caringbackend.domain.institution.counsel.entity.CounselOperatingHours;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.time.DayOfWeek;
import java.util.List;
import java.util.Optional;

public interface CounselOperatingHoursRepository extends JpaRepository<CounselOperatingHours, Long> {

    /**
     * 특정 상담 서비스의 모든 운영 시간 조회
     */
    List<CounselOperatingHours> findByInstitutionCounselId(Long counselId);

    /**
     * 특정 상담 서비스의 특정 요일 운영 시간 조회
     */
    @Query("SELECT oh FROM CounselOperatingHours oh " +
           "WHERE oh.institutionCounsel.id = :counselId " +
           "AND oh.dayOfWeek = :dayOfWeek")
    Optional<CounselOperatingHours> findByCounselIdAndDayOfWeek(
            @Param("counselId") Long counselId,
            @Param("dayOfWeek") DayOfWeek dayOfWeek);

    /**
     * 특정 상담 서비스의 운영일(휴무가 아닌 날) 조회
     */
    @Query("SELECT oh FROM CounselOperatingHours oh " +
           "WHERE oh.institutionCounsel.id = :counselId " +
           "AND oh.isClosed = false " +
           "ORDER BY oh.dayOfWeek")
    List<CounselOperatingHours> findOperatingDays(@Param("counselId") Long counselId);
}

Service: 운영 시간 관리

package com.caring.caringbackend.domain.institution.counsel.service;

import com.caring.caringbackend.domain.institution.counsel.entity.*;
import com.caring.caringbackend.domain.institution.counsel.repository.*;
import com.caring.caringbackend.global.exception.BusinessException;
import com.caring.caringbackend.global.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.List;
import java.util.Map;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class CounselOperatingHoursService {

    private final CounselOperatingHoursRepository operatingHoursRepository;
    private final InstitutionCounselRepository counselRepository;

    /**
     * 특정 상담 서비스의 모든 운영 시간 조회
     */
    public List<CounselOperatingHours> getOperatingHours(Long counselId) {
        return operatingHoursRepository.findByInstitutionCounselId(counselId);
    }

    /**
     * 특정 요일의 운영 시간 조회
     */
    public CounselOperatingHours getOperatingHoursForDay(Long counselId, DayOfWeek dayOfWeek) {
        return operatingHoursRepository.findByCounselIdAndDayOfWeek(counselId, dayOfWeek)
                .orElseThrow(() -> new BusinessException(ErrorCode.OPERATING_HOURS_NOT_FOUND));
    }

    /**
     * 운영 시간 수정
     */
    @Transactional
    public CounselOperatingHours updateOperatingHours(
            Long counselId,
            DayOfWeek dayOfWeek,
            LocalTime startTime,
            LocalTime endTime,
            boolean isClosed) {
        
        CounselOperatingHours hours = getOperatingHoursForDay(counselId, dayOfWeek);
        hours.updateOperatingHours(startTime, endTime, isClosed);
        return hours;
    }

    /**
     * 일괄 운영 시간 수정
     */
    @Transactional
    public void updateBulkOperatingHours(
            Long counselId,
            Map<DayOfWeek, OperatingHoursUpdateDto> updates) {
        
        InstitutionCounsel counsel = counselRepository.findById(counselId)
                .orElseThrow(() -> new BusinessException(ErrorCode.COUNSEL_NOT_FOUND));

        counsel.updateAllOperatingHours(updates);
    }
}

Service: 날짜별 상세 생성 (수정)

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class InstitutionCounselDetailServiceImpl implements InstitutionCounselDetailService {

    private final InstitutionCounselDetailRepository detailRepository;
    private final InstitutionCounselRepository counselRepository;

    /**
     * 날짜별 시간 슬롯 생성 (운영 시간 자동 반영)
     */
    @Transactional
    public InstitutionCounselDetail createCounselDetail(Long counselId, LocalDate serviceDate) {
        InstitutionCounsel counsel = counselRepository.findById(counselId)
                .orElseThrow(() -> new BusinessException(ErrorCode.COUNSEL_NOT_FOUND));

        // 중복 체크
        if (detailRepository.existsByCounselIdAndDate(counselId, serviceDate)) {
            throw new BusinessException(ErrorCode.COUNSEL_DETAIL_ALREADY_EXISTS);
        }

        // 해당 날짜의 요일에 맞는 운영 시간 조회
        CounselOperatingHours operatingHours = counsel.getOperatingHoursForDate(serviceDate);

        // 운영 시간에 맞는 비트마스크 생성
        String bitmask = operatingHours.generateBitmaskForOperatingHours();

        // 상세 생성
        InstitutionCounselDetail detail = InstitutionCounselDetail.builder()
                .institutionCounsel(counsel)
                .serviceDate(serviceDate)
                .timeSlotsBitmask(bitmask)
                .build();

        return detailRepository.save(detail);
    }

    /**
     * 특정 기간의 시간 슬롯 일괄 생성 (운영일만)
     */
    @Transactional
    public List<InstitutionCounselDetail> createCounselDetailsForPeriod(
            Long counselId,
            LocalDate startDate,
            LocalDate endDate) {
        
        InstitutionCounsel counsel = counselRepository.findById(counselId)
                .orElseThrow(() -> new BusinessException(ErrorCode.COUNSEL_NOT_FOUND));

        List<InstitutionCounselDetail> details = new ArrayList<>();
        LocalDate currentDate = startDate;

        while (!currentDate.isAfter(endDate)) {
            // 해당 날짜의 운영 시간 확인
            CounselOperatingHours operatingHours = counsel.getOperatingHoursForDate(currentDate);

            // 휴무일이 아니고, 아직 생성되지 않은 경우에만 생성
            if (!operatingHours.isClosed() && 
                !detailRepository.existsByCounselIdAndDate(counselId, currentDate)) {
                
                InstitutionCounselDetail detail = createCounselDetail(counselId, currentDate);
                details.add(detail);
            }

            currentDate = currentDate.plusDays(1);
        }

        return details;
    }
}

비트마스킹 처리 전략

핵심 원칙

  1. 비트마스크는 항상 48비트(30분 단위) 또는 24비트(1시간 단위) 유지
  2. 운영 시간 외 슬롯은 '0'으로 고정 → 예약 불가
  3. 운영 시간 내 슬롯만 '1'로 시작 → 예약 가능 (이후 예약되면 '0'으로 변경)

비트마스크 생성 로직

/**
 * 운영 시간에 해당하는 비트마스크 생성
 * 
 * 예시: 월요일 09:00 ~ 18:00 (30분 단위)
 * 
 * 00:00 ~ 08:30 → 0 (운영 시간 외)
 * 09:00 ~ 17:30 → 1 (예약 가능)
 * 18:00 ~ 23:30 → 0 (운영 시간 외)
 * 
 * 결과: "000000000000000000111111111111111111000000000000"
 *       (18개 0) (18개 1) (12개 0) = 총 48개
 */
public String generateBitmaskForOperatingHours() {
    if (isClosed) {
        return "0".repeat(timeSlotUnit.getSlotsPerDay());
    }

    char[] bitmask = new char[timeSlotUnit.getSlotsPerDay()];

    for (int i = 0; i < timeSlotUnit.getSlotsPerDay(); i++) {
        LocalTime slotTime = timeSlotUnit.slotIndexToLocalTime(i);
        
        // 슬롯 시간이 운영 시간 내인지 확인
        boolean isWithinOperatingHours = 
            !slotTime.isBefore(startTime) && slotTime.isBefore(endTime);
        
        bitmask[i] = isWithinOperatingHours ? '1' : '0';
    }

    return new String(bitmask);
}

예시: 다양한 운영 시간 패턴

// 월요일: 09:00 ~ 18:00 (30분 단위)
// 슬롯 인덱스: 0(00:00) ~ 17(08:30) → '0'
//             18(09:00) ~ 35(17:30) → '1' (18개)
//             36(18:00) ~ 47(23:30) → '0'
"000000000000000000111111111111111111000000000000"

// 토요일: 09:00 ~ 13:00 (30분 단위)
// 슬롯 인덱스: 0 ~ 17 → '0'
//             18(09:00) ~ 25(12:30) → '1' (8개)
//             26(13:00) ~ 47 → '0'
"000000000000000000111111110000000000000000000000"

// 일요일: 휴무
"000000000000000000000000000000000000000000000000"

// 화요일: 10:00 ~ 16:00 (1시간 단위)
// 슬롯 인덱스: 0(00:00) ~ 9(09:00) → '0'
//             10(10:00) ~ 15(15:00) → '1' (6개)
//             16(16:00) ~ 23(23:00) → '0'
"000000000011111100000000"

API 설계

1. 상담 서비스 생성 (기본 운영 시간 포함)

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

Request:

{
  "title": "방문 상담",
  "description": "기관 방문 상담 서비스",
  "timeSlotUnit": "MINUTES_30"
}

Response:

{
  "success": true,
  "message": "상담 서비스가 생성되었습니다.",
  "data": {
    "counselId": 1,
    "title": "방문 상담",
    "timeSlotUnit": "MINUTES_30",
    "operatingHours": [
      {
        "dayOfWeek": "MONDAY",
        "startTime": "09:00",
        "endTime": "18:00",
        "isClosed": false,
        "slotCount": 18
      },
      {
        "dayOfWeek": "SUNDAY",
        "isClosed": true,
        "slotCount": 0
      }
    ]
  }
}

2. 운영 시간 조회

GET /api/v1/institutions/counsels/{counselId}/operating-hours

Response:

{
  "success": true,
  "data": {
    "counselId": 1,
    "operatingHours": [
      {
        "dayOfWeek": "MONDAY",
        "startTime": "09:00:00",
        "endTime": "18:00:00",
        "isClosed": false,
        "timeSlotUnit": "MINUTES_30",
        "totalSlots": 18,
        "operatingHoursDisplay": "09:00 ~ 18:00"
      },
      {
        "dayOfWeek": "TUESDAY",
        "startTime": "09:00:00",
        "endTime": "18:00:00",
        "isClosed": false,
        "timeSlotUnit": "MINUTES_30",
        "totalSlots": 18,
        "operatingHoursDisplay": "09:00 ~ 18:00"
      },
      {
        "dayOfWeek": "SATURDAY",
        "startTime": "09:00:00",
        "endTime": "13:00:00",
        "isClosed": false,
        "timeSlotUnit": "MINUTES_30",
        "totalSlots": 8,
        "operatingHoursDisplay": "09:00 ~ 13:00"
      },
      {
        "dayOfWeek": "SUNDAY",
        "isClosed": true,
        "operatingHoursDisplay": "휴무"
      }
    ]
  }
}

3. 운영 시간 수정 (단일 요일)

PUT /api/v1/institutions/counsels/{counselId}/operating-hours/{dayOfWeek}

Request:

{
  "startTime": "10:00",
  "endTime": "17:00",
  "isClosed": false
}

Response:

{
  "success": true,
  "message": "운영 시간이 수정되었습니다.",
  "data": {
    "dayOfWeek": "MONDAY",
    "startTime": "10:00:00",
    "endTime": "17:00:00",
    "isClosed": false,
    "totalSlots": 14
  }
}

4. 운영 시간 일괄 수정

PUT /api/v1/institutions/counsels/{counselId}/operating-hours

Request:

{
  "operatingHours": [
    {
      "dayOfWeek": "MONDAY",
      "startTime": "09:00",
      "endTime": "18:00",
      "isClosed": false
    },
    {
      "dayOfWeek": "TUESDAY",
      "startTime": "09:00",
      "endTime": "18:00",
      "isClosed": false
    },
    {
      "dayOfWeek": "SATURDAY",
      "startTime": "10:00",
      "endTime": "14:00",
      "isClosed": false
    },
    {
      "dayOfWeek": "SUNDAY",
      "isClosed": true
    }
  ]
}

5. 날짜별 예약 가능 시간 조회 (운영 시간 반영)

GET /api/v1/institutions/counsels/{counselId}/dates/{date}/available-slots

Response:

{
  "success": true,
  "data": {
    "date": "2025-12-01",
    "dayOfWeek": "MONDAY",
    "operatingHours": {
      "startTime": "09:00",
      "endTime": "18:00",
      "isClosed": false
    },
    "availableSlots": [
      { "slotIndex": 18, "time": "09:00", "available": true },
      { "slotIndex": 19, "time": "09:30", "available": true },
      { "slotIndex": 20, "time": "10:00", "available": false },
      { "slotIndex": 21, "time": "10:30", "available": true }
      // ... 운영 시간 내 슬롯만 포함
    ]
  }
}

6. 특정 기간 예약 슬롯 일괄 생성 (운영일만)

POST /api/v1/institutions/counsels/{counselId}/dates/bulk

Request:

{
  "startDate": "2025-12-01",
  "endDate": "2025-12-31"
}

Response:

{
  "success": true,
  "message": "예약 슬롯이 생성되었습니다.",
  "data": {
    "totalDays": 31,
    "createdDays": 26,
    "skippedDays": 5,
    "details": [
      {
        "date": "2025-12-01",
        "dayOfWeek": "MONDAY",
        "created": true,
        "availableSlots": 18
      },
      {
        "date": "2025-12-07",
        "dayOfWeek": "SUNDAY",
        "created": false,
        "reason": "휴무일"
      }
    ]
  }
}

구현 예시

DTO: OperatingHoursUpdateDto

package com.caring.caringbackend.api.institution.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.time.DayOfWeek;
import java.time.LocalTime;

@Getter
@NoArgsConstructor
@Schema(description = "운영 시간 수정 요청")
public class OperatingHoursUpdateDto {

    @Schema(description = "요일", example = "MONDAY")
    @NotNull(message = "요일은 필수입니다.")
    private DayOfWeek dayOfWeek;

    @Schema(description = "시작 시간", example = "09:00")
    private LocalTime startTime;

    @Schema(description = "종료 시간", example = "18:00")
    private LocalTime endTime;

    @Schema(description = "휴무 여부", example = "false")
    @NotNull(message = "휴무 여부는 필수입니다.")
    private boolean isClosed;

    @AssertTrue(message = "운영일인 경우 시작/종료 시간은 필수입니다.")
    private boolean isValidOperatingHours() {
        if (isClosed) {
            return true;
        }
        return startTime != null && endTime != null && endTime.isAfter(startTime);
    }
}

DTO: OperatingHoursResponse

package com.caring.caringbackend.api.institution.dto.response;

import com.caring.caringbackend.domain.institution.counsel.entity.CounselOperatingHours;
import com.caring.caringbackend.domain.institution.counsel.entity.TimeSlotUnit;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Getter;

import java.time.DayOfWeek;
import java.time.LocalTime;

@Getter
@Builder
@Schema(description = "운영 시간 응답")
public class OperatingHoursResponse {

    @Schema(description = "요일", example = "MONDAY")
    private DayOfWeek dayOfWeek;

    @Schema(description = "시작 시간", example = "09:00:00")
    private LocalTime startTime;

    @Schema(description = "종료 시간", example = "18:00:00")
    private LocalTime endTime;

    @Schema(description = "휴무 여부", example = "false")
    private boolean isClosed;

    @Schema(description = "시간 단위", example = "MINUTES_30")
    private TimeSlotUnit timeSlotUnit;

    @Schema(description = "운영 시간 내 슬롯 개수", example = "18")
    private int totalSlots;

    @Schema(description = "운영 시간 표시", example = "09:00 ~ 18:00")
    private String operatingHoursDisplay;

    public static OperatingHoursResponse from(CounselOperatingHours hours) {
        return OperatingHoursResponse.builder()
                .dayOfWeek(hours.getDayOfWeek())
                .startTime(hours.getStartTime())
                .endTime(hours.getEndTime())
                .isClosed(hours.isClosed())
                .timeSlotUnit(hours.getTimeSlotUnit())
                .totalSlots(hours.getOperatingSlotCount())
                .operatingHoursDisplay(hours.isClosed() 
                    ? "휴무" 
                    : String.format("%s ~ %s", 
                        hours.getStartTime().toString().substring(0, 5),
                        hours.getEndTime().toString().substring(0, 5)))
                .build();
    }
}

Controller: 운영 시간 관리

package com.caring.caringbackend.api.institution.controller.counsel;

import com.caring.caringbackend.api.institution.dto.request.OperatingHoursUpdateDto;
import com.caring.caringbackend.api.institution.dto.response.OperatingHoursResponse;
import com.caring.caringbackend.domain.institution.counsel.service.CounselOperatingHoursService;
import com.caring.caringbackend.global.response.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

import java.time.DayOfWeek;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@RestController
@RequestMapping("/api/v1/institutions/counsels")
@RequiredArgsConstructor
@Tag(name = "Counsel Operating Hours", description = "상담 운영 시간 관리 API")
public class CounselOperatingHoursController {

    private final CounselOperatingHoursService operatingHoursService;

    @GetMapping("/{counselId}/operating-hours")
    @Operation(summary = "운영 시간 조회", description = "특정 상담 서비스의 요일별 운영 시간을 조회합니다.")
    public ApiResponse<List<OperatingHoursResponse>> getOperatingHours(
            @PathVariable Long counselId) {
        
        List<OperatingHoursResponse> responses = operatingHoursService
                .getOperatingHours(counselId)
                .stream()
                .map(OperatingHoursResponse::from)
                .collect(Collectors.toList());

        return ApiResponse.success("운영 시간 조회 성공", responses);
    }

    @PutMapping("/{counselId}/operating-hours/{dayOfWeek}")
    @Operation(summary = "운영 시간 수정", description = "특정 요일의 운영 시간을 수정합니다.")
    public ApiResponse<OperatingHoursResponse> updateOperatingHours(
            @PathVariable Long counselId,
            @PathVariable DayOfWeek dayOfWeek,
            @Valid @RequestBody OperatingHoursUpdateDto request) {
        
        var updatedHours = operatingHoursService.updateOperatingHours(
                counselId,
                dayOfWeek,
                request.getStartTime(),
                request.getEndTime(),
                request.isClosed());

        return ApiResponse.success("운영 시간 수정 성공", OperatingHoursResponse.from(updatedHours));
    }

    @PutMapping("/{counselId}/operating-hours")
    @Operation(summary = "운영 시간 일괄 수정", description = "모든 요일의 운영 시간을 일괄 수정합니다.")
    public ApiResponse<Void> updateBulkOperatingHours(
            @PathVariable Long counselId,
            @Valid @RequestBody Map<DayOfWeek, OperatingHoursUpdateDto> request) {
        
        operatingHoursService.updateBulkOperatingHours(counselId, request);

        return ApiResponse.success("운영 시간 일괄 수정 성공", null);
    }
}

테스트 시나리오

1. 운영 시간 생성 테스트

@Test
@DisplayName("상담 서비스 생성 시 기본 운영 시간이 초기화된다")
void createCounselWithDefaultOperatingHours() {
    // given
    Institution institution = createInstitution();
    
    // when
    InstitutionCounsel counsel = InstitutionCounsel.createWithDefaultOperatingHours(
            institution, "방문 상담", "기관 방문 상담", TimeSlotUnit.MINUTES_30);
    
    // then
    assertThat(counsel.getOperatingHours()).hasSize(7); // 월~일
    
    // 월~금: 09:00 ~ 18:00
    for (DayOfWeek day : List.of(MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY)) {
        CounselOperatingHours hours = counsel.getOperatingHoursForDay(day);
        assertThat(hours.getStartTime()).isEqualTo(LocalTime.of(9, 0));
        assertThat(hours.getEndTime()).isEqualTo(LocalTime.of(18, 0));
        assertThat(hours.isClosed()).isFalse();
        assertThat(hours.getOperatingSlotCount()).isEqualTo(18); // 9시간 * 2슬롯
    }
    
    // 일요일: 휴무
    CounselOperatingHours sunday = counsel.getOperatingHoursForDay(SUNDAY);
    assertThat(sunday.isClosed()).isTrue();
    assertThat(sunday.getOperatingSlotCount()).isEqualTo(0);
}

2. 비트마스크 생성 테스트

@Test
@DisplayName("운영 시간에 맞는 비트마스크가 생성된다")
void generateBitmaskForOperatingHours() {
    // given: 09:00 ~ 18:00 (30분 단위)
    CounselOperatingHours hours = CounselOperatingHours.create(
            counsel, MONDAY, LocalTime.of(9, 0), LocalTime.of(18, 0), 
            false, TimeSlotUnit.MINUTES_30);
    
    // when
    String bitmask = hours.generateBitmaskForOperatingHours();
    
    // then
    assertThat(bitmask).hasSize(48);
    
    // 00:00 ~ 08:30 (슬롯 0~17): '0'
    for (int i = 0; i < 18; i++) {
        assertThat(bitmask.charAt(i)).isEqualTo('0');
    }
    
    // 09:00 ~ 17:30 (슬롯 18~35): '1'
    for (int i = 18; i < 36; i++) {
        assertThat(bitmask.charAt(i)).isEqualTo('1');
    }
    
    // 18:00 ~ 23:30 (슬롯 36~47): '0'
    for (int i = 36; i < 48; i++) {
        assertThat(bitmask.charAt(i)).isEqualTo('0');
    }
}

3. 휴무일 테스트

@Test
@DisplayName("휴무일은 모든 슬롯이 예약 불가 상태다")
void closedDayHasAllZeroBitmask() {
    // given
    CounselOperatingHours sunday = CounselOperatingHours.createClosed(counsel, SUNDAY);
    
    // when
    String bitmask = sunday.generateBitmaskForOperatingHours();
    
    // then
    assertThat(bitmask).isEqualTo("0".repeat(48));
    assertThat(sunday.getOperatingSlotCount()).isEqualTo(0);
}

장점

  1. 유연성: 요일별로 다른 운영 시간 설정 가능
  2. 비트마스킹 유지: 기존 효율적인 비트마스크 방식 그대로 사용
  3. 명확한 설계: 운영 시간과 예약 가능 시간의 분리
  4. 확장성: 향후 특별 운영일(공휴일, 이벤트) 추가 가능
  5. 성능: 비트 연산으로 빠른 예약 가능 여부 확인

마이그레이션 전략

1단계: 테이블 생성

CREATE TABLE counsel_operating_hours (
    -- 위에 정의된 스키마
);

2단계: 기존 상담 서비스에 기본 운영 시간 추가

-- 모든 기존 상담 서비스에 대해 기본 운영 시간 생성
INSERT INTO counsel_operating_hours 
    (institution_counsel_id, day_of_week, start_time, end_time, is_closed, time_slot_unit, created_at, updated_at)
SELECT 
    id,
    day_of_week,
    CASE 
        WHEN day_of_week IN ('MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY') 
        THEN '09:00:00'
        WHEN day_of_week = 'SATURDAY' 
        THEN '09:00:00'
        ELSE '00:00:00'
    END,
    CASE 
        WHEN day_of_week IN ('MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY') 
        THEN '18:00:00'
        WHEN day_of_week = 'SATURDAY' 
        THEN '13:00:00'
        ELSE '00:00:00'
    END,
    CASE 
        WHEN day_of_week = 'SUNDAY' 
        THEN true 
        ELSE false 
    END,
    'MINUTES_30',
    NOW(),
    NOW()
FROM institution_counsel
CROSS JOIN (
    SELECT unnest(ARRAY['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 
                        'FRIDAY', 'SATURDAY', 'SUNDAY']) AS day_of_week
) days;

3단계: 기존 counsel_detail 비트마스크 검증

// 기존 detail의 비트마스크가 새로운 운영 시간 규칙을 위반하는지 확인
// 필요시 조정

결론

이 설계는 비트마스킹의 효율성을 유지하면서 요일별 운영 시간 관리를 가능하게 합니다.

핵심 포인트

  1. 📅 요일별 운영 시간 설정: CounselOperatingHours 엔티티로 관리
  2. 🎯 비트마스크 유지: 48비트(또는 24비트) 그대로 사용
  3. 🔒 운영 시간 외 자동 차단: 비트마스크 생성 시 '0'으로 초기화
  4. 🚀 성능 최적화: 비트 연산으로 빠른 처리

이 방식으로 구현하면 기관은 요일별로 자유롭게 운영 시간을 설정할 수 있고, 비트마스킹의 성능 이점도 그대로 누릴 수 있습니다!

✅ 완료 조건 (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