How to save image into PostgreSQL with Spring

7 분 소요


RECOMMEND POSTS BEFORE THIS

1. Development Environment

일반적으로 이미지는 파일 시스템(file system)이나 AWS S3 같은 외부 저장소를 사용해 저장합니다.

  • 비용 측면에서 데이터베이스보다 파일 시스템이나 AWS S3가 저렴합니다.
  • 관계형 데이터베이스는 일반적으로 정형화 된 텍스트 기반 데이터 처리에 최적화되어 있습니다.
  • AWS S3 같은 외부 저장소를 사용하면 이미지 요청에 대한 서버의 부하를 감소시킬 수 있습니다.

하지만 현재 개발 중인 웹 어플리케이션은 클라우드 환경이라 서버의 파일 시스템을 사용할 수 없고, 개발 시간 단축과 외부 의존성을 최대한 줄이기 위해 데이터베이스에 이미지를 저장하기로 결정했습니다. 개발, 운영 환경은 다음과 같습니다.

  • 코틀린(kotiln)
  • 스프링 부트(spring boot) 3.0.7
  • JPA
  • PostgresSQL

테스트 환경은 다음과 같습니다.

  • H2 데이터베이스 PostgreSQL 모드

이번 포스트는 파일을 업로드 후 파일을 데이터베이스에 저장하는 과정에서 겪은 문제와 테스트 방법에 대해 정리하였습니다. 스프링 프레임워크에서 파일을 업로드 하는 방법이나 단위 테스트에 대한 내용은 How to test file upload in Spring 포스트를 참고하시길 바랍니다.

2. Solve the probloms

개발 과정에서 겪은 문제들을 하나씩 확인해보면서 해결 방법들에 대해서 정리해나가겠습니다. 문제의 원인은 요약하자면 테스트 환경과 개발, 운영 환경의 데이터베이스가 서로 다르기 때문에 발생한 것입니다. H2 데이터베이스를 PostgresSQL 모드로 사용했더라도 JDBC 드라이버가 서로 다르기 때문에 어플리케이션이 다르게 동작한 것으로 예상됩니다.

처음 작성한 엔티티 모습은 다음과 같습니다.

  • @Lob 애너테이션을 사용해 큰 데이터 저장을 위한 데이터임을 지정
package action.`in`.blog.domain

import jakarta.persistence.*

@Entity
class FileEntity(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0,
    val contentType: String,
    val name: String,
    @Lob
    val binaryData: ByteArray
)

2.1. Test in H2 Database with PostgreSQL Mode

PostreSQL 모드의 H2 데이터베이스에서 테스트를 실행합니다.

  • 엔티티 객체를 하나 생성 후 저장합니다.
  • 엔티티 매니저를 정리 후 데이터베이스에 엔티티를 조회합니다.
  • 조회한 결과와 예상 값들을 비교합니다.
package action.`in`.blog.repository

import action.`in`.blog.domain.FileEntity
import jakarta.persistence.EntityManager
import org.junit.jupiter.api.Assertions.assertArrayEquals
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.mock.web.MockMultipartFile
import java.util.*

@DataJpaTest(
    properties = [
        "spring.datasource.url=jdbc:h2:mem:test;MODE=PostgreSQL",
        "spring.datasource.driver-class-name=org.h2.Driver",
        "spring.datasource.username=sa",
        "spring.datasource.password=",
        "spring.jpa.show-sql=true",
        "spring.jpa.generate-ddl=true"
    ]
)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class FileRepositoryTestWithH2 {

    @Autowired
    lateinit var entityManager: EntityManager

    @Autowired
    lateinit var sut: FileRepository


    fun flushAndClear() {
        entityManager.flush()
        entityManager.clear()
    }

    @Test
    fun insert() {

        val fileName = UUID.randomUUID().toString()
        val mockFile = MockMultipartFile(
            "file",
            "profile.png",
            "image/png",
            "some-image-binary".toByteArray()
        )
        val entity = FileEntity(
            name = fileName,
            contentType = mockFile.contentType ?: "image/jpeg",
            binaryData = mockFile.bytes
        )


        sut.save(entity)


        flushAndClear()
        val result = entityManager.find(FileEntity::class.java, entity.id)
        assertEquals(fileName, result.name)
        assertEquals("image/png", result.contentType)
        assertArrayEquals("some-image-binary".toByteArray(), result.binaryData)
    }
}

2.1.1. Exception

binary_data 컬럼을 blob 타입으로 테이블을 생성하는데 실패합니다.

Hibernate: drop table if exists file_entity cascade 
Hibernate: create table file_entity (id bigint generated by default as identity, binary_data blob, content_type varchar(255), name varchar(255), primary key (id))
2023-10-19T06:50:48.919+09:00  WARN 30010 --- [    Test worker] o.h.t.s.i.ExceptionHandlerLoggedImpl     : GenerationTarget encountered exception accepting command : Error executing DDL "create table file_entity (id bigint generated by default as identity, binary_data blob, content_type varchar(255), name varchar(255), primary key (id))" via JDBC Statement

org.hibernate.tool.schema.spi.CommandAcceptanceException: Error executing DDL "create table file_entity (id bigint generated by default as identity, binary_data blob, content_type varchar(255), name varchar(255), primary key (id))" via JDBC Statement
	at org.hibernate.tool.schema.internal.exec.GenerationTargetToDatabase.accept(GenerationTargetToDatabase.java:67) ~[hibernate-core-6.1.7.Final.jar:6.1.7.Final]
	at org.hibernate.tool.schema.internal.SchemaCreatorImpl.applySqlString(SchemaCreatorImpl.java:502) ~[hibernate-core-6.1.7.Final.jar:6.1.7.Final]
	at org.hibernate.tool.schema.internal.SchemaCreatorImpl.applySqlStrings(SchemaCreatorImpl.java:486) ~[hibernate-core-6.1.7.Final.jar:6.1.7.Final]

... 

2.1.2. Solve the problem

@Column 애너테이션의 columnDefinition 속성을 사용해 컬럼 타입을 지정합니다.

package action.`in`.blog.domain

import jakarta.persistence.*

@Entity
class FileEntity(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0,
    val contentType: String,
    val name: String,
    @Lob
    @Column(columnDefinition = "bytea")
    val binaryData: ByteArray
)

테스트를 수행하면 정상적으로 통과합니다.

...

Hibernate: drop table if exists file_entity cascade 
Hibernate: create table file_entity (id bigint generated by default as identity, binary_data bytea, content_type varchar(255), name varchar(255), primary key (id))
2023-10-19T06:52:06.450+09:00  INFO 30086 --- [    Test worker] o.h.e.t.j.p.i.JtaPlatformInitiator       : HHH000490: Using JtaPlatform implementation: [org.hibernate.engine.transaction.jta.platform.internal.NoJtaPlatform]
2023-10-19T06:52:06.454+09:00  INFO 30086 --- [    Test worker] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
2023-10-19T06:52:06.630+09:00  INFO 30086 --- [    Test worker] a.i.b.r.FileRepositoryTestWithH2         : Started FileRepositoryTestWithH2 in 1.13 seconds (process running for 1.707)
Hibernate: insert into file_entity (id, binary_data, content_type, name) values (default, ?, ?, ?)
Hibernate: select f1_0.id,f1_0.binary_data,f1_0.content_type,f1_0.name from file_entity f1_0 where f1_0.id=?
2023-10-19T06:52:06.818+09:00  INFO 30086 --- [ionShutdownHook] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
2023-10-19T06:52:06.819+09:00  INFO 30086 --- [ionShutdownHook] .SchemaDropperImpl$DelayedDropActionImpl : HHH000477: Starting delayed evictData of schema as part of SessionFactory shut-down'
Hibernate: drop table if exists file_entity cascade 

...

2.2. Running with PostgreSQL Database

개발 환경에 어플리케이션을 배포 후 해당 기능을 동작시키면 에러가 발생합니다. 테스트 컨테이너(test container)를 적용해 개발 환경과 동일한 데이터베이스를 사용한 테스트 코드를 실행하면 동일한 에러 메시지를 확인할 수 있습니다.

package action.`in`.blog.repository

import action.`in`.blog.domain.FileEntity
import jakarta.persistence.EntityManager
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.mock.web.MockMultipartFile
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import java.util.*

@DataJpaTest(
    properties = [
        "spring.datasource.url=jdbc:tc:postgresql:latest:///test",
        "spring.datasource.driver-class-name=org.testcontainers.jdbc.ContainerDatabaseDriver",
        "spring.jpa.show-sql=true",
        "spring.jpa.generate-ddl=true"
    ]
)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class FileRepositoryTestWithPostgres {

    @Container
    var mysqlContainer = PostgreSQLContainer("postgres:latest").withDatabaseName("test")

    @Autowired
    lateinit var entityManager: EntityManager

    @Autowired
    lateinit var sut: FileRepository

    fun flushAndClear() {
        entityManager.flush()
        entityManager.clear()
    }

    @Test
    fun insert() {

        val fileName = UUID.randomUUID().toString()
        val mockFile = MockMultipartFile(
            "file",
            "profile.png",
            "image/png",
            "some-image-binary".toByteArray()
        )
        val entity = FileEntity(
            name = fileName,
            contentType = mockFile.contentType ?: "image/jpeg",
            binaryData = mockFile.bytes
        )


        sut.save(entity)


        flushAndClear()
        val result = entityManager.find(FileEntity::class.java, entity.id)
        Assertions.assertEquals(fileName, result.name)
        Assertions.assertEquals("image/png", result.contentType)
        Assertions.assertArrayEquals("some-image-binary".toByteArray(), result.binaryData)
    }
}

2.2.1. Exception

로그를 살펴보면 bytea 타입 컬럼에 bigint 타입 형태의 데이터를 추가하려는 것으로 보입니다.

Hibernate: insert into file_entity (binary_data, content_type, name) values (?, ?, ?)
2023-10-19T06:55:08.290+09:00  WARN 30423 --- [    Test worker] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 0, SQLState: 42804
2023-10-19T06:55:08.290+09:00 ERROR 30423 --- [    Test worker] o.h.engine.jdbc.spi.SqlExceptionHelper   : ERROR: column "binary_data" is of type bytea but expression is of type bigint
  Hint: You will need to rewrite or cast the expression.
  Position: 67

org.springframework.dao.InvalidDataAccessResourceUsageException: could not execute statement; SQL [n/a]
	at org.springframework.orm.jpa.vendor.HibernateJpaDialect.convertHibernateAccessException(HibernateJpaDialect.java:256)
	at org.springframework.orm.jpa.vendor.HibernateJpaDialect.translateExceptionIfPossible(HibernateJpaDialect.java:232)
	at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.translateExceptionIfPossible(AbstractEntityManagerFactoryBean.java:550)

...

2.2.2. Solve the problem

@JdbcType 애너테이션을 사용해 타입에 하이버네이트(hiberate) 프레임워크에게 타입에 대한 힌트를 제공합니다.

package action.`in`.blog.domain

import jakarta.persistence.*
import org.hibernate.annotations.JdbcType
import org.hibernate.type.descriptor.jdbc.VarbinaryJdbcType

@Entity
class FileEntity(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0,
    val contentType: String,
    val name: String,
    @Lob
    @JdbcType(value = VarbinaryJdbcType::class)
    @Column(columnDefinition = "bytea")
    val binaryData: ByteArray
)

테스트를 수행하면 정상적으로 통과합니다.

...

Hibernate: drop table if exists file_entity cascade
2023-10-19T07:08:32.057+09:00  WARN 34199 --- [    Test worker] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Warning Code: 0, SQLState: 00000
2023-10-19T07:08:32.057+09:00  WARN 34199 --- [    Test worker] o.h.engine.jdbc.spi.SqlExceptionHelper   : table "file_entity" does not exist, skipping
Hibernate: create table file_entity (id bigserial not null, binary_data bytea, content_type varchar(255), name varchar(255), primary key (id))
2023-10-19T07:08:32.062+09:00  INFO 34199 --- [    Test worker] o.h.e.t.j.p.i.JtaPlatformInitiator       : HHH000490: Using JtaPlatform implementation: [org.hibernate.engine.transaction.jta.platform.internal.NoJtaPlatform]
2023-10-19T07:08:32.066+09:00  INFO 34199 --- [    Test worker] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
2023-10-19T07:08:32.227+09:00  INFO 34199 --- [    Test worker] a.i.b.r.FileRepositoryTestWithPostgres   : Started FileRepositoryTestWithPostgres in 2.852 seconds (process running for 3.472)
2023-10-19T07:08:32.356+09:00  INFO 34199 --- [    Test worker] tc.postgres:latest                       : Creating container for image: postgres:latest
2023-10-19T07:08:32.407+09:00  INFO 34199 --- [    Test worker] tc.postgres:latest                       : Container postgres:latest is starting: 4d430c825d3817ce2c85e58509a2ef8aaf8c8aca05c8d93b32915b69484491d3
2023-10-19T07:08:33.237+09:00  INFO 34199 --- [    Test worker] tc.postgres:latest                       : Container postgres:latest started in PT0.881229S
2023-10-19T07:08:33.237+09:00  INFO 34199 --- [    Test worker] tc.postgres:latest                       : Container is started (JDBC URL: jdbc:postgresql://localhost:56424/test?loggerLevel=OFF)
Hibernate: insert into file_entity (binary_data, content_type, name) values (?, ?, ?)
Hibernate: select f1_0.id,f1_0.binary_data,f1_0.content_type,f1_0.name from file_entity f1_0 where f1_0.id=?
2023-10-19T07:08:33.433+09:00  INFO 34199 --- [ionShutdownHook] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
2023-10-19T07:08:33.433+09:00  INFO 34199 --- [ionShutdownHook] .SchemaDropperImpl$DelayedDropActionImpl : HHH000477: Starting delayed evictData of schema as part of SessionFactory shut-down'
Hibernate: drop table if exists file_entity cascade

...

3. Other Information

해결 방법을 찾는 과정에서 얻은 기타 정보들에 대해 함께 정리하였습니다.

3.1. @Type Annotation

스택 오버플로우(stack overflow)을 살펴보면 @Type 애너테이션에 타입을 명시하면 해결된다는 답변들이 많습니다. 이 답변은 하이버네이트 5 버전을 사용하는 경우 유효하지만, 하이버네이트 6 버전에선 사용할 수 없습니다. 스프링 부트 데이터 JPA 3.X 버전을 사용하는 경우 하이버네이터 6버전을 사용하므로 주의바랍니다.

https://stackoverflow.com/questions/9114510/seam-file-upload-to-postgres-bytea-column-column-is-bytea-but-expression-is-of

3.2. BYTEA and OID(Large Objects)

PostgreSQL 데이터베이스를 사용하는 경우 엔티티에 다른 컬럼 정의나 타입 정의 없이 @Lob 애너테이션만 추가했다면 OID 타입으로 컬럼을 사용할 수 있습니다. 엔티티 필드와 테이블의 컬럼 타입을 OID로 정의하여 사용하는 경우 H2 데이터베이스를 PostgreSQL 모드로 사용하는 테스트에서 다음과 같은 예외가 발생합니다. PostgreSQL 테스트 컨테이너를 사용하는 테스트나 개발, 운영 환경에선 정상적으로 동작합니다.

...

Hibernate: drop table if exists file_entity cascade 
Hibernate: create table file_entity (id bigint generated by default as identity, binary_data oid, content_type varchar(255), name varchar(255), primary key (id))
2023-10-19T07:59:39.524+09:00  INFO 48071 --- [    Test worker] o.h.e.t.j.p.i.JtaPlatformInitiator       : HHH000490: Using JtaPlatform implementation: [org.hibernate.engine.transaction.jta.platform.internal.NoJtaPlatform]
2023-10-19T07:59:39.528+09:00  INFO 48071 --- [    Test worker] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
2023-10-19T07:59:39.692+09:00  INFO 48071 --- [    Test worker] a.i.b.r.FileRepositoryTestWithH2         : Started FileRepositoryTestWithH2 in 1.122 seconds (process running for 1.686)
Hibernate: insert into file_entity (id, binary_data, content_type, name) values (default, ?, ?, ?)
2023-10-19T07:59:39.858+09:00  WARN 48071 --- [    Test worker] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 22018, SQLState: 22018
2023-10-19T07:59:39.858+09:00 ERROR 48071 --- [    Test worker] o.h.engine.jdbc.spi.SqlExceptionHelper   : Data conversion error converting "X'736f6d652d696d6167652d62696e617279' (FILE_ENTITY: ""BINARY_DATA"" INTEGER)"; SQL statement:
insert into file_entity (id, binary_data, content_type, name) values (default, ?, ?, ?) [22018-214]

...

TEST CODE REPOSITORY

REFERENCE

댓글남기기