Photoverse 백엔드

Photoverse 백엔드

생성일
Sep 20, 2024 08:39 AM
상위 항목
최종 편집 일시
Last updated October 24, 2024
태그
Kotlin
SpringBoot
하위 항목
(진행중 레포지토리)
 
백엔드는 항상 JAVA / SpringBoot를 메인으로 써왔지만 요즘 코틀린 가능자를 뽑는 곳이 늘어나고 있기도 하고 코틀린을 접해보니 자바 대비 좋은 점이 많아서 이번 프로젝트는 코프링을 사용해보기로 했다.

프로젝트 세팅

늘 그래왔듯 스프링 이니셜라이저를 통해 초기 프로젝트 세팅을 해야 한다.
Kotlin, spring을 gradle | kotlin을 사용해서 만들었다. 기존에 자바를 쓸 때는 groovy를 사용했지만 어차피 코틀린 쓸 거 kts로 가기로 했다.
프로젝트 구성은 복잡하게 가지 않기로 했다. 실무에서는 도메인 별로 모듈을 나눠서 관심사, 코드 격리를 했었는데 이번 프로젝트는 사이즈가 크지도 않을 것 같고 포폴 / 공부용으로 만드는 거라 굳이 서브모듈까지 만들 필요도 없다고 판단했다.
kotlin 버번은 1.9.25, springBoot는 3.3.3을 선택했다. 현재 LTS이다.
querydsl을 사용할지 안 할지는 정확히 모르지만 사용할 것을 대비해서 미리 추가해 두었다.
코드 컨벤션 ktlint를 자동으로 맞추기 위해 spotless 또한 추가해 주었다.
토큰 검증용 시큐리티 필터를 사용할 것이기 때문에 spring security 추가.
JPA를 사용하려면 allOpen 부분이 꼭 들어가야 한다. 이유는 아래 글을 참조했다.

build.gradle.kts

plugins { kotlin("jvm") version "1.9.25" kotlin("plugin.spring") version "1.9.25" id("org.springframework.boot") version "3.3.3" id("io.spring.dependency-management") version "1.1.6" kotlin("plugin.jpa") version "1.9.25" id("com.diffplug.spotless") version "6.25.0" id("org.jetbrains.kotlin.kapt") version "1.6.21" } group = "com.pure" version = "0.0.1-SNAPSHOT" java { toolchain { languageVersion = JavaLanguageVersion.of(17) } } configurations { compileOnly { extendsFrom(configurations.annotationProcessor.get()) } } repositories { mavenCentral() } dependencies { implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-web") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.springframework.boot:spring-boot-starter-security") testImplementation("org.springframework.security:spring-security-test") testImplementation("io.mockk:mockk:1.13.12") // https://mvnrepository.com/artifact/io.awspring.cloud/spring-cloud-starter-aws implementation("io.awspring.cloud:spring-cloud-starter-aws:2.4.4") implementation("com.google.firebase:firebase-admin:9.3.0") compileOnly("org.projectlombok:lombok") developmentOnly("org.springframework.boot:spring-boot-devtools") developmentOnly("org.springframework.boot:spring-boot-docker-compose") runtimeOnly("com.mysql:mysql-connector-j") annotationProcessor("org.projectlombok:lombok") // https://velog.io/@yangwon-park/Kotlin-Querydsl-%EC%84%B8%ED%8C%85 참고 implementation("com.querydsl:querydsl-jpa:5.0.0:jakarta") implementation("com.querydsl:querydsl-apt:5.0.0:jakarta") implementation("jakarta.persistence:jakarta.persistence-api") implementation("jakarta.annotation:jakarta.annotation-api") kapt("com.querydsl:querydsl-apt:5.0.0:jakarta") kapt("org.springframework.boot:spring-boot-configuration-processor") testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } kotlin { compilerOptions { freeCompilerArgs.addAll("-Xjsr305=strict") } } tasks.withType<Test> { useJUnitPlatform() } tasks.named("compileKotlin") { dependsOn("spotlessApply") } allOpen { annotation("javax.persistence.Entity") annotation("javax.persistence.Embeddable") annotation("javax.persistence.MappedSuperclass") } spotless { kotlin { target("**/*.kt") targetExclude("**/build/**/*.kt") targetExclude("**/bin/**/*.kt") ktlint() trimTrailingWhitespace() indentWithSpaces(4) endWithNewline() } }
 

Mysql 설정 (docker-compose)

services: mysql: image: 'mysql:latest' environment: - 'MYSQL_DATABASE=photoverse' - 'MYSQL_PASSWORD=pure1234' - 'MYSQL_ROOT_PASSWORD=pure1234' - 'MYSQL_USER=pure' ports: - '3306:3306'
DB를 로컬에 실제로 설치해서 사용하는 것은 매우 귀찮다. 이럴 때 쓰라고 있는 게 docker다.
docker compose 파일이 있는 폴더에서 터미널에docker-compose up -d 해주면 백그라운드에서 Mysql 컨테이너가 실행된다.
docker ps 해주면 현재 실행상태를 확인할 수 있다.
notion image
3306:3306 이런식으로 포트를 명시해서 이어주지 않으면 컨테이너를 내렸다올렸다 할 때마다 내 로컬 → 도커 컨테이너를 부르는 포트가 랜덤으로 바뀐다. 이러면 application.yaml에 db 주소를 붙여놓은 걸 계속 수정해야 하기 때문에 고정으로 연결해준다.

pre-commit 훅 스크립트 설정

/scripts/hooks 폴더에 넣어두고 실행시켜서 사용하는 편이다.
.sh install_hooks.sh 를 실행해서 success가 뜨면 제대로 설치된거다.
이걸 해두면 spotlessApply를 하지 않은 상태에서는 커밋이 아예 안되도록 막을 수 있다.
코틀린이라 spotlessKotlinCheck과 spotlessKotlinApply를 사용했는데 복붙하면서 프로젝트마다 돌려쓴다고 하면 spotlessCheck, spotlessApply로 바꿔도 된다.
pre-commit 실행파일
#!/usr/bin/env bash CURRENT_TOP_LEVEL_DIR_PATH="$(git rev-parse --show-toplevel)" cd "$CURRENT_TOP_LEVEL_DIR_PATH" || exit 1 echo "Executed SpotlessCheck" ./gradlew spotlessKotlinCheck --daemon SPOTLESS_CHECK_RESULT=$? printf "Executed spotlessKotlinApply" ./gradlew spotlessKotlinApply --daemon if [ $SPOTLESS_CHECK_RESULT -eq 0 ]; then printf ">>> Success\n" exit 0 else printf ">>> Failed\n" exit $SPOTLESS_CHECK_RESULT fi
쉘 실행파일
#!/usr/bin/env bash CURRENT_TOP_LEVEL_DIR_PATH="$(git rev-parse --show-toplevel)" printf "Installing pre-commit to repo " if chmod ug+x "$CURRENT_TOP_LEVEL_DIR_PATH/scripts/hooks/pre-commit" && ln -fs "$CURRENT_TOP_LEVEL_DIR_PATH/scripts/hooks/pre-commit" "$CURRENT_TOP_LEVEL_DIR_PATH/.git/hooks/pre-commit"; then printf ">> Success\n" else printf ">> Failed\n" exit 1 fi

application.yaml

server: port: 8080 spring: application: name: photoverse datasource: url: 'jdbc:mysql://localhost:3306/photoverse' username: 'pure' password: 'pure1234' driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: ddl-auto: update properties: hibernate: format_sql: true show_sql: true
초기 단계이기 때문에 어차피 로컬에서만 돌릴 거라 profile을 나누지는 않았다.

querydsl 설정

package com.pure.photoverse.config import com.querydsl.jpa.impl.JPAQueryFactory import jakarta.persistence.EntityManager import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @Configuration class QuerydslConfig(private val em: EntityManager) { @Bean fun querydsl(): JPAQueryFactory { return JPAQueryFactory(em) } }
queryDSL을 사용하려면 따로 @Configuration으로 Bean을 만들어주어야 한다.
위와 같이 작성해두면 된다.
 

프로젝트 구조

├── main │   ├── kotlin │   │   └── com │   │   └── pure │   │   └── photoverse │   │   ├── PhotoverseApplication.kt │   │   ├── application │   │   │   └── UserService.kt │   │   ├── config │   │   │   ├── FirebaseConfig.kt │   │   │   ├── FirebaseTokenFilter.kt │   │   │   ├── QuerydslConfig.kt │   │   │   └── SecurityConfig.kt │   │   ├── domain │   │   │   ├── BaseEntity.kt │   │   │   ├── Post.kt │   │   │   ├── PostComment.kt │   │   │   ├── PostLike.kt │   │   │   ├── PostReplyComment.kt │   │   │   ├── Story.kt │   │   │   ├── StoryComment.kt │   │   │   ├── StoryLike.kt │   │   │   ├── StoryView.kt │   │   │   └── User.kt │   │   ├── dto │   │   │   └── LoginResponse.kt │   │   ├── repository │   │   │   └── user │   │   │   └── UserRepository.kt │   │   └── web │   │   ├── PostController.kt │   │   └── UserController.kt │   └── resources │   ├── application.yaml │   ├── firebaseCredential.json │   ├── static │   └── templates └── test └── kotlin └── com └── pure └── photoverse └── PhotoverseApplicationTests.kt

엔티티 설계

도메인은 일단 User, Post, Story 정도로 구성했다. 필요에 따라 추가할 예정이다.
각 엔티티에는 공통적으로 id, createdAt, updatedAt, deletedAt이 모두 필요하기 때문에 JPA Auditing을 사용해서 처리한다.
각 엔티티에 상속시킬 BaseEntity라는 추상 클래스를 만든다.
이 때 메인 함수가 있는 클래스에 @EnableJpaAuditing을 추가하는 것을 까먹으면 안된다.

BaseEntity

@MappedSuperclass @EntityListeners(AuditingEntityListener::class) abstract class BaseEntity( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long? = null, @CreatedDate @Column(updatable = false, nullable = false, name = "created_at") var createdAt: LocalDateTime? = null, @LastModifiedDate @Column(nullable = false, name = "updated_at") var updatedAt: LocalDateTime? = null, @Column(name = "deleted_at") var deletedAt: LocalDateTime? = null, // For Soft Delete )
deletedAt은 소프트딜리트를 위해 만들었다.
실제로 행삭제를 하기보다는 로그 남기는 것처럼 deletedAt을 활용해서 쿼리 시에 걸러지도록 한다.

User

firebaseToken을 verifyIdToken해서 꺼낼 수 있는 정보들을 가져다 User에 매핑하여 저장한다.
signInProvider는 google.com 과 같은 식으로 들어있기 때문에 Enum으로 만들어서 저장해준다.
리턴도 함수형으로 ?: 같은 걸 써서 리턴할 수 있는 게 자바보다 편하고 좋다.
@Entity @Table(name = "users") class User( @Column(nullable = false, unique = true) val uid: String, @Column(name = "sign_in_provider", nullable = false) @Enumerated(EnumType.STRING) var signInProvider: SignInProvider, @Column(nullable = false) var username: String, @Column var email: String, @Column(name = "profile_image") var profileImage: String? = null, @Column(columnDefinition = "TEXT") var caption: String? = null, @OneToMany(mappedBy = "user", fetch = FetchType.LAZY, cascade = [CascadeType.ALL], orphanRemoval = true) val posts: MutableList<Post> = mutableListOf(), @OneToMany(mappedBy = "user", fetch = FetchType.LAZY, cascade = [CascadeType.ALL], orphanRemoval = true) val postComments: MutableList<PostComment> = mutableListOf(), @OneToMany(mappedBy = "user", fetch = FetchType.LAZY, cascade = [CascadeType.ALL], orphanRemoval = true) val stories: MutableList<Story> = mutableListOf(), ) : BaseEntity() { init { require(uid.isNotBlank()) { "UID는 비어 있을 수 없습니다." } require(username.isNotBlank()) { "닉네임은 비어 있을 수 없습니다." } } enum class SignInProvider( val providerString: String, ) { GOOGLE("google.com"), APPLE("apple.com"), GITHUB("github.com"), ; companion object { fun fromString(providerString: String): SignInProvider { return entries.find { it.providerString == providerString } ?: throw IllegalArgumentException("지원하지 않는 로그인 제공자입니다.") } } } fun updateNickname(username: String) { this.username = username } fun updateEmail(email: String) { this.email = email } fun updateProfileImage(profileImage: String) { this.profileImage = profileImage } fun updateDescription(caption: String) { this.caption = caption } companion object { @JvmOverloads @Suppress("ktlint:standard:max-line-length") fun fixture( uid: String = "1234567890", signInProvider: SignInProvider = SignInProvider.GOOGLE, username: String = "A", email: String = "test@gmail.com", profileImage: String = "https://example.com/1", // 아무거나 이미지 URL caption: String = "test description", ): User { return User( uid = uid, signInProvider = signInProvider, username = username, email = email, profileImage = profileImage, caption = caption, ) } } }

Post, PostComment, PostLike, PostReplyComment

Post는 기본적으로 피드에 올라오는 하나의 게시물이다.
PostComment는 그 게시물에 달리는 댓글이다.
PostReplyComment는 각 게시물의 댓글에 달리는 대댓글이다.
PostLike는 게시물에 좋아요를 누른 사람들을 관리하기 위해 단순히 like 숫자만 올리는 게 아니라 엔티티로 관리한다.
게시물과 유저는 다대일 관계이다.
한 게시물에 이미지를 여러장 올릴 수 있으므로 ElementCollection을 사용해서 List로 관리한다.
게시물 당 댓글이 여러개 달릴 수 있으므로 Post : Comment는 일대다이다.
Comment에 대댓글이 여러개 달릴 수 있으므로 Comment: ReplyComment는 일대다이다.
post
@Entity @Table(name = "posts") class Post( @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) val user: User, @ElementCollection @CollectionTable(name = "images", joinColumns = [JoinColumn(name = "photo_id")]) @Column(nullable = false) var urls: MutableList<String> = mutableListOf(), @Column(columnDefinition = "TEXT") var caption: String? = null, @OneToMany(mappedBy = "post", fetch = FetchType.LAZY, cascade = [CascadeType.ALL], orphanRemoval = true) val postComments: MutableList<PostComment> = mutableListOf(), @OneToMany(mappedBy = "post", fetch = FetchType.LAZY, cascade = [CascadeType.ALL], orphanRemoval = true) val likes: MutableList<PostLike> = mutableListOf(), ) : BaseEntity() { init { require(urls.isNotEmpty()) { "최소 한 개의 사진이나 동영상이 업로드 되어야 합니다." } } fun updateUrl(urls: List<String>) { this.urls = urls.toMutableList() } fun updateDescription(caption: String) { this.caption = caption } fun addComment(postComment: PostComment) { postComments.add(postComment) } fun removeComment(postComment: PostComment) { postComments.remove(postComment) } companion object { @JvmOverloads fun fixture( user: User, urls: List<String> = listOf("https://example.com", "https://example.com"), caption: String? = "사진 설명", ): Post { return Post( user = user, urls = urls.toMutableList(), caption = caption, ) } } }
PostComment
@Entity @Table(name = "post_comments") class PostComment( @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "photo_id", nullable = false) val post: Post, @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) val user: User, @Column(nullable = false) var content: String, @Column(nullable = false) var likes: Int = 0, ) : BaseEntity() { init { require(content.isNotBlank()) { "내용은 비어 있을 수 없습니다." } } fun addLike() { likes++ } fun addComment(post: Post) { post.postComments.add(this) } fun removeComment(post: Post) { post.postComments.remove(this) } companion object { @JvmOverloads fun fixture( post: Post, user: User, content: String = "댓글", likes: Int = 0, ): PostComment { return PostComment( post = post, user = user, content = content, likes = likes, ) } } }
 
PostLike
@Entity @Table(name = "post_likes") class PostLike( @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "photo_id", nullable = false) val post: Post, @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) val user: User, ) : BaseEntity() { fun addLike(post: Post) { post.likes.add(this) } fun dislike(post: Post) { post.likes.remove(this) this.deletedAt = LocalDateTime.now() } }
PostReplyComment
@Entity @Table(name = "post_reply_comments") class PostReplyComment( @ManyToOne(fetch = FetchType.LAZY) val postComment: PostComment, @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) val user: User, @Column(nullable = false, columnDefinition = "TEXT") var content: String, @Column(nullable = false) var likes: Int = 0, ) : BaseEntity()

Story, StoryComment, StoryLike, StoryView

Post와 기본적으로 구조는 같다.
like처럼 스토리 본 사람들을 알아야 하기 때문에 StoryView 엔티티를 따로 만든다.
스토리의 댓글에는 대댓글이 없다고 가정하여 StoryReplyComment는 없다.
Story
@Entity @Table(name = "stories") class Story( @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) val user: User, @Column(nullable = false) val url: String, @Column(name = "media_type", nullable = false) @Enumerated(EnumType.STRING) val mediaType: MediaType, @Column(name = "expiration_time", nullable = false) val expirationTime: LocalDateTime = LocalDateTime.now().plusDays(1), @Column(name = "is_active", nullable = false) var isActive: Boolean = true, @OneToMany(mappedBy = "story", fetch = FetchType.LAZY, cascade = [CascadeType.ALL], orphanRemoval = true) val storyViews: MutableList<StoryView> = mutableListOf(), @OneToMany(mappedBy = "story", fetch = FetchType.LAZY, cascade = [CascadeType.ALL], orphanRemoval = true) val likes: MutableList<StoryLike> = mutableListOf(), @OneToMany(mappedBy = "story", fetch = FetchType.LAZY, cascade = [CascadeType.ALL], orphanRemoval = true) val comments: MutableList<StoryComment> = mutableListOf(), ) : BaseEntity() { init { require(url.isNotBlank()) { "URL은 비어 있을 수 없습니다." } } fun addStory(user: User) { user.stories.add(this) } fun deleteStory(user: User) { user.stories.remove(this) this.isActive = false this.deletedAt = LocalDateTime.now() } companion object { @JvmOverloads fun fixture( user: User, url: String = "https://example.com", mediaType: MediaType = MediaType.IMAGE, ): Story { return Story( user = user, url = url, mediaType = mediaType, expirationTime = LocalDateTime.now().plusDays(1), ) } } enum class MediaType { IMAGE, VIDEO, } }
한 스토리의 유효기간은 보통 1일로 잡기 때문에 1일로 초기화해준다.
크론잡 같은 것을 통해서 주기적으로 체크해서 isActive로 expiration 상태를 관리해준다.
StoryComment
@Entity @Table(name = "story_comments") class StoryComment( @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "story_id", nullable = false) val story: Story, @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) val user: User, @Column(nullable = false, columnDefinition = "TEXT") var content: String, @Column(nullable = false) var likes: Int = 0, ) : BaseEntity() { init { require(content.isNotBlank()) { "내용은 비어 있을 수 없습니다." } } fun addLike() { likes++ } fun addComment(story: Story) { story.comments.add(this) } fun removeComment(story: Story) { story.comments.remove(this) } companion object { @JvmOverloads fun fixture( story: Story, user: User, content: String = "댓글", likes: Int = 0, ): StoryComment { return StoryComment( story = story, user = user, content = content, likes = likes, ) } } }
StoryLike
@Entity @Table(name = "story_likes") class StoryLike( @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "story_id", nullable = false) val story: Story, @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) val user: User, ) : BaseEntity() { fun addLike(story: Story) { story.likes.add(this) } fun dislike(story: Story) { story.likes.remove(this) this.deletedAt = LocalDateTime.now() } }
StoryView
@Entity @Table(name = "story_views") class StoryView( @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "story_id", nullable = false) val story: Story, @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) val user: User, ) : BaseEntity() { fun addStoryView(story: Story) { story.storyViews.add(this) } companion object { fun fixture( story: Story, user: User, ): StoryView { return StoryView( story = story, user = user, ) } } }
 
 

이미지 업로드 기능

이미지를 올릴 클라우드 스토리지가 필요하다.
보통 AWS S3 버킷을 사용하는 게 편리하다. 하지만 나는 프리티어가 끝났기 때문에 다른 방안이 필요했다.
클라우드플레어 R2가 10기가 무료로 주고 트래픽도 100만 건인가 무료로 주는데 AWS S3 API를 똑같이 쓸 수 있어서 R2를 사용해보기로 했다.
먼저 클라우드 플레어 계정을 만들고 R2 버킷을 하나 만들어준다.
AWS S3 SDK를 쓰기 위해 아래 의존성을 build.gradle에 추가했다.
implementation("io.awspring.cloud:spring-cloud-starter-aws:2.4.4")
 
application.yaml에 r2 api를 사용하기 위한 각종 키들을 선언해준다.
값을 그대로 선언하지 않고 환경변수를 통해 주입받을 수 있도록 해준다.
cloudflare: r2: accessKey: ${R2_ACCESS_KEY} secretKey: ${R2_SECRET_KEY} bucketName: ${R2_BUCKET_NAME} region: ${R2_REGION} endpoint: ${R2_ENDPOINT_URL} url: ${R2_PUB_URL}
나중에 배포할 때는 배포도구에서 환경변수를 설정할 수 있다.
나는 로컬 환경에서는 인텔리제이의 run configuration에 환경변수를 넣어서 사용한다.
r2 콘솔에서 이것저것 설정을 한다음에 그 값을 적어야 하는데 그건 나중에 링크로 대체하겠다.
url은 필요가 없을 수도 있는 값인데 나한테는 필요한 게 cloudflare 도메인을 구입하지 않았기 때문에 public에서 파일에 액세스 하려면 r2.dev를 사용해야 하기 때문에 넣었다.
 

R2Config

@Configuration class R2Config( @Value("\${cloudflare.r2.endpoint}") private val r2EndPointUrl: String, @Value("\${cloudflare.r2.accessKey}") private val r2AccessKey: String, @Value("\${cloudflare.r2.secretKey}") private val r2SecretKey: String, @Value("\${cloudflare.r2.region}") private val r2Region: String, ) { @Bean fun r2Client(): AmazonS3? { return AmazonS3Client.builder() .withEndpointConfiguration(AwsClientBuilder.EndpointConfiguration(r2EndPointUrl, r2Region)) .withCredentials(AWSStaticCredentialsProvider(BasicAWSCredentials(r2AccessKey, r2SecretKey))) .build() } }
amazonS3 sdk를 공유하기 때문에 위와 같이 설정하면 된다.
region은 apac을 넣어주긴 했지만 사실 r2는 알아서 자동으로 region을 감지하기 때문에 아무거나 넣어도 된다.

R2FileManagement

R2에 이미지를 올리고 삭제하는 메서드를 정의한 클래스다.
cloudflare 도메인을 연결할 수 있었다면 getUrl이 반환하는 파일 주소를 그대로 사용할 수 있었겠지만 나는 r2.dev 주소를 사용해야 하기 때문에 getFile에 r2PubUrl을 사용해서 퍼블릭 액세스가 가능한 주소로 수동으로 변환해준다.
metaData를 설정하여 주소를 get 했을 때 이미지가 출력되도록 하는 설정을 추가했다.(content-disposition = inline)
@Component class R2FileManagement( @Value("\${cloudflare.r2.bucketName}") private val r2BucketName: String, private val r2Client: AmazonS3, @Value("\${cloudflare.r2.url}") private val r2PubUrl: String, ) { fun uploadImage(file: MultipartFile): String { // Upload file to R2 val originalFilename = file.originalFilename ?: throw IllegalArgumentException("File name is required") FileValidate.checkFileFormat(originalFilename) val fileName = "image/${UUID.randomUUID()}-$originalFilename" val objectMetadata = setFileMetaData(file) r2Client.putObject(r2BucketName, fileName, file.inputStream, objectMetadata) return getFile(fileName) } fun getFile(fileName: String): String { r2Client.getUrl(r2BucketName, fileName).toString() return "$r2PubUrl/$fileName" } fun delete(fileName: String) { r2Client.deleteObject(r2BucketName, fileName) } private fun setFileMetaData(multipartFile: MultipartFile): ObjectMetadata { val objectMetadata = ObjectMetadata() val mimeType = multipartFile.contentType ?: "application/octet-stream" objectMetadata.contentType = mimeType objectMetadata.contentLength = multipartFile.inputStream.available().toLong() // 주소를 넣었을 때 이미지가 출력되지 않고 다운로드 되는 문제 해결. objectMetadata.contentDisposition = "inline" return objectMetadata } }
 
FileValidate.CheckFileFormat()
class FileValidate { companion object { private val IMAGE_EXTENSIONS: List<String> = listOf("jpg", "jpeg", "png", "gif", "webp") fun checkFileFormat(image: String) { val extension = image.substringAfterLast(".") if (!IMAGE_EXTENSIONS.contains(extension)) { throw IllegalArgumentException("Not supported file format") } } } }
혹시나 프론트에서 넘긴 파일이 이미지 파일이 아닌 경우를 대비하기 위한 체크 로직이다.

ImageController, ImageService

@RestController @RequestMapping("/api/v1/images") class ImageController( private val imageService: ImageService, ) { @PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) fun uploadImages( @RequestParam("images") files: List<MultipartFile>, ): ResponseEntity<ImageUploadResponse> { return ResponseEntity.ok(imageService.uploadImages(files)) } }
프론트에서 post를 만드는 것과 동시에 이미지 업로드를 처리하지 않고 이미지 업로드만 따로 처리한 뒤 url을 받고 createPost하는 요청을 따로 보내도록 만들었기 때문에 이미지 배열만 formData에 넣은 걸 백에서 받아서 처리한다.
@Service class ImageService( private val r2FileManagement: R2FileManagement, ) { @Transactional fun uploadImages(files: List<MultipartFile>): ImageUploadResponse { // List<MultipartFile> -> List<String> 개꿀 val urls = files.map { file -> r2FileManagement.uploadImage(file) } return ImageUploadResponse.of(urls) } }
자바였다면 stream().map().toList() 한다음 타입도 List<String>을 명시해야 했겠지만 코틀린은 위와 같이 짧게 끝난다.

ImageServiceTest

@SpringBootTest class ImageServiceTest { private lateinit var imageService: ImageService private lateinit var r2FileManagement: R2FileManagement @BeforeEach fun setUp() { r2FileManagement = mockk() imageService = ImageService(r2FileManagement) } @Test @DisplayName("이미지 업로드가 후 url을 정상적으로 리턴한다.") fun uploadImages() { // given val file1 = MockMultipartFile("image1", "image1.png", "image/png", ByteArray(10)) val file2 = MockMultipartFile("image2", "image1.jpg", "image/jpg", ByteArray(10)) val file3 = MockMultipartFile("image3", "image1.webp", "image/webp", ByteArray(10)) // when every { r2FileManagement.uploadImage(file1) } returns "http://example.com/image1.png" every { r2FileManagement.uploadImage(file2) } returns "http://example.com/image2.jpg" every { r2FileManagement.uploadImage(file3) } returns "http://example.com/image3.webp" val response = imageService.uploadImages(listOf(file1, file2, file3)) // then assertThat(response.urls).hasSize(3) assertThat(response.urls).containsExactly( "http://example.com/image1.png", "http://example.com/image2.jpg", "http://example.com/image3.webp", ) } }
ImageService.uploadImages()를 단위테스트 할 거기 때문에 실제로 r2에 요청을 보내지 않고 mockk()를 사용해서 file 업로드 부분을 처리한다.
 

게시물 만들기 기능

PostController, PostService

@RestController @RequestMapping("/api/v1/posts") class PostController( private val postService: PostService, ) { private val log = LoggerFactory.getLogger(this::class.java) @PostMapping fun createPost( @RequestBody request: CreatePostRequest, ): ResponseEntity<CreatePostResponse> { return ResponseEntity.ok(postService.createPost(request)) } } // CreatePostRequest data class CreatePostRequest( val images: List<String>, val caption: String, ) // CreatePostResponse data class CreatePostResponse( val postId: Long, val images: List<String>, val username: String, val caption: String, val createdAt: LocalDateTime, ) { companion object { fun of( postId: Long, images: List<String>, username: String, caption: String, createdAt: LocalDateTime, ): CreatePostResponse { return CreatePostResponse( postId = postId, images = images, username = username, caption = caption, createdAt = createdAt, ) } } }
createPost는 CreatePostRequest를 받아서 DB에 저장하고 CreatePostResponse를 돌려준다.
 
@Service class PostService( private val userService: UserService, private val postRepository: PostRepository, ) { @Transactional fun createPost(request: CreatePostRequest): CreatePostResponse { val currentUser = userService.getCurrentUser() val newPost = Post( user = currentUser, urls = request.images, caption = request.caption, ) val savedUser = postRepository.save(newPost) return CreatePostResponse.of( savedUser.id ?: throw IllegalArgumentException("Post ID cannot be null"), savedUser.urls, savedUser.user.username, savedUser.caption ?: "", savedUser.createdAt ?: throw IllegalArgumentException("Post createdAt cannot be null"), ) } }
PostService.createPost()는 실제로 Post 엔티티를 만들어서 저장하는 로직이다.

PostService.createPost test

@SpringBootTest class PostServiceTest { private lateinit var postService: PostService private lateinit var postRepository: PostRepository private lateinit var userService: UserService @BeforeEach fun setUp() { postRepository = mockk() userService = mockk() postService = PostService(userService, postRepository) every { userService.getCurrentUser() } returns User( uid = "모자이크", signInProvider = User.SignInProvider.GOOGLE, username = "유저네임", email = "이메일", profileImage = "프로필이미지주소", caption = "caption", ) every { postRepository.save(any()) } answers { val post = arg<Post>(0) spyk<Post>(post) { every { this@spyk.id } returns 1L every { this@spyk.createdAt } returns LocalDateTime.now() } } } @Test @DisplayName("포스트 생성이 정상 동작한다.") fun createPost() { // given val request = CreatePostRequest(listOf("https://example.com/image1.jpg", "https://example.com/image2.jpg"), "test caption") // when val response = postService.createPost(request) // then assertNotNull(response) assertThat(request.images[0]).isEqualTo(response.images[0]) assertThat(request.images[1]).isEqualTo(response.images[1]) assertThat(request.caption).isEqualTo(response.caption) } }
이전에 java에서 repository를 이용해서 테스트할 때는 통합테스트였어서 항상 @Transactional을 붙여서 테스트가 실제 DB에 영향이 없도록 했었다.
이번에는 mockk를 이용해서 아예 repository를 이용하는 부분을 모킹해서 처리하니 Transactional을 쓸 필요도 없고 테스트 속도도 빨라졌다.
이번에는 순수하게 유닛테스트를 작성하는 식으로 하고 있다고 볼 수 있다.
spyk라는 것이 좀 특이했는데 내 엔티티들은 BaseEntity를 모두 상속받아서 id, createdAt, updatedAt, deletedAt을 공유한다.
spyk를 사용해야 Post가 부모 객체로부터 상속받은 필드를 모킹할 수 있다.
arg<Post>(0)은 Post 엔티티로 저장된 것들 중 첫번째 것을 리턴한다는 뜻이다.
id, createdAt을 값을 넣어준 것은 CreatePostResponse를 만들 때 그 두 개의 필드에는 익셉션 처리를 해두었기 때문이다.