개발 일지 (엔티티 설계)
오늘은, daon 서비스에 필요한 엔티티들이 거의 다 구현이 완료된 것 같아서, 전반적으로 엔티티들을 훑어보면서 설계 전략들을 짚는 시간을 가져보고자 한다.
Attraction-AttractionInfo
package com.ssafy.modu.domain.attraction.entity;
import com.ssafy.modu.domain.accessibility.entity.AccessibilityInfo;
import com.ssafy.modu.domain.attraction.entity.enums.TourDetailLoadStatus;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(
name = "attraction",
uniqueConstraints = {
@UniqueConstraint(name = "uk_attraction_content_id", columnNames = "content_id")
},
indexes = {
@Index(name = "idx_attraction_content_type", columnList = "content_type_id"),
@Index(name = "idx_attraction_ldong", columnList = "l_dong_regn_cd,l_dong_signgu_cd"),
@Index(name = "idx_attraction_location", columnList = "latitude,longitude")
}
)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Attraction {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "attraction_id")
private Long id;
@Column(name = "content_id", nullable = false, length = 30)
private String contentId;
@Column(name = "name", nullable = false, length = 200)
private String name;
@Column(name = "address", nullable = false)
private String address;
@Column(name = "address_detail")
private String addressDetail;
@Column(name = "zipcode", length = 20)
private String zipcode;
@Column(name = "latitude", nullable = false, precision = 10, scale = 7)
private BigDecimal latitude;
@Column(name = "longitude", nullable = false, precision = 10, scale = 7)
private BigDecimal longitude;
@Column(name = "content_type_id", nullable = false, length = 10)
private String contentTypeId;
@Column(name = "tel", length = 100)
private String tel;
@Column(name = "first_image_url", length = 500)
private String firstImageUrl;
@Column(name = "thumbnail_image_url", length = 500)
private String thumbnailImageUrl;
@Lob
@Column(columnDefinition = "TEXT")
private String overview;
@Column(name = "l_dong_regn_cd", nullable = false, length = 10)
private String lDongRegnCd;
@Column(name = "l_dong_signgu_cd", nullable = false, length = 10)
private String lDongSignguCd;
@Column(name = "lcls_systm1", length = 20)
private String lclsSystm1;
@Column(name = "lcls_systm2", length = 20)
private String lclsSystm2;
@Column(name = "lcls_systm3", length = 20)
private String lclsSystm3;
@Column(name = "api_created_time", nullable = false)
private LocalDateTime apiCreatedTime;
@Column(name = "api_modified_time", nullable = false)
private LocalDateTime apiModifiedTime;
@Lob
@Column(name = "homepage")
private String homepage;
@Column(name = "show_flag", nullable = false)
private Boolean showFlag = true;
@Column(name = "copyright_type", length = 20)
private String copyrightType;
/**
* detailCommon2(일반 관광 API 공통 상세) 호출 결과 상태.
* SUCCESS: item 존재 및 저장 완료, NO_DATA: item 없음(더 이상 재호출하지 않음).
*/
@Enumerated(EnumType.STRING)
@Column(name = "common_detail_status", nullable = false, length = 30)
private TourDetailLoadStatus commonDetailStatus = TourDetailLoadStatus.NOT_STARTED;
/**
* detailWithTour2(무장애 관광 API 상세) 호출 결과 상태.
* SUCCESS: item 존재 및 저장 완료, NO_DATA: item 없음(더 이상 재호출하지 않음).
*/
@Enumerated(EnumType.STRING)
@Column(name = "accessibility_status", nullable = false, length = 30)
private TourDetailLoadStatus accessibilityStatus = TourDetailLoadStatus.NOT_STARTED;
/**
* 무장애(KorWithService2) 목록에 등장한 관광지 여부.
*
* 이 값이 true이면 "무장애 상세(detailWithTour2) 및 무장애 공통상세(detailCommon2)를 우선 적재" 대상이 됨
*/
@Column(name = "accessible_candidate", nullable = false)
private boolean accessibleCandidate = false;
@Column(name = "common_detail_loaded_at")
private LocalDateTime commonDetailLoadedAt;
@Column(name = "accessibility_loaded_at")
private LocalDateTime accessibilityLoadedAt;
/**
* 목록(areaBasedSyncList2) upsert 시각.
*
* 상세 호출 여부와 무관하게, "목록 동기화"가 실행되었음을 기록
*/
@Column(name = "last_synced_at")
private LocalDateTime lastSyncedAt;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@OneToMany(mappedBy = "attraction", cascade = CascadeType.ALL, orphanRemoval = true)
private List<AccessibilityInfo> accessibilityInfos = new ArrayList<>();
public static Attraction create(String contentId, String contentTypeId) {
Attraction attraction = new Attraction();
attraction.contentId = contentId;
attraction.contentTypeId = contentTypeId;
attraction.name = "";
attraction.address = "";
attraction.latitude = BigDecimal.ZERO;
attraction.longitude = BigDecimal.ZERO;
attraction.lDongRegnCd = "";
attraction.lDongSignguCd = "";
attraction.apiCreatedTime = LocalDateTime.now();
attraction.apiModifiedTime = LocalDateTime.now();
attraction.showFlag = true;
attraction.commonDetailStatus = TourDetailLoadStatus.NOT_STARTED;
attraction.accessibilityStatus = TourDetailLoadStatus.NOT_STARTED;
attraction.accessibleCandidate = false;
return attraction;
}
// 기본 메타 정보 동기화
public void updateFromApi(
String name,
String address,
String addressDetail,
String zipcode,
BigDecimal latitude,
BigDecimal longitude,
String contentTypeId,
String tel,
String firstImageUrl,
String thumbnailImageUrl,
String overview,
String lDongRegnCd,
String lDongSignguCd,
String lclsSystm1,
String lclsSystm2,
String lclsSystm3,
LocalDateTime apiCreatedTime,
LocalDateTime apiModifiedTime,
String homepage,
Boolean showFlag,
String copyrightType
) {
this.name = defaultIfBlank(name, this.name);
this.address = defaultIfBlank(address, "");
this.addressDetail = emptyToNull(addressDetail);
this.zipcode = emptyToNull(zipcode);
this.latitude = latitude == null ? this.latitude : latitude;
this.longitude = longitude == null ? this.longitude : longitude;
this.contentTypeId = defaultIfBlank(contentTypeId, this.contentTypeId);
this.tel = emptyToNull(tel);
this.firstImageUrl = emptyToNull(firstImageUrl);
this.thumbnailImageUrl = emptyToNull(thumbnailImageUrl);
this.overview = emptyToNull(overview);
this.lDongRegnCd = defaultIfBlank(lDongRegnCd, "");
this.lDongSignguCd = defaultIfBlank(lDongSignguCd, "");
this.lclsSystm1 = emptyToNull(lclsSystm1);
this.lclsSystm2 = emptyToNull(lclsSystm2);
this.lclsSystm3 = emptyToNull(lclsSystm3);
this.apiCreatedTime = apiCreatedTime == null ? this.apiCreatedTime : apiCreatedTime;
this.apiModifiedTime = apiModifiedTime == null ? this.apiModifiedTime : apiModifiedTime;
this.homepage = emptyToNull(homepage);
this.showFlag = showFlag == null ? Boolean.TRUE : showFlag;
this.copyrightType = emptyToNull(copyrightType);
}
// 상세 메타 데이터 업데이트
public void updateCommonDetail(String homepage, String overview, String tel, String firstImageUrl, String thumbnailImageUrl) {
if (!isBlank(homepage)) this.homepage = homepage;
if (!isBlank(overview)) this.overview = overview;
if (!isBlank(tel)) this.tel = tel;
if (!isBlank(firstImageUrl)) this.firstImageUrl = firstImageUrl;
if (!isBlank(thumbnailImageUrl)) this.thumbnailImageUrl = thumbnailImageUrl;
}
public void markAccessibleCandidate() {
this.accessibleCandidate = true;
}
// 일반 정보 적재 완료 처리
public void markCommonDetailLoaded() {
this.commonDetailStatus = TourDetailLoadStatus.SUCCESS;
this.commonDetailLoadedAt = LocalDateTime.now();
}
// 무장애 정보 적재 완료 처리
public void markAccessibilityLoaded() {
this.accessibilityStatus = TourDetailLoadStatus.SUCCESS;
this.accessibilityLoadedAt = LocalDateTime.now();
}
// 애초에 items/item이 없는 기본 정보들은, 데이터 없음 처리
public void markCommonDetailNoData() {
this.commonDetailStatus = TourDetailLoadStatus.NO_DATA;
this.commonDetailLoadedAt = LocalDateTime.now();
}
// 애초에 items/item이 없는 무장애 정보들도 데이터 없음 처리
public void markAccessibilityNoData() {
this.accessibilityStatus = TourDetailLoadStatus.NO_DATA;
this.accessibilityLoadedAt = LocalDateTime.now();
}
// 동기화 시각 설정
public void markSyncedNow() {
this.lastSyncedAt = LocalDateTime.now();
}
@PrePersist
void prePersist() {
LocalDateTime now = LocalDateTime.now();
createdAt = now;
updatedAt = now;
}
@PreUpdate
void preUpdate() {
updatedAt = LocalDateTime.now();
}
private static String emptyToNull(String value) {
return isBlank(value) ? null : value.trim();
}
private static String defaultIfBlank(String value, String defaultValue) {
return isBlank(value) ? defaultValue : value.trim();
}
private static boolean isBlank(String value) {
return value == null || value.isBlank();
}
}
@UniqueConstraint
말 그대로, unique 제약을 걸기 위한 애노테이션이다.
지금 엔티티에서는, content_id(관광지 api에서 특정 관광지의 고유 id)를 unique로 받고 있다.
content_id 컬럼을 unique로 둬야한다는 건 알겠는데, 왜 이렇게 복잡하게 애노테이션을 거나?
예리한 질문이다.
지금처럼 컬럼이 하나일 때, unique 제약 조건을 걸려면 그냥 해당 필드에 @Column(unique = true) 애노테이션을 걸어주면 된다.
이렇게 @Column에 unique 옵션을 주는 것은 보다시피 간단하지만 단점이 있다.
- 여러 컬럼들을 묶은 unique 설정은 할 수 없다.
- 예를 들어, {학년, 반} 쌍으로 unique를 판별해야하는 경우, 단일 컬럼 기반으로 unique를 설정할 수 없다.
- 제약 조건의 이름을 지정할 수 없다.
- 제약 조건의 이름을 정하지 않으면, Hibernate가 내부 규칙에 의해 이름을 설정하기 때문에, 개발자 입장에서는 에러 로그를 확인할 때 이해하기가 어렵다.
그렇기 때문에, 웬만하면 확장성을 위해서, unique 옵션을 줄 때 @UniqueConstraint 애노테이션을 이용하자.
@Index
인덱스란 무엇인가
DB 테이블에서 원하는 데이터를 빠르게 찾기 위해 만들어두는 별도의 검색용 자료구조
기본적으로 PK나 UNIQUE 제약 조건이 들어가 있는 컬럼들은, 자동으로 인덱스가 생성된다.
직접적으로 @Index를 명시하는 경우는 다음과 같다.
- 자동으로 만들어지는 인덱스라도, 이름을 명시하고 싶을 때
- 1번에 해당되지 않는 나머지 컬럼들
- 여러 컬럼을 묶어서 하나의 인덱스를 만들고 싶을 때
인덱스가 필요한 상황은 언제일까
인덱스가 필요한 상황은, 다음과 같다.
- 조건을 걸었을 때, 결과가 많이 줄어드는 컬럼
- 범위 검색이 많은 컬럼
- 자주 조회하지만 자주 변경되지 않는 컬럼
- join, order by를 많이 하는 컬럼
우리 프로젝트의 경우, {시도, 시군구}와 {경도, 위도}의 인덱스가 있다.
{시도, 시군구} 인덱스를 만든 이유는, 검색 기능에서, 시도와 시군구 필터링이 가장 중요하기 때문이다.
{경도, 위도} 인덱스를 만든 이유는, 지금 기능에는 포함되어 있지 않지만, 위치 기반으로 주변 관광지를 조회할 때, {경도, 위도} 정보가 자주 쓰이기 때문에 만들어놓았다.
@Enumerated
JPA에서 enum 타입 필드를 DB에 어떤 형태로 저장할지 정하는 애노테이션
DB에 적재하는 방법은 타입에 따라서 2가지가 있다.
- EnumType.STRING
- enum 이름이 문자열로 그대로 저장된다.
- enum에 명시된 그 문자열 그대로 db에 저장이 되기 때문에, 개발자 입장에서 가독성이 좋지만, 데이터를 그만큼 더 쓴다.
- EnumType.ORDINAL
- enum이름이 명시된 순서대로 0번부터 숫자로 매핑되어 저장된다.
- 문제는, 혹시나 enum 안의 항목 순서가 바뀌면 매핑된 숫자 값도 바뀐다는 것이다.
- 이건 치명적인 문제이기 때문에, 유지보수를 고려하면 웬만하면 STRING으로 설정하는 것이 좋다.
public enum TourDetailLoadStatus {
NOT_STARTED, // 0
SUCCESS, // 1
NO_DATA // 2
}
하지만, Enumerated를 쓰지 않으면서, enum을 쓰면서 결과적으로는 INT가 저장되도록 하는 방법이 있다.
Converter와 enum 클래스를 같이 쓰는 방법으로 해결할 수 있다.
@Converter
JPA에서 엔티티 필드 값과 DB 컬럼 값을 서로 변환해주는 클래스를 만들 때 쓰는 애노테이션
Java 코드에서는 개발자들이 알아보기 쉽게 String으로 관리하고, DB에서는 숫자로 관리할 수 있기 때문에, 가독성 측면에서도 좋고 데이터 저장공간 측면에서도 좋다.
이 Converter 기능을 사용하기 위해서는 enum 클래스와 Converter 클래스가 필요하다.
enum 클래스
@Getter
public enum AccessibilityType {
PARKING(0),
ACCESSIBLE_PARKING(1),
PUBLIC_TRANSPORT(2),
ROUTE(3),
TICKET_OFFICE(4),
WHEELCHAIR(5),
EXIT(6),
ELEVATOR(7),
RESTROOM(8),
ACCESSIBLE_RESTROOM(9),
AUDITORIUM(10),
ROOM(11),
BRAILLE_BLOCK(12),
HELP_DOG(13),
GUIDE_HUMAN(14),
AUDIO_GUIDE(15),
BIG_PRINT(16),
BRAILLE_PROMOTION(17),
GUIDE_SYSTEM(18),
SIGN_GUIDE(19),
VIDEO_GUIDE(20),
STROLLER(21),
LACTATION_ROOM(22),
BABY_SPARE_CHAIR(23),
KIDS_FACILITY(24),
PET(25),
ETC(26);
private final int code;
AccessibilityType(int code) {
this.code = code;
}
public static AccessibilityType fromCode(Integer code) {
if (code == null) {
return ETC;
}
for (AccessibilityType type : values()) {
if (type.code == code) {
return type;
}
}
return ETC;
}
}
enum에 문자열 뒤에 고유 번호를 같이 기입하는 방식으로 사용한다.
fromCode 메서드를 통해, 특정 번호가 들어왔을 때 그 번호에 해당하는 enum을 리턴하도록 구현한다.
Converter 클래스
@Converter
public class AccessibilityTypeConverter
implements AttributeConverter<AccessibilityType, Integer> {
@Override
public Integer convertToDatabaseColumn(AccessibilityType attribute) {
return attribute == null ? null : attribute.getCode();
}
@Override
public AccessibilityType convertToEntityAttribute(Integer dbData) {
return AccessibilityType.fromCode(dbData);
}
}
converter 클래스는, 말 그대로, enum으로 들어오면 숫자를 반환하고, 숫자로 들어오면 enum을 반환하도록 하는 연결다리 클래스이다.
엔티티에 적용하기
@Convert(converter = AccessibilityTypeConverter.class)
@Column(name = "type", nullable = false, columnDefinition = "TINYINT")
private AccessibilityType type;
@OneToMany
본인 엔티티에, 여러개의 다른 엔티티가 속할 수 있는 구조이다.
Attraction 엔티티에는 여러 무장애 정보들이 존재한다.
하나의 Attraction이라도, 휠체어 가능, 장애인 화장실 보유 등과 같은 무장애 정보는 여러개가 있기 때문에, 하나의 Attraction에 여러 개의 AccessibilityInfo가 붙을 수 있다.
그렇다는 말은, 연관관계의 주인(foreign key를 가지고 있는 테이블)은 당연히 AccessibilityInfo 테이블이 되고, Attraction 테이블은 비주인이 된다.
mappedBy
- 연관관계의 비주인이 가지는 옵션
- 연관관계의 주인이, 본인을 어떻게 저장하는지(연관관계 주인 테이블에서 본인의 필드명) 기입한다.
fetch
- AccessibilityInfo에는 한 관광지에 대해서 26개의 레코드를 가진다.
- 따라서, Attraction 입장에서는, AccessibilityInfo를 List로 저장을 하고 있는 상황이다.
- 이 상황에서 fetch 타입이 LAZY면, Attraction이 가진 Accessibily의 원소 하나를 방문할 때마다 추가 쿼리가 나갈 수 있는 위험이 있다.
- 그렇기 때문에, n+1 문제를 야기할 수 있는 상황에서는 LAZY로 fetch Type을 두되, @EntityGraph 애노테이션을 repository에서 만들어서 관리해주는 편이 베스트이다.
cascade
- 부모의 행동을 자식이 어디까지 따라할 지 정함
- 주로 @OneToMany 옵션에서 자주 사용된다.
- 대표 옵션은 다음과 같다.
PERSIST 부모 저장 시 자식도 저장 MERGE 부모 병합 시 자식도 병합 REMOVE 부모 삭제 시 자식도 삭제 ALL 위 작업 전부 전파 - 이렇게 하면, 부모가 저장이 되어있는 상태에서, 부모와 연관된 엔티티의 연관 관계를 설정해주면, 자동으로 자식의 엔티티가 저장이 된다.
- 단, 서로의 관계가 연결이 되어있어야 한다.
orphanRemoval=true
- 컬렉션에서, 자식 엔티티를 제거하면, DB에서도 삭제한다.
- 부모 없이는 자식이 존재하면 안 되는 구조에서 잘 맞음
cascade = REMOVE vs orphanRemoval=true
- cascade = REMOVE는, 부모 엔티티가 삭제가 될 때, 그 엔티티와 연결되있던 자식 엔티티들도 삭제
- orphanRemoval=true는, 부모 엔티티 삭제가 아니라, 부모 엔티티의 컬렉션 요소가 삭제될 때 자식 엔티티 삭제
우리 프로젝트의 경우, 특정 관광지가 제거되면, 그 관광지에 대한 무장애 정보들도 저장될 이유가 없기 때문에 cascade = ALL로 설정을 해두었고, 관광지의 accessibilityInfos 컬렉션에서 특정 무장애 정보가 삭제가 된다면, 그 무장애 정보는 더이상 필요가 없는 데이터이기 때문에 orphanRemoval=true로 설정하였다.
@PrePersist
- 특정 객체가 처음으로 DB에 영구 저장이 될 때 실행되는 메서드를 정의해놓는다.
@PreUpdate
- 특정 객체가 DB 안에서 업데이트될 때의 메서드를 정의해놓는다.
Schedule-Node
Schedule-Node도 중요한 연관관계를 가지고 있다.
Schedule은 여러 Node를 가지고 있는 엔티티이고, Node는 하나의 Schedule을 바라보고 있다.
Schedule이 없어지면, 그 Schedule을 바라보고 있던 Node도 제거되어야한다.
앞서 배운 cascade를 여기서 복습해보자.
@OneToMany(mappedBy = "schedule", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("visitDate ASC, visitOrder ASC, id ASC")
private List<Node> nodes = new ArrayList<>();
public void addNode(Node node) {
nodes.add(node);
node.setSchedule(this);
}
- 노드가 추가될 때마다, 연관관계를 설정해주는 모습이다.
- 이 시점에서, Schedule은 이미 영속성 엔티티이다. 그렇기 때문에 연관관계를 설정하고 나면, 자동으로 node도 repository에 저장이 된다.
'개인 프로젝트 > modu' 카테고리의 다른 글
| [daon] 9일차 개발 회고록 (0) | 2026.06.14 |
|---|---|
| [daon] 8일차 개발 회고록 (0) | 2026.06.10 |
| [daon] 6일차 개발 회고록 (0) | 2026.05.29 |
| [daon] 5일차 개발 회고록 (1) | 2026.05.25 |
| [daon] 3일차 개발 회고록 (1) | 2026.05.12 |