From e96b244b3aa25957f07e36198a57aa4c36012c45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dean=5B=EB=B0=B1=EB=B3=91=EB=82=A8=5D?= Date: Mon, 17 Nov 2025 10:00:38 +0900 Subject: [PATCH] feat: add zoo sample --- .../postgres/core/ZooCoreService.java | 80 +++++++++++++ .../kamcoback/postgres/entity/ZooEntity.java | 66 ++++++++++ .../postgres/repository/ZooRepository.java | 6 + .../repository/ZooRepositoryCustom.java | 17 +++ .../repository/ZooRepositoryImpl.java | 87 ++++++++++++++ .../cd/kamcoback/zoo/ZooApiController.java | 77 ++++++++++++ .../kamco/cd/kamcoback/zoo/dto/ZooDto.java | 113 ++++++++++++++++++ .../cd/kamcoback/zoo/service/ZooService.java | 61 ++++++++++ .../db/migration/V1__Create_Animal_Table.sql | 30 +++++ .../db/migration/V2__Create_Zoo_Table.sql | 28 +++++ .../db/migration/V3__Add_Zoo_Id_To_Animal.sql | 16 +++ 11 files changed, 581 insertions(+) create mode 100644 src/main/java/com/kamco/cd/kamcoback/postgres/core/ZooCoreService.java create mode 100644 src/main/java/com/kamco/cd/kamcoback/postgres/entity/ZooEntity.java create mode 100644 src/main/java/com/kamco/cd/kamcoback/postgres/repository/ZooRepository.java create mode 100644 src/main/java/com/kamco/cd/kamcoback/postgres/repository/ZooRepositoryCustom.java create mode 100644 src/main/java/com/kamco/cd/kamcoback/postgres/repository/ZooRepositoryImpl.java create mode 100644 src/main/java/com/kamco/cd/kamcoback/zoo/ZooApiController.java create mode 100644 src/main/java/com/kamco/cd/kamcoback/zoo/dto/ZooDto.java create mode 100644 src/main/java/com/kamco/cd/kamcoback/zoo/service/ZooService.java create mode 100644 src/main/resources/db/migration/V1__Create_Animal_Table.sql create mode 100644 src/main/resources/db/migration/V2__Create_Zoo_Table.sql create mode 100644 src/main/resources/db/migration/V3__Add_Zoo_Id_To_Animal.sql diff --git a/src/main/java/com/kamco/cd/kamcoback/postgres/core/ZooCoreService.java b/src/main/java/com/kamco/cd/kamcoback/postgres/core/ZooCoreService.java new file mode 100644 index 00000000..2d96d2f8 --- /dev/null +++ b/src/main/java/com/kamco/cd/kamcoback/postgres/core/ZooCoreService.java @@ -0,0 +1,80 @@ +package com.kamco.cd.kamcoback.postgres.core; + +import com.kamco.cd.kamcoback.common.service.BaseCoreService; +import com.kamco.cd.kamcoback.postgres.entity.ZooEntity; +import com.kamco.cd.kamcoback.postgres.repository.ZooRepository; +import com.kamco.cd.kamcoback.zoo.dto.ZooDto; +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ZooCoreService implements BaseCoreService { + + private final ZooRepository zooRepository; + + @Transactional(readOnly = true) + public ZooDto.Detail getDataByUuid(String uuid) { + ZooEntity zoo = + zooRepository + .getZooByUuid(uuid) + .orElseThrow( + () -> + new EntityNotFoundException( + "Zoo not found with uuid: " + uuid)); + return toDetailDto(zoo); + } + + // AddReq를 받는 추가 메서드 + @Transactional + public ZooDto.Detail create(ZooDto.AddReq req) { + ZooEntity entity = new ZooEntity(req.getName(), req.getLocation(), req.getDescription()); + ZooEntity saved = zooRepository.save(entity); + return toDetailDto(saved); + } + + @Override + @Transactional + public void remove(Long id) { + ZooEntity zoo = + zooRepository + .getZooByUid(id) + .orElseThrow( + () -> new EntityNotFoundException("Zoo not found with id: " + id)); + zoo.deleted(); + } + + @Override + public ZooDto.Detail getOneById(Long id) { + ZooEntity zoo = + zooRepository + .getZooByUid(id) + .orElseThrow( + () -> new EntityNotFoundException("Zoo not found with id: " + id)); + return toDetailDto(zoo); + } + + @Override + public Page search(ZooDto.SearchReq searchReq) { + Page zooEntities = zooRepository.listZoo(searchReq); + return zooEntities.map(this::toDetailDto); + } + + // Entity -> Detail DTO 변환 (동물 개수 포함) + private ZooDto.Detail toDetailDto(ZooEntity zoo) { + Long activeAnimalCount = zooRepository.countActiveAnimals(zoo.getUid()); + return new ZooDto.Detail( + zoo.getUid(), + zoo.getUuid().toString(), + zoo.getName(), + zoo.getLocation(), + zoo.getDescription(), + zoo.getCreatedDate(), + zoo.getModifiedDate(), + activeAnimalCount); + } +} diff --git a/src/main/java/com/kamco/cd/kamcoback/postgres/entity/ZooEntity.java b/src/main/java/com/kamco/cd/kamcoback/postgres/entity/ZooEntity.java new file mode 100644 index 00000000..b135b67b --- /dev/null +++ b/src/main/java/com/kamco/cd/kamcoback/postgres/entity/ZooEntity.java @@ -0,0 +1,66 @@ +package com.kamco.cd.kamcoback.postgres.entity; + +import com.kamco.cd.kamcoback.postgres.CommonDateEntity; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "tb_zoo") +public class ZooEntity extends CommonDateEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long uid; + + @Column(unique = true, nullable = false) + private UUID uuid; + + @Column(nullable = false, length = 200) + private String name; + + @Column(length = 300) + private String location; + + @Column(columnDefinition = "TEXT") + private String description; + + @Column(nullable = false) + private Boolean isDeleted; + + @OneToMany(mappedBy = "zoo", fetch = FetchType.LAZY, cascade = CascadeType.ALL) + private List animals = new ArrayList<>(); + + // Constructor + public ZooEntity(String name, String location, String description) { + this.uuid = UUID.randomUUID(); + this.name = name; + this.location = location; + this.description = description; + this.isDeleted = false; + } + + // 논리 삭제 + public void deleted() { + this.isDeleted = true; + } + + // 현재 활성 동물 개수 조회 (삭제되지 않은 동물만) + public long getActiveAnimalCount() { + return animals.stream().filter(animal -> !animal.getIsDeleted()).count(); + } +} diff --git a/src/main/java/com/kamco/cd/kamcoback/postgres/repository/ZooRepository.java b/src/main/java/com/kamco/cd/kamcoback/postgres/repository/ZooRepository.java new file mode 100644 index 00000000..a2ee0cdd --- /dev/null +++ b/src/main/java/com/kamco/cd/kamcoback/postgres/repository/ZooRepository.java @@ -0,0 +1,6 @@ +package com.kamco.cd.kamcoback.postgres.repository; + +import com.kamco.cd.kamcoback.postgres.entity.ZooEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ZooRepository extends JpaRepository, ZooRepositoryCustom {} diff --git a/src/main/java/com/kamco/cd/kamcoback/postgres/repository/ZooRepositoryCustom.java b/src/main/java/com/kamco/cd/kamcoback/postgres/repository/ZooRepositoryCustom.java new file mode 100644 index 00000000..caf583e6 --- /dev/null +++ b/src/main/java/com/kamco/cd/kamcoback/postgres/repository/ZooRepositoryCustom.java @@ -0,0 +1,17 @@ +package com.kamco.cd.kamcoback.postgres.repository; + +import com.kamco.cd.kamcoback.postgres.entity.ZooEntity; +import com.kamco.cd.kamcoback.zoo.dto.ZooDto; +import java.util.Optional; +import org.springframework.data.domain.Page; + +public interface ZooRepositoryCustom { + + Page listZoo(ZooDto.SearchReq searchReq); + + Optional getZooByUuid(String uuid); + + Optional getZooByUid(Long uid); + + Long countActiveAnimals(Long zooId); +} diff --git a/src/main/java/com/kamco/cd/kamcoback/postgres/repository/ZooRepositoryImpl.java b/src/main/java/com/kamco/cd/kamcoback/postgres/repository/ZooRepositoryImpl.java new file mode 100644 index 00000000..62bdf94f --- /dev/null +++ b/src/main/java/com/kamco/cd/kamcoback/postgres/repository/ZooRepositoryImpl.java @@ -0,0 +1,87 @@ +package com.kamco.cd.kamcoback.postgres.repository; + +import com.kamco.cd.kamcoback.postgres.entity.QAnimalEntity; +import com.kamco.cd.kamcoback.postgres.entity.QZooEntity; +import com.kamco.cd.kamcoback.postgres.entity.ZooEntity; +import com.kamco.cd.kamcoback.zoo.dto.ZooDto; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class ZooRepositoryImpl implements ZooRepositoryCustom { + + private final JPAQueryFactory queryFactory; + private final QZooEntity qZoo = QZooEntity.zooEntity; + private final QAnimalEntity qAnimal = QAnimalEntity.animalEntity; + + @Override + public Page listZoo(ZooDto.SearchReq searchReq) { + Pageable pageable = searchReq.toPageable(); + + JPAQuery query = + queryFactory + .selectFrom(qZoo) + .where( + qZoo.isDeleted.eq(false), + nameContains(searchReq.getName()), + locationContains(searchReq.getLocation())); + + long total = query.fetchCount(); + + List content = + query.offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .orderBy(qZoo.createdDate.desc()) + .fetch(); + + return new PageImpl<>(content, pageable, total); + } + + @Override + public Optional getZooByUuid(String uuid) { + return Optional.ofNullable( + queryFactory + .selectFrom(qZoo) + .where(qZoo.uuid.eq(UUID.fromString(uuid)), qZoo.isDeleted.eq(false)) + .fetchOne()); + } + + @Override + public Optional getZooByUid(Long uid) { + return Optional.ofNullable( + queryFactory + .selectFrom(qZoo) + .where(qZoo.uid.eq(uid), qZoo.isDeleted.eq(false)) + .fetchOne()); + } + + @Override + public Long countActiveAnimals(Long zooId) { + Long count = + queryFactory + .select(qAnimal.count()) + .from(qAnimal) + .where(qAnimal.zoo.uid.eq(zooId), qAnimal.isDeleted.eq(false)) + .fetchOne(); + + return count != null ? count : 0L; + } + + private BooleanExpression nameContains(String name) { + return name != null && !name.isEmpty() ? qZoo.name.contains(name) : null; + } + + private BooleanExpression locationContains(String location) { + return location != null && !location.isEmpty() ? qZoo.location.contains(location) : null; + } +} diff --git a/src/main/java/com/kamco/cd/kamcoback/zoo/ZooApiController.java b/src/main/java/com/kamco/cd/kamcoback/zoo/ZooApiController.java new file mode 100644 index 00000000..652532a2 --- /dev/null +++ b/src/main/java/com/kamco/cd/kamcoback/zoo/ZooApiController.java @@ -0,0 +1,77 @@ +package com.kamco.cd.kamcoback.zoo; + +import com.kamco.cd.kamcoback.zoo.dto.ZooDto; +import com.kamco.cd.kamcoback.zoo.service.ZooService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping({"/api/zoos", "/v1/api/zoos"}) +public class ZooApiController { + + private final ZooService zooService; + + /** + * 동물원 생성 + * + * @param req 동물원 생성 요청 + * @return 생성된 동물원 정보 (동물 개수 포함) + */ + @PostMapping + public ResponseEntity createZoo(@RequestBody ZooDto.AddReq req) { + ZooDto.Detail created = zooService.createZoo(req); + return ResponseEntity.status(HttpStatus.CREATED).body(created); + } + + /** + * UUID로 동물원 조회 + * + * @param uuid 동물원 UUID + * @return 동물원 정보 (현재 동물 개수 포함) + */ + @GetMapping("/{uuid}") + public ResponseEntity getZoo(@PathVariable String uuid) { + Long id = zooService.getZooByUuid(uuid); + ZooDto.Detail zoo = zooService.getZoo(id); + return ResponseEntity.ok(zoo); + } + + /** + * UUID로 동물원 삭제 (논리 삭제) + * + * @param uuid 동물원 UUID + * @return 삭제 성공 메시지 + */ + @DeleteMapping("/{uuid}") + public ResponseEntity deleteZoo(@PathVariable String uuid) { + Long id = zooService.getZooByUuid(uuid); + zooService.deleteZoo(id); + return ResponseEntity.noContent().build(); + } + + /** + * 동물원 검색 (페이징) + * + * @param name 동물원 이름 (선택) + * @param location 위치 (선택) + * @param page 페이지 번호 (기본값: 0) + * @param size 페이지 크기 (기본값: 20) + * @param sort 정렬 조건 (예: "name,asc") + * @return 페이징 처리된 동물원 목록 (각 동물원의 현재 동물 개수 포함) + */ + @GetMapping + public ResponseEntity> searchZoos( + @RequestParam(required = false) String name, + @RequestParam(required = false) String location, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) String sort) { + ZooDto.SearchReq searchReq = new ZooDto.SearchReq(name, location, page, size, sort); + Page zoos = zooService.search(searchReq); + return ResponseEntity.ok(zoos); + } +} diff --git a/src/main/java/com/kamco/cd/kamcoback/zoo/dto/ZooDto.java b/src/main/java/com/kamco/cd/kamcoback/zoo/dto/ZooDto.java new file mode 100644 index 00000000..4182278d --- /dev/null +++ b/src/main/java/com/kamco/cd/kamcoback/zoo/dto/ZooDto.java @@ -0,0 +1,113 @@ +package com.kamco.cd.kamcoback.zoo.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonIgnore; +import java.time.ZonedDateTime; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +public class ZooDto { + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + public static class AddReq { + + private String name; + private String location; + private String description; + } + + @Getter + public static class Basic { + + @JsonIgnore private Long id; + private String uuid; + private String name; + private String location; + private String description; + + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd'T'HH:mm:ssXXX", + timezone = "Asia/Seoul") + private ZonedDateTime createdDate; + + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd'T'HH:mm:ssXXX", + timezone = "Asia/Seoul") + private ZonedDateTime modifiedDate; + + public Basic( + Long id, + String uuid, + String name, + String location, + String description, + ZonedDateTime createdDate, + ZonedDateTime modifiedDate) { + this.id = id; + this.uuid = uuid; + this.name = name; + this.location = location; + this.description = description; + this.createdDate = createdDate; + this.modifiedDate = modifiedDate; + } + } + + @Getter + public static class Detail extends Basic { + + private Long activeAnimalCount; + + public Detail( + Long id, + String uuid, + String name, + String location, + String description, + ZonedDateTime createdDate, + ZonedDateTime modifiedDate, + Long activeAnimalCount) { + super(id, uuid, name, location, description, createdDate, modifiedDate); + this.activeAnimalCount = activeAnimalCount; + } + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + public static class SearchReq { + + // 검색 조건 + private String name; + private String location; + + // 페이징 파라미터 + private int page = 0; + private int size = 20; + private String sort; + + public Pageable toPageable() { + if (sort != null && !sort.isEmpty()) { + String[] sortParams = sort.split(","); + String property = sortParams[0]; + Sort.Direction direction = + sortParams.length > 1 + ? Sort.Direction.fromString(sortParams[1]) + : Sort.Direction.ASC; + return PageRequest.of(page, size, Sort.by(direction, property)); + } + return PageRequest.of(page, size); + } + } +} diff --git a/src/main/java/com/kamco/cd/kamcoback/zoo/service/ZooService.java b/src/main/java/com/kamco/cd/kamcoback/zoo/service/ZooService.java new file mode 100644 index 00000000..cf773abf --- /dev/null +++ b/src/main/java/com/kamco/cd/kamcoback/zoo/service/ZooService.java @@ -0,0 +1,61 @@ +package com.kamco.cd.kamcoback.zoo.service; + +import com.kamco.cd.kamcoback.postgres.core.ZooCoreService; +import com.kamco.cd.kamcoback.zoo.dto.ZooDto; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +@Transactional(readOnly = true) +public class ZooService { + private final ZooCoreService zooCoreService; + + // 동물원의 UUID로 id조회 + public Long getZooByUuid(String uuid) { + return zooCoreService.getDataByUuid(uuid).getId(); + } + + /** + * 동물원 생성 + * + * @param req 동물원 생성 요청 + * @return 생성된 동물원 정보 (동물 개수 포함) + */ + @Transactional + public ZooDto.Detail createZoo(ZooDto.AddReq req) { + return zooCoreService.create(req); + } + + /** + * 동물원 삭제 (논리 삭제) + * + * @param id 동물원 ID + */ + @Transactional + public void deleteZoo(Long id) { + zooCoreService.remove(id); + } + + /** + * 동물원 단건 조회 + * + * @param id 동물원 ID + * @return 동물원 정보 (동물 개수 포함) + */ + public ZooDto.Detail getZoo(Long id) { + return zooCoreService.getOneById(id); + } + + /** + * 동물원 검색 (페이징) + * + * @param searchReq 검색 조건 + * @return 페이징 처리된 동물원 목록 (각 동물원의 동물 개수 포함) + */ + public Page search(ZooDto.SearchReq searchReq) { + return zooCoreService.search(searchReq); + } +} diff --git a/src/main/resources/db/migration/V1__Create_Animal_Table.sql b/src/main/resources/db/migration/V1__Create_Animal_Table.sql new file mode 100644 index 00000000..b020bfc2 --- /dev/null +++ b/src/main/resources/db/migration/V1__Create_Animal_Table.sql @@ -0,0 +1,30 @@ +-- animal 테이블 생성 +CREATE TABLE tb_animal +( + uid BIGSERIAL PRIMARY KEY, + uuid UUID NOT NULL UNIQUE, + category VARCHAR(50), + species VARCHAR(100), + name VARCHAR(200) NOT NULL, + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_date TIMESTAMPTZ NOT NULL, + modified_date TIMESTAMPTZ NOT NULL +); + +-- 인덱스 생성 +CREATE INDEX idx_animal_uuid ON tb_animal (uuid); +CREATE INDEX idx_animal_category ON tb_animal (category); +CREATE INDEX idx_animal_species ON tb_animal (species); +CREATE INDEX idx_animal_name ON tb_animal (name); +CREATE INDEX idx_animal_is_deleted ON tb_animal (is_deleted); + +-- 주석 추가 +COMMENT ON TABLE tb_animal IS '동물원 동물 정보'; +COMMENT ON COLUMN tb_animal.uid IS '고유 식별자 (PK)'; +COMMENT ON COLUMN tb_animal.uuid IS 'UUID (Unique)'; +COMMENT ON COLUMN tb_animal.category IS '구분 (MAMMALS, BIRDS, FISH,AMPHIBIANS,REPTILES,INSECTS, INVERTEBRATES )'; +COMMENT ON COLUMN tb_animal.species IS '동물 종'; +COMMENT ON COLUMN tb_animal.name IS '동물 이름'; +COMMENT ON COLUMN tb_animal.is_deleted IS '삭제 여부'; +COMMENT ON COLUMN tb_animal.created_date IS '생성일시'; +COMMENT ON COLUMN tb_animal.modified_date IS '수정일시'; diff --git a/src/main/resources/db/migration/V2__Create_Zoo_Table.sql b/src/main/resources/db/migration/V2__Create_Zoo_Table.sql new file mode 100644 index 00000000..2c0c08e4 --- /dev/null +++ b/src/main/resources/db/migration/V2__Create_Zoo_Table.sql @@ -0,0 +1,28 @@ +-- zoo 테이블 생성 +CREATE TABLE tb_zoo +( + uid BIGSERIAL PRIMARY KEY, + uuid UUID NOT NULL UNIQUE, + name VARCHAR(200) NOT NULL, + location VARCHAR(300), + description TEXT, + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_date TIMESTAMPTZ NOT NULL, + modified_date TIMESTAMPTZ NOT NULL +); + +-- 인덱스 생성 +CREATE INDEX idx_zoo_uuid ON tb_zoo (uuid); +CREATE INDEX idx_zoo_name ON tb_zoo (name); +CREATE INDEX idx_zoo_is_deleted ON tb_zoo (is_deleted); + +-- 주석 추가 +COMMENT ON TABLE tb_zoo IS '동물원 정보'; +COMMENT ON COLUMN tb_zoo.uid IS '고유 식별자 (PK)'; +COMMENT ON COLUMN tb_zoo.uuid IS 'UUID (Unique)'; +COMMENT ON COLUMN tb_zoo.name IS '동물원 이름'; +COMMENT ON COLUMN tb_zoo.location IS '위치'; +COMMENT ON COLUMN tb_zoo.description IS '설명'; +COMMENT ON COLUMN tb_zoo.is_deleted IS '삭제 여부'; +COMMENT ON COLUMN tb_zoo.created_date IS '생성일시'; +COMMENT ON COLUMN tb_zoo.modified_date IS '수정일시'; diff --git a/src/main/resources/db/migration/V3__Add_Zoo_Id_To_Animal.sql b/src/main/resources/db/migration/V3__Add_Zoo_Id_To_Animal.sql new file mode 100644 index 00000000..90b6ea81 --- /dev/null +++ b/src/main/resources/db/migration/V3__Add_Zoo_Id_To_Animal.sql @@ -0,0 +1,16 @@ +-- animal 테이블에 zoo_id 컬럼 추가 +ALTER TABLE tb_animal + ADD COLUMN zoo_id BIGINT; + +-- zoo_id에 대한 외래 키 제약조건 추가 +ALTER TABLE tb_animal + ADD CONSTRAINT fk_animal_zoo + FOREIGN KEY (zoo_id) + REFERENCES tb_zoo (uid) + ON DELETE SET NULL; + +-- zoo_id 인덱스 생성 +CREATE INDEX idx_animal_zoo_id ON tb_animal (zoo_id); + +-- 주석 추가 +COMMENT ON COLUMN tb_animal.zoo_id IS '동물원 ID (FK)';