안녕하세요. GoseKose입니다.

이번 포스팅은 CircuitBreaker, Cache, 외부 스토리지를 활용하여 첨부파일 관리하는 과정을 작성하도록 하겠습니다.

 

1. 목표 시나리오

 

 

첨부파일을 업로드할 때, 두 가지 주요 방법이 있습니다:

  1. Client -> Server로 MultipartFile을 업로드:
    • 이 방법은 클라이언트가 파일을 서버로 전송하고, 서버가 이 파일을 스토리지에 저장합니다.
    • 장점: 서버가 파일 업로드와 저장을 관리하므로 보안과 검증이 용이합니다.
    • 단점: 서버에 부하가 증가하고, 대규모 파일 업로드 시 성능 문제가 발생할 수 있습니다.
  2. 클라이언트가 직접 스토리지에 파일을 업로드:
    • 클라이언트가 직접 외부 스토리지에 파일을 업로드합니다.
    • 장점: 서버 부하를 줄일 수 있으며, 클라이언트가 직접 스토리지에 접근하여 빠른 업로드가 가능합니다.
    • 단점: 클라이언트가 스토리지에 직접 접근할 수 있어 보안 문제가 발생할 수 있습니다.

이를 해결하기 위해 클라우드 서비스 제공업체는 Presigned URL을 제공합니다.

Presigned URL은 클라이언트가 지정된 시간 동안 외부 스토리지에 직접 파일을 업로드하거나 다운로드할 수 있도록 임시 권한을 부여하는 URL입니다. 이를 통해 보안 문제를 해결하면서도 서버 부하를 줄일 수 있습니다.

 

따라서, 목표 시나리오는 Presigned URL을 활용하여 외부 스토리지인 Amazon S3, Google Cloud Service에 첨부파일을 관리하는 것으로 진행하였습니다.

 

 

2. 아키텍처 구성

 

 

 

구조는 Controller / UseCase / Port로 구성할 수 있습니다.

외부 스토리지를 두 개 활용할 예정이므로 구현체로 S3Adapter, GCSAdapter를 만들 예정이고,

이 두 가지를 어댑터 패턴으로 분기할 Adapter 총 3가지로 구성합니다.

 

 

3. 가용성을 위한 서킷브레이커 활용하기

 

위의 아키텍처에서 두 가지 외부 스토리지를 활용하는 이유는 가용성을 높이기 위함입니다.

특정 클라우드 서비스에 파일을 업로드하려는 요청에 문제가 생기면, 다른 서비스를 활용하여 이를 대체할 수 있도록 

구성할 수 있습니다. try - catch로 분기하는 코드를 작성할 수 있지만, 시스템 안정성을 높이는 방법으로

SpringBoot는 CircuitBreaker라는 라이브러리를 제공합니다. 서킷 브레이커는 다음의 특징을 가지고 있습니다.

특징 설명
가용성 유지 외부 서비스나 데이터베이스가 일시적으로 불안정할 때 서킷 브레이커가 이를 감지하고 호출을 차단
시스템 안정성 장애가 발생한 서비스에 대한 반복적인 호출을 방지
예외 전파 방지 예외가 전체 애플리케이션에 전파되는 것을 방지

 

동작 방식 설명
Closed 모든 요청이 정상적으로 처리, 실패율이 일정 임계값을 초과하지 않으면 상태 유지
Open 실패율이 임계값을 초과하면 서킷 브레키어 동작 / 모든 요청 차단
Half-Open 일정 시간이 지난 후, 일부 요청을 허용하여 시스템이 회복되었는지 확인

 

 

이러한 특징으로 서킷브레이커를 사용하면

default로 사용하는 Amazon S3에 문제가 생기면, GCS Adapter로 fallback 처리할 수 있도록  구성할 수 있습니다.

 

 

 

 

4. 캐싱 활용하기

 

하나의 파일을 업로드할 때는 일회성 요청인 경우가 많지만, 다운로드는 반복 작업이 수행될 수 있습니다.

매 다운로드 요청마다 PresignedURL을 만들경우 , 성능 문제가 발생할 수 있습니다.

만약 다운로드 요청이 왔을 때 PresignedURL을 생성하고, 일정 시간 동안 캐싱한다면 성능 및 네트워크 비용 문제를 줄일 수 있습니다.

 

 

 

SpringBoot에서는 AOP와 Redis를 활용하여,

캐시와 관련된 횡단 관심사를 분리하면서, 키가 존재하면 캐싱된 정보를 가져오도록 구성할 수 있습니다.

 

 

5. 코드 구성하기

 

- Upload를 위한 PresignedUrl Controller

@RestController
class GenerateFileUploadPresignedUrlRestController(
    private val generateFileUploadPresignedUrlUseCase: GenerateFileUploadPresignedUrlUseCase,
) {
    @GetMapping("/api/v1/files/presignedUrl/upload")
    fun generatePresignedUrl(
        @RequestHeader("memberId") memberId: Long,
        @RequestParam fileName: String,
    ): PresignedUrlMetadataHttpResponse {
        return generateFileUploadPresignedUrlUseCase.generateFileUploadPresignedUrl(
            GenerateFileUploadPresignedUrlUseCase.Command(
                memberId = memberId,
                fileName = fileName,
            ),
        ).let(PresignedUrlMetadataHttpResponse::from)
    }
}

 

 

- Upload를 위한 PresignedUrl UseCase

@Service
class GenerateFileUploadPresignedUrlService(
    private val fileMetadataSaver: FileMetadataSaver,
    private val fileManagementPort: FileManagementPort,
) : GenerateFileUploadPresignedUrlUseCase {
    override fun generateFileUploadPresignedUrl(command: GenerateFileUploadPresignedUrlUseCase.Command): PresignedUrl {
        val (memberId, fileName) = command
        val fileMetadata = fileMetadataSaver.save(FileMetadata.of(memberId, fileName))

        val presignedUrl =
            fileManagementPort.generateFileUploadPresignedUrl(
                fileKey = fileMetadata.fileKey,
                durationMillis = DURATION_MILLIS,
            )

        fileMetadataSaver.save(fileMetadata.registerVendor(presignedUrl.vendor))

        return presignedUrl
    }

    companion object {
        private const val DURATION_MILLIS = 1000 * 60 * 10L // 10분
    }
}

 

 

- CircuitBreaker를 적용한 FileManagementPort 구현체

@Primary
@Component
class FileManagementAdapter(
    @Qualifier("fileManagementS3Adapter") private val s3Adapter: FileManagementS3Adapter,
    @Qualifier("fileManagementGCSAdapter") private val gcsAdapter: FileManagementGCSAdapter,
    circuitBreakerFactory: CircuitBreakerFactory<*, *>,
) : FileManagementPort {
    private val circuitBreaker = circuitBreakerFactory.create("s3CircuitBreaker")
    private val logger = LoggerFactory.getLogger(FileManagementGCSAdapter::class.java)

    override fun generateFileUploadPresignedUrl(
        fileKey: String,
        durationMillis: Long,
    ): PresignedUrl {
        return circuitBreaker.run({
            logger.info("s3 Adapter Try")
            s3Adapter.generateFileUploadPresignedUrl(fileKey, durationMillis)
        }, { throwable ->
            logger.error("s3 Adapter Exception = [${throwable.message}]")
            logger.info("gcs Adapter Try")
            gcsAdapter.generateFileUploadPresignedUrl(fileKey, durationMillis)
        })
    }

    override fun generateFileDownloadPresignedUrl(
        fileKey: String,
        vendor: FileMetadata.Vendor,
        durationMillis: Long,
    ): PresignedUrl {
        return when (vendor) {
            FileMetadata.Vendor.S3 -> s3Adapter.generateFileDownloadPresignedUrl(fileKey, durationMillis)
            FileMetadata.Vendor.GCS -> gcsAdapter.generateFileDownloadPresignedUrl(fileKey, durationMillis)
        }
    }
}

 

 

- Download를 위한 PresignedUrl Controller

@RestController
class GenerateFileDownloadPresignedUrlRestController(
    private val generateFileDownloadPresignedUrlUseCase: GenerateFileDownloadPresignedUrlUseCase,
) {
    @GetMapping("/api/v1/files/{fileId}/presignedUrl/download")
    fun generatePresignedUrl(
        @RequestHeader("memberId") memberId: Long,
        @PathVariable("fileId") fileId: Long,
    ): PresignedUrlMetadataHttpResponse {
        return generateFileDownloadPresignedUrlUseCase.generateFileDownloadPresignedUrl(
            GenerateFileDownloadPresignedUrlUseCase.Command(
                fileId = fileId,
            ),
        ).let(PresignedUrlMetadataHttpResponse::from)
    }
}

 

 

- Download를 위한 PresignedUrl Service

@Service
class GenerateFileDownloadPresignedUrlService(
    private val fileMetadataReader: FileMetadataReader,
    private val fileManagementPort: FileManagementPort,
) : GenerateFileDownloadPresignedUrlUseCase {
    @CacheableAnnotation("presignedUrlDownloadCache", key = "#command.fileId", durationMillis = 1000 * 60 * 9L)
    override fun generateFileDownloadPresignedUrl(command: GenerateFileDownloadPresignedUrlUseCase.Command): PresignedUrl {
        val fileMetadata = fileMetadataReader.getByIdOrNull(command.fileId).notnull()
        val vendor = fileMetadata.vendor
        requireBusiness(vendor != null, BusinessErrorCause.NOT_FOUND)

        return fileManagementPort.generateFileDownloadPresignedUrl(
            fileKey = command.fileId.toString(),
            vendor = vendor,
            durationMillis = DURATION_MILLIS,
        )
    }

    companion object {
        private const val DURATION_MILLIS = 1000 * 60 * 10L // 10분
    }
}

 

 

- AOP를 활용한 횡단 관심사 분리 및 캐싱

@Aspect
@Component
class CacheableAspect(
    private val cacheManagementPort: CacheManagementPort,
) {
    private val parser = SpelExpressionParser()

    @Around("@annotation(cacheableAnnotation)")
    fun around(
        joinPoint: ProceedingJoinPoint,
        cacheableAnnotation: CacheableAnnotation,
    ): Any? {
        val key = "${cacheableAnnotation.cacheName}:${generateKey(joinPoint, cacheableAnnotation)}"

        val cachedValue = cacheManagementPort.get(key, PresignedUrl::class.java)
        if (cachedValue != null) {
            return cachedValue
        }

        return joinPoint.proceed().apply {
            cacheManagementPort.set(key, this, cacheableAnnotation.durationMillis)
        }
    }

    private fun generateKey(
        joinPoint: ProceedingJoinPoint,
        cacheableAnnotation: CacheableAnnotation,
    ): String {
        val method =
            joinPoint.signature.declaringType.getDeclaredMethod(
                joinPoint.signature.name,
                *joinPoint.args.map { it::class.java }.toTypedArray(),
            )
        val context = StandardEvaluationContext()
        method.parameters.forEachIndexed { index, parameter ->
            context.setVariable(parameter.name, joinPoint.args[index])
        }
        val expression = parser.parseExpression(cacheableAnnotation.key)
        return requireNotNull(expression.getValue(context)).toString()
    }
}

 

 

- Redis를 활용한 CacheManagementPort 구현체

@Component
class RedisCacheManagementAdapter(
    private val redisTemplate: StringRedisTemplate,
) : CacheManagementPort {
    override fun <T> get(
        key: String,
        type: Class<T>,
    ): T? {
        val value = redisTemplate.opsForValue()[key]
        return value?.parseJson(type)
    }

    override fun <T> set(
        key: String,
        value: T,
        durationMillis: Long,
    ) {
        redisTemplate.opsForValue().set(key, requireNotNull(value).toJson(), Duration.ofMillis(durationMillis))
    }
}

 

 

6. 추가로 고려할 점

 

파일을 업로드하는 PresignedURL 생성은 서킷 브레이커로 장애 전파를 줄이고 가용성을 늘릴 수 있지만,

다운로드를 위한 PresignedURL을 요청할 경우에는 파일이 업로드된 곳에 요청을 해야 합니다. 

따라서, 파일을 다수의 스토리지에 백업하는 배치를 돌리는 방법으로 가용성을 높이는 방법을 적용할 수 있습니다.

 

다음 포스팅은 서킷 브레이커에 대해서 깊게 정리하는 과정을 작성하도록 하겠습니다.

긴 글 읽어주셔서 감사합니다!

안녕하세요! GoseKose입니다.

 

스프링 배치에서 파티션 단위로 처리하는 과정은 대규모 데이터 처리를 병렬로 분할하여 성능을 최적화할 수 있습니다.

특히, 파티션을 독립적으로 처리할 수 있는데, 이는 partiton 혹은 step 단위로 실행 매개변수를 다룰 수 있음을 의미합니다.

 

파티션 구성 요소는 다음과 같습니다.

 

  • Partitioner: 데이터를 여러 파티션으로 나누는 역할을 합니다.
  • PartitionHandler: 파티션을 각 replica 스텝에 분배하고 병렬로 실행합니다.
  • Step: step 단위 실행 플로우를 정의합니다.

이 세 가지 구성요소를 바탕으로 토이 프로젝트에서 Partition 단위로 병렬 처리한 과정을 정리하도록 하겠습니다.

 

 

 

1. 목표 아키텍처

 

 

 

2. 목표 플로우 

순서 제목 설명
1 Job 실행 Batch Job을 시작합니다.
2 데이터 범위 계산 목표 타겟 데이터 id(pk)를 기반으로 min / max 값을 구합니다.
3 파티션 나누기 minId / maxId의 차이를 구한 후, 파티션 개수 (ex: 5)로 범위를 나눕니다.
4 Partiton 병렬 실행 partition 단위로 병렬로 step을 실행합니다.
5 Step 실행 각 step은 chunk 지향 처리를 수행합니다.
6 예외 처리 Reader 문제 발생 시, Step Listener 에서 chunk 단위로 조회 실패한 min / max id를 저장합니다.
7 Job 종료 Batch Job을 종료합니다.

 

 

 

3. 데이터 범위 계산 및 파티션 나누기

@Bean
fun rangePartitioner(): RangePartitioner {
    val (minId, maxId) = partitionResultJdbcQuery()
    return RangePartitioner(
        minId = minId,
        maxId = maxId,
    )
}

private fun partitionResultJdbcQuery(): Pair<Long?, Long?> {
    val minSQL =
        """
        SELECT MIN(id)
        FROM memory_marbles
        WHERE store_type = 'DAILY'
        AND created_at >= '$startTimeStamp' AND created_at < '$endTimeStamp'
        """.trimIndent()

    val maxSQL =
        """
        SELECT MAX(id)
        FROM memory_marbles
        WHERE store_type = 'DAILY'
        AND created_at >= '$startTimeStamp' AND created_at < '$endTimeStamp'
        """.trimIndent()

    val minId = jdbcTemplate.queryForObject(minSQL, Long::class.java)
    val maxId = jdbcTemplate.queryForObject(maxSQL, Long::class.java)

    return Pair(minId, maxId)
}

class RangePartitioner(
    private val minId: Long?,
    private val maxId: Long?,
) : Partitioner {
    override fun partition(gridSize: Int): MutableMap<String, ExecutionContext> {
        val result = mutableMapOf<String, ExecutionContext>()
        if (minId == null || maxId == null) {
            return result
        }

        val targetSize = (maxId - minId + 1) / gridSize

        var start = minId.toLong()
        var end = start + targetSize - 1

        for (i in 0 until gridSize - 1) {
            val context = ExecutionContext()
            context.putLong("minValue", start)
            context.putLong("maxValue", end)
            result["partition$i"] = context
            start += targetSize
            end = start + targetSize - 1
        }

        val context = ExecutionContext()
        context.putLong("minValue", start)
        context.putLong("maxValue", maxId)
        result["partition${gridSize - 1}"] = context

        return result
    }
}

 

배치 Job을 수행하고자 하는 최소 최대 범위를 구한 후, Partition 개수만큼 범위를 구분합니다.

 

만약 minId = 100, maxId =  200, partitonSize(gridSize) = 5 라면, 다음처럼 비교적 균등하게 파티션을 나눌 수 있습니다.

partition 조회할 where 범위 개수
1 100 <= id <= 119 20
2 120 <= id <= 139 20
3 140 <= id <= 159 20
4 160 <= id <= 179 20
5 180 <= id <= 200 21

 

 

 

4. 파티션 병렬 실행

@Bean
fun memoryMarbleDailyToPermanentUpdaterJob(): Job {
    return JobBuilder(batchProperties.job.name, jobRepository)
        .incrementer(RunIdIncrementer())
        .start(primaryMemoryMarbleDailyToPermanentUpdaterStep())
        .listener(batchJobExecutionListener)
        .preventRestart()
        .build()
}

@Bean
fun primaryMemoryMarbleDailyToPermanentUpdaterStep(): Step {
    return StepBuilder("primaryMemoryMarbleDailyToPermanentUpdaterStep", jobRepository)
        .partitioner("replicaMemoryMarbleDailyToPermanentUpdaterStep", rangePartitioner())
        .step(replicaMemoryMarbleDailyToPermanentUpdaterStep())
        .partitionHandler(partitionHandler())
        .build()
}

@Bean
fun replicaMemoryMarbleDailyToPermanentUpdaterStep(): Step {
    return StepBuilder("replicaMemoryMarbleDailyToPermanentUpdaterStep", jobRepository)
        .chunk<MemoryMarbleJpaEntity, MemoryMarbleJpaEntity>(CHUNK_SIZE, transactionManager)
        .reader(memoryMarbleReader(null, null))
        .processor(memoryMarbleProcessor())
        .writer(memoryMarbleWriter())
        .listener(batchStepExecutionListener())
        .transactionManager(transactionManager)
        .build()
}

@Bean
fun partitionHandler(): TaskExecutorPartitionHandler {
    val partitionHandler = TaskExecutorPartitionHandler()
    partitionHandler.setTaskExecutor(simpleAsyncTaskExecutor)
    partitionHandler.step = replicaMemoryMarbleDailyToPermanentUpdaterStep()

    val (minId, maxId) = partitionResultJdbcQuery()
    if ((minId == null || maxId == null) || (maxId - minId) < PARTITION_SIZE) {
        partitionHandler.gridSize = 1
    } else {
        partitionHandler.gridSize = PARTITION_SIZE
    }

    return partitionHandler
}

 

partition을 활용할 때, StepBuilder()의 partitioner, partitionerHandler를 정의해야 합니다.

각 함수의 역할은 다음과 같습니다.

 

함수 설명
partitioner 위에서 정의한 파티셔널 (RangePartitioner)을 바탕으로 데이터 범위를 나누는 역할을 합니다.
각 파티션은 별도의 ExecutionContext를 가지는데, 이를 바탕으로 실행 매개 변수를 독립적으로 관리할 수 있습니다.
partitionerHandler partitioner에 의해 나뉘어진 파티션을 병렬로 처리합니다.
각 핸들러는 파티션에 정의된 step을 실행합니다.

 

 

 

5-1. Step 정의하기: ItemReader의 Chunk 지향 처리

저는 Chunk 지향 처리 방법으로 Step을 구성하였습니다. Chunk 지향 처리는 각 청크마다 트랜잭션을 관리하므로, 데이터베이스 커넥션 시간을 효율적으로 관리할 수 있습니다.

 

Reader를 구현하는 방법은 ItemReader 구현, QueryDsl로 ItemReader 확장하기 등  다양한 방법이 존재합니다.

전 회사에서 QueryDsl로 AbstractPagingItemReader를 확장해서 배치 시스템을 개선했던 경험이 있어서, 

이번 토이 프로젝트는 JDBC를 활용하여 AbstractPagingItemReader를 확장하는 방법을 선택해 보았습니다.

 

Spring Batch에서 제공하는 JdbcPagingItemReader는 pageSize을 limit으로 설정하되,

내부적으로 커서 기반 페이지네이션으로 동작합니다.

 

 

JdbcPagingItemReader는 다음의 firstPageSql, startAfterValues라는 필드를 가지고 있습니다.

private String firstPageSql;

private Map<String, Object> startAfterValues;

 

firstPageSql이 동작한 후, startAfterValues가 업데이트되면, query 생성 시 cursor에 해당할 id를 (id >?)에 바인딩해줍니다.

이를 바탕으로, offset을 정의하지 않아도, limit와 cursor 기반으로 데이터를 빠르게 조회하고 처리할 수 있습니다.

 

 

5-1. Step 정의하기: CustomJdbcPagingItemReader 정의하기

청크 단위 Reader를 수행할 때, 저장된 데이터를 DAO/VO/DTO로 변환할 때 타입 에러가 발생하곤 합니다.

 

이 경우 스탭이 종료되거나, startAfterValues가 업데이트되지 않아 query가 정상 동작하지 않고 emptyList()를 출력하여
Batch Step은 더 이상 읽을 데이터가 없다고 판단하여 종료할 수 있습니다.

 

해당 문제를 해결하기 위해, AbstractPagingItemReader를 구현한 CustomJdbcPagingItemReader를 정의하였습니다.

기존 JdbcPagingItemReader의 로직을 그대로 가져오되,

doReadePage()와 실패한 페이지 정보를 ExecutionContext에 넘겨 StepListener에서 실패한 데이터의 범위를 저장할 수 있도록 하였습니다.

 

override fun doReadPage() {
    results = results?.apply { clear() } ?: CopyOnWriteArrayList()

    val rowCallback = PagingRowMapper()
    val query: List<T> = try {
        when {
            page == 0 -> {
                logger.info("SQL used for reading first page: [$firstPageSql]")
                executeQuery(firstPageSql, rowCallback, parameterValues)
            }

            startAfterValues != null -> {
                previousStartAfterValues = startAfterValues
                logger.info("SQL used for reading remaining pages: [$remainingPagesSql]")
                executeQuery(remainingPagesSql, rowCallback, startAfterValues)
            }

            else -> emptyList()
        }
    } catch (e: Exception) {
        failedValues["page_$page"] = firstPageSql // 페이지 정보 추가
        logger.error("Error occurred while reading page: ", e)
        retryWithPage(page, 1, 1, rowCallback) // 리트라이 할 수 있도록 로직 추가
    }
    results.addAll(query)
}

private fun executeQuery(sql: String, rowCallback: PagingRowMapper, parameters: Map<String, Any>?): List<T> {
    return if (!parameters.isNullOrEmpty()) {
        if (queryProvider.isUsingNamedParameters) {
            namedParameterJdbcTemplate?.query(sql, getParameterMap(parameters, null), rowCallback) ?: emptyList()
        } else {
            getJdbcTemplate().query(sql, rowCallback, *getParameterList(parameters, null).toTypedArray())
        }
    } else {
        getJdbcTemplate().query(sql, rowCallback)
    }
}

private fun retryWithPage(
    page: Int,
    offsetCount: Int,
    retryCount: Int,
    rowCallback: PagingRowMapper
): List<T> {
    val adjustedMinValue = pageSize * offsetCount // 페이지와 오프셋 기반으로 실패한 데이터는 건너뛰도록 조치
    val sqlWithOffset = "$firstPageSql OFFSET $adjustedMinValue"

    logger.info("Retry SQL used for reading page $page with adjusted offset: [$sqlWithOffset]")

    return try {
        executeQuery(sqlWithOffset, rowCallback, parameterValues)
    } catch (e: Exception) {
        logger.error("Error occurred while retrying page: ", e)
        if (retryCount < 5) { // 리트라이는 최대 5번
            failedValues["page_$page"] = firstPageSql
            retryWithPage(page + 1, offsetCount + 1, retryCount + 1, rowCallback)
        } else {
            throw e
        }
    }
}

@Throws(ItemStreamException::class)
override fun update(executionContext: ExecutionContext) {
    super.update(executionContext)
    if (isSaveState) {
        if (isAtEndOfPage() && startAfterValues != null) {
            executionContext.put(getExecutionContextKey(START_AFTER_VALUE), startAfterValues)
        } else if (previousStartAfterValues != null) {
            executionContext.put(getExecutionContextKey(START_AFTER_VALUE), previousStartAfterValues)
        }
    }
    if (failedValues.isNotEmpty()) { // 실패한 범위가 존재할 경우 executionContext로 넘기기
        executionContext.put(getExecutionContextKey(FAIL_VALUE), failedValues)
    }
}

 

 

만약 doPageReader()가 특정 예외가 발생할 경우,

retry를 하되 offset / limit으로 실패한 범위는 건너뛰고 데이터를 조회할 수 있도록 조치하였습니다.

그리고 실패한 failedValues는 executionContext에 저장하여 후처리를 할 수 있습니다.

 

 

6. 예외 처리

PartitionStepExecutionListener는 후처리 로직으로 두 가지 분기가 수행됩니다.

 

1. Step 전체가 실패한 경우 해당 Step의 파티션 범위를 failedPartition으로 저장

2. 부분적으로 Failed 된 경우, 각 chunk 범위를 failedPartition으로 저장

 

class PartitionStepExecutionListener(
    private val failedPartitionJdbcRepository: FailedPartitionJdbcRepository,
) : BatchStepExecutionListener() {
    override fun addAfterStep(stepExecution: StepExecution) {
        val minId = stepExecution.executionContext.getLong("minValue", -1L)
        val maxId = stepExecution.executionContext.getLong("maxValue", -1L)

        if (minId == -1L || maxId == -1L) return

        if (stepExecution.exitStatus.exitCode == ExitStatus.FAILED.exitCode) {
            failedPartitionJdbcRepository.save(
                FailedPartition.of(
                    minId = minId,
                    maxId = maxId,
                    jobExecutionId = stepExecution.jobExecutionId
                )
            )
        } else if (stepExecution.exitStatus.exitCode == ExitStatus.COMPLETED.exitCode) {
            val failedValue =
                stepExecution.executionContext["$MEMORY_MARBLE_JDBC_PAGING_ITEM_READER.$FAIL_VALUE"] as? Map<String, String>?

            if (failedValue != null) {
                failedValue.keys.filter { it.contains("page_") }
                    .map { key ->
                        val page = key.substring(5).toInt()
                        val newMinId = minId + page * MemoryMarbleDailyToPermanentUpdateJobConfig.PAGE_SIZE
                        FailedPartition.of(
                            minId = newMinId,
                            maxId = min(maxId, newMinId + MemoryMarbleDailyToPermanentUpdateJobConfig.PAGE_SIZE),
                            jobExecutionId = stepExecution.jobExecutionId,
                        )
                    }.let { failedPartitionJdbcRepository.saveAll(it) }
            }
        }
    }
}

interface FailedPartitionJdbcRepository {
    fun save(failedPartition: FailedPartition)
    fun saveAll(failedPartitions: List<FailedPartition>)
}

@Repository
class FailedPartitionJdbcRepositoryImpl(
    @Qualifier("batchSimpleJdbcInsert") private val simpleJdbcInsert: SimpleJdbcInsert,
) : FailedPartitionJdbcRepository {
    init {
        simpleJdbcInsert
            .withTableName("FAILED_PARTITIONS")
            .usingGeneratedKeyColumns("id")
            .usingColumns("min_id", "max_id", "step_execution_id", "created_at", "last_modified_at", "status")
    }

    override fun save(failedPartition: FailedPartition) {
        simpleJdbcInsert.executeBatch(generateMapSqlParameterSource(failedPartition))
    }

    override fun saveAll(failedPartitions: List<FailedPartition>) {
        simpleJdbcInsert.executeBatch(*generateMapSqlParameterSource(failedPartitions))
    }

    private fun generateMapSqlParameterSource(failedPartition: FailedPartition): SqlParameterSource {
        return failedPartition.let { DaoRowMapper.mapSqlParameterSourceWith(it) }
    }

    private fun generateMapSqlParameterSource(failedPartitions: List<FailedPartition>): Array<SqlParameterSource> {
        return failedPartitions.map { DaoRowMapper.mapSqlParameterSourceWith(it) }.toTypedArray()
    }
}

 

 

 

이 두 가지 저장 방식을 토대로, 실패한 데이터 범위를 최대한 줄여서 보정 배치를 수행할 수 있습니다.

 

 

 

CustomJdbcPagingItemReader는 아직 개선할 부분이 많이 있습니다!

추가로 개선되는 부분은 다음 블로그 글로 작성하도록 하겠습니다.!

잘못된 부분이나 개선할 부분 말씀 부탁드립니다!

이상으로 Spring Batch Partition 단위로 병렬 처리하기를 마치도록 하겠습니다!

 

감사합니다!

안녕하세요. 회사와 함께 성장하고 싶은 KOSE입니다.

이번 포스팅은 사설 IP와 사설 IP를 공인 IP로 변경해 주는 NAT에 대한 정리를 진행하고자 합니다.

 

 

1. IP

 

IP (Internet Protocol Address)는 컴퓨터 네트워크 상의 각 장치를 식별하기 위해 사용되는 고유한 번호입니다.

네트워크 내의 컴퓨터, 라우터, 모바일 기기 등 각 장치에 할당된 숫자로 이루어진 라벨로,

이 주소를 바탕으로 데이터를 교환할 수 있습니다.

 

IP는 공인 IP / 사설 IP로 나눌 수 있습니다.

  • 공인 IP: 인터넷 상에서 유일하게 식별될 수 있는 주소입니다.
  • 사설 IP: 하나의 기업이나 가정 내부와 같은 사설 네트워크만의 유일한 주소로, NAT를 통해 인터넷과 통신할 수 있습니다.

 

IP 대역은 IPv4와 IPv6의 버전으로 나뉩니다.

  • IPv4: 32비트 주소로, 약 43억 개의 유일한 주소를 가집니다 각 부분은 0 ~ 255 사이의 숫자로 표시됩니다.
  • IPv6: 128비트 주소로, 콜론을 활용하여 16진수로 표현됩니다.

IP 주소는 전 세계적으로 할당과 관리가 필요하며, 주로 IANA(Internet Assigend Numbers Authority)가 지역별 인터넷 레지스트리를 처리하는 역할을 담당합니다.

 

 

2. 사설 IP

 

공인 IP는 인터넷 상에서 유일하게 식별될 수 있는 주소입니다. 하지만, IPv4의 경우 모든 IP대역을 합하더라도 약 43억 개입니다.

인구 증가와 더불어 IP를 활용하는 기기들이 증가함에 따라 43억 개의 개수는 모든 기기들을 식별할 수 있는 IP 대역이 될 수 없습니다.

따라서, 사설 IP라는 주소를 활용하여 이러한 IP 부족 현상을 해결할 수 있습니다.

 

사설 IP는 공인 IP와 각 구분된 네트워크 대역만 다르다면 (즉 다른 네트워크 상이라면), 아이피 주소는 여러 번 사용될 수 있습니다.

즉 공인 IP처럼 할당을 하게 되면 그 대역이 유일하게 유지되는 것이 아니라, 다른 네트워크에서는 사설 IP가 여러 번 사용될 수 있음을 의미합니다. 이는 하단에 NAT를 설명할 때 부가 설명을 하겠습니다!

 

사설 IP를 사용하는 이유는 다음과 같습니다.

  • 주소 공간 절약: IPv4는 43억개의 주소가 있으므로, 사설 IP를 활용하면, 같은 주소를 여러 네트워크에서 재사용할 수 있습니다.
  • 보안: 사설 IP 주소를 사용하면, 외부 인터넷에서 직접 접속할 수 없으므로 안전하게 보호할 수 있습니다.
  • 네트워크 관리: 네트워크 관리자가 특정 주소를 할당하거나 내부 네트워크 구조를 유연하게 설정할 수 있습니다.

사설 IP도, IP에 대한 구분이므로 IP의 범위 체계를 따르며, 사용가능한 대역이 정해져 있습니다.

  • 10.0.0.0 ~ 10.255.255.255 (10.0.0.0/8): 약 1677만의 주소를 포함하는 가장 큰 블록입니다.
  • 172.16.0.0 ~ 172.31.255.255 (172.16.0.0/12): 약 104만의 주소를 포함하는 블록입니다.
  • 192.168.0.0 ~ 192.168.255.255 (192.168.0.0/16): 주로 가정집에서 볼 수 있는 약 6만 5개의 주소 블록입니다.
* / 뒤에 오는 숫자 ex) /16 은 CIDR(Classless Inter-Domain Routing) 블록입니다.
전통적인 클래스 기반의 IP 주소 체계를 대체하기 위해 도입되었습니다.

/ 뒤에 오는 숫자는 서브넷 마스크의 비트 수를 나타내며,
해당 네트워크에서 호스트 식별자와 네트워크 식별자를 구분하는 데 사용됩니다.

ex) 192.168.0.0 /24 가 주어진다면
앞의 3개의 옥텟은 (8 +  8 + 8 = 24)는 네트워크 주소이며 나머지 옥텟 (8비트)는 호스트 주소가 됩니다

 

 

사설 IP는 이유가 명확하고, 사용가능한 대역이 정해져 있는데 실생활에 어떻게 활용되는 것일까요?

하단의 그림은 스타벅스에 있는 이용객이 와이파이를 쓰고 www.gose.com의 도메인에 접속하는 과정을 표현한 예입니다.  

 

 

wifi는 주로 iptime을 사용하게 됩니다.

공유기는 사용하는 유저에게 사설 IP를 부여하여 라우팅 테이블에 관리하고, 원하는 도메인에 요청을 보낼 때

공인 IP로 변경하여 요청을 수행하게 됩니다.

 

 

3. NAT

 

위에서 잠깐 설명한 공유기는 Gateway 역할을 수행하는 NAT(Network Address Translation)가 될 수 있습니다.

이 기기를 바탕으로 각 사용자는 사설 IP 주소를 공인 IP로 바꿔서 인터넷을 거쳐서 도메인에 접속할 수 있는 구조입니다.

이때, 유저가 설정한 Port, 그리고 동일한 공인 IP로 설정한 여러 가지 사설 IP를 구분할 수 있는 SRC port를 기록하게 됩니다.

 

 

이 라우팅 테이블은 다음의 특징을 가지고 있습니다.

  • NAT 테이블: NAT가 IP 주소를 변환하는 데 사용하는 DB입니다.
    이곳에 내부 사설 IP와 외부 공인 IP의 매핑 정보가 담깁니다.
  • 세션 추적: NAT 장비는 내부 네트워크에서 시작된 세션을 추적하고,
    해당 세션의 외부 통신에 사용되는 공인 IP 주소와 포트를 기록합니다.
  • 다이나믹 매핑: 일반적인 NAT는 동적 주소 매핑을 수행합니다.
    내부 장치가 외부와 통신을 시작하면 공인 IP 주소의 포트와 연결하여 테이블에 기록합니다.

일반적으로 NAT는 Port Address Translation(PAT)를 사용하여 각 개별 통신 세션을 구분할 수 있게 되는 것입니다.

NAT는 개별 라우터 당 자신만의 private IP를 가질 수 있습니다.

위에서 잠깐 언급했던 내용이 바로 이러한 NAT 특성에서 도출될 수 있는 내용입니다.

즉 공인 IP처럼 할당을 하게 되면 그 대역이 유일하게 유지되는 것이 아니라, 다른 네트워크에서는 사설 IP가 여러 번 사용될 수 있음을 의미합니다.

 

이렇게 제한적인 공인 IP를 인터넷 전체에서 유일할 필요가 없는 사설 IP로 보완함으로써, 공인 IP의 소진을 어느 정도 해결할 수  있습니다.

다만, 공인 IP도 결국 IPv4 체계에서는 한정적임으로 IPv6로의 꾸준한 변화가 필요한 이유입니다!

 

이상으로, 사설 IP / NAT 정리를 마치도록 하겠습니다.

읽어주셔서 감사합니다!

 

참고 자료: AWS 강의실 https://www.youtube.com/watch?v=3VXLD0-Iq8A

 

 

안녕하세요. 회사와 함께 성장하고 싶은 KOSE입니다.

 

이번 포스팅은 Kotest 멀티모듈 컨트롤러 테스트에 대한 글을 작성하고자 합니다.

제가 겪은 문제는 멀티 모듈로 스프링을 구성하였을 때,

컨트롤러의 WebMvcTest의 entityManagerFactory 의존성 문제가 발생한 것이었습니다.

 

 

1. @WebMvcTest를 사용한 테스트

 

@ActiveProfiles("test")
@AutoConfigureMockMvc
@WebMvcTest(controllers = [ReviewGroupUpsertController::class])
class ReviewGroupUpsertControllerTestV1(
    private val mockMvc: MockMvc,
) : FunSpec() {
    override fun extensions(): List<Extension> = listOf(SpringExtension)

    @MockkBean
    private lateinit var userVerifyService: UserVerifyService

    @MockkBean
    private lateinit var courseGroupService: CourseGroupService

    @MockkBean
    private lateinit var possibleReviewCourseFindService: PossibleReviewCourseFindService

    @MockkBean
    private lateinit var reviewGroupCommandUseCase: ReviewGroupCommandUseCase

 

Description: Parameter 0 of constructor in com.content.adapter.course.CourseCommandAdapter required a bean named 'entityManagerFactory' that could not be found.

Action: Consider defining a bean named 'entityManagerFactory' in your configuration.

 

@WebMvcTest는 스프링 부트의 슬라이스 테스트 중 하나로, 웹 계층에 대한 테스트를 위해 사용이 됩니다.

이 어노테이션을 사용하면, 스프링 테스트 컨텍스트는 웹 계층의 빈들만 로드하여, 테스트를 가볍게 실행할 수 있습니다.

하지만, 이 어노테이션은 @Controller, @ControllerAdvice, @JsonComponent, WebMvcConfigurer 등과 같은 웹 계층 관련 구성 요소만 스캔하고, 나머지 요소는 스캔하지 않습니다.

 

이 문제로 인해, JPA에 사용되는 entityManagerFactory 등과 같은 의존성 모듈이 스캔되지 않는 문제가 발생하였습니다.

이에 대한 의존성을 TestConfiguration화 하여 사용할 수 있지만,

연쇄적으로 물리는 객체의 의존성으로 인해 이를 제어하는 것도 쉬운 일은 아니었습니다.

 

따라서, mockMvc를 활용하여 컨트롤러의 응답을 테스트하기 위해서는 컨트롤러의 생성자 주입에 필요한 클래스들만 스텁 화하여

단위테스트 형태로 작성하는 로직이 필요하였습니다.

 

 

2. 특정 컨트롤러에 대한 단위 테스트

 

이를 위한 해결책으로 @WebMvcTest 관련 어노테이션을 제거하고,

mockMvc에 대한 인스턴스를 만들어 사용하는 과정을 수행하였습니다.

@ActiveProfiles("test")
class ReviewGroupUpsertControllerMockMvcTest : FunSpec() {
    override fun extensions(): List<Extension> = listOf(SpringExtension)

    @MockkBean
    private lateinit var userVerifyService: UserVerifyService

    @MockkBean
    private lateinit var courseGroupService: CourseGroupService

    @MockkBean
    private lateinit var possibleReviewCourseFindService: PossibleReviewCourseFindService

    @MockkBean
    private lateinit var reviewGroupCommandUseCase: ReviewGroupCommandUseCase

    private lateinit var mockMvc: MockMvc

    override suspend fun beforeSpec(spec: Spec) {
        val reviewGroupUpsertController = ReviewGroupUpsertController(
            userVerifyService = userVerifyService,
            courseGroupService = courseGroupService,
            possibleReviewCourseFindService = possibleReviewCourseFindService,
            reviewGroupCommandUseCase = reviewGroupCommandUseCase
        )

        mockMvc = MockMvcBuilders.standaloneSetup(reviewGroupUpsertController).build()
    }

 

 

어노테이션은 어떠한 프로파일에서 실행할 것인지를 명시하는 ActiveProfiles()를 제외하고 제거하였습니다.

각 MockkBean으로 설정한 Stub을 테스트하고자 하는 컨트롤러에 생성자 주입하여 객체를 만들고

mockMvc를 만들기 위한 빌드를 진행하였습니다.

 

 

3. 구체적인 코드 작성하기

 

a. FunSpec

 

kotest는 다양한 스타일의 테스팅을 지원합니다 (FunSpec, BehaviorSpec 등 다양한 스타일의 테스팅을 지원)

이 중 FunSpec은 scalaTest 방식을 채택하고 있습니다.

 

kotest를 구성할 때, 전통적인 OOP 방식으로 추상 클래스를 상속하는 방법이 있습니다.

반면 FunSpec({ /**/ }) 내부에 작성하는 DSL 스타일로 FunSpec의 생성자에 Lambda를 전달하는 방법도 있습니다.

저는 이 중 OOP 방식으로 작성하는 코드로 작성하였습니다. (두 기능 모두 정상적으로 잘 동작합니다)

class ReviewGroupUpsertControllerMockMvcTest : FunSpec() {
/* 중략 */
}

 

 

b. extentions()

 

Kotest에서 extentions() 함수는 테스트 실행 중에 추가적인 기능을 제공하는 데 사용됩니다. 

SpringExtension은 Kotestdml Spring TestContext Framework와 통합하는 확장입니다.

이 확장을 사용하면 Spring의 DI 기능을 테스트에서 활용할 수 있습니다.

 

SpringExtension을 사용할 때 장점은 다음과 같습니다.

  • SpringContext 통합
  • 스프링 테스트 어노테이션 지원
  • 캐시 된 컨텍스트 지원
  • 트랜잭션 관리

저는 MockkBean을 사용하고 있기 때문에, SpringExtension을 사용하였습니다.

override fun extensions(): List<Extension> = listOf(SpringExtension)

 

 

c. @MockkBean

 

스프링은 MockBean을 사용할 수 있지만, ninja 라이브러리에서 제공하는 MockkBean을 사용하였습니다.

testImplementation("com.ninja-squad:springmockk:4.0.2")

 

 

@MockkBean을 사용할 때 장점은 다음과 같습니다.

  • 코틀린을 위해 만들어진 mocking 라이브러리로 코틀린의 언어적인 특성을 더욱 활용할 수 있습니다
  • 일반 mock과 비교하여, 확장 함수, 동반 객체 등을 Mockito보다 잘 지원합니다.
@MockkBean
private lateinit var userVerifyService: UserVerifyService

@MockkBean
private lateinit var courseGroupService: CourseGroupService

@MockkBean
private lateinit var possibleReviewCourseFindService: PossibleReviewCourseFindService

@MockkBean
private lateinit var reviewGroupCommandUseCase: ReviewGroupCommandUseCase

 

테스트할 컨트롤러의 생성자 주입에 필요한 객체는 스텁화하기 위해 @MockkBean으로 설정하여 지연 초기화를 수행하고 있습니다.

지연 초기화를 하는 이유는, 테스트에 필요한 의존성을 로드한 후 모킹한 클래스를 생성자 주입에 제공하기 위함입니다.

 

 

d. beforeSpec

 

beforeSpec을 사용하여, 테스트 이전에 필요한 스펙을 정의하였습니다.

beforeSpec은 BeforeAll과 비슷하지만, Kotest에서 제공하는 Spec 단위 설정입니다.

Junit의 BeforeAll과 마찬가지로, 각 테스트가 실행되기 전에 한 번만 호출됩니다.

 

이 내부에서 테스트에 필요한 ReviewGroupUpsertController를 생성하여

MockMvcBuilders.standaloneStepUp을 수행하여 mockMvc를 재정의하고 있습니다.

 

standaloneSetUp의 역할은 다음과 같습니다.

  • Spring Web Application Context를 사용하지 않고, 컨트롤러만을 위한 경량 테스트 환경을 구축합니다
    컨트롤러에 필요한 의존성만 명시적으로 사용하여 빠른 속도로 테스트를 수행할 수 있도록 합니다.
private lateinit var mockMvc: MockMvc

override suspend fun beforeSpec(spec: Spec) {
    val reviewGroupUpsertController = ReviewGroupUpsertController(
        userVerifyService = userVerifyService,
        courseGroupService = courseGroupService,
        possibleReviewCourseFindService = possibleReviewCourseFindService,
        reviewGroupCommandUseCase = reviewGroupCommandUseCase
    )

    mockMvc = MockMvcBuilders.standaloneSetup(reviewGroupUpsertController).build()
}

 

 

e. test

 

FunSpec을 OOP 형태로 작성할 때, init 블록에 테스트를 작성할 수 있습니다.

Kotest 프레임워크에서는 테스트 정의를 선언적으로 수행합니다.

테스트 클래스의 인스턴스가 생성될 때, 테스트 케이스가 선언되어야 합니다.

이를 위해 init을 사용하여 테스트 클래스 생성 시점에 테스트를 작성하는 것입니다.

  • 초기화 시점에 실행 보장
  • 선언적 테스트 등록
  • 컴파일 에러 방지

 

나머지는 FunSpec에서 사용하는 이용 방식과 동일합니다

context()에 테스트하고자 하는 상황을 정의합니다.

내부에 MockkBean으로 선언한 객체의 값을 정의합니다.

test() 내부에서는 MockMvcRequestBuilder로 모킹 요청을 설정합니다.

mockMvc.perform()으로 요청하고자 하는 대상을 수행하여 andExpect()로 그에 대한 응답을 테스트할 수 있습니다.

 

init {
        context("ReviewGroup 생성에 대한 mockk 스텁 결과가 주어져요") {
            val userId = 1L

            every { userVerifyService.verifyNormalUserAndGet(any()) } returns User(
                userId = userId,
                userAccountStatus = UserAccountStatus.NORMAL,
                nickName = "UserA"
            )

            every { courseGroupService.getCourseGroup(any(), any()) } returns CourseGroup(
                userId = userId,
                groupId = 1L,
                groupName = "A",
                cityCode = CityCode.SEOUL,
                districtCode = DistrictCode.SEOUL_DOBONG,
            )

            every { reviewGroupCommandUseCase.createReviewGroupOrGet(any()) } returns ReviewGroup(
                courseGroupId = userId,
                reviewGroupId = 1L,
                reviewGroupName = "ReviewA",
                userId = 1L,
                createdAt = LocalDateTime.now(),
                modifiedAt = LocalDateTime.now(),
            )

            every { possibleReviewCourseFindService.verifyPossibleReviewCourseGroup(any()) } just Runs

            test("올바른 json 요청으로 리뷰 그룹 생성 응답을 검증을 성공해요") {
                val reviewCreateGroupApiRequest = "{\"courseGroupId\": 12345}"

                val mockHttpServletRequestBuilder =
                    MockMvcRequestBuilders.post("https://localhost:8078/content/reviewgroups")
                        .contentType(MediaType.APPLICATION_JSON)
                        .header("UserId", userId)
                        .content(reviewCreateGroupApiRequest)

                mockMvc.perform(mockHttpServletRequestBuilder)
                    .andExpect(status().isOk)
                    .andExpect(
                        jsonPath("$.reviewGroupId").value("1")
                    )
            }
        }

 

 

4. 정리하며

 

멀티 모듈로 컨트롤러 단위테스트를 할 때, 다양한 글을 보고 테스트를 작성하였는데 의존성 문제가 발생하였습니다.

많은 시간을 투자하고 여러 번 테스트를 반복하며 코드를 수정해 보았음에도 문제가 해결되지 않았습니다.

공식문서를 참고하여 코드를 작성하니 이 문제를 해결할 수 있었습니다.

조금 더 간편한 방법을 위해 여러 가지 코드를 어떠한 이유가 없이 복사해서 작성했던 과거를 반성할 수 있는 시간이었습니다!

 

읽어주셔서 감사합니다!!!

안녕하세요. 회사와 함께 성장하고 싶은 KOSE입니다.

 

이번 포스팅은 Spring의 Actuator를 살펴보고 Prometheus, Grafana를 연동하는 과정, 중요한 지표 등을 정리하고자 합니다.

 

 

1. Spring Actuator

 

Spring Actuator는 Spring Boot의 일부로, 프로덕션 준비 상태를 갖춘 애플리케이션을 만들 때 유용한 도구입니다.

Actuator를 사용하면, 애플리케이션의 여러 측면을 모니터링하고 관리할 수 있습니다.

 

Spring Actuator의 주요 기능

  • 메트릭: 애플리케이션의 다양한 메트릭을 수집하고 조회할 수 있습니다. HTTP 요청 수, 처리 시간, 데이터베이스 사용량 등을 확인할 수 있습니다.
  • 헬스 체크: 애플리케이션의 상태를 나타내는 health 정보를 제공합니다. 데이터베이스의 연결, 디스크 공간, 사용 중인 서비스 등을 확인할 수 있습니다.
  • 환경 정보: 애플리케이션을 실행하는 데 사용하는 환경 변수, 시스템 프로퍼티, 구성 파일을 확인할 수 있습니다.
  • 로그 관리: 애플리케이션의 로그 수준을 조회하거나 변경할 수 있습니다.
  • 스레드 덤프: 톰캣에 실행 중인 스레드 정보를 확인할 수 있습니다.
  • HTTP 추적: 최근 처리된 HTTP 요청의 트레이스 정보를 제공합니다.

 

스프링에서 actuator를 사용하기 위해서는 gradle.kts에 actuator 라이브러리를 추가해야 합니다.

implementation("org.springframework.boot:spring-boot-starter-actuator")

 

application.yml 에는 actuator에서 제공할 정보를 설정할 수 있습니다.

 

 

  • management.endpoints.health.show-details: 헬스체크를 하는 것으로 always로 설정하면 모든 세부 정보가 표시됩니다.
  • management.endpoints.web.exposure.include: 어떤 엔드포인트를 외부에 노출할 것인지 결정합니다.
  • management.server.port: 액츄에이터의 포트를 설정하는 항목입니다.

 

여기서 스프링은 8080으로 동작하는데, 액츄에이터는 어떻게 9292로 동작할 수 있을까요?

SpringBoot의 내부 구조는, 여러 개의 EmbeddedServletContainer 인스턴스를 가질 수 있습니다

이를 통해 여러 개의 HTTP 서버를 동시에 운영할 수 있는 것입니다.

 

이를 바탕으로 localhost:9292/actuator/health를 요청하면 다음의 헬스체크를 할 수 있습니다.

헬스체크는 스프링부트의 정상 로드뿐만 아니라, DB 등 외부 의존성 커넥션 등도 헬스체크의 대상이 됩니다.

만약 하나라도 정상적인 응답이 오지 않는다면, status가 DOWN이 오게 됩니다.

 

 

 

 

2. Prometheus 활용하기

 

스프링 actuator를 활용할 때, 애플리케이션 상태와 메트릭스를 외부로 노출시킬 수 있습니다.

prometheus를 사용한다면, actuator가 제공하는 /acturator/prometheus 엔드포인트로 요청하여 prometheus가 수집할 수 있는 

포맷으로 메트릭스를 제공받을 수 있습니다.

 

 

prometheus는 pull 기반 모니터링 시스템으로, 지정된 엔트포인트에 정기적으로 데이터를 끌어와서 데이터를 수집할 수 있습니다.

각 시스템, 설치한 방법에 따라 prometheus를 기동 하는 방법, prometheus의 설정 파일을 변경하는 방법이 다를 수 있습니다.

homebrew를 사용하는 경우는 다음의 방법으로 적용할 수 있습니다.

 

# prometheus 실행
brew services start prometheus

# 변경해야 하는 prometheus.yml 파일 위치
/opt/homebrew/etc

# 파일 변경하기
vim prometheus.yml

 

scrape_configs:
  - job_name: "prometheus" # 첫 번째 작업의 이름은 "prometheus"
    static_configs: # 이 작업에서 메트릭을 수집할 대상은 정적으로 정의
    - targets: ["localhost:9090"] # Prometheus 자체 메트릭을 수집
  - job_name: "spring-actuator" # 두 번째 작업의 이름은 "spring-actuator"
    metrics_path: "/actuator/prometheus" # 스프링 부트 애플리케이션의 메트릭 엔드포인트를 설정
    scrape_interval: 10s # 메트릭을 수집하는 간격은 10초
    static_configs:
    - targets: ["localhost:9292"] # 스프링 애플리케이션의 호스트와 포트를 정의

 

이와 같은 방법을 적용하면 프로메테우스를 활용하여 모니터링을 쓸 수 있습니다.

 

 

3. Grafana 연동

 

grafana는 다양한 모니터링 기능을 제공하여 prometheus에 연동하여,

사용자 친화적인 시각화 및 대시보드를 생성하여 처리할 수 있습니다.

그런데, spring actuator에서 grafana를 연동하지 않고 중간에 prometheus를 쓰는 이유는 무엇일까요?

 

  • 확장성과 유연성: Prometheus는 스프링 및 서비스 인프라 등 메트릭을 수집할 수 있습니다. 이렇게 하여 하나의 중앙집중화된
    위치에서 전체 시스템의 메트릭을 모니터링할 수 있습니다.
  • 데이터 처리 및 집계: Prometheus는 수집한 메트릭에 대해 강력한 쿼리 언어를 사용하여 데이터를 처리하고 집계할 수 있습니다.
    이를 통해 평균 및 최대 최소 등과 같은 복잡한 계산을 수행할 수 있습니다.
  • 통합 생태계: Prometheus는 Grafana와 같은 다른 모니터링 툴과 잘 통합됩니다.
    Grafana는 Prometheus와 같은 다양한 데이터  소스를 지원하며, 이를 통해 유연하게 대시보드를 구성할 수 있습니다.

grafana를 설치한 후, 기동하여 localhost:3000으로 이동하면 grafana를 사용할 수 있습니다.

grafana는 이미 잘 만들어진 대시보드가 있어서, 이를 임포트 하여 사용하면 하단처럼 대시보드를 만들 수 있습니다.

(https://grafana.com/grafana/dashboards/)

 

 

스프링을 사용하면 주로 확인해야 할 지표 등이 있습니다.

  • Tomcat thread
  • JVM Statistics Threads/Buffers
  • CPU
  • HikariCP Statistics

 

Tomcat thread

 

Tomcat thread는 grafana Query로 다음처럼 작성할 수 있습니다.

  • tomcat_threads_config_max_threads: 스레드가 생성될 수 있는 최대 개수
  • tomcat_threads_current_threads : 현재 스레드 풀에 존재하는 총 스레드 수
  • tomcat_threads_busy_threads: 현재 처리 중인 요청의 수

 

 

 

현재 스레드풀이 10개이고, 최대 스레드 생성 가능한 개수가 200개라면 maxThreads 설정에 따라 동적으로 늘어날 수 있습니다.

만약 Current thread 풀에 근접하게 Busy thread가 동작하고 있다면 모니터링에 유의해야 합니다.

 

 

JVM

 

JVM은 GC, Heap 등 다양한 지표를 확인할 수 있습니다. 

만약, 인메모리 기반으로 데이터를 저장하거나 세션과 같이 서버에 영향을 줄 수 있는 기능을 사용한다면

Heap 메모리 증가로 인해 GC가 동작할 수 있습니다.

 

 

 

CPU

 

반복적인 I/O나 복잡한 비즈니스 요청이 수행한다면 CPU가 비정상적으로 동작할 수 있습니다.

CPU usage를 통해 이러한 상황을 모니터링할 수 있습니다.

 

 

 

HikariCP Statistics

 

JPA나 JDBC를 사용하면 기본적으로 DB 커넥션을 열고 닫는 과정을 라이브러리가 잘 처리를 해줍니다.

만약 커넥션이 닫히지 않아서 Active DB 커넥션 개수가  총 커넥션 사이즈를 넘어가게 되면, 타임아웃이 증가하게 됩니다.

모니터링을 통해 만약 DB 커넥션 개수가 많이 필요하다면 DBCP 조절을 통해 가능합니다.

(DBCP 강의: https://www.youtube.com/watch?v=zowzVqx3MQ4)

 

 

 

그 외에도 많은 모니터링 지표가 있습니다.

남은 지표들도 추가로 작성해 나가며 정리하도록 하겠습니다!

읽어주셔서 감사합니다~!

 

참고: 영한님 스프링 핵심 원리 활용  https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-%ED%95%B5%EC%8B%AC%EC%9B%90%EB%A6%AC-%ED%99%9C%EC%9A%A9

안녕하세요. 회사와 함께 성장하고 싶은 KOSE입니다.

 

이번 포스팅은 프로젝트를 진행하는 동안 명확한 이해 없이 지나갔었던 build.gradle.kts에 대해 정리하고자 합니다.

 

 

1. Build.gradle

 

build.gradle.kts는 Gradle이라는 빌드 자동화 도구에서 프로젝트 빌드 구성을 정의하기 위해 사용되는 스크립트입니다.

Gradle은 주로 자바, 코틀린, 스칼라와 같은 JVM 언어를 기반으로 사용되고 있습니다.

 

build.gradle 파일은 Groovy라는 동적 언어로 작성되며, 프로젝트 의존성 관리, 플러그인 설정, 태스크 정의와 같은 것을 담당합니다.

  • 의존성 설정: 라이브러리나 다른 모듈에 대한 의존성을 정의합니다.
  • 플러그인 적용: 프로젝트에 필요한 Gradle 플러그인을 정의합니다.
  • 리포지토리 설정: 라이브러리 의존성을 가져올 리포지터리를 설정합니다.
  • 테스크 정의: 빌드, 테스트, 배포 등의 커스텀 태스크를 정의할 수 있습니다.
  • 빌드 설정: 빌드 과정에서 필요한 Java 버전, 컴파일 옵션 등을 설정할 수 있습니다.

build.gradle.kts는 build.gradle과 기능은 같지만, 코틀린 기반의 DSL(Domain-Specific-Language)를 사용하여 작성된 스크립트입니다.  코틀린 DSL을 사용하는 것은 Groovy 기반 DSL에 비해 몇 가지 이점을 제공합니다.

  • 정적 타입 검사: 코틀린은 정적 타입 언어이므로, 코딩 시 타입 에러를 빠르게 확인할 수 있습니다.
  • IDE 지원: 정적 타입 덕분에 인텔리제이와 호환이 잘되는 특징이 있습니다.

 

 

2. 주로 사용되는 블록 정리

 

plugins

빌드 스크립트에 추가적인 기능을 제공합니다. 플러그인은 빌드 프로세스에 추가적인 기능을 제공하거나, 생명주기를 확장합니다.
빌드 프로세스를 제어하고 확장하는 점에서 dependencies와 차이점이 있습니다.
plugins {
    kotlin("jvm") version "1.8.22"
    kotlin("plugin.spring") version "1.8.22"
}

 

 

repositories

mavenCentral()을 주로 호출하게 되는데, 이는 Gradle 의존성을 해결할 때, Maven Central 저장소를 검색하라는 의미입니다. Maven Central 저장소는 오픈 소스 Java 라이브러리를 비롯한 많은 종속성들이 호스팅 되는 곳으로, 중앙 저장소 역할을 합니다.

 

repositories {
    mavenCentral()
}

 

 

dependencies

프로젝트가 컴파일 및 실행을 위해 필요한 라이브러리를 지정합니다.
이는 프로젝트의 소스 코드가 의존하는 외부 모듈이나 라이브러리를 의미합니다. 의존성은 컴파일 / 런타임 / 테스트 의존성 등으로 분리할 수 있습니다

 

dependencies {
    implementation("org.springframework.boot:spring-boot-starter")
}

 

 

java

gradle 빌드 스크립트에서 java 소스 코드와 컴파일된 바이트 코드가 호환되는 버전 등을 지정하는 역할을 합니다.

sourseCompatibility: 이 옵션은 사용하는 Java 소스가 어떤 버전의 java 언어 기능을 사용할 수 있는지 결정합니다.
targertCompatibility: 컴파일된 클래스 파일이 실행될 때, 최소 요구되는 버전을 나타냅니다.

 

java {
    sourseCompatibility = JavaVersion.VERSION_17
    targetCompatibility = JavaVersion.VERSION_17
}

 

 

allprojects

모든 하위 프로젝트에 공통적으로 적용되는 구성을 정의합니다.
멀티 모듈 프로젝트를 사용할 때, 각각의 하위 프로젝트는 'allprojects' 블록에 정의된 설정을 상속받습니다.

 

 

tasks.withType<KotlinCompile>

해당 블록은 KotlinCompile 태스크에 대한 설정을 구성합니다.
코틀린 소스를 컴파일하는 태스크를 정의하여, 코틀린 컴파일러에 전달되는 옵션을 설정할 수 있습니다

freeCompilerArgs += "-Xjsr305=strict": 이 옵션은 코틀린 컴파일러에게 JSR-305 어노테이션에 대한 처리 방법을 strict 모드로 설정하라는 지시를 합니다. JSR-305는 자바코드에서 nullablility를 명시하는 표준 어노테이션입니다.

jvmTarget = JavaVersion.VERSION_17.toString(): 이 설정은 컴파일러가 생성하는 바이트코드가 실행될 JVM의 타깃 버전을 JAVA 17로 설정합니다. 컴파일 코드는 최소 Java 17이 설치된 환경에서 실행될 수 있도록 합니다.

 

 

tasks.withType<Test>

이 블록은 Test에 대한 태스크를 정의합니다

useJUnitPlatform(): 이 설정은 Gradledl JUnit 플랫폼을 사용하여 태스크를 실행하도록 지시합니다.

 

allprojects {

    group = "com.dayplancontent"
    version = "0.0.1-SNAPSHOT"

    tasks.withType<KotlinCompile> {
        kotlinOptions {
            freeCompilerArgs += "-Xjsr305=strict"
            jvmTarget = JavaVersion.VERSION_17.toString()
        }
    }

    tasks.withType<Test> {
        useJUnitPlatform()
    }

    repositories {
        mavenCentral()
    }
}

 

subprojects

이 블록은 Gradle 멀티프로젝트 빌드 설정의 일부로 사용됩니다.
멀티 프로젝트 빌드에서는 하나의 루트 프로젝트와 여러 하위 프로젝트로 구성되며,
각 하위 프로젝트는 공통의 구성이 필요합니다.
만약 root를 제외한 프로젝트가 필요하다면 해당 블록에 작성할 수 있습니다.

 

 

apply

apply는 플러그인이나 다른 스크립트를 현재의 프로젝트 또는 하위 프로젝트에 적용하고 싶을 때, 사용합니다.
apply는 setting.gradle 혹은 setting.gradle.kts에 적은 플러그인을 적용할 때 사용할 수 있습니다.

 

subprojects {
    apply {
        apply(plugin = "org.springframework.boot")
    }

    dependencies {
        implementation("org.jetbrains.kotlin:kotlin-reflect")
    }
}

 

 

project

멀티 모듈 프로젝트를 설정할 때, 특정 모듈은 다른 모듈에 의존할 필요성이 있습니다.
가령 애플리케이션 모듈은 도메인 모듈에 의존하도록 설정해야 합니다.
이 경우 특정 모듈을 project 내부에 작성하여 의존성을 설정할 수 있습니다.

 

dependencies {
    implementation(project(":domain"))
}

 

 

Jar, BootJar by tasks

by tasks는 위임 프로퍼티를 통해 해당 타입의 작업을 찾아서 할당하라는 것을 의미합니다.
여기서 Jar, BootJar는 각각 다음의 의미를 가지게 됩니다.
각 설정을 enable = true 혹은 enable = false를 하게 되면, jar 생성 관련한 부분을 플레그로 제어할 수 있습니다.

BootJar: SpringBoot 애플리케이션을 만드는 설정을 할 수 있습니다.
Jar: 표준 자바 라이브러리 Jar를 만드는 데 사용할 수 있습니다.

 

import org.springframework.boot.gradle.tasks.bundling.BootJar

val jar: Jar by tasks
val bootJar: BootJar by tasks

bootJar.enabled = false
jar.enabled = true

 

 

그 외에도 다양한 옵션들이 있습니다.

다른 옵션들은 해당 글에 추가적으로 옵션을 추가해 나가도록 하겠습니다.!

감사합니다!

안녕하세요. 회사와 함께 성장하고 싶은 KOSE입니다.

 

이번 포스팅은 Amazon Aurora에 대한 정리를 진행하고자 합니다!

 

1. Aurora

 

 

Amazon Aurora는 고성능 상용 데이터베이스의 성능과 가용성에

오픈소스 데이터베이스의 간편함, 비용 효율성을 결합하였습니다.

클라우드르 위해 구축된 MySQL 및 PostgreSQL 호환 관계형 데이터베이스입니다.

 

Aurora는 표준 MySQL 데이터베이스보다 최대 5배 빠르고, 표준 PostgreSQL 데이터베이스보다 3배가 빠릅니다.

비용 또한, 절감되며 하드웨어 프로저닝, 데이터베이스 설정, 패치 및 백업 같은 작업을 자동화하는 RDS에서 aurora를 관리합니다

 

* 하드웨어 프로저닝: 데이터베이스를 호스팅 하기 위해 필요한 서버 및 스토리지 리소스를 준비하고 할당하는 과정

(CPU, 디스크 공간, 네트워크 용량 등을 결정하고 설정함)

 

 

2. RDS와 차이점

 

RDS는 가용영역 내부에 RDS가 존재하며, RDS는 EC2와 EBS로 구성되어 있습니다.

 

* EBS: Amazon Web Service에서 제공하는 Elastic Block Store의 약자입니다.
AWS 클라우드에서 사용할 수 있는 영구적인 스토리지 블록 디바이스를 제공합니다.
EBS는 네트워크를 통해 EC2 인스턴스에 연결되며,
데이터베이스와 같이 지속적인 스토리지가 필요한 애플리케이션에 적합합니다.

 

동일한 리전의 여러 개의 가용영역으로 나눠있는 RDS는 VPC 그룹 내에서 보안 처리가 이루어지게 됩니다.

 

 

Multi AZ 사용시 primary - standby로 읽기 전용 DB로 복제됩니다

(standBy DB는 접근 불가능, failover 발생 시 RDS StandBy가 Primary로 승격)

이러한 구조는 접근 불가능한 DB를 생성하여 싱크를 맞추는 과정이 필요하므로 안정성은 증가하지만,

비용이 두배가 증가합니다.

 

multi AZ와 다른 개념으로 RDS 읽기 전용 리플리카라는 개념이 있습니다.

읽기 전용 리플리카는 데이터베이스 읽기 성능을 향상하기 위한 방법으로, 비동기로 데이터를 복제합니다.

쓰기 쿼리는 Primary에 전송된다면, 읽기 전용 요청은 리플리카에서 처리되어 부하 분산 역할을 수행합니다.

(읽기 전용 리플리카는 DNS 주소가 있습니다)

 

 

https://docs.aws.amazon.com/ko_kr/AmazonRDS/latest/AuroraUserGuide/Aurora.Overview.html

 

 

Aurora DB는 클러스터 구성으로 되어있으며, 이 클러스터에는 하나 이상의 DB 인스턴스가 포함됩니다.

클러스터 볼륨은 여러 가용 영역에 걸쳐 자동으로 복제되고 분산됩니다. 이는 여러 물리적 위치에 저장하므로 높은 내구성과

가용성을 지니고 있습니다.

이때, Aurora는 스토리지 계층을 데이터베이스 엔진과 밀접하게 통합하여 네트워크 지연을 줄이고 I/O 작업을 최적화합니다.

 

여기서 궁금했던 점은, 어떠한 이유로 클러스터 노드로 처리하는 것이 I/O 작업을 최적화할 수 있는지입니다.

Aurora는 전통적인 RDS와 비교하여 밀접하게 통합된 아키텍처를 가지고 있는데, 인스턴스가 스토리지와 효율적으로 통신할 수 있도록

설계가 되어 있기 때문에 I/O 지연시간을 줄이고 클러스터 간 스토리지 계층에서 데이터 관리를 최적화한다고 생각하였습니다.

 

또한, RDS는 EBS 스토리지를 사용하게 되는데, 생성 시 EBS 용량을 지정해서 사용해야 합니다.

Aurora는 필요한 스토리지를 자동으로 확장해 주는 방법을 적용하므로

RDS와 비교했을 때 유연한 자동 확장이 더 용이하다고 볼 수 있습니다.

 

 

3. AWS Aurora 특징

 

 

Aurora는 다음의 장점 및 특징을 가지고 있습니다.

 

  • 분산 스토리지: Aurora는 고가용성과 내구성을 위해 설계된 분산 스토리지 시스템을 사용합니다.
    위의 사진처럼 여러 가용 영역에 걸쳐 복제되어 저장하므로 하나의 AZ에 장애가 발생해도 데이터 손실이 없고 가용성이 유지됩니다.
  • 자동 복제: Aurora는 데이터를 자동으로 여섯 개의 복제본으로 저장합니다. 이 복제본은 세 개 이상의 물리적으로 분산된 AZ에 존재하게 되는데, 단일 인스턴스 장애가 전체 데이터베이스 시스템 장애로 이어지지 않음을 의미합니다.

 

만약 위에처럼 개별 분산 스토리지가 장애가 난다면 총 6개의 복제본에 저장하므로 4개 이상의 쓰기 작업이 가능하다면

쓰기가 정상적으로 처리됩니다 (읽기는 3개 이상 정상적이라면 읽기 능력이 유효합니다)

 

혹은 인스턴스가 장애 난다면 failover로 읽기 인스턴스가 승격하여 쓰기 처리를 수행할 수 있습니다.

 

여기서 궁금한 점은 쓰기/읽기의 엔드포인트가 다른데, 만약 쓰기 엔드포인트가 장애날 때,

서버에서는 이를 어떻게 인지하고 승격한 엔드포인트로 요청을 보낼까요?

 

이는 Amazon Aurora에서 클러스터 엔드포인트를 제공함으로 해결할 수 있습니다.

클러스터 엔드포인트는 항상 Writer의 인스턴스를 가리킵니다. 이는 특정 DNS의 주소를 알 필요 없이 클러스터 엔드포인트로

쓰기 요청을 처리할 수 있습니다.

 

반면 읽기 전용 리플리카가 에러가 난다면, 어떻게 처리될까요?

리플리카들은 개별적인 DNS가 아닌 공통된 DNS를 가집니다.

만약 읽기 전용 엔드포인트로 요청을 보내면 Aurora는 내부적인 로드 밸런싱 메커니즘을 통해 해당 요청을 Reader로 라우팅 합니다.

즉 클라이언트 입장에서는 다수의 리플리카 인스턴스에 대한 각각의 DNS를 알 필요 없이 읽기 전용 엔드포인트로 요청을 보내게 됩니다.

 

여기서, 장애가 발생하면 Aurora는 해당 인스턴스를 자동으로 로드 밸런싱 풀에서 제거하고, 다른 Reader로 라우팅 하게 됩니다.

 

 

이어서 Aurora는 다음의 특징을 가집니다.

  • 스토리지 확장성: Aurora는 사용한 만큼 비용을 지불하는 방식으로 데이터베이스 스토리지를 자동으로 확장합니다.
  • 성능: Aurora는 표준 MySQL 및 PostgreSQL보다 훨씬 높은 성능을 제공합니다.
    이는 Aurora가 데이터베이스 버퍼 풀, 리두 로깅 및 페이지 리플레이와 같은 데이터베이스 엔진의 핵심 부분을 최적화하여 제공하기 때문입니다.
  • 복구 속도: Aurora는 데이터베이스의 물리적인 복사본을 만드는 대신 변경 사항 로그를 저장하기 때문에, 복구 속도가 매우 빠릅니다.
  • 고가용성과 장애 조치: Aurora는 고가용성을 위해 설계되었으며, 인스턴스 장애 시에 장애 조치 시간이 RDS에 비해 훨씬 짧습니다.
  • Backtrack 제공: 데이터베이스를 이전 시점으로 되돌릴 수 있는 기능을 제공합니다
    이 기능을 통해 특정 시점의 상태 이전으로 데이터베이스를 되돌릴 수 있습니다.

 

이상으로 Aurora에 대한 정리를 마치도록 하겠습니다! 추가되는 내용은 계속 업데이트하며 정리하도록 하겠습니다!

잘못된 점이나 수정 필요한 부분은 댓글 부탁드립니다!

감사합니다!!!

 

 

참고: AWS 강의실 https://www.youtube.com/@AWSClassroom

참고: AWS 강의실 RDS https://www.youtube.com/watch?v=koDIV5QMw38

참고: AWS 강의실 Aurora https://www.youtube.com/watch?v=RImUPhD8X-o

참고: https://docs.aws.amazon.com/ko_kr/AmazonRDS/latest/AuroraUserGuide/Aurora.Overview.html

안녕하세요. 회사와 함께 성장하고 싶은 KOSE입니다.

 

이번 포스팅은 도커를 공부하여 이해한 Docker Container에 대한 정리를 작성하고자 합니다.

내용이 다르거나 잘못된 점이 있다면, 댓글 부탁드립니다!

 

 

1. Docker

 

도커 컨테이너는 말 그대로 물건을 담는 그릇과 비슷합니다.

애플리케이션이 있는 상자가 있다면, 이 상자 안에는 애플리케이션과 애플리케이션을 실행할 컴퓨터의 정보가 주어져 있습니다.컴퓨터 정보는 IP, 컴퓨터 이름, 디스크 드라이브 등이 포함됩니다.도커 컨테이너는 사용하는 호스트 운영체제나 다른 환경에 종속적이지 않은 환경에서 애플리케이션을 동작할 수 있는 장점이 있습니다.

 

도커를 실행하고자 하는 Host OS의 종류에 따라 도커가 다른 방식으로 운용될 수 있습니다.

저는 맥 OS를 사용하고 있으므로, 맥을 기준으로 설명하고자 합니다!

 

 

2. Mac의 운영체제에서 동작하는 Docker의 VM

 

윈도우, 맥 OS는 리눅스 운영체제에서 사용하는 커널과 별개의 시스템입니다. 

윈도우는 Windows NT 커널을 기반으로 하고, 맥 OSsms UNIX 계열의 BSD를 기반으로 하는 XNU 커널을 사용합니다.

따라서, 리눅스 커널과 별개의 시스템이므로 이를 호환시켜 줄 기능이 필요합니다.

 

맥은 Docker Desktop for Mac 가상화 기술을 이용해 내부적으로 리눅스 VM을 실행하고 이 VM 위에서 도커 컨테이너를 구동합니다.

경량화된 VM은 도커를 운용하기 위해 필요한 기능만 적용되어 운영체제 위에 하이퍼바이저로 각자 별도의 운영체제를 가지고 운영되는 가상 머신과 비교하여 리소스 낭비가 적습니다.

 

 

Host OS 관점에서는 Docker 경량화 VM은 맥 OS에서 하나의 프로세스로 실행되므로 고유한 PID를 가집니다.

즉, 맥 OS 관점에서는 Docker 경량화 VM은 하나의 애플리케이션 혹은 프로세스로 처리됩니다.

 

 

2. Docker 경량화 VM에서 동작하는 개별 컨테이너

 

Docker 경량화 VM이 Host OS의 개별 PID였다면, Docker 경량화 VM에서 컨테이너는 개별 PID 입니다.

VM 내부에서 실행되는 Docker 컨테이너들은 리눅스 커널 위에서 각기 독립된 프로세스로 실행이 됩니다.

Docker VM은 리눅스 기반이므로 Docker 컨테이너는 이 VM의 리눅스 커널을 공유하게 되는 것입니다.

 

그 결과, 프로세스를 격리할 수 있습니다.

각 컨테이너는 독립된 프로세스로 실행되지만, 동일한 커널 인스턴스를 사용합니다.

 

또한, 네임스페이스라는 커널 기능을 사용하여 격리됩니다.

네임스페이스는 프로세스 ID, 네트워크 인터페이스, 마운트 포인터 등을 컨테이너별로 분리하여

각 컨테이너가 자신만의 격리된 환경을 가지는 것처럼 동작합니다.

 

 

여기서 중요한 점은 각 컨테이너는 VM 내부에서 고유한 PID를 가지는데,

이 PID는 호스트 OS(Mac)의 PID 공간과는 분리되어 있습니다.

따라서, 호스트 OS는 이 내부 PID를 직접적으로는 알 수 없습니다.

 

이 경량화된 VM은 개별적인 운영체제를 가지고 운용되는 가상 머신 아키텍처와는 다른 구조를 보입니다.

위의 사진처럼 Docker VM은 각 컨테이너가 격리된 환경에서 운용되지만, 공통된 Docker VM 내부에서 동작합니다.

이는 운영체제를 공유함으로써 불필요한 리소스를 막을 수 있습니다.

 

도커는 이러한 점에서 격리밀집이라는 두 가지 특징을 충족하게 됩니다.

  • 격리: 각 컨테이너가 격리된 환경에서 운용
  • 밀집: 컴퓨터에 CPU와 메모리가 허용하는 한 되도록 많은 애플리케이션을 실행

 

하지만, 각자 별도의 운영체제를 갖는 가상머신은

 

 

별도의 운영체제로 인해, 애플리케이션이 사용할 CPU, 메모리 자원을 많이 사용하게 됩니다.

또한, 운영체제의 라이선스 비용 및 운영체제 설치 비용 등 복잡한 문제가 추가로 발생합니다.

이러한 특징은 격리된 환경은 보장하지만, 밀집의 특성을 충족하지 못하는 단점이 있습니다.

 

 

3. 정리하며

 

도커 및 쿠버네티스를 공부할 때,  "도커는 OS를 공유한다"라는 의미가 사실 많이 혼란스러웠습니다.

Host OS - 도커 경량화 VM - 각 도커 컨테이너의 연관성을 다시 생각하고 정리하니 어떠한 의미인지 이해할 수 있었습니다.

 

정리하면 다음과 같습니다.

  • 리눅스 환경에서는 컨테이너가 직접적으로 호스트 OS의 리눅스 커널을 공유합니다.
    • 각 컨테이너는 별도의 사용자 공간을 가지지만, 모두 같은 커널 공간을 사용합니다.
    • 각 컨테이너 프로세스는 호스트 시스템에서 고유한 PID를 가지며 실행됩니다.
  • 하지만, 운영체제가 맥이나 윈도우인 경우 직접적인 커널 공유가 불가능합니다.
    • Docker는 리눅스 가상 머신을 구동하고 이 가상 머신에 내장된 리눅스 OS에서 컨테이너를 실행합니다.
    • OS를 공유한다는 의미는 이 VM 내부에서 실행되는 컨테이너가 VM의 리눅스 OS 커널을 공유한다는 의미입니다.
    • 각 컨테이너들은 리눅스 가상 머신 내에서 고유한 PID를 가지며 관리됩니다.

 

이상으로 Docker Container에 대한 정리를 마치도록 하겠습니다!

읽어주셔서 감사합니다!

안녕하세요! 회사와 함께 성장하고 싶은 KOSE입니다.

이번 포스팅은 깃 액션을 활용한 배포에 대한 내용을 정리하고자 합니다.

 

개발에서 CI/CD (Continuous Integration/Continuous Delivery) 중요한 부분입니다.

지속적으로 통합하고, 지속적인 배포로 사용자에게 하여금 지속적인 서비스를 제공하는 것이 중요합니다.

이를 위해 적용할 수 있는 깃 액션과 코드 디플로이를 활용한 배포를 설명하겠습니다.

 

 

1. 목표 플로우

 

 

코드를 배포해야 할 때, 배포를 위한 브랜치로 변경하면 깃 액션이 동작하여 S3에 jar를 비롯한 배포 관련 yml, sh 압축하여 전송합니다.

이어서, Code Deploy를 호출하여 Code Deploy가 S3에 필요한 배포 정보를 바탕으로 EC2에 데이터를 복사합니다.

EC2에서는 앞 서 정의한 배포 관련 yml, sh를 통해 System Manager로부터 필요한 환경 변수를 로드합니다.

이어서, 엔진엑스가 스프링의 포트를 리버스 프록시 하여 배포가 완성되게 됩니다.

 

엔진엑스를 적용하는 과정을 ALB를 도입하여 안정적이고 관리가 용이하게 바꿀 수 있습니다.

(하지만 비용 문제로 인해 NGINX를 적용하였습니다!)

 

 

2. 깃 액션 정의하기

 

저는 main branch에 PR을 요청할 때는 테스트를 실행하는 깃 액션을 정의하였습니다. 배포는 deploy라는 브랜치를 생성하여

배포가 될 수 있도록 정의하였습니다.

 

 

작성하는 워크 플로우는 위에 사진에 자세하게 작성한 파트로 나눌 수 있습니다.

어떠한 브랜치에 적용할 것인지, 어떠한 환경에서 실행할 것인지, 어떻게 단계를 정의할 것인지로 크게 구분할 수 있습니다.

 

main에 머지된 코드로 deploy 브랜치를 생성하면 하단처럼 깃액션이 활성화됩니다.

 

 

다음은 S3에 파일을 올리기 위해 최상위 루트 디렉터리로 tar를 만드는 과정입니다.

jar 혹은 배포를 위한 yml, sh 등을 개별적으로 S3에 올릴 수 있지만 Code Deploy를 활용하려면 tar 혹은 압축 파일로 만들어야 합니다.

 

 

위의 코드처럼, 각 build/libs에 있는 파일과 배포를 위해 정의한 sh, yml에서 파일을 복사한 후 압축하여 tar로 만들어 줍니다.

이제, S3에 파일을 올리는 과정을 정의하면 다음과 같습니다.

 

 

S3에 올리기 위해서는 S3 버킷의 Region 및  URL을 입력합니다.

이어서, 접근하기 위한 액세스 키와 시크릿 키를 입력해야 합니다.

여기에 필요한 정보를 yml 등에 정의하게 된다면 큰 보안 위협을 받을 수 있습니다.

깃 액션에서는 필요한 환경 정보를 시크릿으로 유지할 수 있도록 정의할 수 있는 공간이 있습니다.

 

 

리포지토리의 settings - 좌측 메뉴의 Secrets and variables의 Actions를 클릭하면 New repository secret을 생성할 수 있습니다.

이곳에 S3에 접근 권한을 IAM 권한을 받은 사용자의 액세스키와 시크릿키를 입력하면 됩니다.

(IAM 관련 설명은 좋은 강의가 있어서 링크드립니다! https://www.youtube.com/watch?v=lcly_aIq1KI)

 

 

마지막으로 Code Deploy를 실행하기 위해 Code Deploy에 정의한 배포 그룹 및 애플리케이션 이름, 복사할 버킷 이름 등을 작성합니다.

Code Deploy를 설정하는 과정은 다소 복잡하여 다른 포스팅에서 작성하도록 하겠습니다!

 

이렇게 git Action의 활동에서 처리할 각 역할을 설정해주었습니다.

이제, Code Deploy가 실행될 때, 해야 할 역할을 정의하도록 하겠습니다.

 

 

2. Code Deploy appspec.yml 정의

 

Code Deploy는 정의한 appspec의 내용에 따라, 배포 시나리오를 설정할 수 있습니다.

먼저 Code Deploy는 EC2에 대한 접근 권한, S3에 대한 접근 권한이 있어야 합니다

 

 

작성하는 appspec.yml은 다음처럼 복사하고자 하는 위치, 접근 권한, 스크립트 단계별 실행 등으로 

구성할 수 있습니다.

 

이렇게 작성한 appspec.yml은

code deploy가 실행될 때 앞 서 만든 tar를 압축 해제하고 최상위 루트에 작성된 appspec.yml에 따라 해당 시나리오가

동작하는 방식으로 처리가 됩니다.

 

 

3. Hooks에 적용할 스크립트

 

appspec.yml에 작성한 스크립트는 EC2에 적용되어 실행이 됩니다.

먼저 스프링 서버를 실행하기 위한 java 설치 등은 before_install.sh 에 정의할 수 있습니다.

 

 

자바가 설치되면, application_start.sh를 수행하여 스프링 서버를 배포할 수 있습니다.

 

 

EC2에 스프링을 배포할 때 필요한 환경 변수들이 있을 수 있습니다. 이 정보는 AWS에서 제공하는 System Manager의 파라미터 스토어에 정보를 입력할 수 있습니다. 필요한 정보를 파라미터 스토어에 입력한 후 EC2가 System Manager에 대한 권한을 가지도록 역할을 설정해주면, 이를 안전하게 활용할 수 있습니다.

 

 

다음은, 헬스체크를 통해 현재 실행 중인 포트를 확인하여, 다른 포트로 배포하기 위한 과정입니다.

서버에 정의한 헬스체크 url로 먼저 8080 포트로 요청을 보내서, 만약 해당 포트가 정상 응답이 온다면

배포해야 하는 포트는 8081이 됩니다(사용하지 않는 포트)

 

반면 헬스체크가 안된다면 8080 포트를 사용하지 않는 것이므로 8080 포트로 jar를 실행해야 합니다.

 

 

이어서, 정상 일정 시간 기다린 후 jar를 실행한 포트로 헬스체크를 보냅니다. 만약 성공한다면 이전에 배포한 서버를 graceful로 안정적으로 종료하고, 사용한 PID를 KILL 합니다.(graceful은 스프링의 application.yml에 정의되어야 합니다!)

 

 

마지막으로, 80 포트로 오는 유저의 요청을 현재 실행 중인 스프링 서버로 연결될 수 있도록,

엔진엑스의 리버스 프록시를 바꿔줍니다.

 

 

이렇게 함으로써, 깃 액션과 코드 디플로이를 바탕으로 한 배포 과정을 설정할 수 있습니다!

 

엔진엑스 리버스 프록시를 연결하는 과정은 향로님이 작성하신 엔진엑를 활용한 무중단 배포 구축하기에 상세하게 작성되어 있어서 링크를 남기겠습니다 (https://jojoldu.tistory.com/267)

 

이상으로 깃 액션과 코드 디플로이로 배포하는 과정 정리를 마치겠습니다.

읽어주셔서 감사합니다!

'DevOps' 카테고리의 다른 글

[DevOps] Docker Container  (1) 2023.11.04
[DevOps] Jenkins - Docker - AWS 자동 배포 1편  (0) 2023.03.06

안녕하세요. 회사와 함께 성장하고 싶은 KOSE입니다.

이번 포스팅은 Jpa save()에 대한 정리를 진행하고자 합니다.!

 

오늘 새벽에 개발 관련 카카오톡 채팅방에서 다음의 질문이 올라왔습니다.

BaseEntity()에 createAt, modifiedAt을 설정하고 타임스탬프를 6자리 설정했으나,

save()를 수행한 값과 조회한 값의 타임스탬프가 동일하지 않다는 것이었습니다.!

 

이 문제의 원인은 save()라는 함수(메서드)에 있었습니다.

함께 save()를 정리하여 위의 문제를 해결해보겠습니다.

 

 

1. save()

 

 

Jpa의 가장 많이 쓰이는 함수인 save()는 엔티티를 영속화하여 데이터베이스에 저장하는 역할을 수행합니다.

 

 

Jpa의 save()를 살펴보면 다음의 메커니즘으로 처리가 됩니다.

 

 

코드를 분석하면, S 타입의 entity를 인자로 받아,

식별자가 존재하는 엔티티를 판단하여 처음이라면, entityManager로 영속화한 후, entity를 리턴합니다.

이미 식별자가 존재한다면 entityManager.merge(entity)를 수행하여 병합합니다.

 

이 코드는 인자로 받는 엔티티가 다시 리턴되는 구조로 되어있습니다.
이는 곧 입력받는 인자와 리턴하는 인자는 동일 인스턴스를 의미하는 것입니다.
만약 엔티티가 새로운 값이라면, persist를 통해 setter로 id 식별자를 생성하게 됩니다.

 

여기서 궁금한 사항이 생길 수 있습니다!

대부분은 이제 @Setter를 쓰지 않고 @Noargsconstructor를 쓰며 엔티티를 생성하실 겁니다.

setter는 위험하니, 지양하고 계실 것 같습니다!

 

만약 @Setter를 쓰지 않음으로써 id 필드(혹은 프로퍼티)를 변경 불가능하도록 한다면,
어떻게 persist가 해당 필드를 수정할 수 있는 것일까요?

 

Reflection

 

 

이는 자바의 리플렉션과 관련이 있습니다.

JPA 구현체는 Java의 Reflection API를 사용하여 private 필드에도 직접 접근하고 값을 설정할 수 있습니다.

리플렉션으로 id 필드에 접근한 후, 접근 가능토록 변경한 후, set을 통해 식별자를 바꿔줄 수 있습니다.

 

이를 통해, save()는 함수의 인자와 리턴되는 값은 동일한 객체이되 새로운 엔티티라면 리플렉션으로 식별자를 setter로 등록한다고 할 수 있습니다.

 

 

다음처럼 테스트 코드로 살펴보겠습니다.

새로운 엔티티를 생성하고 save()를 저장한 후 리턴한 값을 savedRestaurantEntity로 설정했습니다.

savedRestaurantEntity는 객체 생성에 설정했던 address의 객체를 그대로 참조하고 있으며, 
shouldBeSameInstanceAs로 생성 시 엔티티와 저장후 엔티티가 동일한 객체 인스턴스임을 검증하고 있습니다.

 

 

2. 타임스탬프가 맞지 않았던 문제

 

문제의 발단은 다음과 같습니다.

 

 

해당 코드처럼 BaseEntity를 생성했음에도 불구하고, 생성시 타임스탬프와 저장 후 조회한 값의 타임스탬프가 같지 않은 것입니다.

 

 

이는 위에서 설명했던 save()의 개념으로 해결할 수 있습니다.

 

 

객체를 생성할 때는 LocalDateTime()의 기본값으로 나노초가 설정이 됩니다.

save()를 하더라도 식별자가 생길 뿐 객체 자체에는 변화가 없습니다.

 

이는 이전에 설정한 TIMESTAMP가 5 더라도, 조회한 값을 가져온 것이 아니므로 객체 자체의 온전한 값이 저장되는 것입니다.

하지만, 실제 조회를 하게 되면, DB에 넣은 값을 조회할 수 있으므로 다음처럼 결과를 얻을 수 있습니다.

 

 

따라서, createdAt, modifiedAt을 쓸 때,

save()를 한 후 식별자로 한 번 더 조회한 값으로 테스트를 수행해야 원하는 값을 얻을 수 있습니다.

(영한님의 JPA 강의에서 save()한 값을 그대로 활용할 때 원하지 않는 문제가 발생할 수 있다고 하셨던 말이 떠오르네요!)

 

이상으로 Jap save()에 대한 정리를 마치도록 하겠습니다. 

읽어주셔서 감사합니다!

+ Recent posts