<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>easyone.log</title>
        <link>https://velog.io/</link>
        <description>백엔드 개발자 지망 대학생</description>
        <lastBuildDate>Tue, 22 Sep 2026 11:54:14 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>easyone.log</title>
            <url>https://velog.velcdn.com/images/jayaione_ele/profile/39f48b4f-645b-4520-8b63-d5a83d9cb658/image.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. easyone.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/jayaione_ele" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[Spring] 스프링 전역 예외처리 ]]></title>
            <link>https://velog.io/@jayaione_ele/Spring-%EC%8A%A4%ED%94%84%EB%A7%81-%EC%A0%84%EC%97%AD-%EC%98%88%EC%99%B8%EC%B2%98%EB%A6%AC</link>
            <guid>https://velog.io/@jayaione_ele/Spring-%EC%8A%A4%ED%94%84%EB%A7%81-%EC%A0%84%EC%97%AD-%EC%98%88%EC%99%B8%EC%B2%98%EB%A6%AC</guid>
            <pubDate>Tue, 22 Sep 2026 11:54:14 GMT</pubDate>
            <description><![CDATA[<p>프로젝트를 많이 해보면서, 예외처리의 구조나 가장 좋은 예외처리? 방식에 대해서 연구해본 적이 없고 그냥 전역 예외처리를 최대한 해보는 것 말고는 없었던 것 같아서 정리해본 글이다.</p>
<p>조금 두서없음주의..</p>
<h3 id="자바-자체의-예외-전파는-어떻게-되는지">자바 자체의 예외 전파는 어떻게 되는지?</h3>
<ul>
<li><code>throw</code>하는 순간 <code>Throwable</code> 객체가 생기고, 네이티브 메서드 <code>fillInStackTrace()</code>가 현재 call Stack을 캡쳐한다. 여기서 많은 비용이 발생한다. <ul>
<li>Call Stack이란? 스레드 하나가 실행될 때 JVM은 스레드 전용JVM 스택이라는 메모리 영역을 할당한다. 힙이랑은 별개다. 메서드 하나당 이 위에 스택 프레임이라는걸 스택 위에 하나씩 쌓는다. </li>
<li>각 프레임 안에는 지역변수,피연산자,리턴주소가 들어있다. 정상적으로 리턴이 되어야 스택에서 pop이 된다. </li>
</ul>
</li>
<li>이런 6단계 콜스택이 있다고 하면, 여기서 throw가 발생하면 여러 프레임을 거쳐서 매칭되는 catch가 없다면 전부 버려지게 된다.그래서 최종 doDispatch() 안에 있는 try-catch로 도달한다. 그러면 콜스택을 거슬러 올라가면서 프레임이 다 버려지는 것이고, 이게 스택 언와인딩이다.<ul>
<li>예외 테이블: 정상 종료가 된다는 건 예외가 나지 않고 return이 되어야 pop이 된다는 것인데, 예외가 발생하면 다른 경로를 탄다. 각 메서드는 컴파일될 때 예외 테이블을 바이트코드에 같이 가지고 있다. (시작위치, 끝 위치, 핸들러 위치, 잡을 예외 타입) 이런 목록으로 구성되어 있다. </li>
<li>catch를 못하면 pop -&gt; 상위 프레임의 예외테이블 확인: throw를 어떤 메서드에서 했는데, JVM이 예외 잡을 구간이 없다고 판단하면 해당 프레임에 있는 코드는 실행되지 않고 pop된다. 그러면 해당 메서드 호출했던 곳으로 돌아가서 해당 프레임의 예외 테이블에 걸리는지 확인한다. 이렇게 프레임을 하나씩 뜯어내면서 거슬러 올라가는 과정이 언와인딩이다. <pre><code class="language-text">DispatcherServlet.doDispatch()  -&gt;  내부에 try catch 존재         
└─ HandlerAdapter.handle()
 └─ 리플렉션으로 컨트롤러 메서드 invoke
      └─ @Controller의 getMember()
           └─ service.findMember()
                └─ repository.findById()
                     └─ throw new RuntimeException(&quot;잘못된 사용자&quot;)</code></pre>
</li>
</ul>
</li>
<li>매칭되는 <code>catch</code>가 없으면 예외는 콜스택을 거슬러 올라가며 **스택 언와인딩된다. 아무도 못 잡으면 그 스레드가 실행 중이던 메서드 밖까지 다 빠져나간다. </li>
<li>웹 요청은 스레드 하나에 매핑되므로, 컨트롤러에서 안 잡힌 예외는 결국 그 요청을 처리하던 스레드의 콜스택 끝, 즉 <strong>서블릿 컨테이너(WAS) 코드</strong>까지 올라간다. <ul>
<li>그나마 doDispatch() 안에 try-catch가 있어서, 이 이후에는 버려지는 프레임이 없어서 언와인딩이 멈추는 것이다. 만약 try-catch가 없었다면?  매칭되는 catch를 만나지 못해서 언와인딩이 이어져서, WAS로 정말 거슬로 올라가는 것이다. </li>
</ul>
</li>
</ul>
<h3 id="서블릿-컨테이너-레벨에서는">서블릿 컨테이너 레벨에서는?</h3>
<p>서블릿 컨테이너 레벨에서는 WAS까지 예외가 전파되면, 등록된 에러페이지를 상태코드나 예외타입에 따라 찾아서 재요청한다. 
이 재요청은 필터 -&gt; 서블릿 -&gt; 인터셉터를 다시 통과한다. </p>
<h3 id="스프링-mvc-레벨에서는-어떻게-처리가-되냐면">스프링 MVC 레벨에서는 어떻게 처리가 되냐면..?</h3>
<p>HandlerExceptionResolver라는게 있는데, 
WAS까지 넘어가는 예외를 500이 아니라 다른 상태코드로 처리하고 싶다면? 컨트롤러 밖으로 예외가 던져진다면 예외를 해결하고 동작을 새로 정의할 수 있는 방법을 제공해준다. </p>
<p>즉 기존에는 디스패쳐서블릿에서 prehandle로 인터셉터에서 사전 체크를 먼저 한다.
그러고 HandlerAdapter에 컨트롤러 실행을 하도록 위임을 한다. 
그런데 예외가 발생하면 컨트롤러 메서드 실행 중에 예외를 throw하게된다. 
-&gt; 여기서는 예외 전달이 안된다. 자바 메서드 콜스택 언와인딩으로, posthandle이 호출되지도 않고, afterCompletion(ex)는 finally 개념으로 호출된다. </p>
<p>즉 디스패쳐서블릿이 잡지 못한 것이고, WAS까지 예외가 전달된다는 것이다. 
그러면 결국 500예외로 내려진다. </p>
<p><strong>문제점</strong></p>
<ul>
<li>요청-응답 사이클이 두번이나 발생한다. 요청을 처리하다가 예외가 난 것이라서, WAS -&gt; /error로 새로운 요청을 또 만들어서 필-&gt;서-&gt;인까지 다시 흐름이 흘러가게 된다. 즉 로그인 인증 필터를 이미 통과를 했는데도? 에러 응답을 해주려고 또 실행된다.</li>
<li>정밀한 제어가 안된다. WAS의 예외 페이지 매핑은 상태코드,예외타입 하나에 경로 하나로만 연결된다. 똑같은 런타임예외인데도 주문,회원에서 난 도메인 예외라서 다르게 응답을 내려주고 싶어도 구분할 수가 없다. </li>
<li>JSON 커스터마이징을 못한다. 기본적인 더러운 응답만을 사용해야 한다. .. 깔끔한거 쓰고싶으면 매번 파싱을 해줘야 한다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/2fb64464-375c-4891-b834-36487183cd19/image.png" alt=""></p>
<p>디스패쳐 서블릿 내부의 doDispatch()에서는 컨트롤러 호출을 할 때, 다음과 같이 try-catch로 감싸고 있다. </p>
<pre><code class="language-java">Exception dispatchException = null;
try {
    mv = ha.handle(request, response, handler); 
} catch (Exception ex) {
    dispatchException = ex; // 예외를 일단 여기서 잡는다.
}

// WAS로 던지기 전에 마지막 기회를 준다
if (dispatchException != null) {
    mv = processHandlerException(request, response, handler, dispatchException);
    //   등록된 HandlerExceptionResolver들을 순서대로 돌려서
    //    resolveException()이 null 아닌 값을 반환하면 그걸 정상 결과로 취급한다.
}

if (mv == null &amp;&amp; dispatchException != null) {
    throw dispatchException; // 아무도 해결을 못하면 WAS로 던진다.
}
</code></pre>
<h3 id="handlerexceptionresolver가-있다면">HandlerExceptionResolver가 있다면?</h3>
<p>예외 전달과 WAS로 다시 던지는 부분 사이에 끼어든다. 리졸버 중에서 하나라도 ModelAndView를 반환하면,  다시 WAS 로 돌아가서 필-&gt;서-&gt;인 이렇게 돌아가지 않고, 디스패쳐서블릿이 WAS에 넘기기 전에 자기가 처리 가능한지? 물어보는 훅을 만들어서 여기서 예외가 나더라도 정상 응답으로 변환하는 것이다.
즉 WAS로 예외가 전달되는 과정을 스킵한다.</p>
<p>즉 자바 try catch 처럼 보면
preHandle    ≈ try 블록 진입 전 준비
handle       ≈ try { 컨트롤러 실행 }
postHandle   ≈ try 블록이 예외 없이 끝났을 때만 실행되는 코드 (성공 경로 전용)
afterCompletion ≈ finally { 성공이든 실패든 반드시 정리, ex를 파라미터로 받아 로깅 가능 }
자바에서 try-finally가 발생 여부랑 상관없이 실행되는 것처럼, postHandle은 언제나 실행되는 게 아니고, ExcpetionResolver가 있다면 성공이든 실패든 finally가 실행되게 되어있는것이다.</p>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/6c0ff29d-e546-4fb7-a6d8-2d3fb2f918be/image.png" alt=""></p>
<h3 id="스프링에서의-예외-처리-체인-방식">스프링에서의 예외 처리 체인 방식</h3>
<p>컨트롤러 밖으로 예외가 나가면 <code>DispatcherServlet</code>은 서블릿 컨테이너까지 올리기 전에 먼저 등록된 <code>HandlerExceptionResolver</code> 체인을 우선순위대로 돌린다. </p>
<p><code>HandlerExceptionResolverComposite</code>가 이 순서대로 하나씩 물어보고, 누군가 <code>null</code>이 아닌 <code>ModelAndView</code>를 반환하면 그 즉시 멈추는 방식이다. 
즉 하나가 처리되면 그 아래는 실행이 아예 안된다.
1번은 보통 body까지 만드는 방식으로 사용하고, 2,3번은 sendError()를 호출하고 WAS에서 랜더링하는걸 떠넘긴다. 
실무에서는 커스텀으로 만드는 걸 선호하기 때문에 1번방식을 주로 사용한다. </p>
<ol>
<li>ExceptionHandlerExceptionResolver ← @ExceptionHandler 처리 (최우선)</li>
<li>ResponseStatusExceptionResolver ← @ResponseStatus / ResponseStatusException</li>
<li>DefaultHandlerExceptionResolver ← 스프링 내부 예외(TypeMismatchException 등)를 4xx로 변환</li>
</ol>
<p>이렇게 우선순위가 있다는 건 1번이 처리하면 2,3은 실행될 기회가 없다는 것이다. 즉 누가 응답을 완성하는지에 따라 다르다.</p>
<p>3번의 경우 <code>TypeMismatchException</code>, <code>HttpMessageNotReadableException</code>, <code>MethodArgumentNotValidException</code> 과 같은 것들을 처리하지만, 1번에서 처리해서 공통 응답으로 내려주도록 할 수 있는 것이다. </p>
<p>즉 내가 1번에서 처리하도록 코드를 짠다, 하면 2,3은 실행될 일이 없는 것이다.!</p>
<h4 id="exceptionhandlerexceptionresolver">ExceptionHandlerExceptionResolver</h4>
<p>resolveException이라는게 호출되면 요청 -&gt; 응답 사이클을 처리할 수가 있다. </p>
<ol>
<li>handler, 즉 현재 요청을 처리한 컨트롤러에 로컬 <code>@ExceptionAhndler</code> 가 있는지 스캔한다. 해당 컨트롤러 클래스를 훑어서 메서드들을 예외 타입별로 캐싱을 해뒀기 때문에, ex 런타임 타입과 가장 가까운 것을 ExceptionDepthComparator로 선택한다. </li>
<li>로컬에 없다면 등록된 @ControllerAdvice/@RestControllerAdvice 빈들을 순서대로 순회를 하는데, 보통 <code>@RestControllerAdvice</code>어노테이션을 붙여 예외처리를 해둔 커스텀 클래스로 가게 된다. </li>
<li>사용자가 설정해둔 메서드 중에서 찾은 메서드를 ServletInvocableHandlerMethod로 리플렉션 호출한다. 파라미터도 진짜 컨트롤러 메서드처럼 HandlerMethodArgumentResolver로 채워진다. 그래서 @ExceptionHandler 메서드에 HttpServletRequest, WebRequest, Model 등을 자유롭게 파라미터로 받을 수가 있다. </li>
<li>리턴값도 컨트롤러처럼 체인을 재사용한다. ResponseEntity가 리턴값이라면? HttpEntityMethodProcessor가 처리한다. 그러면 이 과정이서 응답 바디까지 다 작성이 된다!</li>
</ol>
<pre><code class="language-java">// 도메인 예외를 정의된 상태 코드로 변환  
@ExceptionHandler(DomainException.class)  
public ResponseEntity&lt;CommonResponse&lt;Void&gt;&gt; handleDomainException(DomainException exception) {  
    ErrorCode errorCode = exception.getErrorCode();  
    if (errorCode.getStatus().is5xxServerError()) {  
        log.error(&quot;[DOMAIN_ERROR] code={}, status={}&quot;, errorCode.getCode(), errorCode.getStatus(), exception);  
    } else {  
        log.warn(&quot;[DOMAIN_ERROR] code={}, status={}, message={}&quot;,  
                errorCode.getCode(), errorCode.getStatus(), exception.getMessage());  
    }  
    return ResponseEntity.status(errorCode.getStatus()).body(CommonResponse.error(errorCode));  
}</code></pre>
<p>여기서 보면, 해당 메서드는  ResonseEntity&lt;공통응답&gt; 을 반환한다. 
전체 흐름을 보게되면 </p>
<p>WAS -&gt; DispatcherServlet 까지 가면 다시 WAS로 돌아갈 필요 없이 HandlerExceptionResolver 가 예외 처리를 시도하고, 메시지 컨버터가 JSON을 바로 써서 반환한다. </p>
<h3 id="exceptionhandler-동작방식-사용방식"><code>@ExceptionHandler</code> 동작방식, 사용방식</h3>
<p>일단 사용 방법은, 애노테이션을 선언하고 해당 컨트롤러에서 처리하고 싶은 예외를 지정해주면 된다. 해당 컨트롤러에서 예외 발생 시 메서드가 호출되고, 지정한 예외 또는 예외의 자식 클래스를 모두 잡을 수 있다. </p>
<p><strong>우선순위</strong>
우선순위는 항상 자세한 것이 더 우위다. 부모 -&gt; 자식까지 처리할 수 있는데, 즉 자식 -&gt; 부모,자식 모두 대상이 되는 것이므로 자식예외처리가 우선권을 가진다. 부모만 호출되면 부모예외처리만 호출된다. </p>
<p>다양한 예외를 한번에 처리하거나, 예외 생략도 가능하다. 생략하면 메서드 파라미터의 예외가 지정된다. </p>
<h4 id="어떻게-항상-정확한-메서드를-찾아내는지">어떻게 항상 정확한 메서드를 찾아내는지?</h4>
<p>자바의 한계
자바의 메서드 오버로딩은 정적 바인딩이다. 여러 예외타입의 오버로드가 있어도 컴파일 타임에 &#39;변수 선언 타입&#39;으로 어떤게 호출될 지 결정되며, 런타임의 객체 타입으로 결정되지 않는다. 
그런데 핸들러는 런타임에 호출해야 하기 때문에, 실제로 던져진 예외의 런타입 타입에 가장 가까운 핸드럴를 찾을 수가 없다. </p>
<p>스프링의 리플렉션으로 재구현</p>
<ul>
<li>ExceptionhandlerMethodResolver가 클래스를 스캔해서, <code>@ExceptionHandler</code>에 선언된 예외 타입들을 인덱싱한다. </li>
<li>실제 예외의 <code>getClass()</code>(런타임 타입)를 보고, <code>ExceptionDepthComparator</code>로 상속 계층상 가장 가까운 핸들러를 선택한다. 부모,자식 둘다 매칭 시 자식이 우선순위다. </li>
<li>찾은 메서드는 리플렉션(<code>Method.invoke</code>)으로 호출되고, 파라미터/리턴값도 일반 컨트롤러와 똑같은 인프라(<code>HandlerMethodArgumentResolver</code>/<code>HandlerMethodReturnValueHandler</code>)를 재사용한다. </li>
<li>음 이게 무슨말이냐 하면, 일반적인 자바 코드는 컴파일 타임에 메서드의 존재를 알아야 한다. 스프링은 애노테이션만 붙여주면 따라가서 실행해준다. </li>
<li>즉 스캔 -&gt; 예외 발생 시 런타임 타입으로 후보 목록에서 검색 -&gt; 파라미터 값 준비 -&gt; 스캔에서 찾아둔 메서드를 진짜 호출의 흐름으로 되는 것이다. 파라미터는 HandlerMethodArgumentResolver 에서 채워진다. </li>
<li>즉 스프링이 런타임에 어노테이션 붙였던것을 찾아가서 호출해주는 것이다.!</li>
<li>리플랙션으로 하면 , Method.invoke() 이므로 직접 호출보다 느리다. 초기 호출은 JNI를 거치고, 15회 이상 반복 호출되면 JVM이 바이트코드 기반 접근자 클래스를 동적 생성해서 최적화한다. </li>
</ul>
<p><strong>파라미터 채우기?</strong>
일반 컨트롤러 파라미터는, 각 자리마다 <code>HandlerMethodArgumentResolver</code> 체인에게 파라미터를 채울 수 있는지 순서대로 물어본다. </p>
<ul>
<li><code>@PathVariable String id</code> → <code>PathVariableMethodArgumentResolver</code>가 <code>supportsParameter</code>에서 이 파라미터에 <code>@PathVariable</code>이 붙어있다는 것을 확인하고 URL에서 값을 꺼내준다. </li>
<li><code>HttpServletRequest request</code> → <code>ServletRequestMethodArgumentResolver</code>가 타입이 <code>HttpServletRequest</code>인 것을 보고 그냥 현재 요청 객체를 꽂아준다.</li>
<li><code>@RequestBody Dto dto</code> → <code>RequestResponseBodyMethodArgumentResolver</code>가 메시지 컨버터로 파싱해서 꽂아준다. 
<code>@ExceptionHandler</code> 메서드도 이 체인을 그대로 재사용하는 방식이다. 
예외 객체 자체를 채우는 부분은 이 체인에 들어가기 <strong>전에</strong> 처리된다. </li>
</ul>
<h3 id="핸들러-메서드를-모아둔-controlleradvice-구현하는-방식">핸들러 메서드를 모아둔 ControllerAdvice 구현하는 방식</h3>
<p>여러 컨트롤러가 공유하는 전역 처리를 하고 싶다면, <code>@ControllerAdvice</code>(또는 <code>@RestControllerAdvice</code> = <code>@ControllerAdvice</code> + <code>@ResponseBody</code>)로 분리한다. 
이 클래스를 만드는 방법이 두 가지로 갈리는 편이다. </p>
<ol>
<li>상속 없이 직접 나열<ul>
<li>ExceptionHandlerExceptionResolver에서 스캔되고, 바인딩 실패와 같은 스프링 내부 예외는 직접 처리하지 않으면, DefaultHandlerExceptionResolver로 넘어간다. </li>
<li>관심있는 예외만 명시적 나열하는 방법이다. </li>
<li><pre><code class="language-java">@RestControllerAdvice
public class ExControllerAdvice {
@ExceptionHandler(IllegalArgumentException.class) ...
@ExceptionHandler(UserException.class) ...
@ExceptionHandler(Exception.class) ...
}</code></pre>
</li>
</ul>
</li>
<li><strong><code>ResponseEntityExceptionHandler</code> 상속</strong></li>
</ol>
<ul>
<li>정말 많이 쓰는 방식이다. </li>
<li>스프링 내장 예외 20개가 이미 라우팅되어 있으므로, 필요한 것만 오버라이딩해서 사용하면 된다. </li>
</ul>
<p><code>@ExceptionHandler</code> 를 안쓰고.. HandlerExceptionResolver 를 쓰는 방식</p>
<ul>
<li>이건 request, response를 날것으로 직접 다룬다.</li>
<li>리졸버 리스트에 직접 등록을 해야 하고, 굉장히 옛날 방식이다.. </li>
</ul>
<p>아예 advice 클래스도 없이 — <code>ResponseStatusException</code></p>
<ul>
<li>이건 Controller에서 직접 던지는 방식이다.  ResponseStatusExceptionResolver가 잡아서 sendError를 호출한다. 대신 response body를 커스텀할 수 없어서 BasicErrorController가 만든 기본 형식으로 나간다. </li>
</ul>
<h3 id="responseentityexceptionhandler-동작방식"><code>ResponseEntityExceptionHandler</code> 동작방식</h3>
<p>이건 리플렉션 기반이 아니라, 평범한 자바 상속 및 오버라이딩 방식이다. </p>
<p>handleMethodArgumentNotValid(...) 등 개별 메서드
   ↓
handleExceptionInternal(ex, body, headers, status, request)
   ↓
createResponseEntity(body, headers, statusCode, request)    </p>
<p>예외 타입 하나를 커스터마이징하고, 모든 예외가 handleExceptionInternal을 공통으로 지나간다. 
최종 ResponseEntity로 조립된다. </p>
<p>단점
오버라이드 안한 예외가 있다면, 스프링 기본 구현이 ProblemDetail을 만들어서 handleExceptionInternal로 넘긴다. 즉 공통 포맷과 안 맞는 응답이 섞여 나갈 수도 있다. </p>
<h3 id="responseentity란">ResponseEntity란..?</h3>
<p><code>handleExceptionInternal</code>/<code>createResponseEntity</code> 둘 다 결국 <code>ResponseEntity&lt;Object&gt;</code>를 반환한다.
이건 상태코드+헤더+바디를 담는 불변 값 객체이다. (<code>HttpEntity&lt;T&gt;</code> 상속, <code>T</code>는 바디 타입)</p>
<p>흐름</p>
<ul>
<li><code>DispatcherServlet</code>의 <code>HttpEntityMethodProcessor</code> 가 타입 인식</li>
<li><code>getStatusCode()</code>/<code>getHeaders()</code>/<code>getBody()</code>를 꺼내 응답 조립</li>
<li>바디는 <code>HttpMessageConverter</code>(Jackson)로 직렬화</li>
<li><code>ResponseEntity&lt;ProblemDetail&gt;</code> 이 아닌 이유는? 공통 응답인 CommonResponse가 있다 하면 이 두 타입은 무관하기 때문에, Object로 타입 자체를 지워둬야 바디 타입을 자유롭게 바꿔서 오버라이드 가능하다. </li>
</ul>
<h3 id="결론---어떤-방식이-좋은지">결론 - 어떤 방식이 좋은지?</h3>
<ul>
<li>*<em>API 표면이 크고, 스프링 내장 검증/바인딩 예외를 폭넓게 다뤄야 한다 *</em>
  -&gt;  상속, 대신 <code>handleExceptionInternal</code> 오버라이드로 공통 응답으로 내려가도록 통일해야 한다. </li>
<li><strong>응답 포맷을 100% 명시적으로 통제하고 싶고, 우리가 처리하는 예외 목록이 코드에 다 드러나야 함</strong> →  비상속, 필요한 예외를 전부 직접 나열한다. </li>
</ul>
<h3 id="코드-작성해보기">코드 작성해보기</h3>
<p>Validation 시 400으로 내려지도록 하는 예외를 오버라이드해본다고 해보자.</p>
<p>이건 첫번째 필드만 응답에 포함 /  Validation 실패한 필드를 모두 포함 하는 방법이 있다. 문서 포함에는 validation 필드를 별도 record로 body를 만들어주는 방식이 있는데, 이러면 응답 스키마가 아예 바뀌어 버린다. 개인적으로 별로 선호하지는 않는다.!</p>
<pre><code class="language-java">@Override
protected ResponseEntity&lt;Object&gt; handleMethodArgumentNotValid(
        MethodArgumentNotValidException exception,
        HttpHeaders headers,
        HttpStatusCode status,
        WebRequest request
) {
    // 타입을 String,List&lt;String&gt; 으로 해서 에러 메시지를 여러개 담을 수 있도록 한다. 
    Map&lt;String, List&lt;String&gt;&gt; fieldErrors = new LinkedHashMap&lt;&gt;();
    // 필드 에러만큼 에러를 add해준다. 
    for (FieldError fieldError : exception.getBindingResult().getFieldErrors()) {
        fieldErrors.computeIfAbsent(fieldError.getField(), key -&gt; new ArrayList&lt;&gt;())
                .add(fieldError.getDefaultMessage());
    }
    // 맵에 global라는 값으로 응답에 추가하도록 한다. 
    for (ObjectError globalError : exception.getBindingResult().getGlobalErrors()) {
        fieldErrors.computeIfAbsent(&quot;global&quot;, key -&gt; new ArrayList&lt;&gt;())
                .add(globalError.getDefaultMessage());
    }

    ErrorCode errorCode = CommonErrorCode.INVALID_INPUT;
    return handleExceptionInternal(
            exception,
            CommonResponse.error(errorCode, fieldErrors),
            headers,
            errorCode.getStatus(),
            request
    );
}</code></pre>
<h3 id="최종-예외처리">최종 예외처리</h3>
<p>조금 투머치일 수도 있지만 모든 경우를 커버한다고 가정해서 해보면 다음과 같다. </p>
<pre><code class="language-java">
/** 애플리케이션과 Spring MVC 예외를 {@link CommonResponse} 형식으로 변환하는 전역 처리기 */
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    // 도메인 예외를 정의된 상태 코드로 변환
    @ExceptionHandler(DomainException.class)
    public ResponseEntity&lt;CommonResponse&lt;Void&gt;&gt; handleDomainException(DomainException exception) {
        ErrorCode errorCode = exception.getErrorCode();
        logByStatus(errorCode, exception);
        return ResponseEntity.status(errorCode.getStatus()).body(CommonResponse.error(errorCode));
    }

    // 요청 DTO 외부의 Bean Validation 오류를 필드별로 변환
    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity&lt;CommonResponse&lt;Map&lt;String, List&lt;String&gt;&gt;&gt;&gt; handleConstraintViolation(
            ConstraintViolationException exception
    ) {
        Map&lt;String, List&lt;String&gt;&gt; fieldErrors = new LinkedHashMap&lt;&gt;();
        for (ConstraintViolation&lt;?&gt; violation : exception.getConstraintViolations()) {
            fieldErrors.computeIfAbsent(violation.getPropertyPath().toString(), key -&gt; new ArrayList&lt;&gt;())
                    .add(violation.getMessage());
        }

        ErrorCode errorCode = CommonErrorCode.INVALID_INPUT;
        return ResponseEntity.status(errorCode.getStatus()).body(CommonResponse.error(errorCode, fieldErrors));
    }

    // DB 제약 조건 위반은 클라이언트 요청 간 상태 충돌로 변환
    @ExceptionHandler(DataIntegrityViolationException.class)
    public ResponseEntity&lt;CommonResponse&lt;Void&gt;&gt; handleDataIntegrityViolation(
            DataIntegrityViolationException exception
    ) {
        ErrorCode errorCode = CommonErrorCode.CONFLICT;
        logByStatus(errorCode, exception);
        return ResponseEntity.status(errorCode.getStatus()).body(CommonResponse.error(errorCode));
    }

    // 낙관적 락 예외처리 
    @ExceptionHandler(OptimisticLockingFailureException.class)
    public ResponseEntity&lt;CommonResponse&lt;Void&gt;&gt; handleOptimisticLockingFailure(
            OptimisticLockingFailureException exception
    ) {
        ErrorCode errorCode = CommonErrorCode.CONFLICT;
        logByStatus(errorCode, exception);
        return ResponseEntity.status(errorCode.getStatus()).body(CommonResponse.error(errorCode));
    }

    // 이외의 모든 예외를 500으로 처리
    @ExceptionHandler(Exception.class)
    public ResponseEntity&lt;CommonResponse&lt;Void&gt;&gt; handleUnexpectedException(Exception exception) {
        log.error(&quot;[ERROR] 서버 내부 오류: &quot;, exception);
        ErrorCode errorCode = CommonErrorCode.INTERNAL_SERVER_ERROR;
        return ResponseEntity.status(errorCode.getStatus()).body(CommonResponse.error(errorCode));
    }

    // @Valid 요청 본문의 필드 검증 오류를 변환 (필드별 다중 메시지 + 전역 오류까지 수집)
    @Override
    protected ResponseEntity&lt;Object&gt; handleMethodArgumentNotValid(
            MethodArgumentNotValidException exception,
            HttpHeaders headers,
            HttpStatusCode status,
            WebRequest request
    ) {
        Map&lt;String, List&lt;String&gt;&gt; fieldErrors = new LinkedHashMap&lt;&gt;();
        for (FieldError fieldError : exception.getBindingResult().getFieldErrors()) {
            fieldErrors.computeIfAbsent(fieldError.getField(), key -&gt; new ArrayList&lt;&gt;())
                    .add(fieldError.getDefaultMessage());
        }
        for (ObjectError globalError : exception.getBindingResult().getGlobalErrors()) {
            fieldErrors.computeIfAbsent(&quot;global&quot;, key -&gt; new ArrayList&lt;&gt;())
                    .add(globalError.getDefaultMessage());
        }
        return handleExceptionInternal(exception, fieldErrors, headers, status, request);
    }

    /**
     * 개별 handleXXX()를 따로 오버라이드하지 않아도 여기서 일괄적으로 CommonResponse 포맷 강제
     */
    @Override
    protected ResponseEntity&lt;Object&gt; handleExceptionInternal(
            Exception exception,
            Object body,
            HttpHeaders headers,
            HttpStatusCode statusCode,
            WebRequest request
    ) {
        ErrorCode errorCode = resolveErrorCode(exception);
        logByStatus(errorCode, exception);

        // body가 우리가 직접 채운 값(fieldErrors 등)이면 그대로 담고,
        // 부모 클래스가 기본으로 만든 ProblemDetail이면 버리고 빈 CommonResponse로 응답
        Object data = (body instanceof ProblemDetail) ? null : body;
        CommonResponse&lt;Object&gt; responseBody = (data != null)
                ? CommonResponse.error(errorCode, data)
                : CommonResponse.error(errorCode);

        return ResponseEntity.status(errorCode.getStatus()).headers(headers).body(responseBody);
    }

    private ErrorCode resolveErrorCode(Exception exception) {
        if (exception instanceof MethodArgumentNotValidException) return CommonErrorCode.INVALID_INPUT;
        if (exception instanceof HandlerMethodValidationException) return CommonErrorCode.INVALID_INPUT;
        if (exception instanceof MissingServletRequestParameterException) return CommonErrorCode.INVALID_INPUT;
        if (exception instanceof TypeMismatchException) return CommonErrorCode.INVALID_INPUT;
        if (exception instanceof ServletRequestBindingException) return CommonErrorCode.INVALID_INPUT;
        if (exception instanceof HttpMessageNotReadableException) return CommonErrorCode.MALFORMED_REQUEST;
        if (exception instanceof HttpRequestMethodNotSupportedException) return CommonErrorCode.METHOD_NOT_ALLOWED;
        if (exception instanceof HttpMediaTypeNotSupportedException) return CommonErrorCode.UNSUPPORTED_MEDIA_TYPE;
        if (exception instanceof HttpMessageNotWritableException) return CommonErrorCode.INTERNAL_SERVER_ERROR;
        return CommonErrorCode.INTERNAL_SERVER_ERROR; // 등록 안 된 예외의 최종 기본값
    }

    private void logByStatus(ErrorCode errorCode, Exception exception) {
        if (errorCode.getStatus().is5xxServerError()) {
            log.error(&quot;[ERROR] code={}, status={}&quot;, errorCode.getCode(), errorCode.getStatus(), exception);
        } else {
            log.warn(&quot;[ERROR] code={}, status={}, message={}&quot;,
                    errorCode.getCode(), errorCode.getStatus(), exception.getMessage());
        }
    }
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] 스프링 기본4 - 의존관계 자동 주입]]></title>
            <link>https://velog.io/@jayaione_ele/Spring-%EC%8A%A4%ED%94%84%EB%A7%81-%EA%B8%B0%EB%B3%B84-%EC%9D%98%EC%A1%B4%EA%B4%80%EA%B3%84-%EC%9E%90%EB%8F%99-%EC%A3%BC%EC%9E%85</link>
            <guid>https://velog.io/@jayaione_ele/Spring-%EC%8A%A4%ED%94%84%EB%A7%81-%EA%B8%B0%EB%B3%B84-%EC%9D%98%EC%A1%B4%EA%B4%80%EA%B3%84-%EC%9E%90%EB%8F%99-%EC%A3%BC%EC%9E%85</guid>
            <pubDate>Thu, 17 Sep 2026 10:36:38 GMT</pubDate>
            <description><![CDATA[<h2 id="다양한-의존관계-주입-방법">다양한 의존관계 주입 방법</h2>
<ul>
<li>생성자 주입</li>
<li>수정자 주입(setter 주입)</li>
<li>필드 주입</li>
<li>일반 메서드 주입</li>
</ul>
<h3 id="생성자-주입">생성자 주입</h3>
<p>생성자를 통해서만, 의존관계가 주입되며 외부에서 수정할 수 있는 방법이 없다. </p>
<ul>
<li>생성자 호출시점에 딱 한번만 호출되는 것이 보장된다. </li>
<li>불변, 필수 의존관계에 사용된다. </li>
</ul>
<p>생성자가 한개만 있으면 <code>@Autowired</code>를 생략해도 자동 주입되는데 스프링 빈에만 해당한다. 
생성자 두개 이상이면 무조건 명시해야 한다. </p>
<p>최근에는 Lombok의 @RequiredArgsConstructor를 쓰면 final 필드 기준으로 생성자를 자동 생성해줘서, 이 규칙이랑 합쳐서 생성자 코드 자체를 안 써도 되게 하는 게 요즘 사용 방법이다. </p>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {
    private final MemberRepository memberRepository;
    private final DiscountPolicy discountPolicy;
    // 생성자 코드 자체가 안 보임 (Lombok이 컴파일 시점에 만들어줌)
}</code></pre>
<p>이렇게 하면 생성자 코드 자체가 안보이게 된다. </p>
<h3 id="수정자-주입setter-주입">수정자 주입(setter 주입)</h3>
<pre><code>@Component
public class OrderServiceImpl implements OrderService {

    private MemberRepository memberRepository;  // final 없음!
    private DiscountPolicy discountPolicy;

    @Autowired
    public void setMemberRepository(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @Autowired
    public void setDiscountPolicy(DiscountPolicy discountPolicy) {
        this.discountPolicy = discountPolicy;
    }
}</code></pre><ul>
<li>setter라고 불리는 필드의 값을 변경하는 수정자 메서드를 통해서 의존관계를 주입하는 방법이다. </li>
<li>선택, 변경 가능성이 있는 의존관계에 사용한다. </li>
<li>스프링이 객체를 먼저 기본 생성자로 만들고 나서 setXxx() 메서드들을 호출해서 나중에 값을 채워넣는다. AutoWired가 있으니까 주입, 이런 식이다. </li>
<li>나중에 값을 바꿀 수도 있으므로 final을 못 붙인다. </li>
<li>즉 setter를 호출 한다음에, 의존관계가 완성된다. </li>
</ul>
<p>-&gt; AutoWired는 주입할 대상이 없으면 오류가 발생한다. 주입할 대상이 없어도 동작하게 하려면 <code>@Autowired(required=false)</code>로 지정하면 된다. </p>
<p>-&gt; 실무에서는 잘 안 쓴다. 필수값인데 final이 안되기 때문에 실수로 인해 채우지 않아서 사용하게 되면 NPE 위험이 있다. </p>
<h3 id="필드-주입">필드 주입</h3>
<pre><code>@Autowired private MemberRepository memberRepository;</code></pre><ul>
<li>필드에 바로 주입하는 방법이다. </li>
<li>코드가 간결하지만, 외부에서 변경이 불가능해서 테스트하기 힘들다는 단점이 있다. </li>
<li><blockquote>
<p>생성자가 없어서 순수 자바 코드로는 테스트가 절대 안되고, 순수 자바로 테스트하려면 스프링 없이 new로 값을 채울 방법이 없다. 그러면 어떻게 해야 하냐, <code>@SpringBootTest</code>를 써서 스프링 컨테이너를 통째로 띄워야만 한다. </p>
</blockquote>
</li>
<li>DI 프레임워크(Autowired)가 없으면 아무것도 할 수 없다. </li>
<li>인텔리제이에서 사용하게 되면 경고가 뜨면서 추천되지 않는 방법이라고 뜨기도 한다. </li>
</ul>
<p>-&gt; 사용 안하는게 좋다. </p>
<h3 id="일반-메서드-주입">일반 메서드 주입</h3>
<ul>
<li>일반 메서드를 통해서 주입 받을 수 있다.</li>
<li>한번에 여러 필드를 주입 받을 수 있다.</li>
<li>일반적으로 잘 사용하지 않는다. 생성자나 수정자 주입으로 대부분 커버된다. </li>
<li>자바 규약 이름이 아니어도 된다. </li>
</ul>
<pre><code>@Component
public class OrderServiceImpl implements OrderService {

    private MemberRepository memberRepository;
    private DiscountPolicy discountPolicy;

    @Autowired
    public void init(MemberRepository memberRepository, DiscountPolicy discountPolicy) {
        this.memberRepository = memberRepository;
        this.discountPolicy = discountPolicy;
    }
}</code></pre><h2 id="옵션-처리">옵션 처리</h2>
<p>주입할 스프링 빈이 없어도 동작해야 할 때가 있다. Autowired는 자동 주입 대상이 없으면 오류가 발생한다. 
자동 주입 대상을 옵션으로 처리하는 방법은 다음과 같다. </p>
<p><code>@Autowired(required=false)</code> : 자동 주입할 대상이 없으면 수정자 메서드 자체가 호출 안됨
<code>org.springframework.lang.@Nullable</code> : 자동 주입할 대상이 없으면 null이 입력된다.
<code>Optional&lt;&gt;</code> : 자동 주입할 대상이 없으면 <code>Optional.empty</code> 가 입력된다.</p>
<p>Member라는 타입의 빈이 스프링 컨테이너에 없다면..</p>
<p>setNoBean1: 주입할 빈이 없으니까, 메서드 자체가 호출이 안된다. 
setNoBean2: 빈이 없으면 파라미터에 null이 들어간 채로 호출된다.
setNoBean3: 빈이 없으면 Optional.empty가 들어간 채로 호출이 된다. </p>
<p>@Nullable, Optional은 스프링 전반에 걸쳐서 지원된다. 예를 들어서 생성자 자동 주입에서 특정 필드에
만 사용해도 된다.</p>
<pre><code class="language-java">// 호출 안됨
@Autowired(required = false)
public void setNoBean1(Member member) {
    System.out.println(&quot;setNoBean1 = &quot; + member);
}

// null 호출
@Autowired
public void setNoBean2(@Nullable Member member) {
    System.out.println(&quot;setNoBean2 = &quot; + member);
}

// Optional.empty 호출
@Autowired(required = false)
public void setNoBean3(Optional&lt;Member&gt; member) {
    System.out.println(&quot;setNoBean3 = &quot; + member);
}</code></pre>
<h3 id="생성자-주입을-선택하기">생성자 주입을 선택하기</h3>
<p>결론적으로 생성자 주입이 권장된다. </p>
<p><strong>불변성</strong>
대부분의 의존관계 주입은 한번 일어나면 애플리케이션 종료시점까지 의존관계를 변경할 일이 없다. 
오히려 대부분의 의존관계는 애플리케이션 종료 전까지 변하면 안된다.
수정자 주입을 사용하면, setXxx 메서드를 public으로 열어두어야 한다.
누군가 실수로 변경할 수 도 있고, 변경하면 안되는 메서드를 열어두는 것은 좋은 설계 방법이 아므로, private으로 하는 것이 좋다. </p>
<p>생성자 주입은 객체를 생성할 때 딱 1번만 호출되므로 이후에 호출되는 일이 없다. 
따라서 불변하게 설계할 수 있다는 장점이 있다. </p>
<p><strong>누락</strong>
프레임워크 없이 순수한 자바 코드를 단위 테스트 하는 경우에, 수정자 의존관계라면..
Autowired가 프레임워크 안에서 동작할 때는 의존관계가 없으면 오류가 발생하지만, 프레임워크 없이 순수 자바 코드로만 단위 테스트를 수행한다면 일단 실행은 된다.
그런데 의존관계 주입이 누락되었다면 NPE가 발생한다. 
반명 생성자 주입을 사용하면? 누락이 발생하면 NPE가 아니라 컴파일 오류가 발생해서, 어떤 값을 필수로 주입해야 하는지 바로 알 수가 있다. </p>
<p><strong>final 키워드 사용</strong></p>
<p>생성자 주입을 사용하면 필드에 <code>final</code> 키워드를 사용할 수 있다. 
그래서 생성자에서 혹시라도 값이 설정되지 않는 오류를 컴파일 시점에 막아준다. </p>
<h2 id="조회-빈이-2개-이상일-경우의-문제">조회 빈이 2개 이상일 경우의 문제</h2>
<pre><code class="language-java">@Component
public class FixDiscountPolicy implements DiscountPolicy {}

@Component
public class RateDiscountPolicy implements DiscountPolicy {}</code></pre>
<pre><code class="language-java">@Autowired
private DiscountPolicy discountPolicy;</code></pre>
<p>@Autowired는 타입으로 찾는데, DiscountPolicy 타입이 2개(Fix, Rate)라서 스프링이 뭘 넣을지 모른다. 
NoUniqueBeanDefinitionException이 터지게 된다. </p>
<h3 id="필드명을-빈-이름이랑-똑같이-맞추기">필드명을 빈 이름이랑 똑같이 맞추기</h3>
<p>필드명을 빈 이름이랑 똑같이 맞추면 된다. 타입으로 여러 개 나오면 필트,파라미터 이름으로 추가 매칭한다. 근데 실무에서 잘 안쓴다.</p>
<h3 id="qualifier">Qualifier</h3>
<p>추가 구분자를 붙여서, 주입 시에 추가적인 방법을 제공하는 것이지 빈 이름 자체를 변경하는건 아니다. </p>
<pre><code class="language-java">@Component
@Qualifier(&quot;mainDiscountPolicy&quot;)
public class RateDiscountPolicy implements DiscountPolicy {}</code></pre>
<pre><code class="language-java">public OrderServiceImpl(MemberRepository memberRepository,
                         @Qualifier(&quot;mainDiscountPolicy&quot;) DiscountPolicy discountPolicy) {</code></pre>
<p>주입받을 때 지정해주면 된다. 주입받는 모든 곳에 <code>@Qualifer</code>를 붙여줘야 한다.
Qualifier는 Qualifier를 쓰는 것들끼리만 사용하는 방법이 권장된다. 헷갈리기 때문에..</p>
<h3 id="primary">Primary</h3>
<pre><code>@Component
@Primary
public class RateDiscountPolicy implements DiscountPolicy {}  // 이게 기본적으로 선택된다

@Component
public class FixDiscountPolicy implements DiscountPolicy {}</code></pre><p>@Primary vs @Qualifier 언제 쓰는지? 
자주 쓰는 메인 → @Primary (기본값처럼 편하게)
가끔 쓰는 특수 케이스 → @Qualifier (명시적으로 콕 찍어서)</p>
<p>둘이 충돌나면 <code>@Qualifier</code>가 우선순위다. </p>
<h2 id="조회한-빈이-모두-필요한-경우-listmap">조회한 빈이 모두 필요한 경우 List,Map</h2>
<p>하나만 딱 찍는게 아니라, DiscountPolicy 타입 전부를 받고 싶을 때, Map이나 List로 받아서 사용할 때는 코드로 원하는 것을 골라서 쓸 수가 있다. </p>
<pre><code class="language-java">public DiscountService(Map&lt;String, DiscountPolicy&gt; policyMap,
                        List&lt;DiscountPolicy&gt; policies) {
    this.policyMap = policyMap;  // {&quot;fixDiscountPolicy&quot;: Fix객체, &quot;rateDiscountPolicy&quot;: Rate객체}
    this.policies = policies;    // [Fix객체, Rate객체]
}</code></pre>
<pre><code class="language-java">public int discount(Member member, int price, String discountCode) {
    DiscountPolicy discountPolicy = policyMap.get(discountCode); // &quot;fixDiscountPolicy&quot; 넘기면 Fix 꺼내옴
    return discountPolicy.discount(member, price);
}</code></pre>
<p>전략 패턴을 구현할 때 유용하다. 사용자가 특정 방식을 선택하게 하고 싶을 때 등등.. </p>
<h2 id="자동수동의-올바른-실무-운영-기준">자동,수동의 올바른 실무 운영 기준</h2>
<p>자동 등록 vs 수동 등록</p>
<p>기본은 @Component같이 자동을 사용한다.
요즘 스프링 트렌드가 자동을 선호한다. 
@Component 하나만 붙이면 끝나고 설정 파일 관리 안 해도 되기 때문이다.!
자동으로 등록해도 OCP, DIP 지킬 수 있다.</p>
<p>수동을 고려해야 할 때는 다음과 같다. </p>
<ol>
<li>기술 지원 로직 (업무 로직 아닌 것 — DB 연결, 로깅, 공통 기술)</li>
</ol>
<p>개수 적고, 애플리케이션 전체에 광범위하게 영향 줌
문제 생겨도 어디가 문제인지 파악하기 어려움
→ 수동 등록해서 설정 파일에 딱 드러나게 하는 게 유지보수에 좋음</p>
<ol start="2">
<li>다형성을 적극 활용하는 비즈니스 로직 (위의 Map&lt;String, DiscountPolicy&gt; 같은 경우)</li>
</ol>
<p>코드만 봐서는 어떤 빈들이 들어올지 한눈에 보이지 않는다. 
다른 개발자가 코드 받으면 파악하려고 여러 파일 뒤져야 한다. 
→ 수동 등록하거나, 자동으로 하더라도 관련 클래스들을 한 패키지에 모아두기</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] 스프링 기본3 - 컴포넌트 스캔]]></title>
            <link>https://velog.io/@jayaione_ele/Spring-%EC%8A%A4%ED%94%84%EB%A7%81-%EA%B8%B0%EB%B3%B83-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8-%EC%8A%A4%EC%BA%94</link>
            <guid>https://velog.io/@jayaione_ele/Spring-%EC%8A%A4%ED%94%84%EB%A7%81-%EA%B8%B0%EB%B3%B83-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8-%EC%8A%A4%EC%BA%94</guid>
            <pubDate>Thu, 17 Sep 2026 09:29:07 GMT</pubDate>
            <description><![CDATA[<h2 id="컴포넌트-스캔과-의존관계-자동-주입">컴포넌트 스캔과 의존관계 자동 주입</h2>
<p><code>@Bean</code>으로 하나하나 설정 정보 등록하면 번거롭기 때문에, 반복을 줄이는 것이 필요하다. 그래서 스프링은 이러한 설정 정보 없이도 자동으로 스프링 빈을 등록하는 컴포넌트 스캔이라는 기능을 제공한다. </p>
<p>의존관계를 자동으로 주입해주는 <code>@Authowired</code> 기능도 제공한다. 
타입에 맞는걸 주입해주는? ac.getBean(MemberRepository.class) 이런식으로 자동으로 코드가 들어가는 것이다. 
생성자에서 여러 의존관계도 주입이 가능하다.</p>
<h3 id="컴포넌트-스캔-사용">컴포넌트 스캔 사용</h3>
<p><code>@ComponentScan</code> 을 설정 정보에 붙여주면 된다. 별도로 <code>@Bean</code> 으로 클래스를 등록하지 않아도 된다. 
<img src="https://velog.velcdn.com/images/jayaione_ele/post/991dc86b-3ffc-4eac-b1cb-2e76077e9287/image.png" alt=""></p>
<p>붙여준 어노테이션은, <code>@Component</code>가 붙은 모든 클래스를 스프링 빈으로 등록한다. 기본 클래스명을 사용하고 맨 앞글자만 소문자를 사용한다. </p>
<p><code>@Component(&quot;빈 이름&quot;)</code> 이렇게 지정할 수도 있다. 기본적으로는 MemberServiceImpl -&gt; memberServiceImpl 이렇게 주입된다. </p>
<p><strong><code>@Autowired</code> 의존관계 자동 주입</strong></p>
<ul>
<li>생성자에 지정하면 스프링 컨테이너가 자동으로 해당 빈을 찾아서 주입한다. </li>
<li>타입이 같은 빈을 찾아서 주입한다. </li>
<li>생성자에 파라미터가 많아도, 다 찾아서 자동 주입한다. </li>
</ul>
<h2 id="탐색-위치와-기본-스캔-대상">탐색 위치와 기본 스캔 대상</h2>
<h3 id="탐색할-패키지의-시작-위치-지정하기">탐색할 패키지의 시작 위치 지정하기</h3>
<p>모든 자바 클래스를 다 컴포넌트 스캔하면 시간이 오래 걸리기 때문에, 필요한 위치부터 탐색하도록 시작 위치 지정이 가능하다. 라이브러리까지 다 탐색하고 그런다면 오래걸린다. </p>
<p><code>@ComponentScan</code>으로 지정을 안하면 디폴트가 되는데, 그냥 어노테이션만 붙이면, 설정 정보 클래스의 패키지가 시작 위치가 된다. </p>
<p>요즘에는 설정 정보 클래스의 위치를 프로젝트 최상단에 두는 방식으로 한다. 즉 상위 프로젝트가 com.hello라면 AppConfig같은 메인 설정 정보를 두고, basePackages 지정은 생략하는 것이다. </p>
<p>스프링 부트를 사용하면 <code>@SpringBootApplication</code> 이라는 대표 시작 정보인 어노테이션을 시작 루트에 둔다. 
이 설정 안에 <code>@ComponentScan</code>이 들어있다. 불필요한것만 exclude로 제외하면 된다. </p>
<h3 id="컴포넌트-스캔-기본-대상">컴포넌트 스캔 기본 대상</h3>
<p><code>@Component</code> 뿐만 아니라 다음 내용도 대상에 포함된다.</p>
<p><code>@Component</code> : 컴포넌트 스캔에서 사용
<code>@Controller</code> : 스프링 MVC 컨트롤러에서 사용, 컨트롤러로 인식
<code>@Service</code> : 스프링 비즈니스 로직에서 사용, 특별한 처리는 안하고 비즈니스 계층 인식에 도움이 된다. 
<code>@Repository</code> : 스프링 데이터 접근 계층에서 사용, 데이터 계층의 예외를 스프링 예외로 변환해준다. 특정 예외가 터지면 서비스 계층까지 올라온다. DB마다 던지는 예외가 다르기 때문에, 서비스코드가 특정 DB 접근 기술에 의존하는걸 방지하기 위해서, 스프링 자체에서 추상화해서 변환해주는 것이다. </p>
<p>JDBC의 SQLException 
JPA의 PersistenceException 
MyBatis의 예외</p>
<p>-&gt; DataAccessException (스프링 추상화 예외) 이렇게 하나로 통일해준다. 서비스계층은 <code>DataAccessException</code> 이 예외만 처리하면 된다. 
<code>@Configuration</code> : 스프링 설정 정보에서 사용, 스프링 빈이 싱글톤을 유지하도록 추가 처리를 한다. </p>
<p>이것들도 내부적으로 <code>@Component</code>를 포함하고 있는 것이다. </p>
<p>그런데 애노테이션은 상속 관계라는 것은 없다. 이렇게 애노테이션이 특정 애노테이션을 들고있는것을 인식할 수 있는 건 자바가 아니라 스프링이 지원하는 기능이다.</p>
<p>애노테이션 자체가 메타정보이기 때문에 특별한 처리를 안하는 경우도 있다. 
<code>useDefaultFilters</code> 옵션이라는게 기본으로 켜져있는데 이 옵션을 끄면 기본 스캔 대상들이 제외된다. </p>
<h2 id="필터">필터</h2>
<p><code>includeFilters</code> : 컴포넌트 스캔 대상을 추가로 지정한다.
<code>excludeFilters</code> : 컴포넌트 스캔에서 제외할 대상을 지정한다.</p>
<p>근데 실무에서는 잘 안쓴다고 한다. </p>
<pre><code>@Target(ElementType.TYPE) // 클래스에만 붙일 수 있음 
@Retention(RetentionPolicy.RUNTIME) // 리플렉션으로 읽을 수 있게 런타임까지 정보 유지
@Documented
public @interface MyIncludeComponent { // 인터페이스로 선언하면 애노테이션이 생성됨
}</code></pre><pre><code class="language-java">@MyIncludeComponent
public class BeanA {
}</code></pre>
<p>이렇게하면 BeanA에 MyIncludeComponent라는 설정이 붙는다. </p>
<pre><code class="language-java">@ComponentScan(
    includeFilters = @Filter(type = FilterType.ANNOTATION, classes = MyIncludeComponent.class)
)</code></pre>
<pre><code class="language-java">@ComponentScan(
includeFilters = @Filter(type = FilterType.ANNOTATION, classes =
MyIncludeComponent.class),
excludeFilters = @Filter(type = FilterType.ANNOTATION, classes =
MyExcludeComponent.class)
)</code></pre>
<p>이렇게 BeanB에는 MyExcludeComponent라는 어노테이션을 붙여주고, excludeFilters로 추가하면 스프링 빈에 등록되지 않는다. </p>
<h3 id="filtertype-옵션">FilterType 옵션</h3>
<ul>
<li>ANNOTATION: 기본값, 애노테이션을 인식해서 동작한다.즉 어노테이션이 붙어있는지로 판단해서 , 이게 붙어있는 클래스를 찾아라 이런것이다.
ex) <code>org.example.SomeAnnotation</code></li>
<li>ASSIGNABLE_TYPE: 지정한 타입과 자식 타입을 인식해서 동작한다. 특정 클래스나 인터페이스를 상속,구현했는지로 판단하고 애노테이션이 아니라 타입 자체로 거른다. 
ex) <code>org.example.SomeClass</code></li>
<li>ASPECTJ: AspectJ 패턴 사용해서, 조금 더 복잡한 조건을 표현식으로 쓸 수 있는데 거의 안쓴다.
ex) <code>org.example..*Service+</code></li>
<li>REGEX: 정규 표현식, 클래스 풀네임을 매칭한다. 
ex) <code>org\.example\.Default.*</code></li>
<li>CUSTOM: <code>TypeFilter</code> 이라는 인터페이스를 구현해서 처리한다. 이 클래스를 포함할지 말지를 boolean으로 직접 판단하는 match 메서드를 작성한다. 
ex) <code>org.example.MyTypeFilter</code></li>
</ul>
<h2 id="중복-등록과-충돌">중복 등록과 충돌</h2>
<p>컴포넌트 스캔에서 같은 빈 이름이 등록되면, 오류를 발생시킨다. </p>
<h3 id="수동-빈-vs-자동-빈-충돌">수동 빈 vs 자동 빈 충돌</h3>
<p>수동 vs 자동 이름 겹침 → 원래는 수동이 이겨서(오버라이딩) 우선권을 가진다.
근데 스프링 부트는 기본적으로 에러나게 바꿔놓는다.</p>
<pre><code>Overriding bean definition for bean &#39;memoryMemberRepository&#39; with a different
definition: replacing</code></pre><p>로그를 친절하게 이렇게 띄워준다. 
그런데 스프링에서 이런 오류가 나는게 그냥 설정이 꼬여서 순수 실수에 의한 것이기 때문에 스프링 부트에서는 이를 무조건 잡을 수 있게, 충돌이 발생한다. 다음과 같이 에러가 발생한다. </p>
<p><code>Consider renaming one of the beans or enabling overriding by setting
spring.main.allow-bean-definition-overriding=true</code></p>
<h3 id="자동-vs-자동">자동 vs 자동</h3>
<p>자동 vs 자동 이름 겹침 → 무조건 에러를 발생시킨다. (ConflictingBeanDefinitionException)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] 스프링 기본2 - 싱글톤 컨테이너]]></title>
            <link>https://velog.io/@jayaione_ele/Spring-%EC%8A%A4%ED%94%84%EB%A7%81-%EA%B8%B0%EB%B3%B8-%EC%8B%B1%EA%B8%80%ED%86%A4-%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88</link>
            <guid>https://velog.io/@jayaione_ele/Spring-%EC%8A%A4%ED%94%84%EB%A7%81-%EA%B8%B0%EB%B3%B8-%EC%8B%B1%EA%B8%80%ED%86%A4-%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88</guid>
            <pubDate>Wed, 16 Sep 2026 20:28:59 GMT</pubDate>
            <description><![CDATA[<h2 id="웹-애플리케이션과-싱글톤">웹 애플리케이션과 싱글톤</h2>
<p>memberService라는게 있는데, 이걸 호출할 때마다 객체를 생성하도록 AppConfig에 요청하게 되면 매번 다른 객체를 생성하게 된다. </p>
<p>내부에 memberRepository도 생성자에 담고 있다면 더 생성된다. 그러면 메모리 낭비가 심해진다.</p>
<p>이를 해결하려면 싱글톤 패턴으로, 해결방안은 해당 객체가 딱 1개만 생성되고 공유하도록 설계하면 된다.</p>
<h2 id="싱글톤-패턴">싱글톤 패턴</h2>
<p>클래스,인스턴스가 1개만 생성되는 것을 보장하는 디자인 패턴이다.
객체 인스턴스를 두개 이상 생성하지 못하도록 막아야 하는데, private 생성자로만 객체 생성을 막는 방법이 있다. </p>
<p>적용하는 방법은 다음과 같다. </p>
<pre><code class="language-java">package hello.core.singleton;

public class SingletonService {
    //1. static 영역에 객체를 딱 1개만 생성해둔다.
    private static final SingletonService instance = new SingletonService();
    //2. public으로 열어서 객체 인스턴스가 필요하면 이 static 메서드를 통해서만 조회하도록 허용한
    다.
    public static SingletonService getInstance() {
        return instance;    
    }
    //3. 생성자를 private으로 선언해서 외부에서 new 키워드를 사용한 객체 생성을 못하게 막는다.
    private SingletonService() {
    }
    public void logic() {
        System.out.println(&quot;싱글톤 객체 로직 호출&quot;);
    }
}</code></pre>
<ol>
<li>static 영역에서 객체 인스턴스를 미리 하나 생성한다.</li>
<li>이 객체 인스턴스가 필요하면 getInatance()로만 조회 가능하고, 항상 같읕 인스턴스를 반환한다. </li>
<li>생성자를 private으로 막아서 외부에서 new 키워드로 객체 인스턴스가 생성되는 것을 막는다. 1개만 생성되어야 하기 때문이다. </li>
</ol>
<h3 id="문제점">문제점</h3>
<ul>
<li>싱글톤 패턴을 구현하는 코드 자체가 많이 들어간다. private으로 만들고, getInstance()를 만들고 등등 할일이 많다. </li>
<li>의존관계상 클라이언트가 구체 클래스에 의존한다. AppConfig에서 뭔가 하려고 하면 MemberServiceImpl.어쩌고 해서 꺼내야 하기 때문에, 구체 클래스에 의존하게 된다. -&gt; DIP를 위반한다.</li>
<li>클라이언트가 구체 클래스에 의존해서 OCP 원칙을 위반할 가능성이 높다. </li>
<li>테스트하기 어렵다. 인스턴스를 다 받아와서 설정이 끝나버리기 때문에, 유연한 테스트가 어렵다. </li>
<li>내부 속성을 변경하거나 초기화 하기 어렵다.</li>
<li>private 생성자로 자식 클래스를 만들기 어렵다.</li>
<li>결론적으로 유연성이 떨어진다. DI 적용하기가 어렵다. </li>
<li>안티패턴으로 불리기도 한다.</li>
</ul>
<h2 id="싱글톤-컨테이너">싱글톤 컨테이너</h2>
<p>스프링 컨테이너는 싱글톤 패턴의 문제점을 해결해준다. 그러면서 객체 인스턴스를 싱글톤으로 관리한다. 
즉 빈이라는 걸 싱글톤으로 관리한다. </p>
<p>즉 싱글톤 패턴을 따로 코드를 안써줘도 객체 인스턴스를 싱글톤으로 관리한다.
컨테이너는 객체를 하나만 생성해서 관리한는데, 스프링 컨테이너는 싱글톤 컨테이너 역할을 하는데, 싱글톤 레지스트리라고 한다. </p>
<p>단점 해결도 가능하다.</p>
<ul>
<li>지저분한 코드를 작성하지 않아도 된다. </li>
<li>OCP 원칙 위반도 안된다. 구현체를 바꿔도 스프링 설정(config)만 수정하면 되고,  클라이언트 코드는 안 건드려도 된다.</li>
<li>테스트도 자유로워진다. 순수 자바 객체(POJO)처럼 다루기 때문에 Mock 객체 주입이 쉬움. 원래 싱글톤 패턴은 생성자를 막아놔서 테스트용 가짜 객체를 넣기 힘들었는데, 스프링은 생성자 주입이라 테스트에서 원하는 구현체를 자유롭게 넣을 수 있다. </li>
<li>결론적으로 유연성 문제가 해결된다. 스프링 컨테이너가 객체 생성과 생명주기 관리를 대신 해주면서, 객체 자체는 평범한 자바 객체로 유지되니까 DI가 자연스럽게 적용된다. </li>
</ul>
<p>스프링 컨테이너 덕분에 고객의 요청이 올 때마다 객체를 생성하는 게 아니라, 이미 만들어진 객체를 공유해서 효율적으로 재사용할 수 있다. </p>
<p>스프링의 기본 빈 등록 방식은 싱글톤인데 싱글톤 방식만 지원하는건 아니고 요청 할때마다 새로운 객체 생성하고, 반환하는 기능도 제공한다.  -&gt; 빈 스코프라고 한다. </p>
<h2 id="싱글톤-방식-주의점">싱글톤 방식 주의점</h2>
<p>싱글톤 객체는 상태를 유지하게 설계하면 안된다. 같은 객체 인스턴스를 공유하기 때문이다.</p>
<p><strong>무상태로 설계해야한다.!</strong></p>
<ul>
<li>특정 클라이언트에 의존적인 필드가 있으면 안된다. </li>
<li>특정 클라이언트가 값을 변경할 수 있는 필드가 있으면 안된다. </li>
<li>가급적 읽기만 가능해야 한다.</li>
<li>필드 대신에 자바에서 공유되지 않는 지역변수,파라미터,ThreadLocal 등을 사용해야 한다. </li>
<li>스프링 빈의 필드에 공유 값을 설정하면 절대 안된다. !!</li>
</ul>
<p>-&gt; 예시: 스레드A - 사용자1이 주문, 스레드B - 사용자2가 주문했는데 A가 그 이후 조회를 하면, 금액이 변경된다. 즉 공유값을 price로 설정하면 문제가 생긴다. </p>
<h2 id="configuration과-싱글톤">Configuration과 싱글톤</h2>
<pre><code class="language-java">@Configuration
public class AppConfig {

    @Bean
    public MemberService memberService() {
        return new MemberServiceImpl(memberRepository());
    }

    @Bean
    public OrderService orderService() {
        return new OrderServiceImpl(
                memberRepository(),
                discountPolicy()
        );
    }

    @Bean
    public MemberRepository memberRepository() {
        return new MemoryMemberRepository();
    }

    @Bean
    public DiscountPolicy discountPolicy() {
        return new FixDiscountPolicy();
    }
}</code></pre>
<p>memberService 빈을 만드는 코드 -&gt; memberRepository를 호출한다. 
이 메서드를 호출하면 new MemoryMemberRepository()를 호출한다. 
orderService 빈을 만드는 코드도 memberRepository를 호출한다. 
이 메서드를 호출하면 new MemoryMemberRepository()를 호출한다. </p>
<p>-&gt; 결과적으로 다른 2개의 레포지토리가 생성된다. 그러면 싱글톤이 깨지는 것처럼 보인다. </p>
<p>-&gt; 테스트를 해서 확인해보면, memberRepository는 모두 같은 인스턴스가 공유되어 사용된다. 각각 2번 호출했는데, 다른 인스턴스가 생성되지 않는 이유는..  스프링이 클래스의 바이트코드를 조작하는 라이브러리를 사용하는데, 
결론적으로는 <code>@Configuration</code> 이 붙은 클래스를 스프링이 그대로 쓰지는 않고, CGLIB로 바이트코드를 조작해서 상속받은 가짜 클래스, 프록시를 만들어서 등록하기 때문이다. </p>
<p>즉 memberRepository() 호출 -&gt; 스프링 컨테이너에 이미 빈 등록 -&gt; new MemoryMemberRepository() 실행 안함 -&gt; 컨테이너에 있는 기존 인스턴스를 그냥 반환하는 것이다. </p>
<p>의사코드로 보면 대강 이런식으로 동작한다. </p>
<pre><code class="language-java">@Bean
public MemberRepository memberRepository() {
    if (memberRepository가 이미 스프링 컨테이너에 등록되어 있으면) {
        return 컨테이너에서 찾은 그 memberRepository 반환;
    } else {
        기존 로직 실행해서 MemoryMemberRepository 생성;
        스프링 컨테이너에 등록;
        그 인스턴스 반환;
    }
}</code></pre>
<p>그래서 orderService() 안에서 memberRepository()를 호출하더라도, new 키워드로 새로 인스턴스가 생성되는게 아니다. <strong>이미 등록된 빈을 컨테이너에서 꺼내오기</strong> 만 하는 것이다. </p>
<p>이 모든 것을 <code>@Configuration</code> 어노테이션을 붙이면, 프록시 처리를 해서 해주는 것이다. <code>@Bean</code>만 쓰면 호출할 때마다 새로운 인스턴스가 생성되어 싱글톤이 깨진다.</p>
<p><code>AnnotationConfigApplicationContext</code> 에 파라미터로 넘긴 값은 스프링 빈으로 등록된다. 그래서
<code>AppConfig</code> 도 스프링 빈이 된다.
<code>AppConfig</code> 스프링 빈을 조회해서 클래스 정보를 출력해본다. </p>
<p>내부적으로 좀 보면, bean을 찍어보면 bean = class hello.core.AppConfig$$EnhancerBySpringCGLIB$$bd479d70 이런식으로 찍힌다. </p>
<p>순수한 클래스라면, <code>class hello.core.AppConfig</code> 이렇게 출력되어야 한다. </p>
<p>-&gt; 직접 만든게 아니라, 스프링이 CGLIB라는 바이트코드 조작 라이브러리를 사용해서, AppConfig 클래스를 상속받은 임의의 다른 클래스를 만들고, 그 다른 클래스를 스프링 빈으로 등록한 것이다. 
즉 이게 순수 자바 코드라면, </p>
<pre><code>AppConfig appConfig = new AppConfig();
appConfig.memberRepository(); // new MemoryMemberRepository() 1
appConfig.memberRepository(); // new MemoryMemberRepository() 2
// 1,2 는 다른 인스턴스다.</code></pre><p>-&gt; 임의의 다른 클래스가 싱글톤이 보장되게 해주는 것이다. 즉 바이트 코드를 조작해서, 작성되어 있다. </p>
<p>결론적으로는 <code>@Configuration</code>이 있어야 한다. Bean만 사용하면 안된다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[회고] 독서 기록 서비스 NOOK 회고록]]></title>
            <link>https://velog.io/@jayaione_ele/%ED%9A%8C%EA%B3%A0-%EB%8F%85%EC%84%9C-%EA%B8%B0%EB%A1%9D-%EC%84%9C%EB%B9%84%EC%8A%A4-NOOK-%ED%9A%8C%EA%B3%A0%EB%A1%9D</link>
            <guid>https://velog.io/@jayaione_ele/%ED%9A%8C%EA%B3%A0-%EB%8F%85%EC%84%9C-%EA%B8%B0%EB%A1%9D-%EC%84%9C%EB%B9%84%EC%8A%A4-NOOK-%ED%9A%8C%EA%B3%A0%EB%A1%9D</guid>
            <pubDate>Tue, 08 Sep 2026 19:17:10 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/ef7ad665-2c89-4fd4-bdeb-a80a86c1d70f/image.png" alt=""></p>
<p>이 프로젝트는 UMC 8기에서 시작되었다.</p>
<p>처음에 8기를 하면서 UMC SpringBoot 시니어로 참여했다. </p>
<p>&#39;NOOK&#39;에 지원한 이유는 디자인이 예뻐서였다! 목표가 캐릭터가 이쁘고, 분위기있는 디자인이 있는 (나는 백엔드지만) 프로젝트를 하는게 8기 목표였다. </p>
<p>백엔드는 3명이었지만 팀원들이 다들 열심히 해줬고, 프론트도 너무 좋고 열심히 하는 팀원들을 만나서 잘 끝마쳤던 것 같다. 무엇보다 멋지고 귀여운 디자이너, 프로페셔널 카리스마 PM의 공이 컸다 ㅎㅎ</p>
<p>다인원 협업은 정말 오랜만이었는데,처음에는 사람이 많아서 적응하기 힘들었지만 잘 운영하고 이끌어준 PM님ㅎㅎ 덕에 수월하게 진행이 되었다. </p>
<p>8기 데모데이에도 사람이 정말 많이 왔다. !</p>
<p>목걸이도 각자 캐릭터대로 커스터마이징했다.(디자이너 최고)
<img src="https://velog.velcdn.com/images/jayaione_ele/post/d063e661-c4b6-4316-8bcd-e159bbe2bea9/image.jpg" alt=""></p>
<p>명함,끈갈피 만들기 이벤트도 했다. 
<img src="https://velog.velcdn.com/images/jayaione_ele/post/70915237-f68a-4702-8da6-dbe22d657069/image.png" alt=""></p>
<p>데모데이가 끝나고, <strong>런칭</strong>을 하자는 말이 나왔다. 팀원들이 다 바빠질 것 같고, 오래 걸릴 수도 있겠지만 코드 리팩토링하고 싶은 부분도 많았어서 하기로 했다. </p>
<p>리팩토링하면서 정말 많이 개선했던 것 같다. 기능이 바뀌기도 했지만, 기존 코드에서 마음에 안들었던 부분을 많이 뜯어고치고, 공부도 많이 했다. </p>
<p>특히 테스트코드 세팅이나 공부, 캐싱이나 인덱스, 모니터링 등등.. 해보고 싶은거 다 해봤다.!</p>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/642b9fc5-8a5d-4a2f-9b63-3961fecc323a/image.png" alt=""></p>
<p>많은 일도 있었다. 프론트,백엔드 팀원이 각각 한명씩 나가면서, 역할 재분배도 되고... </p>
<p>중간중간에 회의도 간간히 했다. 초기에는 위클리 스크럼을 하면서 매주 한 일 보고를 했다. </p>
<p>많이 안한 일이 있으면 좀 죄책감을 가지고 했던 것 같다. ㅎㅎ</p>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/117e4516-843a-4a13-9de3-ac6f44e51be9/image.png" alt=""></p>
<p>간간히 대면 회의도 했다. </p>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/1212df98-2786-4acf-ab8d-f75ebba9311a/image.png" alt=""></p>
<p>(나) (다른 백엔드 개발자) 인데 _신입사원 + CTO 같다는 ... _</p>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/96e7e014-02bc-4f85-af1a-8058b1d005aa/image.png" alt=""></p>
<h2 id="결론">결론</h2>
<p>런칭 했어요.!!</p>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/09f76d6d-1e9d-46e7-b1e2-750ff92058e7/image.png" alt=""></p>
<p>링크 : <a href="https://www.booknook.page">https://www.booknook.page</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] 스프링 기본1 - IoC,DI, 스프링 컨테이너와 스프링 빈]]></title>
            <link>https://velog.io/@jayaione_ele/Spring-IoCDI-%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88</link>
            <guid>https://velog.io/@jayaione_ele/Spring-IoCDI-%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88</guid>
            <pubDate>Thu, 03 Sep 2026 09:14:12 GMT</pubDate>
            <description><![CDATA[<h2 id="제어의-역전-iocinversion-of-control">제어의 역전 IoC(Inversion of Control)</h2>
<ul>
<li>제어의 역전이란 외부에서 프로그램 제어 흐름을 관리하는 것이다. </li>
<li>AppConfig가 프로그램의 제어권을 가져간다. </li>
<li>즉 서비스의 구현 객체는 자신의 로직을 실행하는 역할만 담당하고, 제어 흐름은 AppConfig가 가지고 있다. 구현 객체는 필요한 인터페이스를 호출하지만, 외부에서 관리하므로 어떤 구현 객체들이 실행될지 모른다. </li>
</ul>
<h3 id="프레임워크와-라이브러리">프레임워크와 라이브러리</h3>
<ul>
<li>프레임워크가 내가 작성한 코드를 제어하고, 대신 실행하면 프레임워크이다. Junit, SpringBoot 등등</li>
<li>작성한 코드가 직접 제어 흐름을 담당하는 것은 라이브러리이다. </li>
</ul>
<h2 id="의존관계-주입-didependency-injection">의존관계 주입 DI(Dependency Injection)</h2>
<ul>
<li>정적 클래스 의존관계와 실행 시점에 결정되는 동적 객체(인스턴스) 의존 관계 둘을 분리해서 생각해야 한다. </li>
<li>Service 구현체는 인터페이스에 의존하고, 어떤 구현 객체가 사용될지는 모른다. </li>
</ul>
<h3 id="정적-클래스-의존관계">정적 클래스 의존관계</h3>
<ul>
<li>클래스가 사용하는 import 문만 보고 의존관계를 쉽게 판단할 수 있다. Service가  MemberRepository를 참조하고 있는 걸 보면 의존한다는 것을 알 수 있다.</li>
<li>그러나 실제로 어떤 객체가 서비스 구현체에 주입될지는 알 수 없다. </li>
</ul>
<h3 id="동적-객체-인스턴스-의존관계">동적 객체 인스턴스 의존관계</h3>
<ul>
<li>애플리케이션 실행 시점에 실제 생성된 객체 인스턴스의 참조가 연결된 의존 관계이다. </li>
<li>애플리케이션 실행 시점, 런타임에 외부에서 실제 구현 객체를 생성하고 클라이언트에 전달해서, 클라이언트와 서버 실제 의존 관계가 연결되는 것을 의존관계 주입이라고 한다. </li>
<li>즉 객체 인스턴스를 생성하면 참조값을 전달해서 연결되는 것이다. </li>
<li>의존관계 주입을 사용하면 클라이언트 코드를 변경하지 않아도 호출하는 대상의 타입 인스턴스 변경이 가능하다. 즉 정적 클래스 구조를 손대지 않아도, 쉽게 변경 가능하다. </li>
</ul>
<h3 id="ioc-컨테이너-di-컨테이너">IoC 컨테이너, DI 컨테이너</h3>
<ul>
<li>AppConfig처럼 외부에서 객체를 생성하고 관리하면서 의존관계를 연결해주는 것을 DI 컨테이너 또는 IoC 컨테이너라고 한다. </li>
</ul>
<h3 id="appconfig-설정하기">AppConfig 설정하기</h3>
<ul>
<li>Appconfig 설정을 구성한다는 뜻의 <code>@Configuration</code> 어노테이션을 붙여주고, 각 메서드에 <code>@Bean</code> 어노테이션을 붙여주면, 스프링 컨테이너에 빈으로 등록한다는 것이 된다. </li>
</ul>
<h3 id="dl-dependency-lookup">DL (Dependency Lookup)</h3>
<ul>
<li><p>MemberServiceImpl이 자기 자신 안에서 getBean()을 호출해서 의존 객체를 능동적으로 검색한다. </p>
</li>
<li><p>문제: 이 클래스는 이제 &#39;스프링이 있다&#39;는 걸 알아야 하고, ApplicationContext라는 스프링 API에 직접 의존하게 된다.. → 순수한 비즈니스 로직이 프레임워크에 오염된다. 옛날 방식이다. </p>
</li>
</ul>
<p>단순히 호출하는게 아니라, 의존관계를 검색을 하고, 자신이 필요로 하믄 의존 객체를 능동적으로 찾는다.
즉 의존관계를 맺을 객체 결정,생성은 외부 컨테이너에서 IoC로 처리하지만, 가져올 때는 스스로 컨테이너에게 요청을 한다. 이렇게 검색을 할 때 getBean() 메서드를 호출한다. </p>
<p>예전과 다르게 DI는, MemberServiceImpl은 getBean이 뭔지도 모르고, ApplicationContext라는게 뭔지 모른다. 
그러면 getBean을 누가 호출해주냐면, <code>@Autowired</code> 필드나 서블릿 진입점 등이 이 역할을 대신해준다. </p>
<h2 id="스프링-컨테이너">스프링 컨테이너</h2>
<ul>
<li><code>ApplicationContext</code>를 스프링 컨테이너라고 한다.  모든 빈을 관리한다. </li>
<li>AppConfig 대신 스프링 컨테이너를 통해서 사용한다.</li>
<li><code>@Configuration</code>이 붙은 AppConfig를 설정 정보로 사용하고, Bean 어노테이션이 붙은 메서드를 모두 호출해서 반환된 객체를 스프링 컨테이너에 등록한다. 이 객체를 스프링 빈이라고 한다. </li>
<li>스프링 빈은 applicationContext.getBean() 메서드로 찾을 수 있고, 스프링 컨테이너에 등록된 스프링 빈을 찾아서 사용이 가능하다.</li>
</ul>
<pre><code class="language-java">
@Configuration
public class AppConfig {
    @Bean
    public MemberService memberService() {
        return new MemberServiceImpl(memberRepository());
    }
}
</code></pre>
<p>-&gt; 메서드명 memberService로 빈 등록이 된 것이다. </p>
<pre><code class="language-java">
ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
MemberService memberService = ac.getBean(&quot;memberService&quot;, MemberService.class);
</code></pre>
<p>이렇게하면 appConfig -&gt; memberService 순으로 등록이 된다. </p>
<h4 id="configuration은-싱글톤을-어떻게-보장하는지">Configuration은 싱글톤을 어떻게 보장하는지?</h4>
<p>memberService() 안에서 memberRepository()를 직접 호출할하고 있는데, 객체가 호출 시마다 생성하는 게 아니라, 이미 등록된 빈이 있으면 반환하고 없으면 등록한다. 이게 어떻게 되는 걸까..?</p>
<p><code>@Configuration</code>이 붙은 클래스는 스프링이 CGLIB 바이트코드 조작 라이브러리로 AppConfig를 상속받은 임의의 다른 클래스를 만들고, 그 다른 클래스를 스프링 빈으로 등록한다. </p>
<p>이 프록시 클래스가 <code>@Bean</code>이 붙은 메서드마다 이미 스프링 컨테이너에 등록된 빈이 있으면 그 빈을 반환하고, 없으면 생성해서 등록 후에 반환하는 <strong>코드를 생성해준다.</strong></p>
<p><strong>그래서 싱글톤이 깨지지 않는 것이다.</strong></p>
<p><code>@Configuration</code> 없이 단독으로 사용한다면 <code>@Component</code>클래스에 Bean을 붙이는 식이 될 건데, 이렇게 하면 CGLB 프록시가 적용되지 않아서 메서드 호출 시마다 새로운 객체가 생성되고, 이 경우에 싱글톤이 깨진다. </p>
<p>CGLB 프록시는 중간에서 메서드 호출을 가로채는 프록시다. 그래서 적용되면 이미 있는 것을 반환하고 호출은 패스하도록 해주는 것이다. </p>
<p>근데 이 방법이 컴포넌트 스캔이랑은 어떻게 다른지? 궁금했다..</p>
<h3 id="컴포넌트-스캔--생성자-주입-방식과의-차이">컴포넌트 스캔 + 생성자 주입 방식과의 차이</h3>
<p>여기서 MemberService가 memberRepository를 생성자 파라미터로 가지고 있기 때문에 repository 메서드를 호출하는게 아니라, 컨테이너가 이미 만들어놓은 인스턴스를 생성자 파라미터로 꽂아준다. </p>
<p>즉 여기서는 프록시가 별도로 필요가 없다. </p>
<pre><code class="language-java">@Repository
public class MemoryMemberRepository implements MemberRepository { ... }

@Service
public class MemberServiceImpl implements MemberService {
    private final MemberRepository memberRepository;

    public MemberServiceImpl(MemberRepository memberRepository) { // 호출X , 주입
        this.memberRepository = memberRepository;
    }
}

@Service
public class OrderServiceImpl implements OrderService {
    private final MemberRepository memberRepository;

    public OrderServiceImpl(MemberRepository memberRepository) { // 주입
        this.memberRepository = memberRepository;
    }
}</code></pre>
<h3 id="bean">Bean</h3>
<p>빈 또는 빈 오브젝트는 스프링이 IoC 방식으로 관리하는 오브젝트라는 뜻이다.
스프링을 사용하는 애플레이케이션에서 만들어지는 모든 오브젝트가 다 빈은 아니고, 스프링이 생성과 제어를 담당하는 오브젝트만을 빈이라고 한다. </p>
<h3 id="beanfactory">BeanFactory</h3>
<ul>
<li>스프링 컨텍스트의 최상위 인터페이스이다. </li>
<li>스프링의 IoC를 담당하는 핵심 컨테이너를 가리킨다. 빈의 생명 주기, 부가적인 빈 관리 기능을 담당한다.</li>
<li>보통 빈 팩토리를 바로 사용하는 건 아니고, 이를 확장한 애플리케이션 컨텍스트를 사용한다. BeanFactory 안에는 getBean()과 같은 메서드가 정의되어 있다. </li>
</ul>
<h3 id="applicationcontext">ApplicationContext</h3>
<ul>
<li><code>ApplicationContext</code> <ul>
<li>앞서 스프링 컨테이너라고 했는데, 인터페이스이다. </li>
<li>빈 팩토리를 확장한 IoC 컨테이너라고 할 수 있다. 빈 팩토리의 기능과 동일한데 여기서 스프링 제공 부가 서비스를 추가로 제공한다. </li>
<li>즉 빈 팩토리를 상속한다. 빈 팩토리는 사용하지 않지만, 구분해서 설명하기는 한다.</li>
</ul>
</li>
<li>모든 컨테이너는 XML을 기반으로 만들 수 있고, 애노테이션 기반의 자바 설정 클래스로 만들 수 있다.</li>
<li>직전에 AppConfig를 사용한 방식이 어노테이션 기반의 자바 클래스로 스프링 컨테이너를 만든 것이다. </li>
</ul>
<h3 id="스프링-컨테이너-생성-과정">스프링 컨테이너 생성 과정</h3>
<p><strong>빈 등록</strong> </p>
<ul>
<li>스프링 컨테이너를 생성할 때는, Appconfig.class라는 것에 구성 정보를 지정하고 만들어줘야 한다. </li>
<li>스프링 컨테이너는 파라미터로 들어오는 설정 클래스 정보를 사용해서 스프링 빈을 등록한다. </li>
</ul>
<p><code>@Bean</code> 어노테이션을 달아준 memberService가 있다고 하자. 이걸 스프링 컨테이너의 빈 저장소에 이름,객체 이렇게 등록을 해준다. 빈 이름은 메서드명을 하용하지만, 직접 부여할 수도 있다. </p>
<blockquote>
<p>빈은 항상 다른 이름을 부여해야 한다. 같은 이름을 부여하면 다른 빈이 무시되거나 덮어쓰기 하거나 하는 설정에 따라 오류가 발생한다. </p>
</blockquote>
<p><strong>스프링 빈 의존관계 설정</strong></p>
<p>memberService -&gt; memberRepository 
orderService -&gt; discountPolicy </p>
<p>이런식으로 의존관계가 있을 것이다. 스프링 컨테이너는 설정 정보를 참고해서 의존관계를 주입한다. 
스프링 컨테이너는 설정 정보를 참고해서 의존관계를 주입(DI)한다.</p>
<p><strong>빈 조회</strong></p>
<p>빈 조회하기: <code>ac.getBeanDefinitionNames()</code> : 스프링에 등록된 모든 빈 이름을 조회한다.
빈 이름으로 빈 객체 조회 : <code>ac.getBean()</code>, <code>ac.getBean(빈이름,타입)</code>
동일한 타입이 둘 이상이면 오류가 발생한다. </p>
<p><code>ac.getBeansOfType()</code>을 사용하면 해당 타입의 모든 빈을 조회할 수 있다.</p>
<p><strong>스프링 빈 조회 - 상속 관계</strong></p>
<ul>
<li>부모 타입으로 조회하면 자식 타입도 함께 조회한다.</li>
<li>모든 자바 객체 부모인 Object 타입으로 조회하면 모든 스프링 빈을 조회한다. </li>
</ul>
<p><strong>ApplicationContext가 제공하는 부가 기능</strong></p>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/b65bd7f4-e406-4697-b235-d9f0b89faf57/image.png" alt=""></p>
<ul>
<li>메시지소스를 활용한 국제화 기능: 한국 -&gt; 한국어 영어 -&gt; 영어로 출력</li>
<li>환경변수: 로컬, 개발, 운영 등의 프로파일을 구분해서 처리한다.</li>
<li>Application Event: 이벤트 발행,구독 모델을 편리하게 지원한다.</li>
<li>편리한 리소스 조회: 파일, 클래스패스,외부 등에서 리소스를 편리하게 조회한다. </li>
</ul>
<h3 id="다양한-설정-형식-지원---자바-코드-xml">다양한 설정 형식 지원 - 자바 코드, XML</h3>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/02a1bf82-a9ee-4ef9-9c91-2aec6b5e73f4/image.png" alt=""></p>
<ul>
<li>애노테이션 기반 자바 코드 설정 사용</li>
<li>XML 설정 사용: 이건 잘 하지 않는데, 컴파일 없이 빈 설정 정보를 변경할 수 있다는 장점이 있다.</li>
<li>GenericXmlApplicationContext를 사용하면서 xml 설정 파일을 넘기면 된다.</li>
</ul>
<pre><code>public class XmlAppContext {
    @Test
    void xmlAppContext() {
        ApplicationContext ac = new
    GenericXmlApplicationContext(&quot;appConfig.xml&quot;);
        MemberService memberService = ac.getBean(&quot;memberService&quot;,
    MemberService.class);
        assertThat(memberService).isInstanceOf(MemberService.class);
}</code></pre><h3 id="beandefinition---스프링-빈-설정-메타-정보">BeanDefinition - 스프링 빈 설정 메타 정보</h3>
<ul>
<li><code>BeanDefinition</code> 이라는 추상화로 스프링은 다양한 설정 형식을 지원한다. 즉 BeanDefinition 자체가 인터페이스고 스프링 컨테이너는 추상화된 BeanDefinition에만 의존한다. </li>
<li>스프링 컨테이너는 자바 코드인지, XML인지 몰라도 된다. 오직 BeanDefinition만 알면 된다.
<code>BeanDefinition</code> 을 빈 설정 메타정보라 한다.
<code>@Bean</code> , <code>&lt;bean&gt;</code> 당 각각 하나씩 메타 정보가 생성된다.
스프링 컨테이너는 이 메타정보를 기반으로 스프링 빈을 생성한다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/82883318-1c7f-4e15-8ce7-ceed598dd8b4/image.png" alt=""></p>
<ul>
<li><code>AnnotationConfig</code>안의 <code>AnnotatedBeanDefinitionReader</code> : <code>AnnotationConfigApplicationContext</code> 안에 들어가보면 있는 건데, 이걸 사용해서 AppConfig.class 내부의 설정 정보 등등이 있는 코드를 읽고 <code>BeanDefinition</code>이라는 빈 메타정보를 생성한다.</li>
<li>Xml도 마찬가지로 가능하다. AppConfig를 가지고 BeanDefinition을 생성한다. </li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Server] HTTP 캐시]]></title>
            <link>https://velog.io/@jayaione_ele/Server-HTTP-%EC%BA%90%EC%8B%9C</link>
            <guid>https://velog.io/@jayaione_ele/Server-HTTP-%EC%BA%90%EC%8B%9C</guid>
            <pubDate>Tue, 25 Aug 2026 08:44:13 GMT</pubDate>
            <description><![CDATA[<h3 id="프록시-캐시">프록시 캐시</h3>
<ul>
<li><p>원 서버가 미국에 있을 경우 느리기 때문에 한국 어딘가에 프록시 캐시 서버를 두고, 해당 서버를 거쳐서 올 수 있도록 한다. </p>
</li>
<li><p>그러면 웹브라우저가 프록시 캐시 서버에 접근하게 된다. </p>
</li>
<li><p>응답 시간이 훨씬 빨라진다.</p>
</li>
<li><p>프록시 캐시는 public, 로컬 캐시는 private</p>
</li>
</ul>
<h3 id="캐시-지시어">캐시 지시어</h3>
<p>Cache-Control: <public> 이런식으로 사용한다. </p>
<ul>
<li>private: 응답이 해당 사용자만을 위한 것이므로 로컬에만 저장되어야 한다. </li>
<li>public: 응답이 public 캐시에 저장되어도 된다.</li>
<li>s-maxage: 프록시 캐시에만 적용되는 max-age이다.</li>
</ul>
<h3 id="캐시-무효화">캐시 무효화</h3>
<ul>
<li>Cache-Control 캐시 지시어: no-cache,no-store,must-revaildate 필수<ul>
<li>no-cache: 항상 원 서버에 검증하고 사용해야 한다. </li>
<li>no-store: 데이터에 민감한 정보가 있으므로 저장하면 안된다. 메모리에서 사용하고 최대한 빨리 삭제해야 한다. </li>
<li>must-revalidate: <ul>
<li>캐시 만료 후에 최초 조회 시 원 서버에 검증해야한다. 원 서버 접근 실패 시에 반듯이 504 타임아웃 오류가 발생해야 한다. <ul>
<li>no cache는 프록시 캐시 서버로 가는데, 원 서버에 요청을 하고 원 서버에서 검증을 하게 된다. 304 not modified로 응답을 하게 된다. 순간적으로 네트워크 단절되어 원 서버 접근이 불가능할 경우, 프록시 캐시에서 오류보다도 오래된 데이터로 보여주도록 세팅을 하게 되면 그렇게도 가능하게 정책이 되어 있다. </li>
<li>must-revalidate: 네트워크 단절되어 원 서버 접근 불가능한 경우 항상 오류가 발생되어 504 오류가 발생해야 한다. </li>
</ul>
</li>
</ul>
</li>
</ul>
</li>
<li>Pragma: no-cache, 과거 브라우저 HTTP 1.0 하위 호환이 가능하다. </li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Server] HTTP 지식 리마인드]]></title>
            <link>https://velog.io/@jayaione_ele/Server-HTTP-%EC%A7%80%EC%8B%9D-%EB%A6%AC%EB%A7%88%EC%9D%B8%EB%93%9C</link>
            <guid>https://velog.io/@jayaione_ele/Server-HTTP-%EC%A7%80%EC%8B%9D-%EB%A6%AC%EB%A7%88%EC%9D%B8%EB%93%9C</guid>
            <pubDate>Tue, 25 Aug 2026 07:46:38 GMT</pubDate>
            <description><![CDATA[<h3 id="http-헤더">HTTP 헤더</h3>
<ul>
<li><p>field-name은 대소문자 구분이 없다.</p>
</li>
<li><p>HTTP 전송에 필요한 모든 부가정보다.메시지 바디 내용, 바디 크기, 요청 브라우저 정보, 캐시 관리 정보 등등..</p>
</li>
<li><p>필요 시 임의의 헤더 추가가 가능하다. </p>
</li>
<li><p>Content-Type: 표현 데이터의 형식 설명, 미디어 타입,문자 인코딩.. image/png 이런식으로도 가능</p>
</li>
<li><p>Content-Encoding: 표현 데이터 압축하기 위해 사용한다. 데이터 전달하는 곳에서 압축 후에 인코딩 헤더를 추가한다. 데이터를 읽는 쪽에서 인코딩 헤더 정보로 압축을 해제한다. </p>
</li>
<li><p>Content-Lanaguage: 표현 데이터의 자연 언어를 표현한다. ko, en, en-US</p>
</li>
</ul>
<h3 id="api-uri-설계를-잘하는법">API URI 설계를 잘하는법</h3>
<ul>
<li>리소스 식별이 가장 중요하다. </li>
<li>회원이라는 개념이 리소스지 어떤 행위가 리소스가 아니므로 회원 자체를 식별할수만 있게 매핑해야 한다. </li>
<li>URI 계층 구조를 활용하고, 상위를 컬렉션으로 보고 복수단어 사용이 권장된다.</li>
<li>POST인데 조회 데이터 넘겨야 할때, 애매하면 POST를 쓴다.</li>
<li>POST 결과로 새로운 리소스가 생성되지 않을 수도 있다, 컨트롤 URI라고 한다. </li>
</ul>
<h3 id="http-메서드-속성">HTTP 메서드 속성</h3>
<ul>
<li><p>안전: 호출해도 리소스 변경이 안된다. </p>
</li>
<li><p>멱등: 한번 호출하든 두번 호출하든 결과가 똑같다.</p>
</li>
<li><p>캐시 가능: 실제로는 GET,HEAD 정도만 캐시로 사용한다. POST,PATCH는 본문 내용까지 고려해야 해서 구현이 쉽지 않다. </p>
</li>
<li><p>GET,HEAD,POST,PATCH 제외하면 다 캐시 불가능하다. </p>
</li>
<li><p>POST,PATCH,CONNECT는 멱등성 보장이 안된다. </p>
</li>
</ul>
<h3 id="기타-메서드-종류">기타 메서드 종류</h3>
<ul>
<li>HEAD: GET과 동일하지만 메시지 부분 제외하고 상태줄과 헤더만 반환한다.</li>
<li>OPTIONS: 대상 리소스에 대한 통신 가능 옵션을 설명하고 주로 CORS에서 사용한다. </li>
<li>CONNECT: 대상 리소스로 식별되는 서버에 대한 터널을 설정한다. </li>
<li>TRACE: 대상 리소스에 대한 경로를 따라서 메시지 루프백 테스트를 수행한다. </li>
</ul>
<h3 id="503-service-unavailable">503 Service Unavailable</h3>
<ul>
<li>서버가 일시적 과부하 또는 예정된 작업으로 잠시 요청을 처리할 수 없다. </li>
<li>Retry-After 헤더 필드로 얼마뒤에 복구되는지 보낼 수 있다. </li>
</ul>
<h3 id="쿠키-생명주기">쿠키 생명주기</h3>
<ul>
<li>expires: 만료일 되면 쿠키삭제</li>
<li>max-age: 0이나 음수 지정 시 쿠키 삭제</li>
<li>세션 쿠키: 만료 날짜 생략하면 브라우저 종료시까지만 유지</li>
<li>영속 쿠키: 만료 날짜 입력 시 해당 날짜까지 유지</li>
</ul>
<h3 id="쿠키-보안">쿠키 보안</h3>
<ul>
<li>Secure: 쿠키는 http, https 구분하지 않고 전송하고 Secure 적용 시 https인 경우메나 전송한다.</li>
<li>HttpOnly: XSS 공격 방지용 옵션으로 자바스크립트에서 접근 불가능하고 HTTP 전송에만 사용한다. </li>
<li>SameSite: XSRF 공격 방지용 옵션으로 요청 도메인과 쿠키에 설정된 도메인이 같은 경우만 쿠키를 전송한다. </li>
</ul>
<h3 id="검증-헤더와-조건부-요청">검증 헤더와 조건부 요청</h3>
<ul>
<li>클라이언트가 예전에 리소스를 받으면서 Last-Modified나 ETag 같은 검증 헤더를 캐시에 같이 저장해 두고,  캐시 max-age가 지나면 데이터 재사용 못하고 확인요청을 보낸다. </li>
<li>이때 If-Modified-Since 또는 If-None-Match 헤더에 아까 저장해둔 값을 실어서 보내서, 해당 버전이 아직 최신인지 묻는다.</li>
<li>서버가 확인해서 데이터가 안 바귀었다면 304 Not Modified를 바디 없이 헤더만 응답한다. </li>
<li>클라이언트는 헤더 정보만 갱신한다. 로컬 캐시 데이터를 재사용하면 되기 때문에..</li>
<li>요약하면 캐시 유효기간은 지났지만 실제 데이터가 안바뀐 경우, 헤더만 주고받아서 트래픽을 아낀다. </li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Server] HTTP 3XX - 리다이렉션]]></title>
            <link>https://velog.io/@jayaione_ele/Server-HTTP-3XX-%EB%A6%AC%EB%8B%A4%EC%9D%B4%EB%A0%89%EC%85%98</link>
            <guid>https://velog.io/@jayaione_ele/Server-HTTP-3XX-%EB%A6%AC%EB%8B%A4%EC%9D%B4%EB%A0%89%EC%85%98</guid>
            <pubDate>Tue, 25 Aug 2026 07:12:52 GMT</pubDate>
            <description><![CDATA[<h2 id="3xx-리다이렉션">3XX 리다이렉션</h2>
<p>요청 완료하기 위해 클라이언트 프로그램(웹브라우저)의 추가 조치가 필요할 때 보내는 상태코드이다.</p>
<h3 id="리다이렉션이란">리다이렉션이란?</h3>
<p>웹 브라우저는 3xx 응답의 결과에 Location 헤더가 있으면 Location 위치로 자동 이동한다.
요청을 하고 /event라는 엔드포인트로 요청했는데, 이 엔드포인트가 아니라 /new-event라는 엔드포인트로 바뀌었을 경우 Moved Permanently 301 코드를 반환해야한다. 
이 경우 웹브라우저가 /new-event라는 새로운 엔드포인트로 자동 리다이렉트해준다.
클라이언트 입장에서는 이 과정이 매우 빠르므로 인식하지 못한다. </p>
<h3 id="리다이렉션-종류">리다이렉션 종류</h3>
<h4 id="영구-리다이렉션">영구 리다이렉션</h4>
<ul>
<li>301,308</li>
<li>리소스의 URI가 영구적으로 이동한다.</li>
<li>원래의 URL을 사용하지 않고, 검색 엔진 등에서도 변경을 인지할 수 있다. </li>
<li>301,308 둘다 경로가 완전히 바뀌었다는 것을 알려준다.</li>
<li>301은 Moved Permanently로 리다이렉트시 요청 메서드가 GET으로 변하고 본문이 제거될 수 있다. 브라우저에서 구현이 이런식으로 되어 있다. POST로 보냈지만 body가 사라지고 GET으로 요청이 전송될 수 있다는 것이다. </li>
</ul>
<h4 id="일시적-리다이렉션">일시적 리다이렉션</h4>
<ul>
<li>302,307,303</li>
<li>리소스 URI가 일시적으로 변경된다.</li>
<li>검색 엔진 등에서 URL을 변경하면 안된다. </li>
<li>302 Found: 리다이렉트 요청 메서드가 GET으로 변경되고, 본문이 제거될 수 있다. </li>
<li>307 Temporary Redirect: 302와 기능은 같은데 리다이렉트 시 요청 메서드와 본문을 유지해야 한다.POST로 보내면 POST를 유지해야하고, 변경하면 안된다. </li>
<li>303 See Other: 302와 기능은 같은데 리다이렉트 요청 메서드가 GET으로 변경된다. </li>
<li>실무에서는 아직 302 많이 사용, 307이 권장된다.</li>
</ul>
<h4 id="prg">PRG</h4>
<ul>
<li>Post로 주문 시 웹 브라우저 새로고침 시, 새로고침은 재요청이 되므로 중복 주문이 될 수 있다. </li>
<li>PRG 적용 시, POST로 요청 시 새로고침으로 인한 중복 요청 방지하기 위해, 결과 화면만 GET으로 리다이렉트</li>
</ul>
<ul>
<li><p>300 : multiple Choices, 잘 사용 안함</p>
</li>
<li><p>304 Not Modified: </p>
<ul>
<li>캐시 목적으로 사용한다.</li>
<li>클라이언트에게 리소스가 수정되지 않았음을 알려준다.</li>
<li>클라이언트는 로컬 PC에 저장된 캐시를 재사용한다. 캐시로 리다이렉트한다.</li>
<li>로컬 캐시를 사용해야 하므로 응답에 메시지 바디를 포함하면 안된다. 캐시에 있는 데이터를 사용하라는게 명확하기 때문에!</li>
<li>조건부 GET, HEAD 요청 시에 사용한다. </li>
</ul>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Java/Kotlin] equals()와 hashCode()]]></title>
            <link>https://velog.io/@jayaione_ele/JavaKotlin-equals%EC%99%80-hashCode</link>
            <guid>https://velog.io/@jayaione_ele/JavaKotlin-equals%EC%99%80-hashCode</guid>
            <pubDate>Wed, 08 Jul 2026 07:07:49 GMT</pubDate>
            <description><![CDATA[<h2 id="개념">개념</h2>
<p><code>equals()</code> : </p>
<ul>
<li>두 객체 비교, true면 hashCode()도 같아야 함</li>
<li>객체 주소가 같으면 True</li>
<li>== 는 값만 판단 가능, 같은 객체인지 판단하려면 equals 사용 필수</li>
<li>String 객체에서는 주소가 같으면 True, 오버라이딩된 equals 함수를 사용하기 때문</li>
</ul>
<p><code>hashCode()</code> : </p>
<ul>
<li>hashCode()가 같다고 해서 equals()가 true일 필요는 없음</li>
<li>동일한 객체들이 해시 기반 컬렉션에서 같은 버켓에 가도록 보장</li>
</ul>
<h2 id="java">Java</h2>
<p>equals():</p>
<ul>
<li>Object.equals()는 참조 비교</li>
<li>같은 타입 객체여도 같은 인스턴스가 아니라면 false</li>
</ul>
<p>equals()를 오버라이드할 때 중요한 점</p>
<ul>
<li>hasCode() 오버라이드 필수: hashCode()를 오버라이드하지 않는다면 기본 Object.hashCode()가 사용됨 -&gt; 이럴 경우 equals에서 같다고 판정이 나더라도 해시값이 다를수가 있음 -&gt; hashSet에서 contain 여부를 조회할 때, false가 반환될 수 있음</li>
<li>자기 자신 비교, 타입 체크, 필드 비교, hashCode도 같은 필드 기준 계산 필요<pre><code class="language-java">import java.util.Objects;
</code></pre>
</li>
</ul>
<p>public class Member {
    private final String name;
    private final int age;</p>
<pre><code>public Member(String name, int age) {
    this.name = name;
    this.age = age;
}

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (!(o instanceof Member)) return false;
    Member member = (Member) o;
    return age == member.age &amp;&amp;
            Objects.equals(name, member.name);
}

@Override
public int hashCode() {
    return Objects.hash(name, age);
}</code></pre><p>}</p>
<pre><code>

단점
- equals만 오버라이드하고, hashCode를 만들지 않는다면 버그 발생하기 때문에 번거로움
- equalsI()와 hashCode()가  다른 필드를 보게 되면 계약이 깨짐
- mutable 필드를 기준으로 hashCode()를 생성하게 되면 문제가 생길 수 있음
    - 추후 HashSet에 해당 객체를 넣고, mutable 필드를 수정하면 hash값이 넣었을 때와 변경 이후가 다르기 때문에 문제가 생길 수 있음
    - 해시 기반 컬렉션의 key가 되는 객체는 불변이 안전함

```java
class Member {
    String name;
}


Set&lt;Member&gt; set = new HashSet&lt;&gt;();
Member m = new Member(&quot;kim&quot;);
set.add(m);

m.name = &quot;lee&quot;;

set.contains(m); // 이상 동작 가능
</code></pre><h3 id="kotlin">Kotlin</h3>
<p>equals보다는 <code>==</code> , <code>===</code> 를 사용한 equality 를 위한 연산자 문법 사용이 권장된다. </p>
<ul>
<li><p>== : equals() 기반 비교, 내부적으로 equals 사용</p>
<ul>
<li><p>Kotlin의 모든 클래스는 기본적으로 Any를 상속하는데, 자바 Object처럼..</p>
</li>
<li><p>Any에는 이미 equals 함수가 있는데, 같은 인스턴스면 true, 다르면 false</p>
<pre><code class="language-kotlin">// 기본 equals 함수
open operator fun equals(other: Any?): Boolean

// 오버라이드 equals 함수
override fun equals(other: Any?): Boolean
</code></pre>
</li>
</ul>
</li>
<li><p>=== : 참조 동일성 비교, 같은 인스턴스인지 비교 </p>
</li>
<li><p><code>Any.equals()</code>: 참조 동일성 성격</p>
</li>
<li><p><code>data class</code>는<code>equals()</code>와 <code>hashCode()</code> 자동 생성</p>
<ul>
<li>같은 타입이라면 true</li>
</ul>
</li>
<li><p>data class의 경우</p>
</li>
</ul>
<pre><code class="language-kotlin">data class Member(
    val name: String,
    val age: Int
)

val m1 = Member(&quot;kim&quot;, 20)
val m2 = Member(&quot;kim&quot;, 20)

println(m1 == m2)   // true
println(m1 === m2)  // false
</code></pre>
<ul>
<li><p>일반 클래스에서 오버라이드할  경우 - hashCode() 오버라이드 필수</p>
<pre><code class="language-java">class Money(val amount: Int) {
  override fun equals(other: Any?): Boolean {
      if (this === other) return true
      if (other !is Money) return false
      return this.amount == other.amount
  }

  override fun hashCode(): Int {
      return amount
  }
}
</code></pre>
</li>
</ul>
<p>```</p>
<h3 id="주의할-점">주의할 점</h3>
<ul>
<li>일반 클래스는 자동 값 비교가 아님, 데이터클래스만 해당</li>
<li>배열 비교의 경우 <code>equals()</code>보다는 <code>contentEquals()</code>로 비교</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Server] Spring Boot 메트릭 수집 모니터링 구축 (Railway + Better Stack OTLP push)]]></title>
            <link>https://velog.io/@jayaione_ele/Server-Spring-Boot-%EB%A9%94%ED%8A%B8%EB%A6%AD-%EC%88%98%EC%A7%91-%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-%EA%B5%AC%EC%B6%95-Railway-Better-Stack-OTLP-push</link>
            <guid>https://velog.io/@jayaione_ele/Server-Spring-Boot-%EB%A9%94%ED%8A%B8%EB%A6%AD-%EC%88%98%EC%A7%91-%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-%EA%B5%AC%EC%B6%95-Railway-Better-Stack-OTLP-push</guid>
            <pubDate>Mon, 06 Jul 2026 13:30:59 GMT</pubDate>
            <description><![CDATA[<h1 id="spring-boot-메트릭은-어떻게-전달되는지">Spring Boot 메트릭은 어떻게 전달되는지..?</h1>
<p>이번에 모니터링을 평소 하던 AWS가 아닌 Railway와 연결해서 하면서, 어떤 모니터링 방식으로 메트릭 수집을 하면 좋을지 생각해보게 되었다.</p>
<p>메트릭은 매 이벤트를 전송하는 게 아니라, 애플리케이션 메모리 안에서 숫자를 계속 누적하거나 갱신하다가 일정 주기마다 스냅샷을 보내는 구조였다.</p>
<p>이번 글에서는 Spring Boot Actuator, Micrometer, OTLP, Grafana까지 메트릭이 흘러가는 과정을 한 번에 정리한다.</p>
<h2 id="전체-흐름">전체 흐름</h2>
<p>Spring Boot 애플리케이션에서 JVM 메트릭이 Grafana 같은 백엔드까지 가는 흐름은 대략 이렇다.</p>
<pre><code class="language-text">JVM 내부 상태
-&gt; Micrometer 계측/집계
-&gt; OTLP registry가 주기적으로 전송
-&gt; Grafana 같은 백엔드에서 저장/조회</code></pre>
<p>중요한 점은 요청마다 외부로 메트릭을 보내지 않는다는 것이다.</p>
<p>애플리케이션 안의 <code>MeterRegistry</code>가 숫자를 관리하고, <code>OtlpMeterRegistry</code> 같은 registry가 설정된 주기마다 현재 상태를 묶어서 전송한다. 예를 들어 step이 30초라면 30초마다 한 번씩 스냅샷을 보낸다.</p>
<p>그래서 트래픽이 많아져도 메트릭 전송량이 요청 수만큼 폭증하지 않는다. 전송량은 주로 메트릭 개수, 라벨 조합 수, 전송 주기에 영향을 받는다.</p>
<h2 id="jvm-상태">JVM 상태</h2>
<p>JVM은 내부 상태를 MXBean, 즉 JMX 기반 인터페이스로 노출한다.</p>
<p>대표적으로 이런 값들이 있다.</p>
<ul>
<li><code>MemoryMXBean</code>: 힙/논힙 메모리 사용량</li>
<li><code>GarbageCollectorMXBean</code>: GC 횟수, 누적 시간</li>
<li><code>OperatingSystemMXBean</code>: CPU 사용률</li>
<li><code>ThreadMXBean</code>: 스레드 수</li>
</ul>
<p>Spring Boot Actuator를 붙이면 기동 시 여러 MeterBinder가 자동으로 등록된다.</p>
<ul>
<li><code>JvmMemoryMetrics</code></li>
<li><code>JvmGcMetrics</code></li>
<li><code>ProcessorMetrics</code></li>
<li><code>JvmThreadMetrics</code></li>
</ul>
<p>이 Binder들이 JVM의 MXBean 값을 읽어서 Micrometer의 Meter로 연결한다. 그래서 애플리케이션에서 <code>jvm.memory.used</code>, <code>process.cpu.usage</code>, <code>jvm.threads.live</code> 같은 메트릭을 볼 수 있게 된다.</p>
<h2 id="micrometer로-값-관리">Micrometer로 값 관리</h2>
<p>Micrometer는 계측 추상화 계층이다. 중심에는 <code>MeterRegistry</code>가 있고, 그 안에 여러 Meter가 등록된다.</p>
<p>대표적인 Meter는 세 가지다.</p>
<table>
<thead>
<tr>
<th>Meter</th>
<th>의미</th>
<th>예시</th>
</tr>
</thead>
<tbody><tr>
<td>Gauge</td>
<td>현재 순간 값</td>
<td>힙 사용량, 스레드 수</td>
</tr>
<tr>
<td>Counter</td>
<td>단조 증가 누적값</td>
<td>이벤트 횟수, 누적 바이트</td>
</tr>
<tr>
<td>Timer</td>
<td>호출 수, 총 시간, 최대값, 히스토그램</td>
<td>HTTP 지연 시간, GC pause</td>
</tr>
</tbody></table>
<p>여기서 헷갈리기 쉬운 점은 계측과 전송이 분리되어 있다는 것이다.</p>
<p>Micrometer는 앱 안에서 값을 관리한다. 
외부로 보내는 일은 Prometheus registry, OTLP registry 같은 구현체가 맡는다.</p>
<h2 id="otlp는-어떻게-전송하는지">OTLP는 어떻게 전송하는지?</h2>
<p>OTLP 방식에서 <code>OtlpMeterRegistry</code>는 push 방식으로 동작한다.</p>
<p>앱이 시작되면 백그라운드 스레드가 돌고, 설정된 step마다 publish 과정이 실행된다.</p>
<pre><code class="language-text">1. Registry의 모든 Meter를 확인한다.
2. Gauge는 지금 값을 읽는다.
3. Counter, Timer는 누적값과 히스토그램 상태를 읽는다.
4. OTLP protobuf 포맷으로 직렬화한다.
5. 설정된 endpoint로 HTTP POST를 보낸다.</code></pre>
<p>설정에서는 보통 이런 값이 중요하다.</p>
<pre><code class="language-yaml">management:
  otlp:
    metrics:
      export:
        url: https://example-otlp-endpoint
        step: 30s
        headers:
          Authorization: Bearer ${OTLP_TOKEN}</code></pre>
<p>실제 속성 이름은 Spring Boot와 Micrometer 버전에 따라 확인이 필요하지만, 개념적으로는 &quot;어디로&quot;, &quot;얼마나 자주&quot;, &quot;어떤 인증으로&quot; 보낼지가 핵심이다.</p>
<h2 id="prometheus-pull과-otlp-push의-차이">Prometheus pull과 OTLP push의 차이</h2>
<p>메트릭을 수집하는 방식은 크게 pull과 push로 나눠서 볼 수 있다.</p>
<h3 id="prometheus-pull">Prometheus pull</h3>
<p>Prometheus 방식은 수집기가 앱으로 들어와서 값을 가져간다.</p>
<pre><code class="language-text">Prometheus -&gt; /actuator/prometheus -&gt; Spring Boot App</code></pre>
<p>앱은 <code>/actuator/prometheus</code> 같은 엔드포인트에 현재 값을 노출하고, Prometheus가 주기적으로 scrape한다.</p>
<p>Docker Compose나 Kubernetes처럼 내부 네트워크와 service discovery가 잘 잡힌 환경에서는 자연스럽다. 하지만 Railway처럼 외부 수집기가 앱 주소로 접근하거나 target을 관리하기 애매한 환경에서는 설정이 번거로울 수 있다.</p>
<h3 id="otlp-push">OTLP push</h3>
<p>OTLP 방식은 앱이 외부 endpoint로 직접 보낸다.</p>
<pre><code class="language-text">Spring Boot App -&gt; OTLP endpoint -&gt; Grafana Cloud</code></pre>
<p>수집기가 앱으로 들어올 필요가 없다. 앱이 인증 정보를 들고 외부 endpoint로 POST를 보내면 된다.</p>
<p>그래서 PaaS 환경이나 외부 접근이 까다로운 환경에서는 push 방식이 더 단순할 수 있다.</p>
<h2 id="grafana에서는-어떻게-보이는지">Grafana에서는 어떻게 보이는지?</h2>
<p>Grafana 같은 백엔드는 받은 숫자를 시계열로 저장한다.</p>
<p>시계열은 보통 다음 조합으로 구분된다.</p>
<pre><code class="language-text">메트릭 이름 + 라벨 조합</code></pre>
<p>예를 들어 같은 <code>jvm.memory.used</code>라도 라벨이 다르면 다른 시계열이다.</p>
<ul>
<li><code>area=heap</code></li>
<li><code>area=nonheap</code></li>
<li><code>id=G1 Eden Space</code></li>
<li><code>application=gerd</code></li>
</ul>
<p><code>management.metrics.tags.application=gerd</code>처럼 공통 라벨을 붙여두면 나중에 Grafana에서 특정 앱만 필터링하기 좋다.</p>
<p>PromQL에서는 이런 식으로 조회할 수 있다.</p>
<pre><code class="language-promql">process_cpu_usage</code></pre>
<pre><code class="language-promql">rate(jvm_gc_pause_seconds_sum[5m])</code></pre>
<p>미리 만들어진 JVM 대시보드를 가져오면 heap, GC pause, CPU, thread 같은 기본 패널을 빠르게 구성할 수 있다.</p>
<h2 id="better-stack이랑-다른점">Better Stack이랑 다른점</h2>
<p>Better Stack은 로그 수집, 업타임 모니터링, 온콜까지 한 번에 묶기 좋다. 별도로 Loki나 알림 시스템을 붙이지 않아도 시작하기 쉽다는 장점이 있다.</p>
<p>대신 메트릭 대시보드는 Grafana의 PromQL 대시보드를 그대로 쓰기보다 Better Stack의 SQL 방언에 맞춰 직접 패널을 구성해야 할 수 있다.</p>
<p>예를 들어 진단용으로는 이런 패널을 만들 수 있다.</p>
<ul>
<li>Heap used vs max</li>
<li>Live threads</li>
<li>GC pause p50/p95/p99</li>
<li>CPU usage</li>
<li>Disk free</li>
<li>Logback events by level</li>
</ul>
<p>Better Stack 쿼리에서는 게이지 값에 <code>avgMerge(value_avg)</code>를 쓰거나, 라벨을 <code>label(&#39;level&#39;)</code>처럼 꺼내는 식의 차이가 있다. 히스토그램 quantile도 저장소 방언에 맞춰 확인해야 한다.</p>
<p>여기서 중요한 건 쿼리를 외우는 게 아니라, 실제 저장된 metric name과 label을 먼저 확인하는 것이다. Micrometer에서 보던 이름, Prometheus 이름, OTLP 저장소에서 보이는 이름이 조금씩 다를 수 있다.</p>
<h2 id="커스텀-대시보드-구성">커스텀 대시보드 구성</h2>
<p>Better Stack은 그라파냐처럼 spring boot 전용 세팅되어있는게 없다. 
그래서 확인할 항목을 골라서 커스텀으로 쿼리를 입력해서 직접 만들어야 한다. </p>
<p>다음과 같은 순서로 제작했다. </p>
<h3 id="1-better-stack-source-만들기">1. Better Stack Source 만들기</h3>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/f242b225-db7e-4cab-a157-d42836cc05f2/image.png" alt=""></p>
<p>Telemetry -&gt; Sources 탭에 들어가서 Connect Source를 누른다. 
Source를 만들면 Source token, url 이렇게 두개가 나온다. </p>
<pre><code class="language-bash">curl -X POST \
  -H &#39;Content-Type: application/json&#39; \
  -H &#39;Authorization: Bearer [token]&#39; \
  -d &#39;{&quot;dt&quot;:&quot;&#39;&quot;$(date -u +&#39;%Y-%m-%d %T UTC&#39;)&quot;&#39;&quot;,&quot;message&quot;:&quot;Hello from Better Stack!&quot;}&#39; \
  --insecure \
  https://[url]</code></pre>
<pre><code class="language-yml">management:
  endpoints:
    web:
      exposure:
        include: health,info,env,configprops  
  endpoint:
    health:
      show-details: never
      probes:
        enabled: true
  health:
    diskspace:
      enabled: true
    ping:
      enabled: true
    livenessstate:
      enabled: true
    readinessstate:
      enabled: true
  metrics:
    tags:
      application: ${spring.application.name}
  otlp:
    metrics:
      export:
        enabled: true
        url: ${BETTERSTACK_OTLP_URL}/v1/metrics
        headers:
          Authorization: Bearer ${BETTERSTACK_SOURCE_TOKEN}
        step: 30s</code></pre>
<p>각각 OTLP, SOURCE_TOKEN으로 넣어준다!</p>
<h3 id="2-커스텀-대시보드-만들기">2. 커스텀 대시보드 만들기</h3>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/aa9612da-bc0e-448b-8838-538ae13d8162/image.png" alt=""></p>
<p>해당 탭에서 Create dashboard로 들어간다. 그러면 처음에는 아예 대시보드가 비어있다. </p>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/a3fd3fba-795b-4378-81d3-f99e95622bbb/image.png" alt=""></p>
<p>맨 오른쪽의 플러스 버튼을 누르면 차트를 만들 수 있다. </p>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/bbbb088c-24e7-49b1-88e2-bcbd16556204/image.png" alt=""></p>
<p>source를 아까 만들었던것으로 절정하고, Query를 입력하고 Run query를 하면 설정한 차트 타입에 따라 볼 수 있다. </p>
<p>나는 다음과 같은 항목을 추가해줬다. </p>
<table>
<thead>
<tr>
<th>패널</th>
<th>확인하려는 것</th>
</tr>
</thead>
<tbody><tr>
<td>Heap used vs max</td>
<td>메모리 기준선과 누수 의심</td>
</tr>
<tr>
<td>GC pause p95/p99</td>
<td>사용자 지연으로 이어질 수 있는 멈춤</td>
</tr>
<tr>
<td>CPU usage</td>
<td>앱 CPU와 시스템 CPU 추세</td>
</tr>
<tr>
<td>Live threads</td>
<td>스레드 증가나 고착 여부</td>
</tr>
<tr>
<td>Logback events</td>
<td>error/warn 로그 급증</td>
</tr>
<tr>
<td>Disk free</td>
<td>디스크 고갈 위험</td>
</tr>
</tbody></table>
<p>이렇게 항목을 달아주면...</p>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/69179452-91e2-4a7b-b157-d7b2cd7224c9/image.png" alt=""></p>
<p>완성이된다~!</p>
<h2 id="정리">정리</h2>
<p>메트릭 수집의 핵심은 이벤트 전송이 아니라 숫자 상태의 주기적 스냅샷이다.</p>
<ul>
<li>JVM은 MXBean/JMX로 내부 상태를 노출한다.</li>
<li>Spring Boot Actuator의 MeterBinder가 JVM 값을 Micrometer Meter로 연결한다.</li>
<li>Micrometer는 앱 메모리의 MeterRegistry에서 값을 관리한다.</li>
<li>OTLP registry는 step마다 protobuf로 직렬화해서 HTTP POST한다.</li>
<li>Grafana나 Better Stack은 이를 시계열로 저장하고 label 기준으로 조회한다.</li>
</ul>
<p>Prometheus pull이 좋은 환경도 있고, OTLP push가 더 단순한 환경도 있다. 지금 나같은 경우는 Railway를 사용하고 있기에 Better Stack을 사용했다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[회고] 다사다난 취준생의 상반기 회고록]]></title>
            <link>https://velog.io/@jayaione_ele/%ED%9A%8C%EA%B3%A0-%EB%8B%A4%EC%82%AC%EB%8B%A4%EB%82%9C-%EC%B7%A8%EC%A4%80%EC%83%9D%EC%9D%98-%EC%83%81%EB%B0%98%EA%B8%B0-%ED%9A%8C%EA%B3%A0%EB%A1%9D</link>
            <guid>https://velog.io/@jayaione_ele/%ED%9A%8C%EA%B3%A0-%EB%8B%A4%EC%82%AC%EB%8B%A4%EB%82%9C-%EC%B7%A8%EC%A4%80%EC%83%9D%EC%9D%98-%EC%83%81%EB%B0%98%EA%B8%B0-%ED%9A%8C%EA%B3%A0%EB%A1%9D</guid>
            <pubDate>Tue, 30 Jun 2026 20:04:39 GMT</pubDate>
            <description><![CDATA[<p>상반기 시간이 너무 빨리 흘러간 것 같기도 하고 .. 이래저래 많은 일이 있었어서 상반기 회고록을 작성했다.!</p>
<p>가장 큰 사건 위주로 보면</p>
<ul>
<li>UMC 9기 수료 및 데모데이 </li>
<li>새로운 프로젝트 백엔드 합류, 8월 런칭 예정</li>
<li>UMC 스프링 파트장, 안드로이드 챌린저로 스터디 진행</li>
<li>개발동아리 프로그라피 11기 참여 </li>
</ul>
<p>등등등.. 이 있었다. 학교도 안다니면서 정말 다사다난했다!</p>
<h3 id="12월">1~2월</h3>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/beb8c0b9-89ba-46c0-bfb4-73414f34f9eb/image.png" alt=""></p>
<p>1월에는 일단 UMC 데모데이를 위한 프로젝트 TAMINGO를 시작했다. 
백엔드 5명은 처음이었고 팀장을 맡게 되었다. IOS와 연동하는 것도 처음이었기에 기대가 되었다.
그리고 프로젝트 자체가 LLM API 호출이나 Redis 캐싱이 많이 필요한 프로젝트라서 좀 애먹었던 것 같다.
생각보다 시간투자를 많이 해야 해서 힘들었다 ..
그리고 길찾기 API를 사용해서 도착 예상 시간을 매번 계산해야 했기에 지도 API는 이것저것 거의 다 만져본 것 같다.</p>
<p>그리고 런칭 준비 중인 독서 기록 서비스 NOOK의 리뉴얼 버전 개발을 시작했다. 
기존 기능과 많이 달라지기도 했고 Rest Docs를 도입해서, 안좋았던 부분들을 다 버리고 시작하는 느낌이라 좀 기분이 좋았다 .. ^ㅡ^</p>
<p>2월 말! 드디어 UMC 데모데이를 했다. 생각보다 내가 맡은 기능 질문이 많이 들어와서 당황스러웠지만.. 
하루종일 단체티 입고 다니면서 구경하니 재밌었다. 
마지막으로 참여하는 스프링 부트 프로젝트였는데 꽤나 만족스러웠다!</p>
<h3 id="34월">3~4월</h3>
<p>3월에는 역시 동아리 시작이다.. 
UMC 스프링부트 파트장으로서의 마지막 10기를 시작했다. 
서류,면접,OT 등등 하니 시간이 빨리 갔다.</p>
<p>이번 기수에는 스프링부트가 아닌 Android 파트로 프로젝트에 참여하게 되었다. 
코틀린 공부를 하려고 했지만 잘 안하게 되어서.. 강제로라도 하기 위해 한것도 있고,
AX 시대에 풀스택 개발을 해보면 좋지 않을까? 해서 하게 되었다. 
그리고 React도 공부를 해봤지만 재미가 없었기에.. 
1학년때 하고 포기한 Android.. 를 다시 시작하게 되었다.</p>
<p>그리고 4월에는 새로운 동아리를 시작했다.
다른 개발 동아리들과 달리 과제를 받는게 흥미로웠고, 빨리 출시해서 돈을 벌어보고 싶다는 생각에 프로그라피 11기에 지원하게 되었다.
과제를 비록 전날에 시작해서 대충대충 해서 냈지만...! 
면접까지 거쳐서 백엔드 파트로 합격하게 되었다~! 두근두근.. </p>
<p>4월에는 또 새로운 프로젝트에 백엔드 파트로 합류하게 되었다.
알림 기능을 맡게 되었고, 필요하면 다른 기능도 맡기로 하고 들어가게 되었다.</p>
<h3 id="56월">5~6월</h3>
<p>음.. 이제 내가 벌려놓은 일을 수습할 시간이다.</p>
<p>먼저 프로그라피에서는 5월에 기획 단계에 들어갔기에 초기 세팅 작업을 열심히 했다.
AI 세팅, 코틀린 스프링 세팅 등등.. 을 진행했다. 
이번에는 AWS가 아닌 Railway를 사용하면서 비용 절감에 집중해보려고 한다!</p>
<p>6월에는 많은 일이 있었다. 
싸피 코딩테스트 응시 및 면접 준비(하느라 개발을 거의 못했다..), 프로그라피 백엔드 팀원 탈주로 인한 업무량 증가..
이것때문에 코틀린 스프링을 우다다다다 해서 깨달은게 많다.</p>
<p>이것 말고도 다른 프로젝트를 간간히 진행했다.</p>
<p>그리고 매주 나를 힘들게했던 안드로이드 스터디가 끝났다. 스프링 스터디 피드백도!
프론트는 나랑 잘 안맞는다고 느꼈다. 그렇지만 내부 로직 짜는 건 재밌게 했던 것 같다. </p>
<h3 id="회고록">회고록</h3>
<p>졸업유예를 하고 코딩테스트,포트폴리오 제작,CS 공부 등등을 하려던 나의 목표가 프로젝트 기계(?)로 바뀌었지만 프로젝트를 하면서 많이 배웠다. 
확실히 학교를 안다니면서 프로젝트를 하니 삶의 질이 달라지는 것 같다. ^^
그렇지만 하반기에는 프로젝트를 많이 하더라도 공부를 꾸준히 병행해야겠다는 생각이 들었다.
코딩테스트 문제 풀기도 초반엔 열심히 했는데 나중에는 바빠서 거의 못했다.</p>
<h3 id="하반기에-할일들">하반기에 할일들</h3>
<ul>
<li>운동을 열심히 하자.주4회이상(중요)</li>
<li>성공적으로 프로젝트 런칭하기</li>
<li>공모전 참여하기 </li>
<li>CS 공부, 코딩테스트 공부 매일 꾸준히 하기</li>
<li>본격적 취준하기</li>
<li>안드로이드 프로젝트하기</li>
</ul>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/50195ad3-0a46-4490-83e8-37cbd3d7b8a8/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring/Kotlin] Sealed Class로 DTO 생성하기]]></title>
            <link>https://velog.io/@jayaione_ele/SpringKotlin-Sealed-Class%EB%A1%9C-DTO-%EC%83%9D%EC%84%B1%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jayaione_ele/SpringKotlin-Sealed-Class%EB%A1%9C-DTO-%EC%83%9D%EC%84%B1%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 21 Jun 2026 13:04:11 GMT</pubDate>
            <description><![CDATA[<h1 id="kotlin-sealed-class">Kotlin Sealed Class</h1>
<p>sealed class는 상속 가능한 범위를 같은 파일로 제한하는 클래스다.</p>
<pre><code class="language-kotlin">sealed class Shape
data class Circle(val radius: Double) : Shape()
data class Rectangle(val w: Double, val h: Double) : Shape()</code></pre>
<p>컴파일러가 모든 하위 타입을 알고 있기 때문에, <code>when</code> 분기에서 <code>else</code> 없이도 모든 케이스를 강제 처리할 수 있다.</p>
<pre><code class="language-kotlin">when (shape) {
    is Circle -&gt; ...
    is Rectangle -&gt; ...
    // else 불필요 — 누락 시 컴파일 에러
}</code></pre>
<p>나중에 <code>Triangle</code>을 추가하면, 처리하지 않은 <code>when</code>에서 즉시 컴파일 에러가 발생한다. 런타임이 아닌 컴파일 타임에 누락을 잡아주는 게 핵심.</p>
<hr>
<h3 id="언제-쓰는지">언제 쓰는지?</h3>
<p>타입에 따라 가지고 있는 데이터 구조가 다를 때 유용하다.</p>
<hr>
<h2 id="실제-사용-예--타임라인-api-응답-dto">실제 사용 예 — 타임라인 API 응답 DTO</h2>
<p>요구사항 </p>
<p>타임라인 아이템은 식사 기록이 1개인 <code>SINGLE</code>과 2개 이상인 <code>GROUP</code> 두 가지 타입이 있다. 문제는 등급(<code>grade</code>) 필드가 <code>SINGLE</code>일 때만 존재한다는 점이다. 또한 여러개이려면 대표 음식도 두개를 표시해야 한다. 그리고 같이 기록된 음식의 개수도 표시해야 한다. </p>
<p>이걸 하나의 DTO로 표현하면 이렇게 된다.</p>
<pre><code class="language-kotlin">data class TimeLineItemDTO(
    val type: TimeLineType,
    val grade: String?,  // SINGLE이면 값 있고, GROUP이면 null
    ...
)</code></pre>
<p><code>grade</code>가 null인 이유가 타입 레벨에서 드러나지 않는다. 클라이언트는 왜 null인지 알 수 없고, 서버도 <code>grade</code>를 실수로 채워 보내도 컴파일러가 잡아주지 못한다. 즉 타입에 따라 해당하지 않는 필드를 null로 채우면 비효율적이라는 것이다. </p>
<p><code>sealed class</code>로 분리하면 이 문제가 사라진다.</p>
<pre><code class="language-kotlin">sealed class TimeLineItemDTO {
    data class Single(
        val grade: String,  // non-null 보장
        ...
    ) : TimeLineItemDTO()

    data class Group(
        // grade 자체가 없음
        ...
    ) : TimeLineItemDTO()
}</code></pre>
<p><code>SINGLE</code>이면 <code>grade</code>가 반드시 있고, <code>GROUP</code>이면 애초에 필드가 없다. nullable 대신 타입 구조로 제약을 표현한 것이다. 서비스 레이어에서 <code>when</code> 분기로 각 타입을 생성하면 컴파일러가 모든 케이스 처리를 강제한다.</p>
<hr>
<p><code>abstract class</code>와 비슷하지만 <strong>상속 범위 제한 + 분기 누락 검사</strong> 가 추가된 것으로 이해하면 된다.</p>
<p><code>abstract class</code>는 누군가 다른 파일에서 <code>Triangle : Shape()</code>를 추가할 수도 있으니, 컴파일러는 <code>when</code>에 무조건 <code>else</code>를 강제한다. 즉 해당 요구사항에서는 두 타입만 올것인데 다른게 올 수 있다고 하는 것이다. </p>
<p><code>sealed class</code>는 하위 타입을 전부 안다는 것이 보장되니까, <code>when</code>에서 모든 케이스를 빠짐없이 처리했는지를 컴파일러가 검사해줄 수 있다. 
만약 <code>Triangle</code>을 새로 추가했는데 <code>when</code>에서 빼먹으면, 그 즉시 컴파일 에러가 난다는 점에서 <code>abstract class</code>와 차이가 있다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] 외부 LLM API의 트랜잭션 문제 ]]></title>
            <link>https://velog.io/@jayaione_ele/Spring-%EC%99%B8%EB%B6%80-LLM-API%EC%9D%98-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EB%AC%B8%EC%A0%9C</link>
            <guid>https://velog.io/@jayaione_ele/Spring-%EC%99%B8%EB%B6%80-LLM-API%EC%9D%98-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EB%AC%B8%EC%A0%9C</guid>
            <pubDate>Sat, 20 Jun 2026 20:06:04 GMT</pubDate>
            <description><![CDATA[<h2 id="트러블슈팅-비동기-증상-분석-결과가-최신-데이터를-덮어쓰는-문제">트러블슈팅: 비동기 증상 분석 결과가 최신 데이터를 덮어쓰는 문제</h2>
<h3 id="문제">문제</h3>
<p>증상 생성/수정 후 Gemini 기반 패턴 분석을 비동기로 갱신하고 있었다.</p>
<p>기존 흐름은 다음과 같았다.</p>
<pre><code class="language-kotlin">@Async(&quot;analysisTaskExecutor&quot;)
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun refreshAsync(symptomId: String, userId: Long) {
    val symptom = symptomRepository.findByExternalIdAndUser_Id(symptomExternalId, userId) ?: return
    if (!symptom.isAnalysisDirty) return

    val result = symptomPatternAnalysisService.generate(symptom, userId)

    if (result.shouldUpdate) {
        symptom.updateAnalysis(result.analysisJson)
    }
}</code></pre>
<p>이 구조에는 두 가지 문제가 있었다.</p>
<ol>
<li>Gemini 호출이 DB 트랜잭션 안에서 실행된다.</li>
<li>오래 걸린 이전 비동기 작업이 나중에 끝나면서 최신 분석 결과를 덮어쓸 수 있다.</li>
</ol>
<blockquote>
<p>요구사항 : 사용자가 증상 생성 시 증상에 대한 분석을 생성해야 한다. 결과는 anslysisJson이라는 컬럼으로 저장되며, 응답을 내려줄 때는 dto로 매핑한다.  </p>
</blockquote>
<p>예를 들어 사용자가 증상을 생성하면 분석 작업 A가 시작되고, 바로 메모를 수정하면 분석 작업 B가 다시 시작된다.<br>B가 먼저 끝나 최신 데이터 기준 분석을 저장했더라도, A가 늦게 끝나면 과거 데이터 기준 분석으로 <code>analysisJson</code>을 다시 덮어쓸 수 있었다.</p>
<h3 id="원인">원인</h3>
<p>비동기 작업마다 “이 분석이 어떤 시점의 증상 상태를 기준으로 만든 결과인지”를 검증하지 않았다.</p>
<p>즉, 작업 시작 시점의 증상 상태와 결과 저장 시점의 증상 상태가 같은지 확인하는 장치가 없었다.</p>
<p>또한 <code>@Transactional(REQUIRES_NEW)</code>가 refresh 전체에 걸려 있어, DB 조회뿐 아니라 Gemini 외부 호출 중에도 트랜잭션이 유지될 수 있었다.</p>
<h3 id="해결-방법">해결 방법</h3>
<p><code>Symptom</code>에 분석 전용 버전 필드인 <code>analysisVersion</code>을 추가했다.</p>
<p>증상 내용이 바뀌어 분석이 다시 필요해질 때마다 버전을 증가시킨다.</p>
<pre><code class="language-kotlin">@Column(name = &quot;analysis_version&quot;, nullable = false, columnDefinition = &quot;bigint default 0&quot;)
var analysisVersion: Long = analysisVersion
    protected set

fun markAnalysisDirty() {
    isAnalysisDirty = true
    analysisVersion += 1
}</code></pre>
<p>분석 결과를 저장할 때는 작업 시작 시점의 버전과 현재 버전이 같은 경우에만 반영한다.</p>
<pre><code class="language-kotlin">fun updateAnalysis(analysisJson: String, expectedVersion: Long): Boolean {
    if (!isAnalysisDirty || analysisVersion != expectedVersion) return false

    this.analysisJson = analysisJson
    isAnalysisDirty = false
    return true
}</code></pre>
<p>비동기 서비스에서는 작업 시작 시점의 버전을 캡처한다.</p>
<pre><code class="language-kotlin">@Async(&quot;analysisTaskExecutor&quot;)
fun refreshAsync(symptomId: String, userId: Long) {
    val symptom = symptomRepository.findByExternalIdAndUser_Id(symptomExternalId, userId) ?: return
    if (!symptom.isAnalysisDirty) return

    val expectedVersion = symptom.analysisVersion

    val result = symptomPatternAnalysisService.generate(symptom, userId)

    if (result.shouldUpdate) {
        symptomAnalysisResultWriter.updateIfVersionMatches(
            symptomId = symptomId,
            userId = userId,
            expectedVersion = expectedVersion,
            analysisJson = result.analysisJson,
        )
    }
}</code></pre>
<p>결과 저장은 별도 writer에서 짧은 쓰기 트랜잭션으로 처리했다.</p>
<pre><code class="language-kotlin">@Service
class SymptomAnalysisResultWriter(
    private val symptomRepository: SymptomRepository,
) {
    @Transactional
    fun updateIfVersionMatches(
        symptomId: String,
        userId: Long,
        expectedVersion: Long,
        analysisJson: String,
    ): Boolean {
        val symptomExternalId = runCatching { UUID.fromString(symptomId) }.getOrNull() ?: return false
        val symptom = symptomRepository.findByExternalIdAndUser_Id(symptomExternalId, userId) ?: return false

        return symptom.updateAnalysis(analysisJson, expectedVersion)
    }
}</code></pre>
<h3 id="결과">결과</h3>
<p>오래된 비동기 작업이 늦게 끝나더라도, 그 사이 증상이 수정되어 <code>analysisVersion</code>이 증가했다면 결과를 저장하지 않는다.</p>
<p>즉, 최신 증상 상태에 기반한 분석만 반영된다.</p>
<p>또한 Gemini 호출을 감싼 긴 쓰기 트랜잭션을 제거하고, 최종 저장 시점에만 짧은 트랜잭션을 사용하도록 분리했다.</p>
<h3 id="검증">검증</h3>
<p>오래된 분석 결과가 버려지는 테스트를 추가했다.</p>
<pre><code>@Test
fun `작업 시작 이후 증상이 바뀌어 버전이 다르면 오래된 분석 결과를 버린다`() {
    val symptom = SymptomFixture.symptom(isAnalysisDirty = true, analysisVersion = 3L)

    whenever(symptomRepository.findByExternalIdAndUser_Id(SymptomFixture.SYMPTOM_EXTERNAL_ID, userId))
        .thenReturn(symptom)

    val updated = writer.updateIfVersionMatches(
        symptomId = SymptomFixture.SYMPTOM_EXTERNAL_ID.toString(),
        userId = userId,
        expectedVersion = 2L,
        analysisJson = &quot;&quot;&quot;{&quot;label&quot;:&quot;오래된 결과&quot;}&quot;&quot;&quot;,
    )

    assertThat(updated).isFalse()
    assertThat(symptom.analysisJson).isNull()
    assertThat(symptom.isAnalysisDirty).isTrue()
    assertThat(symptom.analysisVersion).isEqualTo(3L)
}</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[[DB] TestContainers 도입기]]></title>
            <link>https://velog.io/@jayaione_ele/DB-TestContainers-%EB%8F%84%EC%9E%85%EA%B8%B0</link>
            <guid>https://velog.io/@jayaione_ele/DB-TestContainers-%EB%8F%84%EC%9E%85%EA%B8%B0</guid>
            <pubDate>Mon, 15 Jun 2026 07:46:48 GMT</pubDate>
            <description><![CDATA[<h1 id="testcontainers로-postgresql-통합-테스트-환경-맞추기">Testcontainers로 PostgreSQL 통합 테스트 환경 맞추기</h1>
<p>이번에 테스트 환경을 보다가 찝찝한 지점이 있었다.</p>
<p>운영은 PostgreSQL을 쓰고 있는데, 테스트는 H2의 PostgreSQL mode로 돌고 있었다.</p>
<p>처음에는 PostgreSQL mode면 어느 정도 비슷하지 않나?라고 생각할 수 있다.<br>그런데 Repository 테스트나 JPA 매핑 테스트에서는 이 차이가 생각보다 크다.
H2, PostgreSQL 예약어를 둘다 피해야 하고, 같은 환경이 아니므로 테스트 시 예외가 생길 수도 있는 것이다.</p>
<h2 id="testcontainers란">TestContainers란?</h2>
<p>통합테스트의 중요성이 강조될수록 필요하다고 생각한다. 보통 통합 테스트를 위해서 인메모리나 임베디드를 사용한다. 
H2 인메모리 데이터베이스를 주로 통합테스트에 사용하는데,
통합테스트에 필요한 기능을 전부 지원하지 않을 수도 있다고 한다. 예를들어 PostgreSQL을 애플리케이션에서 사용한다고 하면 고급 기능을 지원하지 않을 수 있다는 것이다. </p>
<h3 id="동작-원리">동작 원리</h3>
<p>도커 컨테이너로 래핑된 실제 서비스와의 통합 테스트를 위해서, 간단한 API를 제공하는 테스트 라이브러리이다. </p>
<ul>
<li>TestContainers API를 사용해서 도커 컨테이너 시작</li>
<li>서비스를 사용해서 테스트 실행</li>
<li>사용된 서비스 컨테이너 파괴, 성공 여부와 상관없음</li>
</ul>
<p>즉 테스트를 성공하려면 도커가 설치되어있어야 한다.</p>
<h2 id="왜-h2를-사용하지-않는지">왜 H2를 사용하지 않는지</h2>
<p>H2 PostgreSQL mode는 PostgreSQL을 완전히 재현하지 않는다.<br>PostgreSQL처럼 보이게 일부 문법을 맞춰주는 것에 가깝다.</p>
<p>그래서 다음 같은 부분에서 차이가 생길 수 있다.</p>
<ul>
<li><code>@SQLRestriction</code> 기반 소프트 삭제 필터</li>
<li>native query</li>
<li>PostgreSQL 전용 타입이나 함수</li>
<li><code>@MapsId</code> 공유 PK 매핑</li>
<li>FK, unique, not null 같은 제약 조건</li>
<li>cascade, orphan removal 동작</li>
</ul>
<p>예를 들어 이런 native query가 있다고 하자.</p>
<pre><code class="language-sql">SELECT COUNT(*)
FROM users
WHERE user_id = ?</code></pre>
<p>H2에서는 통과했는데 PostgreSQL에서는 식별자, 타입, 문법 차이 때문에 실패할 수 있다.<br>이러면 테스트는 통과인데 운영에서 깨지는 상황이 생긴다.</p>
<p>그래서 테스트 DB도 운영과 같은 PostgreSQL 16으로 맞추기로 했다.</p>
<h2 id="선택한-방식-jdbctc-url">선택한 방식: jdbc:tc URL</h2>
<p>이번에는 Testcontainers의 JDBC URL 방식을 썼다.</p>
<pre><code class="language-yaml">spring:
  datasource:
    url: jdbc:tc:postgresql:16:///testdb
    username: test
    password: test
  test:
    database:
      replace: none</code></pre>
<p><code>jdbc:tc:postgresql:16:///testdb</code>는 Testcontainers가 제공하는 특수한 JDBC URL이다.<br>애플리케이션이 이 datasource로 연결하려고 하면 Testcontainers가 PostgreSQL 컨테이너를 띄워준다.</p>
<p>이 방식의 장점은 설정이 가볍다는 것이다.</p>
<pre><code class="language-kotlin">dependencies {
    testImplementation(&quot;org.testcontainers:junit-jupiter&quot;)
    testImplementation(&quot;org.testcontainers:postgresql&quot;)
}</code></pre>
<p>컨테이너를 직접 생성하는 테스트 설정 클래스를 만들 필요가 없다.<br>지금처럼 PostgreSQL 하나만 필요한 상황에서는 가장 단순하다.</p>
<h2 id="activeprofilestest-추가하기">@ActiveProfiles(&quot;test&quot;) 추가하기</h2>
<p>놓치기 쉬운 부분이 있다.</p>
<p><code>application-test.yml</code>에 Testcontainers datasource를 잡아도, 테스트가 test profile로 실행되지 않으면 해당 설정을 읽지 않는다.</p>
<p>그래서 Repository 테스트나 통합 테스트에 profile을 명시했다.</p>
<pre><code class="language-kotlin">@DataJpaTest
@ActiveProfiles(&quot;test&quot;)
class UserRepositoryTest {
    // ...
}</code></pre>
<p>통합 테스트도 마찬가지다.</p>
<pre><code class="language-kotlin">@SpringBootTest
@ActiveProfiles(&quot;test&quot;)
class HealthControllerTests {
    // ...
}</code></pre>
<p>그리고 <code>@DataJpaTest</code> 같은 slice test에서는 Spring Boot가 datasource를 임베디드 DB로 바꾸려고 할 수 있다.<br>그래서 다음 설정도 같이 둔다.</p>
<pre><code class="language-yaml">spring:
  test:
    database:
      replace: none</code></pre>
<p>이 설정이 없으면 열심히 Testcontainers datasource를 잡아놓고도 테스트에서는 다른 DB를 볼 수 있다.</p>
<h2 id="serviceconnection">@ServiceConnection</h2>
<p>Spring Boot에서는 <code>@ServiceConnection</code>으로 Testcontainers 연결 정보를 자동 등록할 수도 있다.</p>
<pre><code class="language-kotlin">@TestConfiguration(proxyBeanMethods = false)
class TestcontainersConfig {

    @Bean
    @ServiceConnection
    fun postgresContainer(): PostgreSQLContainer&lt;*&gt; =
        PostgreSQLContainer(&quot;postgres:16&quot;)
}</code></pre>
<p>이 방식은 컨테이너를 코드로 직접 다룬다.<br>그래서 다음 상황에서는 JDBC URL보다 더 낫다.</p>
<ul>
<li>PostgreSQL 외에 Redis, Kafka 같은 컨테이너가 추가된다.</li>
<li>컨테이너 옵션을 코드로 제어해야 한다.</li>
<li>datasource 외의 연결 정보도 함께 등록해야 한다.</li>
<li>테스트별 컨테이너 생명주기를 명확하게 관리해야 한다.</li>
</ul>
<p>즉 이번 선택 기준은 이렇게 잡았다.</p>
<table>
<thead>
<tr>
<th>상황</th>
<th>선택</th>
</tr>
</thead>
<tbody><tr>
<td>PostgreSQL 하나만 필요하다</td>
<td><code>jdbc:tc:</code> URL</td>
</tr>
<tr>
<td>여러 컨테이너가 필요하다</td>
<td><code>@ServiceConnection</code></td>
</tr>
<tr>
<td>Spring Boot 지원 범위를 벗어난 세부 설정이 필요하다</td>
<td><code>@DynamicPropertySource</code></td>
</tr>
</tbody></table>
<p><code>@ServiceConnection</code>을 쓰기 어려운 경우에는 <code>@DynamicPropertySource</code>로 직접 datasource property를 넣을 수도 있다.</p>
<pre><code class="language-kotlin">@Testcontainers
@SpringBootTest
class UserIntegrationTest {

    companion object {
        @Container
        val postgres = PostgreSQLContainer(&quot;postgres:16&quot;)

        @JvmStatic
        @DynamicPropertySource
        fun properties(registry: DynamicPropertyRegistry) {
            registry.add(&quot;spring.datasource.url&quot;, postgres::getJdbcUrl)
            registry.add(&quot;spring.datasource.username&quot;, postgres::getUsername)
            registry.add(&quot;spring.datasource.password&quot;, postgres::getPassword)
        }
    }
}</code></pre>
<h2 id="트레이드오프">트레이드오프</h2>
<p>Testcontainers를 쓰면 분명 H2보다 느리다.</p>
<p>첫 실행 때는 Docker 이미지 pull이 필요할 수 있고, 컨테이너 기동 시간도 있다.<br>또 로컬이나 CI에 Docker가 없으면 테스트가 돌지 않는다.</p>
<p>하지만 DB 통합 테스트의 목적이 운영 DB와의 정합성 검증이라면 이 비용은 낼 만하다.</p>
<p>정리하면 이렇다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>얻은 것</th>
<th>감수한 것</th>
</tr>
</thead>
<tbody><tr>
<td>정합성</td>
<td>운영과 같은 PostgreSQL에서 테스트</td>
<td>H2보다 무거움</td>
</tr>
<tr>
<td>속도</td>
<td>JVM 내 컨테이너 공유로 비용 분산 가능</td>
<td>첫 컨테이너 기동 수 초</td>
</tr>
<tr>
<td>환경</td>
<td>로컬과 CI의 DB 조건 통일</td>
<td>Docker 필수</td>
</tr>
<tr>
<td>설정</td>
<td>JDBC URL은 코드 0줄</td>
<td>DB 단독일 때 가장 적합</td>
</tr>
<tr>
<td>확장성</td>
<td>지금 단계에서는 충분</td>
<td>Redis 등 추가 시 방식 전환 필요</td>
</tr>
</tbody></table>
<h2 id="ci에서는">CI에서는?</h2>
<p>GitHub Actions의 <code>ubuntu-latest</code> runner는 Docker를 사용할 수 있다.<br>그래서 일반적인 Testcontainers 기반 테스트는 별도의 PostgreSQL service를 workflow에 띄우지 않아도 동작한다.</p>
<p>물론 Docker가 필요한 테스트가 된다는 점은 명확히 해야 한다.<br>로컬 개발자 환경이나 CI runner가 Docker를 지원하지 않으면 테스트가 실패한다.</p>
<h2 id="정리">정리</h2>
<ul>
<li>운영이 PostgreSQL이면 DB 통합 테스트도 PostgreSQL에서 돌리는 편이 낫다.</li>
<li>H2 PostgreSQL mode는 PostgreSQL과 같지 않다.</li>
<li><code>jdbc:tc:postgresql:16:///testdb</code>는 단일 DB 컨테이너 테스트에 가장 가볍다.</li>
<li><code>@DataJpaTest</code>에서는 <code>spring.test.database.replace: none</code>을 확인해야 한다.</li>
<li><code>application-test.yml</code>을 쓰려면 <code>@ActiveProfiles(&quot;test&quot;)</code>가 필요하다.</li>
<li>Redis, Kafka 등 컨테이너가 늘어나면 <code>@ServiceConnection</code>이나 <code>@DynamicPropertySource</code>로 넘어가는 게 낫다.</li>
</ul>
<p>테스트 속도만 보면 H2가 더 좋다.<br>하지만 운영에서 깨질 가능성을 줄이는 테스트라면, 조금 느려도 실제 DB에서 검증하는 쪽이 더 낫다고 판단해서 도입하게 되었다. </p>
<h3 id="출처">출처</h3>
<p><a href="https://java.testcontainers.org/modules/databases/jdbc/">https://java.testcontainers.org/modules/databases/jdbc/</a>
<a href="https://java.testcontainers.org/modules/databases/postgres/">https://java.testcontainers.org/modules/databases/postgres/</a>
<a href="https://docs.spring.io/spring-boot/reference/testing/testcontainers.html">https://docs.spring.io/spring-boot/reference/testing/testcontainers.html</a>
<a href="https://docs.spring.io/spring-boot/api/java/org/springframework/boot/test/autoconfigure/jdbc/AutoConfigureTestDatabase.Replace.html">https://docs.spring.io/spring-boot/api/java/org/springframework/boot/test/autoconfigure/jdbc/AutoConfigureTestDatabase.Replace.html</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] Page, Slice, Cursor Pagination 언제 써야 할지?]]></title>
            <link>https://velog.io/@jayaione_ele/Spring-Page-Slice-Cursor-Pagination-%EC%96%B8%EC%A0%9C-%EC%8D%A8%EC%95%BC-%ED%95%A0%EC%A7%80</link>
            <guid>https://velog.io/@jayaione_ele/Spring-Page-Slice-Cursor-Pagination-%EC%96%B8%EC%A0%9C-%EC%8D%A8%EC%95%BC-%ED%95%A0%EC%A7%80</guid>
            <pubDate>Sun, 14 Jun 2026 15:56:01 GMT</pubDate>
            <description><![CDATA[<p>커서 페이징을 다시 보다가 갑자기 헷갈렸다.</p>
<p><code>PageRequest.of(0, size)</code>를 쓰면 offset이 0이니까 cursor 기반 조회에서 사실상 <code>limit size</code>처럼 동작하는 것 아닌가?<br>그럼 <code>Limit.of(size)</code>랑 뭐가 다른가?<br>또 <code>Slice</code>는 <code>hasNext()</code>가 있으니까 cursor pagination처럼 써도 되는 걸까?</p>
<p>정리해보니 핵심은 이거였다.</p>
<blockquote>
<p><code>Page</code>, <code>Slice</code>, <code>PageRequest</code>는 Spring Data의 페이징 추상화이고, cursor pagination은 조회 전략이다.  </p>
</blockquote>
<h2 id="pagerequest에-대해-살펴보기">PageRequest에 대해 살펴보기</h2>
<p><code>PageRequest</code>는 <code>Pageable</code>의 구현체다.</p>
<p>구조를 단순화하면 다음과 같다.</p>
<pre><code class="language-text">AbstractPageRequest
  - int page
  - int size

PageRequest
  - Sort sort</code></pre>
<p>즉 <code>PageRequest</code>는 사실상 다음 세 가지를 가진다.</p>
<pre><code class="language-text">page + size + sort</code></pre>
<p>여기서 중요한 점은 <code>offset</code>을 필드로 저장하지 않는다는 것이다.<br>offset은 필요할 때 계산된다.</p>
<pre><code class="language-kotlin">val request = PageRequest.of(2, 500)

request.pageNumber // 2
request.pageSize   // 500
request.offset     // 1000</code></pre>
<p>계산식은 단순하다.</p>
<pre><code class="language-kotlin">offset = page * size</code></pre>
<p>그래서 다음 요청은:</p>
<pre><code class="language-kotlin">PageRequest.of(2, 500)</code></pre>
<p>SQL로 보면 대략 이런 의미가 된다.</p>
<pre><code class="language-sql">limit 500 offset 1000</code></pre>
<h2 id="pagerequestof0-size는-limitofsize와-같을까">PageRequest.of(0, size)는 Limit.of(size)와 같을까?</h2>
<p>완전히 같지는 않다.</p>
<pre><code class="language-kotlin">PageRequest.of(0, size)
Limit.of(size)</code></pre>
<p>둘 다 &quot;size개만 가져온다&quot;는 결과만 보면 비슷할 수 있다.<br>하지만 표현하는 정보가 다르다.</p>
<p><code>PageRequest</code>는 다음 정보를 가진다.</p>
<ul>
<li>page number</li>
<li>page size</li>
<li>offset</li>
<li>sort</li>
<li>다음/이전 페이지 요청 생성 메서드</li>
</ul>
<p>반면 <code>Limit</code>은 말 그대로 개수 제한만 표현한다.</p>
<p>그래서 cursor 기반 조회에서 <code>PageRequest.of(0, size)</code>를 쓰면 offset은 항상 0이다.<br>이동은 offset이 아니라 cursor 조건이 담당한다.</p>
<pre><code class="language-kotlin">fun findNextUsers(lastUserId: Long?, pageable: Pageable): List&lt;User&gt; {
    return queryFactory
        .selectFrom(user)
        .where(
            lastUserId?.let { user.id.gt(it) }
        )
        .orderBy(user.id.asc())
        .offset(pageable.offset)
        .limit(pageable.pageSize.toLong())
        .fetch()
}</code></pre>
<pre><code class="language-kotlin">findNextUsers(
    lastUserId = 1000L,
    pageable = PageRequest.of(0, 100),
)</code></pre>
<p>이 경우 offset은 0이고, 실제 다음 페이지 이동은 <code>id &gt; 1000</code> 조건이 담당한다.</p>
<p>다만 개인적으로는 cursor 조회라면 <code>PageRequest</code>보다 명시적인 <code>size</code>, <code>Limit</code>, 또는 <code>CursorRequest</code> 같은 타입이 더 읽기 좋다고 생각한다.<br><code>PageRequest</code>라는 이름 자체가 offset 기반 페이지를 떠올리게 만들기 때문이다.</p>
<h2 id="page와-slice-차이">Page와 Slice 차이</h2>
<p>Spring Data에서 <code>Page</code>와 <code>Slice</code>는 비슷해 보이지만 목적이 다르다.</p>
<h3 id="page">Page</h3>
<p><code>Page</code>는 현재 페이지 데이터뿐 아니라 전체 개수와 전체 페이지 수를 알고 있다.</p>
<pre><code class="language-kotlin">val page: Page&lt;User&gt; = userRepository.findAll(PageRequest.of(0, 20))

page.content
page.totalElements
page.totalPages
page.hasNext()</code></pre>
<p>여기서 헷갈렸던 부분이 있다.</p>
<blockquote>
<p>Page도 hasNext()를 가지고 있을까?</p>
</blockquote>
<p>정답은 가지고 있다.</p>
<p><code>Page</code>는 <code>Slice</code>를 상속하기 때문에 <code>hasNext()</code>를 사용할 수 있다.</p>
<p>다만 <code>Page</code>는 전체 개수와 전체 페이지 수를 알기 위해 count query가 추가로 실행될 수 있다.<br>그래서 전체 개수가 필요 없는 상황이라면 굳이 <code>Page</code>를 쓸 필요가 없다.</p>
<h3 id="slice">Slice</h3>
<p><code>Slice</code>는 전체 개수는 모른다.</p>
<p>대신 다음 slice가 있는지만 알 수 있다.</p>
<pre><code class="language-kotlin">val slice: Slice&lt;User&gt; = userRepository.findByActiveTrue(PageRequest.of(0, 20))

slice.content
slice.hasNext()
slice.number
slice.size</code></pre>
<p>그래서 다음 같은 상황에 적합하다.</p>
<ul>
<li>무한 스크롤</li>
<li>더보기 버튼</li>
<li>전체 페이지 수가 필요 없는 목록</li>
<li>count query 비용을 줄이고 싶은 경우</li>
</ul>
<h2 id="slice는-cursor-pagination이-아니다">Slice는 cursor pagination이 아니다</h2>
<p>여기서 중요한 점이 있다.</p>
<p><code>Slice</code>는 반환 타입이다.<br>cursor pagination은 조회 방식이다.</p>
<p>다음 코드는 <code>Slice</code>를 반환하지만 cursor pagination은 아니다.</p>
<pre><code class="language-kotlin">fun findByActiveTrue(pageable: Pageable): Slice&lt;User&gt;

val first = repository.findByActiveTrue(PageRequest.of(0, 100))
val second = repository.findByActiveTrue(first.nextPageable())</code></pre>
<p><code>first.nextPageable()</code>을 쓰면 다음 요청은 page가 1이 된다.</p>
<pre><code class="language-text">page = 1
size = 100
offset = 100</code></pre>
<p>즉 여전히 offset 기반이다.</p>
<p>cursor pagination은 마지막으로 읽은 값을 다음 조회 조건으로 넘겨야 한다.</p>
<pre><code class="language-kotlin">fun findNextUsers(lastUserId: Long?, size: Int): List&lt;User&gt; {
    return queryFactory
        .selectFrom(user)
        .where(
            lastUserId?.let { user.id.gt(it) }
        )
        .orderBy(user.id.asc())
        .limit(size.toLong() + 1)
        .fetch()
}</code></pre>
<p>보통은 <code>size + 1</code>개를 조회해서 다음 데이터가 있는지 판단한다.</p>
<pre><code class="language-kotlin">data class CursorPage&lt;T&gt;(
    val items: List&lt;T&gt;,
    val nextCursor: Long?,
    val hasNext: Boolean,
)

fun toCursorPage(rows: List&lt;User&gt;, size: Int): CursorPage&lt;User&gt; {
    val hasNext = rows.size &gt; size
    val items = rows.take(size)

    return CursorPage(
        items = items,
        nextCursor = if (hasNext) items.lastOrNull()?.id else null,
        hasNext = hasNext,
    )
}</code></pre>
<h2 id="삭제가-섞이면-offset은-위험하다">삭제가 섞이면 offset은 위험하다</h2>
<p>offset 기반 pagination이 특히 위험한 상황은 조회하면서 데이터를 삭제하거나 상태를 바꾸는 경우다.</p>
<p>예를 들어 FCM 토큰 발송 실패 데이터를 조회해서 삭제한다고 해보자.</p>
<pre><code class="language-text">처음 데이터: [1, 2, 3, 4, 5, 6]
page=0, size=2 조회: [1, 2]
처리 후 1, 2 삭제
남은 데이터: [3, 4, 5, 6]
page=1, size=2 조회: offset 2부터 조회 -&gt; [5, 6]</code></pre>
<p>이러면 <code>[3, 4]</code>를 건너뛰게 된다.</p>
<p>이런 경우에는 page나 slice보다 cursor 기반 조회가 낫다.</p>
<pre><code class="language-kotlin">var cursor: Long? = null

while (true) {
    val rows = tokenRepository.findFailedTokensAfter(
        lastId = cursor,
        limit = batchSize + 1,
    )

    val hasNext = rows.size &gt; batchSize
    val targets = rows.take(batchSize)

    if (targets.isEmpty()) break

    tokenRepository.deleteAll(targets)
    cursor = targets.last().id

    if (!hasNext) break
}</code></pre>
<p>핵심은 다음 페이지를 몇 개 건너뛸지로 찾지 않는다는 것이다.<br>마지막으로 처리한 id 이후를 찾는다. 이렇게 되면 중간에 삭제되더라도 offset이 밀리지 않는다. </p>
<h2 id="상황에-따른-정리">상황에 따른 정리</h2>
<table>
<thead>
<tr>
<th>상황</th>
<th>선택</th>
</tr>
</thead>
<tbody><tr>
<td>전체 개수, 전체 페이지 수가 필요하다</td>
<td>Page</td>
</tr>
<tr>
<td>다음 페이지 존재 여부만 필요하다</td>
<td>Slice</td>
</tr>
<tr>
<td>단순히 N개만 제한하면 된다</td>
<td>List + Limit</td>
</tr>
<tr>
<td>삭제나 상태 변경을 동반한다</td>
<td>Cursor</td>
</tr>
<tr>
<td>무한 스크롤이나 대량 순회다</td>
<td>Cursor 또는 Slice</td>
</tr>
</tbody></table>
<p>마지막으로 다시 정리하면, </p>
<ul>
<li><code>Page</code>도 <code>hasNext()</code>를 가진다.</li>
<li><code>Page</code>는 전체 개수 계산을 위해 count query가 붙을 수 있다.</li>
<li><code>Slice</code>는 전체 개수를 모르고 다음 slice 존재 여부만 안다.</li>
<li><code>Slice</code>를 쓴다고 cursor pagination이 되는 것은 아니다.</li>
<li><code>PageRequest.of(0, size)</code>는 cursor 조건과 함께 쓰면 offset 0이라 limit처럼 동작할 수 있다.</li>
<li>하지만 cursor 의도를 명확히 하려면 별도 <code>CursorRequest</code>, <code>Limit</code>, <code>size</code> 파라미터를 고려하는 게 좋다.</li>
</ul>
<p>결국 중요한 건 반환 타입 이름이 아니라 조회 방식이다.<br>데이터가 중간에 바뀔 수 있는지, 전체 개수가 필요한지, 다음 페이지 판단만 필요한지를 보고 선택해야 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] MapsId로 일대일 매핑하기 ]]></title>
            <link>https://velog.io/@jayaione_ele/Spring-MapsId%EB%A1%9C-%EC%9D%BC%EB%8C%80%EC%9D%BC-%EB%A7%A4%ED%95%91%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jayaione_ele/Spring-MapsId%EB%A1%9C-%EC%9D%BC%EB%8C%80%EC%9D%BC-%EB%A7%A4%ED%95%91%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 07 Jun 2026 17:49:06 GMT</pubDate>
            <description><![CDATA[<h2 id="배경">배경</h2>
<p>User마다 한개씩만 생성되어야 하는 엔티티들이 있다. 
기존에는 기본키를 따로 두고 user를 FK로 두는 방식으로 진행했다. </p>
<p>그렇지만 기본키를 많이 안쓰고 findByUserId 식의 메서드를 많이 사용한다면 컬럼 낭비라고 생각하여 전략을 변경해보게 되었다. </p>
<h2 id="전략">전략</h2>
<p>기존 방식은 다음과 같다. </p>
<p><code>@OneToOne</code> + id + user_id </p>
<pre><code>  @Entity
  @Table(name = &quot;user_notification_settings&quot;)
  class UserNotificationSetting(

      // 자체 PK
      @Id
      @GeneratedValue(strategy = GenerationType.IDENTITY)
      @Column(name = &quot;notification_setting_id&quot;)
      val id: Long? = null,                           

      // user FK
      @OneToOne(fetch = FetchType.LAZY, optional = false)
      @JoinColumn(name = &quot;user_id&quot;, nullable = false, unique = true)
      @OnDelete(action = OnDeleteAction.CASCADE)
      val user: User,                                

      // ...
  )</code></pre><ul>
<li>user_id를 unique 제약 조건으로 두면서도 불필요한 notification_setting_id가 존재하는 방식이다. </li>
</ul>
<p><code>@MapsId</code>를 사용해서 개선하고자 한다. </p>
<h3 id="mapsid">MapsId</h3>
<ul>
<li>부모 엔티티의 PK를 자식 엔티티의 PK로 공유하는 패턴이다.</li>
<li>즉 별도의 id 컬럼을 생성하지 않고 외래 키가 곧 기본키가 된다. </li>
</ul>
<p>부모 - User</p>
<pre><code class="language-kotlin">  @Entity
  @Table(name = &quot;users&quot;)
  class User(
      @Id
      @GeneratedValue(strategy = GenerationType.IDENTITY)
      @Column(name = &quot;user_id&quot;)
      val id: Long? = null,
      // ...
  )</code></pre>
<p>자식 - UserNotificationSetting</p>
<ul>
<li>신규 저장 시 DB가 user_id를 자동 생성한다.</li>
<li>user에서 가져온 PK를 <code>@Id</code> 필드에서 자동으로 채우는 방식이다. </li>
<li>DB 레벨에서 User 삭제 시 해당 엔티티도 자동 삭제된다. </li>
</ul>
<pre><code class="language-kotlin">
  @Entity
  @Table(name = &quot;user_notification_settings&quot;)
  class UserNotificationSetting(

      // 
      @MapsId
      @OneToOne(fetch = FetchType.LAZY, optional = false)
      @JoinColumn(name = &quot;user_id&quot;, nullable = false)
      @OnDelete(action = OnDeleteAction.CASCADE)
      val user: User,

      @Id
      @Column(name = &quot;user_id&quot;)
      val userId: Long? = null,                        

      // ...
  )</code></pre>
<p>저장 방법은 다음과 같다. </p>
<pre><code>  // UserAccountRegistrar.kt
  // user를 먼저 저장
  val user = userRepository.save(buildUser())   
  // MapsId가 user.id를 읽어서 user_notification_settings.user_id에 자동 세팅 
  notificationSettingRepository.save(
      UserNotificationSetting(user = user)            
  )
  // @MapsId가 user.id를 읽어서 user_notification_settings.user_id에
  자동 세팅</code></pre><ul>
<li>user의 자식이기 때문에 user가 null인 상태에서는 절대 저장할 수 없다. </li>
</ul>
<p>결론적으로 <code>@MapsId</code> 방식에서는 컬럼이 하나다.
user_id가 PK면서 동시에 FK이기 때문이다. </p>
<p>일대일 매핑을 할 때 무지성 중간테이블 기본키 + 외래키 해서 추가 컬럼을 만들기보다는 이런 식으로 하면 컬럼 낭비를 줄일 수 있을 것 같다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Kotlin/Spring] Kotlin 환경에서 효율적으로 로그 작성]]></title>
            <link>https://velog.io/@jayaione_ele/KotlinSpring-Kotlin-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-%ED%9A%A8%EC%9C%A8%EC%A0%81%EC%9C%BC%EB%A1%9C-%EB%A1%9C%EA%B7%B8-%EC%9E%91%EC%84%B1</link>
            <guid>https://velog.io/@jayaione_ele/KotlinSpring-Kotlin-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-%ED%9A%A8%EC%9C%A8%EC%A0%81%EC%9C%BC%EB%A1%9C-%EB%A1%9C%EA%B7%B8-%EC%9E%91%EC%84%B1</guid>
            <pubDate>Sun, 07 Jun 2026 09:49:44 GMT</pubDate>
            <description><![CDATA[<h2 id="배경">배경</h2>
<p>자바 스프링 -&gt; 코틀린 스프링으로 갈아타면서, <code>Slf4j</code>을 당연하게 사용했는데 코틀린은 롬북을 잘 사용하지 않기도 하고, 이참에 다른 로거와의 성능을 비교해서 써보자 하고 다른 로그 작성 방법을 찾아보게 되었다. </p>
<h3 id="로거-종류">로거 종류</h3>
<h4 id="slf4j-기본-parameterized">SLF4J 기본 parameterized</h4>
<ul>
<li>로그 수준에 따라서 로그가 남겨진다. 즉 logger.info, logger.debug로 지정하면 레벨에 따라 표시 여부가 졀정된다.</li>
<li>문제점은 로그가 현재 로그 수준에 해당하지 않으면 남겨지지 않는데, 로그에서 호출하는 객체의 메서드는 그대로 호출된다. </li>
<li>즉 비용이 있는 메서드면 쓸데없는 성능 저하로 이어진다. </li>
<li>String 결합만 막힐 뿐, 인자 평가 자체는 막지 못한다 → 불완전한 Lazy</li>
<li>Object, 비싼 메서드는 실행이 되긴 한다는 것</li>
</ul>
<p>해결..?</p>
<ul>
<li>람다를 사용하면 로그 수준 조건문을 사용하지 않아도 성능 영향 없이 가능하다. 
  -&gt; but Object 가변인자만 받을 수 있고, 람다를 사용할 방법은 없다.. 결국 조건문 사용해야한다. </li>
</ul>
<h4 id="해결-시도-1----조건문">해결 시도 1  - 조건문</h4>
<pre><code>  if (logger.isDebugEnabled()) {

  if (logger.isDebugEnabled()) {
      logger.debug(&quot;{}&quot;, foo.veryExpensiveMethod());
  }</code></pre><p> -&gt; 문장마다 매번 체크 붙여줘야 해서 귀찮다.. </p>
<h4 id="slf4j-supplier-20">SLF4J Supplier (2.0+)</h4>
<pre><code class="language-kotlin">logger.debug(&quot;{}&quot;, () -&gt; foo.veryExpensiveMethod());</code></pre>
<ul>
<li>이렇게 하면 인자를 람다로 감싸게 됨, 조건문에 걸려야 실행된다.</li>
<li>그렇지만 인자를 람다로 감싸야 진짜 Lazy 방법이다. </li>
<li>그런데 인자를 감싸면 인자가 많을수록 람다도 여러 개 필요해서 아주 번거롭다. </li>
</ul>
<h4 id="kotlin-logging">Kotlin-logging</h4>
<p>사용 방법</p>
<pre><code class="language-kotlin"> logger.debug { &quot;${foo.veryExpensiveMethod()}&quot; }</code></pre>
<ul>
<li>위의 문제 해결하려면 인자마다 람다로 감싸야 하는데, 코틀린에서는 함수 자체가 람다를 인자로 받는 구조라서 해결된다. </li>
<li>{} 안의 코드 전체가 하나의 람다 블록이다. 함수 호출을 포함한 문자열 생성 자체가 람다 안에 있어서, debug 레벨이 활성화됐을 때만 블록 전체가 실행된다. 
내부적으로  if (isDebugEnabled) logger.debug(msg().toString()) 으로 동작한다. </li>
</ul>
<h3 id="결론">결론</h3>
<p>Kotlin-logging은 조건문과 SLF4J Supplier와 동일한 효과를 가지면서, 매번 조건문을 쓰지 않아도 되고 인자마다 람다를 감싸지 않아도 된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] Jetpack Compose 에서 Lazy 레이아웃 사용하기 ]]></title>
            <link>https://velog.io/@jayaione_ele/Android-Jetpack-Compose-%EC%97%90%EC%84%9C-Lazy-%EB%A0%88%EC%9D%B4%EC%95%84%EC%9B%83-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jayaione_ele/Android-Jetpack-Compose-%EC%97%90%EC%84%9C-Lazy-%EB%A0%88%EC%9D%B4%EC%95%84%EC%9B%83-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 19 May 2026 18:44:00 GMT</pubDate>
            <description><![CDATA[<p>UMC 안드로이드 8주차 미션 블로그 챌린지입니다.</p>
<h1 id="android---jetpack-compose-에서-lazy-레이아웃-사용하기">Android - Jetpack Compose 에서 Lazy 레이아웃 사용하기</h1>
<h1 id="개념정리">개념정리</h1>
<h2 id="recyclerview-vs-lazy-레이아웃의-차이">RecyclerView vs Lazy 레이아웃의 차이</h2>
<h4 id="recyclerview는-기존-view-방식">RecyclerView는 기존 View 방식</h4>
<ul>
<li>RecyclerView, Adapter, ViewHolder, 각 항목의 레이아웃 XML이 필요하다. </li>
<li>즉 XML파일, 코틀린 파일 분리해서 관리해야 한다. </li>
</ul>
<h4 id="lazy-레이아웃">Lazy 레이아웃</h4>
<ul>
<li>LazyColumn, LazyRow로 전부 대체 가능</li>
<li>Compose에서는 화면에 보이는 영역에 보이는 아이템만 컴포즈하고 배치</li>
<li>스크롤로 화면 밖을 벗어난 아이템은 컴포지션에서 제거됨 <ul>
<li>Lazy 레이아웃은 컴포즈의 컴포지션 단계를 제어함<ul>
<li>컴포지션(Composition) → 레이아웃(Layout) → 그리기(Drawing)</li>
</ul>
</li>
<li>일반 컬럼에서는 화면 밖이더라도 컴포즈됨</li>
<li>LazyColumn은 화면 밖에 있다면 컴포지션 트리에 존재하지 않음</li>
<li>즉 스크롤할 때 화면 밖으로 나가면 컴포지션에서도 제거되며, onDispose 호출 + remember로 들고 있던 상태도 사라짐</li>
<li>스크롤하면서 새로 화면에 보이게된 요소는 새로 컴포즈될 때 상태가 초기화됨</li>
</ul>
</li>
<li>즉 화면에 보이지 않는 아이템은 컴포지션 트리에서도 존재하지 않기 때문에, 메모리나 CPU 낭비가 없다.</li>
<li>Key를 통한 아이템 재배치<ul>
<li>Key가 없다면 위치 기반으로 상태를 추적한다. 이렇게되면 아이템 순서가 바뀔 수 있기 때문에, id 기반으로 상태를 추적해서 순서가 변경된다면 key에 따라서 재배치되어 정확성이 보장됨</li>
</ul>
</li>
</ul>
<h3 id="lazycolumn--lazyrow의-기본-사용법-숙지">LazyColumn / LazyRow의 기본 사용법 숙지</h3>
<h4 id="lazycolumn">LazyColumn</h4>
<ul>
<li>아이템을 수직으로 배치해서 세로 스크롤을 지원<pre><code class="language-kotlin">LazyColumn {
  item { Text(&quot;헤더&quot;) }
  items(5) { index -&gt; Text(&quot;Item $index&quot;) }
  item { Text(&quot;푸터&quot;) }
}</code></pre>
</li>
</ul>
<h4 id="lazyrow">LazyRow</h4>
<ul>
<li>아이템 수평으로 배치해서 가로 스크롤 지원<pre><code class="language-kotlin">LazyRow {
  items(photos) { photo -&gt; PhotoCard(photo) }
}</code></pre>
</li>
</ul>
<ul>
<li>대부분의 Compose 레이아웃은 <code>@Composable</code> 콘텐츠 블록을 직접 받지만, Lazy 컴포넌트는 <code>LazyListScope.()</code> <strong>DSL 블록</strong>을 받음</li>
<li>레이아웃과 스크롤 위치에 따라 필요한 아이템을 알아서 추가</li>
</ul>
<h3 id="옵션">옵션</h3>
<h4 id="content-padding--콘텐츠-가장자리-여백">Content Padding — 콘텐츠 가장자리 여백</h4>
<ul>
<li>LazyColumn 자체가 아닌 아이템들에 패딩이 적용됨</li>
<li>Modifier.padding()으로 직접 붙이면 컴포저블 자체에 여백이 생김</li>
<li>contentPadding은 스크롤 가능한 콘텐츠 영역에 여백 추가<pre><code class="language-kotlin">LazyColumn(
  contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
) { /* ... */ }</code></pre>
</li>
</ul>
<h4 id="content-spacing--아이템-간격">Content Spacing — 아이템 간격</h4>
<ul>
<li><code>Arrangement.spacedBy()</code> 사용</li>
<li>아이템 사이에 간격을 줌</li>
</ul>
<pre><code class="language-kotlin">LazyColumn(verticalArrangement = Arrangement.spacedBy(4.dp)) { /* ... */ }
LazyRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) { /* ... */ }</code></pre>
<h3 id="lazylistscope-dsl의-다양한-함수-활용">LazyListScope DSL의 다양한 함수 활용</h3>
<ul>
<li><p>레이아웃 내에 아이템 관련 다양한 함수 제공</p>
</li>
<li><p>item(){} : 단일 아이템</p>
</li>
<li><p>item(count){} : 개수로 아이템 추가</p>
</li>
<li><p>item(list): 컬렉션으로 아이템 추가 </p>
</li>
<li><p>itemIndexed(): 인덱스로 아이템 추가, 아이템의 순서가 필요할 때 사용</p>
</li>
<li><p>contentType: 성능 최적화, 다양한 타입 아이템이 혼재할 때 지정하면 컴포즈가 동일 타입끼리만 컴포즈 재사용해서 성능을 높일 수 있음</p>
</li>
</ul>
<h3 id="lazyverticalgrid--lazyhorizontalgrid-그리드-레이아웃-이해">LazyVerticalGrid / LazyHorizontalGrid 그리드 레이아웃 이해</h3>
<ul>
<li>아이템을 그리드 형태로 표시할 때 사용</li>
</ul>
<h4 id="gridcellsadaptive--최소-너비로-열-수-자동-결정"><code>GridCells.Adaptive</code> — 최소 너비로 열 수 자동 결정</h4>
<ul>
<li><p>화면 너비에 따라 열 수가 자동으로 결정됨</p>
</li>
<li><p>다양한 화면 크기를 설정할 수 있음</p>
<pre><code class="language-kotlin">  LazyVerticalGrid(
  columns = GridCells.Adaptive(minSize = 128.dp)
  ) {
      items(photos) { photo -&gt; PhotoItem(photo) }
  }</code></pre>
</li>
</ul>
<h3 id="gridcellsfixed--고정-열-수"><code>GridCells.Fixed</code> — 고정 열 수</h3>
<pre><code class="language-kotlin">LazyVerticalGrid(
    columns = GridCells.Fixed(2),
    verticalArrangement = Arrangement.spacedBy(16.dp),
    horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
    items(photos) { item -&gt; PhotoItem(item) }
}</code></pre>
<h3 id="griditemspan---특정-아이템에-커스텀-span-적용">GridItemSpan - 특정 아이템에 커스텀 span 적용</h3>
<ul>
<li>특정 아이템이 여러 열을 차지하게 할 때 <code>span</code> 파라미터를 활용</li>
<li>아이템이 몇 칸을 차지할지 span 파라미터로 지정</li>
<li>maxLineSpan은 현재 행의 전체 컬럼 수 -&gt; 한 행을 통째로 점유 가능</li>
</ul>
<pre><code class="language-kotlin">LazyVerticalGrid(columns = GridCells.Adaptive(minSize = 30.dp)) {
    item(span = { GridItemSpan(maxLineSpan) }) {
        CategoryCard(&quot;Fruits&quot;)  // 전체 행을 차지
    }
    items(items) { item -&gt; ItemCard(item) }
}</code></pre>
<h3 id="lazyverticalstaggeredgrid">LazyVerticalStaggeredGrid</h3>
<ul>
<li>각 아이템의 높이 또는 너비가 다를 수 있는 그리드 </li>
<li>핀터레스트처럼 불규칙한 높이를 가진 그리드를 생성</li>
<li>벽돌을 쌓듯이 빈 공간을 채움</li>
<li>짧은 컬럼에 다음 아이템을 채우는 방식</li>
</ul>
<p><strong><code>columns = StaggeredGridCells.Adaptive(200.dp)</code></strong></p>
<ul>
<li>각 컬럼이 최소 200dp가 되도록 화면 너비에 따라 컬럼 수 자동 결정</li>
<li>고정하고 싶으면 <code>StaggeredGridCells.Fixed(2)</code>처럼 개수 지정</li>
</ul>
<p><strong><code>verticalItemSpacing = 4.dp</code></strong></p>
<ul>
<li>세로 방향 아이템 간 간격</li>
<li>같은 컬럼 내 위/아래 아이템 사이 여백</li>
</ul>
<p><strong><code>horizontalArrangement = Arrangement.spacedBy(4.dp)</code></strong></p>
<ul>
<li>가로 방향 컬럼 간 간격</li>
</ul>
<p>아이템 블록 </p>
<pre><code class="language-kotlin">modifier = Modifier.fillMaxWidth().wrapContentHeight()</code></pre>
<ul>
<li><code>fillMaxWidth()</code>: 컬럼 너비를 꽉 채움 </li>
<li><code>wrapContentHeight()</code>: 이미지 원본 비율에 맞춰 높이 결정, staggered 효과가 생김</li>
</ul>
<h3 id="아이템-key-애니메이션-sticky-header-등-심화-기능-활용">아이템 Key, 애니메이션, Sticky Header 등 심화 기능 활용</h3>
<h4 id="아이템-key">아이템 Key</h4>
<ul>
<li>각 아이템의 상태는 리스트에서 아이템의 위치를 키로 사용해서, 데이터가 변경되면 위치가 바뀐 아이템은 상태를 잃을 수 있음</li>
<li>아이템에서 Key를 사용하면, 아이템 위치가 변경되더라도 컴포즈가 상태(remember한 값 등)를 아이템과 함께 이동시킴</li>
</ul>
<pre><code class="language-kotlin">LazyColumn {
    items(
        items = messages,
        key = { message -&gt; message.id }  // key 사용
    ) { message -&gt;
        MessageRow(message)
    }
}</code></pre>
<h4 id="아이템-변경-애니메이션">아이템 변경 애니메이션</h4>
<ul>
<li><code>animateItem()</code> modifier를 사용하면 아이템 추가·삭제·이동 시 애니메이션을 자동으로 적용할 수 있음, Key와 함게 사용해야 효과적<pre><code class="language-kotlin">  LazyColumn {
      items(messages, key = { it.id }) { message -&gt;
          MessageRow(
              message,
              modifier = Modifier.animateItem()
          )
      }
  }</code></pre>
</li>
</ul>
<h3 id="sticky-header--고정-헤더">Sticky Header — 고정 헤더</h3>
<ul>
<li>그룹화된 데이터를 표시할 때 유용함</li>
<li><code>stickyHeader()</code>를 사용 </li>
</ul>
<p>단일 헤더</p>
<pre><code class="language-kotlin">@Composable
fun ListWithHeader(items: List&lt;Item&gt;) {
    LazyColumn {
        stickyHeader { Header() }
        items(items) { item -&gt; ItemRow(item) }
    }
}</code></pre>
<p>여러 헤더 (이름 첫 글자별 그룹)</p>
<ul>
<li>groupBy: 리스트를 어떤 기준으로 묶어서 Map으로 만들어 줌</li>
<li>Key는 첫글자, value는 첫글자로 시작하는 연락처 목록 -&gt; Map으로 선언</li>
<li>그룹별로 선언하고, CharacterHeader로 해당 그룹의 헤더를 그림, 스크롤해도 상단에 붙어있게 할 수 있음</li>
</ul>
<pre><code class="language-kotlin">// 그룹핑은 ViewModel에서 처리하는 것을 권장
val grouped = contacts.groupBy { it.firstName[0] }

@Composable
fun ContactsList(grouped: Map&lt;Char, List&lt;Contact&gt;&gt;) {
    LazyColumn {
        grouped.forEach { (initial, contactsForInitial) -&gt;
            stickyHeader { CharacterHeader(initial) }
            items(contactsForInitial) { contact -&gt; ContactListItem(contact) }
        }
    }
}</code></pre>
<p>주의 - 같은 방향 스크롤 중첩 금지</p>
<ul>
<li>크기가 정해지지 않은 <code>LazyColumn</code>을 세로 스크롤 <code>Column</code> 안에 중첩하면 <code>IllegalStateException</code>이 발생</li>
<li>방향이 다른 경우 허용: 가로 스크롤 Row 안에 세로 <code>LazyColumn</code> 은 가능</li>
</ul>
<p>주의 - 하나의 item 블록에 여러 요소 넣지 않기</p>
<ul>
<li>하나의 <code>item { }</code> 블록에 여러 컴포저블을 넣으면 하나의 단위로 취급되어 성능이 안좋아짐</li>
<li>Divider는 이전 아이템과 같은 블록에 넣어도 됨 </li>
</ul>
<h3 id="스크롤-상태-제어-lazyliststate-이해">스크롤 상태 제어 (LazyListState) 이해</h3>
<ul>
<li>LazyListState: LazyColumn, Row의 현재 상태를 담고 있는 객체 - 스크롤, 아이템 </li>
<li>state 파라미터에 상태를 넘겨주면 , 상태를 통해 리스트를 조회하거나 조작할 수 있음</li>
<li>리스트의 스크롤 상태를 읽거나 제어하고 싶을 때 사용</li>
</ul>
<pre><code class="language-kotlin">@Composable
fun MessageList(messages: List&lt;Message&gt;) {
    val listState = rememberLazyListState()
    val coroutineScope = rememberCoroutineScope()

    LazyColumn(state = listState) { /* ... */ }

    ScrollToTopButton(
        onClick = {
            coroutineScope.launch {
                listState.animateScrollToItem(index = 0)  // 부드럽게 상단으로
            }
        }
    )
}</code></pre>
<table>
<thead>
<tr>
<th>API</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>firstVisibleItemIndex</code></td>
<td>현재 화면에 보이는 첫 번째 아이템의 인덱스</td>
</tr>
<tr>
<td><code>firstVisibleItemScrollOffset</code></td>
<td>첫 번째 아이템의 스크롤 오프셋</td>
</tr>
<tr>
<td><code>scrollToItem(index)</code></td>
<td>즉시 해당 위치로 이동</td>
</tr>
<tr>
<td><code>animateScrollToItem(index)</code></td>
<td>애니메이션과 함께 부드럽게 이동 (smooth scroll)</td>
</tr>
</tbody></table>
<ul>
<li><code>scrollToItem()</code>과 <code>animateScrollToItem()</code> 모두 <strong>suspend 함수</strong>이므로 반드시 코루틴 내에서 호출해야 함</li>
<li>코루틴으로 해야하는 이유: 부드럽게 스크롤을 하는 함수인데 시간이 걸리는 작업이라서, 코루틴을 시작하고 그 안에서 호출 가능</li>
<li><code>rememberCoroutineScope()</code> : 해당 컴포저블이 살아있는 동안 사용하는 코루틴</li>
</ul>
<h3 id="derivedstateof-최적화"><code>derivedStateOf</code> 최적화</h3>
<ul>
<li><p>firstVisibleItemIndex는 스크롤하면 계속 바뀌는 값인데, 컴포즈에서 State값이 바뀌면 그걸 읽는 컴포저블은 리컴포지션되어 다시 그러짐</p>
</li>
<li><p>즉 인덱스가 바뀔 때마다 매번 화면을 다시 그림, 결과가 똑같으면 매번 다시 그릴 필요가 없음</p>
</li>
<li><p><code>derivedStateOf</code>: 결과값을 감지해서, 변경이 된다고 하면 그때 리컴포지션이 일어남 </p>
</li>
<li><p>즉 스크롤할때마다 리컴포지션이 발생하지 않고, 값이 실제로 바뀔때만 된다~</p>
</li>
<li><p>스크롤 이벤트는 매우 빈번하게 발생하므로, <code>firstVisibleItemIndex</code>를 읽을 때는 <code>derivedStateOf</code>로 감싸 결과값이 실제로 변경될 때만 Recomposition이 발생하도록 최적화해야 함</p>
</li>
<li><p><code>rememberLazyListState()</code> — LazyListState를 컴포지션에 기억</p>
</li>
<li><p><code>rememberCoroutineScope()</code> — 컴포저블 수명에 묶인 코루틴 스코프</p>
</li>
<li><p><code>derivedStateOf</code> — 다른 State에서 파생된 State, 변화 감지 최적화</p>
</li>
<li><p><code>Arrangement</code> — 아이템 배치 전략 (<code>spacedBy</code>, <code>Center</code>, <code>SpaceBetween</code> 등)</p>
</li>
<li><p><code>PaddingValues</code> — contentPadding에 사용하는 패딩 값 래퍼</p>
</li>
</ul>
<hr>
<h2 id="구현-내용">구현 내용</h2>
<h3 id="화면-구성">화면 구성</h3>
<ul>
<li><strong>홈</strong>: 메인 배너 이미지 + What&#39;s new 가로 스크롤 상품 목록</li>
<li><strong>구매하기</strong>: 탭 바(전체/Tops&amp;T-Shirts/sale) + 2열 상품 그리드, BestSeller 뱃지</li>
<li><strong>위시리스트</strong>: 좋아요한 상품만 필터링해서 표시, 비어있을 때 빈 상태 처리</li>
<li><strong>상품 상세</strong>: 이미지, 상품 정보, 뒤로가기</li>
</ul>
<h3 id="아키텍처">아키텍처</h3>
<ul>
<li><strong>Route/Screen 분리</strong>: <code>Route</code>에서 ViewModel 연결 및 상태 관리, <code>Screen</code>은 UI만 담당</li>
<li><strong>Hilt</strong>: ViewModel 의존성 주입</li>
<li><strong>UiState</strong>: 각 화면마다 sealed class로 상태 관리</li>
</ul>
<h3 id="네비게이션">네비게이션</h3>
<ul>
<li><code>@Serializable</code> data object/class 기반 타입 안전 네비게이션</li>
<li><code>navigation&lt;MainGraph&gt;</code>로 중첩 그래프 구성</li>
<li>하단 탭 바: 현재 route 기반으로 선택 탭 자동 반영</li>
</ul>
<h3 id="기타">기타</h3>
<ul>
<li><strong>Coil</strong>: <code>AsyncImage</code>로 네트워크 이미지 로딩</li>
<li><strong>BackHandler</strong>: 홈 화면에서 2초 내 두 번 뒤로가기 시 앱 종료</li>
<li><strong>Mock 데이터</strong>: 실제 API 연동 전 Mock 데이터로 UI 검증</li>
<li><strong>Desgin System</strong>: 자주 사용하는 Color, text style을 디자인 시스템 패키지로 분리</li>
</ul>
<h2 id="트러블-슈팅">트러블 슈팅</h2>
<h2 id="1--navigation-graph-scope-방식으로-변경해서-viewmodel-공유">1 . Navigation Graph Scope 방식으로 변경해서 ViewModel 공유</h2>
<h4 id="문제">문제</h4>
<p>좋아요 뷰모델을 만들어서 여러 화면에서 사용하려고 했는데, 이러면 Main에 등록해야 하는데, 공식문서를 찾아보니까 좋은 방식이 아니었다. </p>
<ul>
<li><code>MainScreen → AppNavHost → 각 Route</code>로 계속 파라미터 전달해야 함</li>
<li>ViewModel 생명주기가 Activity 전체에 묶임 (앱 켜있는 내내 살아있음)</li>
<li>공식 Android에서 권장하지 않는 방향.. </li>
</ul>
<h4 id="해결">해결</h4>
<p>Navigation Graph Scope 방식으로 변경, <code>hiltViewModel</code>로 같은 NavGraph 안에서 동일한 인스턴스를 공유하도록 해서 좋아요 상태가 공유되도록 설정했다. </p>
<pre><code class="language-kotlin">val parentEntry = remember(backStackEntry) {
    navController.getBackStackEntry&lt;MainGraph&gt;()
}
val favoriteViewModel: FavoriteViewModel = hiltViewModel(parentEntry)</code></pre>
<ul>
<li>좋아요 클릭 -&gt; FavoirteViewModel.toggleLike(id) 호출</li>
<li>Wish 화면에서 likedProductsIds를 받아오므로, 변경 감지해서 해당 상품 표시되도록</li>
<li>같은 그래프 내부이므로 뷰모델이 같은 인스턴스라서 상태가 공유된다.</li>
<li>MainGraph를 벗어나면 ViewModel도 자동 소멸하는 방식이다. </li>
</ul>
<h3 id="2--상세-페이지-진입-시-하단-탭-구매하기-유지">2. ## 상세 페이지 진입 시 하단 탭 구매하기 유지</h3>
<ul>
<li>상품 상세 페이지로 이동하면 하단 탭이 아무것도 선택되지 않은 상태가 되어버린다. </li>
<li><code>this?.hasRoute&lt;PurchaseDetail&gt;() == true -&gt; BottomNavItem.PURCHASE</code> </li>
<li>이렇게 하면 상세보기의 경우에도 구매하기 탭 선택된 상태로 매핑된다. </li>
</ul>
<pre><code class="language-kotlin">this?.hasRoute&lt;PurchaseDetail&gt;() == true -&gt; BottomNavItem.PURCHASE</code></pre>
<h3 id="3-asyncimage-큰-사이즈-적용-안됨">3. AsyncImage 큰 사이즈 적용 안됨</h3>
<p>Box 안에서 이미지를 사용하는 방식으로 구현했는데, 
AsyncImage에서 직접 사이즈 지정했는데 적용이 안되었다. .
Box에서 사이즈 고정, 이미지에서 max로 채우도록 변경했다. </p>
<pre><code class="language-kotlin">Box(  
    modifier = Modifier  
        .size(314.dp)  
        .background(Color(0xFFF5F5F5))  
        .aspectRatio(1f)  
) {  
    AsyncImage(  
        model = item.productImage,  
        contentDescription = item.name,  
        modifier = Modifier.fillMaxSize(),  
        contentScale = ContentScale.Crop  
    )  
}</code></pre>
<h3 id="4-tabbar-언더라인이-화면-전체를-채움---text기준으로-채워지지도-않음">4. TabBar 언더라인이 화면 전체를 채움 /  Text기준으로 채워지지도 않음</h3>
<h4 id="문제-1">문제</h4>
<p>탭을 왼쪽 정렬하려고 <code>weight(1f)</code>를 제거했더니 선택된 탭의 언더라인이 화면 전체 너비를 채워버렸다 ... </p>
<h4 id="원인">원인</h4>
<p><code>weight(1f)</code> 제거 후에  Column에 너비 제약이 없어지면서 내부 <code>fillMaxWidth()</code>가 부모의 max constraint을 그대로 사용해버렸다. </p>
<h3 id="해결-1">해결</h3>
<ul>
<li><code>Row</code>: <code>fillMaxWidth()</code> + <code>weight(1f)</code> 제거 → 탭이 왼쪽부터 붙음, <code>padding(start = 9.dp)</code> 은 유지 </li>
<li><code>Column</code>: <code>Modifier.width(IntrinsicSize.Max)</code>로 Column을 텍스트 너비로 고정해 해결,<br>horizontal 패딩은 Column에서 Text로 이동해 언더라인이 탭 전체 너비를 정확히 채우도록 수정함</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Kotlin] Kotlin 기초 - 변수,타입,연산자]]></title>
            <link>https://velog.io/@jayaione_ele/Kotlin-Kotlin-%EA%B8%B0%EC%B4%88-%EB%B3%80%EC%88%98%ED%83%80%EC%9E%85%EC%97%B0%EC%82%B0%EC%9E%90</link>
            <guid>https://velog.io/@jayaione_ele/Kotlin-Kotlin-%EA%B8%B0%EC%B4%88-%EB%B3%80%EC%88%98%ED%83%80%EC%9E%85%EC%97%B0%EC%82%B0%EC%9E%90</guid>
            <pubDate>Sun, 17 May 2026 17:22:57 GMT</pubDate>
            <description><![CDATA[<p><a href="https://www.inflearn.com/course/java-to-kotlin/dashboard?cid=328606">자바 개발자를 위한 코틀린 입문</a>
해당 강의를 듣고 작성하였다. </p>
<p>안드로이드,스프링에서 코틀린을 많이 사용하게 되었는데, 자바랑 비슷한 것 같다가도 너무 다른 것 같은데 지식 없이 사용하자니 어떤 실수를 하는지도 모르는 것 같아서 빠르게 기초를 공부하고자 한다. </p>
<h3 id="변수">변수</h3>
<ul>
<li><p><code>var</code>, <code>val</code>이 있는데 <code>val</code>을 주로 사용한다. <code>final</code> 같은 느낌이다.</p>
</li>
<li><p>객체는 <code>new</code> 없이 생성 가능하다.</p>
</li>
</ul>
<h3 id="null">Null</h3>
<ul>
<li><p><strong><strong>Safe Call</strong></strong>: <code>null</code>이 아닌 경우에만 호출한다. <code>?.</code>로 호출하면 <code>null</code>이면 <code>null</code>을 반환한다.</p>
</li>
<li><p><strong><strong>Not-null assertion</strong></strong>: <code>null</code>이 절대 아니라면 <code>!!</code>로 단언 가능하다. <code>null</code>이라면 NPE를 던진다.</p>
</li>
<li><p><strong><strong>엘비스 연산자</strong></strong>: <code>?:</code>는 <code>null</code>이라면 이 값으로 정의한다. 즉 <code>null</code>일 때만 호출된다.</p>
</li>
<li><p><strong>**</strong><code>**?.let { }</code>****: <code>null</code>이 아닐 때만 블록을 실행한다.</p>
</li>
<li><p><strong><strong>플랫폼 타입</strong></strong>: 코틀린이 <code>null</code> 관련 정보를 알 수 없는 타입은 exception이 날 수 있다. 자바 코드 사용 시 유의한다.</p>
</li>
<li><p><code>null</code> 검사를 한 번 하면 non-null임을 컴파일러가 알 수 있다.</p>
</li>
</ul>
<h3 id="type">Type</h3>
<p><strong>기본 타입</strong></p>
<ul>
<li><p>기본값을 보고 타입을 추론한다. <code>L</code>이 붙으면 <code>Long</code>, <code>f</code>가 붙으면 <code>Float</code>이다.</p>
</li>
<li><p>코틀린에서 타입 변환은 명시적으로 이루어져야 한다. 자바는 암시적으로 큰 타입으로 자동 변형되지만, 코틀린은 타입이 안 맞으면 에러가 난다. <code>to변환타입()</code>을 반드시 사용한다.</p>
</li>
<li><p>결과를 타입 변환할 때 자바는 괄호를 사용하지만, 코틀린은 <code>결과.to변환타입()</code>으로 한다.</p>
</li>
<li><p>변수가 nullable이면 적절한 처리가 필요하다.</p>
</li>
</ul>
<p><strong>타입 캐스팅</strong></p>
<ul>
<li><p><code>instanceOf</code> 대신 <code>is</code>로 해당 타입인지 <code>true</code>/<code>false</code>를 반환한다.</p>
</li>
<li><p><code>value as Type</code>: 괄호 대신 <code>as</code>로 타입 캐스팅한다. 타입이 아니면 예외를 던진다.</p>
</li>
<li><p><code>value as? Type</code>: <code>value</code>가 해당 타입이 아니거나 <code>null</code>이면 <code>null</code>을 반환한다. 안전한 타입 형변환 방법이다.</p>
</li>
<li><p><strong><strong>스마트 캐스트</strong></strong>: <code>is</code>를 통과했다면 <code>as</code>를 생략하고 바로 해당 타입으로 써도 된다. 컴파일러가 이를 기억하기 때문이다.</p>
</li>
<li><p><code>!is</code>는 <code>instanceOf</code>의 반대이다.</p>
</li>
</ul>
<p><strong>코틀린의 특이한 타입</strong></p>
<ul>
<li><code>Any</code></li>
</ul>
<p>    - Java의 <code>Object</code> 역할로, 모든 객체와 모든 원시타입의 최상위 타입이다.</p>
<p>    - <code>equals</code>, <code>hashCode</code>, <code>toString</code>이 존재한다.</p>
<p>    - <code>Any</code> 자체는 <code>null</code>을 포함하지 못한다. 포함하려면 <code>Any?</code>로 쓴다.</p>
<ul>
<li><code>Unit</code></li>
</ul>
<p>    - Java의 <code>void</code>와 동일한 역할을 한다.</p>
<p>    - <code>Unit</code>은 그 자체로 타입 인자로 사용 가능하다. 제네릭 <code>void</code>를 하려면 <code>Void</code>를 써야 하는데, <code>Unit</code>은 그냥 쓰면 된다.</p>
<p>    - 코틀린의 <code>Unit</code>은 실제로 존재하는 타입이다.</p>
<ul>
<li><code>Nothing</code></li>
</ul>
<p>    - 함수가 정상적으로 끝나지 않았다는 사실을 표현하는 역할을 한다.</p>
<p>    - 무조건 예외 반환하거나 무한 루프인 함수에 사용한다.</p>
<p>    - 잘 사용하지 않는다.</p>
<p><strong>String interpolation / String indexing</strong></p>
<ul>
<li><p><code>${변수}</code>를 사용하면 값이 들어간다.</p>
</li>
<li><p>자바에서는 <code>String.format</code>으로 문자열 포맷을 했어야 했다.</p>
</li>
<li><p>단일 변수라면 중괄호를 생략할 수도 있다.</p>
</li>
<li><p>그런데 변수 이름만 사용하더라도 <code>${변수}</code>로 쓰는 것이 가독성, 정규식 활용, 통일성 측면에서 좋다.</p>
</li>
<li><p>여러 줄에 걸친 문자열 작성 시 <code>&quot;&quot;&quot; &quot;&quot;&quot;.trimIndent()</code>로 한다. 자바에서는 <code>StringBuilder</code>의 <code>append</code>를 써야 했다.</p>
</li>
<li><p>문자열에서 특정 문자 가져오기: 자바에서는 <code>charAt</code>을 사용했지만, 코틀린에서는 배열처럼 <code>str[인덱스]</code>로 가져온다.</p>
</li>
</ul>
<h3 id="연산자">연산자</h3>
<ul>
<li><p>**단항, 산술 연산자: 자바와 완전히 동일하다.</p>
</li>
<li><p><strong>비교 연산자와 동등성, 동일성</strong></p>
</li>
</ul>
<p>    - 비교 연산자는 사용법이 완전히 동일하다.</p>
<p>    - 객체에 비교 연산자를 사용하면 <code>compareTo</code>를 자동으로 호출한다. 자바는 무조건 <code>compareTo</code>를 직접 사용했어야 했다.</p>
<p>    - <strong><strong>동등성</strong></strong>: 두 객체의 값이 같은지를 본다.</p>
<p>    - <strong><strong>동일성</strong></strong>: 객체가 동일한지, 즉 주소가 같은지를 본다.</p>
<p>    - 코틀린에서는 동일성에 <code>===</code>, 동등성에 <code>==</code>를 사용한다. <code>==</code> 호출 시 간접적으로 <code>equals</code>를 호출해준다.</p>
<p>    - <code>==</code>은 값 비교, <code>===</code>은 주소까지 같은지를 본다.</p>
<ul>
<li><p><strong><strong>논리 연산자</strong></strong>: 자바와 동일하게 lazy 연산을 한다. <code>or</code> 연산 시 앞 내용이 <code>true</code>면 뒤를 수행하지 않고 바로 본문으로 이동하고, <code>and</code> 연산 시 앞 내용이 <code>false</code>면 수행하지 않고 바로 이동한다.</p>
</li>
<li><p><strong><strong>코틀린의 특이한 연산자</strong></strong></p>
</li>
</ul>
<p>    - <code>in</code> / <code>!in</code>: 컬렉션이나 범위에 포함되어 있는지/아닌지를 본다.</p>
<p>    - <code>a..b</code>: <code>a</code>부터 <code>b</code>까지의 범위 객체를 생성한다.</p>
<p>    - <code>a[i]</code>: 특정 인덱스 <code>i</code>로 값을 가져온다.</p>
<p>    - <code>a[i] = b</code>: <code>i</code>에 <code>b</code>를 넣는다.</p>
<ul>
<li><strong><strong>연산자 오버로딩</strong></strong></li>
</ul>
<p>    - 코틀린에서는 객체마다 연산자를 직접 정의할 수 있다.</p>
<p>    - 자바에서는 객체 안 요소에서 연산자를 쓰려면 함수를 만들어야 했다. 코틀린에서도 함수를 정의하면 되지만, 한 번 정의하면 해당 연산자를 그대로 사용 가능하다. 대신 내부적으로 해당 연산의 함수가 호출되며, 연산마다 정해진 이름의 함수여야 한다.</p>
]]></description>
        </item>
    </channel>
</rss>