찌리릿[Zziririt] 프로젝트 회고

박미소·2024년 4월 17일
1

코틀린

목록 보기
43/44
post-thumbnail

KEEP(지속할 것)

  1. 프로젝트 타임라인 설정 -> 기획 단계에서 기본 틀을 만들어놔서 개발할 때 매끄럽게 계획들을 진행할 수 있었다.
  2. 스프레드 시트로 테스트 코드 시나리오 공유 -> 다른 팀원의 도메인 정책을 이해하는데 아주 좋았다.

PROBLEM(문제가 된 것)

  1. 추상적인 정책들을 코드로 구현하는 것이 어려웠다.
  2. 프론트를 그리며 구현하는 것 또한 어려웠다.

TRY(다음에 시도할 것)

  1. 팀원들이 각자 개발에 집중할 수 있게 개인 개발 시간을 정해서 그 시간동안 혼자서 집중하는 시간을 가지는 것.
  2. 클라이밍 가면서 하자.


열정 넘치는 팀원들과 함께한 덕분에 좋은 성과를 낼 수 있었던 것 같다.
찌리릿을 통해서 많은 것들을 배웠고 개발 외적으로도 개인적인 성장을 할 수 있었다.


기획

Figma 를 활용해 브레인스토밍을 하고 와이어프레임을 만들었다.

프로젝트의 목표

네이버 스트리밍 플랫폼 치지직의 커뮤니티 서비스를 제공하는 것

찌리릿 아키텍쳐

ERD


개발한 도메인과 성능개선

QueryDsl 을 활용한 스케쥴러

게시판의 정책으로 8일 이상 스트리머 게시판 내 업데이트 된 게시글이 없다면 게시판을 비활성화 상태값으로 변경시켜 프론트에서 보이지 않도록 구현해야했다.

일정 주기로 실행시켜야하므로 스프링 스케쥴러를 활용했고, 직관적으로 쿼리를 확인하고 복잡한 쿼리를 쉽게 작성할 수 있는 QueryDsl 을 활용했다.

 @Transactional
    @Scheduled(cron = "0 0 0 * * *")
    fun boardScheduler() {
        val inactiveBoardIdList = boardRepository.findInactiveBoardStatus()

        boardRepository.updateBoardStatusToInactive(inactiveBoardIdList)
    }

대량의 데이터를 효율적으로 일괄 업데이트 처리하기 위해 더티 체킹 방식이 아닌 QueryDsl update 방식을 사용했다.

override fun findBoardStatusToInactive(): List<Long> {
        val checkInactiveDate = LocalDateTime.now().minusDays(8)

        return queryFactory.select(board.id).distinct()
            .from(post)
            .leftJoin(board)
            .on(board.id.eq(post.board.id))
            .where(post.modifiedAt.loe(checkInactiveDate), board.boardType.eq(BoardType.STREAMER_BOARD))
            .fetch()
    }

override fun updateBoardStatusToInactive(inactiveBoardIdList: List<Long>) {
        queryFactory
            .update(board)
            .set(board.boardActStatus, BoardActStatus.INACTIVE)
            .where(board.id.`in`(inactiveBoardIdList))
            .execute()
    }    

트러블 슈팅: 스케쥴러 작업 중복 실행 가능성 문제

scale-out 한 다른 어플리케이션 서버에서 구동 중인 2번째 스케쥴러가 동시 작업 진행 시 스케쥴러 수행 자체의 중복이 발생할 수 있는 문제점을 발견했다.

중복 실행에 대한 해결을 위해 ShedLock 을 이용해 스케쥴링 Lock 을 설정했다.

Shedlock 정보 관리를 위한 테이블 생성

CREATE TABLE shedlock(name VARCHAR(64) NOT NULL, lock_until TIMESTAMP NOT NULL,
    locked_at TIMESTAMP NOT NULL, locked_by VARCHAR(255) NOT NULL, PRIMARY KEY (name));

Bean 설정

@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "15m")
class ScheduleConfig {

    @Bean
    fun lockProvider(dataSource: DataSource): LockProvider {
        return JdbcTemplateLockProvider(
            JdbcTemplateLockProvider.Configuration.builder()
                .withJdbcTemplate(JdbcTemplate(dataSource))
                .usingDbTime()
                .build()
        )
    }
}

@SchedulerLock 적용

    @Transactional
    @Scheduled(cron = "0 0 0 * * *")
    @SchedulerLock(name = "boardStatus_lock", lockAtLeastFor = "14m", lockAtMostFor = "14m")
    fun boardScheduler() {
        LockAssert.assertLocked()

        val inactiveBoardIdList = boardRepository.findInactiveBoardStatus()
        boardRepository.updateBoardStatusToInactive(inactiveBoardIdList)

        logger.debug { "scheduled task" }
    }

S3 파일 업로드 구현

스트리머 게시판 신청 시 치지직 스트리머임을 인증하는 이미지를 제출해야하는 정책이 있어 AWS S3 를 이용했다.

AWS 비밀 키를 백엔드에서 안전하게 관리하고 클라이언트에 노출시키지 않게해 보안을 강화하기 위해서 백엔드에서 S3를 관리하도록 결정했다.

파일의 확장자를 확인해 이미지 파일만 받을 수 있게 구현했다.

fun String.isImageFileOrThrow() {
    val fileExtension = this.split(".").let { it[it.lastIndex] }
    check(fileExtension.contains("png") or fileExtension.contains("jpeg") or fileExtension.contains("jpg")) {
        throw RestApiException(ErrorCode.NOT_IMAGE_FILE_EXTENSION)
    }
}

다른 도메인에서도 사용할 수 있도록 메서드로 구현했다.

@Service
class S3Service(
    private val amazonS3Client: AmazonS3Client,
) {

    @Value("\${cloud.aws.s3.bucket}")
    private lateinit var bucket: String

    fun uploadFiles(dir: String, files: List<MultipartFile>): List<String> {
        val imageUrls = ArrayList<String>()
        for (file in files) {
            val randomFileName = "$dir/${UUID.randomUUID()}${LocalDateTime.now()}${file.originalFilename}"

            file.originalFilename?.isImageFileOrThrow()

            val objectMetadata = ObjectMetadata()
            objectMetadata.contentLength = file.size
            objectMetadata.contentType = file.contentType

            try {
                val inputStream: InputStream = file.inputStream
                amazonS3Client.putObject(bucket, randomFileName, inputStream, objectMetadata)
                val uploadFileUrl = amazonS3Client.getUrl(bucket, randomFileName).toString()
                imageUrls.add(uploadFileUrl)
            } catch (e: IOException) {
                e.printStackTrace()
            }
        }
        return imageUrls
    }

}

트러블 슈팅: 타임아웃 방지

다수의 사용자로부터 동시에 S3 서비스 요청이 들어올 경우 서버의 스레드 소진 위험 가능성이 존재하는 것을 발견했고 이로 인해 타임아웃이 발생될 수 있다.

이에 따라 스레드 풀 설정을 적절하게 하고 타임아웃 문제를 해결하기 위해 코루틴을 이용했다.

백그라운드 스레드로 별도의 스레드 풀을 사용함으로써 비동기 작업을 할 수 있게 withContext와 Dispatchers.IO 로 읽기/쓰기 작업에 스레드를 최적화시켰다.

await 함수를 사용해 비동기 작업이 완료될때까지 기다리고 결과를 반환받기위해 async를 활용했다.

suspend fun imageUploadWithCoroutine(dir: String, files: List<MultipartFile>) = withContext(Dispatchers.IO) {
    val imageUrls = ArrayList<String>()
    val uploadImage = files.map {
        val randomFileName = "$dir/${UUID.randomUUID()}${LocalDateTime.now()}${it.originalFilename}"
        val uploadFileUrl = amazonS3Client.getUrl(bucket, randomFileName).toString()

        it.originalFilename?.isImageFileOrThrow()
        val objectMetadata = ObjectMetadata().apply {
            this.contentType = it.contentType
            this.contentLength = it.size
        }
        async {
            val putObjectRequest = PutObjectRequest(
                bucket,
                randomFileName,
                it.inputStream,
                objectMetadata,
            )
            amazonS3Client.putObject(putObjectRequest)
            imageUrls.add(uploadFileUrl)
        }
    }
    uploadImage.await()
    return@withContext imageUrls

성능개선: presigned URL

클라이언트에서 여러 장의 이미지 파일 자체를 백엔드로 데이터를 보내주면 aws에 저장되고 클라이언트에게 다시 링크를 넘겨주고 있는데 이 과정에서 서버의 부하가 생길 수 있는 문제가 있다.

그래서 서버를 경유하지 않고 프론트에서 직접 파일을 업로드 할 수 있는 aws S3 의 Presigned Url 정책을 통해 해결하기로 결정했다.

fun getPreSignedUrl(fileNames: List<MultipartFile>): Map<String, Serializable>? {
        val encodedFileName = fileNames.let { "${it}_${LocalDateTime.now()}" }
        val expiration = Date()
        var expTimeMillis: Long = expiration.time
        
        expTimeMillis += (3 * 60 * 1000).toLong() // 3분
        expiration.time = expTimeMillis // url 만료 시간 설정

        val generatePresignedUrlRequest: GeneratePresignedUrlRequest =
            GeneratePresignedUrlRequest(bucket, "test/${encodedFileName}")
                .withMethod(HttpMethod.PUT).withExpiration(expiration)

        return mapOf(
            "preSignedUrl" to amazonS3Client.generatePresignedUrl(generatePresignedUrlRequest),
            "encodedFileName" to encodedFileName
        )
    }

성능개선: 응답속도 향상

응답속도 비교를 위해 이미지 업로드 컨트롤러를 따로 만들어 포스트맨을 이용해 테스트를 진행했다.
1001 ms -> 452 ms (54.8% 감소)
452 ms -> 250 ms (44.6% 감소)
프론트에서 S3를 관리하면 업로드 속도가 제일 빠르다는 것을 알 수 있다.

@PostMapping("/imageUpload")
fun uploadImage(@RequestParam multipartFile: List<MultipartFile>): String {
    return s3Service.uploadFiles(dir = "test", multipartFile)
}

@PostMapping("/imageUploadWithCoroutine")
suspend fun imageUploadWithCotoutine(@RequestParam multipartFile: List<MultipartFile>): String {
    return s3Service.imageUploadWithCoroutine(dir = "test", multipartFile)
}

@PostMapping("/imageUploadWithPreSignedUrl")
fun uploadImageWithPreSignedUrl(@RequestParam multipartFile: List<MultipartFile>): Map<String, Serializable>? {
    return s3Service.getPreSignedUrl(multipartFile)
}

찌리릿 고도화 프로젝트

앱 출시를 위해서 ios 개발자 분들과 협업을 하게 됐다
구현하고 싶었던 이벤트 도메인과 성능개선을 진행하고 싶고 앱 개발자들이 원하는 api 와 프론트-백 연결하는 법이 궁금하다 웹이랑 많이 다른가 싶고
재밌게 협업해보고 싶다!

0개의 댓글