[EmergencyLink #3] JPA 엔티티 설계 — length 불일치와 schema.sql 오류 썰

배고픈개발로그·2026년 4월 28일

EmergencyLink

목록 보기
3/3
post-thumbnail

들어가며

2편에서 Spring Boot + Docker Compose로 인프라를 띄웠다. 이번 편은 그 위에 데이터 그릇을 만든다.

엔티티는 별 거 아닌 것 같다. @Entity 붙이고 필드 몇 개 적으면 끝나는 거 아닌가? 그렇게 시작했다가 두 번 쓰러졌다.

  • 한 번은 length 안 적었다가 validate 실패
  • 한 번은 schema.sql이 끝까지 실행되지 않아서 테이블 4개가 안 만들어짐

패키지 구조 — 도메인형으로 가기로 했다

엔티티를 작성하기 전에 패키지 구조부터 정해야 한다. 두 가지 방식 중 골라야 한다.

계층형 vs 도메인형

계층형 (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/

BaseEntity — 모든 엔티티의 부모

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 = false

created_at에만 붙어있다. 한 번 생성된 후엔 수정 불가. 실수로 변경되는 걸 방지한다.


ENUM 설계 — 정해진 값만 들어가야 한다

여행경보 단계는 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) — 무조건 STRING

ENUM을 DB에 저장하는 방식이 두 가지다.

EnumType.ORDINAL  →  0, 1, 2, 3 같은 숫자로 저장 (위험)
EnumType.STRING   →  "LEVEL_1" 같은 문자열로 저장 (안전, 표준)

ORDINAL은 절대 쓰면 안 된다. ENUM 순서가 바뀌는 순간 기존 데이터가 다 깨진다. 예를 들어 LEVEL_2와 LEVEL_3 사이에 새 단계를 추가하면? 기존 데이터는 그대로 인덱스 2를 가리키고 있는데, 이제 그건 다른 값이다.

STRING은 enum 이름을 그대로 저장하니까 순서 바뀌어도 안전하다.


첫 번째 함정 — VARCHAR length 불일치

엔티티를 다 만들고 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;

@Columnlength를 명시하지 않으면 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를 항상 명시하는 게 안전하다.


1:N 관계 — @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 vs LAZY

EAGER (즉시 로딩)
  Embassy 조회 시 Country도 같이 즉시 조회
  → 매번 JOIN 쿼리 발생
  → N+1 문제의 원흉

LAZY (지연 로딩)
  Embassy 조회 시 Country는 안 가져옴
  실제 country.getName() 호출하는 순간 그때 가져옴
  → 실무 표준이라고 한다.

무조건 LAZY로 외워두면 된다. EAGER는 의도한 게 아니라면 절대 쓰지 말 것.


Nullable FK 패턴 — Notice의 트릭

공지사항은 두 종류다.

  • 일본 지진 발생 → 일본 조회하는 사람한테만
  • EmergencyLink 서비스 점검 → 전체 사용자

그래서 컬럼 하나로 구분하면 된다.

@Entity
public class Notice extends BaseEntity {

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "country_id")  // nullable = false 명시 안 함
    private Country country;
    
    // ...
}

@JoinColumnnullable = false를 안 적으면 기본값이 nullable = true다.

country_id = NULL    → 전체 공지
country_id = 1 (JP)  → 일본 공지

이걸 Nullable FK 패턴이라고 한다. 테이블 하나로 두 가지 목적을 다 처리할 수 있어서 깔끔하다.


Setter 대신 의미 있는 메서드 — Admin 엔티티

@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이 끝까지 실행되지 않았다

진짜 고생한 부분이다.

테이블을 한 번에 만들기 위해 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가 살아있는 건 이전에 만들어진 게 그대로 남은 거였다.

여러 가지 시도했다.

  • IntelliJ 캐시 초기화 (Invalidate Caches and Restart)
  • Gradle clean build
  • application.yml 들여쓰기 재확인
  • build/resources/main/ 에 schema.sql이 들어갔는지 확인 → 잘 들어가 있음

전부 정상이었다. 근데도 schema.sql이 실행 안 됐다. Spring Boot 3.x의 SQL 초기화 동작이 환경에 따라 미묘하게 다른 듯하다. (정확한 원인은 끝까지 못 잡았다. 나중에 시간 될 때 다시 파볼 예정)

결정 — mode: never로 우회

너무 시간을 오래 잡는 것 같아 건너뛰고 schema.sql은 개발 초기에만 잠깐 쓰는 도구라고 알고있다.

그래서 결정했다.

  1. mysql에 직접 접속해서 5개 테이블 SQL을 한 번에 수동 실행
  2. application.yml의 mode: alwaysmode: never로 변경
  3. 앞으로 스키마 변경은 SQL을 직접 관리
spring:
  sql:
    init:
      mode: never        # always → never

"schema.sql 자동 실행은 환경에 따라 동작이 불안정해서 명시적인 마이그레이션 방식이 더 안전하다고 판단했다."

자동화에 의존하지 않고 명시적으로 관리하는 게 안전하다.


정리 — 5개 테이블 완성

mysql> SHOW TABLES;
+-------------------------+
| Tables_in_emergencylink |
+-------------------------+
| admin                   |
| country                 |
| embassy                 |
| emergency_number        |
| notice                  |
+-------------------------+

여기까지가 2단계의 끝이다. 자바 엔티티 5개, ENUM 5개, DB 테이블 5개. 이제 데이터 그릇이 다 준비됐다.


다음 편

  • #4편: Repository — JpaRepository 메서드 쿼리, 그리고 N+1 문제 미리 짚기

관련 링크

2개의 댓글

comment-user-thumbnail
2026년 5월 4일

이번 포스팅도 유익하네요 감사합니다

1개의 답글