핵심 요약
- @Lob는 JPA에서 “이 필드/프로퍼티는 대용량 데이터(LOB: Large Object)로 저장하라”는 의미를 주는 매핑 애노테이션입니다.
- 문자열 계열(
String,char[])에 붙이면 보통 CLOB/TEXT류로, 바이트 계열(byte[])에 붙이면 BLOB류로 매핑됩니다. - MySQL 기준 “몇 자”는 고정값이 아니라 바이트와 문자셋(utf8mb4)에 의해 달라지며, 한글은 대개 3바이트/자, 이모지는 4바이트/자 수준으로 계산합니다.
- @Lob는 만능이 아닙니다. 조회 패턴(목록 조회, 검색, 정렬), 인덱싱, 캐싱, 네트워크 전송량까지 고려해 “본문을 DB에 어떻게 저장·조회할지”를 함께 설계해야 합니다.
목차
- 1. @Lob는 무엇을 해결하려고 등장했나?
- 2. 애노테이션 정의 읽기: Target/Retention이 의미하는 것
- 3. @Lob 매핑 동작: String이면 CLOB, byte[]면 BLOB
- 4. MySQL 기준 “대략 몇 자?”를 계산하는 법
- 5. 스키마 관점: 어떤 컬럼 타입으로 떨어질까?
- 6. Spring Boot/JPA 실전 예제: 게시글 본문과 첨부파일
- 7. 성능/운영 관점 체크리스트: 목록 조회, 검색, 캐시, 트랜잭션
- 8. 대안 설계: 본문 분리, 외부 스토리지, 전문검색
- 9. 요약: 언제 @Lob를 쓰고, 언제 다른 선택을 할까?
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, 스키마 명시, 테이블 분리, 외부 스토리지 같은 선택지를 조합하면 됩니다.