-
Notifications
You must be signed in to change notification settings - Fork 0
Description
[FEAT] Institution(요양 기관) CRUD API 구현
📋 이슈 개요
요양 기관 관리를 위한 CRUD API를 구현합니다. Institution 엔티티와 관련된 InstitutionAdmin(기관 관리자), CareGiver(요양보호사), 전문 질환 정보 등을 함께 관리할 수 있는 RESTful API를 제공합니다.
🎯 목적
- 기관 정보 관리: 요양원, 주간보호센터 등 기관의 기본 정보 관리
- 기관 관리자 관리: 기관을 운영하는 관리자 계정 관리
- 기관 검색 및 필터링: 위치, 기관 유형, 승인 상태별 기관 검색
- 기관 승인 프로세스: 기관 등록 후 승인 대기 → 승인/거부 워크플로우
- 확장 가능성: 향후 인증 기능과 통합 가능한 구조 설계
🔍 현재 상태
도메인 엔티티 구조
Institution (요양 기관)
├── id: Long
├── name: String
├── owner: InstitutionAdmin (1:1, OWNER 역할)
├── institutionType: InstitutionType (NURSING_HOME, DAY_CARE_CENTER, etc.)
├── phoneNumber: String
├── address: Address (Embedded)
├── location: GeoPoint (Embedded)
├── approvalStatus: ApprovalStatus (PENDING, APPROVED, REJECTED)
├── bedCount: Integer
├── isAdmissionAvailable: Boolean
├── specializedConditions: List<InstitutionSpecializedCondition> (1:N)
├── priceInfo: PriceInfo (Embedded)
└── openingHours: String
InstitutionAdmin (기관 관리자)
├── id: Long
├── institution: Institution (N:1)
├── email: String (unique)
├── passwordHash: String
└── role: InstitutionAdminRole (OWNER, ADMIN, STAFF)
CareGiver (요양보호사)
├── id: Long
├── institution: Institution (N:1)
├── name: String
├── phoneNumber: String
├── certificationNumber: String
└── isActive: Boolean
InstitutionSpecializedCondition (전문 질환)
├── id: Long
├── institution: Institution (N:1)
└── specializedCondition: SpecializedCondition
현재 구현된 것
- ✅ Institution 엔티티 설계 완료
- ✅ InstitutionAdmin 엔티티 설계 완료
- ✅ CareGiver 엔티티 설계 완료
- ✅ 연관관계 매핑 완료
- ✅ ApprovalStatus, InstitutionType 등 Enum 정의 완료
구현 필요한 것
- ❌ Repository 계층
- ❌ Service 계층
- ❌ Controller 계층 (API 엔드포인트)
- ❌ DTO (Request/Response)
- ❌ 검색 및 필터링 로직
- ❌ 승인 프로세스 로직
- ❌ 예외 처리
✅ 구현 목표
API 엔드포인트
1. Institution CRUD
| Method | Endpoint | Description | 인증 필요 |
|---|---|---|---|
| POST | /api/v1/institutions |
기관 등록 신청 (PENDING 상태로 생성) | |
| GET | /api/v1/institutions |
기관 목록 조회 (검색, 필터링, 페이징) | ❌ |
| GET | /api/v1/institutions/{institutionId} |
기관 상세 조회 | ❌ |
| PUT | /api/v1/institutions/{institutionId} |
기관 정보 수정 | |
| DELETE | /api/v1/institutions/{institutionId} |
기관 삭제 | |
| PATCH | /api/v1/institutions/{institutionId}/approval |
기관 승인 처리 (관리자) | |
| PATCH | /api/v1/institutions/{institutionId}/admission-status |
입소 가능 여부 변경 |
2. Institution 검색 및 필터링
| Method | Endpoint | Description | 인증 필요 |
|---|---|---|---|
| GET | /api/v1/institutions/search |
위치 기반 검색 (반경 내 기관) | ❌ |
| GET | /api/v1/institutions/filter |
복합 필터링 (유형, 승인상태, 입소가능 등) | ❌ |
검색 파라미터 예시:
latitude,longitude,radius- 위치 기반 검색institutionType- 기관 유형approvalStatus- 승인 상태isAdmissionAvailable- 입소 가능 여부keyword- 기관명 검색
3. InstitutionAdmin CRUD (기관 관리자)
| Method | Endpoint | Description | 인증 필요 |
|---|---|---|---|
| POST | /api/v1/institutions/{institutionId}/admins |
기관 관리자 추가 | |
| GET | /api/v1/institutions/{institutionId}/admins |
기관 관리자 목록 | |
| PUT | /api/v1/institutions/{institutionId}/admins/{adminId} |
관리자 권한 수정 | |
| DELETE | /api/v1/institutions/{institutionId}/admins/{adminId} |
관리자 삭제 |
4. CareGiver CRUD (요양보호사)
| Method | Endpoint | Description | 인증 필요 |
|---|---|---|---|
| POST | /api/v1/institutions/{institutionId}/caregivers |
요양보호사 등록 | |
| GET | /api/v1/institutions/{institutionId}/caregivers |
요양보호사 목록 | ❌ |
| GET | /api/v1/institutions/{institutionId}/caregivers/{caregiverId} |
요양보호사 상세 | ❌ |
| PUT | /api/v1/institutions/{institutionId}/caregivers/{caregiverId} |
요양보호사 정보 수정 | |
| PATCH | /api/v1/institutions/{institutionId}/caregivers/{caregiverId}/active |
활동 상태 변경 | |
| DELETE | /api/v1/institutions/{institutionId}/caregivers/{caregiverId} |
요양보호사 삭제 |
⚠️ 참고: 인증 기능은 별도 이슈에서 구현 중이므로, 현재는 인증 없이 구현하되 추후 통합 가능한 구조로 설계합니다.
🛠️ 구현 계획
1단계: Repository 계층 구현
📁 domain/institution/profile/repository/InstitutionRepository.java
public interface InstitutionRepository extends JpaRepository<Institution, Long> {
// 승인 상태별 조회
Page<Institution> findByApprovalStatus(ApprovalStatus status, Pageable pageable);
// 기관 유형별 조회
Page<Institution> findByInstitutionType(InstitutionType type, Pageable pageable);
// 기관명 검색
Page<Institution> findByNameContaining(String keyword, Pageable pageable);
// 입소 가능 기관 조회
Page<Institution> findByIsAdmissionAvailableAndApprovalStatus(
Boolean isAdmissionAvailable, ApprovalStatus status, Pageable pageable);
// 위치 기반 검색 (Native Query 또는 Querydsl 활용)
@Query("SELECT i FROM Institution i WHERE " +
"i.approvalStatus = :status AND " +
"FUNCTION('ST_Distance_Sphere', " +
"POINT(i.location.longitude, i.location.latitude), " +
"POINT(:longitude, :latitude)) <= :radius")
List<Institution> findNearbyInstitutions(
@Param("latitude") double latitude,
@Param("longitude") double longitude,
@Param("radius") double radius,
@Param("status") ApprovalStatus status
);
}📁 domain/institution/profile/repository/InstitutionAdminRepository.java
public interface InstitutionAdminRepository extends JpaRepository<InstitutionAdmin, Long> {
Optional<InstitutionAdmin> findByEmail(String email);
boolean existsByEmail(String email);
List<InstitutionAdmin> findByInstitutionId(Long institutionId);
Optional<InstitutionAdmin> findByIdAndInstitutionId(Long id, Long institutionId);
// OWNER 역할 관리자 조회
Optional<InstitutionAdmin> findByInstitutionIdAndRole(Long institutionId, InstitutionAdminRole role);
}📁 domain/institution/profile/repository/CareGiverRepository.java
public interface CareGiverRepository extends JpaRepository<CareGiver, Long> {
List<CareGiver> findByInstitutionId(Long institutionId);
Optional<CareGiver> findByIdAndInstitutionId(Long id, Long institutionId);
// 활동 중인 요양보호사만 조회
List<CareGiver> findByInstitutionIdAndIsActive(Long institutionId, Boolean isActive);
// 자격증 번호로 조회
Optional<CareGiver> findByCertificationNumber(String certificationNumber);
boolean existsByCertificationNumber(String certificationNumber);
}2단계: DTO 설계 및 구현
📁 api/dto/institution/request/ (요청 DTO)
InstitutionCreateRequest.java - 기관 등록 신청
{
"name": "행복 요양원",
"institutionType": "NURSING_HOME",
"phoneNumber": "02-1234-5678",
"address": {
"zipCode": "12345",
"mainAddress": "서울시 강남구 테헤란로 123",
"detailAddress": "1층"
},
"location": {
"latitude": 37.5665,
"longitude": 126.9780
},
"bedCount": 50,
"priceInfo": {
"monthlyFee": 2000000,
"admissionFee": 5000000,
"description": "식사 및 간식 포함"
},
"openingHours": "월-일 00:00-24:00",
"ownerEmail": "owner@example.com",
"ownerPassword": "hashedPassword",
"specializedConditions": ["DEMENTIA", "STROKE"]
}InstitutionUpdateRequest.java - 기관 정보 수정
InstitutionSearchRequest.java - 기관 검색 (위치 기반)
InstitutionFilterRequest.java - 기관 필터링
ApprovalRequest.java - 승인/거부 요청
📁 api/dto/institution/response/ (응답 DTO)
InstitutionResponse.java - 기관 기본 정보
InstitutionDetailResponse.java - 기관 상세 정보 (관리자, 요양보호사 포함)
InstitutionListResponse.java - 기관 목록 (페이징)
InstitutionSearchResponse.java - 검색 결과 (거리 정보 포함)
📁 api/dto/admin/request/
InstitutionAdminCreateRequest.java - 관리자 추가
InstitutionAdminUpdateRequest.java - 관리자 권한 수정
📁 api/dto/admin/response/
InstitutionAdminResponse.java - 관리자 정보
InstitutionAdminListResponse.java - 관리자 목록
📁 api/dto/caregiver/request/
CareGiverCreateRequest.java - 요양보호사 등록
{
"name": "김요양",
"phoneNumber": "010-1234-5678",
"certificationNumber": "CG-2024-12345",
"isActive": true
}CareGiverUpdateRequest.java - 요양보호사 정보 수정
📁 api/dto/caregiver/response/
CareGiverResponse.java - 요양보호사 정보
CareGiverListResponse.java - 요양보호사 목록
3단계: Service 계층 구현
📁 domain/institution/profile/service/InstitutionService.java
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class InstitutionService {
private final InstitutionRepository institutionRepository;
private final InstitutionAdminRepository adminRepository;
/**
* 기관 등록 신청 (PENDING 상태로 생성)
* - 기관 정보와 함께 OWNER 역할의 관리자도 동시 생성
*/
@Transactional
public InstitutionResponse createInstitution(InstitutionCreateRequest request);
/**
* 기관 목록 조회 (페이징)
*/
public Page<InstitutionResponse> getInstitutions(Pageable pageable);
/**
* 기관 상세 조회
*/
public InstitutionDetailResponse getInstitution(Long institutionId);
/**
* 기관 정보 수정
*/
@Transactional
public InstitutionResponse updateInstitution(Long institutionId, InstitutionUpdateRequest request);
/**
* 기관 승인 처리 (관리자 전용)
*/
@Transactional
public InstitutionResponse approveInstitution(Long institutionId, ApprovalRequest request);
/**
* 입소 가능 여부 변경
*/
@Transactional
public InstitutionResponse updateAdmissionStatus(Long institutionId, Boolean isAvailable);
/**
* 기관 삭제 (soft delete)
*/
@Transactional
public void deleteInstitution(Long institutionId);
/**
* 위치 기반 기관 검색
*/
public List<InstitutionSearchResponse> searchNearbyInstitutions(
double latitude, double longitude, double radius);
/**
* 복합 필터링 검색
*/
public Page<InstitutionResponse> filterInstitutions(
InstitutionFilterRequest filter, Pageable pageable);
}📁 domain/institution/profile/service/InstitutionAdminService.java
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class InstitutionAdminService {
private final InstitutionAdminRepository adminRepository;
private final InstitutionRepository institutionRepository;
/**
* 기관 관리자 추가
*/
@Transactional
public InstitutionAdminResponse createAdmin(Long institutionId,
InstitutionAdminCreateRequest request);
/**
* 기관 관리자 목록 조회
*/
public List<InstitutionAdminResponse> getAdmins(Long institutionId);
/**
* 관리자 권한 수정
*/
@Transactional
public InstitutionAdminResponse updateAdminRole(Long institutionId, Long adminId,
InstitutionAdminRole newRole);
/**
* 관리자 삭제 (OWNER는 삭제 불가)
*/
@Transactional
public void deleteAdmin(Long institutionId, Long adminId);
/**
* 이메일 중복 체크
*/
public boolean isEmailExists(String email);
}📁 domain/institution/profile/service/CareGiverService.java
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class CareGiverService {
private final CareGiverRepository careGiverRepository;
private final InstitutionRepository institutionRepository;
/**
* 요양보호사 등록
*/
@Transactional
public CareGiverResponse createCareGiver(Long institutionId, CareGiverCreateRequest request);
/**
* 기관의 요양보호사 목록 조회
*/
public List<CareGiverResponse> getCareGivers(Long institutionId);
/**
* 활동 중인 요양보호사만 조회
*/
public List<CareGiverResponse> getActiveCareGivers(Long institutionId);
/**
* 요양보호사 상세 조회
*/
public CareGiverResponse getCareGiver(Long institutionId, Long caregiverId);
/**
* 요양보호사 정보 수정
*/
@Transactional
public CareGiverResponse updateCareGiver(Long institutionId, Long caregiverId,
CareGiverUpdateRequest request);
/**
* 활동 상태 변경
*/
@Transactional
public CareGiverResponse updateActiveStatus(Long institutionId, Long caregiverId, Boolean isActive);
/**
* 요양보호사 삭제
*/
@Transactional
public void deleteCareGiver(Long institutionId, Long caregiverId);
/**
* 자격증 번호 중복 체크
*/
public boolean isCertificationNumberExists(String certificationNumber);
}4단계: Controller 계층 구현
📁 api/controller/InstitutionController.java
@RestController
@RequestMapping("/api/v1/institutions")
@RequiredArgsConstructor
@Tag(name = "🏥 Institution", description = "요양 기관 관리 API")
public class InstitutionController {
private final InstitutionService institutionService;
@PostMapping
@Operation(summary = "기관 등록 신청",
description = "새로운 요양 기관을 등록 신청합니다. (승인 대기 상태)")
public ResponseEntity<ApiResponse<InstitutionResponse>> createInstitution(
@Valid @RequestBody InstitutionCreateRequest request);
@GetMapping
@Operation(summary = "기관 목록 조회", description = "등록된 기관 목록을 조회합니다.")
public ResponseEntity<ApiResponse<Page<InstitutionResponse>>> getInstitutions(
Pageable pageable);
@GetMapping("/{institutionId}")
@Operation(summary = "기관 상세 조회", description = "기관의 상세 정보를 조회합니다.")
public ResponseEntity<ApiResponse<InstitutionDetailResponse>> getInstitution(
@PathVariable Long institutionId);
@GetMapping("/search")
@Operation(summary = "위치 기반 기관 검색",
description = "현재 위치 기반으로 반경 내 기관을 검색합니다.")
public ResponseEntity<ApiResponse<List<InstitutionSearchResponse>>> searchNearby(
@RequestParam double latitude,
@RequestParam double longitude,
@RequestParam(defaultValue = "5000") double radius);
@GetMapping("/filter")
@Operation(summary = "기관 필터링", description = "다양한 조건으로 기관을 필터링합니다.")
public ResponseEntity<ApiResponse<Page<InstitutionResponse>>> filterInstitutions(
@ModelAttribute InstitutionFilterRequest filter,
Pageable pageable);
// ... 나머지 엔드포인트
}📁 api/controller/InstitutionAdminController.java
@RestController
@RequestMapping("/api/v1/institutions/{institutionId}/admins")
@RequiredArgsConstructor
@Tag(name = "👔 Institution Admin", description = "기관 관리자 관리 API")
public class InstitutionAdminController {
private final InstitutionAdminService adminService;
// CRUD 엔드포인트 구현
}📁 api/controller/CareGiverController.java
@RestController
@RequestMapping("/api/v1/institutions/{institutionId}/caregivers")
@RequiredArgsConstructor
@Tag(name = "👨⚕️ CareGiver", description = "요양보호사 관리 API")
public class CareGiverController {
private final CareGiverService careGiverService;
// CRUD 엔드포인트 구현
}5단계: 예외 처리
📁 global/exception/InstitutionException.java
public class InstitutionNotFoundException extends BusinessException {
public InstitutionNotFoundException() {
super(ErrorCode.INSTITUTION_NOT_FOUND);
}
}
public class InstitutionAlreadyApprovedException extends BusinessException {
public InstitutionAlreadyApprovedException() {
super(ErrorCode.INSTITUTION_ALREADY_APPROVED);
}
}
public class InstitutionAdminNotFoundException extends BusinessException {
public InstitutionAdminNotFoundException() {
super(ErrorCode.INSTITUTION_ADMIN_NOT_FOUND);
}
}
public class DuplicateAdminEmailException extends BusinessException {
public DuplicateAdminEmailException() {
super(ErrorCode.DUPLICATE_ADMIN_EMAIL);
}
}
public class OwnerCannotBeDeletedException extends BusinessException {
public OwnerCannotBeDeletedException() {
super(ErrorCode.OWNER_CANNOT_BE_DELETED);
}
}
public class CareGiverNotFoundException extends BusinessException {
public CareGiverNotFoundException() {
super(ErrorCode.CAREGIVER_NOT_FOUND);
}
}
public class DuplicateCertificationNumberException extends BusinessException {
public DuplicateCertificationNumberException() {
super(ErrorCode.DUPLICATE_CERTIFICATION_NUMBER);
}
}ErrorCode 추가
// Institution 관련
INSTITUTION_NOT_FOUND(404, "I001", "요양 기관을 찾을 수 없습니다."),
INSTITUTION_ALREADY_APPROVED(400, "I002", "이미 승인된 기관입니다."),
INSTITUTION_ACCESS_DENIED(403, "I003", "해당 기관에 접근할 수 없습니다."),
// InstitutionAdmin 관련
INSTITUTION_ADMIN_NOT_FOUND(404, "IA001", "기관 관리자를 찾을 수 없습니다."),
DUPLICATE_ADMIN_EMAIL(400, "IA002", "이미 사용 중인 관리자 이메일입니다."),
OWNER_CANNOT_BE_DELETED(400, "IA003", "기관장(OWNER)은 삭제할 수 없습니다."),
// CareGiver 관련
CAREGIVER_NOT_FOUND(404, "CG001", "요양보호사를 찾을 수 없습니다."),
DUPLICATE_CERTIFICATION_NUMBER(400, "CG002", "이미 등록된 자격증 번호입니다."),6단계: Entity 도메인 로직 추가
📁 domain/institution/profile/entity/Institution.java (기존 파일 수정)
public class Institution extends BaseEntity {
// ...existing fields...
/**
* 기관 정보 수정
*/
public void updateInfo(String name, InstitutionType type, String phoneNumber,
Address address, GeoPoint location, Integer bedCount,
PriceInfo priceInfo, String openingHours) {
this.name = name;
this.institutionType = type;
this.phoneNumber = phoneNumber;
this.address = address;
this.location = location;
this.bedCount = bedCount;
this.priceInfo = priceInfo;
this.openingHours = openingHours;
}
/**
* 승인 처리
*/
public void approve() {
if (this.approvalStatus == ApprovalStatus.APPROVED) {
throw new InstitutionAlreadyApprovedException();
}
this.approvalStatus = ApprovalStatus.APPROVED;
}
/**
* 승인 거부
*/
public void reject() {
this.approvalStatus = ApprovalStatus.REJECTED;
}
/**
* 입소 가능 여부 변경
*/
public void updateAdmissionAvailability(Boolean isAvailable) {
this.isAdmissionAvailable = isAvailable;
}
/**
* 기관장 설정
*/
public void setOwner(InstitutionAdmin owner) {
if (owner.getRole() != InstitutionAdminRole.OWNER) {
throw new IllegalArgumentException("OWNER 역할이 아닙니다.");
}
this.owner = owner;
}
/**
* 승인 완료 여부 확인
*/
public boolean isApproved() {
return this.approvalStatus == ApprovalStatus.APPROVED;
}
}📁 domain/institution/profile/entity/InstitutionAdmin.java (기존 파일 수정)
public class InstitutionAdmin extends BaseEntity {
// ...existing fields...
/**
* 역할 변경
*/
public void updateRole(InstitutionAdminRole newRole) {
if (this.role == InstitutionAdminRole.OWNER) {
throw new OwnerCannotBeDeletedException();
}
this.role = newRole;
}
/**
* OWNER 여부 확인
*/
public boolean isOwner() {
return this.role == InstitutionAdminRole.OWNER;
}
}📁 CareGiver Entity (위치 확인 필요)
public class CareGiver extends BaseEntity {
// ...existing fields...
/**
* 정보 수정
*/
public void updateInfo(String name, String phoneNumber, String certificationNumber) {
this.name = name;
this.phoneNumber = phoneNumber;
this.certificationNumber = certificationNumber;
}
/**
* 활동 상태 변경
*/
public void updateActiveStatus(Boolean isActive) {
this.isActive = isActive;
}
}7단계: 검색 및 필터링 고도화
Querydsl 또는 Specification 활용
복잡한 검색 조건을 처리하기 위해 Querydsl 도입 검토:
- 위치 기반 검색 (ST_Distance_Sphere)
- 동적 필터링 (기관 유형, 승인 상태, 입소 가능 여부 등)
- 정렬 (거리순, 평점순, 가격순 등)
🧪 테스트 계획
단위 테스트
- Repository 테스트 (특히 위치 기반 쿼리)
- Service 테스트 (승인 프로세스, 비즈니스 로직)
- DTO 변환 테스트
통합 테스트
- API 엔드포인트 테스트
- 기관 등록 → 승인 워크플로우 테스트
- 검색 및 필터링 테스트
- 트랜잭션 테스트
📝 주의사항
1. 인증 기능 연동 대비
- 기관 등록은 현재 누구나 가능하지만, 추후 인증된 사용자만 가능하도록 변경
- 기관 정보 수정은 해당 기관의 관리자만 가능하도록 권한 체크 로직 추가 예정
- 승인 처리는 시스템 관리자만 가능하도록 제한 예정
2. 승인 프로세스
- 기관 등록 시 자동으로
PENDING상태로 생성 - 관리자가 수동으로
APPROVED또는REJECTED처리 - 승인된 기관만 일반 사용자에게 노출
3. 기관장(OWNER) 관리
- 기관 등록 시 자동으로 OWNER 역할의 관리자 생성
- OWNER는 삭제 불가
- 기관당 OWNER는 1명만 존재
4. 위치 기반 검색
- MySQL의 Spatial Index 활용 고려
- 성능 최적화를 위한 인덱스 설정 필요
- 거리 계산은 Haversine 공식 또는 ST_Distance_Sphere 함수 사용
5. 데이터 검증
- 자격증 번호 중복 체크
- 이메일 중복 체크
- 위도/경도 유효성 검증
- 가격 정보 유효성 검증
📚 참고사항
연관된 이슈
- Member CRUD 구현 이슈 (동시 진행)
- 인증 기능 구현 이슈 (진행 중)
- 리뷰 기능 구현 이슈 (추후 연동)
우선순위
- Institution Repository 및 기본 Service 구현
- DTO 설계 및 구현
- Controller 및 기본 CRUD API 구현
- InstitutionAdmin 및 CareGiver CRUD 구현
- 검색 및 필터링 기능 구현
- 승인 프로세스 구현
- 예외 처리 및 검증
- 테스트 코드 작성
기술 스택 고려사항
- Querydsl: 복잡한 동적 쿼리 처리
- Spatial Index: 위치 기반 검색 성능 최적화
- Redis: 자주 조회되는 기관 정보 캐싱 (추후 고려)
✅ 완료 조건
- Institution Repository 구현
- InstitutionAdmin Repository 구현
- CareGiver Repository 구현
- Service 계층 비즈니스 로직 구현
- Controller 및 모든 API 엔드포인트 구현
- Request/Response DTO 구현
- 검색 및 필터링 기능 구현
- 승인 프로세스 구현
- 예외 처리 및 ErrorCode 정의
- Entity 도메인 로직 추가
- API 문서화 (Swagger)
- 단위 테스트 작성
- 통합 테스트 작성
- Postman/REST Client 테스트 완료
작성일: 2025-10-24
담당자: @Uechann
예상 소요 시간: 6-7일