<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>개인히스토리저장소</title>
    <link>https://osc131.tistory.com/</link>
    <description>IT 개발 관련 다양한 정보를 공유하는 블로그입니다.</description>
    <language>ko</language>
    <pubDate>Mon, 3 Aug 2026 16:22:35 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>OSC131</managingEditor>
    <item>
      <title>[AI] 프롬프트 엔지니어링 실전 - 답 품질을 바꾸는 5가지 패턴</title>
      <link>https://osc131.tistory.com/220</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;환경 : &lt;/b&gt;OpenAI / Claude 등 LLM API 공통&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;목표 : &lt;/b&gt;같은 모델에서 답 품질을 끌어올리는 실전 프롬프트 패턴 (AI 입문 시리즈 2/4)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 역할 부여&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프롬프트 맨 앞에 &lt;b&gt;&quot;너는 누구다&quot;&lt;/b&gt;를 박아주면 답의 톤과 깊이가 달라짐. 같은 질문도 부여한 역할에 따라 전혀 다른 수준으로 나옴.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;// 모호한 프롬프트
이 코드 리뷰해줘

// 역할 + 기준 부여
너는 10년차 백엔드 개발자다.
아래 Kotlin 코드를 보안과 성능 관점에서만 리뷰.
지적마다 수정 코드도 함께 제시.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;역할이 먹히는 이유는 모델의 답이 모이는 방향이 바뀌기 때문임. 막연한 질문엔 학습 데이터의 평균치로 두루뭉술하게 답하다가, 역할이 주어지면 그 분야 전문가가 쓸 법한 어휘와 판단 기준 쪽으로 답의 분포가 좁혀짐. 즉 역할은 없던 지식을 더해주는 게 아니라, 이미 가진 지식 중 &lt;b&gt;어디를 꺼낼지 방향&lt;/b&gt;을 잡아주는 장치임.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 한계도 분명함. 역할을 줘도 모르는 사실을 지어내는 환각은 그대로 남음 &amp;mdash; 역할은 관점과 깊이를 바꿀 뿐 정확성까지 보장하진 않음. 한편 역할은 &lt;code&gt;system&lt;/code&gt; 메시지에 넣으면 대화 내내 유지되므로, 매 질문마다 반복할 필요가 없음.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 구체적으로 지시&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모호한 단어를 빼고 &lt;b&gt;측정 가능한 조건&lt;/b&gt;으로 바꿈. &quot;잘&quot;, &quot;간단히&quot;, &quot;적당히&quot; 같은 말은 모델이 제멋대로 해석함.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모호한 단어를 모델은 학습 분포의 평균값으로 해석함. &quot;간단히&quot;가 사람마다 다르듯 모델도 매번 다른 길이로 잡음. 길이&amp;middot;대상&amp;middot;형식을 숫자로 못 박으면 해석의 폭이 좁아져 결과가 일정해짐.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;// 모호함 &amp;mdash; 길이도 형식도 안 정해짐
요약해줘

// 구체적 &amp;mdash; 길이, 대상, 제약을 명시
아래 글을 3문장으로 요약.
비전공자도 이해할 수 있게, 전문 용어는 쉽게 풀어서.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제약을 거는 게 핵심인데, &quot;하지 마라&quot;보다 &lt;b&gt;&quot;이렇게 해라&quot;&lt;/b&gt;가 더 잘 먹힘. 부정 지시는 무엇을 하지 말지만 알려줄 뿐 그럼 뭘 해야 하는지는 안 알려줘서, 모델이 그 빈칸을 또 제멋대로 채우기 때문.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;// 약함
너무 길게 쓰지 마

// 강함
각 항목은 한 문장, 최대 5개 항목으로 작성&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 출력 형식 고정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;응답을 코드로 받아 처리할 거면 형식을 못 박아야 함. 형식을 안 정하면 매번 다른 모양으로 와서 파싱이 깨짐.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// 형식 지정 없음 &amp;rarr; 줄글로 옴, 파싱 불가

// 형식 고정 &amp;rarr; 항상 같은 구조
아래 리뷰를 분석.
다음 JSON 형식으로만 답하고 다른 말은 붙이지 마라.
{&quot;sentiment&quot;: &quot;긍정|부정|중립&quot;, &quot;score&quot;: 1~5, &quot;keywords&quot;: []}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;※ &quot;다른 말은 붙이지 마라&quot;가 중요함. 안 그러면 앞에 &quot;네, 분석해드리겠습니다&quot; 같은 군더더기가 붙어서 JSON 파싱이 깨짐.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;형식을 더 확실히 지키게 하려면 프롬프트로만 부탁하지 말고 두 가지를 같이 씀. 1편에서 다룬 &lt;b&gt;temperature를 낮춰&lt;/b&gt; 무작위성을 줄이고, 모델이 지원하면 &lt;b&gt;JSON 모드나 structured output&lt;/b&gt;(스키마 강제) 기능을 켜는 것. 프롬프트 문장 하나로 형식을 비는 것보다 훨씬 안정적임.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 예시 제공 (Few-shot)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설명보다 &lt;b&gt;예시 한두 개&lt;/b&gt;가 훨씬 강력함. 원하는 입출력 쌍을 보여주면 모델이 그 패턴을 그대로 따라함.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;// 입력 &amp;rarr; 출력 예시를 먼저 보여줌 (Few-shot)
다음 형식으로 회사명을 정규화해라.

입력: &quot;삼성전자(주)&quot; &amp;rarr; 출력: 삼성전자
입력: &quot;(주)카카오&quot; &amp;rarr; 출력: 카카오
입력: &quot;네이버 주식회사&quot; &amp;rarr; 출력: ?&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예시가 0개면 zero-shot, 1개 이상이면 few-shot임. 모델은 예시의 입력&amp;rarr;출력 쌍에서 형식과 규칙을 거꾸로 추론해 흉내 내므로, 예시의 형태가 일관될수록 정확도가 올라감. 분류&amp;middot;포맷 변환&amp;middot;추출처럼 정답 모양이 정해진 작업에서 특히 효과가 큼.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 예시 나열만으로 안 되는 영역이 있음. 여러 단계를 거쳐야 하는 추론&amp;middot;계산 문제는 예시를 줘도 자주 틀리는데, 이때 필요한 게 다음 패턴인 단계적 사고 유도임.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. 단계적 사고 유도&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;복잡한 추론은 &lt;b&gt;&quot;바로 답하지 말고 단계적으로 생각하라&quot;&lt;/b&gt;를 넣으면 정답률이 올라감. 모델이 중간 과정을 거치며 실수를 줄이기 때문.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;// 바로 답 요구 &amp;rarr; 계산 실수 잦음
이 주문들 총 할인액은?

// 단계 유도 &amp;rarr; 과정을 거쳐 정확해짐
각 주문의 할인액을 하나씩 계산한 뒤, 마지막에 합계를 내라.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;효과의 이유는 단순함. 답을 한 번에 뱉으면 중간 계산을 건너뛰다 틀리는데, 과정을 글로 쓰게 하면 각 단계가 다음 단계의 근거가 되어 오류가 누적되기 전에 드러남.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고로 o1 같은 추론 특화 모델은 이 과정을 내부적으로 이미 수행하므로, 이 기법은 일반 모델에서 특히 효과적임.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 입력 데이터와 지시를 섞지 말고 &lt;b&gt;구분자로 분리&lt;/b&gt;하면 어디까지가 데이터인지 명확해짐.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;아래 ``` 안의 텍스트만 번역해라. 지시문은 번역하지 마라.
```
{사용자 입력}
```&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모델을 바꾸기 전에 프롬프트부터 손보는 게 먼저. 같은 모델에서도 위 5가지로 체감 품질이 크게 달라짐.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>AI</category>
      <author>OSC131</author>
      <guid isPermaLink="true">https://osc131.tistory.com/220</guid>
      <comments>https://osc131.tistory.com/220#entry220comment</comments>
      <pubDate>Tue, 23 Jun 2026 00:11:41 +0900</pubDate>
    </item>
    <item>
      <title>[Spring] @Value 주입이 null인 경우</title>
      <link>https://osc131.tistory.com/218</link>
      <description>&lt;h2 style=&quot;text-align: center;&quot;&gt;[Spring] @Value 주입이 null인 경우&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;환경 : &lt;/strong&gt;Spring Boot 3.x, Kotlin 1.9&lt;/p&gt;

&lt;br&gt;

&lt;hr&gt;

&lt;h3&gt;1. 원인&lt;/h3&gt;

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

&lt;hr&gt;

&lt;br&gt;

&lt;h3&gt;2. 해결 방법&lt;/h3&gt;

&lt;h4&gt;2.1) static 필드 금지 — 생성자 주입 사용&lt;/h4&gt;

&lt;p&gt;Spring은 인스턴스 필드에만 의존성 주입 가능. &lt;code&gt;static&lt;/code&gt; 필드는 클래스 레벨이라 주입 안 됨&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;// 동작 안 함
@Component
class AppConfig {
    companion object {
        @Value(&quot;\${app.name}&quot;)
        lateinit var appName: String // null
    }
}

// 정상
@Component
class AppConfig(
    @Value(&quot;\${app.name}&quot;) val appName: String
)&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;어쩔 수 없이 &lt;code&gt;static&lt;/code&gt;처럼 써야 하면 setter trick&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;@Component
class AppConfig {
    companion object {
        lateinit var appName: String
            private set
    }

    @Value(&quot;\${app.name}&quot;)
    fun setAppNameStatic(value: String) {
        appName = value
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;하지만 위 방식보다는 &lt;code&gt;@ConfigurationProperties&lt;/code&gt;로 객체로 관리하는 게 더 깔끔&lt;/p&gt;

&lt;br&gt;

&lt;h4&gt;2.2) new 키워드 금지 — 빈으로 등록&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;new&lt;/code&gt;로 만든 객체는 Spring 컨테이너 밖이라 &lt;code&gt;@Value&lt;/code&gt; 동작 안 함&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;// 동작 안 함
class EmailService {
    @Value(&quot;\${mail.from}&quot;)
    lateinit var from: String
}

@Service
class NotificationService {
    private val emailService = EmailService() // new와 동일 → @Value 무시
}&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code&gt;// 정상 — 빈으로 등록
@Component
class EmailService(
    @Value(&quot;\${mail.from}&quot;) val from: String
)

@Service
class NotificationService(
    private val emailService: EmailService // DI
)&lt;/code&gt;&lt;/pre&gt;

&lt;br&gt;

&lt;h4&gt;2.3) 초기화 순서 문제 — @PostConstruct에서 사용&lt;/h4&gt;

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

&lt;pre&gt;&lt;code&gt;// 동작 안 함
@Component
class AppConfig {
    @Value(&quot;\${app.name}&quot;)
    lateinit var appName: String

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

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

    lateinit var fullName: String

    @PostConstruct
    fun init() {
        fullName = &quot;App: $appName&quot; // 주입 완료 후 실행
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;br&gt;

&lt;h4&gt;2.4) property 키 확인 — default 값 설정&lt;/h4&gt;

&lt;p&gt;오타나 키 누락 시 기본값으로 방어&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;// 키 오타나 누락 시 예외 발생
@Value(&quot;\${app.nmae}&quot;) // 오타
lateinit var appName: String

// default 값 사용
@Value(&quot;\${app.name:DefaultApp}&quot;)
lateinit var appName: String&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;숫자나 boolean도 동일&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;@Value(&quot;\${app.timeout:30}&quot;)
var timeout: Int = 0

@Value(&quot;\${app.enabled:true}&quot;)
var enabled: Boolean = false&lt;/code&gt;&lt;/pre&gt;

&lt;br&gt;

&lt;h4&gt;2.5) SpEL 표현식 오류&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;#{...}&lt;/code&gt;는 SpEL, &lt;code&gt;${...}&lt;/code&gt;는 property placeholder. 헷갈리지 않기&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;// property 값
@Value(&quot;\${app.name}&quot;)
lateinit var appName: String

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

// property를 SpEL에서 사용
@Value(&quot;#{\${app.timeout} * 1000}&quot;)
var timeoutMs: Long = 0&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;SpEL 문법 오류 시 앱 시작 자체가 실패하므로 쉽게 발견됨&lt;/p&gt;

&lt;br&gt;

&lt;h4&gt;2.6) @ConfigurationProperties 권장&lt;/h4&gt;

&lt;p&gt;여러 property를 다룰 때는 &lt;code&gt;@Value&lt;/code&gt;보다 &lt;code&gt;@ConfigurationProperties&lt;/code&gt;가 안전&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;// application.yml
app:
  name: MyApp
  timeout: 30
  mail:
    from: no-reply@example.com
    host: smtp.example.com&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code&gt;@ConfigurationProperties(prefix = &quot;app&quot;)
@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&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code&gt;@Service
class NotificationService(
    private val appProperties: AppProperties
) {
    fun send() {
        println(&quot;앱: ${appProperties.name}, 발신: ${appProperties.mail.from}&quot;)
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;타입 안전성 + IDE 자동완성 + 검증 통합 가능&lt;/p&gt;

&lt;hr&gt;

&lt;br&gt;

&lt;h3&gt;3. 디버깅&lt;/h3&gt;

&lt;h4&gt;3.1) Environment에서 직접 확인&lt;/h4&gt;

&lt;p&gt;property가 실제로 로드됐는지 확인&lt;/p&gt;

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

&lt;br&gt;

&lt;h4&gt;3.2) default 값으로 누락 확인&lt;/h4&gt;

&lt;pre&gt;&lt;code&gt;@Value(&quot;\${app.name:NOT_FOUND}&quot;)
lateinit var appName: String

@PostConstruct
fun check() {
    if (appName == &quot;NOT_FOUND&quot;) {
        println(&quot;app.name property 누락&quot;)
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;br&gt;

&lt;h4&gt;3.3) 주입 시점 로그&lt;/h4&gt;

&lt;pre&gt;&lt;code&gt;@Component
class AppConfig(
    @Value(&quot;\${app.name}&quot;) val appName: String
) {
    init {
        println(&quot;생성자 시점 appName: $appName&quot;)
    }

    @PostConstruct
    fun postConstruct() {
        println(&quot;@PostConstruct 시점 appName: $appName&quot;)
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;code&gt;init&lt;/code&gt;과 &lt;code&gt;@PostConstruct&lt;/code&gt; 모두에서 찍히면 정상. &lt;code&gt;init&lt;/code&gt;에서만 찍히고 값이 이상하면 주입 실패&lt;/p&gt;

&lt;hr&gt;

&lt;p&gt;※ 생성자 주입 + &lt;code&gt;@ConfigurationProperties&lt;/code&gt;가 가장 안전. &lt;code&gt;@Value&lt;/code&gt;는 간단한 값 하나만 쓸 때 사용&lt;/p&gt;

&lt;br&gt;</description>
      <category>TroubleShooting</category>
      <author>OSC131</author>
      <guid isPermaLink="true">https://osc131.tistory.com/218</guid>
      <comments>https://osc131.tistory.com/218#entry218comment</comments>
      <pubDate>Tue, 26 May 2026 23:20:25 +0900</pubDate>
    </item>
    <item>
      <title>[Spring] @Scheduled 멀티 인스턴스 중복 실행</title>
      <link>https://osc131.tistory.com/217</link>
      <description>&lt;h2 style=&quot;text-align: center;&quot;&gt;[Spring] @Scheduled 멀티 인스턴스 중복 실행&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;환경 : &lt;/strong&gt;Spring Boot 3.x, Kotlin 1.9, Kubernetes&lt;/p&gt;

&lt;br&gt;

&lt;hr&gt;

&lt;h3&gt;1. 원인&lt;/h3&gt;

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

&lt;hr&gt;

&lt;br&gt;

&lt;h3&gt;2. 해결 방법&lt;/h3&gt;

&lt;h4&gt;2.1) ShedLock — 분산 락 기반 스케줄 중복 방지&lt;/h4&gt;

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

&lt;pre&gt;&lt;code&gt;// build.gradle.kts
dependencies {
    implementation(&quot;org.springframework.boot:spring-boot-starter-data-mongodb&quot;)
    implementation(&quot;net.javacrumbs.shedlock:shedlock-spring:5.9.1&quot;)
    implementation(&quot;net.javacrumbs.shedlock:shedlock-provider-mongo:5.9.1&quot;)
}&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code&gt;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 = &quot;10m&quot;)
class ScheduleConfig

@Bean
fun lockProvider(mongoClient: MongoClient): LockProvider {
    return MongoLockProvider(mongoClient.getDatabase(&quot;mydb&quot;))
}&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code&gt;@Component
class ScheduledJob {
    @Scheduled(cron = &quot;0 0 * * * *&quot;) // 매시 정각
    @SchedulerLock(
        name = &quot;dailyReport&quot;,
        lockAtMostFor = &quot;5m&quot;, // 최대 5분 후 락 자동 해제
        lockAtLeastFor = &quot;1m&quot;  // 최소 1분간 락 유지
    )
    fun generateDailyReport() {
        println(&quot;[${LocalDateTime.now()}] 리포트 생성 시작 (pod: ${System.getenv(&quot;HOSTNAME&quot;)})&quot;)
        // 실제 배치 로직
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;code&gt;lockAtMostFor&lt;/code&gt; — 작업이 비정상 종료되도 락이 영원히 안 풀리지 않도록 최대 시간 설정&lt;/p&gt;

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

&lt;br&gt;

&lt;h4&gt;2.2) ShedLock with JDBC&lt;/h4&gt;

&lt;p&gt;MongoDB 대신 DB 사용하는 경우&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;// build.gradle.kts
dependencies {
    implementation(&quot;net.javacrumbs.shedlock:shedlock-spring:5.9.1&quot;)
    implementation(&quot;net.javacrumbs.shedlock:shedlock-provider-jdbc-template:5.9.1&quot;)
}&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code&gt;@Bean
fun lockProvider(dataSource: DataSource): LockProvider {
    return JdbcTemplateLockProvider(
        JdbcTemplateLockProvider.Configuration.builder()
            .withJdbcTemplate(JdbcTemplate(dataSource))
            .usingDbTime()
            .build()
    )
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;DB에 &lt;code&gt;shedlock&lt;/code&gt; 테이블 생성 필요&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;-- 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)
);&lt;/code&gt;&lt;/pre&gt;

&lt;br&gt;

&lt;h4&gt;2.3) ShedLock with Redis&lt;/h4&gt;

&lt;pre&gt;&lt;code&gt;// build.gradle.kts
dependencies {
    implementation(&quot;net.javacrumbs.shedlock:shedlock-spring:5.9.1&quot;)
    implementation(&quot;net.javacrumbs.shedlock:shedlock-provider-redis-spring:5.9.1&quot;)
    implementation(&quot;org.springframework.boot:spring-boot-starter-data-redis&quot;)
}&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code&gt;@Bean
fun lockProvider(connectionFactory: RedisConnectionFactory): LockProvider {
    return RedisLockProvider(connectionFactory)
}&lt;/code&gt;&lt;/pre&gt;

&lt;br&gt;

&lt;h4&gt;2.4) Quartz Cluster 모드&lt;/h4&gt;

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

&lt;pre&gt;&lt;code&gt;// build.gradle.kts
implementation(&quot;org.springframework.boot:spring-boot-starter-quartz&quot;)&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code&gt;# 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&lt;/code&gt;&lt;/pre&gt;

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

&lt;br&gt;

&lt;h4&gt;2.5) Leader Election 패턴&lt;/h4&gt;

&lt;p&gt;Kubernetes 환경에서는 한 pod만 leader로 선출해서 스케줄 실행&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;// build.gradle.kts
implementation(&quot;org.springframework.integration:spring-integration-zookeeper&quot;)&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code&gt;@Configuration
class LeaderConfig {
    @Bean
    fun leaderInitiator(curatorFramework: CuratorFramework): LeaderInitiator {
        return LeaderInitiator(curatorFramework, Candidate(&quot;schedulerLeader&quot;, Runnable {
            println(&quot;리더 선출됨&quot;)
        }))
    }
}

@Component
class ScheduledJob(private val leaderInitiator: LeaderInitiator) {
    @Scheduled(fixedRate = 60000)
    fun task() {
        if (leaderInitiator.context.isLeader) {
            println(&quot;리더만 실행&quot;)
        }
    }
}&lt;/code&gt;&lt;/pre&gt;

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

&lt;br&gt;

&lt;h4&gt;2.6) Profile 격리 — 단일 인스턴스 전용&lt;/h4&gt;

&lt;p&gt;스케줄 작업을 특정 profile에서만 활성화&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;@Component
@Profile(&quot;scheduler&quot;)
class ScheduledJob {
    @Scheduled(cron = &quot;0 0 * * * *&quot;)
    fun generateReport() { ... }
}&lt;/code&gt;&lt;/pre&gt;

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

&lt;p&gt;간단하지만 해당 pod가 죽으면 스케줄 중단됨 — 고가용성 낮음&lt;/p&gt;

&lt;hr&gt;

&lt;br&gt;

&lt;h3&gt;3. 디버깅&lt;/h3&gt;

&lt;h4&gt;3.1) 로그에 hostname/pod 이름 출력&lt;/h4&gt;

&lt;p&gt;여러 인스턴스 중 어디서 실행됐는지 확인&lt;/p&gt;

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

&lt;p&gt;ShedLock 적용 전에는 pod 개수만큼 로그 찍힘. 적용 후에는 하나만 찍혀야 정상&lt;/p&gt;

&lt;br&gt;

&lt;h4&gt;3.2) ShedLock 락 상태 확인&lt;/h4&gt;

&lt;p&gt;MongoDB 예시&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;db.shedLock.find()&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;JDBC 예시&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;SELECT * FROM shedlock;&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;code&gt;lock_until&lt;/code&gt; 시간이 현재보다 미래면 락 유지 중&lt;/p&gt;

&lt;hr&gt;

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

&lt;br&gt;</description>
      <category>TroubleShooting</category>
      <author>OSC131</author>
      <guid isPermaLink="true">https://osc131.tistory.com/217</guid>
      <comments>https://osc131.tistory.com/217#entry217comment</comments>
      <pubDate>Wed, 6 May 2026 23:58:18 +0900</pubDate>
    </item>
    <item>
      <title>[Spring] @Async 동작하지 않는 경우</title>
      <link>https://osc131.tistory.com/216</link>
      <description>&lt;h2 style=&quot;text-align: center;&quot;&gt;[Spring] @Async 동작하지 않는 경우&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;환경 : &lt;/strong&gt;Spring Boot 3.x, Kotlin 1.9&lt;/p&gt;

&lt;br&gt;

&lt;hr&gt;

&lt;h3&gt;1. 원인&lt;/h3&gt;

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

&lt;hr&gt;

&lt;br&gt;

&lt;h3&gt;2. 해결 방법&lt;/h3&gt;

&lt;h4&gt;2.1) @EnableAsync 추가&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;@Transactional&lt;/code&gt;과 마찬가지로 명시적으로 활성화 필요&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;@EnableAsync
@SpringBootApplication
class Application

fun main(args: Array&amp;lt;String&amp;gt;) {
    runApplication&amp;lt;Application&amp;gt;(*args)
}&lt;/code&gt;&lt;/pre&gt;

&lt;br&gt;

&lt;h4&gt;2.2) 자기호출 회피 — 별도 빈으로 분리&lt;/h4&gt;

&lt;p&gt;Spring AOP 프록시 기반이라 같은 클래스 내부 호출 시 &lt;code&gt;@Async&lt;/code&gt; 무시됨&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;// 동작 안 함
@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(&quot;스레드: ${Thread.currentThread().name}&quot;)
        emailService.send(order.email, &quot;주문 완료&quot;)
    }
}

// 정상 — 별도 빈 분리
@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(&quot;스레드: ${Thread.currentThread().name}&quot;)
        emailService.send(order.email, &quot;주문 완료&quot;)
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;br&gt;

&lt;h4&gt;2.3) public 메서드 사용&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;private&lt;/code&gt;, &lt;code&gt;protected&lt;/code&gt; 메서드는 프록시가 가로채지 못함&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;// 동작 안 함
@Service
class NotificationService {
    @Async
    private fun send(message: String) { ... } // private 무시
}

// 정상
@Service
class NotificationService {
    @Async
    fun send(message: String) { ... }
}&lt;/code&gt;&lt;/pre&gt;

&lt;br&gt;

&lt;h4&gt;2.4) 반환 타입 — CompletableFuture 사용&lt;/h4&gt;

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

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

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

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

&lt;br&gt;

&lt;h4&gt;2.5) TaskExecutor 분리 — 데드락 방지&lt;/h4&gt;

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

&lt;pre&gt;&lt;code&gt;@Configuration
@EnableAsync
class AsyncConfig {
    @Bean(name = [&quot;taskExecutor&quot;])
    fun taskExecutor(): Executor {
        val executor = ThreadPoolTaskExecutor()
        executor.corePoolSize = 5
        executor.maxPoolSize = 10
        executor.queueCapacity = 100
        executor.setThreadNamePrefix(&quot;async-&quot;)
        executor.initialize()
        return executor
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;여러 Executor를 나눠서 사용 가능&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;@Configuration
@EnableAsync
class AsyncConfig {
    @Bean(name = [&quot;emailExecutor&quot;])
    fun emailExecutor(): Executor {
        val executor = ThreadPoolTaskExecutor()
        executor.corePoolSize = 3
        executor.maxPoolSize = 5
        executor.setThreadNamePrefix(&quot;email-&quot;)
        executor.initialize()
        return executor
    }

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

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

    @Async(&quot;reportExecutor&quot;)
    fun generateReport(userId: Long) { ... }
}&lt;/code&gt;&lt;/pre&gt;

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

&lt;hr&gt;

&lt;br&gt;

&lt;h3&gt;3. 디버깅&lt;/h3&gt;

&lt;h4&gt;3.1) 스레드 이름 로그&lt;/h4&gt;

&lt;p&gt;비동기가 제대로 동작하는지 확인하려면 스레드 이름 출력&lt;/p&gt;

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

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

&lt;p&gt;같은 스레드 이름이 나오면 &lt;code&gt;@Async&lt;/code&gt;가 무시된 것&lt;/p&gt;

&lt;br&gt;

&lt;h4&gt;3.2) 로그 레벨 설정&lt;/h4&gt;

&lt;pre&gt;&lt;code&gt;# application.yml
logging:
  level:
    org.springframework.aop.interceptor.AsyncExecutionInterceptor: TRACE&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;정상 동작 시 출력&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;Async execution of method [...] started
Async execution of method [...] finished&lt;/code&gt;&lt;/pre&gt;

&lt;hr&gt;

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

&lt;br&gt;</description>
      <category>TroubleShooting</category>
      <author>OSC131</author>
      <guid isPermaLink="true">https://osc131.tistory.com/216</guid>
      <comments>https://osc131.tistory.com/216#entry216comment</comments>
      <pubDate>Wed, 29 Apr 2026 12:43:50 +0900</pubDate>
    </item>
    <item>
      <title>[Spring] @Transactional 동작하지 않는 경우</title>
      <link>https://osc131.tistory.com/215</link>
      <description>&lt;h2 style=&quot;text-align: center;&quot;&gt;[Spring] @Transactional 동작하지 않는 경우&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;환경 : &lt;/strong&gt;Spring Boot 3.x, Kotlin 1.9, JPA&lt;/p&gt;

&lt;br&gt;

&lt;hr&gt;

&lt;h3&gt;1. 원인&lt;/h3&gt;

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

&lt;hr&gt;

&lt;br&gt;

&lt;h3&gt;2. 해결 방법&lt;/h3&gt;

&lt;h4&gt;2.1) public 메서드로 선언&lt;/h4&gt;

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

&lt;pre&gt;&lt;code&gt;// 동작 안 함
@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)
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;br&gt;

&lt;h4&gt;2.2) 자기호출 회피 — 별도 빈으로 분리&lt;/h4&gt;

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

&lt;pre&gt;&lt;code&gt;// 동작 안 함
@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))
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;br&gt;

&lt;h4&gt;2.3) checked exception 롤백 — rollbackFor 명시&lt;/h4&gt;

&lt;p&gt;기본 롤백 대상은 &lt;code&gt;RuntimeException&lt;/code&gt;과 &lt;code&gt;Error&lt;/code&gt;만. checked exception(예: &lt;code&gt;IOException&lt;/code&gt;, 사용자 정의 &lt;code&gt;BusinessException : Exception&lt;/code&gt;)은 던져도 커밋됨&lt;/p&gt;

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

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

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

&lt;br&gt;

&lt;h4&gt;2.4) propagation 옵션 정리&lt;/h4&gt;

&lt;p&gt;가장 자주 헷갈리는 3가지&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;// 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) { ... }&lt;/code&gt;&lt;/pre&gt;

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

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

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    fun writeLog() { ... }
}&lt;/code&gt;&lt;/pre&gt;

&lt;hr&gt;

&lt;br&gt;

&lt;h3&gt;3. 트랜잭션 적용 여부 디버깅&lt;/h3&gt;

&lt;p&gt;의심될 때는 코드와 로그로 직접 확인&lt;/p&gt;

&lt;h4&gt;3.1) 코드로 확인&lt;/h4&gt;

&lt;pre&gt;&lt;code&gt;import org.springframework.transaction.support.TransactionSynchronizationManager

@Service
class OrderService {
    @Transactional
    fun process() {
        val active = TransactionSynchronizationManager.isActualTransactionActive()
        val name = TransactionSynchronizationManager.getCurrentTransactionName()
        println(&quot;트랜잭션 활성: $active, 이름: $name&quot;)
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;code&gt;active = false&lt;/code&gt; 면 어드바이스가 적용되지 않은 것&lt;/p&gt;

&lt;br&gt;

&lt;h4&gt;3.2) 로그로 확인&lt;/h4&gt;

&lt;pre&gt;&lt;code&gt;# application.yml
logging:
  level:
    org.springframework.transaction.interceptor: TRACE&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;정상 적용 시 출력&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;Getting transaction for [com.example.OrderService.createOrder]
Completing transaction for [com.example.OrderService.createOrder]&lt;/code&gt;&lt;/pre&gt;

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

&lt;hr&gt;

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

&lt;br&gt;</description>
      <category>Java</category>
      <author>OSC131</author>
      <guid isPermaLink="true">https://osc131.tistory.com/215</guid>
      <comments>https://osc131.tistory.com/215#entry215comment</comments>
      <pubDate>Fri, 24 Apr 2026 02:49:41 +0900</pubDate>
    </item>
    <item>
      <title>[JPA] save()가 INSERT 대신 SELECT를 날리는 경우</title>
      <link>https://osc131.tistory.com/214</link>
      <description>&lt;h2 style=&quot;text-align: center;&quot; data-ke-size=&quot;size26&quot;&gt;[JPA] save()가 INSERT 대신 SELECT를 날리는 경우&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;환경 : &lt;/b&gt;Spring Boot 3.x, JPA, Hibernate, Kotlin 1.9&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;증상&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;// memberRepository.save(member) 호출 시
// INSERT가 아니라 SELECT 쿼리가 먼저 실행됨
Hibernate: select m1_0.id ... from member m1_0 where m1_0.id=?
Hibernate: insert into member ...&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 원인&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Data JPA의 &lt;code&gt;save()&lt;/code&gt;는 내부적으로 &lt;code&gt;persist&lt;/code&gt;와 &lt;code&gt;merge&lt;/code&gt;를 구분함&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;// SimpleJpaRepository 내부 구현
@Transactional
override fun &amp;lt;S : T&amp;gt; save(entity: S): S {
    if (entityInformation.isNew(entity)) {
        em.persist(entity) // INSERT
        return entity
    } else {
        return em.merge(entity) // SELECT + INSERT 또는 UPDATE
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;isNew()&lt;/code&gt; 판단 기준:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;ID가 &lt;code&gt;null&lt;/code&gt;이면 새 엔티티 &amp;rarr; &lt;code&gt;persist&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;ID가 &lt;code&gt;null&lt;/code&gt;이 아니면 기존 엔티티 &amp;rarr; &lt;code&gt;merge&lt;/code&gt; (SELECT 먼저 실행)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 ID를 직접 할당하는 경우. ID가 이미 세팅되어 있으니 JPA가 기존 엔티티로 판단해서 &lt;code&gt;merge&lt;/code&gt;를 호출함&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// ID 직접 할당 &amp;rarr; isNew() = false &amp;rarr; merge &amp;rarr; SELECT 발생
@Entity
class Product(
    @Id
    val id: String = UUID.randomUUID().toString(), // 이미 값이 있음
    val name: String
)&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 해결 방법&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2.1) @GeneratedValue 사용 (가장 간단)&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB에서 ID를 자동 생성하면 &lt;code&gt;save()&lt;/code&gt; 시점에 ID가 &lt;code&gt;null&lt;/code&gt;이므로 항상 &lt;code&gt;persist&lt;/code&gt;&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Entity
class Member(
    val name: String,
    val email: String,
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0
)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2.2) Persistable 인터페이스 구현&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ID를 직접 할당해야 하는 경우, &lt;code&gt;Persistable&lt;/code&gt;을 구현해서 &lt;code&gt;isNew()&lt;/code&gt; 판단 로직을 직접 제어&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Entity
class Product(
    @Id
    val id: String = UUID.randomUUID().toString(),
    val name: String,
    @CreatedDate
    var createdAt: LocalDateTime? = null
) : Persistable&amp;lt;String&amp;gt; {

    override fun getId(): String = id

    override fun isNew(): Boolean = createdAt == null
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;createdAt&lt;/code&gt;이 &lt;code&gt;null&lt;/code&gt;이면 아직 DB에 저장 안 된 것 &amp;rarr; &lt;code&gt;persist&lt;/code&gt; 호출. &lt;code&gt;@CreatedDate&lt;/code&gt;는 저장 시점에 자동 세팅되므로 한 번 저장된 엔티티는 &lt;code&gt;isNew() = false&lt;/code&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;※ &lt;code&gt;@CreatedDate&lt;/code&gt; 사용하려면 &lt;code&gt;@EnableJpaAuditing&lt;/code&gt; 필요&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@EnableJpaAuditing
@SpringBootApplication
class Application&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2.3) @Version 필드 활용&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;@Version&lt;/code&gt; 필드가 &lt;code&gt;null&lt;/code&gt;이면 새 엔티티로 판단&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Entity
class Product(
    @Id
    val id: String = UUID.randomUUID().toString(),
    val name: String,
    @Version
    val version: Long? = null // null이면 isNew() = true
)&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. persist vs merge 동작 비교&lt;/h3&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;// persist &amp;mdash; INSERT 1번
em.persist(member)
// SQL: INSERT INTO member (name, email) VALUES (?, ?)

// merge &amp;mdash; SELECT 1번 + INSERT 또는 UPDATE 1번
em.merge(member)
// SQL: SELECT ... FROM member WHERE id = ?
//      INSERT INTO member ... (새 엔티티인 경우)
//      UPDATE member SET ... (기존 엔티티인 경우)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;merge&lt;/code&gt;는 항상 SELECT가 선행됨. 대량 INSERT 시 성능 차이가 큼&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3.1) 대량 INSERT 성능 차이&lt;/h4&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 나쁜 예 &amp;mdash; ID 직접 할당 + saveAll &amp;rarr; merge 1000번 = SELECT 1000 + INSERT 1000
val products = (1..1000).map { Product(id = &quot;P-$it&quot;, name = &quot;상품$it&quot;) }
productRepository.saveAll(products)
// 총 쿼리: 2000번

// 좋은 예 &amp;mdash; Persistable 구현 후 saveAll &amp;rarr; persist 1000번
// 총 쿼리: 1000번 (SELECT 없음)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;※ 대량 INSERT 시에는 &lt;code&gt;Persistable&lt;/code&gt; 구현 + &lt;code&gt;spring.jpa.properties.hibernate.jdbc.batch_size=50&lt;/code&gt; 설정까지 하면 더 효과적&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>TroubleShooting</category>
      <author>OSC131</author>
      <guid isPermaLink="true">https://osc131.tistory.com/214</guid>
      <comments>https://osc131.tistory.com/214#entry214comment</comments>
      <pubDate>Wed, 15 Apr 2026 16:30:26 +0900</pubDate>
    </item>
    <item>
      <title>@Cacheable 캐시가 동작하지 않는 경우</title>
      <link>https://osc131.tistory.com/213</link>
      <description>&lt;h2 style=&quot;text-align: center;&quot;&gt;[Spring Boot] @Cacheable 캐시가 동작하지 않는 경우&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;환경 : &lt;/strong&gt;Spring Boot 3.x, Kotlin 1.9&lt;/p&gt;

&lt;br&gt;

&lt;hr&gt;

&lt;h3&gt;1. 원인&lt;/h3&gt;

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

&lt;hr&gt;

&lt;br&gt;

&lt;h3&gt;2. 해결 방법&lt;/h3&gt;

&lt;h4&gt;2.1) @EnableCaching 추가&lt;/h4&gt;

&lt;p&gt;Spring Boot에서 캐시를 사용하려면 명시적으로 활성화해야 함&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;@EnableCaching
@SpringBootApplication
class Application

fun main(args: Array&amp;lt;String&amp;gt;) {
    runApplication&amp;lt;Application&amp;gt;(*args)
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;의존성도 확인&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;// build.gradle.kts
dependencies {
    implementation(&quot;org.springframework.boot:spring-boot-starter-cache&quot;)
}&lt;/code&gt;&lt;/pre&gt;

&lt;br&gt;

&lt;h4&gt;2.2) 같은 클래스 내부 호출 금지&lt;/h4&gt;

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

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

    @Cacheable(&quot;members&quot;)
    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(&quot;members&quot;)
    fun getMember(id: Long): Member {
        return memberRepository.findById(id).orElseThrow()
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;br&gt;

&lt;h4&gt;2.3) 캐시 키 확인&lt;/h4&gt;

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

&lt;pre&gt;&lt;code&gt;// 나쁜 예 — request 객체가 매번 새로 생성되면 키가 매번 다름
@Cacheable(&quot;members&quot;)
fun search(request: MemberSearchRequest): List&amp;lt;Member&amp;gt; { ... }

// 좋은 예 — 명시적으로 키 지정
@Cacheable(value = [&quot;members&quot;], key = &quot;#name&quot;)
fun findByName(name: String): List&amp;lt;Member&amp;gt; { ... }

// 복합 키
@Cacheable(value = [&quot;members&quot;], key = &quot;#name + '_' + #status&quot;)
fun findByNameAndStatus(name: String, status: Status): List&amp;lt;Member&amp;gt; { ... }&lt;/code&gt;&lt;/pre&gt;

&lt;br&gt;

&lt;h4&gt;2.4) null 반환 처리&lt;/h4&gt;

&lt;p&gt;기본적으로 &lt;code&gt;null&lt;/code&gt;도 캐시에 저장됨. 하지만 캐시 구현체에 따라 다를 수 있음&lt;/p&gt;

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

&lt;hr&gt;

&lt;br&gt;

&lt;h3&gt;3. 캐시 삭제&lt;/h3&gt;

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

&lt;pre&gt;&lt;code&gt;@Service
class MemberCacheService(private val memberRepository: MemberRepository) {

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

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

    @CacheEvict(value = [&quot;members&quot;], allEntries = true)
    fun clearAllCache() {
        // 전체 캐시 삭제
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;hr&gt;

&lt;br&gt;

&lt;h3&gt;4. 캐시 구현체 선택&lt;/h3&gt;

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

&lt;h4&gt;4.1) Caffeine (로컬 캐시)&lt;/h4&gt;

&lt;pre&gt;&lt;code&gt;// build.gradle.kts
implementation(&quot;com.github.ben-manes.caffeine:caffeine&quot;)&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code&gt;# application.yml
spring:
  cache:
    type: caffeine
    caffeine:
      spec: maximumSize=500,expireAfterWrite=10m&lt;/code&gt;&lt;/pre&gt;

&lt;br&gt;

&lt;h4&gt;4.2) Redis (분산 캐시)&lt;/h4&gt;

&lt;pre&gt;&lt;code&gt;// build.gradle.kts
implementation(&quot;org.springframework.boot:spring-boot-starter-data-redis&quot;)&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code&gt;# application.yml
spring:
  cache:
    type: redis
  data:
    redis:
      host: localhost
      port: 6379&lt;/code&gt;&lt;/pre&gt;

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

&lt;br&gt;</description>
      <category>Java</category>
      <author>OSC131</author>
      <guid isPermaLink="true">https://osc131.tistory.com/213</guid>
      <comments>https://osc131.tistory.com/213#entry213comment</comments>
      <pubDate>Tue, 7 Apr 2026 01:21:40 +0900</pubDate>
    </item>
    <item>
      <title>[JPA] MultipleBagFetchException</title>
      <link>https://osc131.tistory.com/212</link>
      <description>&lt;h2 style=&quot;text-align: center;&quot;&gt;[JPA] MultipleBagFetchException 해결&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;환경 : &lt;/strong&gt;Spring Boot 3.x, JPA, Hibernate&lt;/p&gt;

&lt;br&gt;

&lt;hr&gt;

&lt;p&gt;&lt;strong&gt;에러 메시지&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;org.hibernate.loader.MultipleBagFetchException:
  cannot simultaneously fetch multiple bags:
  [com.example.Member.orders, com.example.Member.addresses]&lt;/code&gt;&lt;/pre&gt;

&lt;hr&gt;

&lt;br&gt;

&lt;h3&gt;1. 원인&lt;/h3&gt;

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

&lt;pre&gt;&lt;code&gt;@Entity
class Member(
    @OneToMany(mappedBy = &quot;member&quot;)
    val orders: List&amp;lt;Order&amp;gt; = mutableListOf(),

    @OneToMany(mappedBy = &quot;member&quot;)
    val addresses: List&amp;lt;Address&amp;gt; = mutableListOf()
)

// 이 쿼리에서 터짐
@Query(&quot;SELECT m FROM Member m JOIN FETCH m.orders JOIN FETCH m.addresses&quot;)
fun findAllWithOrdersAndAddresses(): List&amp;lt;Member&amp;gt;&lt;/code&gt;&lt;/pre&gt;

&lt;hr&gt;

&lt;br&gt;

&lt;h3&gt;2. 해결 방법&lt;/h3&gt;

&lt;h4&gt;2.1) List를 Set으로 변경&lt;/h4&gt;

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

&lt;pre&gt;&lt;code&gt;@Entity
class Member(
    @OneToMany(mappedBy = &quot;member&quot;)
    val orders: Set&amp;lt;Order&amp;gt; = mutableSetOf(),

    @OneToMany(mappedBy = &quot;member&quot;)
    val addresses: Set&amp;lt;Address&amp;gt; = mutableSetOf()
)&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;※ 순서가 필요하면 &lt;code&gt;LinkedHashSet&lt;/code&gt; 사용&lt;/p&gt;

&lt;br&gt;

&lt;h4&gt;2.2) @BatchSize로 분리 조회&lt;/h4&gt;

&lt;p&gt;fetch join 대신 &lt;code&gt;@BatchSize&lt;/code&gt;로 IN 절 조회. 쿼리 2~3번으로 해결&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;@Entity
class Member(
    @OneToMany(mappedBy = &quot;member&quot;)
    @BatchSize(size = 100)
    val orders: List&amp;lt;Order&amp;gt; = mutableListOf(),

    @OneToMany(mappedBy = &quot;member&quot;)
    @BatchSize(size = 100)
    val addresses: List&amp;lt;Address&amp;gt; = mutableListOf()
)&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code&gt;# 또는 전역 설정
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100&lt;/code&gt;&lt;/pre&gt;

&lt;br&gt;

&lt;h4&gt;2.3) fetch join을 나눠서 실행&lt;/h4&gt;

&lt;p&gt;한 번에 하나의 컬렉션만 fetch join하고 나머지는 Hibernate 초기화에 맡기기&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;@Query(&quot;SELECT m FROM Member m JOIN FETCH m.orders&quot;)
fun findAllWithOrders(): List&amp;lt;Member&amp;gt;

// addresses는 @BatchSize 또는 별도 조회로 처리&lt;/code&gt;&lt;/pre&gt;

&lt;br&gt;</description>
      <category>TroubleShooting</category>
      <author>OSC131</author>
      <guid isPermaLink="true">https://osc131.tistory.com/212</guid>
      <comments>https://osc131.tistory.com/212#entry212comment</comments>
      <pubDate>Sat, 4 Apr 2026 00:34:44 +0900</pubDate>
    </item>
    <item>
      <title>[Spring Boot] @Valid 검증이 동작하지 않는 경우</title>
      <link>https://osc131.tistory.com/211</link>
      <description>&lt;h2 style=&quot;text-align: center;&quot; data-ke-size=&quot;size26&quot;&gt;[Spring Boot] @Valid 검증이 동작하지 않는 경우&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;환경 : &lt;/b&gt;Spring Boot 3.x, Kotlin 1.9&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;에러 메시지&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;// @Valid를 붙였는데 유효하지 않은 값이 그대로 통과됨
// MethodArgumentNotValidException이 발생하지 않음&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 원인&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot 3.x부터 &lt;code&gt;spring-boot-starter-validation&lt;/code&gt; 의존성이 기본 포함 안 됨. 별도 추가 필요&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또는 아래 케이스에서도 동작하지 않음&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;@Valid&lt;/code&gt; 어노테이션 위치가 잘못된 경우&lt;/li&gt;
&lt;li&gt;Kotlin &lt;code&gt;data class&lt;/code&gt; 필드에 어노테이션이 안 먹는 경우&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@Validated&lt;/code&gt;와 &lt;code&gt;@Valid&lt;/code&gt; 혼동&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 해결 방법&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2.1) 의존성 추가&lt;/h4&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;// build.gradle.kts
dependencies {
    implementation(&quot;org.springframework.boot:spring-boot-starter-validation&quot;)
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2.2) @Valid 위치 확인&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Controller 메서드의 &lt;code&gt;@RequestBody&lt;/code&gt; 앞에 &lt;code&gt;@Valid&lt;/code&gt; 선언&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// 동작 안 함
@PostMapping
fun save(@RequestBody request: MemberRequest) // @Valid 누락

// 정상
@PostMapping
fun save(@Valid @RequestBody request: MemberRequest)&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2.3) Kotlin data class에서 field 타겟 지정&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Kotlin은 생성자 파라미터와 필드가 분리되어 있어서 어노테이션이 필드에 안 붙을 수 있음. &lt;code&gt;@field:&lt;/code&gt; 사용 타겟 지정 필요&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// 동작 안 함
data class MemberRequest(
    @NotBlank
    val name: String,
    @Email
    val email: String
)

// 정상
data class MemberRequest(
    @field:NotBlank
    val name: String,
    @field:Email
    val email: String
)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;※ Kotlin에서는 &lt;code&gt;@field:&lt;/code&gt;를 안 붙이면 어노테이션이 생성자 파라미터에만 적용되고 필드에는 적용 안 됨&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2.4) @Validated로 클래스 레벨 검증&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;@RequestParam&lt;/code&gt;이나 &lt;code&gt;@PathVariable&lt;/code&gt; 검증은 &lt;code&gt;@Valid&lt;/code&gt;가 아니라 클래스에 &lt;code&gt;@Validated&lt;/code&gt; 필요&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Validated // 클래스 레벨에 선언
@RestController
class MemberController(private val memberService: MemberService) {

    @GetMapping(&quot;/{id}&quot;)
    fun getById(@PathVariable @Min(1) id: Long): Member {
        return memberService.getById(id)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 에러 응답 커스터마이징&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 응답은 가독성이 떨어짐. &lt;code&gt;@ExceptionHandler&lt;/code&gt;로 포맷 정리&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;@RestControllerAdvice
class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException::class)
    fun handleValidation(e: MethodArgumentNotValidException): ResponseEntity&amp;lt;Map&amp;lt;String, String&amp;gt;&amp;gt; {
        val errors = e.bindingResult.fieldErrors.associate {
            it.field to (it.defaultMessage ?: &quot;&quot;)
        }
        return ResponseEntity.badRequest().body(errors)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;응답 예시&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
    &quot;name&quot;: &quot;must not be blank&quot;,
    &quot;email&quot;: &quot;must be a well-formed email address&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Kotlin</category>
      <author>OSC131</author>
      <guid isPermaLink="true">https://osc131.tistory.com/211</guid>
      <comments>https://osc131.tistory.com/211#entry211comment</comments>
      <pubDate>Fri, 27 Mar 2026 00:55:35 +0900</pubDate>
    </item>
    <item>
      <title>Kotlin + Spring Boot 시작하기</title>
      <link>https://osc131.tistory.com/210</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;환경 : &lt;/b&gt;Spring Boot 3.1, Kotlin 1.9, Intellij&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;목표 : &lt;/b&gt;Kotlin 기반 Spring Boot 프로젝트 생성 및 간단한 CRUD API 구현&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 코프링(Kopring)이란?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Ko&lt;/b&gt;tlin + S&lt;b&gt;pring&lt;/b&gt;의 합성어. Spring Boot 프로젝트를 Kotlin으로 개발하는 것을 의미&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Java 대비 주요 차이점&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Null 안정성 :&lt;/b&gt; Java는 런타임에 NPE 발생. Kotlin은 컴파일 타임에 체크&lt;/li&gt;
&lt;li&gt;&lt;b&gt;코드량 :&lt;/b&gt; 동일한 기능 대비 약 30~40% 감소&lt;/li&gt;
&lt;li&gt;&lt;b&gt;데이터 클래스 :&lt;/b&gt; Java는 Lombok 필요. Kotlin은 &lt;code&gt;data class&lt;/code&gt;로 기본 제공&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 프로젝트 생성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://start.spring.io&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;start.spring.io&lt;/a&gt; 에서 아래와 같이 설정&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;Project  : Gradle - Kotlin
  Language : Kotlin
  Spring Boot : 3.1.x

  Dependencies:
  - Spring Web
  - Spring Data JPA
  - H2 Database&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. build.gradle.kts 설정&lt;/h3&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;plugins {
      kotlin(&quot;jvm&quot;) version &quot;1.9.0&quot;
      kotlin(&quot;plugin.spring&quot;) version &quot;1.9.0&quot;
      kotlin(&quot;plugin.jpa&quot;) version &quot;1.9.0&quot;
      id(&quot;org.springframework.boot&quot;) version &quot;3.1.0&quot;
      id(&quot;io.spring.dependency-management&quot;) version &quot;1.1.0&quot;
  }

  dependencies {
      implementation(&quot;org.springframework.boot:spring-boot-starter-web&quot;)
      implementation(&quot;org.springframework.boot:spring-boot-starter-data-jpa&quot;)
      implementation(&quot;com.fasterxml.jackson.module:jackson-module-kotlin&quot;)
      runtimeOnly(&quot;com.h2database:h2&quot;)
  }&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;plugin.spring :&lt;/b&gt; Kotlin 클래스는 기본이 &lt;code&gt;final&lt;/code&gt;이라 Spring 프록시 생성과 충돌. 자동으로 &lt;code&gt;open&lt;/code&gt; 처리&lt;/li&gt;
&lt;li&gt;&lt;b&gt;plugin.jpa :&lt;/b&gt; JPA Entity에 필요한 기본 생성자 자동 생성&lt;/li&gt;
&lt;li&gt;&lt;b&gt;jackson-module-kotlin :&lt;/b&gt; Kotlin data class 직렬화/역직렬화 지원&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. CRUD API 구현&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;4.1) Entity&lt;/h4&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Entity
  class Member(
      val name: String,
      val email: String,
      @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
      val id: Long = 0
  )&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;4.2) Repository&lt;/h4&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;interface MemberRepository : JpaRepository&amp;lt;Member, Long&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;4.3) Service&lt;/h4&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Service
  class MemberService(
      private val memberRepository: MemberRepository
  ) {
      fun getAll(): List&amp;lt;Member&amp;gt; = memberRepository.findAll()

      fun save(name: String, email: String): Member {
          return memberRepository.save(Member(name = name, email = email))
      }
  }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생성자에 선언하는 것만으로 의존성 주입. &lt;code&gt;@Autowired&lt;/code&gt; 불필요&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;4.4) Controller&lt;/h4&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@RestController
  @RequestMapping(&quot;/members&quot;)
  class MemberController(
      private val memberService: MemberService
  ) {
      @GetMapping
      fun getAll(): List&amp;lt;Member&amp;gt; = memberService.getAll()

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

  data class MemberRequest(
      val name: String,
      val email: String
  )&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. 결과 확인&lt;/h3&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;./gradlew bootRun&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;5.1) 전체 조회&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;curl http://localhost:8080/members&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;5.2) 저장&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;scilab&quot;&gt;&lt;code&gt;curl -X POST http://localhost:8080/members \
    -H &quot;Content-Type: application/json&quot; \
    -d '{&quot;name&quot;:&quot;홍길동&quot;,&quot;email&quot;:&quot;hong@test.com&quot;}'&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;5.3) 저장 후 전체 조회 결과&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;[{&quot;id&quot;:1,&quot;name&quot;:&quot;홍길동&quot;,&quot;email&quot;:&quot;hong@test.com&quot;}]&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Kotlin</category>
      <author>OSC131</author>
      <guid isPermaLink="true">https://osc131.tistory.com/210</guid>
      <comments>https://osc131.tistory.com/210#entry210comment</comments>
      <pubDate>Tue, 10 Mar 2026 21:30:03 +0900</pubDate>
    </item>
  </channel>
</rss>