
2편에서 Spring Boot + Docker Compose로 인프라를 띄웠다. 이번 편은 그 위에 데이터 그릇을 만든다.
엔티티는 별 거 아닌 것 같다. @Entity 붙이고 필드 몇 개 적으면 끝나는 거 아닌가? 그렇게 시작했다가 두 번 쓰러졌다.
length 안 적었다가 validate 실패schema.sql이 끝까지 실행되지 않아서 테이블 4개가 안 만들어짐엔티티를 작성하기 전에 패키지 구조부터 정해야 한다. 두 가지 방식 중 골라야 한다.
계층형 (Layered)
controller/
CountryController.java
EmbassyController.java
service/
CountryService.java
EmbassyService.java
entity/
Country.java
Embassy.java
도메인형 (Domain-Driven)
country/
Country.java
CountryController.java
CountryService.java
embassy/
Embassy.java
EmbassyController.java
EmbassyService.java
도메인형으로 갔다.
도메인이 늘어날수록 계층형은 관련 코드가 여러 폴더에 흩어진다. "Country 관련 코드 어딨어?" 하려면 폴더 5개를 다 뒤져야 한다. 반면 도메인형은 한 도메인이 한 폴더 안에 모인다. 변경 범위가 명확하고 응집도가 높다.
src/main/java/com/parkjjae/emergencylink/
├── common/
│ └── entity/
│ └── BaseEntity.java 공통 (created_at, updated_at)
├── country/
│ └── entity/
│ ├── Country.java
│ └── AlertLevel.java
├── embassy/
│ └── entity/
├── emergencynumber/
│ └── entity/
├── notice/
│ └── entity/
└── admin/
└── entity/
5개 엔티티에 모두 created_at, updated_at이 들어간다.
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}
핵심 어노테이션 세 개를 짚는다.
@MappedSuperclass"이 클래스 자체는 테이블이 아니다. 하지만 이걸 상속받는 자식 엔티티들은 이 안의 필드를 자기 컬럼으로 가져간다."
BaseEntity 자체로는 테이블이 안 만들어진다. Country가 BaseEntity를 상속받으면, country 테이블에 created_at, updated_at 컬럼이 생긴다.
@EntityListeners(AuditingEntityListener.class)엔티티의 생성/수정 이벤트를 감지하는 리스너를 등록한다. 이게 있어야 @CreatedDate, @LastModifiedDate가 동작한다.
@EnableJpaAuditing 필수@EnableJpaAuditing
@SpringBootApplication
public class EmergencyLinkApplication {
public static void main(String[] args) {
SpringApplication.run(EmergencyLinkApplication.class, args);
}
}
이거 빼먹으면 시간이 자동으로 안 채워진다. 함정 1.
updatable = falsecreated_at에만 붙어있다. 한 번 생성된 후엔 수정 불가. 실수로 변경되는 걸 방지한다.
여행경보 단계는 1~4 외에 절대 다른 값이 들어오면 안 된다. 이런 건 ENUM으로 만든다.
@Getter
@RequiredArgsConstructor
public enum AlertLevel {
LEVEL_1(1, "여행유의", "조심해서 가세요"),
LEVEL_2(2, "여행자제", "가능하면 가지 마세요"),
LEVEL_3(3, "출국권고", "지금 있으면 나오세요"),
LEVEL_4(4, "여행금지", "법적으로 방문 금지");
private final int level;
private final String name;
private final String description;
}
이렇게 하면 API 응답에서 단계 숫자, 한글 이름, 설명을 한 번에 보낼 수 있다. 단순히 1, 2, 3, 4만 저장하는 것보다 도메인 의미가 풍부해진다.
@Enumerated(EnumType.STRING) — 무조건 STRINGENUM을 DB에 저장하는 방식이 두 가지다.
EnumType.ORDINAL → 0, 1, 2, 3 같은 숫자로 저장 (위험)
EnumType.STRING → "LEVEL_1" 같은 문자열로 저장 (안전, 표준)
ORDINAL은 절대 쓰면 안 된다. ENUM 순서가 바뀌는 순간 기존 데이터가 다 깨진다. 예를 들어 LEVEL_2와 LEVEL_3 사이에 새 단계를 추가하면? 기존 데이터는 그대로 인덱스 2를 가리키고 있는데, 이제 그건 다른 값이다.
STRING은 enum 이름을 그대로 저장하니까 순서 바뀌어도 안전하다.
엔티티를 다 만들고 schema.sql로 테이블도 만들었다. ddl-auto는 validate. 실행했더니 빨간 ERROR가 떴다.
Schema-validation: wrong column type encountered in column [alert_level]
원인은 단순했다.
// 처음 작성한 코드
@Enumerated(EnumType.STRING)
@Column(name = "alert_level", nullable = false) // length 명시 안 함
private AlertLevel alertLevel;
@Column에 length를 명시하지 않으면 String은 VARCHAR(255)가 기본값이다.
근데 schema.sql에는 VARCHAR(20)으로 적었다.
JPA 엔티티가 기대하는 것: VARCHAR(255)
실제 DB: VARCHAR(20)
→ 불일치 → validate 실패
해결은 한 줄.
@Column(name = "alert_level", length = 20, nullable = false)
private AlertLevel alertLevel;
ENUM 값이 길어봤자 "LEVEL_1" 7글자다. 255는 메모리 낭비고, 20이면 충분하다. String 필드는 length를 항상 명시하는 게 안전하다.
@ManyToOne(fetch = LAZY)대사관은 국가에 속한다. 한 국가에 여러 대사관이 있을 수 있다. 1:N 관계.
@Entity
public class Embassy extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "country_id", nullable = false)
private Country country;
// ...
}
여기서 fetch = LAZY가 매우 중요하다.
EAGER (즉시 로딩)
Embassy 조회 시 Country도 같이 즉시 조회
→ 매번 JOIN 쿼리 발생
→ N+1 문제의 원흉
LAZY (지연 로딩)
Embassy 조회 시 Country는 안 가져옴
실제 country.getName() 호출하는 순간 그때 가져옴
→ 실무 표준이라고 한다.
무조건 LAZY로 외워두면 된다. EAGER는 의도한 게 아니라면 절대 쓰지 말 것.
공지사항은 두 종류다.
그래서 컬럼 하나로 구분하면 된다.
@Entity
public class Notice extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "country_id") // nullable = false 명시 안 함
private Country country;
// ...
}
@JoinColumn에 nullable = false를 안 적으면 기본값이 nullable = true다.
country_id = NULL → 전체 공지
country_id = 1 (JP) → 일본 공지
이걸 Nullable FK 패턴이라고 한다. 테이블 하나로 두 가지 목적을 다 처리할 수 있어서 깔끔하다.
@Entity
public class Admin extends BaseEntity {
@Column(name = "refresh_token", length = 500)
private String refreshToken;
public void updateRefreshToken(String refreshToken) {
this.refreshToken = refreshToken;
}
public void clearRefreshToken() {
this.refreshToken = null;
}
}
@Setter를 안 붙였다. 대신 updateRefreshToken(), clearRefreshToken() 메서드를 만들었다.
왜 이렇게 하는가?
@Setter 사용
admin.setRefreshToken("xxx") "이걸 set한다"
admin.setRefreshToken(null)
→ 의도가 코드에 안 드러남
의미 있는 메서드
admin.updateRefreshToken("xxx") "토큰을 갱신한다"
admin.clearRefreshToken() "토큰을 제거한다"
→ 비즈니스 의도가 명확하게 드러남
객체지향 설계 원칙 중 "객체는 메시지를 받는다"에 충실한 방식이다.
진짜 고생한 부분이다.
테이블을 한 번에 만들기 위해 schema.sql을 작성했고, application.yml에 mode: always를 설정했다.
spring:
sql:
init:
mode: always
jpa:
defer-datasource-initialization: true
근데 실행하면 country 테이블만 만들어지고 나머지 4개가 안 만들어졌다. 그런데 더 이상한 건, country를 지우고 다시 실행해도 똑같았다. country만 만들어지고 멈춤.
콘솔 로그를 처음부터 끝까지 뒤졌다. schema.sql 실행 흔적 자체가 없었다. create table 로그가 하나도 안 나왔다. country가 살아있는 건 이전에 만들어진 게 그대로 남은 거였다.
여러 가지 시도했다.
Invalidate Caches and Restart)전부 정상이었다. 근데도 schema.sql이 실행 안 됐다. Spring Boot 3.x의 SQL 초기화 동작이 환경에 따라 미묘하게 다른 듯하다. (정확한 원인은 끝까지 못 잡았다. 나중에 시간 될 때 다시 파볼 예정)
mode: never로 우회너무 시간을 오래 잡는 것 같아 건너뛰고 schema.sql은 개발 초기에만 잠깐 쓰는 도구라고 알고있다.
그래서 결정했다.
mode: always를 mode: never로 변경spring:
sql:
init:
mode: never # always → never
"schema.sql 자동 실행은 환경에 따라 동작이 불안정해서 명시적인 마이그레이션 방식이 더 안전하다고 판단했다."
자동화에 의존하지 않고 명시적으로 관리하는 게 안전하다.
mysql> SHOW TABLES;
+-------------------------+
| Tables_in_emergencylink |
+-------------------------+
| admin |
| country |
| embassy |
| emergency_number |
| notice |
+-------------------------+
여기까지가 2단계의 끝이다. 자바 엔티티 5개, ENUM 5개, DB 테이블 5개. 이제 데이터 그릇이 다 준비됐다.
이번 포스팅도 유익하네요 감사합니다