티스토리 뷰
목표
- Spring Boot 2.1.x 기반 프로젝트에서 복수개의 데이터베이스에 대해
HikariCP
커넥션 풀 기반으로 JPA 개발 환경을 구축한다. Spring Data JPA
를 적용하여 RAW 쿼리문 작성을 최소화하고 페이지네이션 기능 등을 간편하게 제공하는 리파지터리를 구현한다.Spring Data REST
를 적용하여 개발된 엔티티와 리파지터리에 대해HAL
형식으로 즉각적으로 확인한다.Flyway
를 적용하여 데이터베이스에 대한 형상 관리를 애플리케이션 레벨에서 수행한다.
build.gradle
- /build.gradle 파일에 아래 내용을 추가한다.
buildscript {
ext {
kotlinVersion = '1.3.41'
}
dependencies {
classpath("org.jetbrains.kotlin:kotlin-allopen:${kotlinVersion}")
classpath("org.jetbrains.kotlin:kotlin-noarg:${kotlinVersion}")
}
}
apply plugin: 'kotlin-jpa'
allOpen {
annotation("javax.persistence.Entity")
annotation("javax.persistence.MappedSuperclass")
annotation("javax.persistence.Embeddable")
}
dependencies {
implementation('org.springframework.boot:spring-boot-starter-data-jpa')
compile group: 'com.zaxxer', name: 'HikariCP', version: '3.3.1'
// MySQL 일 경우
compile group: 'mysql', name: 'mysql-connector-java', version: '8.0.13'
// MariaDB 일 경우
compile group: 'org.mariadb.jdbc', name: 'mariadb-java-client', version: '2.5.1'
}
- 위는 Kotlin으로 JPA 관련 클래스를 효율적으로 작성하기 위한 설정이다. 각 설정의 자세한 의미는 이 글을 정독할 것을 추천한다.
- HikariCP는 Spring Boot 2.x부터 기본 커넥션 풀 라이브러리로 채택되었을 정도로 현존 최고 성능을 자랑한다. (따라서 기본적으로 제공되므로 생략해도 무관하다. 다만, 최신 버전을 사용하기 위해 별도로 명시했다.)
- MySQL(또는 MariaDB) 데이터베이스 사용을 가정하여 관련 드라이버 아티팩트를 추가하였다.
application.yml
- /src/main/resources/application.yml 파일에 아래 내용을 추가한다.
spring:
datasource:
somedb:
jdbc-url: jdbc:mysql://localhost:3306/somedb?useUnicode=yes&characterEncoding=UTF-8
driver-class-name: com.mysql.cj.jdbc.Driver
username: someuser
password: somepassword
jpa:
database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
open-in-view: false
properties:
hibernate:
format_sql: true
generate-ddl: true
logging:
level:
org:
hibernate:
SQL: DEBUG
type:
descriptor:
sql:
BasicBinder: TRACE
- 본래
spring.datasource
에 적절히 커넥션 풀 정보를 명시하면 Spring Boot가 자동으로 인식하여 DataSource 빈을 생성해준다. 하지만 프로젝트 내에 2개 이상의 DataSource 빈 설정이 필요할 경우 확장이 불가능하다. 따라서 위와 같이 somedb라는 이름의 데이터베이스 식별자를 추가하였다. spring.jpa.database-platform
에는 어떤 데이터베이스에 접속할 것인지에 대한 명시적 설정이 가능하다. MySQL의 경우 위와 같이org.hibernate.dialect.MySQL5InnoDBDialect
로 설정하면 InnoDB 스토리지 엔진의 특성을 활용할 수 있다.spring.jpa.generate-ddl
옵션을 활성화화면 최초 데이터베이스 스키마를 자동으로 생성해준다. 이 옵션은 개발 환경에서만 권장하며 운영 환경에서는Flyway
와 같은 별도의 전문적인 마이그레이션 툴 이용을 권장한다.logging.level.org.hibernate.SQL
을 DEBUG로 설정하면 로그에 JPA가 실행하는 쿼리문을 출력해주는 역할을 한다. 추가적으로logging.level.org.hibernate.type.descriptor.sql.BasicBinder
를 TRACE로 설정해주면 쿼리문의 파라메터까지 출력해준다. 마지막으로spring.jpa.properties.hibernate.format_sql
옵션을 활성화하면 쿼리문을 가독성이 좋게 정렬하여 출력해준다.
JpaConfig 작성
- /src/main/java/JpaConfig.java 파일에 아래 내용을 작성한다.
package com.jsonobject.jpademo
import com.zaxxer.hikari.HikariDataSource
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.jdbc.DataSourceBuilder
import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Primary
import org.springframework.data.jpa.repository.config.EnableJpaRepositories
import org.springframework.orm.jpa.JpaTransactionManager
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.annotation.EnableTransactionManagement
import javax.persistence.EntityManagerFactory
import javax.sql.DataSource
@Configuration
@EnableTransactionManagement
@EnableJpaRepositories(
entityManagerFactoryRef = "entityManagerFactory",
transactionManagerRef = "transactionManager",
basePackages = ["com.jsonobject.jpademo.repository.somedb"]
)
class JpaConfig {
@Primary
@Bean
@ConfigurationProperties("spring.datasource.somedb")
fun dataSource(): DataSource {
val dataSource = DataSourceBuilder.create().type(HikariDataSource::class.java).build()
// UTF-8이 아닌 레거시 데이터베이스에 연결시 한글 문자열을 온전히 처리하기 위해 사용
dataSource.connectionInitSql = "SET NAMES utf8mb4"
return dataSource
}
@Primary
@Bean
fun entityManagerFactory(
builder: EntityManagerFactoryBuilder,
@Qualifier("dataSource") dataSource: DataSource): LocalContainerEntityManagerFactoryBean {
return builder
.dataSource(dataSource)
.packages("com.jsonobject.jpademo.entity.somedb")
.persistenceUnit("somedb")
.build()
}
@Primary
@Bean
fun transactionManager(
@Qualifier("entityManagerFactory") entityManagerFactory: EntityManagerFactory): PlatformTransactionManager {
val transactionManager = JpaTransactionManager()
transactionManager.entityManagerFactory = entityManagerFactory
return transactionManager
}
}
- JpaConfg 환경 설정 빈은 연결하고자 하는 물리적인 데이터베이스마다 1개씩 작성해야 한다.(연결하는 데이터베이스가 1개라면 위 예제로 충분하다.) 각 설정마다 대응하는 엔티티와 리파지터리의 패키지 위치를 명시해주어야 정상적으로 작동한다.
- EntityManagerFactory 빈 생성시 persistenceUnit을 지어줄 수 있다. 복수개의 EntityManagerFactory 빈 생성시 각 빈의 식별자 역할을 하는데 1개의 빈만 생성할 것이라면 생략해도 된다.
User 엔티티 작성
- 테이블을 대표할 엔티티를 작성할 차례이다. /src/main/java/User.java 파일에 아래 내용을 작성한다.
package com.jsonobject.jpademo;
import javax.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "some_database.user")
class User {
@Id
@Column(name = "id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long = 0
@Column(name = "name")
var name: String = ""
@Column(name = "email")
var email: String = ""
@Column(name = "gender")
@Enumerated(EnumType.STRING)
var gender: Gender = Gender.MALE
@Column(name = "created_at")
var createdAt: LocalDateTime = LocalDateTime.now()
// JPA로 제어하지 않을 필드
@Transient
var notJpaField: String = ""
}
@Entity
를 클래스 레벨에 명시하여 JPA에 의해 관리되는 엔티티 임을 선언했다. 추가적으로@Table
을 명시하면 실제 맵핑될 테이블의 이름을 명확히 선언할 수 있다.@Id
를 필드 레벨에 명시하여 기본키 식별자 역할을 하는 필드 임을 선언했다. 추가적으로@GeneratedValue(strategy = GenerationType.IDENTITY)
을 명시하여 기본키 생성을 데이터베이스가 수행하도록 선언했다.@Column
을 필드 레벨에 명시하여 테이블의 어떤 컬럼에 대응하는 필드인지를 선언했다. 데이터베이스 컬럼과 엔티티 필드의 일치하는 경우에는 생략해도 된다.- 만약 JPA로 제어하지 않을 필드를 정의해야 한다면
@Transient
를 필드 레벨에 선언해야 한다. [관련 링크] - Enum 클래스의 경우
@Enumerated
를 명시해주지 않으면 예외가 발생한다. 데이터베이스에 어떻게 저장하고 가져올지 방법을 정해주어야 한다. EnumType.STRING은 Enum의 이름을 그대로 문자열로 저장한다. 반면에 EnumType.ORDINAL은 숫자로 저장한다. [관련 링크] - Enum 클래스와 데이터베이스 컬럼 간의 데이터 변환을 직접 수행하는 방법도 있다. 아래와 같이
@Converter
클래스를 작성하면 된다. autoApply = true 옵션을 주면 엔티티에 별도로 @Converter를 명시하지 않아도 자동으로 컨버터가 적용되어 편리하다. 이 경우, 주의할 점은 @Enumerated를 제거해야 정상적으로 컨버터가 작동한다. [관련 링크]
@Converter(autoApply = true)
class GenderConverter : AttributeConverter<Gender, String> {
override fun convertToEntityAttribute(dbData: String?): Gender? {
return when (dbData) {
null -> null
else -> Gender.valueOf(dbData.toUpperCase())
}
}
override fun convertToDatabaseColumn(attribute: Gender?): String? {
return attribute?.name?.toLowerCase()
}
}
- 아래는 JSON 문자열이 저장된 데이터베이스의 컬럼을 Java의 Map
타입으로 변환하는 컨버터의 작성 예이다.
/**
* 컨버터를 사용할 엔티티 필드에는 아래와 같이 명시
*
* @Convert(converter = JsonConverter::class)
* var someJsonField: Map<String, String> = emptyMap()
*/
@Converter(autoApply = false)
class JsonConverter : AttributeConverter<Map<String, String>, String> {
override fun convertToEntityAttribute(dbData: String?): Map<String, String> {
return try {
ObjectMapper().readValue<HashMap<String, String>>(dbData ?: "")
} catch (ex: Exception) {
return emptyMap()
}
}
override fun convertToDatabaseColumn(attribute: Map<String, String>?): String {
return try {
ObjectMapper().writeValueAsString(attribute)
} catch (ex: Exception) {
return ""
}
}
}
UserRepository 리파지터리 작성
- User 엔티티에 대한 CRUD를 수행할 리파지터리를 작성할 차례이다. /src/main/java/UserRepository.java 파일에 아래 내용을 작성한다.
package com.jsonobject.jpademo;
import org.springframework.data.jpa.repository.JpaRepository;
interface UserRepository : JpaRepository<User, Long>
- 소스 코드를 보면 놀라울 정도로 간단하다. 단지
JpaRepository
인터페이스를 상속했을 뿐이다. 전통적으로 추가해야 했던 @Repository 또한 명시할 필요가 없다. 이 것 만으로도 엔티티에 대한 기본적인 CRUD를 위한 메써드를 제공한다. - 엔티티가 복잡해질수록 진가는 더욱 발휘된다. Spring Data가 사전 제공하는 네이밍 규칙을 따라 추가적으로 메써드를 선언하면 런타임에서 자동으로 해당 쿼리를 생성해준다. 자세한 네이밍 규칙은 이 글을 참고한다.
- @Repository 인터페이스의 메써드 레벨에는
@Lock
을 명시할 수 있다. 특정 엔티티를 조회하는 순간부터 트랜잭션이 종료되는 시점까지 다른 트랜잭션에서의 해당 엔터티에 대한 접근을 차단하여, 데이터의 무결성을 보장할 수 있다. 대표적으로 2가지 전략이 지정할 수 있는데 @Lock(LockModeType.PESSIMISTIC_READ)는 트랜잭션이 끝날 때까지 쓰기, 삭제만 차단하고, 읽기는 보장한다. @Lock(LockModeType.PESSIMISTIC_WRITE)는 훨씬 엄격하다. 트랜잭션이 끝날 때까지 다른 트랜잭션으로부터의 읽기, 쓰기, 삭제를 차단한다. [관련 링크] - 이제 요구사항에 따라 필요한 메써드를 제작하면 된다. Spring Boot Data JPA는 철저히 엔티티의 필드 이름을 기반의 메써드 작성을 요구한다. 작성 예는 아래와 같다.
// User의 address: Address 필드로 조회한다.
fun findByAddress(address: Address): List<User>
// User의 address: Address 필드의 id로 조회한다.
fun findByAddressId(addressId: Long): List<User>
// User의 gender: Gender 필드를 내림차순으로 정렬 후 100개를 조회한다.
fun findTop100ByGenderOrderByIdDesc(gender: Gender): List<User>
트랜잭션 적용
- @Service 클래스의 클래스 레벨 또는 메써드 레벨에 트랜잭션을 적용할 수 있다. (Querydsl을 사용했을 경우 @Repository 클래스에도 적용이 필요하다.) 아래는 사용 가능한 모든 옵션을 명시한 예이다.
@Transactional(readOnly = false, isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED, transactionManager = "someTransactionManager", rollbackFor = [Exception::class])
fun doSomething() {
...
}
페이지네이션을 적용한 컨트롤러 작성
Pageable
객체를 이용하면 컨트롤러 레벨부터 적은 공수로 상당히 편리하게 페이지네이션을 적용할 수 있다. [관련 링크] 작성 예는 아래와 같다.
@RestController
class UserController(
val userRepository: UserRepository,
) {
/**
* /users?page=0&size=5&sort=id,desc
*/
@GetMapping("/users")
fun getAllUsers(pageable: Pageable): ResponseEntity<*> {
val users = userRepository.findAll(pageable)
return ResponseEntity.ok(users)
}
}
- 응답 결과는 다음과 같다.
{
"content": [
{
"id": 2000,
"name": "20a71f52",
"email": "1ce508fb@gmail.com",
"createdAt": "2019-10-05T17:22:13.073858"
},
{
"id": 1999,
"name": "77e0a0f5",
"email": "63acf78d@gmail.com",
"createdAt": "2019-10-05T17:22:13.071905"
},
{
"id": 1998,
"name": "582e1f69",
"email": "31ca8f95@gmail.com",
"createdAt": "2019-10-05T17:22:13.070929"
},
{
"id": 1997,
"name": "4f2cd4ed",
"email": "35f00153@gmail.com",
"createdAt": "2019-10-05T17:22:13.068977"
},
{
"id": 1996,
"name": "cb2df7d",
"email": "136bc01e@gmail.com",
"createdAt": "2019-10-05T17:22:13.067025"
}
],
"pageable": {
"sort": {
"sorted": true,
"unsorted": false,
"empty": false
},
"pageNumber": 0,
"pageSize": 5,
"offset": 0,
"unpaged": false,
"paged": true
},
"last": false,
"totalPages": 400,
"totalElements": 2000,
"numberOfElements": 5,
"first": true,
"size": 5,
"number": 0,
"sort": {
"sorted": true,
"unsorted": false,
"empty": false
},
"empty": false
}
Application 작성
- 이제 애플리케이션의 시작점이 되는 Application을 작성할 차례이다.
package com.jsonobject.jpademo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@SpringBootApplication
public class JpaDemoApplication {
public static void main(String[] args) {
SpringApplication.run(JpaDemoApplication.class, args);
}
}
테스트 케이스 작성
package com.jsonobject.jpademo;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.Rollback;
import org.springframework.test.context.junit4.SpringRunner;
import javax.transaction.Transactional;
import java.time.LocalDateTime;
@RunWith(SpringRunner.class)
@SpringBootTest
public class JpaDemoApplicationTests {
@Autowired
private UserRepository userRepository;
@Test
@Transactional
@Rollback(false)
public void createUser() {
User user = new User();
user.setName("test");
user.setEmail("test@gmail.com");
user.setDate(LocalDateTime.now());
userRepository.save(user);
}
}
- 테스트시 기본적으로 케이스를 성공하면 트랜잭션은 롤백된다.
@Rollback(false)
을 설정하면 트랜잭션 결과를 영구적으로 커밋할 수 있다.
Flyway 적용
Flyway
는 데이터베이스의 마이그레이션 작업과 형상관리를 자동화해주는 무료 오픈 소스 라이브러리이다. 번거롭고 간과하기 쉬운 데이터베이스 형상관리를 자동으로 작업해주기 때문에 프로젝트 관리가 상당히 편리해진다. 적용 방법은 아래와 같다. 먼저/build.gradle
파일에 아래 내용을 추가한다.
dependencies {
compile group: 'org.flywaydb', name: 'flyway-core', version: '5.2.4'
}
- 다음으로
/src/main/resources/application.yml
파일에 아래 설정을 추가한다.
spring:
flyway:
enabled: true
encoding: UTF-8
spring.flyway.enabled
옵션을 true로 설정하면 애플리케이션 시작 시점마다 Flyway가 실행되어 자동으로 데이터베이스 형상관리를 수행한다. (형상관리 이력은flyway_schema_history
테이블에 기록되어 관리된다. 마이그레이션 중 문제가 생길 경우 해당 테이블을 직접 수정하거나 삭제하면 된다.)마지막으로 Flyway의 형상관리 대상이 되는 SQL 스크립트를 작성할 차례이다.
/src/main/resources/db/migration/V{version}__{description}.sql
파일에 초기 테이블과 인덱스를 생성하는 SQL 스크립트를 작성하면 된다. 형상이 변경되면 기존 스크립트는 유지한 채 위 파일명 컨벤션을 준수하여 새로운 스크립트를 작성하면 Flyway가 자동으로 적용해준다.- 애플리케이션 시작 후 형상관리가 성공적으로 완료되면 아래 로그가 출력된다.
2018-12-20 11:37:54.141 INFO 1279 --- [ main] o.f.c.internal.license.VersionPrinter : Flyway Community Edition 5.2.4 by Boxfuse
2018-12-20 11:37:54.142 INFO 1279 --- [ main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Starting...
2018-12-20 11:37:54.170 INFO 1279 --- [ main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Start completed.
2018-12-20 11:37:54.174 INFO 1279 --- [ main] o.f.c.internal.database.DatabaseFactory : Database: jdbc:mariadb://localhost:3306/some_db (MySQL 5.6)
2018-12-20 11:37:54.217 INFO 1279 --- [ main] o.f.core.internal.command.DbValidate : Successfully validated 1 migration (execution time 00:00.018s)
2018-12-20 11:37:54.258 INFO 1279 --- [ main] o.f.c.i.s.JdbcTableSchemaHistory : Creating Schema History table: `some_db`.`flyway_schema_history`
2018-12-20 11:37:54.328 INFO 1279 --- [ main] o.f.core.internal.command.DbMigrate : Current version of schema `some_db`: << Empty Schema >>
2018-12-20 11:37:54.329 INFO 1279 --- [ main] o.f.core.internal.command.DbMigrate : Migrating schema `some_db` to version 20181220 - INIT
2018-12-20 11:37:54.408 INFO 1279 --- [ main] o.f.core.internal.command.DbMigrate : Successfully applied 1 migration to schema `somedb` (execution time 00:00.150s)
Flyway: java.lang.IllegalStateException 트러블슈팅
- 형상관리 대상 파일을 저장하는 프로젝트의 /src/main/resources/db/migration 디렉토리가 존재하지 않으면 아래 예외가 발생한다.
Caused by: java.lang.IllegalStateException: Cannot find migrations location in: [classpath:db/migration] (please add migrations or check your Flyway configuration)
Flyway: org.flywaydb.core.api.FlywayException 트러블슈팅
- 형상관리 대상 파일의 파일명 규칙을 지키지 않을 경우 아래 예외가 발생한다. 파일명은 반드시 V{version}__{description}.sql이 되어야 한다. (대소문자도 지켜야 한다.)
Caused by: org.flywaydb.core.api.FlywayException: Wrong migration name format: xxxxx.sql(It should look like this: V1.2__Description.sql)
쿼리 결과를 @Entity가 아닌 POJO에 맵핑하기
- 만약 Spring Data JPA에서 쿼리 결과를 엔티티가 아닌 일반적인 POJO 클래스에 맵핑하려면 어떻게 해야 할까?
@SqlResultSetMapping
,@NamedNativeQuery
를 활용하여 엔티티 클래스 레벨에 맵핑 정보를 명시하면 된다. 작성 예는 아래와 같다.
@SqlResultSetMapping(
name = "barMapping",
classes = {
@ConstructorResult(
targetClass = Bar.class,
columns = {
@ColumnResult(name = "id", type = Long.class),
@ColumnResult(name = "bar", type = String.class),
@ColumnResult(name = "created_at", type = LocalDateTime.class)
}
)
}
)
@NamedNativeQuery(
name = "findAllByCreatedAtBetween",
resultSetMapping = "barMapping",
resultClass = Bar.class,
query = "SELECT * FROM bar WHERE created_at BETWEEN :start_at AND :end_at ORDER BY id ASC"
)
@Entity
public class Foo {
...
}
- Repository 인터페이스는 아래와 같이 작성한다.
public interface FooRepository extends JpaRepository<Foo, Long> {
@Query(nativeQuery = true, name = "findAllByCreatedAtBetween")
List<Bar> findAllByCreatedAtBetween(@Param("startAt") LocalDateTime startAt, @Param("endAt") LocalDateTime endAt);
}
Spring Data REST 적용하기
Spring Data REST
를 적용하면 모든 리파지터리에 대해 자동으로 HAL(Hypertext Application Language) 형식의 REST API를 생성해준다. 추가적으로HAL Explorer
를 추가하면 미려한 UI로 모든 리파지터리에 대한 CRUD가 가능해진다. 인하우스 시스템의 어드민에 사용하기에는 정말 편리한 기능이다.- 먼저 프로젝트 루트의 /build.gradle 파일에 아래 내용을 추가한다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-rest'
compile group: 'org.springframework.data', name: 'spring-data-rest-hal-explorer', version: '3.2.0.RELEASE'
compile group: 'org.webjars', name: 'hal-explorer', version: '0.11.0'
}
- 다음으로 브라우저에서 /explorer 주소 요청시 HAL Expolorer이 실행되도록 맵핑해주어야 한다.
@Configuration
class RestConfig : WebMvcConfigurer {
override fun addResourceHandlers(registry: ResourceHandlerRegistry) {
if (!registry.hasMappingForPattern("/explorer/**")) {
registry.addResourceHandler("/explorer/**")
.addResourceLocations("classpath:/META-INF/resources/webjars/hal-explorer/0.11.0/")
}
}
}
참고 글
댓글
최근에 올라온 글
최근에 달린 댓글
- Total
- Today
- Yesterday
링크
TAG
- spring
- graylog
- Spring Boot
- JHipster
- node.js
- Docker
- Tomcat
- Kendo UI
- 로드 바이크
- bootstrap
- chrome
- jstl
- Kendo UI Web Grid
- CentOS
- java
- 자전거
- 구동계
- JavaScript
- jpa
- kotlin
- 태그를 입력해 주세요.
- Spring MVC 3
- maven
- jsp
- 평속
- DynamoDB
- 알뜰폰
- 로드바이크
- MySQL
- Eclipse
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |
글 보관함