728x90
반응형

[Spring] @Value 주입이 null인 경우

환경 : Spring Boot 3.x, Kotlin 1.9



1. 원인

  • static 필드에 직접 사용 — Spring이 주입 불가
  • new 키워드로 생성한 객체 — Spring 빈이 아님
  • 생성자 호출 시점에 다른 빈에서 해당 필드 참조 — 초기화 순서 문제
  • property 키 오타 또는 application.yml 누락
  • SpEL 표현식 오류


2. 해결 방법

2.1) static 필드 금지 — 생성자 주입 사용

Spring은 인스턴스 필드에만 의존성 주입 가능. static 필드는 클래스 레벨이라 주입 안 됨

// 동작 안 함
@Component
class AppConfig {
    companion object {
        @Value("\${app.name}")
        lateinit var appName: String // null
    }
}

// 정상
@Component
class AppConfig(
    @Value("\${app.name}") val appName: String
)

어쩔 수 없이 static처럼 써야 하면 setter trick

@Component
class AppConfig {
    companion object {
        lateinit var appName: String
            private set
    }

    @Value("\${app.name}")
    fun setAppNameStatic(value: String) {
        appName = value
    }
}

하지만 위 방식보다는 @ConfigurationProperties로 객체로 관리하는 게 더 깔끔


2.2) new 키워드 금지 — 빈으로 등록

new로 만든 객체는 Spring 컨테이너 밖이라 @Value 동작 안 함

// 동작 안 함
class EmailService {
    @Value("\${mail.from}")
    lateinit var from: String
}

@Service
class NotificationService {
    private val emailService = EmailService() // new와 동일 → @Value 무시
}
// 정상 — 빈으로 등록
@Component
class EmailService(
    @Value("\${mail.from}") val from: String
)

@Service
class NotificationService(
    private val emailService: EmailService // DI
)

2.3) 초기화 순서 문제 — @PostConstruct에서 사용

생성자 시점에 다른 필드를 참조하면 주입 전 상태일 수 있음

// 동작 안 함
@Component
class AppConfig {
    @Value("\${app.name}")
    lateinit var appName: String

    val fullName = "App: $appName" // 생성자 시점 → null
}

// 정상 — @PostConstruct 사용
@Component
class AppConfig {
    @Value("\${app.name}")
    lateinit var appName: String

    lateinit var fullName: String

    @PostConstruct
    fun init() {
        fullName = "App: $appName" // 주입 완료 후 실행
    }
}

2.4) property 키 확인 — default 값 설정

오타나 키 누락 시 기본값으로 방어

// 키 오타나 누락 시 예외 발생
@Value("\${app.nmae}") // 오타
lateinit var appName: String

// default 값 사용
@Value("\${app.name:DefaultApp}")
lateinit var appName: String

숫자나 boolean도 동일

@Value("\${app.timeout:30}")
var timeout: Int = 0

@Value("\${app.enabled:true}")
var enabled: Boolean = false

2.5) SpEL 표현식 오류

#{...}는 SpEL, ${...}는 property placeholder. 헷갈리지 않기

// property 값
@Value("\${app.name}")
lateinit var appName: String

// SpEL 표현식
@Value("#{T(java.lang.Math).random() * 100}")
var randomValue: Double = 0.0

// property를 SpEL에서 사용
@Value("#{\${app.timeout} * 1000}")
var timeoutMs: Long = 0

SpEL 문법 오류 시 앱 시작 자체가 실패하므로 쉽게 발견됨


2.6) @ConfigurationProperties 권장

여러 property를 다룰 때는 @Value보다 @ConfigurationProperties가 안전

// application.yml
app:
  name: MyApp
  timeout: 30
  mail:
    from: no-reply@example.com
    host: smtp.example.com
@ConfigurationProperties(prefix = "app")
@ConstructorBinding
data class AppProperties(
    val name: String,
    val timeout: Int,
    val mail: MailProperties
)

data class MailProperties(
    val from: String,
    val host: String
)

@EnableConfigurationProperties(AppProperties::class)
@SpringBootApplication
class Application
@Service
class NotificationService(
    private val appProperties: AppProperties
) {
    fun send() {
        println("앱: ${appProperties.name}, 발신: ${appProperties.mail.from}")
    }
}

타입 안전성 + IDE 자동완성 + 검증 통합 가능



3. 디버깅

3.1) Environment에서 직접 확인

property가 실제로 로드됐는지 확인

@Component
class DebugConfig(private val env: Environment) {
    @PostConstruct
    fun check() {
        val appName = env.getProperty("app.name")
        println("app.name = $appName") // null이면 yml 파일 확인
    }
}

3.2) default 값으로 누락 확인

@Value("\${app.name:NOT_FOUND}")
lateinit var appName: String

@PostConstruct
fun check() {
    if (appName == "NOT_FOUND") {
        println("app.name property 누락")
    }
}

3.3) 주입 시점 로그

@Component
class AppConfig(
    @Value("\${app.name}") val appName: String
) {
    init {
        println("생성자 시점 appName: $appName")
    }

    @PostConstruct
    fun postConstruct() {
        println("@PostConstruct 시점 appName: $appName")
    }
}

init@PostConstruct 모두에서 찍히면 정상. init에서만 찍히고 값이 이상하면 주입 실패


※ 생성자 주입 + @ConfigurationProperties가 가장 안전. @Value는 간단한 값 하나만 쓸 때 사용


728x90
반응형
728x90
반응형

[Spring] @Scheduled 멀티 인스턴스 중복 실행

환경 : Spring Boot 3.x, Kotlin 1.9, Kubernetes



1. 원인

  • 여러 pod/replica가 동일한 스케줄 코드 보유 — 각자 독립적으로 실행
  • 배치 작업이 멱등하지 않으면 데이터 중복 생성/처리
  • 분산 락 없이 여러 인스턴스가 동시 작업


2. 해결 방법

2.1) ShedLock — 분산 락 기반 스케줄 중복 방지

가장 간단한 방법. Redis/MongoDB/JDBC 등의 공유 저장소에 락을 기록해서 한 번에 한 인스턴스만 실행

// build.gradle.kts
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-data-mongodb")
    implementation("net.javacrumbs.shedlock:shedlock-spring:5.9.1")
    implementation("net.javacrumbs.shedlock:shedlock-provider-mongo:5.9.1")
}
import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock
import net.javacrumbs.shedlock.spring.annotation.SchedulerLock
import org.springframework.scheduling.annotation.EnableScheduling
import org.springframework.scheduling.annotation.Scheduled

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

@Bean
fun lockProvider(mongoClient: MongoClient): LockProvider {
    return MongoLockProvider(mongoClient.getDatabase("mydb"))
}
@Component
class ScheduledJob {
    @Scheduled(cron = "0 0 * * * *") // 매시 정각
    @SchedulerLock(
        name = "dailyReport",
        lockAtMostFor = "5m", // 최대 5분 후 락 자동 해제
        lockAtLeastFor = "1m"  // 최소 1분간 락 유지
    )
    fun generateDailyReport() {
        println("[${LocalDateTime.now()}] 리포트 생성 시작 (pod: ${System.getenv("HOSTNAME")})")
        // 실제 배치 로직
    }
}

lockAtMostFor — 작업이 비정상 종료되도 락이 영원히 안 풀리지 않도록 최대 시간 설정

lockAtLeastFor — 작업이 너무 빨리 끝나도 일정 시간은 락 유지 (다른 인스턴스가 바로 또 실행하지 않도록)


2.2) ShedLock with JDBC

MongoDB 대신 DB 사용하는 경우

// build.gradle.kts
dependencies {
    implementation("net.javacrumbs.shedlock:shedlock-spring:5.9.1")
    implementation("net.javacrumbs.shedlock:shedlock-provider-jdbc-template:5.9.1")
}
@Bean
fun lockProvider(dataSource: DataSource): LockProvider {
    return JdbcTemplateLockProvider(
        JdbcTemplateLockProvider.Configuration.builder()
            .withJdbcTemplate(JdbcTemplate(dataSource))
            .usingDbTime()
            .build()
    )
}

DB에 shedlock 테이블 생성 필요

-- PostgreSQL 예시
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)
);

2.3) ShedLock with Redis

// build.gradle.kts
dependencies {
    implementation("net.javacrumbs.shedlock:shedlock-spring:5.9.1")
    implementation("net.javacrumbs.shedlock:shedlock-provider-redis-spring:5.9.1")
    implementation("org.springframework.boot:spring-boot-starter-data-redis")
}
@Bean
fun lockProvider(connectionFactory: RedisConnectionFactory): LockProvider {
    return RedisLockProvider(connectionFactory)
}

2.4) Quartz Cluster 모드

고급 스케줄링 기능(동적 스케줄 등록/삭제, 실패 재시도)이 필요하면 Quartz 사용

// build.gradle.kts
implementation("org.springframework.boot:spring-boot-starter-quartz")
# application.yml
spring:
  quartz:
    job-store-type: jdbc
    properties:
      org.quartz.jobStore.isClustered: true
      org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate
      org.quartz.scheduler.instanceId: AUTO

Quartz 테이블 스키마는 공식 문서 참고. ShedLock보다 무겁지만 분산 환경에서 안정적


2.5) Leader Election 패턴

Kubernetes 환경에서는 한 pod만 leader로 선출해서 스케줄 실행

// build.gradle.kts
implementation("org.springframework.integration:spring-integration-zookeeper")
@Configuration
class LeaderConfig {
    @Bean
    fun leaderInitiator(curatorFramework: CuratorFramework): LeaderInitiator {
        return LeaderInitiator(curatorFramework, Candidate("schedulerLeader", Runnable {
            println("리더 선출됨")
        }))
    }
}

@Component
class ScheduledJob(private val leaderInitiator: LeaderInitiator) {
    @Scheduled(fixedRate = 60000)
    fun task() {
        if (leaderInitiator.context.isLeader) {
            println("리더만 실행")
        }
    }
}

Zookeeper/etcd 같은 별도 인프라 필요. 대부분 환경에서는 ShedLock이 더 간단


2.6) Profile 격리 — 단일 인스턴스 전용

스케줄 작업을 특정 profile에서만 활성화

@Component
@Profile("scheduler")
class ScheduledJob {
    @Scheduled(cron = "0 0 * * * *")
    fun generateReport() { ... }
}

배포 시 한 pod만 SPRING_PROFILES_ACTIVE=scheduler 환경변수 주입. 나머지 pod는 스케줄 코드 자체가 빈 등록 안 됨

간단하지만 해당 pod가 죽으면 스케줄 중단됨 — 고가용성 낮음



3. 디버깅

3.1) 로그에 hostname/pod 이름 출력

여러 인스턴스 중 어디서 실행됐는지 확인

@Component
class ScheduledJob {
    @Scheduled(cron = "0 * * * * *")
    @SchedulerLock(name = "test", lockAtMostFor = "1m")
    fun task() {
        val hostname = System.getenv("HOSTNAME") ?: "local"
        println("[${LocalDateTime.now()}] 실행됨 (pod: $hostname)")
    }
}

ShedLock 적용 전에는 pod 개수만큼 로그 찍힘. 적용 후에는 하나만 찍혀야 정상


3.2) ShedLock 락 상태 확인

MongoDB 예시

db.shedLock.find()

JDBC 예시

SELECT * FROM shedlock;

lock_until 시간이 현재보다 미래면 락 유지 중


※ ShedLock이 가장 범용적. 간단한 배치는 ShedLock, 복잡한 스케줄 관리는 Quartz Cluster, 인프라 통제 가능하면 Leader Election


728x90
반응형
728x90
반응형

[Spring] @Async 동작하지 않는 경우

환경 : Spring Boot 3.x, Kotlin 1.9



1. 원인

  • @EnableAsync 설정 누락
  • 같은 클래스 내부에서 호출 (프록시 우회)
  • private 메서드에 적용
  • void 반환 타입 사용 — 예외 추적 불가
  • 같은 스레드풀에서 호출자·피호출자 동시 사용 시 데드락


2. 해결 방법

2.1) @EnableAsync 추가

@Transactional과 마찬가지로 명시적으로 활성화 필요

@EnableAsync
@SpringBootApplication
class Application

fun main(args: Array<String>) {
    runApplication<Application>(*args)
}

2.2) 자기호출 회피 — 별도 빈으로 분리

Spring AOP 프록시 기반이라 같은 클래스 내부 호출 시 @Async 무시됨

// 동작 안 함
@Service
class OrderService(private val orderRepository: OrderRepository) {
    fun process(orderId: Long) {
        val order = orderRepository.findById(orderId).orElseThrow()
        sendEmail(order) // 내부 호출 → @Async 무시
    }

    @Async
    fun sendEmail(order: Order) {
        println("스레드: ${Thread.currentThread().name}")
        emailService.send(order.email, "주문 완료")
    }
}

// 정상 — 별도 빈 분리
@Service
class OrderService(private val asyncEmailService: AsyncEmailService) {
    fun process(orderId: Long) {
        val order = orderRepository.findById(orderId).orElseThrow()
        asyncEmailService.sendEmail(order) // 외부 빈 호출
    }
}

@Service
class AsyncEmailService(private val emailService: EmailService) {
    @Async
    fun sendEmail(order: Order) {
        println("스레드: ${Thread.currentThread().name}")
        emailService.send(order.email, "주문 완료")
    }
}

2.3) public 메서드 사용

private, protected 메서드는 프록시가 가로채지 못함

// 동작 안 함
@Service
class NotificationService {
    @Async
    private fun send(message: String) { ... } // private 무시
}

// 정상
@Service
class NotificationService {
    @Async
    fun send(message: String) { ... }
}

2.4) 반환 타입 — CompletableFuture 사용

void(Kotlin에서는 Unit)는 결과나 예외를 받을 수 없음. 비동기 메서드가 실패해도 호출자는 모름

// 나쁜 예 — 예외 발생해도 추적 불가
@Async
fun processOrder(orderId: Long) {
    val order = orderRepository.findById(orderId).orElseThrow()
    throw RuntimeException("결제 실패") // 로그에만 찍히고 끝
}

// 좋은 예 — CompletableFuture 반환
@Async
fun processOrder(orderId: Long): CompletableFuture<Order> {
    val order = orderRepository.findById(orderId).orElseThrow()
    paymentService.charge(order)
    return CompletableFuture.completedFuture(order)
}

// 호출자에서 예외 처리 가능
fun process(orderId: Long) {
    asyncService.processOrder(orderId)
        .exceptionally { ex ->
            log.error("비동기 실패: ${ex.message}")
            null
        }
}

2.5) TaskExecutor 분리 — 데드락 방지

기본 스레드풀은 SimpleAsyncTaskExecutor — 매번 새 스레드 생성. 운영 환경에서는 풀 크기 제한된 Executor 직접 설정 권장

@Configuration
@EnableAsync
class AsyncConfig {
    @Bean(name = ["taskExecutor"])
    fun taskExecutor(): Executor {
        val executor = ThreadPoolTaskExecutor()
        executor.corePoolSize = 5
        executor.maxPoolSize = 10
        executor.queueCapacity = 100
        executor.setThreadNamePrefix("async-")
        executor.initialize()
        return executor
    }
}

여러 Executor를 나눠서 사용 가능

@Configuration
@EnableAsync
class AsyncConfig {
    @Bean(name = ["emailExecutor"])
    fun emailExecutor(): Executor {
        val executor = ThreadPoolTaskExecutor()
        executor.corePoolSize = 3
        executor.maxPoolSize = 5
        executor.setThreadNamePrefix("email-")
        executor.initialize()
        return executor
    }

    @Bean(name = ["reportExecutor"])
    fun reportExecutor(): Executor {
        val executor = ThreadPoolTaskExecutor()
        executor.corePoolSize = 2
        executor.maxPoolSize = 3
        executor.setThreadNamePrefix("report-")
        executor.initialize()
        return executor
    }
}

@Service
class NotificationService {
    @Async("emailExecutor")
    fun sendEmail(message: String) { ... }

    @Async("reportExecutor")
    fun generateReport(userId: Long) { ... }
}

주의 — 같은 스레드풀에서 A가 B를 호출하고 B도 비동기면, 스레드풀이 꽉 차면 데드락 발생 가능. 이런 경우 호출 구조에 따라 Executor를 나눠야 함



3. 디버깅

3.1) 스레드 이름 로그

비동기가 제대로 동작하는지 확인하려면 스레드 이름 출력

@Service
class OrderService {
    fun process(orderId: Long) {
        println("호출 스레드: ${Thread.currentThread().name}") // http-nio-8080-exec-1
        asyncService.sendEmail(orderId)
    }
}

@Service
class AsyncEmailService {
    @Async
    fun sendEmail(orderId: Long) {
        println("비동기 스레드: ${Thread.currentThread().name}") // async-1
        emailService.send(...)
    }
}

같은 스레드 이름이 나오면 @Async가 무시된 것


3.2) 로그 레벨 설정

# application.yml
logging:
  level:
    org.springframework.aop.interceptor.AsyncExecutionInterceptor: TRACE

정상 동작 시 출력

Async execution of method [...] started
Async execution of method [...] finished

※ 자기호출과 void 반환 타입이 가장 흔한 실수. 실무에서는 CompletableFuture 반환 + 별도 빈 분리가 기본


728x90
반응형
728x90
반응형

[Spring] @Transactional 동작하지 않는 경우

환경 : Spring Boot 3.x, Kotlin 1.9, JPA



1. 원인

  • private/protected 메서드에 적용
  • 같은 클래스 내부에서 호출 (self-invocation, 프록시 우회)
  • checked exception 발생 — 기본 롤백 대상 아님
  • readOnly = true 인데 데이터 변경 시도
  • propagation 옵션 잘못 설정


2. 해결 방법

2.1) public 메서드로 선언

Spring AOP 프록시는 public 메서드만 가로챔. private, protected, package-private 은 어드바이스가 적용되지 않음

// 동작 안 함
@Service
class MemberService(private val memberRepository: MemberRepository) {
    @Transactional
    private fun saveInternal(member: Member) { // private → 무시
        memberRepository.save(member)
    }
}

// 정상
@Service
class MemberService(private val memberRepository: MemberRepository) {
    @Transactional
    fun save(member: Member) { // public
        memberRepository.save(member)
    }
}

2.2) 자기호출 회피 — 별도 빈으로 분리

@Cacheable, @Async와 동일한 문제. 같은 클래스 내부에서 호출하면 프록시를 거치지 않아 어드바이스가 무시됨

// 동작 안 함
@Service
class OrderService(private val orderRepository: OrderRepository) {
    fun createOrder(request: OrderRequest) {
        validate(request)
        save(request) // 내부 호출 → @Transactional 무시
    }

    @Transactional
    fun save(request: OrderRequest) {
        orderRepository.save(Order.from(request))
    }
}

// 정상 — 별도 빈으로 분리
@Service
class OrderService(private val orderTransactionService: OrderTransactionService) {
    fun createOrder(request: OrderRequest) {
        validate(request)
        orderTransactionService.save(request) // 외부 빈 호출
    }
}

@Service
class OrderTransactionService(private val orderRepository: OrderRepository) {
    @Transactional
    fun save(request: OrderRequest) {
        orderRepository.save(Order.from(request))
    }
}

2.3) checked exception 롤백 — rollbackFor 명시

기본 롤백 대상은 RuntimeExceptionError만. checked exception(예: IOException, 사용자 정의 BusinessException : Exception)은 던져도 커밋됨

// 동작 안 함 — IOException 던져도 커밋됨
@Transactional
fun process() {
    memberRepository.save(member)
    throw IOException("외부 호출 실패") // 롤백 안 됨
}

// 정상 — Exception 전체 롤백
@Transactional(rollbackFor = [Exception::class])
fun process() {
    memberRepository.save(member)
    throw IOException("외부 호출 실패") // 롤백됨
}

Kotlin은 모든 예외를 unchecked 로 처리하지만, Java 라이브러리에서 던지는 checked exception 은 여전히 위 규칙을 따름. 확실히 하려면 rollbackFor = [Exception::class] 명시


2.4) propagation 옵션 정리

가장 자주 헷갈리는 3가지

// REQUIRED (기본값) — 기존 트랜잭션에 참여, 없으면 새로 생성
@Transactional(propagation = Propagation.REQUIRED)
fun save(member: Member) { ... }

// REQUIRES_NEW — 항상 새 트랜잭션 (기존 것 잠시 보류)
// 부모가 롤백돼도 살아남아야 하는 로그 기록 등에 사용
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun writeAuditLog(log: AuditLog) { ... }

// NESTED — savepoint 기반 부분 롤백 가능
// 부모 롤백 시 같이 롤백, 자식만 롤백 시 부모는 살아남음
@Transactional(propagation = Propagation.NESTED)
fun trySave(member: Member) { ... }

주의 — REQUIRES_NEW 도 자기호출 시 동일하게 무시됨. 같은 클래스에서 호출하면 propagation 전체가 의미 없어짐

@Service
class OrderService {
    @Transactional
    fun createOrder() {
        save()
        writeLog() // 자기호출 → REQUIRES_NEW 무시, 같은 트랜잭션에서 실행
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    fun writeLog() { ... }
}


3. 트랜잭션 적용 여부 디버깅

의심될 때는 코드와 로그로 직접 확인

3.1) 코드로 확인

import org.springframework.transaction.support.TransactionSynchronizationManager

@Service
class OrderService {
    @Transactional
    fun process() {
        val active = TransactionSynchronizationManager.isActualTransactionActive()
        val name = TransactionSynchronizationManager.getCurrentTransactionName()
        println("트랜잭션 활성: $active, 이름: $name")
    }
}

active = false 면 어드바이스가 적용되지 않은 것


3.2) 로그로 확인

# application.yml
logging:
  level:
    org.springframework.transaction.interceptor: TRACE

정상 적용 시 출력

Getting transaction for [com.example.OrderService.createOrder]
Completing transaction for [com.example.OrderService.createOrder]

위 로그가 안 찍히면 어드바이스 자체가 적용되지 않은 것. 1번 원인부터 점검


※ 대부분의 케이스가 자기호출 또는 private 메서드. 디버깅 전에 먼저 의심할 것


728x90
반응형
728x90
반응형

[JPA] save()가 INSERT 대신 SELECT를 날리는 경우

환경 : Spring Boot 3.x, JPA, Hibernate, Kotlin 1.9

 


증상

// memberRepository.save(member) 호출 시
// INSERT가 아니라 SELECT 쿼리가 먼저 실행됨
Hibernate: select m1_0.id ... from member m1_0 where m1_0.id=?
Hibernate: insert into member ...

 

1. 원인

Spring Data JPA의 save()는 내부적으로 persistmerge를 구분함

// SimpleJpaRepository 내부 구현
@Transactional
override fun <S : T> save(entity: S): S {
    if (entityInformation.isNew(entity)) {
        em.persist(entity) // INSERT
        return entity
    } else {
        return em.merge(entity) // SELECT + INSERT 또는 UPDATE
    }
}

isNew() 판단 기준:

  • ID가 null이면 새 엔티티 → persist
  • ID가 null이 아니면 기존 엔티티 → merge (SELECT 먼저 실행)

문제는 ID를 직접 할당하는 경우. ID가 이미 세팅되어 있으니 JPA가 기존 엔티티로 판단해서 merge를 호출함

// ID 직접 할당 → isNew() = false → merge → SELECT 발생
@Entity
class Product(
    @Id
    val id: String = UUID.randomUUID().toString(), // 이미 값이 있음
    val name: String
)

 

2. 해결 방법

2.1) @GeneratedValue 사용 (가장 간단)

DB에서 ID를 자동 생성하면 save() 시점에 ID가 null이므로 항상 persist

@Entity
class Member(
    val name: String,
    val email: String,
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0
)

 

2.2) Persistable 인터페이스 구현

ID를 직접 할당해야 하는 경우, Persistable을 구현해서 isNew() 판단 로직을 직접 제어

@Entity
class Product(
    @Id
    val id: String = UUID.randomUUID().toString(),
    val name: String,
    @CreatedDate
    var createdAt: LocalDateTime? = null
) : Persistable<String> {

    override fun getId(): String = id

    override fun isNew(): Boolean = createdAt == null
}

createdAtnull이면 아직 DB에 저장 안 된 것 → persist 호출. @CreatedDate는 저장 시점에 자동 세팅되므로 한 번 저장된 엔티티는 isNew() = false

@CreatedDate 사용하려면 @EnableJpaAuditing 필요

@EnableJpaAuditing
@SpringBootApplication
class Application

 

2.3) @Version 필드 활용

@Version 필드가 null이면 새 엔티티로 판단

@Entity
class Product(
    @Id
    val id: String = UUID.randomUUID().toString(),
    val name: String,
    @Version
    val version: Long? = null // null이면 isNew() = true
)

 

3. persist vs merge 동작 비교

// persist — INSERT 1번
em.persist(member)
// SQL: INSERT INTO member (name, email) VALUES (?, ?)

// merge — SELECT 1번 + INSERT 또는 UPDATE 1번
em.merge(member)
// SQL: SELECT ... FROM member WHERE id = ?
//      INSERT INTO member ... (새 엔티티인 경우)
//      UPDATE member SET ... (기존 엔티티인 경우)

merge는 항상 SELECT가 선행됨. 대량 INSERT 시 성능 차이가 큼

3.1) 대량 INSERT 성능 차이

// 나쁜 예 — ID 직접 할당 + saveAll → merge 1000번 = SELECT 1000 + INSERT 1000
val products = (1..1000).map { Product(id = "P-$it", name = "상품$it") }
productRepository.saveAll(products)
// 총 쿼리: 2000번

// 좋은 예 — Persistable 구현 후 saveAll → persist 1000번
// 총 쿼리: 1000번 (SELECT 없음)

※ 대량 INSERT 시에는 Persistable 구현 + spring.jpa.properties.hibernate.jdbc.batch_size=50 설정까지 하면 더 효과적

 

728x90
반응형
728x90
반응형

[Spring Boot] @Cacheable 캐시가 동작하지 않는 경우

환경 : Spring Boot 3.x, Kotlin 1.9



1. 원인

  • @EnableCaching 설정 누락
  • 같은 클래스 내부에서 호출 (프록시 우회)
  • 반환값이 null인 경우 캐시 미저장
  • 캐시 키가 매번 달라지는 경우


2. 해결 방법

2.1) @EnableCaching 추가

Spring Boot에서 캐시를 사용하려면 명시적으로 활성화해야 함

@EnableCaching
@SpringBootApplication
class Application

fun main(args: Array<String>) {
    runApplication<Application>(*args)
}

의존성도 확인

// build.gradle.kts
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-cache")
}

2.2) 같은 클래스 내부 호출 금지

@Transactional, @Async와 동일한 문제. Spring AOP 프록시 기반이라 내부 호출 시 캐시가 무시됨

// 동작 안 함
@Service
class MemberService {
    fun getMemberInfo(id: Long): MemberDto {
        val member = getMember(id) // 내부 호출 → @Cacheable 무시
        return MemberDto.from(member)
    }

    @Cacheable("members")
    fun getMember(id: Long): Member {
        return memberRepository.findById(id).orElseThrow()
    }
}

// 정상 — 별도 클래스로 분리
@Service
class MemberService(private val memberCacheService: MemberCacheService) {
    fun getMemberInfo(id: Long): MemberDto {
        val member = memberCacheService.getMember(id) // 외부 Bean 호출
        return MemberDto.from(member)
    }
}

@Service
class MemberCacheService(private val memberRepository: MemberRepository) {
    @Cacheable("members")
    fun getMember(id: Long): Member {
        return memberRepository.findById(id).orElseThrow()
    }
}

2.3) 캐시 키 확인

기본 캐시 키는 메서드 파라미터 전체. 파라미터가 매번 다른 객체면 캐시 히트가 안 됨

// 나쁜 예 — request 객체가 매번 새로 생성되면 키가 매번 다름
@Cacheable("members")
fun search(request: MemberSearchRequest): List<Member> { ... }

// 좋은 예 — 명시적으로 키 지정
@Cacheable(value = ["members"], key = "#name")
fun findByName(name: String): List<Member> { ... }

// 복합 키
@Cacheable(value = ["members"], key = "#name + '_' + #status")
fun findByNameAndStatus(name: String, status: Status): List<Member> { ... }

2.4) null 반환 처리

기본적으로 null도 캐시에 저장됨. 하지만 캐시 구현체에 따라 다를 수 있음

// null 결과는 캐시하지 않기
@Cacheable(value = ["members"], unless = "#result == null")
fun findByIdOrNull(id: Long): Member? {
    return memberRepository.findByIdOrNull(id)
}


3. 캐시 삭제

데이터가 변경되면 캐시도 갱신해야 함. 안 하면 오래된 데이터가 계속 반환됨

@Service
class MemberCacheService(private val memberRepository: MemberRepository) {

    @Cacheable(value = ["members"], key = "#id")
    fun getMember(id: Long): Member {
        return memberRepository.findById(id).orElseThrow()
    }

    @CacheEvict(value = ["members"], key = "#id")
    fun updateMember(id: Long, request: MemberUpdateRequest): Member {
        val member = memberRepository.findById(id).orElseThrow()
        member.update(request)
        return memberRepository.save(member)
    }

    @CacheEvict(value = ["members"], allEntries = true)
    fun clearAllCache() {
        // 전체 캐시 삭제
    }
}


4. 캐시 구현체 선택

별도 설정 없으면 ConcurrentMapCache(메모리)가 기본. 운영 환경에서는 Redis나 Caffeine 권장

4.1) Caffeine (로컬 캐시)

// build.gradle.kts
implementation("com.github.ben-manes.caffeine:caffeine")
# application.yml
spring:
  cache:
    type: caffeine
    caffeine:
      spec: maximumSize=500,expireAfterWrite=10m

4.2) Redis (분산 캐시)

// build.gradle.kts
implementation("org.springframework.boot:spring-boot-starter-data-redis")
# application.yml
spring:
  cache:
    type: redis
  data:
    redis:
      host: localhost
      port: 6379

※ 단일 서버면 Caffeine, 멀티 서버(팟 여러 개)면 Redis. 둘 다 쓰는 2-Level 캐시도 가능


728x90
반응형
728x90
반응형

[JPA] MultipleBagFetchException 해결

환경 : Spring Boot 3.x, JPA, Hibernate



에러 메시지

org.hibernate.loader.MultipleBagFetchException:
  cannot simultaneously fetch multiple bags:
  [com.example.Member.orders, com.example.Member.addresses]


1. 원인

2개 이상의 컬렉션(List)을 동시에 fetch join 하면 발생. Hibernate가 카테시안 곱 결과를 올바르게 매핑할 수 없기 때문

@Entity
class Member(
    @OneToMany(mappedBy = "member")
    val orders: List<Order> = mutableListOf(),

    @OneToMany(mappedBy = "member")
    val addresses: List<Address> = mutableListOf()
)

// 이 쿼리에서 터짐
@Query("SELECT m FROM Member m JOIN FETCH m.orders JOIN FETCH m.addresses")
fun findAllWithOrdersAndAddresses(): List<Member>


2. 해결 방법

2.1) List를 Set으로 변경

가장 간단한 방법. Set은 중복을 허용하지 않아 카테시안 곱 문제가 발생하지 않음

@Entity
class Member(
    @OneToMany(mappedBy = "member")
    val orders: Set<Order> = mutableSetOf(),

    @OneToMany(mappedBy = "member")
    val addresses: Set<Address> = mutableSetOf()
)

※ 순서가 필요하면 LinkedHashSet 사용


2.2) @BatchSize로 분리 조회

fetch join 대신 @BatchSize로 IN 절 조회. 쿼리 2~3번으로 해결

@Entity
class Member(
    @OneToMany(mappedBy = "member")
    @BatchSize(size = 100)
    val orders: List<Order> = mutableListOf(),

    @OneToMany(mappedBy = "member")
    @BatchSize(size = 100)
    val addresses: List<Address> = mutableListOf()
)
# 또는 전역 설정
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

2.3) fetch join을 나눠서 실행

한 번에 하나의 컬렉션만 fetch join하고 나머지는 Hibernate 초기화에 맡기기

@Query("SELECT m FROM Member m JOIN FETCH m.orders")
fun findAllWithOrders(): List<Member>

// addresses는 @BatchSize 또는 별도 조회로 처리

728x90
반응형
728x90
반응형

[Spring Boot] @Valid 검증이 동작하지 않는 경우

환경 : Spring Boot 3.x, Kotlin 1.9

 


에러 메시지

// @Valid를 붙였는데 유효하지 않은 값이 그대로 통과됨
// MethodArgumentNotValidException이 발생하지 않음

 

1. 원인

Spring Boot 3.x부터 spring-boot-starter-validation 의존성이 기본 포함 안 됨. 별도 추가 필요

또는 아래 케이스에서도 동작하지 않음

  • @Valid 어노테이션 위치가 잘못된 경우
  • Kotlin data class 필드에 어노테이션이 안 먹는 경우
  • @Validated@Valid 혼동

 

2. 해결 방법

2.1) 의존성 추가

// build.gradle.kts
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-validation")
}

2.2) @Valid 위치 확인

Controller 메서드의 @RequestBody 앞에 @Valid 선언

// 동작 안 함
@PostMapping
fun save(@RequestBody request: MemberRequest) // @Valid 누락

// 정상
@PostMapping
fun save(@Valid @RequestBody request: MemberRequest)

2.3) Kotlin data class에서 field 타겟 지정

Kotlin은 생성자 파라미터와 필드가 분리되어 있어서 어노테이션이 필드에 안 붙을 수 있음. @field: 사용 타겟 지정 필요

// 동작 안 함
data class MemberRequest(
    @NotBlank
    val name: String,
    @Email
    val email: String
)

// 정상
data class MemberRequest(
    @field:NotBlank
    val name: String,
    @field:Email
    val email: String
)

※ Kotlin에서는 @field:를 안 붙이면 어노테이션이 생성자 파라미터에만 적용되고 필드에는 적용 안 됨

2.4) @Validated로 클래스 레벨 검증

@RequestParam이나 @PathVariable 검증은 @Valid가 아니라 클래스에 @Validated 필요

@Validated // 클래스 레벨에 선언
@RestController
class MemberController(private val memberService: MemberService) {

    @GetMapping("/{id}")
    fun getById(@PathVariable @Min(1) id: Long): Member {
        return memberService.getById(id)
    }
}

 

3. 에러 응답 커스터마이징

기본 응답은 가독성이 떨어짐. @ExceptionHandler로 포맷 정리

@RestControllerAdvice
class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException::class)
    fun handleValidation(e: MethodArgumentNotValidException): ResponseEntity<Map<String, String>> {
        val errors = e.bindingResult.fieldErrors.associate {
            it.field to (it.defaultMessage ?: "")
        }
        return ResponseEntity.badRequest().body(errors)
    }
}

응답 예시

{
    "name": "must not be blank",
    "email": "must be a well-formed email address"
}

 

728x90
반응형

'Kotlin' 카테고리의 다른 글

Kotlin + Spring Boot 시작하기  (0) 2026.03.10
728x90
반응형

환경 : Spring Boot 3.1, Kotlin 1.9, Intellij

목표 : Kotlin 기반 Spring Boot 프로젝트 생성 및 간단한 CRUD API 구현


1. 코프링(Kopring)이란?

Kotlin + Spring의 합성어. Spring Boot 프로젝트를 Kotlin으로 개발하는 것을 의미

Java 대비 주요 차이점

  • Null 안정성 : Java는 런타임에 NPE 발생. Kotlin은 컴파일 타임에 체크
  • 코드량 : 동일한 기능 대비 약 30~40% 감소
  • 데이터 클래스 : Java는 Lombok 필요. Kotlin은 data class로 기본 제공

2. 프로젝트 생성

start.spring.io 에서 아래와 같이 설정

Project  : Gradle - Kotlin
  Language : Kotlin
  Spring Boot : 3.1.x

  Dependencies:
  - Spring Web
  - Spring Data JPA
  - H2 Database

3. build.gradle.kts 설정

plugins {
      kotlin("jvm") version "1.9.0"
      kotlin("plugin.spring") version "1.9.0"
      kotlin("plugin.jpa") version "1.9.0"
      id("org.springframework.boot") version "3.1.0"
      id("io.spring.dependency-management") version "1.1.0"
  }

  dependencies {
      implementation("org.springframework.boot:spring-boot-starter-web")
      implementation("org.springframework.boot:spring-boot-starter-data-jpa")
      implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
      runtimeOnly("com.h2database:h2")
  }
  • plugin.spring : Kotlin 클래스는 기본이 final이라 Spring 프록시 생성과 충돌. 자동으로 open 처리
  • plugin.jpa : JPA Entity에 필요한 기본 생성자 자동 생성
  • jackson-module-kotlin : Kotlin data class 직렬화/역직렬화 지원

4. CRUD API 구현

4.1) Entity

@Entity
  class Member(
      val name: String,
      val email: String,
      @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
      val id: Long = 0
  )

4.2) Repository

interface MemberRepository : JpaRepository<Member, Long>

4.3) Service

@Service
  class MemberService(
      private val memberRepository: MemberRepository
  ) {
      fun getAll(): List<Member> = memberRepository.findAll()

      fun save(name: String, email: String): Member {
          return memberRepository.save(Member(name = name, email = email))
      }
  }

생성자에 선언하는 것만으로 의존성 주입. @Autowired 불필요

4.4) Controller

@RestController
  @RequestMapping("/members")
  class MemberController(
      private val memberService: MemberService
  ) {
      @GetMapping
      fun getAll(): List<Member> = memberService.getAll()

      @PostMapping
      fun save(@RequestBody request: MemberRequest): Member =
          memberService.save(request.name, request.email)
  }

  data class MemberRequest(
      val name: String,
      val email: String
  )

5. 결과 확인

./gradlew bootRun

5.1) 전체 조회

curl http://localhost:8080/members

5.2) 저장

curl -X POST http://localhost:8080/members \
    -H "Content-Type: application/json" \
    -d '{"name":"홍길동","email":"hong@test.com"}'

5.3) 저장 후 전체 조회 결과

[{"id":1,"name":"홍길동","email":"hong@test.com"}]

 

728x90
반응형

'Kotlin' 카테고리의 다른 글

[Spring Boot] @Valid 검증이 동작하지 않는 경우  (0) 2026.03.27
728x90
반응형

1. 개요

마이크로서비스 아키텍처와 같이 작은 서비스들로 이루어진 환경에서 복잡한 트랜잭션과 데이터 일관성을 효과적으로 관리하기 위해 SAGA 패턴이 사용됩니다. 이 패턴은 여러 서비스 간의 트랜잭션을 조율하고 데이터 일관성을 유지하기 위한 강력한 도구로 활용됩니다.

 

분해된 큰 트랜잭션:

SAGA 패턴은 큰 트랜잭션을 작은 단위의 작업으로 분해합니다. 각 작업은 개별적인 서비스로 구현되며, 작은 범위의 트랜잭션을 조율하는 것이 가능해집니다.

 

단위 작업의 트랜잭션:

각 단위 작업은 자체적인 트랜잭션을 가집니다. 이러한 작은 트랜잭션은 각 서비스 내에서 안전하게 수행

될 수 있습니다.

 

SAGA 관리 로직:

SAGA 패턴은 전체 트랜잭션의 흐름을 관리하는 로직을 필요로 합니다. 이 로직은 각 단계의 작업이 순차적으로 진행될 수 있도록 조율하며, 작업의 완료 여부를 확인합니다.

 

복잡한 트랜잭션의 관리:

다중 서비스 간의 복잡한 트랜잭션을 분해하고 조율함으로써 전체 시스템의 확장성과 견고성을 높일 수 있습니다.

 

구현의 복잡성과 주의사항:

SAGA 패턴은 구현이 복잡하며, 각 작업이 성공적으로 완료되지 않을 경우 컴펜세이팅 트랜잭션을 관리해야 합니다. 따라서 신중한 계획과 테스트가 필요합니다.

 

2. 종류 

 

추가/수정 패턴 (Compensating Transaction Pattern): 

이 방식은 특정 작업에 대한 성공적인 완료가 보장되지 않을 경우, 이전 작업을 취소하는 로직을 구현합니다. 이전 작업을 취소하는 로직을 "보상 트랜잭션" 또는 "컴펜세이팅 트랜잭션"이라고 합니다. 이러한 컴펜세이팅 트랜잭션들이 순차적으로 실행되며, 일련의 작업들의 트랜잭션을 조율하게 됩니다.

 

사가 로깅 패턴 (Saga Logging Pattern): 

이 방식은 각 단계의 트랜잭션 상태와 진행 여부를 기록하는 로깅 메커니즘을 사용하여 트랜잭션의 상태를 추적합니다. 각 작업이 시작되고 완료될 때 로그를 남기고, 다음 작업으로 진행 여부를 확인할 수 있도록 합니다.

 

3. 장단점

 

장점

분산 시스템에서 트랜잭션 조율: 마이크로서비스 아키텍처와 같은 분산 시스템에서 여러 서비스 간의 트랜잭션을 조율하는데 유용합니다. 각 서비스의 트랜잭션을 분리하여 더 작은 범위에서 관리하고, 전체 시스템의 유연성과 확장성을 향상시킵니다.

부분 실패에 대한 대응: 일부 서비스에서 오류가 발생하여 트랜잭션이 실패할 경우, 해당 작업만 롤백하고 다른 작업은 계속 진행할 수 있습니다. 이로써 전체 트랜잭션을 망가뜨리지 않고 일부 실패를 처리할 수 있습니다.

변경의 용이성: 각 단위 작업은 개별적인 서비스로 구현되므로, 각 서비스의 변경이 다른 서비스에 영향을 덜 주며 유지보수가 용이합니다.

 

단점

구현 복잡성: SAGA 패턴은 복잡한 구조를 가지며, 작은 단위의 트랜잭션과 그에 따른 컴펜세이팅 트랜잭션을 구현해야 합니다. 이로 인해 개발과 유지보수가 어려울 수 있습니다.

컴펜세이팅 트랜잭션 처리의 어려움: 각 작업이 성공적으로 완료된 후 다음 작업으로 진행하는 것이 아니라, 중간에 실패할 경우 이전 작업을 롤백하고 컴펜세이팅 트랜잭션을 실행해야 합니다. 이런 컴펜세이팅 트랜잭션의 구현은 복잡하고 예외 처리가 중요합니다.

장애 복구 어려움: 트랜잭션 조율 중에 실패가 발생하거나 일부 서비스가 다운되면 전체 SAGA 트랜잭션이 실패할 수 있습니다. 이러한 상황에서의 복구 작업은 복잡하고 신중한 처리가 필요합니다.

응답 시간 지연: 각 작업이 별도의 트랜잭션으로 실행되기 때문에 전체 SAGA 트랜잭션이 완료될 때까지 시간이 걸릴 수 있습니다. 이로 인해 응답 시간이 지연될 수 있습니다.

 

 

SAGA 패턴은 복잡한 분산 시스템에서 효과적인 데이터 일관성과 트랜잭션 관리를 위한 방법이지만, 그 구현과 관리가 쉽지 않을 수 있습니다. 사용 시에는 장점과 단점을 고려하여 상황에 맞게 적용해야 합니다.

728x90
반응형

'이론 정리' 카테고리의 다른 글

Blocking/Non-blocking, Sync와 Async: 개념과 예시  (0) 2023.08.17
[OOP] 객체지향 프로그래밍  (0) 2021.02.27
한글 인코딩  (0) 2019.06.04
가상화 ( IO )  (0) 2018.02.11
가상화 ( 서버 )  (0) 2018.02.11

+ Recent posts