@Lob 개념 및 활용

핵심 요약

  • @Lob는 JPA에서 “이 필드/프로퍼티는 대용량 데이터(LOB: Large Object)로 저장하라”는 의미를 주는 매핑 애노테이션입니다.
  • 문자열 계열(String, char[])에 붙이면 보통 CLOB/TEXT류로, 바이트 계열(byte[])에 붙이면 BLOB류로 매핑됩니다.
  • MySQL 기준 “몇 자”는 고정값이 아니라 바이트문자셋(utf8mb4)에 의해 달라지며, 한글은 대개 3바이트/자, 이모지는 4바이트/자 수준으로 계산합니다.
  • @Lob는 만능이 아닙니다. 조회 패턴(목록 조회, 검색, 정렬), 인덱싱, 캐싱, 네트워크 전송량까지 고려해 “본문을 DB에 어떻게 저장·조회할지”를 함께 설계해야 합니다.

목차

JPA를 처음 배우면 @Column(length=...) 같은 옵션은 익숙한데, 어느 순간 “게시글 본문이 길어질 수 있어요”, “PDF 첨부파일을 저장해야 해요” 같은 요구사항이 나오면서 @Lob를 마주하게 됩니다. 그런데 입문자 입장에서는 이런 의문이 생깁니다. “긴 본문이란 정확히 몇 자지?”, “VARCHAR로는 안 되나?”, “그냥 @Lob 붙이면 다 해결되나?”

이 글은 @Lob의 정의, 역할, MySQL 기준 길이 감각, 그리고 실무 설계 포인트까지 한 번에 정리합니다. 특히 “몇 자”라는 질문에 대해, MySQL이 실제로는 바이트 기반으로 동작한다는 점을 숫자로 풀어서 설명하겠습니다.

1. @Lob는 무엇을 해결하려고 등장했나?

JPA는 자바 객체를 관계형 데이터베이스 테이블에 매핑하는 표준입니다. 대부분의 필드는 평범한 컬럼 타입(INT, VARCHAR, DATETIME)에 들어가지만, 세상에는 “컬럼 하나에 담기엔 너무 큰 데이터”가 있습니다.

  • 게시글/문서의 긴 본문(HTML, Markdown, 리포트 원문)
  • 로그 원문/대량 텍스트
  • 이미지, PDF, 첨부파일 같은 바이너리

이런 데이터는 DB에서 흔히 LOB(Large Object)라는 범주로 취급합니다. JPA의 @Lob는 “이 필드는 LOB로 저장해야 한다”는 의도(힌트)를 명시하는 애노테이션입니다. 애노테이션 자체에는 옵션이 없지만, 붙이는 것만으로 JPA 구현체(Hibernate 등)가 DDL 생성 및 JDBC 바인딩 전략을 LOB 쪽으로 선택하도록 유도합니다.

2. 애노테이션 정의 읽기: Target/Retention이 의미하는 것

@Lob의 코드는 다음과 같습니다.

package jakarta.persistence;

import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

@Target({METHOD, FIELD})
@Retention(RUNTIME)
public @interface Lob { }

여기서 중요한 것은 2가지입니다.

  • @Target({METHOD, FIELD}): 필드에 붙일 수도 있고, getter 메서드에 붙일 수도 있습니다. 즉 JPA의 접근 방식(필드 접근 vs 프로퍼티 접근) 모두를 지원합니다.
  • @Retention(RUNTIME): 런타임까지 애노테이션 정보가 유지됩니다. JPA는 런타임에 리플렉션으로 애노테이션을 읽어 매핑 정보를 구성하므로 필수 조건입니다.

입문자 관점에서 한 줄로 정리하면 이렇습니다. @Lob는 실행 중에 JPA가 읽어서 DB 저장 방식에 반영하는 “런타임 매핑 힌트”입니다.

3. @Lob 매핑 동작: String이면 CLOB, byte[]면 BLOB

@Lob가 “얼마나 긴지”를 숫자로 직접 지정하지 않는 이유는, LOB의 핵심이 “길이”만이 아니라 “저장/전송/조회 방식 자체”이기 때문입니다. JPA는 대체로 다음 규칙으로 해석합니다.

  • 문자 기반 LOB: String, char[] 등에 @Lob를 붙이면 보통 CLOB (또는 MySQL에서 TEXT 계열)로 매핑합니다.
  • 바이너리 기반 LOB: byte[], Byte[] 등에 @Lob를 붙이면 보통 BLOB으로 매핑합니다.

즉 “긴 본문”은 보통 String + @Lob 조합이고, “첨부파일”은 보통 byte[] + @Lob 조합입니다.

예시 1) 게시글 본문(CLOB/TEXT 계열)

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;

@Entity
public class Post {

  @Id
  private Long id;

  @Lob
  private String content; // 긴 본문: CLOB/TEXT 계열로 매핑될 가능성이 큼

  // getter/setter 생략

}

예시 2) 첨부파일(BLOB 계열)

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;

@Entity
public class Attachment {

  @Id
  private Long id;

  @Lob
  private byte[] fileData; // 바이너리: BLOB 계열로 매핑될 가능성이 큼

  // getter/setter 생략
}

여기까지는 간단합니다. 진짜 헷갈리는 지점은 “그래서 MySQL에서 대략 몇 자부터 LOB로 봐야 하냐?”입니다. 이제 그 질문을 바이트/문자셋 관점으로 풀어보겠습니다.

4. MySQL 기준 “대략 몇 자?”를 계산하는 법

MySQL의 TEXT 계열은 글자 수가 아니라 바이트 수로 최대치가 정해져 있습니다. 그리고 실제 글자 수는 문자셋에 따라 달라집니다. Spring Boot에서 MySQL을 쓸 때 흔히 사용하는 문자셋은 utf8mb4입니다(이모지까지 안전하게 지원).

utf8mb4에서 대략적인 바이트 감각은 다음과 같습니다.

  • 영문/숫자(ASCII): 보통 1바이트/자
  • 한글: 보통 3바이트/자
  • 이모지: 보통 4바이트/자

MySQL의 TEXT 계열은 크기별로 단계가 나뉩니다(최대 바이트 기준).

  • TINYTEXT: 255 bytes
  • TEXT: 65,535 bytes (약 64KB)
  • MEDIUMTEXT: 16,777,215 bytes (약 16MB)
  • LONGTEXT: 4,294,967,295 bytes (약 4GB)

이제 이를 “대략 몇 자”로 환산해 봅시다(설계 감각을 위한 근사치입니다).

타입 최대 바이트 영문(1B/자) 기준 한글(3B/자) 기준 이모지(4B/자) 기준
TINYTEXT 255B 약 255자 약 85자 약 63자
TEXT 65,535B 약 65,535자 약 21,845자 약 16,383자
MEDIUMTEXT 16,777,215B 약 1,677만자 약 559만자 약 419만자
LONGTEXT 4,294,967,295B 약 42억자 약 14억자 약 10억자

여기서 입문자에게 가장 실용적인 결론은 한 문장입니다. 한글 본문 기준으로 TEXT는 대략 2만 자 정도까지 커버한다는 감각을 잡으면 됩니다. 많은 “게시글/문서 본문”은 2만 자를 넘지 않기 때문에, 단순히 길이만 보고는 굳이 LONGTEXT까지 갈 이유가 없습니다.

하지만 JPA에서 @Lob를 붙이면 구현체/방언(Dialect) 조합에 따라 TEXT가 아니라 더 큰 타입(예: LONGTEXT)으로 가는 경우도 있습니다. 따라서 @Lob는 “최대치”를 크게 열어두는 선택이며, “정확히 이 정도면 충분”한 설계가 필요하면 컬럼 타입을 더 명시적으로 다루기도 합니다.

5. 스키마 관점: 어떤 컬럼 타입으로 떨어질까?

JPA에서 애노테이션을 붙였다고 해서 DB가 “무조건 동일한 타입”을 쓰는 것은 아닙니다. 실제로 어떤 DDL이 생성되는지는 다음 요소에 의해 결정됩니다.

  • JPA 구현체(Hibernate 등)
  • DB 방언(Dialect) 설정
  • DDL 자동 생성 전략(ddl-auto)
  • 컬럼 정의를 명시했는지(columnDefinition 등)

따라서 초보자가 흔히 하는 실수는 “@Lob면 끝”이라고 생각하고, 나중에 테이블을 보면 본문 컬럼이 생각보다 큰 타입으로 잡혀 있거나(혹은 반대로 너무 작게 잡혀) 운영 중 문제를 겪는 것입니다.

DDL 확인 팁

개발 환경에서는 Hibernate가 생성하는 DDL을 로그로 확인하거나, 실제 DB 스키마를 확인하는 습관이 중요합니다.

# application.properties 예시

spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

또는 운영 관점에서 확실히 하려면, DDL 자동 생성에 기대기보다 “스키마 마이그레이션 도구(Flyway/Liquibase)”로 타입을 명확히 고정하는 방식이 일반적입니다.

6. Spring Boot/JPA 실전 예제: 게시글 본문과 첨부파일

입문자에게 가장 와닿는 예시는 “게시글 + 첨부파일”입니다. 여기서 중요한 설계 포인트는 2가지입니다.

  • 게시글 목록 조회는 자주 일어나므로, 목록에서 본문 전체를 매번 읽는 것은 비효율적입니다.
  • 첨부파일 바이너리는 DB에 넣는 것이 항상 정답은 아닙니다(외부 스토리지 대안이 있음).

예시 1) Post 엔티티: 본문은 @Lob로, 요약은 별도 컬럼

import jakarta.persistence.*;

@Entity
@Table(name = "posts")
public class Post {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  @Column(nullable = false, length = 200)
  private String title;

  // 목록 조회 최적화를 위해, 요약(프리뷰)은 별도 컬럼로 둔다.
  @Column(nullable = false, length = 300)
  private String preview;

  // 실제 본문은 길어질 수 있으므로 LOB로 취급
  @Lob
  @Column(nullable = false)
  private String content;

  protected Post() {}

  public Post(String title, String content) {
    this.title = title;
    this.content = content;
    this.preview = makePreview(content);
  }

  private String makePreview(String content) {
    // 실제로는 HTML strip, 공백 정리 등을 더 할 수 있다.
    if (content == null) return "";
    return content.length() <= 300 ? content : content.substring(0, 300);
  }

  // getter 생략
}

이 구조의 장점은 명확합니다. 목록 API에서는 preview만 내려주고, 상세 API에서만 content를 내려주면 됩니다. “LOB는 크다”는 사실을 API 설계(목록/상세 분리)로 함께 해결하는 접근입니다.

예시 2) Attachment 엔티티: BLOB로 저장

import jakarta.persistence.*;

@Entity
@Table(name = "attachments")
public class Attachment {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  @ManyToOne(fetch = FetchType.LAZY)
  @JoinColumn(name = "post_id", nullable = false)
  private Post post;

  @Column(nullable = false, length = 255)
  private String originalFilename;

  @Column(nullable = false, length = 100)
  private String contentType;

  @Lob
  @Column(nullable = false)
  private byte[] data;

  protected Attachment() {}

  public Attachment(Post post, String originalFilename, String contentType, byte[] data) {
    this.post = post;
    this.originalFilename = originalFilename;
    this.contentType = contentType;
    this.data = data;
  }

}

입문자에게 중요한 포인트는 이겁니다. “DB에 파일을 넣을 수 있다”와 “DB에 파일을 넣는 것이 좋다”는 다른 문제입니다. 예를 들어 파일 크기가 커지고 트래픽이 많아지면 DB I/O가 병목이 되기 쉽습니다. 그래서 실무에서는 첨부파일을 S3 같은 오브젝트 스토리지에 저장하고, DB에는 URL/메타데이터만 저장하는 패턴도 매우 흔합니다(이 글의 8장 대안 설계에서 다룹니다).

7. 성능/운영 관점 체크리스트: 목록 조회, 검색, 캐시, 트랜잭션

초보자가 @Lob를 쓰면서 가장 크게 실수하는 지점은 “정확히 저장은 되는데, 운영에서 느려지는 이유를 모르는 것”입니다. 아래 체크리스트는 @Lob를 “기술적으로” 이해하는 것만큼이나 중요합니다.

1) 목록 조회에서 LOB를 읽지 않게 설계하라

게시글 목록 API에서 본문(content)까지 같이 내려주면, 데이터가 조금만 커져도 응답이 급격히 무거워집니다. 해결책은 다음 중 하나입니다.

  • 본문 미포함 DTO를 별도로 만들고, 목록에서는 그 DTO만 반환
  • 요약 컬럼(preview)을 별도로 저장해 목록에서 사용
  • 상세 조회에서만 본문을 읽는 구조로 API를 분리

2) 검색/정렬/인덱싱은 특히 조심

LOB 컬럼은 일반적인 인덱싱/정렬에 제약이 있는 경우가 많습니다(특히 MySQL에서 TEXT/BLOB 관련 인덱스는 길이 제한이나 추가 설정이 필요합니다). 따라서 “본문에서 검색이 필요하다”는 요구가 있으면 단순히 @Lob로 끝내지 말고, 아래 같은 대안을 고려해야 합니다.

  • MySQL Full-Text Index 적용(요구사항에 따라)
  • Elasticsearch/OpenSearch 같은 전문검색 엔진 사용
  • 검색용 컬럼(또는 별도 테이블)에 토큰화된 데이터를 저장

3) 캐시/세션/직렬화에 LOB를 얹으면 비용이 커진다

2차 캐시나 세션에 엔티티 전체가 올라가면, LOB 데이터까지 메모리에 들고 있게 될 수 있습니다. 특히 “목록에서 엔티티 그대로 반환” 같은 패턴은 위험합니다. 가능하면 다음 원칙을 지키는 것이 안전합니다.

  • 엔티티를 API 응답에 직접 노출하지 말고 DTO로 변환
  • 목록 응답 DTO는 LOB 필드를 제외
  • 필요한 경우에만 상세에서 LOB를 포함

4) 트랜잭션과 JDBC 스트리밍 관점

LOB은 DB/드라이버에 따라 “스트리밍 방식”으로 읽히는 경우가 있고, 이때 트랜잭션 경계 밖에서 접근하면 문제가 생길 수 있습니다. 입문자에게는 다소 어려운 내용이지만, 결론만 기억하면 됩니다.

  • LOB 접근은 가능하면 트랜잭션 안에서 끝내라.
  • 서비스 계층에서 필요한 데이터를 DTO로 뽑아낸 뒤 트랜잭션을 끝내라.

8. 대안 설계: 본문 분리, 외부 스토리지, 전문검색

@Lob는 “저장 타입”을 LOB로 바꾸는 도구일 뿐, “서비스 설계 전체”를 해결해 주지는 않습니다. 따라서 요구사항이 커질수록 아래 대안이 더 좋은 선택이 되기도 합니다.

대안 1) 본문을 별도 테이블로 분리

목록 조회가 많고, 본문은 상세에서만 필요하다면 본문을 아예 분리하는 것도 좋은 전략입니다.

import jakarta.persistence.*;

@Entity
@Table(name = "post_contents")
public class PostContent {
  @Id
  private Long postId;
 
  @MapsId
  @OneToOne(fetch = FetchType.LAZY)
  @JoinColumn(name = "post_id")
  private Post post;

  @Lob
  @Column(nullable = false)
  private String content;

}

이렇게 하면 목록 조회 시 posts 테이블만 읽어도 되므로, 대량 트래픽에서 유리할 수 있습니다(단, 조인/조회 설계는 더 정교해져야 합니다).

대안 2) 첨부파일은 오브젝트 스토리지로

이미지/PDF 같은 바이너리를 DB에 넣으면 백업/복구/복제/성능 비용이 커질 수 있습니다. 그래서 실무에서는 파일 자체는 S3 같은 스토리지에 저장하고, DB에는 URL만 저장하는 패턴이 많습니다.

import jakarta.persistence.*;

@Entity
@Table(name = "attachments")
public class AttachmentMeta {
  @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  @ManyToOne(fetch = FetchType.LAZY)
  @JoinColumn(name = "post_id", nullable = false)
  private Post post;

  @Column(nullable = false, length = 255)
  private String originalFilename;

  @Column(nullable = false, length = 500)
  private String fileUrl;

  // S3 URL 또는 CDN URL
  @Column(nullable = false)
  private long sizeBytes;

}

이 구조는 “DB는 메타데이터”, “파일은 스토리지”로 역할을 분리하므로 확장성 측면에서 유리합니다.

대안 3) 본문 검색은 전문검색을 고려

“본문 검색이 핵심 기능”이라면, DB에 LOB로 저장하는 것과 별개로 검색 인프라(Full-Text, Elasticsearch 등)를 함께 설계해야 합니다. @Lob만 붙이면 검색이 쉬워지는 것이 아니라는 점을 꼭 기억하세요.

9. 요약: 언제 @Lob를 쓰고, 언제 다른 선택을 할까?

마지막으로 입문자를 위한 결론을 상황별로 정리합니다.

상황 추천 접근 이유
본문이 길어질 수 있고(수 KB~수만 자), 상한을 크게 열어두고 싶다 @Lob String TEXT/CLOB 계열로 저장해 길이 제한 이슈를 줄임
한글 기준 최대 1~2만 자 수준이면 충분하고 스키마를 명확히 통제하고 싶다 스키마 마이그레이션으로 TEXT 등 명시 과도한 타입(예: LONGTEXT)을 피하고 운영 감각을 명확히
목록 조회가 많고 본문은 상세에서만 필요 본문을 별도 테이블로 분리 + 상세에서만 조회 목록 응답 가벼워짐, DB I/O 최적화
이미지/PDF 같은 파일을 대량 저장/다운로드 오브젝트 스토리지 + DB에는 URL/메타데이터 DB 부하/백업/복제 비용 감소, 확장성 향상
본문 검색이 핵심 전문검색(FTS/Elasticsearch) 병행 @Lob만으로 검색/인덱싱 문제가 자동 해결되지 않음

정리하면, @Lob는 “긴 본문을 저장할 수 있게 해주는 스위치”에 가깝습니다. 하지만 진짜 실무 문제는 “긴 본문을 어떻게 저장하고, 언제 읽고, 어떻게 검색하고, 목록에서 얼마나 제외할지”에 있습니다. MySQL에서는 TEXT 계열이 바이트 기반이라는 점(utf8mb4에서 한글은 대략 3바이트/자)을 기준으로, 대략 2만 자(한글 기준) ≈ TEXT 최대치라는 감각을 먼저 잡으세요. 그 다음 트래픽과 기능 요구(검색/첨부/목록/상세)에 맞춰 @Lob, 스키마 명시, 테이블 분리, 외부 스토리지 같은 선택지를 조합하면 됩니다.

이 글은 어떠셨나요? 자유롭게 의견을 남겨주세요! 💬