-
Notifications
You must be signed in to change notification settings - Fork 0
Open
Labels
Description
기관 상담 예약 리팩토링 - 요일별 운영 시간 관리 설계
📌 이슈 유형
- ✨ 새 기능 추가 (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슬롯)
제약사항
✅ 비트마스킹 방식은 유지 (성능 최적화)
✅ 요일별로 다른 운영 시간 설정 가능
✅ 특정 요일 휴무 처리 가능
✅ 기존 시스템과 호환
설계 개요
핵심 아이디어
- 요일별 운영 시간 설정 엔티티 생성:
CounselOperatingHours - 비트마스크는 항상 48비트 유지: 전체 하루(00:00~23:30)를 표현
- 운영 시간 외는 자동으로 예약 불가 처리: 비트마스크 생성 시 운영 시간 외는 '0'으로 초기화
- 날짜별 상세 생성 시 요일 확인: 해당 날짜의 요일에 맞는 운영 시간 적용
아키텍처
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;
}
}비트마스킹 처리 전략
핵심 원칙
- 비트마스크는 항상 48비트(30분 단위) 또는 24비트(1시간 단위) 유지
- 운영 시간 외 슬롯은 '0'으로 고정 → 예약 불가
- 운영 시간 내 슬롯만 '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}/counselsRequest:
{
"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-hoursResponse:
{
"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-hoursRequest:
{
"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-slotsResponse:
{
"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/bulkRequest:
{
"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단계: 테이블 생성
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의 비트마스크가 새로운 운영 시간 규칙을 위반하는지 확인
// 필요시 조정결론
이 설계는 비트마스킹의 효율성을 유지하면서 요일별 운영 시간 관리를 가능하게 합니다.
핵심 포인트
- 📅 요일별 운영 시간 설정:
CounselOperatingHours엔티티로 관리 - 🎯 비트마스크 유지: 48비트(또는 24비트) 그대로 사용
- 🔒 운영 시간 외 자동 차단: 비트마스크 생성 시 '0'으로 초기화
- 🚀 성능 최적화: 비트 연산으로 빠른 처리
이 방식으로 구현하면 기관은 요일별로 자유롭게 운영 시간을 설정할 수 있고, 비트마스킹의 성능 이점도 그대로 누릴 수 있습니다!
✅ 완료 조건 (AC: Acceptance Criteria)
- 조건 1
- 조건 2
- 조건 3
🛠️ 해결 방안 / 구현 상세
🧩 작업 범위
- 변경 예상 파일/모듈:
- 주요 클래스/메서드:
- 데이터베이스 영향:
- API 변경사항:
🧪 테스트 계획
- 단위 테스트:
- 통합 테스트:
- 수동 테스트 시나리오:
🔐 보안/성능 고려사항
🚀 배포/릴리스 체크리스트
- 환경변수/설정(yml) 변경 없음 또는 문서화 완료
- 스키마 변경 없음 또는 마이그레이션 스크립트 준비
- 역호환성 확인
- API 문서(Swagger) 업데이트
🙋♂️ 담당자 정보
- 백엔드: @Uechann
📎 참고 자료
- 관련 이슈: #이슈번호
- 참고 문서:
- 디자인: