<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>ho.log</title>
        <link>https://velog.io/</link>
        <description>예?</description>
        <lastBuildDate>Sat, 19 Sep 2026 14:09:55 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>ho.log</title>
            <url>https://velog.velcdn.com/images/baeksh_8/profile/3b8dacfd-967e-4d24-a282-5b118a05abd2/image.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. ho.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/baeksh_8" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Spring Security로 인증/인가 맛보기]]></title>
            <link>https://velog.io/@baeksh_8/Spring-Security%EB%A1%9C-%EC%9D%B8%EC%A6%9D%EC%9D%B8%EA%B0%80-%EB%A7%9B%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@baeksh_8/Spring-Security%EB%A1%9C-%EC%9D%B8%EC%A6%9D%EC%9D%B8%EA%B0%80-%EB%A7%9B%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Sat, 19 Sep 2026 14:09:55 GMT</pubDate>
            <description><![CDATA[<p>오늘은 Spring Security를 처음 배우며 알게 된 것들을 정리한다. 폼 로그인, 인메모리 사용자, 비밀번호 암호화, 요청 경로별 인가, 예외 처리까지 한 바퀴 돌았다.</p>
<hr>
<h3 id="securityfilterchain은-dispatcherservlet-앞단에서-요청을-가로채는-필터-체인이다">SecurityFilterChain은 DispatcherServlet 앞단에서 요청을 가로채는 필터 체인이다</h3>
<p>Spring MVC는 요청이 들어오면 <code>DispatcherServlet</code>이 컨트롤러로 라우팅해주는데, Spring Security는 그보다 먼저 동작하는 서블릿 필터들의 묶음(체인)으로 끼어든다. 그래서 컨트롤러 코드는 보안 로직을 전혀 몰라도 되고, 필터 단계에서 &quot;이 요청을 통과시킬지 말지&quot;가 이미 결정된다.</p>
<pre><code class="language-java">@Bean
SecurityFilterChain filterChain(HttpSecurity http,
                                CustomAccessDeniedHandler customAccessDeniedHandler) {
    http
        .authorizeHttpRequests(auth -&gt; auth
            .requestMatchers(&quot;/&quot;).permitAll()
            .requestMatchers(&quot;/error/**&quot;).permitAll()
            .requestMatchers(&quot;/free/1&quot;, &quot;/free/2&quot;).hasRole(&quot;PREMIUM&quot;)
            .anyRequest().authenticated()
        )
        .formLogin(form -&gt; form
            .loginPage(&quot;/login&quot;)
            .loginProcessingUrl(&quot;/login&quot;)
            .usernameParameter(&quot;username&quot;)
            .passwordParameter(&quot;password&quot;)
            .defaultSuccessUrl(&quot;/&quot;)
            .failureUrl(&quot;/login?error&quot;)
            .permitAll()
        )
        .logout(logout -&gt; logout
            .logoutUrl(&quot;/logout&quot;)
            .logoutSuccessUrl(&quot;/login?logout&quot;)
            .invalidateHttpSession(true)
            .deleteCookies(&quot;JSESSIONID&quot;)
        )
        .exceptionHandling(ex -&gt; ex
            .accessDeniedHandler(customAccessDeniedHandler));
    return http.build();
}</code></pre>
<p><code>HttpSecurity</code>는 이 필터 체인을 조립하는 빌더다. <code>authorizeHttpRequests</code>, <code>formLogin</code>, <code>logout</code> 같은 메서드를 체이닝해서 하나씩 규칙을 쌓고, 마지막에 <code>http.build()</code>를 호출하면 실제로 동작하는 <code>SecurityFilterChain</code> 빈이 완성된다.</p>
<p><strong>어떻게 동작하는지 (동작 순서)</strong></p>
<ol>
<li>요청이 들어오면 인증 필터들을 거치면서 &quot;이 사용자가 누구인지&quot;(Authentication)를 확인한다.</li>
<li><code>authorizeHttpRequests</code>에 등록된 규칙을 위에서부터 순서대로 검사해서 &quot;이 요청을 허용할지&quot;(Authorization)를 결정한다.</li>
<li>허용되면 그제서야 <code>DispatcherServlet</code>으로 넘어가서 평소처럼 컨트롤러가 실행된다.</li>
<li>인증이 안 됐으면 로그인 페이지로, 인증은 됐는데 권한이 부족하면 <code>AccessDeniedHandler</code>로 빠진다.</li>
</ol>
<p><strong>왜 컨트롤러가 아니라 필터에서 막는지</strong></p>
<p>만약 인가 체크를 각 컨트롤러 메서드 안에서 <code>if (권한 없음) return 403;</code> 식으로 직접 짜면, 컨트롤러가 100개면 그 체크 로직을 100번 반복하게 되고 하나라도 빠뜨리면 그게 바로 보안 구멍이 된다. 필터 단에서 URL 패턴 기준으로 일괄 관리하면 규칙이 한 곳에 모이고, 새 컨트롤러를 추가해도 규칙만 추가하면 되니 빠뜨릴 걱정이 줄어든다.</p>
<hr>
<h3 id="authorizehttprequests의-규칙은-등록된-순서대로-먼저-매치되는-것이-이긴다">authorizeHttpRequests의 규칙은 등록된 순서대로 먼저 매치되는 것이 이긴다</h3>
<pre><code class="language-java">.authorizeHttpRequests(auth -&gt; auth
        .requestMatchers(&quot;/&quot;).permitAll()
        .requestMatchers(&quot;/error/**&quot;).permitAll()
        .requestMatchers(&quot;/free/1&quot;, &quot;/free/2&quot;).hasRole(&quot;PREMIUM&quot;)
        .anyRequest().authenticated()
)</code></pre>
<p>이 블록은 위에서 아래로 순서대로 읽힌다. <code>/</code>는 누구나(<code>permitAll</code>), <code>/free/1</code>과 <code>/free/2</code>는 <code>PREMIUM</code> 역할을 가진 사용자만, 나머지는 로그인만 되어 있으면(<code>authenticated</code>) 통과시킨다.</p>
<p><strong>왜 순서가 중요한지</strong></p>
<p>만약 <code>.anyRequest().authenticated()</code>를 맨 위에 놓으면 그 아래 있는 <code>/free/1</code>에 대한 <code>hasRole(&quot;PREMIUM&quot;)</code> 규칙은 절대 실행되지 않는다. <code>anyRequest()</code>가 모든 경로에 이미 매치되어버려서 뒤에 있는 더 구체적인 규칙까지 요청이 도달하지 못하기 때문이다. 그래서 이런 설정은 항상 &quot;더 구체적인 경로가 먼저, 가장 넓은 범위(<code>anyRequest</code>)가 마지막&quot;이라는 원칙을 지켜야 한다.</p>
<p><strong>직접 확인해본 것</strong></p>
<p><code>application.yaml</code>을 보면 <code>app.security.role: ADMIN</code>으로 되어 있는데, 정작 <code>/free/1</code>, <code>/free/2</code>는 <code>hasRole(&quot;PREMIUM&quot;)</code>을 요구한다. 즉 지금 등록된 <code>admin</code> 계정으로 로그인해도 두 경로는 403이 난다. 역할 이름이 스펙(SecurityProperty)과 인가 규칙(SecurityConfig) 두 군데에 나뉘어 있다 보니 이렇게 서로 어긋나기 쉽다는 걸 직접 겪었다. 나중에 실제 프로젝트에서는 역할 상수를 한 군데(enum 등)로 모아둬야겠다고 느꼈다.</p>
<hr>
<h3 id="사용자-정보는-inmemoryuserdetailsmanager에-userdetails-객체로-등록한다">사용자 정보는 InMemoryUserDetailsManager에 UserDetails 객체로 등록한다</h3>
<pre><code class="language-java">@Bean
InMemoryUserDetailsManager userDetailService(
        SecurityProperty p,
        PasswordEncoder passwordEncoder
) {
    UserDetails admin = User.builder()
            .username(p.username())
            .password(passwordEncoder.encode(p.password()))
            .roles(p.role())
            .build();
    return new InMemoryUserDetailsManager(admin);
}</code></pre>
<p>Spring Security는 로그인 폼에서 넘어온 아이디/비밀번호를 검증할 때 <code>UserDetailsService</code>라는 인터페이스를 통해 &quot;이 아이디를 가진 사용자 정보를 가져와줘&quot;라고 요청한다. <code>InMemoryUserDetailsManager</code>는 이 인터페이스를 구현한 가장 단순한 형태로, DB 없이 메모리에 사용자 목록을 들고 있다가 그 요청에 응답해준다.</p>
<p><code>User.builder()</code>로 만드는 <code>UserDetails</code>는 아이디, (암호화된) 비밀번호, 역할(role) 세 가지를 최소한으로 담는 값 객체다. <code>.roles(p.role())</code>을 호출하면 내부적으로 <code>ROLE_ADMIN</code>처럼 <code>ROLE_</code> 접두사를 자동으로 붙여준다. 그래서 <code>hasRole(&quot;PREMIUM&quot;)</code>을 쓸 때도 <code>&quot;ROLE_PREMIUM&quot;</code>이라고 직접 쓰지 않아도 되는 것이다.</p>
<p><strong>왜 굳이 DB 없이 인메모리로 시작하는지</strong></p>
<p>Spring Security를 처음 배울 때는 &quot;인증/인가 흐름 자체&quot;를 이해하는 게 목적이지, 회원 DB 스키마를 설계하는 게 목적이 아니다. <code>InMemoryUserDetailsManager</code>를 쓰면 DB 연결 없이도 로그인 폼 → 인증 → 인가로 이어지는 전체 파이프라인을 바로 눈으로 확인할 수 있다. 실제 서비스로 넘어갈 때는 이 자리를 JPA 기반의 <code>UserDetailsService</code> 구현체로 바꿔 끼우기만 하면 되는 구조라, 지금 배운 흐름이 그대로 재사용된다.</p>
<p><strong>어떻게 설정값을 외부에서 주입받는지</strong></p>
<p><code>SecurityProperty</code>는 이렇게 생겼다.</p>
<pre><code class="language-java">@ConfigurationProperties(prefix = &quot;app.security&quot;)
public record SecurityProperty(
        String username,
        String password,
        String role
) {
}</code></pre>
<p><code>application.yaml</code>의 <code>app.security.username</code>, <code>app.security.password</code>, <code>app.security.role</code> 값을 자동으로 이 record에 매핑해준다. 클래스 상단에 <code>@ConfigurationProperties</code>만 붙여도 되는 이유는, <code>SecApplication</code>에 <code>@ConfigurationPropertiesScan</code>이 붙어 있어서 Spring이 앱을 띄울 때 이런 record/클래스를 찾아 자동으로 빈으로 등록해주기 때문이다. 계정 정보를 자바 코드에 하드코딩하지 않고 설정 파일로 빼두면, 나중에 운영 환경에서는 환경 변수(<code>${SECURITY_USER}</code>처럼)로 갈아끼우기만 하면 되니 코드를 건드릴 필요가 없다.</p>
<hr>
<h3 id="비밀번호는-평문이-아니라-passwordencoder를-거쳐-저장한다">비밀번호는 평문이 아니라 PasswordEncoder를 거쳐 저장한다</h3>
<pre><code class="language-java">@Bean
public PasswordEncoder passwordEncoder() {
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}</code></pre>
<p>그리고 사용자를 만들 때도 <code>passwordEncoder.encode(p.password())</code>로 감싸서 넣는다. 즉 <code>application.yaml</code>에는 <code>admin1234</code>라는 평문이 적혀 있지만, 실제로 메모리에 등록되는 <code>UserDetails</code>의 비밀번호는 해시된 문자열이다.</p>
<p><strong>어떻게 DelegatingPasswordEncoder가 동작하는지</strong></p>
<p><code>createDelegatingPasswordEncoder()</code>로 만든 인코더는 인코딩할 때 문자열 앞에 <code>{bcrypt}</code> 같은 접두사를 붙인다. 그래서 나중에 로그인 시도가 들어와서 비밀번호를 비교할 때, 저장된 해시 문자열의 접두사를 보고 &quot;아, 이건 bcrypt로 인코딩된 거구나&quot;를 판단해서 그에 맞는 알고리즘으로 검증한다. 이 덕분에 나중에 더 강력한 알고리즘으로 정책이 바뀌어도 기존에 저장된 다른 방식의 해시들과 공존할 수 있다.</p>
<p><strong>왜 BCryptPasswordEncoder를 직접 쓰지 않고 이걸 쓰는지</strong></p>
<p>코드에 주석 처리된 <code>new BCryptPasswordEncoder()</code>를 보면, 처음엔 그냥 BCrypt를 직접 쓰려고 했던 흔적이 남아있다. 문제는 BCrypt 하나만 고정해서 쓰면 나중에 알고리즘을 교체하고 싶을 때(예: 더 안전한 알고리즘이 나왔을 때) 기존 사용자들의 비밀번호 해시를 어떻게 처리할지가 골치아파진다. <code>DelegatingPasswordEncoder</code>는 알고리즘을 문자열에 태그로 남겨두는 방식이라 이런 마이그레이션에 유연하다는 걸 이번에 찾아보면서 알게 됐다. 그래서 Spring Security 공식 문서에서도 특별한 이유가 없으면 이 팩토리 메서드를 기본값으로 쓰라고 권장한다.</p>
<hr>
<h3 id="폼-로그인은-loginpage와-loginprocessingurl을-분리해서-이해해야-헷갈리지-않는다">폼 로그인은 loginPage와 loginProcessingUrl을 분리해서 이해해야 헷갈리지 않는다</h3>
<pre><code class="language-java">.formLogin(form -&gt; form
        .loginPage(&quot;/login&quot;)
        .loginProcessingUrl(&quot;/login&quot;)
        .usernameParameter(&quot;username&quot;)
        .passwordParameter(&quot;password&quot;)
        .defaultSuccessUrl(&quot;/&quot;)
        .failureUrl(&quot;/login?error&quot;)
        .permitAll()
)</code></pre>
<p><code>loginPage(&quot;/login&quot;)</code>은 &quot;로그인 폼 화면을 보여주는 GET 요청 주소&quot;이고, <code>loginProcessingUrl(&quot;/login&quot;)</code>은 &quot;그 폼이 제출됐을 때(POST) Spring Security가 가로채서 실제 인증을 처리하는 주소&quot;다. 이번 프로젝트에서는 둘 다 <code>/login</code>으로 같은 경로를 쓰지만, 실제로는 서로 다른 HTTP 메서드(GET vs POST)에 대한 설정이라 값이 같아도 괜찮다.</p>
<pre><code class="language-java">@Controller
@RequestMapping(&quot;/login&quot;)
public class LoginController {
    @GetMapping
    public String login() {
        return &quot;login&quot;;
    }
    // 별도로 post를 만들 필요는 없음
}</code></pre>
<p>그래서 컨트롤러에는 <code>@GetMapping</code>만 있고 POST 핸들러가 없다. <code>/login</code>으로 오는 POST 요청은 컨트롤러까지 가지도 않고 Spring Security의 인증 필터가 중간에서 가로채서 처리해버리기 때문이다.</p>
<p><strong>왜 login.html의 form도 신경 써서 봐야 하는지</strong></p>
<pre><code class="language-html">&lt;form th:action=&quot;@{/login}&quot; method=&quot;post&quot;&gt;
    &lt;input name=&quot;username&quot; placeholder=&quot;username&quot;&gt;
    &lt;input type=&quot;password&quot; name=&quot;password&quot; placeholder=&quot;password&quot;&gt;
    &lt;button&gt;로그인&lt;/button&gt;
&lt;/form&gt;</code></pre>
<p><code>usernameParameter(&quot;username&quot;)</code>, <code>passwordParameter(&quot;password&quot;)</code> 설정은 &quot;이 파라미터 이름으로 넘어온 값을 아이디/비밀번호로 취급하겠다&quot;는 약속이다. 폼의 <code>input name</code> 속성이 이 값과 정확히 일치해야만 Spring Security가 값을 읽어갈 수 있다. 이름이 하나라도 다르면 로그인 시도 자체가 인식이 안 되고 계속 로그인 실패로 튕겨나가는데, 처음에는 원인을 못 찾다가 이 매핑 관계를 알고 나서야 이해가 됐다.</p>
<p><strong>어떻게 성공/실패가 갈리는지</strong></p>
<p>로그인이 성공하면 <code>defaultSuccessUrl(&quot;/&quot;)</code>에 따라 메인 페이지로 리다이렉트되고, 실패하면 <code>failureUrl(&quot;/login?error&quot;)</code>로 돌아간다. 쿼리 파라미터 <code>?error</code>는 화면에서 &quot;아이디/비밀번호가 틀렸다&quot;는 안내를 보여줄 때 사용할 수 있는 신호다(지금 <code>login.html</code>에는 아직 이 처리를 추가하지 않았는데, 다음에 <code>th:if</code>로 에러 메시지를 붙여봐야겠다).</p>
<hr>
<h3 id="로그아웃은-기본적으로-post만-허용하고-세션과-쿠키까지-함께-정리한다">로그아웃은 기본적으로 POST만 허용하고, 세션과 쿠키까지 함께 정리한다</h3>
<pre><code class="language-java">.logout(logout -&gt; logout
        .logoutUrl(&quot;/logout&quot;)
        .logoutSuccessUrl(&quot;/login?logout&quot;)
        .invalidateHttpSession(true)
        .deleteCookies(&quot;JSESSIONID&quot;)
)</code></pre>
<pre><code class="language-html">&lt;a href=&quot;/logout&quot;&gt;로그아웃 (GET -&gt; X)&lt;/a&gt;
&lt;form th:action=&quot;@{/logout}&quot; method=&quot;post&quot;&gt;
    &lt;button&gt;로그아웃 (POST)&lt;/button&gt;
&lt;/form&gt;</code></pre>
<p><code>index.html</code>에 일부러 GET 링크와 POST 폼을 둘 다 만들어놨는데, 실제로 눌러보면 GET 링크는 동작하지 않고 POST 버튼만 로그아웃이 된다.</p>
<p><strong>왜 GET으로 로그아웃이 안 되는지</strong></p>
<p>로그아웃을 GET으로 열어두면 <code>&lt;img src=&quot;/logout&quot;&gt;</code>처럼 그냥 페이지 안에 링크나 이미지 태그만 심어도 사용자가 모르는 사이에 로그아웃(혹은 더 위험한 작업)이 실행될 수 있다. 이런 공격을 CSRF(사이트 간 요청 위조)라고 부르는데, Spring Security는 상태를 변경하는 요청(로그인, 로그아웃 등)은 기본적으로 POST와 CSRF 토큰 검증을 요구해서 이런 위조 요청을 막는다. 그래서 로그아웃 버튼은 반드시 <code>&lt;form method=&quot;post&quot;&gt;</code>로 감싸야 한다는 걸 이번에 직접 눈으로 확인했다.</p>
<p><strong>어떻게 세션 정리가 이루어지는지</strong></p>
<p><code>invalidateHttpSession(true)</code>는 서버 쪽 세션(<code>HttpSession</code>)을 무효화해서 세션에 저장돼 있던 로그인 정보를 완전히 날려버린다. <code>deleteCookies(&quot;JSESSIONID&quot;)</code>는 브라우저에 내려간 세션 쿠키까지 지우라고 응답 헤더에 지시를 담는다. 둘 중 하나만 하면 서버는 로그아웃 처리했는데 브라우저에는 예전 세션 쿠키가 남아있는 식의 애매한 상태가 될 수 있어서, 이 둘을 같이 설정하는 게 맞다는 걸 알게 됐다.</p>
<hr>
<h3 id="인가-실패는-accessdeniedhandler로-원하는-화면으로-보낼-수-있다">인가 실패는 AccessDeniedHandler로 원하는 화면으로 보낼 수 있다</h3>
<pre><code class="language-java">@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(
            HttpServletRequest request,
            HttpServletResponse response,
            AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.sendRedirect(&quot;/&quot;);
    }
}</code></pre>
<p>로그인은 했지만 권한이 부족해서 막히는 경우(예: <code>PREMIUM</code> 역할이 필요한 페이지에 <code>ADMIN</code> 계정으로 접근), Spring Security는 기본적으로 403 에러 페이지를 보여준다. 그런데 여기서는 <code>AccessDeniedHandler</code>를 직접 구현해서 403 페이지 대신 메인 페이지(<code>/</code>)로 그냥 리다이렉트하도록 바꿔놨다.</p>
<pre><code class="language-java">.exceptionHandling(ex -&gt; ex
        .accessDeniedHandler(customAccessDeniedHandler));</code></pre>
<p>이렇게 <code>exceptionHandling</code>에 등록해줘야 필터 체인이 기본 403 처리 대신 이 핸들러를 사용한다.</p>
<p><strong>왜 인증 실패와 인가 실패를 다르게 다뤄야 하는지</strong></p>
<p>&quot;로그인이 안 된 사용자&quot;(비인증, <code>AuthenticationException</code>)와 &quot;로그인은 했지만 권한이 없는 사용자&quot;(인가 실패, <code>AccessDeniedException</code>)는 서로 다른 상황이다. 전자는 로그인 페이지로 보내는 게 자연스럽고, 후자는 이미 로그인된 사용자니까 로그인 페이지로 다시 보내는 게 오히려 어색하다. 그래서 Spring Security도 이 둘을 <code>AuthenticationEntryPoint</code>와 <code>AccessDeniedHandler</code>라는 서로 다른 인터페이스로 분리해서 처리하게 만들어 놨다는 걸 이번에 코드를 뜯어보면서 이해했다. </p>
<hr>
<h2 id="오늘-삽질하며-배운-것-요약-메모">오늘 삽질하며 배운 것 (요약 메모)</h2>
<ul>
<li><code>authorizeHttpRequests</code>는 순서가 곧 우선순위다. <code>anyRequest()</code>는 항상 제일 마지막에 둬야 한다.</li>
<li>설정 파일의 역할 이름(<code>app.security.role</code>)과 인가 규칙의 역할 이름(<code>hasRole(...)</code>)은 서로 다른 곳에 적혀 있어서, 값이 어긋나도 컴파일 에러가 나지 않는다. 실행해보고 403이 뜨는 걸 보고서야 눈치챘다. 나중에는 역할 이름을 enum이나 상수로 한 곳에 모아둬야겠다.</li>
<li>로그아웃은 GET으로 절대 안 뚫린다는 걸 직접 링크를 눌러보고 확인했다. CSRF 방어 때문이라는 걸 알고 나니 왜 POST 폼으로 감싸야 하는지 납득이 됐다.</li>
<li><code>.roles(&quot;PREMIUM&quot;)</code>을 쓰면 내부적으로 <code>ROLE_PREMIUM</code>으로 저장된다는 걸 몰랐다면 <code>hasAuthority(&quot;ROLE_PREMIUM&quot;)</code>과 <code>hasRole(&quot;PREMIUM&quot;)</code>을 헷갈렸을 것 같다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[SpringAI로 이미지 생성 서비스를 만들어 보자]]></title>
            <link>https://velog.io/@baeksh_8/SpringAI%EB%A1%9C-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%83%9D%EC%84%B1-%EC%84%9C%EB%B9%84%EC%8A%A4%EB%A5%BC-%EB%A7%8C%EB%93%A4%EC%96%B4-%EB%B3%B4%EC%9E%90</link>
            <guid>https://velog.io/@baeksh_8/SpringAI%EB%A1%9C-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%83%9D%EC%84%B1-%EC%84%9C%EB%B9%84%EC%8A%A4%EB%A5%BC-%EB%A7%8C%EB%93%A4%EC%96%B4-%EB%B3%B4%EC%9E%90</guid>
            <pubDate>Sat, 19 Sep 2026 12:13:53 GMT</pubDate>
            <description><![CDATA[<p>Spring Boot 4.1.0 + Spring AI 2.0.0으로 작은 이미지 생성 서비스를 만들어 보았다. 
사용자가 프롬프트를 입력하면 
① LLM이 그 프롬프트를 다듬고 
② 다듬어진 프롬프트로 이미지 생성 모델을 호출하고 
③ 결과 이미지를 스토리지에 올리고 
④ 어떤 프롬프트로 어떤 이미지를 만들었는지 DB에 남긴다. 
각 기능이 코드 안에서 정확히 어떤 로직으로 동작하는지를 먼저 설명하고, 그 다음에 왜 그렇게 설계했는지와 실제 구현이 어떤 모습인지를 덧붙이는 순서로 정리했다.</p>
<h3 id="사용자-입력을-이미지-생성용-프롬프트로-바꾸는-프롬프트-개선-단계">사용자 입력을 이미지 생성용 프롬프트로 바꾸는 프롬프트 개선 단계</h3>
<p>이 서비스에서 사용자가 입력창에 쓴 문장은 곧바로 이미지 생성 모델로 가지 않는다. 
그 전에 LLM을 한 번 거쳐서 &quot;이미지 생성 모델이 잘 알아듣는 형태&quot;로 번역/재구성되는 단계가 있다. 
이 역할을 하는 <code>ChatClient</code>는 다음과 같이 정의되어 있다.</p>
<pre><code class="language-java">// ImageGenConfig.java
@Bean
ChatClient promptImproveClient(ChatModel model) {
    return ChatClient.builder(model)
            .defaultSystem(&quot;&quot;&quot;
                    - 전달받은 내용을 500자 이내의 영문으로 된 이미지 생성용 프롬프트로 개선
                    - 특징이 잘 드러나게 자세하지만 간결한 표현으로 구성
                    &quot;&quot;&quot;)
            .defaultOptions(ChatOptions.builder()
                    .temperature(0.3)
                    .maxTokens(1000))
            .build();
}</code></pre>
<p><code>ChatClient.builder(model)</code>로 만든 빌더에 <code>defaultSystem(...)</code>을 붙이면, 이 클라이언트로 보내는 모든 요청 앞에 저 시스템 프롬프트가 항상 깔린다. 
즉 매번 &quot;영문으로, 500자 이내로, 이미지 생성용 프롬프트로 바꿔줘&quot;라고 반복해서 적어줄 필요 없이, 사용자가 입력한 원문만 <code>user()</code>로 넘기면 된다.
<code>temperature</code>를 0.3으로 낮게 잡은 것도 눈여겨볼 부분인데, 이 값이 높을수록 모델이 매번 다른 표현을 창의적으로 골라내고, 낮을수록 비슷한 입력에는 비슷한 스타일의 결과를 안정적으로 낸다.
프롬프트 번역·재구성처럼 &quot;매번 다르게 나오면 곤란한&quot; 작업에는 창의성보다 일관성이 중요하므로 이 값을 낮추었다.</p>
<p>실제 호출부는 <code>ImageGenService</code>에 있다.</p>
<pre><code class="language-java">public String improvePrompt(String prompt) {
    String improved = promptImproveClient
            .prompt().user(prompt)
            .call().content();
    System.out.println(&quot;old = &quot; + prompt);
    System.out.println(&quot;new = &quot; + improved);
    return improved;
}</code></pre>
<p><code>.prompt().user(prompt)</code>로 사용자 메시지를 세팅하고 <code>.call().content()</code>로 문자열 응답만 뽑아낸다.
Spring AI의 <code>ChatClient</code>는 스트리밍(<code>.stream()</code>)이나 구조화된 응답(<code>.entity(...)</code>)도 지원하지만,
여기서는 그냥 텍스트 하나만 필요하니 가장 단순한 형태를 쓴 것이다.</p>
<p>이 호출이 실제로 어디로 가는지는 <code>application-ai.yaml</code>을 보면 이렇다.</p>
<pre><code class="language-yaml">spring:
  ai:
    openai:
      api-key: ${GROQ_API_KEY}
      base-url: https://api.groq.com/openai/v1
      chat:
        model: openai/gpt-oss-120b</code></pre>
<p>의존성은 <code>spring-ai-starter-model-openai</code>인데 <code>base-url</code>은 OpenAI가 아니라 Groq을 가리킨다.
Groq이 OpenAI와 같은 Chat Completions API 스펙을 그대로 흉내 내주기 때문에, OpenAI 전용으로 짜인 클라이언트 코드를 한 줄도 안 바꾸고 <code>base-url</code>과 <code>api-key</code>만 갈아끼워서 재사용할 수 있는 것이다.
모델 이름의 <code>openai/gpt-oss-120b</code>도 헷갈리기 쉬운데, OpenAI가 만든 비공개 모델이 아니라 OpenAI가 오픈소스로 공개한 <code>gpt-oss-120b</code>를 Groq이 자체 LPU 인프라에 올려서 아주 빠르게 서빙해주는 것이다.
프롬프트를 다듬는 정도의 가벼운 작업이라 최상급 모델까지는 필요 없고, 대신 Groq 특유의 저지연 추론 덕분에 이미지 생성 전에 LLM 호출을 한 단계 더 끼워 넣어도 사용자가 느끼는 전체 대기 시간에 큰 부담을 주지 않는다는 점까지 고려된 조합으로 보인다.</p>
<hr>
<h3 id="spring-ai가-아니라-커스텀-restclient로-이미지-생성-api를-직접-두드리는-구조">Spring AI가 아니라 커스텀 RestClient로 이미지 생성 API를 직접 두드리는 구조</h3>
<p>프롬프트가 준비되면 실제 이미지를 만들 차례인데, 이 부분은 Spring AI의 <code>ImageModel</code> 추상화를 쓰지 않는다. 대신 순수한 <code>RestClient</code>로 Cloudflare Workers AI의 REST 엔드포인트를 직접 호출한다.
Spring AI의 이미지 생성 추상화는 OpenAI DALL-E 계열을 기준으로 만들어져 있어서, Cloudflare Workers AI처럼 지원 대상이 아닌 provider는 표준 API로 감쌀 방법이 없기 때문이다.</p>
<p>먼저 이 호출 전용 <code>RestClient</code> 빈을 보자.</p>
<pre><code class="language-java">// ImageGenConfig.java
@Bean
RestClient cfWorkersAiClient(RestClient.Builder b, CFProperty p) {
    HttpClientSettings settings = HttpClientSettings.defaults()
            .withTimeouts(
                    Duration.ofSeconds(5),   // connection - 요청은 금방 보내야 하니까
                    Duration.ofSeconds(60)   // read - 이미지 처리는 오래 걸릴 수 있으니까
            );
    String baseUrl = &quot;https://api.cloudflare.com/client/v4/accounts/%s/ai&quot;.formatted(p.accountId());
    return b.clone()
            .baseUrl(baseUrl)
            .defaultHeader(HttpHeaders.AUTHORIZATION, &quot;Bearer %s&quot;.formatted(p.apiToken()))
            .requestFactory(ClientHttpRequestFactoryBuilder.detect().build(settings))
            .build();
}</code></pre>
<p><code>baseUrl</code>에 계정 전용 AI 엔드포인트를 미리 박아두고 <code>defaultHeader</code>로 인증 토큰까지 심어뒀기 때문에, 이 빈을 주입받는 쪽에서는 매 요청마다 URL 전체와 인증 헤더를 신경 쓸 필요 없이 뒤에 붙는 경로(<code>/run/{model}</code>)만 신경 쓰면 된다.</p>
<p>타임아웃을 connection 5초, read 60초로 다르게 잡은 것도 의미가 있는데, &quot;요청을 보내는 것&quot;과 &quot;이미지 생성이 끝나서 응답이 돌아오는 것&quot;은 걸리는 시간의 성격이 완전히 다르기 때문이다. 연결 자체는 금방 되거나 실패하지만, 이미지 생성은 모델이 실제로 그림을 그리는 동안 서버가 응답을 들고 있어야 하므로 read 쪽에 훨씬 여유를 준 것이다.</p>
<p>실제 호출은 서비스 계층에 있다.</p>
<pre><code class="language-java">public GenResultDTO invokeImage(String prompt) {
    String modelName = &quot;@cf/black-forest-labs/flux-1-schnell&quot;;
    GenResultDTO result = aiClient.post()
            .uri(&quot;/run/%s&quot;.formatted(modelName))
            .contentType(MediaType.APPLICATION_JSON)
            .body(new GenRequestDTO(prompt, 4))
            .retrieve()
            .body(GenResultDTO.class);
    return result;
}</code></pre>
<p><code>GenRequestDTO(prompt, 4)</code>에서 <code>steps</code>를 4로 준 것도 아무 값이나 고른 게 아니다. <code>flux-1-schnell</code>은 이름 그대로 &quot;빠른(schnell)&quot; 버전으로, 몇 단계 안 되는 적은 스텝만으로도 충분히 쓸만한 품질이 나오도록 설계된 모델이다. Flux의 일반 모델이 수십 스텝을 도는 것과 비교하면, steps=4는 이 모델의 특성에 맞춰 속도를 최대한 끌어올린 값이다.</p>
<p>응답을 담는 <code>GenResultDTO</code>도 구조를 보면 설계 의도가 드러난다.</p>
<pre><code class="language-java">public record GenResultDTO(boolean success, ImageResult result, List&lt;CFMessage&gt; errors) {
    public record ImageResult(String image) { } // base64
    public record CFMessage(int code, String message) { }
}</code></pre>
<p>Cloudflare API는 성공하든 실패하든 HTTP 200으로 응답하면서<code>success</code> 플래그와 <code>errors</code> 배열로 결과를 구분하는 패턴을 쓰는데, 이 DTO는 그 두 경우를 모두 담을 수 있게 필드를 미리 만들어뒀다.</p>
<p>다만 지금 코드에서는 <code>result.success()</code>나 <code>result.errors()</code>를 실제로 검사하는 곳이 없어서, 이 필드들은 아직 &quot;언젠가 쓰려고 만들어둔&quot; 상태로 남아 있다(9번 항목에서 다시 다룬다).</p>
<hr>
<h3 id="base64로-받은-이미지를-디코딩해서-s3-호환-스토리지에-올리는-저장-로직">base64로 받은 이미지를 디코딩해서 S3 호환 스토리지에 올리는 저장 로직</h3>
<p>Cloudflare가 돌려주는 이미지는 바이너리 파일이 아니라 base64로 인코딩된 문자열이다. 이걸 그대로 DB 텍스트 컬럼에 넣어버릴 수도 있지만, 그러면 컬럼 하나가 수십~수백 KB짜리 문자열을 매번 들고 있어야 해서 조회·백업 성능이 나빠지고, 애초에 브라우저에 이미지를 서빙하기에도 적합하지 않다.</p>
<p>그래서 이 프로젝트는 이미지 바이트 자체는 오브젝트 스토리지에 두고, DB에는 &quot;어디에 저장했는지&quot;를 가리키는 키(파일명)만 남기는 방식을 쓴다.</p>
<pre><code class="language-java">public String upload(GenResultDTO result) {
    String filename = &quot;%s.jpg&quot;.formatted(UUID.randomUUID());
    byte[] bytes = Base64.getDecoder().decode(result.result().image());
    InputStream data = new ByteArrayInputStream(bytes);
    ObjectMetadata metadata = ObjectMetadata.builder()
            .contentType(MediaType.IMAGE_JPEG_VALUE)
            .build();
    s3Template.upload(bucket, filename, data, metadata);
    return filename; // 호출 시 Key 값 filename만 return
}</code></pre>
<p>순서를 그대로 따라가 보면, <code>Base64.getDecoder().decode(...)</code>로 문자열을 실제 바이트 배열로 되돌리고, 그 바이트 배열을 <code>ByteArrayInputStream</code>으로 감싸서 <code>S3Template.upload(...)</code>가 요구하는 <code>InputStream</code> 형태로 맞춘다. 파일명은 <code>UUID.randomUUID()</code>로 매번 새로 만들기 때문에 여러 사용자가 동시에 이미지를 생성해도 파일명이 겹칠 걱정이 없다. 업로드가 끝나면 이 <code>filename</code>(=S3의 key)만 반환값으로 돌려주고, 이 값이 나중에 <code>GenImage</code> 엔티티에 저장된다.</p>
<p>여기서 &quot;S3&quot;라는 이름이 붙어 있지만 실제로는 AWS S3가 아니다. <code>application-file.yaml</code>을 보면 이게 드러난다.</p>
<pre><code class="language-yaml">spring:
  cloud:
    aws:
      s3:
        endpoint: ${STORAGE_ENDPOINT}
        region: ${STORAGE_REGION}
        path-style-access-enabled: true</code></pre>
<p><code>spring-cloud-aws-starter-s3</code>는 &quot;S3 API 규격을 따르는 스토리지라면 AWS가 아니어도 <code>endpoint</code>만 바꿔서 붙일 수 있게&quot; 해주는 라이브러리다. 그 덕분에 <code>.env.dev.sample</code>에 적힌 것처럼 실제로는 Supabase Storage의 S3 호환 엔드포인트(<code>https://*.storage.supabase.co/storage/v1/s3</code>)를 그대로 연결해서 쓰고 있다.</p>
<p><code>path-style-access-enabled: true</code>는 요청 URL 형태를 <code>bucket.endpoint.com/key</code>(virtual-hosted style)가 아니라 <code>endpoint.com/bucket/key</code>(path style)로 만들라는 옵션인데, AWS가 아닌 S3 호환 스토리지들은 대체로 path style만 지원하기 때문에 켜줘야하는 설정이다.</p>
<hr>
<h3 id="이미지를-스토리지-url로-직접-노출하지-않고-컨트롤러가-대신-내려주는-방식">이미지를 스토리지 URL로 직접 노출하지 않고 컨트롤러가 대신 내려주는 방식</h3>
<p>템플릿에서 이미지 태그가 가리키는 주소를 보면 스토리지 주소가 아니라 이 애플리케이션 자신의 경로다.</p>
<pre><code class="language-html">&lt;!-- gen/page.html --&gt;
&lt;img th:src=&quot;@{/gen/{f}(f=${result.filename})}&quot; width=&quot;240&quot; alt=&quot;생성된 이미지&quot;&gt;</code></pre>
<p>브라우저가 이 <code>&lt;img&gt;</code>를 렌더링하려고 요청을 보내면, 그 요청은 스토리지가 아니라 우리 서버의 <code>/gen/{filename}</code>으로 간다. 그걸 받는 쪽은 이렇게 되어 있다.</p>
<pre><code class="language-java">// ImageGenController.java
@GetMapping(&quot;/{filename}&quot;)
public ResponseEntity&lt;Resource&gt; download(@PathVariable String filename) {
    return ResponseEntity.ok()
            .contentType(MediaType.IMAGE_JPEG)
            .body(imageGenService.download(filename));
}</code></pre>
<pre><code class="language-java">// ImageGenService.java
public Resource download(String filename) {
    return s3Template.download(bucket, filename);
}</code></pre>
<p>컨트롤러는 <code>filename</code>을 받아 서비스에 넘기고, 서비스는 <code>s3Template.download(bucket, filename)</code>로 스토리지에서 실제 파일을 가져와 <code>Resource</code>로 감싸 돌려준다. 컨트롤러는 그 <code>Resource</code>를 <code>Content-Type: image/jpeg</code>로 응답 바디에 그대로 실어 보낸다. 즉 브라우저 입장에서는 이미지가
어느 스토리지에 저장돼 있는지 전혀 알 필요가 없고, 항상 우리 서버를 거쳐서만 이미지를 받는다.</p>
<p>이렇게 한 겹을 끼워두면 버킷 자체는 비공개로 유지하면서도 인증·로깅 같은 접근 제어를 애플리케이션 레이어에 자유롭게 얹을 수 있고, 나중에 스토리지를 다른 곳으로 옮기더라도(예: Supabase → AWS S3) 프론트엔드 코드는 한 글자도 바꿀 필요가 없다.</p>
<pre><code class="language-html">&lt;!--/* [[${result.result.image}]] */--&gt;
&lt;!--/* &lt;img th:src=&quot;&#39;data:image/jpeg;base64,&#39; + ${result.result.image}&quot; */--&gt;
&lt;!--/* width=&quot;240&quot; alt=&quot;생성된 이미지&quot;&gt; */--&gt;</code></pre>
<p>초기 버전은 Cloudflare가 돌려준 base64 문자열을 <code>data:image/jpeg;base64,...</code> 형태로 <code>&lt;img&gt;</code>에 직접 꽂아 넣는 방식이었다. 구현은 훨씬 간단하지만, 페이지를 새로고침할 때마다 base64 문자열 전체를 다시 실어 날라야 해서 무겁고, 무엇보다 &quot;예전에 만든 이미지 목록을 다시 보여주기&quot; 기능을 만들려면 그 긴 문자열을 어딘가에 영구히 저장해두는 수밖에 없다. S3에 저장하고 key만 DB에 남기는 지금 구조로 바꾸면서 이 문제가 자연스럽게 함께 해결된 셈이다.</p>
<hr>
<h3 id="프롬프트-개선부터-db-저장까지-하나로-묶은-generateimage의-흐름">프롬프트 개선부터 DB 저장까지 하나로 묶은 <code>generateImage()</code>의 흐름</h3>
<p>지금까지 본 네 단계(프롬프트 개선 → 이미지 생성 → 업로드 → DB 저장)를 실제로 이어붙이는 진입점이 <code>ImageGenService.generateImage()</code>다.</p>
<pre><code class="language-java">@Transactional
public ImageResultDTO generateImage(String prompt) {
    String improved = improvePrompt(prompt);
    GenResultDTO result = generate(improved);
    String key = upload(result);
    repository.save(GenImage.builder()
            .filename(key)
            .prompt(prompt)
            .improved(improved)
            .build());
    return new ImageResultDTO(key, prompt, improved);
}</code></pre>
<p>코드를 그대로 읽으면 &quot;프롬프트를 개선하고 → 그 결과로 이미지를 만들고 → 업로드해서 key를 얻고 → 원본 프롬프트·개선된 프롬프트·key를 한 행으로 저장한다&quot;는 흐름이 그대로 보인다. 이 메서드 전체에 <code>@Transactional</code>을 붙인 이유는 &quot;이미지 생성까지는 다 됐는데 DB 저장만 실패해서 어정쩡한 데이터가 남는&quot; 상황을 막고 싶었기 때문이다.</p>
<p>다만 여기서 짚고 넘어가야 할 부분이 있다. <code>@Transactional</code>이 실제로 보장해주는 건 DB 커넥션의 커밋/롤백뿐이고, 그 안에서 호출하는 외부 HTTP API(LLM 호출, 이미지 생성, S3 업로드)는 전혀 롤백 대상이 아니다. 즉 마지막 <code>repository.save(...)</code>가 실패해서 트랜잭션이 롤백되더라도, 이미 Cloudflare에 만들어달라고 요청해서 비용이 발생한 이미지와 이미 S3에 올라간 파일은 그대로 남는다. 게다가 read timeout을 60초까지 열어둔 이미지 생성 API 호출이 트랜잭션 범위 안에 있다는 것은, 그 시간 동안 DB 커넥션 풀에서 커넥션 하나를 계속 붙잡고 놓아주지 않는다는 뜻이기도 하다.
느린 외부 API 호출과 DB 트랜잭션을 한 메서드에 같이 두면 늘 따라오는 트레이드오프다.</p>
<hr>
<h3 id="configurationproperties-record로-cloudflare-설정값을-묶은-방식"><code>@ConfigurationProperties</code> record로 Cloudflare 설정값을 묶은 방식</h3>
<p>Cloudflare 연동에 필요한 값은 계정 ID와 API 토큰 두 개인데, 이 둘을 각각 <code>@Value</code>로 따로 주입받는 대신 하나의 타입으로 묶어뒀다.</p>
<pre><code class="language-java">@ConfigurationProperties(prefix = &quot;app.cf&quot;)
public record CFProperty(String accountId, String apiToken) {
}</code></pre>
<p><code>prefix = &quot;app.cf&quot;</code>이므로 <code>application-ai.yaml</code>의 <code>app.cf.account-id</code>, <code>app.cf.api-token</code> 값이 각각 <code>accountId</code>, <code>apiToken</code>으로 바인딩된다. (스프링의 relaxed binding 덕분에 케밥 케이스 YAML 키가 카멜 케이스 필드에 자동으로 매핑된다). 이 클래스를 빈으로 등록하기 위한 별도의 <code>@Bean</code> 메서드는 어디에도 없는데, 대신 애플리케이션 클래스에 이런 애노테이션이 붙어 있다.</p>
<pre><code class="language-java">@SpringBootApplication
@ConfigurationPropertiesScan
public class ImagegenApplication { ... }</code></pre>
<p><code>@ConfigurationPropertiesScan</code>이 클래스패스를 스캔하면서 <code>@ConfigurationProperties</code>가 붙은 타입을 자동으로 찾아 빈으로 등록해주기 때문에, <code>CFProperty</code>는 별도 설정 없이도
<code>ImageGenConfig.cfWorkersAiClient(RestClient.Builder b, CFProperty p)</code>에서 바로 주입받아 쓸 수 있다. 두 값을 항상 세트로 다뤄야 한다는 사실이 record라는 타입 하나로 명확하게 드러나고, record는 필드가 모두 <code>final</code>이라 한 번 값이 채워지면 실수로 바뀔 일도 없다.</p>
<p>다만 같은 파일에서 S3 버킷 이름은 조금 다른 방식으로 주입받는다.</p>
<pre><code class="language-java">@Value(&quot;${app.sb.bucket}&quot;)
private String bucket;</code></pre>
<p>버킷 이름은 값이 하나뿐이라 굳이 별도 타입을 만들지 않고 필드에 바로 주입한 것인데, 일관성만 놓고 보면 이것도 <code>CFProperty</code>처럼 작은 record로 뽑아냈어도 괜찮았을 부분이다.</p>
<hr>
<h3 id="prompt-improved-컬럼-길이를-넉넉하게-잡아둔-엔티티-설계"><code>prompt</code>, <code>improved</code> 컬럼 길이를 넉넉하게 잡아둔 엔티티 설계</h3>
<p><code>GenImage</code> 엔티티는 사용자가 입력한 원본 프롬프트와 LLM이 다듬은 프롬프트를 함께 저장한다.</p>
<pre><code class="language-java">@Entity
public class GenImage {
    @Id @GeneratedValue
    private Long id;
    private String filename;
    @Column(length = 2000)
    private String prompt;
    @Column(length = 2000)
    private String improved;
}</code></pre>
<p><code>prompt</code>, <code>improved</code> 두 컬럼에만 <code>@Column(length = 2000)</code>이 붙어 있다. 사용자가 입력하는 원본 프롬프트는 컨트롤러의 <code>@Size(max = 500)</code>으로 500자까지만 들어오도록 막혀 있지만, LLM이 돌려주는 개선된 프롬프트는 상황이 다르다. &quot;500자 이내로 써줘&quot;라는 요청은 시스템 프롬프트로 모델에게 부탁한 것일 뿐 강제되는 규칙이 아니라서, 모델이 이 지침을 넘겨서 응답할 가능성을 배제할 수 없다. JPA/Hibernate가 문자열 컬럼에 기본으로 잡는 길이(255자)로는 이 값을 안전하게 담기 어렵기 때문에 여유 있게 2000자로 늘려잡은 것이다. 반면 <code>filename</code>은 애플리케이션이 직접 <code>UUID + &quot;.jpg&quot;</code> 형태로 만드는 고정 길이 값이라 굳이 길이를 조정할 필요가 없어 기본값 그대로
남아 있다.</p>
<p>이 스키마는 마이그레이션 도구 없이 <code>application-db.yaml</code>의 <code>hibernate.ddl-auto: update</code> 설정을 통해 애플리케이션이 뜰 때마다 자동으로 맞춰진다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26/08/10 IL - (2) Vector Search와 SearchRequest 활용]]></title>
            <link>https://velog.io/@baeksh_8/260810-IL-2-Vector-Search%EC%99%80-SearchRequest-%ED%99%9C%EC%9A%A9</link>
            <guid>https://velog.io/@baeksh_8/260810-IL-2-Vector-Search%EC%99%80-SearchRequest-%ED%99%9C%EC%9A%A9</guid>
            <pubDate>Sat, 19 Sep 2026 10:38:16 GMT</pubDate>
            <description><![CDATA[<h2 id="vector-search와-searchrequest-활용">Vector Search와 SearchRequest 활용</h2>
<p>Part 1에서 이미지와 PDF를 Vector Store에 저장하는 방법을 배웠다면, 이번에는 <strong>저장된 Document를 실제 질문과 비교해서 검색하는 방법</strong>을 학습했다.</p>
<p>이번 프로젝트에서 핵심적으로 사용한 것은 다음과 같다.</p>
<ul>
<li><code>VectorStore</code></li>
<li><code>similaritySearch()</code></li>
<li><code>SearchRequest</code></li>
<li><code>topK</code></li>
<li><code>similarityThreshold</code></li>
<li>이미지 RAG 검색 결과 DTO</li>
<li>PDF RAG에서 <code>QuestionAnswerAdvisor</code>를 활용한 검색</li>
</ul>
<p>즉, 단순히 데이터를 Vector Store에 저장하는 것에서 끝나는 것이 아니라 <strong>사용자의 질문과 의미적으로 가까운 Document를 찾아오는 과정</strong>을 구현했다.</p>
<hr>
<h3 id="vectorstore의-similaritysearch">VectorStore의 similaritySearch</h3>
<p>이미지 RAG에서는 다음과 같이 <code>VectorStore</code>에서 검색을 수행한다.</p>
<pre><code class="language-java">SearchRequest request = SearchRequest.builder()
        .query(query)
        .topK(3)
        .similarityThreshold(0.5)
        .build();

List&lt;Document&gt; results =
        imageVectorStore.similaritySearch(request);</code></pre>
<p>여기서 핵심은 <code>similaritySearch()</code>다.</p>
<p>사용자가 검색어를 입력하면 해당 검색어를 기반으로 Vector Store에서 유사한 Document를 찾아온다.</p>
<pre><code class="language-text">사용자 검색어
     ↓
Vector Store 검색
     ↓
유사도가 높은 Document
     ↓
List&lt;Document&gt;</code></pre>
<hr>
<h3 id="searchrequest로-검색-조건-지정하기">SearchRequest로 검색 조건 지정하기</h3>
<p>이번 프로젝트에서는 단순히 문자열만 전달하는 것이 아니라 <code>SearchRequest</code>를 사용해서 검색 조건을 지정했다.</p>
<pre><code class="language-java">SearchRequest request = SearchRequest.builder()
        .query(query)
        .topK(3)
        .similarityThreshold(0.5)
        .build();</code></pre>
<p>각 옵션의 의미는 다음과 같다.</p>
<table>
<thead>
<tr>
<th>설정</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td><code>query()</code></td>
<td>검색할 질문 또는 검색어</td>
</tr>
<tr>
<td><code>topK(3)</code></td>
<td>최대 3개의 검색 결과를 가져옴</td>
</tr>
<tr>
<td><code>similarityThreshold(0.5)</code></td>
<td>유사도가 0.5 이상인 결과만 사용</td>
</tr>
</tbody></table>
<p>따라서 이 코드는</p>
<blockquote>
<p><strong>검색어와 유사도가 충분히 높은 Document 중 최대 3개를 가져온다.</strong></p>
</blockquote>
<p>라고 이해할 수 있다.</p>
<hr>
<h3 id="topk의-역할">topK의 역할</h3>
<p><code>topK()</code>는 검색 결과의 최대 개수를 결정한다.</p>
<p>이번 프로젝트의 이미지 검색에서는 다음과 같이 설정되어 있다.</p>
<pre><code class="language-java">.topK(3)</code></pre>
<p>따라서 검색 결과가 아무리 많더라도 최대 3개의 Document만 가져온다.</p>
<pre><code class="language-text">검색 결과
 ├─ Document 1
 ├─ Document 2
 ├─ Document 3
 ├─ Document 4
 └─ Document 5

        ↓ topK(3)

최종 결과
 ├─ Document 1
 ├─ Document 2
 └─ Document 3</code></pre>
<p>여기서 중요한 것은 <code>topK</code>가 <strong>반드시 3개를 반환한다는 의미는 아니라는 것</strong>이다.</p>
<p><code>similarityThreshold</code> 조건까지 적용되기 때문에 조건을 만족하는 Document가 2개라면 2개만 반환될 수 있다.</p>
<hr>
<h3 id="similaritythreshold의-역할">similarityThreshold의 역할</h3>
<p>다음 설정은 검색 결과의 최소 유사도 기준이다.</p>
<pre><code class="language-java">.similarityThreshold(0.5)</code></pre>
<p>검색된 Document의 유사도가 너무 낮다면 결과에서 제외한다.</p>
<p>개념적으로 보면 다음과 같다.</p>
<pre><code class="language-text">검색 결과

Document A → 0.91
Document B → 0.76
Document C → 0.61
Document D → 0.42
Document E → 0.31

similarityThreshold = 0.5

                ↓

Document A → 포함
Document B → 포함
Document C → 포함
Document D → 제외
Document E → 제외</code></pre>
<p>따라서 <code>topK</code>와 <code>similarityThreshold</code>는 서로 다른 역할을 한다.</p>
<pre><code class="language-text">similarityThreshold
→ &quot;얼마나 비슷해야 검색 결과로 인정할 것인가?&quot;

topK
→ &quot;인정된 결과를 최대 몇 개 가져올 것인가?&quot;</code></pre>
<hr>
<h3 id="이미지-rag에서는-검색-결과를-dto로-변환">이미지 RAG에서는 검색 결과를 DTO로 변환</h3>
<p>이미지 RAG에서는 검색된 <code>Document</code>를 그대로 사용하는 것이 아니라 <code>ImageRagSearchResult</code>로 표현한다.</p>
<p>실제 DTO는 다음과 같다.</p>
<pre><code class="language-java">public record ImageRagSearchResult(
        String caption,
        String publicUrl
) {
}</code></pre>
<p>여기에는 두 가지 정보가 들어간다.</p>
<ul>
<li><code>caption</code>: 이미지에 대해 생성된 설명</li>
<li><code>publicUrl</code>: Supabase Storage에 저장된 이미지 URL</li>
</ul>
<p>즉, 이미지 검색 결과는 단순히 &quot;비슷한 벡터가 발견됐다&quot;에서 끝나는 것이 아니라 <strong>검색된 이미지의 설명과 실제 이미지를 확인할 수 있는 정보</strong>로 구성된다.</p>
<hr>
<h3 id="pdf-rag에서는-검색을-questionansweradvisor와-연결">PDF RAG에서는 검색을 QuestionAnswerAdvisor와 연결</h3>
<p>PDF RAG에서는 검색 결과를 직접 다루는 방식 외에 <code>QuestionAnswerAdvisor</code>를 이용해서 RAG 검색과 답변 생성을 연결한다.</p>
<p>업로드된 PDF의 Chunk가 Vector Store에 저장되어 있기 때문에 사용자의 질문이 들어오면 관련 Document를 검색하고 이를 질문에 활용할 수 있다.</p>
<p>PDF RAG에서 사용하는 검색 조건 역시 다음과 같이 <code>SearchRequest</code>를 기반으로 구성되어 있다.</p>
<pre><code class="language-java">SearchRequest.builder()
        .topK(3)
        .similarityThreshold(0.5)
        .build()</code></pre>
<p>여기서 중요한 점은 <strong>검색 결과를 LLM에게 그냥 전부 전달하는 것이 아니라 검색 조건을 통해 관련성이 높은 Document를 선별한다는 것</strong>이다.</p>
<hr>
<h3 id="vector-search에서-중요한-두-가지">Vector Search에서 중요한 두 가지</h3>
<p>이번 프로젝트에서 Vector Search를 구현하면서 가장 중요하게 이해한 것은 다음 두 가지다.</p>
<h3 id="①-검색-결과의-개수">① 검색 결과의 개수</h3>
<pre><code class="language-java">.topK(3)</code></pre>
<p>얼마나 많은 Document를 가져올지 결정한다.</p>
<h3 id="②-검색-결과의-관련성">② 검색 결과의 관련성</h3>
<pre><code class="language-java">.similarityThreshold(0.5)</code></pre>
<p>어느 정도 이상 유사한 Document만 검색 결과로 사용할지 결정한다.</p>
<p>둘을 함께 사용하면 다음과 같은 형태가 된다.</p>
<pre><code class="language-text">                    검색어
                      │
                      ▼
               Vector Search
                      │
             ┌────────┴────────┐
             │                 │
       유사도 기준 확인       결과 개수 제한
             │                 │
    threshold = 0.5       topK = 3
             │                 │
             └────────┬────────┘
                      ▼
                검색 결과</code></pre>
<hr>
<h3 id="image-rag와-pdf-rag의-검색-방식">Image RAG와 PDF RAG의 검색 방식</h3>
<p>이번 프로젝트에서는 이미지와 PDF가 모두 Vector Search를 사용하지만, 검색 결과를 활용하는 방식에는 차이가 있다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>Image RAG</th>
<th>PDF RAG</th>
</tr>
</thead>
<tbody><tr>
<td>저장 대상</td>
<td>이미지 설명 <code>caption</code></td>
<td>PDF에서 추출한 Chunk</td>
</tr>
<tr>
<td>검색</td>
<td><code>imageVectorStore.similaritySearch()</code></td>
<td><code>QuestionAnswerAdvisor</code>와 Vector Store</td>
</tr>
<tr>
<td>검색 조건</td>
<td><code>SearchRequest</code></td>
<td><code>SearchRequest</code></td>
</tr>
<tr>
<td><code>topK</code></td>
<td>3</td>
<td>3</td>
</tr>
<tr>
<td><code>similarityThreshold</code></td>
<td>0.5</td>
<td>0.5</td>
</tr>
<tr>
<td>검색 결과 활용</td>
<td><code>caption</code>, <code>publicUrl</code></td>
<td>질문에 필요한 문서 Context</td>
</tr>
</tbody></table>
<p>즉, <strong>검색 자체의 핵심 원리는 동일하고 검색된 Document를 어떻게 활용하는지가 다르다.</strong></p>
<hr>
<h3 id="vector-search를-직접-구현하면서-알게-된-것">Vector Search를 직접 구현하면서 알게 된 것</h3>
<p>Vector Search는 단순히</p>
<pre><code class="language-java">similaritySearch(query)</code></pre>
<p>한 줄로 끝나는 기능처럼 보이지만 실제로는 검색 결과를 어떻게 제한할 것인지 결정하는 것이 중요하다.</p>
<p>이번 프로젝트에서는</p>
<pre><code class="language-java">SearchRequest.builder()
        .query(query)
        .topK(3)
        .similarityThreshold(0.5)
        .build();</code></pre>
<p>처럼 검색 조건을 명시적으로 구성했다.</p>
<p>이를 통해</p>
<pre><code class="language-text">검색어
 ↓
유사도 비교
 ↓
threshold 기준으로 필터링
 ↓
topK만큼 결과 선택
 ↓
RAG에서 활용</code></pre>
<p>이라는 검색 과정을 이해할 수 있었다.</p>
<hr>
<p>이번 Part 2에서 가장 중요하게 배운 것은 <strong>Vector Store에 문서를 저장하는 것과 저장된 문서를 검색해서 활용하는 것은 별도의 과정</strong>이라는 점이다.</p>
<p>Part 1에서는</p>
<pre><code class="language-text">이미지 → Document → Vector Store
PDF → Document → Chunk → Vector Store</code></pre>
<p>처럼 <strong>데이터를 적재하는 과정</strong>을 다뤘다면,</p>
<p>Part 2에서는</p>
<pre><code class="language-text">질문
 ↓
SearchRequest
 ↓
Vector Search
 ↓
관련 Document 검색
 ↓
RAG에서 활용</code></pre>
<p>하는 과정을 다룬다.</p>
<p>특히 <code>SearchRequest</code>를 이용해</p>
<pre><code class="language-java">.topK(3)
.similarityThreshold(0.5)</code></pre>
<p>와 같이 검색 범위를 조절할 수 있다는 점이 중요했다.</p>
<hr>
<h3 id="느낀-점">느낀 점</h3>
<p>이번 프로젝트를 통해 RAG는 단순히 AI에게 질문을 전달하는 방식이 아니라, <strong>질문과 관련된 데이터를 먼저 검색하고 그 결과를 AI가 활용하도록 만드는 구조</strong>라는 것을 코드로 이해할 수 있었다.</p>
<p>특히 이미지 RAG에서는 검색된 <code>Document</code>에서 <code>caption</code>과 <code>publicUrl</code>을 활용하고, PDF RAG에서는 검색된 문서를 질문에 활용한다는 차이를 확인하면서 <strong>같은 Vector Search를 서로 다른 형태의 데이터에 적용할 수 있다는 점</strong>을 배웠다.</p>
<p>또한 <code>topK</code>와 <code>similarityThreshold</code>를 직접 설정하면서 RAG에서 <strong>무엇을 검색할 것인지뿐만 아니라 검색 결과를 어디까지 허용할 것인지도 중요하다</strong>는 것을 알게 되었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26/08/10 IL - (1)-2 PDF Document 변환 / VectorStore 저장]]></title>
            <link>https://velog.io/@baeksh_8/260810-IL-1-2-PDF-Document-%EB%B3%80%ED%99%98-VectorStore-%EC%A0%80%EC%9E%A5</link>
            <guid>https://velog.io/@baeksh_8/260810-IL-1-2-PDF-Document-%EB%B3%80%ED%99%98-VectorStore-%EC%A0%80%EC%9E%A5</guid>
            <pubDate>Sat, 19 Sep 2026 07:49:29 GMT</pubDate>
            <description><![CDATA[<h2 id="pdf를-rag-문서로-변환--vectorstore-저장">PDF를 RAG 문서로 변환 / VectorStore 저장</h2>
<p>이번 프로젝트에서는 이미지뿐만 아니라 <strong>PDF 파일을 RAG의 검색 대상 문서로 적재하는 과정</strong>을 구현했다.</p>
<p>PDF는 이미지처럼 그대로 Vector Store에 넣는 것이 아니라,</p>
<blockquote>
<p><strong>PDF 파일 → 페이지별 Document → Chunk → Vector Store 저장</strong></p>
</blockquote>
<p>과정을 거쳐야 한다.</p>
<p>이번 프로젝트에서 핵심적으로 사용한 기술은 다음과 같다.</p>
<ul>
<li><code>MultipartFile</code>을 이용한 PDF 업로드</li>
<li><code>PagePdfDocumentReader</code></li>
<li><code>PdfDocumentReaderConfig</code></li>
<li><code>TokenTextSplitter</code></li>
<li><code>Document</code></li>
<li><code>VectorStore</code></li>
<li>PDF 원본 파일명 metadata 저장</li>
<li><code>vectorStore.add()</code>를 통한 벡터 저장</li>
</ul>
<hr>
<h3 id="pdf를-바로-vector-store에-넣지-않는-이유">PDF를 바로 Vector Store에 넣지 않는 이유</h3>
<p>PDF 파일 자체는 RAG가 바로 검색할 수 있는 형태가 아니다.</p>
<p>따라서 업로드된 PDF를 먼저 <code>Document</code> 객체로 변환하고, 다시 검색하기 적절한 크기의 조각으로 나누어야 한다.</p>
<p>전체적인 흐름은 다음과 같다.</p>
<pre><code class="language-text">PDF 업로드
   ↓
MultipartFile
   ↓
InputStreamResource
   ↓
PagePdfDocumentReader
   ↓
페이지별 Document
   ↓
TokenTextSplitter
   ↓
여러 개의 Chunk
   ↓
metadata 추가
   ↓
VectorStore.add()
   ↓
벡터 DB 저장</code></pre>
<p>이 과정에서 중요한 것은 <strong>PDF를 읽는 것과 PDF를 검색 가능한 단위로 만드는 것은 별개의 작업</strong>이라는 점이다.</p>
<hr>
<h3 id="pdf-업로드-받기">PDF 업로드 받기</h3>
<p>PDF 파일은 <code>MultipartFile</code>로 전달받는다.</p>
<p>실제 프로젝트에서는 PDF 업로드를 처리하는 서비스에서 다음과 같은 형태로 파일을 받는다.</p>
<pre><code class="language-java">public int uploadPDF(MultipartFile file) {
    validate(file);

    ...
}</code></pre>
<p>여기서 <code>MultipartFile</code>은 사용자가 업로드한 파일을 Spring에서 표현하는 객체다.</p>
<p>따라서 서비스 입장에서는 다음과 같은 정보를 가지고 PDF를 처리할 수 있다.</p>
<pre><code class="language-text">MultipartFile
 ├─ 파일 이름
 ├─ Content-Type
 ├─ InputStream
 └─ 파일 데이터</code></pre>
<p>특히 PDF 내용을 읽기 위해서는 다음처럼 <code>getInputStream()</code>을 사용한다.</p>
<pre><code class="language-java">Resource resource =
        new InputStreamResource(
                file.getInputStream()
        );</code></pre>
<p>즉,</p>
<pre><code class="language-text">MultipartFile
      ↓
getInputStream()
      ↓
InputStreamResource
      ↓
PDF Reader</code></pre>
<p>라는 형태로 PDF Reader가 읽을 수 있는 <code>Resource</code>로 변환한다.</p>
<hr>
<h3 id="pagepdfdocumentreader로-pdf-읽기">PagePdfDocumentReader로 PDF 읽기</h3>
<p>PDF를 읽는 핵심 코드가 다음 부분이다.</p>
<pre><code class="language-java">PagePdfDocumentReader reader =
        new PagePdfDocumentReader(
                resource,
                PdfDocumentReaderConfig.builder().build()
        );</code></pre>
<p>여기서 중요한 클래스가 <code>PagePdfDocumentReader</code>다.</p>
<p>이 Reader는 PDF를 읽어서 Spring AI의 <code>Document</code> 형태로 변환하는 역할을 한다.</p>
<p>그리고 실제로 PDF를 읽는 부분은 다음과 같다.</p>
<pre><code class="language-java">List&lt;Document&gt; pages = reader.read();</code></pre>
<p>결과적으로 PDF 하나가 여러 개의 <code>Document</code>로 변환된다.</p>
<p>예를 들어 PDF가 5페이지라면 개념적으로 다음과 같은 형태가 된다.</p>
<pre><code class="language-text">PDF
 ├─ 1페이지 → Document
 ├─ 2페이지 → Document
 ├─ 3페이지 → Document
 ├─ 4페이지 → Document
 └─ 5페이지 → Document</code></pre>
<p>즉, 이 단계에서는 <strong>PDF를 페이지 단위의 Document로 변환</strong>한다.</p>
<hr>
<h3 id="pdf를-페이지-단위로-읽은-다음-다시-chunk로-나누기">PDF를 페이지 단위로 읽은 다음 다시 Chunk로 나누기</h3>
<p>페이지 단위로 만들어졌다고 해서 바로 Vector Store에 저장하는 것은 아니다.</p>
<p>프로젝트에서는 다음 코드를 사용한다.</p>
<pre><code class="language-java">TokenTextSplitter splitter =
        TokenTextSplitter.builder().build();

List&lt;Document&gt; chunks =
        splitter.apply(pages);</code></pre>
<p>여기서 <code>TokenTextSplitter</code>가 하는 역할은 <strong>Document를 더 작은 텍스트 조각으로 나누는 것</strong>이다.</p>
<p>전체 과정을 보면:</p>
<pre><code class="language-text">PDF
 ↓
PagePdfDocumentReader
 ↓
pages
 ↓
TokenTextSplitter
 ↓
chunks</code></pre>
<p>이다.</p>
<h3 id="왜-다시-나눌까">왜 다시 나눌까?</h3>
<p>페이지 하나가 항상 검색하기 좋은 크기의 텍스트라는 보장이 없기 때문이다.</p>
<p>예를 들어 한 페이지에 매우 많은 내용이 있다면 하나의 <code>Document</code>로 저장했을 때 검색 단위가 너무 커질 수 있다.</p>
<p>그래서</p>
<pre><code class="language-text">페이지 단위 Document
        ↓
TokenTextSplitter
        ↓
작은 Chunk 여러 개</code></pre>
<p>형태로 변환한다.</p>
<p>이렇게 만들어진 <code>chunks</code>가 실제 RAG 검색의 기본 단위가 된다.</p>
<hr>
<h3 id="chunk에-pdf-파일명-metadata-추가하기">Chunk에 PDF 파일명 metadata 추가하기</h3>
<p>이번 프로젝트에서 특히 눈여겨볼 부분이 이 코드다.</p>
<pre><code class="language-java">chunks.forEach(
        chunk -&gt; chunk.getMetadata()
                .put(
                        &quot;filename&quot;,
                        file.getOriginalFilename()
                )
);</code></pre>
<p>Chunk의 본문만 저장하는 것이 아니라 <strong>어떤 PDF에서 나온 내용인지 metadata로 저장</strong>한다.</p>
<p>예를 들어 사용자가</p>
<pre><code class="language-text">spring-ai-guide.pdf</code></pre>
<p>를 업로드했다면 각각의 Chunk에 다음과 같은 metadata가 들어갈 수 있다.</p>
<pre><code class="language-text">Document
 ├─ text
 │   └─ PDF에서 추출된 내용
 │
 └─ metadata
     └─ filename = &quot;spring-ai-guide.pdf&quot;</code></pre>
<p>이렇게 하면 나중에 검색 결과에서 해당 내용이 <strong>어떤 파일에서 나온 것인지 확인할 수 있는 기반</strong>을 만들 수 있다.</p>
<hr>
<h3 id="vectorstore에-chunk-저장">VectorStore에 Chunk 저장</h3>
<p>마지막으로 만들어진 Chunk들을 Vector Store에 저장한다.</p>
<pre><code class="language-java">vectorStore.add(chunks);</code></pre>
<p>여기서 중요한 것은 <code>chunks</code>가 단순한 문자열 목록이 아니라는 점이다.</p>
<pre><code class="language-java">List&lt;Document&gt; chunks</code></pre>
<p>형태로 만들어져 있다.</p>
<p>그리고 이 <code>Document</code> 목록을 <code>VectorStore</code>에 전달한다.</p>
<p>전체 PDF 적재 코드는 다음과 같은 구조다.</p>
<pre><code class="language-java">public int uploadPDF(MultipartFile file) {
    validate(file);

    try {
        Resource resource =
                new InputStreamResource(
                        file.getInputStream()
                );

        PagePdfDocumentReader reader =
                new PagePdfDocumentReader(
                        resource,
                        PdfDocumentReaderConfig.builder().build()
                );

        List&lt;Document&gt; pages = reader.read();

        TokenTextSplitter splitter =
                TokenTextSplitter.builder().build();

        List&lt;Document&gt; chunks =
                splitter.apply(pages);

        chunks.forEach(
                chunk -&gt; chunk.getMetadata()
                        .put(
                                &quot;filename&quot;,
                                file.getOriginalFilename()
                        )
        );

        vectorStore.add(chunks);

        return chunks.size();

    } catch (Exception e) {
        throw new IllegalArgumentException(e);
    }
}</code></pre>
<p>이 코드 하나에 PDF RAG의 <strong>적재 과정 전체</strong>가 들어있다.</p>
<hr>
<h3 id="코드의-역할을-단계별로-정리">코드의 역할을 단계별로 정리</h3>
<table>
<thead>
<tr>
<th>코드</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td><code>MultipartFile file</code></td>
<td>업로드된 PDF를 받음</td>
</tr>
<tr>
<td><code>file.getInputStream()</code></td>
<td>PDF 데이터를 읽을 수 있는 InputStream 획득</td>
</tr>
<tr>
<td><code>InputStreamResource</code></td>
<td>PDF Reader가 사용할 Resource 생성</td>
</tr>
<tr>
<td><code>PagePdfDocumentReader</code></td>
<td>PDF를 읽음</td>
</tr>
<tr>
<td><code>reader.read()</code></td>
<td>PDF를 <code>Document</code> 목록으로 변환</td>
</tr>
<tr>
<td><code>TokenTextSplitter</code></td>
<td>Document를 검색하기 적절한 Chunk로 분할</td>
</tr>
<tr>
<td><code>getMetadata().put()</code></td>
<td>원본 PDF 파일명 저장</td>
</tr>
<tr>
<td><code>vectorStore.add(chunks)</code></td>
<td>Chunk를 Vector Store에 적재</td>
</tr>
<tr>
<td><code>chunks.size()</code></td>
<td>생성된 Chunk 개수 반환</td>
</tr>
</tbody></table>
<hr>
<h3 id="pdf-rag에서-중요한-것은-파일-→-chunk-변환">PDF RAG에서 중요한 것은 &quot;파일 → Chunk&quot; 변환</h3>
<p>이번 구현을 공부하면서 PDF RAG를 단순히</p>
<pre><code class="language-text">PDF → Vector DB</code></pre>
<p>라고 생각하면 안 된다.</p>
<p>실제로는 다음과 같은 여러 단계가 존재한다.</p>
<pre><code class="language-text">                    PDF
                     │
                     ▼
            PagePdfDocumentReader
                     │
                     ▼
             List&lt;Document&gt;
              페이지 단위
                     │
                     ▼
             TokenTextSplitter
                     │
                     ▼
              List&lt;Document&gt;
               Chunk 단위
                     │
                     ▼
              metadata 추가
                     │
                     ▼
             VectorStore.add()
                     │
                     ▼
               Vector Store</code></pre>
<p>즉 <strong>RAG의 검색 품질을 결정하는 중요한 부분 중 하나가 문서를 어떻게 쪼개서 저장하느냐</strong>라는 것을 알 수 있다.</p>
<hr>
<h3 id="controller에서는-pdf-적재를-서비스에-위임">Controller에서는 PDF 적재를 서비스에 위임</h3>
<p>PDF 업로드 요청을 받는 Controller에서는 PDF 처리 자체를 직접 하지 않고 서비스의 <code>uploadPDF()</code>를 호출한다.</p>
<p>실제 프로젝트의 PDF 업로드 흐름에서는 서비스에서 다음 메서드를 사용한다.</p>
<pre><code class="language-java">uploadPDF(MultipartFile file)</code></pre>
<p>그리고 PDF를 적재한 뒤 반환되는 값은 다음과 같다.</p>
<pre><code class="language-java">return chunks.size();</code></pre>
<p>즉, 업로드된 PDF가 몇 개의 Chunk로 분리되었는지를 반환하도록 되어 있다.</p>
<p>예를 들어:</p>
<pre><code class="language-text">PDF 업로드
   ↓
100개의 Chunk 생성
   ↓
vectorStore.add(chunks)
   ↓
100 반환</code></pre>
<p>처럼 동작한다.</p>
<p>이 값은 단순한 숫자처럼 보이지만 <strong>PDF가 RAG 저장 단위로 몇 개의 Document로 분리되었는지 확인할 수 있는 값</strong>이다.</p>
<hr>
<p>이번 Part 1-2에서 가장 중요하게 이해한 것은 <strong>PDF를 RAG에 사용하기 위해서는 문서 변환 과정이 필요하다는 것</strong>이다.</p>
<p>특히 다음 세 가지를 구분해서 이해해야 한다.</p>
<h3 id="①-reader">① Reader</h3>
<pre><code class="language-java">PagePdfDocumentReader</code></pre>
<p>PDF를 읽어서 <code>Document</code>로 만든다.</p>
<h3 id="②-splitter">② Splitter</h3>
<pre><code class="language-java">TokenTextSplitter</code></pre>
<p>Document를 작은 Chunk로 나눈다.</p>
<h3 id="③-vectorstore">③ VectorStore</h3>
<pre><code class="language-java">vectorStore.add(chunks);</code></pre>
<p>나누어진 Chunk를 벡터 저장소에 적재한다.</p>
<p>따라서 이번 프로젝트의 PDF 적재 과정은</p>
<blockquote>
<p><strong>Reader → Splitter → VectorStore</strong></p>
</blockquote>
<p>라는 구조로 이해하면 된다.</p>
<hr>
<h3 id="느낀-점">느낀 점</h3>
<p>이번에는 PDF를 단순히 파일로 저장하는 것이 아니라 <strong>AI가 검색할 수 있는 Document 단위로 변환하는 과정</strong>이 필요하다는 것을 배웠다.</p>
<p>특히 <code>PagePdfDocumentReader</code>로 PDF를 읽은 뒤 <code>TokenTextSplitter</code>를 통해 다시 Chunk로 나누는 과정을 직접 코드로 확인하면서, RAG에서 문서 적재가 단순한 파일 업로드와 다르다는 것을 이해할 수 있었다.</p>
<p>또한 Chunk마다 <code>filename</code> metadata를 추가함으로써 <strong>본문뿐만 아니라 문서의 출처 정보도 함께 관리할 수 있다는 점</strong>도 알게 되었다.</p>
<p>결국 PDF RAG에서는</p>
<pre><code class="language-text">PDF 파일을 받는 것</code></pre>
<p>에서 끝나는 것이 아니라,</p>
<pre><code class="language-text">PDF
 → Document
 → Chunk
 → Metadata
 → VectorStore</code></pre>
<p>까지 변환해야 실제 검색에 활용할 수 있다는 것이 이번 Part 1-2의 핵심이었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[프로그래머스 AI 활용 백엔드 과정 7기 2차 백엔드 프로젝트 회고]]></title>
            <link>https://velog.io/@baeksh_8/2%EC%B0%A8-%EB%B0%B1%EC%97%94%EB%93%9C-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%ED%9A%8C%EA%B3%A0</link>
            <guid>https://velog.io/@baeksh_8/2%EC%B0%A8-%EB%B0%B1%EC%97%94%EB%93%9C-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%ED%9A%8C%EA%B3%A0</guid>
            <pubDate>Sun, 13 Sep 2026 10:52:59 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/baeksh_8/post/4b80616b-52ad-4a79-88d2-0b7ae96aed7c/image.jpg" alt=""></p>
<pre><code>                                          Huh?</code></pre><p>프로그래머스 데브코스 AI 활용 백엔드 과정 7기의 두번째 프로젝트인 백엔드 팀 프로젝트가 시작되었다.
다른 백엔드 개발자와의 협업 경험이 전무하여 이번이 처음으로 하는 백엔드 팀 프로젝트였다. 지금 쓰는 프로젝트 회고도 처음 쓰는 프로젝트 회고이다... </p>
<p>프로젝트의 주제는 재능 및 프리랜서 매칭 플랫폼이었다. </p>
<h2 id="keep">Keep</h2>
<ul>
<li>사용자의 입장에서 서비스를 어떤 목적으로 어떻게 사용할 지 생각하며, 사용될 기능이나 기술을 새롭게 배우고 고민하며 추가해나가는 부분이 알찼다.</li>
<li>개발 중에 일어난 문제에 대하여 트러블 슈팅을 진행했고 이를 공식 문서화하며 기록한 점은 이어나가면 좋을 것 같다.</li>
</ul>
<h2 id="problem">Problem</h2>
<ol>
<li><p>프로젝트 첫 날, 프로젝트를 계획하며 단방향 거래 방식에서 양방향 거래 방식으로 변경하면서 기능의 흐름에 대한 팀원별로 이해가 어긋남을 발견하였다. 핵심 기능과 객체 관계등을 서로 다르게 이해하였고 이로 인해 개발 진행 과정에서 혼선이 발생하였다. 이를 유저워크플로우를 따라가며 핵심 기능과 객체 관계등을 보다 더 정확하게 정의, 분기를 나눠서 세밀한 부분까지 유저의 입장에서 사용성을 예상하며 정리해나갔다. 앞으로 있을 <strong>프로젝트 진행에서는 프로젝트 기획 단계에서 적극적으로 서로 질문해가며 한 단계, 한 산출물마다 꼼꼼히 설계를 해야 한다는 것을 느꼈다.</strong></p>
</li>
<li><p>계속해서 개발 중 프로젝트를 하던 중 강사님께서 프로젝트 기획 점검을 진행하셨고, 많은 부분에서 부족한 부분이 있음을 발견하였다. 프로젝트 문서와 컨벤션을 정립하지 않았고 팀원간에 프로젝트 현재 진행도와 의사결정이 서로 공유가 잘 되지 않았다. 또 개발 계획이나 구현 계획에 정확한 가이드나 지침도 없어서 예상치 못한 상황 발생 시 대응에 어려움이 예상됐다.
그래서 피드백 후에 현재 진행도에 맞춰 <strong>구두가 아닌 문서화에 우선순위</strong>를 두어 팀 내 문서를 재정립하고 다시 작업을 진행해나갔다. 아직 문서화가 덜 진행된 부분이 있었지만 그래도 현 상황 진행도가 어떠한 지, 이어서 어떻게 진행을 해야 할 지 맞추어나가는 시간을 가지니 앞으로 해야할 것이 뚜렷이 보였다. </p>
</li>
</ol>
<h2 id="try">Try</h2>
<ul>
<li>이번 프로젝트는 처음 진행하는 백엔드 협업 프로젝트기도 해서 빠듯한 개발 일정에 기능 구현과 개발만 쫓기듯이 진행한 것 같다. 다음 프로젝트는 개발과 함께 이슈 추적/관리를 하며 중간중간 회고도 진행하고 코드/커밋/PR 컨벤션, 전체적인 WBS를 정하고 개발을 시작하는 게 좋을 것 같다.</li>
</ul>
<h2 id="소감">소감</h2>
<p>이번 프로젝트에서 가장 한 것이 없는 것 같다..ㅜㅜ 수업시간에 배웠던 기능을 왜 적용해야 하고 어떻게 적용해야하는지 철저하게 복습하지 않은 탓인 것 같다. 때문에 반복적인 CRUD 코드 구현과 좀 더 클린한 코드 작성에만 신경이 더 쓰인 것 같다.</p>
<p>더욱더 개발에 AI를 적극적으로 활용은 하되 그러한 코드나 기능이 어떻게 동작하는지 왜 그것이어야만 하는지 꼭 꼼꼼히 따져보며 개발을 해나가야겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26/08/10 IL - (1)-1 이미지 RAG를 위한 이미지 저장·전처리·AI 분석·VectorStore 저장]]></title>
            <link>https://velog.io/@baeksh_8/260810-IL-1-1-RAG%EC%9D%98-%EA%B8%B0%EB%B3%B8-%EA%B5%AC%EC%A1%B0%EC%99%80-Document-VectorStore%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%A0%80%EC%9E%A5</link>
            <guid>https://velog.io/@baeksh_8/260810-IL-1-1-RAG%EC%9D%98-%EA%B8%B0%EB%B3%B8-%EA%B5%AC%EC%A1%B0%EC%99%80-Document-VectorStore%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%A0%80%EC%9E%A5</guid>
            <pubDate>Wed, 19 Aug 2026 14:17:49 GMT</pubDate>
            <description><![CDATA[<h2 id="이미지-rag를-위한-이미지-저장·전처리·ai-분석·vectorstore-저장">이미지 RAG를 위한 이미지 저장·전처리·AI 분석·VectorStore 저장</h2>
<p>이번 프로젝트에서는 <strong>이미지를 AI가 검색할 수 있는 데이터로 만드는 과정</strong>에 집중하였다. 특히 업로드된 이미지를 Supabase Storage에 저장하고, 이미지 크기를 조정한 뒤 AI에게 이미지 설명을 생성하게 하고, 그 설명과 이미지 URL을 <code>Document</code>의 metadata에 저장하여 <code>VectorStore</code>에 넣는 과정을 실습하였다. 또한 이후 검색 결과를 화면에 전달하기 위해 <code>ImageRagSearchResult</code> DTO를 사용하는 구조도 학습하였다. </p>
<hr>
<h3 id="이미지-rag에서-이미지-자체를-그대로-저장하지-않는-이유">이미지 RAG에서 이미지 자체를 그대로 저장하지 않는 이유</h3>
<p>이번 프로젝트에서 가장 중요한 흐름은 다음과 같다.</p>
<pre><code class="language-text">사용자가 이미지 업로드
        ↓
MultipartFile
        ↓
Supabase Storage에 실제 이미지 저장
        ↓
이미지 크기 조정
        ↓
AI가 이미지 설명 생성
        ↓
Document 생성
        ↓
caption + publicUrl을 metadata에 저장
        ↓
imageVectorStore.add()</code></pre>
<p>즉, 이번 프로젝트에서는 단순히 이미지를 저장하는 것이 아니라 <strong>이미지를 검색할 수 있는 형태의 정보로 변환하는 과정</strong>을 구현하였다. <code>ImageService</code>에서는 실제 이미지 저장과 이미지 설명 생성, <code>Document</code> 생성, <code>VectorStore</code> 저장까지 하나의 흐름으로 연결되어 있다. </p>
<hr>
<h3 id="imageservice의-구성">ImageService의 구성</h3>
<p>먼저 이번 프로젝트의 핵심 서비스인 <code>ImageService</code>를 살펴보았다.</p>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class ImageService {

    @Qualifier(&quot;googleGenAiChatModel&quot;)
    private final ChatModel chatModel;

    @Qualifier(&quot;imageVectorStore&quot;)
    private final VectorStore imageVectorStore;

    // Supabase Storage
    private final S3Template s3Template;
}</code></pre>
<p>여기에서 중요한 것은 세 가지 의존성이다.</p>
<table>
<thead>
<tr>
<th>객체</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td><code>chatModel</code></td>
<td>업로드된 이미지를 해석하여 설명을 생성</td>
</tr>
<tr>
<td><code>imageVectorStore</code></td>
<td>이미지 설명과 metadata를 VectorStore에 저장하고 검색</td>
</tr>
<tr>
<td><code>s3Template</code></td>
<td>Supabase Storage에 이미지 업로드</td>
</tr>
</tbody></table>
<p>특히 <code>VectorStore</code>에 <code>@Qualifier(&quot;imageVectorStore&quot;)</code>를 사용한 것이 중요하다.</p>
<p>이번 프로젝트에서는 이미지 검색을 위한 별도의 VectorStore Bean인 <code>imageVectorStore</code>를 사용하고 있기 때문에 해당 이름을 지정하여 주입받는다. <code>ImageRagConfig</code>에서는 이 Bean을 <code>PgVectorStore</code> 기반으로 생성하고, 테이블 이름을 <code>image_vector_store</code>로 지정하였다. </p>
<hr>
<h3 id="이미지-업로드-controller">이미지 업로드 Controller</h3>
<p>이미지 업로드 요청은 <code>ImageController</code>에서 받는다.</p>
<pre><code class="language-java">@PostMapping
public String uploadImage(@RequestParam MultipartFile file,
                          RedirectAttributes redirectAttributes) {

    System.out.println(&quot;file = &quot; + file);
    System.out.println(file.getContentType());
    System.out.println(file.getOriginalFilename());

    redirectAttributes.addFlashAttribute(&quot;msg&quot;, &quot;파일 업로드 완료&quot;);

    String answer = imageService.explain(file);

    redirectAttributes.addFlashAttribute(&quot;answer&quot;, answer);

    return &quot;redirect:/image&quot;;
}</code></pre>
<p>사용자가 이미지 파일을 업로드하면</p>
<pre><code class="language-java">@RequestParam MultipartFile file</code></pre>
<p>을 통해 파일을 전달받는다.</p>
<p>그리고 Controller에서는 직접 파일을 저장하거나 AI 분석을 수행하지 않고</p>
<pre><code class="language-java">String answer = imageService.explain(file);</code></pre>
<p>을 호출한다.</p>
<p>즉 역할은 다음과 같이 나뉜다.</p>
<table>
<thead>
<tr>
<th>계층</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td>Controller</td>
<td>요청을 받고 Service 호출</td>
</tr>
<tr>
<td>ImageService</td>
<td>이미지 저장 및 AI 분석</td>
</tr>
<tr>
<td>Storage</td>
<td>실제 이미지 저장</td>
</tr>
<tr>
<td>VectorStore</td>
<td>검색을 위한 데이터 저장</td>
</tr>
</tbody></table>
<p>Controller에서 모든 작업을 처리하지 않고 Service에 실제 작업을 위임하는 구조를 확인할 수 있었다.</p>
<hr>
<h3 id="uuid로-이미지-파일명-생성">UUID로 이미지 파일명 생성</h3>
<p>이미지 저장 과정에서 다음 코드가 사용된다.</p>
<pre><code class="language-java">String uuid = java.util.UUID.randomUUID().toString();

String extension =
        StringUtils.getFilenameExtension(
                file.getOriginalFilename());

String newFilename =
        uuid + &quot;.&quot; + extension;</code></pre>
<p>사용자가 업로드한 파일 이름이</p>
<pre><code class="language-text">cat.jpg</code></pre>
<p>라고 하더라도 그대로 저장하지 않는다.</p>
<p>먼저</p>
<pre><code class="language-java">UUID.randomUUID().toString()</code></pre>
<p>으로 UUID를 생성한다.</p>
<p>그리고</p>
<pre><code class="language-java">StringUtils.getFilenameExtension(
    file.getOriginalFilename()
)</code></pre>
<p>으로 원본 파일의 확장자를 가져온다.</p>
<p>결과적으로</p>
<pre><code class="language-text">UUID + &quot;.&quot; + jpg</code></pre>
<p>형태의 새로운 파일명이 만들어진다.</p>
<p>예를 들어 개념적으로</p>
<pre><code class="language-text">cat.jpg
        ↓
UUID 생성
        ↓
550e8400-....jpg</code></pre>
<p>와 같은 형태가 된다.</p>
<p>이렇게 하면 서로 다른 사용자가 같은 이름의 파일을 업로드하더라도 저장 파일명이 겹칠 가능성을 줄일 수 있다.</p>
<hr>
<h3 id="supabase-storage에-이미지-저장">Supabase Storage에 이미지 저장</h3>
<p>파일명까지 만들었으면 실제 저장을 수행한다.</p>
<pre><code class="language-java">S3Resource result =
        s3Template.upload(
                &quot;rag&quot;,
                newFilename,
                file.getInputStream()
        );</code></pre>
<p>여기서 새롭게 학습한 부분은 <code>S3Template</code>이다.</p>
<p>프로젝트에서는 Supabase Storage를 S3 방식으로 다루고 있다.</p>
<pre><code class="language-text">S3Template
   ↓
Supabase Storage
   ↓
rag 버킷
   ↓
newFilename</code></pre>
<p><code>&quot;rag&quot;</code>는 업로드할 Storage 영역이고,</p>
<p><code>newFilename</code>은 실제 저장할 파일 이름이다.</p>
<p><code>file.getInputStream()</code>은 업로드된 파일의 내용을 읽어 Storage로 전달한다.</p>
<p>따라서 이 코드 한 줄을 통해</p>
<p><strong>사용자가 업로드한 MultipartFile → Supabase Storage</strong></p>
<p>로 실제 파일이 이동한다.</p>
<hr>
<h3 id="s3resource를-이용한-저장-결과-확인">S3Resource를 이용한 저장 결과 확인</h3>
<p>업로드 결과는</p>
<pre><code class="language-java">S3Resource result</code></pre>
<p>로 받는다.</p>
<p>그리고</p>
<pre><code class="language-java">System.out.println(&quot;result = &quot; + result.getURL());</code></pre>
<p>을 통해 업로드된 파일의 URL을 확인한다. </p>
<p>즉 <code>S3Template</code>이 단순히 저장만 하는 것이 아니라 저장 결과를 <code>S3Resource</code>로 반환하기 때문에 저장된 리소스의 URL을 가져올 수 있다는 점을 학습하였다.</p>
<hr>
<h3 id="public-url-생성">Public URL 생성</h3>
<p>그런데 코드에서는 <code>result.getURL()</code>을 그대로 사용하는 것이 아니라 URL을 변경한다.</p>
<pre><code class="language-java">String publicUrl = result.getURL().toString()
        .replace(
                &quot;storage.supabase.co/storage/v1/s3&quot;,
                &quot;supabase.co/storage/v1/object/public&quot;
        );</code></pre>
<p>프로젝트 코드에는 실제 URL 형태가 주석으로 작성되어 있다.</p>
<pre><code class="language-text">https://...storage.supabase.co/storage/v1/s3/rag/movie.jpg</code></pre>
<p>를</p>
<pre><code class="language-text">https://...supabase.co/storage/v1/object/public/rag/movie.jpg</code></pre>
<p>형태로 변환한다. </p>
<p>즉,</p>
<pre><code class="language-text">S3 방식의 URL
       ↓
public URL 형태로 변환</code></pre>
<p>하는 것이다.</p>
<p>이렇게 만들어진 <code>publicUrl</code>은 나중에 <code>Document</code>의 metadata에 저장된다.</p>
<hr>
<h3 id="이미지-크기-조정">이미지 크기 조정</h3>
<p>이미지를 AI에게 전달하기 전에 다음 코드를 실행한다.</p>
<pre><code class="language-java">byte[] resized = resize(
        file.getInputStream(),
        file.getContentType().split(&quot;/&quot;)[1],
        512,
        512
);</code></pre>
<p>여기에서 새롭게 사용한 것이 <code>Thumbnailator</code>이다.</p>
<pre><code class="language-java">public byte[] resize(
        InputStream inputStream,
        String format,
        int width,
        int height
) {

    ByteArrayOutputStream outputStream =
            new ByteArrayOutputStream();

    try {
        Thumbnails.of(inputStream)
                .size(width, height)
                .outputFormat(format)
                .toOutputStream(outputStream);

    } catch (Exception e) {
        throw new IllegalArgumentException(e);
    }

    return outputStream.toByteArray();
}</code></pre>
<p>처리 흐름은 다음과 같다.</p>
<pre><code class="language-text">InputStream
    ↓
Thumbnailator
    ↓
512 × 512
    ↓
ByteArrayOutputStream
    ↓
byte[]</code></pre>
<p><code>Thumbnails.of(inputStream)</code>으로 원본 이미지 스트림을 받고,</p>
<pre><code class="language-java">.size(width, height)</code></pre>
<p>를 통해 크기를 설정한다.</p>
<p>이번 코드에서는 실제 호출할 때</p>
<pre><code class="language-text">512 × 512</code></pre>
<p>를 전달한다. </p>
<p>마지막에는</p>
<pre><code class="language-java">outputStream.toByteArray()</code></pre>
<p>를 이용하여 처리된 이미지를 <code>byte[]</code>로 반환한다.</p>
<hr>
<h3 id="media-객체로-이미지-전달">Media 객체로 이미지 전달</h3>
<p>크기가 조정된 이미지는 AI가 처리할 수 있도록 <code>Media</code>로 만든다.</p>
<pre><code class="language-java">Media media = new Media(
        MimeTypeUtils.parseMimeType(
                file.getContentType()
        ),
        new ByteArrayResource(resized)
);</code></pre>
<p>여기서 중요한 것은 이미지 데이터가</p>
<pre><code class="language-text">byte[]</code></pre>
<p>에서</p>
<pre><code class="language-text">ByteArrayResource</code></pre>
<p>를 거쳐</p>
<pre><code class="language-text">Media</code></pre>
<p>가 된다는 것이다.</p>
<p>즉,</p>
<pre><code class="language-text">MultipartFile
      ↓
InputStream
      ↓
resize()
      ↓
byte[]
      ↓
ByteArrayResource
      ↓
Media</code></pre>
<p>의 흐름으로 이미지가 AI에게 전달할 수 있는 형태로 변환된다.</p>
<hr>
<h3 id="ai에게-이미지-설명-생성">AI에게 이미지 설명 생성</h3>
<p>이번 프로젝트의 핵심 부분이다.</p>
<pre><code class="language-java">String caption = chatClient.prompt()
        .user(u -&gt; u
                .text(&quot;첨부한 이미지를 해석해주세요&quot;)
                .media(media))
        .call()
        .content();</code></pre>
<p>여기서 중요한 것은 단순히 문자열을 AI에게 전달하는 것이 아니라</p>
<pre><code class="language-java">.media(media)</code></pre>
<p>를 통해 <strong>실제 이미지 데이터를 함께 전달한다는 것</strong>이다.</p>
<p>결과는</p>
<pre><code class="language-java">String caption</code></pre>
<p>으로 받는다.</p>
<p>즉</p>
<pre><code class="language-text">이미지
  ↓
AI
  ↓
이미지 설명
  ↓
caption</code></pre>
<p>이라는 과정이 만들어진다.</p>
<p>그리고 이 <code>caption</code>이 이후 이미지 검색의 핵심 데이터가 된다.</p>
<hr>
<h3 id="document에-이미지-설명과-url-저장">Document에 이미지 설명과 URL 저장</h3>
<p>AI가 만들어낸 <code>caption</code>과 Storage의 <code>publicUrl</code>을 <code>Document</code>에 저장한다.</p>
<pre><code class="language-java">Document document = Document.builder()
        .text(caption)
        .metadata(&quot;caption&quot;, caption)
        .metadata(&quot;publicUrl&quot;, publicUrl)
        .build();</code></pre>
<h3 id="text">text</h3>
<pre><code class="language-text">이미지 설명</code></pre>
<p>을 Document의 본문으로 사용한다.</p>
<h3 id="metadata">metadata</h3>
<p>추가적인 정보를 저장한다.</p>
<p>이번 프로젝트에서는</p>
<pre><code class="language-text">caption
publicUrl</code></pre>
<p>을 metadata로 저장하였다.</p>
<p>결과적으로 하나의 Document는 개념적으로</p>
<pre><code class="language-text">Document

├── text
│    └── 이미지 설명
│
└── metadata
     ├── caption
     └── publicUrl</code></pre>
<p>구조를 가지게 된다.</p>
<hr>
<h3 id="vectorstore에-document-저장">VectorStore에 Document 저장</h3>
<p>Document를 만들었으면</p>
<pre><code class="language-java">imageVectorStore.add(
        List.of(document)
);</code></pre>
<p>를 실행한다.</p>
<p>이 부분을 통해 이미지가 단순한 파일 저장으로 끝나는 것이 아니라 <strong>검색 가능한 데이터로 등록</strong>된다.</p>
<p>전체적으로 보면</p>
<pre><code class="language-text">이미지 업로드
      ↓
Storage 저장
      ↓
이미지 설명 생성
      ↓
Document 생성
      ↓
VectorStore 저장</code></pre>
<p>이다.</p>
<p>이것이 이번 프로젝트에서 구현한 <strong>이미지 RAG 데이터 적재 과정</strong>이다.</p>
<hr>
<h3 id="imageragsearchresult-dto">ImageRagSearchResult DTO</h3>
<p>검색 결과를 화면에서 사용하기 위해 별도의 DTO를 사용한다.</p>
<pre><code class="language-java">public record ImageRagSearchResult(
        String caption,
        String publicUrl
) {
}</code></pre>
<p>이 DTO는 검색 결과에서 필요한</p>
<pre><code class="language-text">이미지 설명

+

이미지 URL</code></pre>
<p>만 전달하기 위한 객체이다.</p>
<p>즉 VectorStore 내부의 <code>Document</code>를 그대로 Controller나 View에 전달하는 것이 아니라, 화면에서 필요한 데이터만 DTO로 변환한다.</p>
<hr>
<h3 id="이미지-검색-과정">이미지 검색 과정</h3>
<p>이미지를 저장했다면 다음으로 저장된 이미지 중에서 질문과 관련된 이미지를 검색할 수 있다.</p>
<p>프로젝트에서는 다음 코드를 사용하였다.</p>
<pre><code class="language-java">SearchRequest request = SearchRequest.builder()
        .query(query)
        .topK(3)
        .similarityThreshold(0.5)
        .build();

List&lt;Document&gt; results =
        imageVectorStore.similaritySearch(request);</code></pre>
<p>이번 Part에서는 검색 원리 자체보다는 <strong>저장한 Document를 어떻게 검색 요청으로 연결하는지</strong>에 초점을 맞추었다.</p>
<p>여기서</p>
<pre><code class="language-java">.query(query)</code></pre>
<p>는 사용자가 입력한 검색어이고,</p>
<pre><code class="language-java">.topK(3)</code></pre>
<p>은 최대 3개의 결과를 가져오도록 설정한다.</p>
<pre><code class="language-java">.similarityThreshold(0.5)</code></pre>
<p>는 유사도가 0.5 이상인 결과를 대상으로 한다.</p>
<p>검색 결과는</p>
<pre><code class="language-java">List&lt;Document&gt; results</code></pre>
<p>로 반환된다.</p>
<hr>
<h3 id="document의-metadata를-검색-결과-dto로-변환">Document의 metadata를 검색 결과 DTO로 변환</h3>
<p>검색된 <code>Document</code>에서 metadata를 가져온다.</p>
<pre><code class="language-java">return results.stream().map(
        d -&gt; {
            Object caption =
                    d.getMetadata().get(&quot;caption&quot;);

            Object publicUrl =
                    d.getMetadata().get(&quot;publicUrl&quot;);

            return new ImageRagSearchResult(
                    caption.toString(),
                    publicUrl.toString()
            );
        }
).toList();</code></pre>
<p>검색 결과인 <code>Document</code>에는 우리가 앞에서 저장했던</p>
<pre><code class="language-text">caption
publicUrl</code></pre>
<p>이 들어 있다.</p>
<p>그래서</p>
<pre><code class="language-java">d.getMetadata().get(&quot;caption&quot;)</code></pre>
<p>으로 이미지 설명을 가져오고,</p>
<pre><code class="language-java">d.getMetadata().get(&quot;publicUrl&quot;)</code></pre>
<p>로 실제 이미지 URL을 가져온다.</p>
<p>그리고 이를</p>
<pre><code class="language-java">new ImageRagSearchResult(...)</code></pre>
<p>로 변환한다.</p>
<p>최종적으로 화면에는</p>
<pre><code class="language-text">검색된 이미지의 설명

+

실제 이미지 URL</code></pre>
<p>만 전달된다.</p>
<hr>
<h3 id="이미지-rag-전체-흐름">이미지 RAG 전체 흐름</h3>
<p>이번 Part에서 가장 중요하게 기억해야 할 흐름이다.</p>
<pre><code class="language-text">[이미지 업로드]

MultipartFile
      ↓
UUID 파일명 생성
      ↓
S3Template
      ↓
Supabase Storage
      ↓
publicUrl 생성
      ↓
이미지 resize
      ↓
Media 생성
      ↓
AI 이미지 분석
      ↓
caption 생성
      ↓
Document 생성
      ↓
caption + publicUrl metadata 저장
      ↓
imageVectorStore.add()
      ↓
VectorStore</code></pre>
<p>그리고 검색할 때는</p>
<pre><code class="language-text">사용자 query
      ↓
SearchRequest
      ↓
imageVectorStore.similaritySearch()
      ↓
Document 검색
      ↓
metadata에서 caption / publicUrl 추출
      ↓
ImageRagSearchResult
      ↓
화면 출력</code></pre>
<p>으로 이어진다.</p>
<hr>
<h3 id="새롭게-배운-내용">새롭게 배운 내용</h3>
<table>
<thead>
<tr>
<th>새롭게 배운 내용</th>
<th>학습한 내용</th>
</tr>
</thead>
<tbody><tr>
<td><code>S3Template</code></td>
<td>Supabase Storage에 이미지를 업로드하는 방법</td>
</tr>
<tr>
<td><code>S3Resource</code></td>
<td>Storage 업로드 결과와 URL을 다루는 방법</td>
</tr>
<tr>
<td>UUID 파일명</td>
<td>업로드 파일에 새로운 이름을 생성하는 방법</td>
</tr>
<tr>
<td>Public URL 변환</td>
<td>Storage URL을 public object URL 형태로 변환하는 방법</td>
</tr>
<tr>
<td>Thumbnailator</td>
<td>이미지를 512×512 크기로 변환하는 방법</td>
</tr>
<tr>
<td><code>Media</code></td>
<td>처리한 이미지 데이터를 AI 요청에 포함시키는 방법</td>
</tr>
<tr>
<td><code>Document</code></td>
<td>이미지 설명과 추가 정보를 RAG 데이터로 구성하는 방법</td>
</tr>
<tr>
<td>Document metadata</td>
<td><code>caption</code>, <code>publicUrl</code> 같은 추가 정보를 Document에 저장하는 방법</td>
</tr>
<tr>
<td><code>imageVectorStore</code></td>
<td>이미지 전용 VectorStore를 구성하여 데이터를 저장·검색하는 구조</td>
</tr>
<tr>
<td><code>SearchRequest</code></td>
<td>검색어, 최대 결과 수, 유사도 기준을 지정하는 방법</td>
</tr>
<tr>
<td><code>ImageRagSearchResult</code></td>
<td>검색 결과에서 화면에 필요한 데이터만 DTO로 전달하는 방법</td>
</tr>
</tbody></table>
<hr>
<h3 id="핵심-코드">핵심 코드</h3>
<p>이번 프로젝트에서 가장 중요한 코드는 다음 흐름으로 기억하면 된다.</p>
<h3 id="①-이미지-저장">① 이미지 저장</h3>
<pre><code class="language-java">String uuid = java.util.UUID.randomUUID().toString();

String extension =
        StringUtils.getFilenameExtension(
                file.getOriginalFilename());

String newFilename = uuid + &quot;.&quot; + extension;

S3Resource result =
        s3Template.upload(
                &quot;rag&quot;,
                newFilename,
                file.getInputStream());</code></pre>
<p>↓</p>
<h3 id="②-public-url-생성">② Public URL 생성</h3>
<pre><code class="language-java">String publicUrl = result.getURL().toString()
        .replace(
                &quot;storage.supabase.co/storage/v1/s3&quot;,
                &quot;supabase.co/storage/v1/object/public&quot;);</code></pre>
<p>↓</p>
<h3 id="③-이미지-전처리">③ 이미지 전처리</h3>
<pre><code class="language-java">byte[] resized = resize(
        file.getInputStream(),
        file.getContentType().split(&quot;/&quot;)[1],
        512,
        512);</code></pre>
<p>↓</p>
<h3 id="④-ai-이미지-설명-생성">④ AI 이미지 설명 생성</h3>
<pre><code class="language-java">String caption = chatClient.prompt()
        .user(u -&gt; u
                .text(&quot;첨부한 이미지를 해석해주세요&quot;)
                .media(media))
        .call()
        .content();</code></pre>
<p>↓</p>
<h3 id="⑤-rag-document-생성">⑤ RAG Document 생성</h3>
<pre><code class="language-java">Document document = Document.builder()
        .text(caption)
        .metadata(&quot;caption&quot;, caption)
        .metadata(&quot;publicUrl&quot;, publicUrl)
        .build();</code></pre>
<p>↓</p>
<h3 id="⑥-vectorstore-저장">⑥ VectorStore 저장</h3>
<pre><code class="language-java">imageVectorStore.add(List.of(document));</code></pre>
<p>이 6단계를 이해하면 <strong>이번 프로젝트에서 이미지가 어떻게 RAG 데이터로 변환되는지</strong>를 이해한 것이다.</p>
<hr>
<h3 id="part-1-1-핵심-정리">Part 1-1 핵심 정리</h3>
<p>이번 프로젝트에서 새롭게 배운 가장 중요한 부분은 <strong>이미지를 단순히 저장하는 것과 AI가 검색할 수 있는 데이터로 만드는 것은 다르다는 것</strong>이었다.</p>
<p>이미지는 먼저 <code>S3Template</code>을 통해 Supabase Storage에 저장하고, <code>Thumbnailator</code>를 이용해 AI에 전달할 이미지를 처리한다. 이후 <code>Media</code>를 통해 AI에게 이미지를 전달하여 <code>caption</code>을 생성하고, 이 설명과 <code>publicUrl</code>을 <code>Document</code>의 metadata에 저장한다. 마지막으로 <code>imageVectorStore.add()</code>를 통해 Document를 VectorStore에 저장함으로써 이후 검색할 수 있는 이미지 RAG 데이터가 만들어진다. </p>
<p>즉 이번 Part의 핵심은</p>
<blockquote>
<p><strong>이미지 → 저장 → 전처리 → AI 설명 → Document + Metadata → VectorStore</strong></p>
</blockquote>
<p>라는 하나의 파이프라인을 이해하는 것이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26/08/07 IL - (2)-2 JPA 조회 성능 최적화]]></title>
            <link>https://velog.io/@baeksh_8/260807-IL-2-2-JPA-%EC%A1%B0%ED%9A%8C-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94%EC%99%80-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%A0%84%EC%B2%B4-%EB%8F%99%EC%9E%91-%ED%9D%90%EB%A6%84</link>
            <guid>https://velog.io/@baeksh_8/260807-IL-2-2-JPA-%EC%A1%B0%ED%9A%8C-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94%EC%99%80-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%A0%84%EC%B2%B4-%EB%8F%99%EC%9E%91-%ED%9D%90%EB%A6%84</guid>
            <pubDate>Wed, 19 Aug 2026 01:00:55 GMT</pubDate>
            <description><![CDATA[<h2 id="jpa-조회-성능-최적화와-프로젝트-전체-동작-흐름">JPA 조회 성능 최적화와 프로젝트 전체 동작 흐름</h2>
<p>이번 프로젝트에서는 <strong>조회 성능을 향상시키기 위한 JPA 조회 전략과 Repository 설계 방식</strong>을 학습하였다. 특히 <code>FetchType.LAZY</code>, <code>@EntityGraph</code>를 이용하여 <strong>N+1 문제를 해결하는 방법</strong>을 익혔으며, Repository를 인터페이스로 추상화하여 Service 계층이 구현체에 의존하지 않도록 설계하였다.</p>
<p>또한 Controller부터 View까지의 전체 요청 흐름을 정리하면서 Spring MVC와 JPA가 어떻게 함께 동작하는지 이해할 수 있었다.</p>
<hr>
<h3 id="fetchtypelazy">FetchType.LAZY</h3>
<p>MovieEntity와 MovieImageEntity는 연관관계를 가지고 있지만, 영화를 조회할 때마다 항상 이미지가 필요한 것은 아니다.</p>
<pre><code class="language-java">@OneToMany(
        mappedBy = &quot;movie&quot;,
        fetch = FetchType.LAZY
)
private List&lt;MovieImageEntity&gt; images;</code></pre>
<p><code>FetchType.LAZY</code>는 <strong>연관된 데이터를 실제로 사용할 때 조회하는 방식</strong>이다.</p>
<p>예를 들어</p>
<pre><code class="language-java">MovieEntity movie =
        movieRepository.findById(id).get();</code></pre>
<p>를 실행하면 처음에는</p>
<pre><code class="language-text">Movie

↓

title

price</code></pre>
<p>까지만 조회된다.</p>
<p>이미지는 아직 조회되지 않는다.</p>
<p>이미지가 필요한 순간</p>
<pre><code class="language-java">movie.getImages();</code></pre>
<p>을 호출하면 그때 SQL이 실행되어 이미지를 조회한다.</p>
<p>즉</p>
<pre><code class="language-text">Movie 조회

↓

MovieEntity 생성

↓

movie.getImages()

↓

Image 조회</code></pre>
<p>순서로 동작한다.</p>
<hr>
<h3 id="lazy를-사용하는-이유">LAZY를 사용하는 이유</h3>
<p>만약 영화를 100개 조회한다고 가정해 보자.</p>
<p>모든 영화의 이미지를 항상 함께 가져온다면</p>
<pre><code class="language-text">Movie 100개

↓

Image 500개</code></pre>
<p>까지 한 번에 메모리에 올라오게 된다.</p>
<p>하지만 목록 화면에서는 영화 제목만 필요한 경우도 있다.</p>
<p>그래서 필요하지 않은 데이터를 미리 조회하지 않기 위해 LAZY를 사용한다.</p>
<p>이를 통해 불필요한 메모리 사용을 줄이고 조회 성능을 향상시킬 수 있다.</p>
<hr>
<h3 id="n1-문제">N+1 문제</h3>
<p>LAZY를 사용하면 새로운 문제가 발생한다.</p>
<p>예를 들어</p>
<pre><code class="language-java">List&lt;MovieEntity&gt; movies =
        movieRepository.findAll();</code></pre>
<p>을 실행한다.</p>
<p>첫 번째 SQL은</p>
<pre><code class="language-sql">SELECT *
FROM movie;</code></pre>
<p>이다.</p>
<p>하지만</p>
<pre><code class="language-java">movie.getImages();</code></pre>
<p>를 호출할 때마다</p>
<pre><code class="language-sql">SELECT *
FROM movie_image
WHERE movie_id = ?;</code></pre>
<p>가 반복 실행된다.</p>
<p>Movie가 100개라면</p>
<pre><code class="language-text">Movie 조회

1번

+

Image 조회

100번

=

101번 SQL 실행</code></pre>
<p>이 된다.</p>
<p>이 현상을</p>
<p><strong>N+1 문제</strong>라고 한다.</p>
<hr>
<h3 id="entitygraph를-이용한-해결">EntityGraph를 이용한 해결</h3>
<p>이번 프로젝트에서는 N+1 문제를 해결하기 위해</p>
<p><code>@EntityGraph</code>를 사용하였다.</p>
<pre><code class="language-java">@EntityGraph(attributePaths = &quot;images&quot;)
List&lt;MovieEntity&gt; findAll();</code></pre>
<p><code>@EntityGraph</code>를 사용하면</p>
<p>Movie를 조회하는 순간 Image도 함께 조회한다.</p>
<p>즉</p>
<pre><code class="language-text">Movie 조회

+

Image 조회

↓

한 번의 SQL 실행</code></pre>
<p>이 된다.</p>
<p>SQL은 내부적으로</p>
<pre><code class="language-sql">SELECT *

FROM movie

LEFT JOIN movie_image</code></pre>
<p>와 유사한 방식으로 실행된다.</p>
<p>즉</p>
<p>Movie와 Image를 한 번에 가져오기 때문에 N+1 문제가 발생하지 않는다.</p>
<hr>
<h3 id="repository-추상화">Repository 추상화</h3>
<p>이번 프로젝트에서는 Service가 JpaRepository를 직접 사용하지 않았다.</p>
<pre><code class="language-java">public interface MovieRepository {

    MovieEntity save(MovieEntity movie);

    List&lt;MovieEntity&gt; findAll();

}</code></pre>
<p>실제로 데이터를 저장하는 구현체는</p>
<pre><code class="language-text">MovieRepository

↓

MovieRepositoryImpl

↓

JpaRepository</code></pre>
<p>이다.</p>
<p>Service는</p>
<pre><code class="language-java">private final MovieRepository movieRepository;</code></pre>
<p>만 알고 있다.</p>
<p>즉 인터페이스에만 의존한다.</p>
<hr>
<h3 id="repository를-추상화한-이유">Repository를 추상화한 이유</h3>
<p>현재는 JPA를 사용하지만 나중에</p>
<pre><code class="language-text">JPA

↓

MyBatis</code></pre>
<p>로 변경할 수도 있다.</p>
<p>Service는</p>
<pre><code class="language-java">movieRepository.findAll();</code></pre>
<p>만 사용하고 있으므로 구현체만 교체하면 된다.</p>
<p>즉 변경에 유연한 구조를 만들기 위해 Repository를 추상화하였다.</p>
<hr>
<h3 id="학습한-내용">학습한 내용</h3>
<table>
<thead>
<tr>
<th>학습 내용</th>
<th>세부 내용</th>
</tr>
</thead>
<tbody><tr>
<td>MultipartFile</td>
<td>업로드된 파일을 Java 객체로 처리하는 방법</td>
</tr>
<tr>
<td>UploadFile</td>
<td>원본 파일명과 저장 파일명을 분리하여 관리하는 구조</td>
</tr>
<tr>
<td>FileStore</td>
<td>파일 저장 방식을 인터페이스로 추상화하는 방법</td>
</tr>
<tr>
<td>Local/S3 저장</td>
<td>저장소를 구현체로 분리하여 교체 가능하도록 설계</td>
</tr>
<tr>
<td>DTO → Entity 변환</td>
<td>화면 데이터와 DB 객체를 분리하는 방법</td>
</tr>
<tr>
<td>OneToMany / ManyToOne</td>
<td>영화와 이미지의 1:N 연관관계 구성</td>
</tr>
<tr>
<td>Cascade</td>
<td>부모 Entity 저장·삭제 시 자식 Entity도 함께 처리</td>
</tr>
<tr>
<td>orphanRemoval</td>
<td>부모와 연결이 끊어진 자식 Entity 자동 삭제</td>
</tr>
<tr>
<td>BaseEntity</td>
<td>생성일과 수정일을 공통으로 관리하는 구조</td>
</tr>
<tr>
<td>Auditing</td>
<td>생성일과 수정일 자동 관리</td>
</tr>
<tr>
<td>Persistence Context</td>
<td>Entity를 관리하는 JPA의 핵심 공간</td>
</tr>
<tr>
<td>Dirty Checking</td>
<td>Entity 변경 사항을 자동으로 UPDATE하는 기능</td>
</tr>
<tr>
<td>FetchType.LAZY</td>
<td>필요한 시점에만 연관 데이터를 조회하는 전략</td>
</tr>
<tr>
<td>EntityGraph</td>
<td>N+1 문제를 해결하기 위한 조회 최적화 방법</td>
</tr>
<tr>
<td>Repository 추상화</td>
<td>인터페이스 기반으로 변경에 유연한 구조 설계</td>
</tr>
</tbody></table>
<hr>
<h3 id="느낀-점">느낀 점</h3>
<p>이번 프로젝트를 통해 단순히 <strong>파일을 업로드하는 기능을 구현하는 것보다, 객체지향적인 설계와 JPA의 동작 원리를 이해하는 것이 훨씬 중요하다</strong>는 점을 느꼈다. <code>MultipartFile</code>을 <code>UploadFile</code>과 <code>MovieImageEntity</code>로 분리하여 관리하고, <code>FileStore</code> 인터페이스를 이용해 Local과 S3 저장 방식을 자유롭게 교체할 수 있도록 설계하면서 <strong>다형성과 의존성 역전 원칙(DIP)</strong> 이 실제 프로젝트에서 어떻게 활용되는지 경험할 수 있었다.</p>
<p>또한 <code>OneToMany</code>, <code>ManyToOne</code>, <code>Cascade</code>, <code>orphanRemoval</code>, <code>Dirty Checking</code>, <code>FetchType.LAZY</code>, <code>EntityGraph</code>를 함께 학습하면서 JPA가 단순히 SQL을 대신 실행하는 기술이 아니라 <strong>객체의 생명주기와 연관관계를 관리하고 조회 성능까지 고려하는 ORM 프레임워크</strong>라는 점을 이해하게 되었다. 앞으로도 기능 구현에만 집중하기보다 <strong>확장성과 유지보수성을 고려한 설계와 성능 최적화 방법</strong>을 함께 고민하며 개발해야겠다고 느꼈다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26/08/07 IL - (2)-1 JPA 연관관계 설계와 영속성 컨텍스트를 이용한 데이터 관리]]></title>
            <link>https://velog.io/@baeksh_8/260807-IL-2-1-JPA-%EC%97%B0%EA%B4%80%EA%B4%80%EA%B3%84-%EC%84%A4%EA%B3%84%EC%99%80-%EC%98%81%EC%86%8D%EC%84%B1-%EC%BB%A8%ED%85%8D%EC%8A%A4%ED%8A%B8%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EA%B4%80%EB%A6%AC</link>
            <guid>https://velog.io/@baeksh_8/260807-IL-2-1-JPA-%EC%97%B0%EA%B4%80%EA%B4%80%EA%B3%84-%EC%84%A4%EA%B3%84%EC%99%80-%EC%98%81%EC%86%8D%EC%84%B1-%EC%BB%A8%ED%85%8D%EC%8A%A4%ED%8A%B8%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EA%B4%80%EB%A6%AC</guid>
            <pubDate>Tue, 18 Aug 2026 14:10:38 GMT</pubDate>
            <description><![CDATA[<h2 id="jpa-연관관계-설계와-영속성-컨텍스트를-이용한-데이터-관리">JPA 연관관계 설계와 영속성 컨텍스트를 이용한 데이터 관리</h2>
<p>이번 프로젝트에서는 <strong>MovieEntity와 MovieImageEntity 사이의 연관관계</strong>를 설계하고, JPA가 객체를 어떻게 관리하는지에 대해 학습하였다.</p>
<p>특히 <strong>OneToMany와 ManyToOne 관계</strong>, <strong>Cascade</strong>, <strong>orphanRemoval</strong>, <strong>BaseEntity를 이용한 Auditing</strong>, <strong>영속성 컨텍스트(Persistence Context)</strong> 와 <strong>Dirty Checking</strong>을 이해하면서 JPA가 단순한 ORM이 아니라 객체의 생명주기를 관리하는 프레임워크라는 점을 알게 되었다.</p>
<hr>
<h3 id="movieentity와-movieimageentity를-분리한-이유">MovieEntity와 MovieImageEntity를 분리한 이유</h3>
<p>영화 하나에는 여러 장의 이미지가 존재한다.</p>
<p>예를 들어</p>
<pre><code class="language-text">인사이드 아웃2

↓

포스터

↓

스틸컷1

↓

스틸컷2</code></pre>
<p>처럼 하나의 영화가 여러 이미지를 가진다.</p>
<p>그래서 하나의 Entity에</p>
<pre><code class="language-java">private String image;</code></pre>
<p>를 두는 것이 아니라</p>
<p>MovieEntity와 MovieImageEntity를 분리하였다.</p>
<hr>
<h3 id="movieentity">MovieEntity</h3>
<pre><code class="language-java">@OneToMany(
        mappedBy = &quot;movie&quot;,
        cascade = CascadeType.ALL,
        orphanRemoval = true
)
private List&lt;MovieImageEntity&gt; images = new ArrayList&lt;&gt;();</code></pre>
<h3 id="onetomany">@OneToMany</h3>
<p>Movie 하나가</p>
<p>Image 여러 개를 가진다는 의미이다.</p>
<h3 id="mappedby">mappedBy</h3>
<pre><code class="language-java">mappedBy = &quot;movie&quot;</code></pre>
<p>는 MovieImageEntity에 있는</p>
<pre><code class="language-java">private MovieEntity movie;</code></pre>
<p>필드를 기준으로 관계를 관리하겠다는 의미이다.</p>
<p>Movie가 외래키(FK)를 직접 관리하지 않는다.</p>
<p>DB에서는 MovieImage가</p>
<pre><code class="language-text">movie_id</code></pre>
<p>를 가지고 있다.</p>
<h3 id="list-movieimageentity">List&lt; MovieImageEntity &gt;</h3>
<p>MovieEntity는</p>
<p>이미지 한 개가 아니라</p>
<p>여러 개를 관리한다.</p>
<p>그래서 List&lt; MovieImageEntity &gt;</p>
<p>를 사용한다.</p>
<hr>
<h3 id="movieimageentity">MovieImageEntity</h3>
<pre><code class="language-java">@ManyToOne(fetch = FetchType.LAZY)

@JoinColumn(name = &quot;movie_id&quot;)

private MovieEntity movie;</code></pre>
<p>MovieImage 입장에서 보면</p>
<p>이미지 하나는 반드시 하나의 영화에 속한다.</p>
<p>즉</p>
<pre><code class="language-text">MovieImage

↓

Movie</code></pre>
<p>관계이다.</p>
<p>그래서</p>
<pre><code class="language-java">@ManyToOne</code></pre>
<p>을 사용한다.</p>
<hr>
<h3 id="joincolumn">@JoinColumn</h3>
<pre><code class="language-java">@JoinColumn(name = &quot;movie_id&quot;)</code></pre>
<p>는</p>
<p>DB의</p>
<pre><code class="language-text">movie_id</code></pre>
<p>컬럼과 연결된다.</p>
<p>예를 들어</p>
<p>Movie 테이블</p>
<table>
<thead>
<tr>
<th>id</th>
<th>title</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>인사이드아웃2</td>
</tr>
</tbody></table>
<p>MovieImage 테이블</p>
<table>
<thead>
<tr>
<th>id</th>
<th>movie_id</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>1</td>
</tr>
<tr>
<td>2</td>
<td>1</td>
</tr>
<tr>
<td>3</td>
<td>1</td>
</tr>
</tbody></table>
<p>처럼 저장된다.</p>
<hr>
<h3 id="연관관계-편의-메서드">연관관계 편의 메서드</h3>
<p>프로젝트에는</p>
<pre><code class="language-java">public void addImage(MovieImageEntity image){

    images.add(image);

    image.setMovie(this);

}</code></pre>
<p>메서드가 존재한다.</p>
<p>많은 사람들이</p>
<pre><code class="language-java">images.add(image);</code></pre>
<p>만 하면 된다고 생각한다.</p>
<p>하지만 MovieImage도 자신이 어느 Movie에 속하는지 알아야 한다.</p>
<p>그래서</p>
<pre><code class="language-text">Movie

↓

Image 추가

────────────

Image

↓

Movie 지정</code></pre>
<p>두 작업을 동시에 수행해야 한다.</p>
<p>이를 연관관계 편의 메서드라고 한다.</p>
<hr>
<h3 id="cascade">Cascade</h3>
<pre><code class="language-java">cascade = CascadeType.ALL</code></pre>
<p>Cascade는</p>
<p>부모(Entity)의 작업을</p>
<p>자식(Entity)에게 전파하는 기능이다.</p>
<p>예를 들어</p>
<pre><code class="language-java">movieRepository.save(movie);</code></pre>
<p>를 호출하면</p>
<p>실제로는</p>
<pre><code class="language-text">Movie 저장

↓

Image1 저장

↓

Image2 저장

↓

Image3 저장</code></pre>
<p>가 자동으로 수행된다.</p>
<p>즉 Movie만 저장해도 Image까지 함께 저장된다.</p>
<hr>
<h3 id="orphanremoval">orphanRemoval</h3>
<pre><code class="language-java">orphanRemoval = true</code></pre>
<p>Movie는 그대로 두고</p>
<p>Image 하나만 삭제한다고 가정하자.</p>
<pre><code class="language-java">movie.getImages().remove(image);</code></pre>
<p>그러면</p>
<p>Collection에서는 제거되지만</p>
<p>DB에서는 삭제되지 않을 수도 있다.</p>
<p>하지만</p>
<pre><code class="language-java">orphanRemoval = true</code></pre>
<p>를 사용하면</p>
<pre><code class="language-text">List.remove()

↓

JPA 감지

↓

DELETE 실행</code></pre>
<p>이 된다.</p>
<p>즉 부모와 관계가 끊어진 자식 Entity를 자동으로 삭제한다.</p>
<hr>
<h3 id="baseentity">BaseEntity</h3>
<p>모든 Entity에는</p>
<pre><code class="language-text">생성일

수정일</code></pre>
<p>이 필요하다.</p>
<p>그래서</p>
<p>공통 기능을</p>
<p>BaseEntity에서 관리하였다.</p>
<pre><code class="language-java">@MappedSuperclass

@EntityListeners(AuditingEntityListener.class)

public abstract class BaseEntity {

    @CreatedDate

    private LocalDateTime createdAt;

    @LastModifiedDate

    private LocalDateTime updatedAt;

}</code></pre>
<p>MovieEntity는</p>
<pre><code class="language-java">extends BaseEntity</code></pre>
<p>만 하면</p>
<pre><code class="language-text">createdAt

updatedAt</code></pre>
<p>를 자동으로 상속받는다.</p>
<p>중복 코드를 줄일 수 있는 구조이다.</p>
<hr>
<h3 id="jpa-auditing">JPA Auditing</h3>
<p>Auditing은 생성일과 수정일을 자동으로 관리하는 기능이다.</p>
<p>Movie 저장</p>
<p>↓</p>
<p>createdAt 자동 저장</p>
<p>Movie 수정</p>
<p>↓</p>
<p>updatedAt 자동 수정</p>
<p>개발자가 직접 시간을 넣지 않아도 된다.</p>
<hr>
<h3 id="영속성-컨텍스트persistence-context">영속성 컨텍스트(Persistence Context)</h3>
<p>영속성 컨텍스트는</p>
<p><strong>JPA가 Entity를 관리하는 공간</strong>이다.</p>
<p>객체를 조회하면</p>
<pre><code class="language-text">Database

↓

MovieEntity 생성

↓

Persistence Context 저장

↓

Service 반환</code></pre>
<p>이 된다.</p>
<p>즉 조회한 Entity는 JPA가 계속 관리하고 있다.</p>
<hr>
<h3 id="dirty-checking">Dirty Checking</h3>
<p>예를 들어</p>
<pre><code class="language-java">MovieEntity movie =
        repository.findById(id).get();

movie.changeTitle(&quot;새 제목&quot;);</code></pre>
<p>save()를 호출하지 않았다.</p>
<p>그런데도 DB가 수정된다.</p>
<p>왜일까?</p>
<pre><code class="language-text">Movie 조회

↓

Persistence Context 저장

↓

title 변경

↓

Transaction 종료

↓

기존 값과 비교

↓

UPDATE 실행</code></pre>
<p>이 과정을 Dirty Checking이라고 한다.</p>
<p>즉 JPA는 Entity가 변경되었는지를 자동으로 감지한다.</p>
<hr>
<h1 id="transactional의-역할">@Transactional의 역할</h1>
<p>Dirty Checking은 Transaction 안에서만 동작한다.</p>
<p>그래서 Service에는</p>
<pre><code class="language-java">@Transactional</code></pre>
<p>이 존재한다.</p>
<p>Transaction이 끝나는 순간 JPA가 변경 여부를 확인하고 UPDATE를 실행한다.</p>
<hr>
<h3 id="학습한-내용">학습한 내용</h3>
<table>
<thead>
<tr>
<th>학습 내용</th>
<th>세부 내용</th>
</tr>
</thead>
<tbody><tr>
<td>OneToMany</td>
<td>하나의 영화가 여러 이미지를 관리하는 연관관계</td>
</tr>
<tr>
<td>ManyToOne</td>
<td>이미지가 하나의 영화를 참조하는 구조</td>
</tr>
<tr>
<td>mappedBy</td>
<td>연관관계의 주인이 아닌 Entity를 지정하는 방법</td>
</tr>
<tr>
<td>JoinColumn</td>
<td>외래키를 생성하여 Entity를 연결하는 방법</td>
</tr>
<tr>
<td>연관관계 편의 메서드</td>
<td>양방향 관계를 안전하게 연결하는 방법</td>
</tr>
<tr>
<td>Cascade</td>
<td>부모 Entity 저장·삭제 시 자식 Entity도 함께 처리하는 기능</td>
</tr>
<tr>
<td>orphanRemoval</td>
<td>부모와 연결이 끊어진 자식 Entity를 자동으로 삭제하는 기능</td>
</tr>
<tr>
<td>BaseEntity</td>
<td>공통 필드를 상속으로 관리하는 구조</td>
</tr>
<tr>
<td>Auditing</td>
<td>생성일과 수정일을 자동으로 관리하는 기능</td>
</tr>
<tr>
<td>Persistence Context</td>
<td>Entity를 관리하는 JPA의 핵심 공간</td>
</tr>
<tr>
<td>Dirty Checking</td>
<td>Entity 변경 사항을 자동으로 감지하여 UPDATE하는 기능</td>
</tr>
<tr>
<td>Transaction</td>
<td>영속성 컨텍스트와 Dirty Checking이 동작하는 범위</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[26/08/07 IL - (1)-2 MovieService를 이용한 파일 업로드 처리와 Entity 생성 과정]]></title>
            <link>https://velog.io/@baeksh_8/260807-IL-1-2-MovieService%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%ED%8C%8C%EC%9D%BC-%EC%97%85%EB%A1%9C%EB%93%9C-%EC%B2%98%EB%A6%AC%EC%99%80-Entity-%EC%83%9D%EC%84%B1-%EA%B3%BC%EC%A0%95</link>
            <guid>https://velog.io/@baeksh_8/260807-IL-1-2-MovieService%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%ED%8C%8C%EC%9D%BC-%EC%97%85%EB%A1%9C%EB%93%9C-%EC%B2%98%EB%A6%AC%EC%99%80-Entity-%EC%83%9D%EC%84%B1-%EA%B3%BC%EC%A0%95</guid>
            <pubDate>Tue, 18 Aug 2026 13:54:49 GMT</pubDate>
            <description><![CDATA[<h2 id="movieservice를-이용한-파일-업로드-처리와-entity-생성-과정">MovieService를 이용한 파일 업로드 처리와 Entity 생성 과정</h2>
<p>이번 프로젝트에서는 사용자가 입력한 영화 정보와 이미지 파일을 하나의 요청으로 받아 데이터베이스에 저장하는 기능을 구현하였다. 이전 프로젝트에서는 문자열과 숫자만 저장하였다면, 이번에는 <strong>MultipartFile을 이용한 파일 업로드</strong>, <strong>DTO를 Entity로 변환</strong>, <strong>MovieEntity와 MovieImageEntity의 연관관계 생성</strong>까지 함께 처리하였다.</p>
<p>특히 Service 계층에서 파일 저장과 비즈니스 로직을 담당하도록 구현하여 <strong>Controller는 요청 처리만 수행하고 Service는 실제 기능을 수행하는 계층 분리 구조</strong>를 학습할 수 있었다.</p>
<hr>
<h3 id="controller의-역할">Controller의 역할</h3>
<p>Controller는 사용자의 요청을 받아 Service에게 전달하는 역할만 수행한다.</p>
<pre><code class="language-java">@PostMapping(&quot;/new&quot;)
public String createMovie(

        @Valid MovieDTO request,

        BindingResult bindingResult

) {

    if (bindingResult.hasErrors()) {

        return &quot;movies/new&quot;;

    }

    movieService.insert(request);

    return &quot;redirect:/movies&quot;;

}</code></pre>
<h3 id="valid-moviedto">@Valid MovieDTO</h3>
<p>사용자가 입력한 제목, 이미지를 MovieDTO로 전달받는다.</p>
<pre><code class="language-text">브라우저

↓

MovieDTO 생성

↓

Validation</code></pre>
<p>순서로 진행된다.</p>
<hr>
<h3 id="bindingresult">BindingResult</h3>
<p>Validation이 실패하면 MovieService는 실행되지 않는다.</p>
<p>예를 들어</p>
<pre><code class="language-text">제목 없음

↓

Validation 실패

↓

등록 화면 유지</code></pre>
<p>가 된다.</p>
<p>즉 사용자 입력 오류는 Exception이 아니라 BindingResult가 처리한다.</p>
<hr>
<h3 id="movieserviceinsert">movieService.insert()</h3>
<p>Controller는 파일 저장도 하지 않고 Entity도 만들지 않는다.</p>
<pre><code class="language-text">MovieDTO

↓

MovieService</code></pre>
<p>에게 전달한다.</p>
<p>이것이 Controller의 역할이다.</p>
<hr>
<h3 id="movieentity-생성">MovieEntity 생성</h3>
<pre><code class="language-java">MovieEntity movie =

        MovieEntity.builder()

                .title(dto.title())

                .price(dto.price())

                .build();</code></pre>
<p>MovieDTO는</p>
<p>사용자가 입력한 데이터이다.</p>
<p>DB에는 Entity를 저장해야 한다.</p>
<p>그래서</p>
<pre><code class="language-text">MovieDTO

↓

MovieEntity</code></pre>
<p>로 변환한다.</p>
<p>MovieEntity는 아직 DB에 저장되지 않은 상태이다.</p>
<hr>
<h3 id="multipartfile-반복-처리">MultipartFile 반복 처리</h3>
<p>사용자는 이미지를 여러 장 업로드할 수 있다.</p>
<p>따라서</p>
<pre><code class="language-java">for (MultipartFile image : dto.images()) {

    ...

}</code></pre>
<p>를 사용한다.</p>
<p>예를 들어</p>
<pre><code class="language-text">포스터

스틸컷1

스틸컷2</code></pre>
<p>를 선택했다면</p>
<p>반복문은</p>
<pre><code class="language-text">1번째 파일

↓

2번째 파일

↓

3번째 파일</code></pre>
<p>순으로 처리한다.</p>
<hr>
<h3 id="movieimageentity-생성">MovieImageEntity 생성</h3>
<p>파일을 저장한 후에는 이미지 정보를 Entity로 만든다.</p>
<pre><code class="language-java">MovieImageEntity imageEntity =

        MovieImageEntity.builder()

                .originalName(upload.originalName())

                .storedName(upload.storedName())

                .build();</code></pre>
<p>MovieImageEntity는 파일 자체를 저장하는 것이 아니라 파일 정보를 저장한다.</p>
<p>즉 DB에는</p>
<pre><code class="language-text">movie.jpg

82ab73.jpg</code></pre>
<p>만 저장된다.</p>
<p>실제 파일은 Local 또는 S3에 존재한다.</p>
<hr>
<h3 id="movie와-image-연결">Movie와 Image 연결</h3>
<p>이미지를 Movie와 연결해야 한다.</p>
<pre><code class="language-java">movie.addImage(imageEntity);</code></pre>
<p>이 메서드는 단순히</p>
<pre><code class="language-java">images.add(imageEntity);</code></pre>
<p>만 하지 않는다.</p>
<p>내부에서는</p>
<pre><code class="language-java">images.add(imageEntity);

imageEntity.setMovie(this);</code></pre>
<p>도 함께 수행한다.</p>
<p>즉</p>
<pre><code class="language-text">Movie

↓

Image 추가

────────────

Image

↓

Movie 지정</code></pre>
<p>를 동시에 처리한다.</p>
<p>이를 <strong>연관관계 편의 메서드</strong> 라고 한다.</p>
<hr>
<h3 id="repository-저장">Repository 저장</h3>
<p>이미지 연결이 끝나면 Movie를 저장한다.</p>
<pre><code class="language-java">movieRepository.save(movie);</code></pre>
<p>Movie만 저장하지만 Cascade가 설정되어 있으므로</p>
<pre><code class="language-text">Movie 저장

↓

Image1 저장

↓

Image2 저장

↓

Image3 저장</code></pre>
<p>가 자동으로 수행된다.</p>
<p>개발자는 Movie만 저장하면 된다.</p>
<hr>
<h3 id="controller와-service-역할-비교">Controller와 Service 역할 비교</h3>
<table>
<thead>
<tr>
<th>Controller</th>
<th>Service</th>
</tr>
</thead>
<tbody><tr>
<td>요청 받기</td>
<td>비즈니스 로직 수행</td>
</tr>
<tr>
<td>Validation 확인</td>
<td>Entity 생성</td>
</tr>
<tr>
<td>View 반환</td>
<td>파일 저장</td>
</tr>
<tr>
<td>Redirect 처리</td>
<td>Repository 호출</td>
</tr>
</tbody></table>
<p>Controller는</p>
<p>&quot;무엇을 할지&quot;</p>
<p>결정하고</p>
<p>Service는</p>
<p>&quot;어떻게 할지&quot;</p>
<p>구현한다.</p>
<hr>
<h3 id="dto-→-entity-변환-과정">DTO → Entity 변환 과정</h3>
<p>이번 프로젝트에서는</p>
<p>다음과 같은 흐름으로 객체가 변경된다.</p>
<pre><code class="language-text">사용자 입력

↓

MovieDTO

↓

MovieEntity

↓

Database</code></pre>
<p>이미지는</p>
<pre><code class="language-text">MultipartFile

↓

UploadFile

↓

MovieImageEntity

↓

Database</code></pre>
<p>라는 별도의 흐름을 가진다.</p>
<p>즉 영화 정보와 이미지 정보는 각각 Entity로 변환된 뒤 연관관계를 맺어 저장된다.</p>
<hr>
<h3 id="validation과-service의-관계">Validation과 Service의 관계</h3>
<p>Service는 Validation을 직접 수행하지 않는다.</p>
<p>Validation은 Controller에서 끝난다.</p>
<pre><code class="language-text">Validation 성공

↓

MovieService 실행</code></pre>
<p>즉 Service는 항상 정상적인 데이터가 들어온다고 가정하고 비즈니스 로직만 수행한다.</p>
<hr>
<h2 id="학습한-내용">학습한 내용</h2>
<table>
<thead>
<tr>
<th>학습 내용</th>
<th>세부 내용</th>
</tr>
</thead>
<tbody><tr>
<td>Controller 역할</td>
<td>요청 수신, Validation 확인, Service 호출</td>
</tr>
<tr>
<td>Service 역할</td>
<td>Entity 생성, 파일 저장, Repository 호출</td>
</tr>
<tr>
<td>MultipartFile 반복 처리</td>
<td>여러 장의 이미지를 반복문으로 저장</td>
</tr>
<tr>
<td>UploadFile</td>
<td>파일 저장 결과를 객체로 관리</td>
</tr>
<tr>
<td>MovieImageEntity</td>
<td>파일 정보를 Entity로 저장</td>
</tr>
<tr>
<td>연관관계 편의 메서드</td>
<td>Movie와 Image를 동시에 연결하는 방법</td>
</tr>
<tr>
<td>DTO → Entity 변환</td>
<td>화면 데이터와 DB 객체를 분리하는 구조</td>
</tr>
<tr>
<td>Cascade 저장</td>
<td>Movie 저장 시 Image도 함께 저장되는 구조</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[26/08/07 IL - (1)-1 MultipartFile을 활용한 파일 업로드 구조와 FileStore 설계 ]]></title>
            <link>https://velog.io/@baeksh_8/260807-IL-1-1-MultipartFile%EC%9D%84-%ED%99%9C%EC%9A%A9%ED%95%9C-%ED%8C%8C%EC%9D%BC-%EC%97%85%EB%A1%9C%EB%93%9C-%EA%B5%AC%EC%A1%B0%EC%99%80-FileStore-%EC%84%A4%EA%B3%84</link>
            <guid>https://velog.io/@baeksh_8/260807-IL-1-1-MultipartFile%EC%9D%84-%ED%99%9C%EC%9A%A9%ED%95%9C-%ED%8C%8C%EC%9D%BC-%EC%97%85%EB%A1%9C%EB%93%9C-%EA%B5%AC%EC%A1%B0%EC%99%80-FileStore-%EC%84%A4%EA%B3%84</guid>
            <pubDate>Tue, 18 Aug 2026 11:48:11 GMT</pubDate>
            <description><![CDATA[<h2 id="multipartfile을-활용한-파일-업로드-구조와-filestore-설계">MultipartFile을 활용한 파일 업로드 구조와 FileStore 설계</h2>
<p>이번 프로젝트에서는 기존 영화 CRUD 프로젝트를 확장하여 <strong>이미지 파일 업로드 기능</strong>을 구현하였다. 단순히 영화 제목과 가격 같은 텍스트 데이터만 처리하는 것이 아니라, 사용자가 여러 장의 이미지를 선택하여 업로드하고 이를 서버(Local) 또는 S3와 같은 외부 저장소에 저장하는 구조를 학습하였다.</p>
<p>특히 <code>MultipartFile</code>을 이용한 파일 처리, <code>UploadFile</code>을 활용한 파일 정보 관리, <code>FileStore</code> 인터페이스를 이용한 저장소 추상화, <code>StorageProperty</code>를 통한 설정 관리 등을 구현하면서 <strong>객체지향적인 파일 저장 구조</strong>를 이해할 수 있었다.</p>
<hr>
<h3 id="multipartfile이란">MultipartFile이란?</h3>
<p>기존 프로젝트에서는 사용자가 입력한 값이 모두 문자열이나 숫자였다.</p>
<pre><code class="language-text">제목

가격</code></pre>
<p>하지만 이번 프로젝트에서는 사용자가 파일도 함께 업로드한다.</p>
<pre><code class="language-text">제목

이미지1

이미지2

이미지3</code></pre>
<p>Spring에서는 업로드된 파일을 <strong>MultipartFile</strong> 객체로 변환하여 전달한다.</p>
<p>즉,</p>
<pre><code class="language-text">브라우저

↓

파일 선택

↓

HTTP Multipart Request

↓

MultipartFile 객체 생성

↓

Controller</code></pre>
<p>과정으로 동작한다.</p>
<hr>
<h3 id="moviedto를-이용한-입력-데이터-관리">MovieDTO를 이용한 입력 데이터 관리</h3>
<p>사용자가 입력한 데이터는 <code>MovieDTO</code>에서 관리하였다.</p>
<pre><code class="language-java">public record MovieDTO(

        @NotBlank String title,

        @NotEmpty List&lt;MultipartFile&gt; images

) {
}</code></pre>
<h4 id="notblank-string-title"><code>@NotBlank String title</code></h4>
<p>영화 제목은 반드시 입력되어야 한다.</p>
<ul>
<li>null 불가</li>
<li>빈 문자열 불가</li>
<li>공백만 입력 불가</li>
</ul>
<p>Validation을 통해 잘못된 입력을 사전에 방지한다.</p>
<h4 id="listmultipartfile-images"><code>List&lt;MultipartFile&gt; images</code></h4>
<p>파일 하나가 아니라</p>
<pre><code class="language-text">포스터

스틸컷1

스틸컷2</code></pre>
<p>처럼 여러 장을 업로드할 수 있으므로</p>
<pre><code class="language-java">MultipartFile image;</code></pre>
<p>가 아니라</p>
<pre><code class="language-java">List&lt;MultipartFile&gt; images;</code></pre>
<p>를 사용하였다.</p>
<hr>
<h3 id="multipartfile-안에는-무엇이-들어-있을까">MultipartFile 안에는 무엇이 들어 있을까?</h3>
<p>MultipartFile은 단순히 파일만 저장하는 객체가 아니다.</p>
<p>다음과 같은 정보를 포함한다.</p>
<table>
<thead>
<tr>
<th>메서드</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td><code>getOriginalFilename()</code></td>
<td>원본 파일 이름</td>
</tr>
<tr>
<td><code>getContentType()</code></td>
<td>MIME 타입</td>
</tr>
<tr>
<td><code>getSize()</code></td>
<td>파일 크기</td>
</tr>
<tr>
<td><code>getInputStream()</code></td>
<td>파일 내용</td>
</tr>
<tr>
<td><code>transferTo()</code></td>
<td>실제 파일 저장</td>
</tr>
</tbody></table>
<hr>
<h3 id="uploadfile-dto">UploadFile DTO</h3>
<p>파일을 저장한 뒤에는 MultipartFile 자체를 계속 사용할 필요가 없다.</p>
<pre><code class="language-java">public record UploadFile(

        String originalName,

        String storedName

) {

    public String url() {

        return &quot;/upload/%s&quot;.formatted(storedName);

    }

}</code></pre>
<p>파일을 저장하면 두 가지 이름이 존재한다.</p>
<p>예를 들어 사용자가</p>
<pre><code class="language-text">cat.jpg</code></pre>
<p>를 업로드하였다.</p>
<p>실제로 서버에는</p>
<pre><code class="language-text">2fa39fd8-8ab2.jpg</code></pre>
<p>처럼 저장된다.</p>
<p>따라서</p>
<table>
<thead>
<tr>
<th>originalName</th>
<th>storedName</th>
</tr>
</thead>
<tbody><tr>
<td>cat.jpg</td>
<td>2fa39fd8-8ab2.jpg</td>
</tr>
</tbody></table>
<p>를 모두 저장해야 한다.</p>
<hr>
<h3 id="originalname을-저장하는-이유">originalName을 저장하는 이유</h3>
<p>사용자는</p>
<pre><code class="language-text">cat.jpg</code></pre>
<p>를 보고 싶다.</p>
<p>다운로드 화면에서도</p>
<pre><code class="language-text">cat.jpg</code></pre>
<p>라고 표시되어야 한다.</p>
<hr>
<h3 id="storedname을-저장하는-이유">storedName을 저장하는 이유</h3>
<p>서버에서는 파일명이 중복되면 안 된다.</p>
<p>예를 들어</p>
<p>두 사용자가 모두</p>
<pre><code class="language-text">cat.jpg</code></pre>
<p>를 업로드하면 기존 파일이 덮어써질 수 있다.</p>
<p>그래서 UUID를 이용하여</p>
<pre><code class="language-text">cat.jpg

↓

7fd81e2d.jpg</code></pre>
<p>처럼 저장한다.</p>
<p>이를 통해 파일명 충돌을 방지할 수 있다.</p>
<hr>
<h3 id="url-생성-메서드">URL 생성 메서드</h3>
<p>UploadFile에는</p>
<pre><code class="language-java">public String url() {
    return &quot;/upload/%s&quot;.formatted(storedName);
}</code></pre>
<p>도 존재한다.</p>
<p>이 메서드는 저장된 파일명을 이용하여 브라우저에서 접근 가능한 URL을 생성한다.</p>
<p>예를 들어 storedName이</p>
<pre><code class="language-text">abc123.jpg</code></pre>
<p>이면</p>
<pre><code class="language-text">/upload/abc123.jpg</code></pre>
<p>를 반환한다.</p>
<p>화면에서는 이 URL을 <code>&lt;img&gt;</code> 태그의 <code>src</code> 속성으로 사용하여 이미지를 출력한다.</p>
<hr>
<h3 id="filestore-인터페이스">FileStore 인터페이스</h3>
<pre><code class="language-java">public interface FileStore {

    UploadFile storeFile(MultipartFile image);

}</code></pre>
<p>MovieService는 파일을 어떻게 저장하는지 알 필요가 없다.</p>
<pre><code class="language-java">UploadFile upload = fileStore.storeFile(image);</code></pre>
<p>만 호출한다.</p>
<p>실제로 저장하는 방법은</p>
<p>FileStore</p>
<p>↓</p>
<p>LocalFileStore 또는 S3FileStore가 담당한다.</p>
<p>즉, MovieService는 <strong>인터페이스에만 의존</strong>한다.</p>
<hr>
<h3 id="localfilestore-구현">LocalFileStore 구현</h3>
<p>LocalFileStore는 프로젝트 내부 폴더에 파일을 저장한다.</p>
<pre><code class="language-java">@Component
public class LocalFileStore implements FileStore {

    private final Path uploadPath =
            Path.of(&quot;src/main/resources/static/upload&quot;);

}</code></pre>
<p>업로드된 파일은</p>
<pre><code class="language-text">src

↓

main

↓

resources

↓

static

↓

upload</code></pre>
<p>폴더에 저장된다.</p>
<p>브라우저는</p>
<pre><code class="language-text">/upload/파일명</code></pre>
<p>으로 접근할 수 있다.</p>
<hr>
<h3 id="파일-저장-과정">파일 저장 과정</h3>
<p>LocalFileStore 내부에서는</p>
<p>다음 순서로 동작한다.</p>
<pre><code class="language-text">MultipartFile

↓

비어있는지 검사

↓

확장자 검사

↓

UUID 생성

↓

파일 저장

↓

UploadFile 반환</code></pre>
<p>즉, MovieService는 파일이 어디에 저장되는지 전혀 알지 못한다.</p>
<hr>
<h3 id="storageproperty를-이용한-설정-관리">StorageProperty를 이용한 설정 관리</h3>
<p>S3 정보를 관리하기 위해 <code>StorageProperty</code>를 사용하였다.</p>
<pre><code class="language-java">@ConfigurationProperties(prefix = &quot;app.storage&quot;)

@Validated

public record StorageProperty(

        String bucket,

        String endpoint,

        String region,

        String access_key,

        String secret_key

) {
}</code></pre>
<p>기존에는</p>
<pre><code class="language-java">@Value(...)</code></pre>
<p>를 여러 번 사용해야 했다.</p>
<p>하지만 ConfigurationProperties를 사용하면</p>
<pre><code class="language-text">application.yml

↓

app.storage

↓

StorageProperty</code></pre>
<p>로 자동 매핑된다.</p>
<p>따라서 S3 관련 설정을 하나의 객체로 관리할 수 있다.</p>
<hr>
<h3 id="filestore를-인터페이스로-만든-이유">FileStore를 인터페이스로 만든 이유</h3>
<p>현재는</p>
<pre><code class="language-text">LocalFileStore</code></pre>
<p>를 사용할 수도 있고</p>
<pre><code class="language-text">S3FileStore</code></pre>
<p>를 사용할 수도 있다.</p>
<p>MovieService는</p>
<pre><code class="language-java">private final FileStore fileStore;</code></pre>
<p>만 알고 있다.</p>
<p>따라서</p>
<p>나중에</p>
<pre><code class="language-text">AWS S3

↓

Azure Blob

↓

Google Cloud Storage</code></pre>
<p>등으로 변경하더라도 MovieService는 전혀 수정하지 않아도 된다.</p>
<p>이것이 <strong>다형성과 DIP(의존성 역전 원칙)</strong> 의 대표적인 예시이다.</p>
<hr>
<h3 id="학습한-내용">학습한 내용</h3>
<table>
<thead>
<tr>
<th>학습 내용</th>
<th>세부 내용</th>
</tr>
</thead>
<tbody><tr>
<td>MultipartFile</td>
<td>업로드된 파일을 Java 객체로 처리하는 방법</td>
</tr>
<tr>
<td>MovieDTO</td>
<td>여러 장의 이미지를 <code>List&lt;MultipartFile&gt;</code>로 관리하는 방법</td>
</tr>
<tr>
<td>UploadFile</td>
<td>원본 파일명과 저장 파일명을 분리하여 관리하는 이유</td>
</tr>
<tr>
<td>UUID</td>
<td>파일명 중복을 방지하기 위한 고유 식별자 생성</td>
</tr>
<tr>
<td>FileStore</td>
<td>저장 방식을 추상화한 인터페이스</td>
</tr>
<tr>
<td>LocalFileStore</td>
<td>프로젝트 내부에 파일을 저장하는 구현체</td>
</tr>
<tr>
<td>StorageProperty</td>
<td><code>@ConfigurationProperties</code>를 활용한 설정 관리</td>
</tr>
<tr>
<td>DIP</td>
<td>인터페이스에 의존하여 저장소 변경에 유연하게 대응하는 구조</td>
</tr>
</tbody></table>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[26/08/06 TIL - 레이아웃과 Thymeleaf Fragment, 서버사이드 예외처리 (2)]]></title>
            <link>https://velog.io/@baeksh_8/260805-TILI-Learned-%EB%A0%88%EC%9D%B4%EC%95%84%EC%9B%83%EA%B3%BC-Thymeleaf-Fragment-%EC%84%9C%EB%B2%84%EC%82%AC%EC%9D%B4%EB%93%9C-%EC%98%88%EC%99%B8%EC%B2%98%EB%A6%AC-2</link>
            <guid>https://velog.io/@baeksh_8/260805-TILI-Learned-%EB%A0%88%EC%9D%B4%EC%95%84%EC%9B%83%EA%B3%BC-Thymeleaf-Fragment-%EC%84%9C%EB%B2%84%EC%82%AC%EC%9D%B4%EB%93%9C-%EC%98%88%EC%99%B8%EC%B2%98%EB%A6%AC-2</guid>
            <pubDate>Thu, 06 Aug 2026 12:11:25 GMT</pubDate>
            <description><![CDATA[<h2 id="spring-data-jpa를-활용한-영화-crud-기능-구현과-spring-mvc의-예외-처리-구조">Spring Data JPA를 활용한 영화 CRUD 기능 구현과 Spring MVC의 예외 처리 구조</h2>
<p>이번 프로젝트에서는 <strong>Spring Data JPA를 활용한 영화 CRUD 기능 구현과 Spring MVC의 예외 처리 구조</strong>를 함께 학습하였다. <code>JpaRepository</code>와 Service 계층을 이용하여 조회, 등록, 수정, 삭제 기능을 계층적으로 구현하고, <code>BaseEntity</code>와 JPA Auditing을 통해 생성일과 수정일을 자동으로 관리하는 방법을 익혔다. 또한 <code>Optional.orElseThrow()</code>를 활용한 안전한 데이터 조회와 <code>Custom Exception</code>, <code>@ExceptionHandler</code>, <code>@ControllerAdvice</code>, <code>@ResponseStatus</code>를 이용한 예외 처리 구조를 구현하여, 상황에 맞는 <strong>404, 409, 500, 5xx 오류 페이지</strong>를 사용자에게 제공하는 방법도 학습하였다. 이를 통해 <strong>계층형 아키텍처를 활용한 유지보수성 높은 구조와 사용자 친화적인 예외 처리 방식</strong>을 이해할 수 있었다.</p>
<hr>
<h3 id="spring-data-jpa와-jparepository">Spring Data JPA와 JpaRepository</h3>
<p>이번 프로젝트에서는 영화 정보를 관리하기 위해 <code>JpaRepository</code>를 사용하였다.</p>
<pre><code class="language-java">public interface MovieJpaRepository
        extends JpaRepository&lt;MovieEntity, Long&gt; {
}</code></pre>
<p>(프로젝트 Repository 구조)</p>
<p><code>JpaRepository</code>는 Spring Data JPA가 제공하는 인터페이스로, 기본적인 CRUD 기능을 미리 구현해 놓은 저장소이다.</p>
<p>즉, 개발자가 직접 SQL을 작성하지 않아도 다음과 같은 기능을 바로 사용할 수 있다.</p>
<table>
<thead>
<tr>
<th>메서드</th>
<th>기능</th>
</tr>
</thead>
<tbody><tr>
<td>save()</td>
<td>데이터 저장</td>
</tr>
<tr>
<td>findAll()</td>
<td>전체 조회</td>
</tr>
<tr>
<td>findById()</td>
<td>ID 조회</td>
</tr>
<tr>
<td>delete()</td>
<td>삭제</td>
</tr>
<tr>
<td>existsById()</td>
<td>존재 여부 확인</td>
</tr>
</tbody></table>
<p>예를 들어</p>
<pre><code class="language-java">movieJpaRepository.findAll();</code></pre>
<p>을 호출하면 내부적으로는 다음과 같은 SQL과 유사한 쿼리가 자동으로 실행된다.</p>
<pre><code class="language-sql">SELECT *
FROM movie;</code></pre>
<p>즉, 개발자는 SQL을 직접 작성하지 않고도 객체를 이용하여 데이터를 조회할 수 있다는 점을 학습하였다.</p>
<hr>
<h3 id="repository-추상화">Repository 추상화</h3>
<p>이번 프로젝트에서는 Repository를 단순히 JpaRepository만 사용하는 것이 아니라</p>
<pre><code class="language-text">MovieRepository

↓

MovieRepositoryImpl

↓

MovieJpaRepository</code></pre>
<p>와 같이 여러 계층으로 분리하였다.</p>
<p>Controller는</p>
<pre><code class="language-java">private final MovieRepository movieRepository;</code></pre>
<p>만 알고 있다.</p>
<p>실제로 데이터베이스에 접근하는 코드는</p>
<pre><code class="language-text">MovieRepositoryImpl</code></pre>
<p>이 담당한다.</p>
<p>즉,</p>
<pre><code class="language-text">Controller

↓

MovieRepository (인터페이스)

↓

MovieRepositoryImpl

↓

JpaRepository

↓

Database</code></pre>
<p>순서로 동작한다.</p>
<hr>
<h3 id="repository를-분리하는-이유">Repository를 분리하는 이유</h3>
<p>예를 들어 현재는 JPA를 사용하지만</p>
<p>나중에 JPA에서 MyBatis로 변경해야 하는 상황이 발생할 수도 있다.</p>
<p>이 경우</p>
<p>Controller는</p>
<pre><code class="language-java">movieRepository.findAll();</code></pre>
<p>만 사용하고 있기 때문에</p>
<p>구현체만 변경하면 된다.</p>
<p>즉,</p>
<p><strong>구현보다 인터페이스에 의존하도록 설계하는 객체지향 원칙(DIP)</strong> 을 적용한 구조라는 점을 이해하였다.</p>
<hr>
<h3 id="service-계층의-역할">Service 계층의 역할</h3>
<p>이번 프로젝트에서는 Controller가 Repository를 직접 호출하지 않고 Service를 거쳐 데이터를 처리하였다.</p>
<p>전체 구조는 다음과 같다.</p>
<pre><code class="language-text">사용자 요청

↓

Controller

↓

MovieService

↓

Repository

↓

Database</code></pre>
<hr>
<h3 id="service를-사용하는-이유">Service를 사용하는 이유</h3>
<p>Controller의 역할은</p>
<ul>
<li>요청 받기</li>
<li>화면 반환</li>
</ul>
<p>이다.</p>
<p>반면</p>
<p>Service는</p>
<ul>
<li>비즈니스 로직 수행</li>
<li>데이터 가공</li>
<li>여러 Repository 호출</li>
</ul>
<p>등을 담당한다.</p>
<p>예를 들어 영화 등록 기능은</p>
<pre><code class="language-text">사용자 입력

↓

Validation 성공

↓

MovieFormDTO

↓

Entity 변환

↓

Service

↓

Repository

↓

Database 저장</code></pre>
<p>순서로 동작한다.</p>
<p>이처럼 Controller와 Service의 역할을 분리하면 각 계층이 하나의 책임만 가지게 되어 코드의 가독성과 유지보수성이 향상된다는 점을 학습하였다.</p>
<hr>
<h1 id="optionalorelsethrow">Optional.orElseThrow()</h1>
<p>영화를 조회할 때는</p>
<pre><code class="language-java">findById(id)

.orElseThrow(...)</code></pre>
<p>방식을 사용하였다.</p>
<p><code>findById()</code>는</p>
<p>결과가 없을 수도 있기 때문에</p>
<p><code>Optional&lt;MovieEntity&gt;</code>를 반환한다.</p>
<p>예를 들어</p>
<pre><code class="language-text">ID = 1

↓

데이터 존재

↓

MovieEntity 반환</code></pre>
<p>하지만</p>
<pre><code class="language-text">ID = 999

↓

데이터 없음

↓

Optional.empty()</code></pre>
<p>가 된다.</p>
<p>이때</p>
<pre><code class="language-java">.orElseThrow(...)</code></pre>
<p>를 사용하면</p>
<pre><code class="language-text">Optional.empty()

↓

NoMovieException 발생</code></pre>
<p>하도록 구현할 수 있다.</p>
<p>이를 통해 null을 직접 비교하지 않고도 안전하게 예외를 처리하는 방법을 학습하였다.</p>
<hr>
<h3 id="custom-exceptionnomovieexception">Custom Exception(NoMovieException)</h3>
<p>이번 프로젝트에서는 영화가 존재하지 않을 경우를 처리하기 위해 <strong>Custom Exception</strong>을 생성하였다.</p>
<pre><code class="language-java">@ResponseStatus(HttpStatus.NOT_FOUND)
public class NoMovieException extends RuntimeException {

    public NoMovieException(Long movieId) {
        super(&quot;해당 영화를 찾을 수 없습니다. id=&quot; + movieId);
    }

}</code></pre>
<p>일반적으로 영화를 조회할 때</p>
<pre><code class="language-text">ID = 1</code></pre>
<p>이라면</p>
<p>정상적으로 데이터를 반환한다.</p>
<p>하지만</p>
<pre><code class="language-text">ID = 999</code></pre>
<p>처럼 존재하지 않는 ID를 요청하면</p>
<p>MovieEntity가 존재하지 않는다.</p>
<p>이때</p>
<pre><code class="language-java">.orElseThrow(
    () -&gt; new NoMovieException(id)
);</code></pre>
<p>를 호출하면</p>
<p>동작 과정은 다음과 같다.</p>
<pre><code class="language-text">사용자 요청

↓

findById()

↓

Optional.empty()

↓

NoMovieException 발생

↓

Spring Exception 처리</code></pre>
<p>이처럼 <strong>비즈니스 상황에 맞는 예외를 직접 정의</strong>하여 사용할 수 있다는 점을 학습하였다.</p>
<hr>
<h3 id="responsestatus">@ResponseStatus</h3>
<p>Custom Exception에는</p>
<pre><code class="language-java">@ResponseStatus(HttpStatus.NOT_FOUND)</code></pre>
<p>을 지정하였다.</p>
<p><code>@ResponseStatus</code>는 예외가 발생했을 때 어떤 HTTP 상태 코드를 반환할지 지정하는 Annotation이다.</p>
<p>예를 들어</p>
<pre><code class="language-text">NoMovieException</code></pre>
<p>이 발생하면</p>
<p>Spring은 자동으로</p>
<pre><code class="language-text">HTTP 404</code></pre>
<p>를 반환한다.</p>
<pre><code class="language-text">예외 발생

↓

@ResponseStatus 확인

↓

404 상태 코드 설정

↓

Error Page 이동</code></pre>
<p>이를 통해 예외와 HTTP 상태 코드를 자연스럽게 연결하는 방법을 이해하였다.</p>
<hr>
<h3 id="exceptionhandler를-이용한-예외-처리">@ExceptionHandler를 이용한 예외 처리</h3>
<p>이번 프로젝트에서는 특정 Controller 내부에서 발생한 예외를 처리하기 위해 <code>@ExceptionHandler</code>를 사용할 수 있다는 점도 학습하였다.</p>
<p>예를 들어</p>
<pre><code class="language-java">@ExceptionHandler(NoMovieException.class)
public String handleMovieException() {

    return &quot;errors/404&quot;;

}</code></pre>
<p>과 같은 형태로 사용할 수 있다.</p>
<pre><code class="language-text">Controller 실행

↓

NoMovieException 발생

↓

@ExceptionHandler 탐색

↓

404 화면 반환</code></pre>
<p>즉, Controller 내부에서 발생한 특정 예외를 원하는 화면으로 연결할 수 있다.</p>
<hr>
<h3 id="controlleradvice를-이용한-공통-예외-처리">@ControllerAdvice를 이용한 공통 예외 처리</h3>
<p>프로젝트에서는 예외 처리를 하나의 Controller마다 작성하는 것이 아니라 <strong>공통으로 관리하는 구조</strong>도 함께 학습하였다.</p>
<p>동작 과정은 다음과 같다.</p>
<pre><code class="language-text">MovieController

↓

MemberController

↓

OrderController

↓

ControllerAdvice

↓

공통 Exception 처리</code></pre>
<p>예를 들어</p>
<p>모든 Controller에서</p>
<pre><code class="language-text">NoMovieException</code></pre>
<p>이 발생할 수 있다.</p>
<p>각 Controller마다</p>
<pre><code class="language-java">@ExceptionHandler(...)</code></pre>
<p>를 작성하면</p>
<p>같은 코드가 계속 반복된다.</p>
<p>그래서</p>
<p><code>@ControllerAdvice</code>를 사용하면</p>
<pre><code class="language-text">모든 Controller

↓

예외 발생

↓

ControllerAdvice

↓

공통 처리</code></pre>
<p>구조가 된다.</p>
<p>이를 통해 예외 처리 코드도 중복 없이 관리할 수 있다는 점을 학습하였다.</p>
<hr>
<h3 id="error-page-구성">Error Page 구성</h3>
<p>이번 프로젝트에서는 다양한 HTTP 상태 코드에 맞는 Error Page도 구현하였다.</p>
<p>프로젝트에는</p>
<ul>
<li><p>404.html</p>
</li>
<li><p>409.html</p>
</li>
<li><p>500.html</p>
</li>
<li><p>5xx.html</p>
</li>
</ul>
<p>등이 존재하였다.</p>
<hr>
<h3 id="404-error">404 Error</h3>
<p>404는</p>
<pre><code class="language-text">존재하지 않는 데이터</code></pre>
<p>또는</p>
<pre><code class="language-text">잘못된 URL</code></pre>
<p>을 의미한다.</p>
<p>예를 들어</p>
<pre><code class="language-text">/movies/999</code></pre>
<p>를 요청하면</p>
<pre><code class="language-text">Movie 없음

↓

404</code></pre>
<p>이 발생한다.</p>
<hr>
<h3 id="409-error">409 Error</h3>
<p>409는</p>
<pre><code class="language-text">Conflict</code></pre>
<p>상태이다.</p>
<p>예를 들어</p>
<p>중복된 영화 등록처럼</p>
<p>비즈니스 규칙이 충돌하는 상황에서 사용할 수 있다.</p>
<hr>
<h3 id="500-error">500 Error</h3>
<p>500은</p>
<p>예상하지 못한 서버 오류를 의미한다.</p>
<p>예를 들어</p>
<pre><code class="language-text">NullPointerException

SQLException</code></pre>
<p>등이 발생하면</p>
<p>500 오류가 반환된다.</p>
<hr>
<h3 id="5xx-error">5xx Error</h3>
<p>5xx는</p>
<p>500번대 오류를 공통으로 처리하는 페이지이다.</p>
<p>특정 상태 코드에 해당하는 페이지가 없으면</p>
<p>5xx.html이 대신 출력된다.</p>
<p>이를 통해 사용자에게 시스템 오류가 발생했다는 사실을 친절하게 안내할 수 있다는 점을 이해하였다.</p>
<hr>
<h3 id="dispatcherservlet-이후-exception-처리-흐름">DispatcherServlet 이후 Exception 처리 흐름</h3>
<p>이번 프로젝트에서는 예외가 발생했을 때 Spring MVC 내부에서 어떤 순서로 처리되는지도 학습하였다.</p>
<p>전체 흐름은 다음과 같다.</p>
<pre><code class="language-text">브라우저 요청

↓

DispatcherServlet

↓

Controller

↓

Service

↓

Repository

↓

예외 발생

↓

ExceptionResolver

↓

@ControllerAdvice

↓

Error Controller

↓

Error Page

↓

브라우저</code></pre>
<p>즉,</p>
<p>예외가 발생하더라도 바로 프로그램이 종료되는 것이 아니라</p>
<p>Spring MVC가 예외를 가로채어 적절한 화면을 반환한다는 점을 이해하였다.</p>
<hr>
<h3 id="ssrserver-side-rendering에서의-예외-처리">SSR(Server Side Rendering)에서의 예외 처리</h3>
<p>이번 프로젝트는 Thymeleaf를 사용하는 <strong>SSR(Server Side Rendering)</strong> 방식이다.</p>
<p>따라서</p>
<p>예외가 발생하면</p>
<p>JSON이 아니라</p>
<p>HTML을 반환한다.</p>
<p>예를 들어</p>
<pre><code class="language-text">영화 조회

↓

NoMovieException

↓

404.html 생성

↓

브라우저 출력</code></pre>
<p>처럼 동작한다.</p>
<p>REST API였다면</p>
<pre><code class="language-json">{
  &quot;status&quot;:404,
  &quot;message&quot;:&quot;Movie Not Found&quot;
}</code></pre>
<p>를 반환했겠지만</p>
<p>SSR에서는 사용자에게 보여 줄 HTML 페이지를 반환한다는 차이를 이해하였다.</p>
<hr>
<h3 id="bindingresult와-exception의-차이">BindingResult와 Exception의 차이</h3>
<p>이번 프로젝트를 진행하면서 Validation과 Exception의 차이도 명확하게 이해할 수 있었다.</p>
<table>
<thead>
<tr>
<th>BindingResult</th>
<th>Exception</th>
</tr>
</thead>
<tbody><tr>
<td>사용자의 입력 오류 처리</td>
<td>프로그램 실행 중 예외 처리</td>
</tr>
<tr>
<td>Validation 실패</td>
<td>시스템 또는 비즈니스 오류</td>
</tr>
<tr>
<td>Form 화면 유지</td>
<td>Error Page 이동</td>
</tr>
<tr>
<td>Controller 내부에서 처리</td>
<td>Spring Exception Resolver가 처리</td>
</tr>
</tbody></table>
<p>예를 들어</p>
<p>사용자가</p>
<pre><code class="language-text">가격 = -1000</code></pre>
<p>을 입력하면</p>
<p>Validation 실패이므로</p>
<p>BindingResult가 처리한다.</p>
<p>반면</p>
<pre><code class="language-text">없는 영화 조회</code></pre>
<p>는</p>
<p>Validation 문제가 아니라</p>
<p>비즈니스 예외이므로</p>
<p>NoMovieException이 발생한다.</p>
<p>즉,</p>
<p>Validation과 Exception은 서로 다른 목적을 가진다는 점을 이해하였다.</p>
<hr>
<h3 id="직면한-문제와-해결-과정">직면한 문제와 해결 과정</h3>
<table>
<thead>
<tr>
<th>직면한 문제</th>
<th>해결 방법</th>
<th>이를 통해 배운 점</th>
</tr>
</thead>
<tbody><tr>
<td>Validation과 Exception의 차이가 헷갈렸다.</td>
<td>사용자 입력 오류는 <code>BindingResult</code>, 시스템 및 비즈니스 오류는 Exception으로 구분하였다.</td>
<td>입력 검증과 예외 처리는 목적과 처리 방식이 다르다는 점을 이해하였다.</td>
</tr>
<tr>
<td>Error Page가 자동으로 어떻게 연결되는지 이해하기 어려웠다.</td>
<td><code>@ResponseStatus</code>, <code>ExceptionResolver</code>, <code>ControllerAdvice</code>의 동작 과정을 정리하였다.</td>
<td>Spring MVC가 예외를 자동으로 처리하여 적절한 오류 페이지를 반환한다는 점을 배웠다.</td>
</tr>
</tbody></table>
<hr>
<h3 id="느낀-점">느낀 점</h3>
<p>이번 프로젝트를 통해 <strong>기능 구현뿐만 아니라 유지보수성과 사용자 경험을 고려한 설계가 매우 중요하다</strong>는 점을 배울 수 있었다. Thymeleaf Fragment를 활용하여 공통 화면을 재사용하여 코드의 중복을 줄이는 방법을 익혔다.</p>
<p>특히 예외 처리 부분에서는 <code>Optional.orElseThrow()</code>를 이용한 안전한 조회, <code>Custom Exception</code>과 <code>@ControllerAdvice</code>를 활용한 공통 예외 처리, 그리고 <code>@ResponseStatus</code>와 오류 페이지를 이용한 사용자 친화적인 오류 응답을 구현하면서 <strong>정상적인 기능 구현뿐 아니라 예외 상황까지 고려하는 것이 완성도 높은 애플리케이션을 만드는 중요한 요소</strong>라는 점을 배울 수 있었다. 이번 프로젝트를 통해 Spring MVC의 요청 처리부터 예외 처리까지 전체 흐름을 하나의 구조로 이해할 수 있었고, 앞으로도 <strong>재사용성과 확장성을 고려한 설계와 사용자 중심의 예외 처리</strong>를 지속적으로 적용해야겠다는 점을 느꼈다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26/08/06 TIL - 레이아웃과 Thymeleaf Fragment, 서버사이드 예외처리 (1)]]></title>
            <link>https://velog.io/@baeksh_8/260805-ILI-Learned-Thymeleaf-Fragment</link>
            <guid>https://velog.io/@baeksh_8/260805-ILI-Learned-Thymeleaf-Fragment</guid>
            <pubDate>Thu, 06 Aug 2026 08:54:58 GMT</pubDate>
            <description><![CDATA[<h2 id="thymeleaf-fragment와-layout을-활용한-화면-구성-및-dto-기반-crud-화면-설계">Thymeleaf Fragment와 Layout을 활용한 화면 구성 및 DTO 기반 CRUD 화면 설계</h2>
<p>이번 프로젝트에서는 기존의 Spring MVC 프로젝트를 확장하여 <strong>Thymeleaf Fragment를 활용한 화면 재사용</strong>, <strong>공통 Layout 구성</strong>, <strong>DTO를 활용한 화면과 도메인 객체의 분리</strong>, 그리고 <strong>영화(Movie) CRUD 화면 구성</strong>을 학습하였다.</p>
<p>이전에는 HTML 페이지마다 <code>&lt;head&gt;</code>, <code>&lt;nav&gt;</code>, 오류 출력 영역 등을 각각 작성하였다면, 이번 프로젝트에서는 이러한 공통 요소를 <strong>Fragment</strong>로 분리하여 필요한 화면에서 재사용하는 구조를 구현하였다.</p>
<p>또한 사용자가 입력하는 데이터를 처리하기 위한 <code>MovieFormDTO</code>와 화면에 출력하기 위한 <code>MovieViewDTO</code>를 분리하여 사용하면서 <strong>DTO를 목적에 따라 구분하는 방법</strong>도 학습하였다.</p>
<p>이를 통해 화면 구성의 중복을 줄이고, 유지보수성을 높이며, Controller와 View가 역할에 맞게 동작하는 구조를 이해할 수 있었다.</p>
<hr>
<h3 id="fragment프래그먼트의-개념">Fragment(프래그먼트)의 개념</h3>
<p>이번 프로젝트에서 가장 먼저 새롭게 학습한 내용은 <strong>Fragment</strong>이다.</p>
<p>Fragment란 여러 HTML 화면에서 반복적으로 사용되는 부분을 하나의 파일로 분리하여 재사용하는 기능이다.</p>
<p>예를 들어 모든 페이지에는 다음과 같은 요소가 반복된다.</p>
<ul>
<li><code>&lt;head&gt;</code></li>
<li>Navigation Bar</li>
<li>Footer</li>
<li>Validation Error</li>
<li>공통 CSS</li>
<li>공통 JavaScript</li>
</ul>
<p>기존 방식이라면</p>
<pre><code class="language-text">list.html

↓

head 작성

↓

nav 작성

↓

body 작성

──────────────

detail.html

↓

head 작성

↓

nav 작성

↓

body 작성</code></pre>
<p>처럼 모든 HTML에 같은 내용을 복사해야 한다.</p>
<p>이 방식은 Navigation 메뉴 하나를 수정하더라도 모든 HTML을 수정해야 한다.</p>
<p>이번 프로젝트에서는 이러한 문제를 해결하기 위해 Fragment를 사용하였다.</p>
<pre><code class="language-text">fragments

↓

head.html

↓

nav.html

↓

errorMsg.html

↓

각 화면에서 참조</code></pre>
<p>즉, 공통 화면을 한 곳에서 관리하고 필요한 화면에서 불러오는 구조를 학습하였다. </p>
<hr>
<h3 id="thfragment를-이용한-화면-조각-정의">th:fragment를 이용한 화면 조각 정의</h3>
<p>Fragment는 <code>th:fragment</code>를 이용하여 정의하였다.</p>
<p>예를 들어 Navigation을 하나의 Fragment로 정의하면</p>
<pre><code class="language-html">&lt;nav th:fragment=&quot;mainNav&quot;&gt;

    &lt;a th:href=&quot;@{/movies}&quot;&gt;
        영화 목록
    &lt;/a&gt;

&lt;/nav&gt;</code></pre>
<p>(프로젝트 구조와 동일한 형태)</p>
<p><code>th:fragment=&quot;mainNav&quot;</code>은</p>
<p>현재 HTML의 <code>&lt;nav&gt;</code> 태그를</p>
<p><strong>mainNav라는 이름의 재사용 가능한 조각으로 등록</strong>한다.</p>
<pre><code class="language-text">Spring Boot 실행

↓

fragments/nav.html

↓

mainNav 등록

↓

다른 HTML에서 참조 가능</code></pre>
<p>이렇게 등록된 Fragment는</p>
<p>목록 화면</p>
<p>↓</p>
<p>상세 화면</p>
<p>↓</p>
<p>등록 화면</p>
<p>↓</p>
<p>수정 화면</p>
<p>모두 동일하게 사용할 수 있다.</p>
<p>즉, Navigation을 한 번만 작성하면 프로젝트 전체에서 재사용할 수 있다는 점을 학습하였다. </p>
<hr>
<h3 id="threplace와-thinsert의-차이">th:replace와 th:insert의 차이</h3>
<p>Fragment를 사용하는 방법에는 여러 가지가 있지만 이번 프로젝트에서는 <code>th:replace</code>와 <code>th:insert</code>의 차이를 함께 학습하였다.</p>
<p>예를 들어</p>
<pre><code class="language-html">&lt;div
th:replace=&quot;~{fragments/nav :: mainNav}&quot;&gt;
&lt;/div&gt;</code></pre>
<p>과</p>
<pre><code class="language-html">&lt;div
th:insert=&quot;~{fragments/nav :: mainNav}&quot;&gt;
&lt;/div&gt;</code></pre>
<p>은 결과가 서로 다르다.</p>
<h3 id="threplace">th:replace</h3>
<pre><code class="language-text">div 태그

↓

삭제

↓

Fragment로 교체</code></pre>
<p>즉,</p>
<p>원래 <code>&lt;div&gt;</code>는 사라지고</p>
<p>Fragment 자체가 그 위치를 대신한다.</p>
<p>예를 들면</p>
<pre><code class="language-html">&lt;div
th:replace=&quot;...&quot;&gt;
&lt;/div&gt;</code></pre>
<p>↓</p>
<p>렌더링 후</p>
<pre><code class="language-html">&lt;nav&gt;

...

&lt;/nav&gt;</code></pre>
<p>가 된다.</p>
<hr>
<h3 id="thinsert">th:insert</h3>
<p>반대로</p>
<pre><code class="language-text">div 태그

↓

유지

↓

안쪽에 Fragment 삽입</code></pre>
<p>된다.</p>
<p>렌더링 후에는</p>
<pre><code class="language-html">&lt;div&gt;

&lt;nav&gt;

...

&lt;/nav&gt;

&lt;/div&gt;</code></pre>
<p>구조가 된다.</p>
<table>
<thead>
<tr>
<th>속성</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td>th:replace</td>
<td>기존 태그 제거 후 Fragment로 교체</td>
</tr>
<tr>
<td>th:insert</td>
<td>기존 태그 유지 후 내부에 Fragment 삽입</td>
</tr>
</tbody></table>
<p>프로젝트에서는 불필요한 태그를 남기지 않기 위해 대부분 <code>th:replace</code>를 사용하는 것이 적합하다는 점을 이해하였다. </p>
<hr>
<h3 id="공통-head-fragment-구성">공통 Head Fragment 구성</h3>
<p>모든 화면에는</p>
<ul>
<li>title</li>
<li>charset</li>
<li>css</li>
<li>javascript</li>
</ul>
<p>등이 반복된다.</p>
<p>이번 프로젝트에서는 이를 하나의 <code>head.html</code> Fragment로 분리하였다.</p>
<p>기존에는 head 태그 부분이 모든 html에 반복되었다.</p>
<p>이 head 태그 부분을 분리하여 다른 html이 분리된 Fragment를 쓰는
구조로 변경하였다.</p>
<p>이렇게 하면</p>
<p>CSS를 하나 추가할 때</p>
<p>모든 HTML을 수정하는 것이 아니라</p>
<p><code>head.html</code> 하나만 수정하면 된다.</p>
<p>이를 통해 <strong>중복 코드를 줄이는 가장 좋은 방법은 공통 부분을 분리하는 것</strong>이라는 점을 배웠다.</p>
<hr>
<h3 id="navigation-fragment-구성">Navigation Fragment 구성</h3>
<p>프로젝트의 Navigation 역시 Fragment로 관리하였다.</p>
<p>Navigation에는</p>
<ul>
<li><p>영화 목록</p>
</li>
<li><p>영화 등록</p>
</li>
</ul>
<p>등 공통 메뉴가 존재하였다.</p>
<p>동작 구조는</p>
<pre><code class="language-text">사용자 요청

↓

HTML

↓

th:replace

↓

nav Fragment

↓

최종 HTML 생성</code></pre>
<p>이다.</p>
<p>따라서 메뉴를 수정하더라도</p>
<p>Navigation Fragment 하나만 수정하면</p>
<p>모든 화면에 자동으로 반영된다.</p>
<p>이는 유지보수성과 확장성을 크게 높여 주는 구조라는 점을 이해하였다.</p>
<hr>
<h3 id="validation-오류-출력-fragmenterrormsg">Validation 오류 출력 Fragment(errorMsg)</h3>
<p>이번 프로젝트에서는 Validation 오류도 Fragment로 분리하였다.</p>
<p>기존에는</p>
<p>모든 Form마다 오류 출력 블록을 계속 작성하였다.</p>
<p>이번 프로젝트에서는 errorMsg Fragment를 등록 화면과 수정 화면에 재사용하는 구조를 사용하였다.</p>
<p>이렇게 하면 Validation을 사용하는 Form이 늘어나더라도</p>
<p>동일한 Fragment를 가져다 사용할 수 있다.</p>
<p>즉, 오류 출력 방식이 변경되더라도 Fragment 하나만 수정하면 된다는 점을 학습하였다.</p>
<hr>
<h3 id="layout을-이용한-화면-구성">Layout을 이용한 화면 구성</h3>
<p>이번 프로젝트에서는 화면을</p>
<ul>
<li><p>Header</p>
</li>
<li><p>Navigation</p>
</li>
<li><p>Main</p>
</li>
<li><p>Footer</p>
</li>
</ul>
<p>처럼 여러 부분으로 나누어 구성하였다.</p>
<p>전체 구조는 다음과 같다.</p>
<pre><code class="language-text">Head Fragment

↓

Navigation Fragment

↓

Body

↓

Footer</code></pre>
<p>각 화면은 필요한 Body만 작성하고</p>
<p>공통 부분은 Fragment에서 가져온다.</p>
<p>예를 들어</p>
<pre><code class="language-text">Movie List

↓

Head

↓

Nav

↓

Movie Table

────────────

Movie Detail

↓

Head

↓

Nav

↓

Movie Info</code></pre>
<p>처럼 Body만 달라지고</p>
<p>나머지는 모두 동일하게 유지된다.</p>
<p>이를 통해 <strong>레이아웃과 실제 화면을 분리하는 구조</strong>를 학습하였다. </p>
<hr>
<h3 id="validation과-fragment의-연결">Validation과 Fragment의 연결</h3>
<p>등록 화면에서는</p>
<p>Validation이 실패하면</p>
<pre><code class="language-text">MovieFormDTO

↓

BindingResult

↓

Error Fragment

↓

오류 출력</code></pre>
<p>순서로 동작한다.</p>
<p>즉</p>
<p>Controller는</p>
<p>오류를 생성하기만 하고</p>
<p>실제 화면 출력은 Fragment가 담당한다.</p>
<p>이를 통해 Controller와 View의 역할을 명확하게 분리하는 구조를 이해하였다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26/08/05 IL(I Learned) - Spring Boot 설정 관리와 JPA Auditing]]></title>
            <link>https://velog.io/@baeksh_8/260805-ILI-Learned-Spring-Boot-%EC%84%A4%EC%A0%95-%EA%B4%80%EB%A6%AC%EC%99%80-JPA-Auditing</link>
            <guid>https://velog.io/@baeksh_8/260805-ILI-Learned-Spring-Boot-%EC%84%A4%EC%A0%95-%EA%B4%80%EB%A6%AC%EC%99%80-JPA-Auditing</guid>
            <pubDate>Thu, 06 Aug 2026 08:27:14 GMT</pubDate>
            <description><![CDATA[<h2 id="spring-boot-설정-관리와-jpa-auditing을-활용한-공통-기능-설계">Spring Boot 설정 관리와 JPA Auditing을 활용한 공통 기능 설계</h2>
<p>이번 프로젝트에서는 단순히 데이터를 저장하고 조회하는 기능을 구현하는 것을 넘어, <strong>프로젝트에서 반복적으로 사용되는 공통 기능을 효율적으로 관리하는 방법</strong>을 학습하였다.</p>
<p>특히 Spring Boot의 <code>@ConfigurationProperties</code>를 활용하여 설정 파일의 값을 객체로 관리하는 방법과, JPA Auditing을 이용하여 엔티티의 생성 시간과 수정 시간을 자동으로 관리하는 기능을 구현하였다.</p>
<p>또한 Repository를 인터페이스와 구현체로 분리하는 구조와 <code>@Transactional</code>을 이용한 트랜잭션 관리까지 학습하면서, <strong>프로젝트의 유지보수성과 확장성을 고려한 설계 방법</strong>을 이해할 수 있었다.</p>
<hr>
<h3 id="configurationproperties를-이용한-설정-객체-관리">@ConfigurationProperties를 이용한 설정 객체 관리</h3>
<p>Spring Boot에서는 데이터베이스 정보, API Key, 프로젝트 이름 등 다양한 설정을 <code>application.yml</code>에 작성한다.</p>
<p>이러한 설정을 하나의 객체로 관리하기 위해 <code>@ConfigurationProperties</code>를 사용하였다.</p>
<pre><code class="language-java">@ConfigurationProperties(prefix = &quot;app&quot;)
@Validated
public record AppProperties(
        @NotBlank String name,
        @NotBlank String version
) {
}</code></pre>
<p><code>prefix = &quot;app&quot;</code>은 설정 파일에서 <code>app</code>으로 시작하는 값을 하나의 객체에 자동으로 연결(Binding)한다.</p>
<p>예를 들어 설정 파일에 다음과 같이 작성되어 있다고 가정하자.</p>
<pre><code class="language-yaml">app:
  name: Library
  version: 1.0</code></pre>
<p>애플리케이션이 실행되면 Spring Boot는</p>
<pre><code class="language-text">application.yml

↓

app.name

↓

AppProperties.name

────────────

app.version

↓

AppProperties.version</code></pre>
<p>순서로 값을 읽어 객체를 생성한다.</p>
<p>따라서 Controller나 Service에서는</p>
<pre><code class="language-java">appProperties.name()</code></pre>
<p>처럼 메서드를 호출하여 설정 값을 사용할 수 있다.</p>
<hr>
<h2 id="객체로-관리하는-이유">객체로 관리하는 이유</h2>
<p>설정이 하나만 있다면</p>
<pre><code class="language-java">@Value(&quot;${app.name}&quot;)</code></pre>
<p>만으로도 충분하다.</p>
<p>하지만</p>
<pre><code class="language-yaml">app:
  name:
  version:
  company:
  description:
  email:</code></pre>
<p>처럼 설정이 많아질 경우</p>
<p>각각 <code>@Value</code>를 사용하는 것보다 하나의 객체로 관리하는 것이 훨씬 편리하다.</p>
<hr>
<h3 id="validated를-이용한-설정-검증">@Validated를 이용한 설정 검증</h3>
<p>이번 프로젝트에서는 설정 객체에도 Validation을 적용하였다.</p>
<pre><code class="language-java">@Validated</code></pre>
<p>일반적으로 Validation은 사용자의 입력(Form)에만 사용하는 것으로 생각하기 쉽다.</p>
<p>하지만 설정 파일도 잘못 작성될 수 있다.</p>
<p>예를 들어</p>
<pre><code class="language-yaml">app:
  name:</code></pre>
<p>처럼 프로젝트 이름이 비어 있다면</p>
<p>애플리케이션이 정상적으로 동작하지 않을 수도 있다.</p>
<p>이때</p>
<pre><code class="language-java">@NotBlank</code></pre>
<p>를 적용하면</p>
<p>Spring Boot가 실행되는 순간</p>
<pre><code class="language-text">application.yml

↓

Validation

↓

실패

↓

Application 종료</code></pre>
<p>가 된다.</p>
<p>즉, 잘못된 설정으로 서버가 실행되는 것을 미리 방지할 수 있다는 점을 새롭게 학습하였다.</p>
<hr>
<h3 id="configurationpropertiesscan의-역할">@ConfigurationPropertiesScan의 역할</h3>
<p>설정 객체를 자동으로 등록하기 위해</p>
<p>프로젝트에서는</p>
<pre><code class="language-java">@ConfigurationPropertiesScan</code></pre>
<p>을 사용하였다.</p>
<p>Spring Boot가 시작되면</p>
<pre><code class="language-text">@SpringBootApplication

↓

@ConfigurationPropertiesScan

↓

@ConfigurationProperties 검색

↓

Bean 등록

↓

DI 가능</code></pre>
<p>순서로 동작한다.</p>
<p>만약 이 Annotation이 없다면</p>
<p><code>AppProperties</code>가 Bean으로 등록되지 않아</p>
<pre><code class="language-java">@RequiredArgsConstructor</code></pre>
<p>에서 주입받을 수 없다.</p>
<p>따라서 설정 객체를 자동으로 검색하여 Spring Container에 등록하는 역할을 수행한다는 점을 이해하였다.</p>
<hr>
<h3 id="baseentity를-이용한-공통-필드-관리">BaseEntity를 이용한 공통 필드 관리</h3>
<p>프로젝트에는 모든 Entity가 공통으로 사용하는 정보를 관리하기 위한 <code>BaseEntity</code>가 존재하였다.</p>
<pre><code class="language-java">@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {

    @CreatedDate
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime updatedAt;

}</code></pre>
<p>대부분의 데이터는</p>
<ul>
<li>생성일</li>
<li>수정일</li>
</ul>
<p>정보를 가지고 있다.</p>
<p>예를 들어</p>
<table>
<thead>
<tr>
<th>제목</th>
<th>가격</th>
<th>생성일</th>
<th>수정일</th>
</tr>
</thead>
<tbody><tr>
<td>Java</td>
<td>30000</td>
<td>2026-08-05</td>
<td>2026-08-10</td>
</tr>
</tbody></table>
<p>처럼 관리된다.</p>
<p>만약 모든 Entity에</p>
<pre><code class="language-java">private LocalDateTime createdAt;
private LocalDateTime updatedAt;</code></pre>
<p>를 직접 작성한다면</p>
<p>중복 코드가 계속 발생한다.</p>
<p>그래서 공통 기능을 BaseEntity에 작성하고</p>
<pre><code class="language-java">public class BookEntity
        extends BaseEntity</code></pre>
<p>처럼 상속하여 사용하였다.</p>
<hr>
<h3 id="jpa-auditing">JPA Auditing</h3>
<p>이번 프로젝트에서 새롭게 학습한 기능 중 하나는 <strong>JPA Auditing</strong>이었다.</p>
<p>Auditing은 Entity가 생성되거나 수정되는 시간을 자동으로 기록하는 기능이다.</p>
<p>프로젝트에서는</p>
<pre><code class="language-java">@EnableJpaAuditing</code></pre>
<p>을 활성화하였다.</p>
<p>사용자가</p>
<pre><code class="language-text">새 책 등록</code></pre>
<p>을 수행하면</p>
<p>내부적으로</p>
<pre><code class="language-text">Book 저장

↓

Persist

↓

@CreatedDate

↓

현재 시간 저장</code></pre>
<p>이 자동으로 수행된다.</p>
<p>예를 들어</p>
<pre><code class="language-text">2026-08-06 10:30</code></pre>
<p>에 저장했다면</p>
<p>createdAt도</p>
<pre><code class="language-text">2026-08-06 10:30</code></pre>
<p>으로 자동 저장된다.</p>
<p>개발자가</p>
<pre><code class="language-java">entity.setCreatedAt(...)</code></pre>
<p>를 직접 호출하지 않아도 된다.</p>
<hr>
<h3 id="lastmodifieddate">@LastModifiedDate</h3>
<p>책 정보를 수정하면</p>
<pre><code class="language-text">가격 변경

↓

UPDATE

↓

@LastModifiedDate

↓

현재 시간 저장</code></pre>
<p>이 자동으로 수행된다.</p>
<p>예를 들어</p>
<p>기존 데이터가</p>
<table>
<thead>
<tr>
<th>생성일</th>
<th>수정일</th>
</tr>
</thead>
<tbody><tr>
<td>08/01</td>
<td>08/01</td>
</tr>
</tbody></table>
<p>이었다면</p>
<p>가격 수정 후에는</p>
<table>
<thead>
<tr>
<th>생성일</th>
<th>수정일</th>
</tr>
</thead>
<tbody><tr>
<td>08/01</td>
<td>08/06</td>
</tr>
</tbody></table>
<p>처럼 수정일만 변경된다.</p>
<p>이를 통해 변경 이력을 자동으로 관리할 수 있다는 점을 학습하였다.</p>
<hr>
<h3 id="repository-추상화">Repository 추상화</h3>
<p>이번 프로젝트에서는 Repository를 인터페이스와 구현체로 분리하였다.</p>
<pre><code>BookRepository

↓

BookRepositoryImpl

↓

JpaRepository</code></pre><p>Controller는</p>
<pre><code class="language-text">BookRepository</code></pre>
<p>만 알고 있다.</p>
<p>실제 구현은</p>
<pre><code class="language-text">BookRepositoryImpl</code></pre>
<p>이 담당한다.</p>
<p>이 구조를 사용하면</p>
<p>나중에</p>
<pre><code class="language-text">JPA

↓

MyBatis</code></pre>
<p>로 변경하더라도</p>
<p>Controller와 Service는 수정하지 않아도 된다.</p>
<p>즉,</p>
<p>구현보다 인터페이스에 의존하는 구조를 만들 수 있다는 점을 이해하였다.</p>
<hr>
<h3 id="transactional의-역할">@Transactional의 역할</h3>
<p>Service에서는</p>
<pre><code class="language-java">@Transactional</code></pre>
<p>과</p>
<pre><code class="language-java">@Transactional(readOnly = true)</code></pre>
<p>를 함께 사용하였다.</p>
<h3 id="일반-transaction">일반 Transaction</h3>
<pre><code class="language-java">@Transactional</code></pre>
<p>은</p>
<ul>
<li>INSERT</li>
<li>UPDATE</li>
<li>DELETE</li>
</ul>
<p>처럼 데이터를 변경하는 작업에서 사용한다.</p>
<p>동작 과정은</p>
<pre><code class="language-text">Transaction 시작

↓

DB 작업

↓

성공

↓

Commit

────────────

실패

↓

Rollback</code></pre>
<p>이다.</p>
<p>즉, 여러 작업 중 하나라도 실패하면 이전 상태로 되돌려 데이터의 일관성을 유지한다.</p>
<hr>
<h3 id="readonly--true">readOnly = true</h3>
<p>조회 기능에서는</p>
<pre><code class="language-java">@Transactional(readOnly = true)</code></pre>
<p>를 사용하였다.</p>
<p>조회는 데이터를 변경하지 않기 때문에 JPA는 변경 사항을 추적할 필요가 없다.</p>
<p>따라서 Dirty Checking과 같은 작업을 생략하여 불필요한 비용을 줄일 수 있다.</p>
<p>예를 들어</p>
<pre><code class="language-text">도서 목록 조회

↓

SELECT

↓

readOnly Transaction

↓

조회만 수행</code></pre>
<p>처럼 동작하며, 읽기 전용 작업에 적합하다.</p>
<hr>
<h3 id="학습한-내용">학습한 내용</h3>
<table>
<thead>
<tr>
<th>학습 내용</th>
<th>세부 학습 내용</th>
</tr>
</thead>
<tbody><tr>
<td><code>@ConfigurationProperties</code></td>
<td>여러 설정 값을 하나의 객체로 바인딩하여 관리하는 방법</td>
</tr>
<tr>
<td><code>@Validated</code></td>
<td>설정 객체에도 Validation을 적용하여 실행 전에 오류를 검증하는 방법</td>
</tr>
<tr>
<td><code>@ConfigurationPropertiesScan</code></td>
<td>설정 객체를 자동으로 검색하여 Bean으로 등록하는 과정</td>
</tr>
<tr>
<td><code>BaseEntity</code></td>
<td>여러 Entity에서 공통으로 사용하는 필드를 상속으로 관리하는 방법</td>
</tr>
<tr>
<td>JPA Auditing</td>
<td><code>@CreatedDate</code>, <code>@LastModifiedDate</code>를 이용하여 생성일과 수정일을 자동으로 기록하는 기능</td>
</tr>
<tr>
<td>Repository 추상화</td>
<td>인터페이스와 구현체를 분리하여 유지보수성과 확장성을 높이는 구조</td>
</tr>
<tr>
<td><code>@Transactional</code></td>
<td>여러 데이터 변경 작업을 하나의 트랜잭션으로 관리하는 방법</td>
</tr>
<tr>
<td><code>readOnly = true</code></td>
<td>조회 전용 트랜잭션을 사용하여 성능을 최적화하는 방법</td>
</tr>
</tbody></table>
<hr>
<h3 id="느낀-점">느낀 점</h3>
<p>이번 프로젝트를 통해 <strong>애플리케이션은 단순히 기능을 구현하는 것뿐만 아니라, 반복되는 기능을 공통화하고 설정을 체계적으로 관리하는 설계가 중요하다</strong>는 점을 배울 수 있었다. 특히 <code>BaseEntity</code>와 JPA Auditing을 활용하여 생성일과 수정일을 자동으로 관리하는 기능을 구현하면서 중복 코드를 줄이는 방법을 익혔고, <code>@ConfigurationProperties</code>를 통해 설정을 객체로 관리함으로써 유지보수성과 안정성을 높일 수 있다는 점을 이해하였다. 또한 Repository를 인터페이스와 구현체로 분리하는 구조와 <code>@Transactional</code>을 통한 트랜잭션 관리 방식을 학습하면서 <strong>객체지향적인 설계와 데이터의 일관성을 유지하는 방법</strong>을 함께 배울 수 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26/08/05 IL(I Learned) - Spring Validation]]></title>
            <link>https://velog.io/@baeksh_8/260805-ILI-Learned-Spring-Validation</link>
            <guid>https://velog.io/@baeksh_8/260805-ILI-Learned-Spring-Validation</guid>
            <pubDate>Thu, 06 Aug 2026 08:14:38 GMT</pubDate>
            <description><![CDATA[<h2 id="spring-validation을-활용한-form-데이터-검증과-사용자-입력-처리">Spring Validation을 활용한 Form 데이터 검증과 사용자 입력 처리</h2>
<p>이번 프로젝트에서는 사용자가 입력한 데이터를 데이터베이스에 저장하기 전에 <strong>올바른 값인지 검증(Validation)</strong> 하는 방법을 학습하였다. 단순히 Controller에서 데이터를 받아 저장하는 것이 아니라, Bean Validation을 이용하여 입력값의 형식을 검사하고, 검증에 실패했을 경우 사용자에게 오류 메시지를 보여 주면서 다시 입력할 수 있도록 구현하였다.</p>
<p>또한 <code>@Valid</code>, <code>@Validated</code>, <code>BindingResult</code>, Validation Group, Thymeleaf의 오류 출력 기능을 함께 사용하면서 <strong>사용자가 잘못된 데이터를 입력했을 때 서버와 화면이 어떻게 동작하는지</strong>를 이해할 수 있었다.</p>
<p>실제 웹 서비스에서는 회원가입, 로그인, 상품 등록, 게시글 작성과 같이 거의 모든 입력 화면에서 Validation이 사용되므로 이번 프로젝트를 통해 Spring Validation의 전체 흐름을 익힐 수 있었다.</p>
<hr>
<h3 id="validation데이터-검증이-필요한-이유">Validation(데이터 검증)이 필요한 이유</h3>
<p>웹 애플리케이션에서는 사용자가 항상 올바른 값만 입력한다고 보장할 수 없다.</p>
<p>예를 들어 책 등록 화면이 있다고 가정해 보자.</p>
<p>사용자가 다음과 같이 입력할 수도 있다.</p>
<table>
<thead>
<tr>
<th>제목</th>
<th>저자</th>
<th>가격</th>
</tr>
</thead>
<tbody><tr>
<td>(공백)</td>
<td>홍길동</td>
<td>-10000</td>
</tr>
</tbody></table>
<p>또는</p>
<table>
<thead>
<tr>
<th>제목</th>
<th>저자</th>
<th>가격</th>
</tr>
</thead>
<tbody><tr>
<td>가나다라마바사아자차카타파하라마바사아자차카타파하</td>
<td>홍길동</td>
<td>999999999</td>
</tr>
</tbody></table>
<p>이러한 값이 그대로 저장된다면</p>
<ul>
<li>데이터의 신뢰성이 떨어지고</li>
<li>화면에서 오류가 발생하거나</li>
<li>비즈니스 로직이 정상적으로 동작하지 않을 수 있다.</li>
</ul>
<p>그래서 데이터를 저장하기 전에 반드시 검증을 수행해야 한다.</p>
<p>전체 흐름은 다음과 같다.</p>
<pre><code class="language-text">사용자 입력

↓

Controller

↓

Validation

↓

성공

↓

DB 저장

──────────────

실패

↓

오류 메시지 출력

↓

입력 화면 유지</code></pre>
<p>이번 프로젝트에서는 이러한 과정을 Spring Validation이 자동으로 처리하도록 구현하였다.</p>
<hr>
<h3 id="bean-validation-annotation-이해">Bean Validation Annotation 이해</h3>
<p>데이터 검증은 DTO에서 Annotation을 이용하여 정의하였다.</p>
<pre><code class="language-java">@NotBlank
@Size(min = 1, max = 50, message = &quot;{Size.bookForm.title2}&quot;)
private String title;</code></pre>
<pre><code class="language-java">@NotNull
@PositiveOrZero
@Max(value = 1_000_000)
private Integer price;</code></pre>
<p>Spring Validation은 DTO에 선언된 Annotation을 읽어서 자동으로 검증을 수행한다.</p>
<p>예를 들어</p>
<pre><code class="language-java">@NotBlank
private String title;</code></pre>
<p>은</p>
<p>제목이</p>
<pre><code class="language-text">&quot;&quot;

또는

&quot;     &quot;</code></pre>
<p>처럼 공백이면</p>
<p>검증에 실패한다.</p>
<hr>
<h4 id="notblank">@NotBlank</h4>
<p>문자열이</p>
<ul>
<li>null</li>
<li>&quot;&quot;</li>
<li>공백</li>
</ul>
<p>모두 허용하지 않는다.</p>
<p>예를 들어</p>
<table>
<thead>
<tr>
<th>입력값</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td>Java</td>
<td>성공</td>
</tr>
<tr>
<td>&quot;&quot;</td>
<td>실패</td>
</tr>
<tr>
<td>&quot;     &quot;</td>
<td>실패</td>
</tr>
<tr>
<td>null</td>
<td>실패</td>
</tr>
</tbody></table>
<p>회원가입의</p>
<ul>
<li>이름</li>
<li>아이디</li>
<li>제목</li>
</ul>
<p>등에 자주 사용된다.</p>
<hr>
<h4 id="notempty">@NotEmpty</h4>
<pre><code class="language-java">@NotEmpty
private String author;</code></pre>
<p><code>@NotEmpty</code>는</p>
<p>빈 문자열은 허용하지 않지만</p>
<p>공백은 허용한다.</p>
<p>예를 들어</p>
<table>
<thead>
<tr>
<th>입력값</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td>홍길동</td>
<td>성공</td>
</tr>
<tr>
<td>&quot;&quot;</td>
<td>실패</td>
</tr>
<tr>
<td>&quot;     &quot;</td>
<td>성공</td>
</tr>
</tbody></table>
<p>따라서 일반적인 사용자 이름에는 <code>@NotBlank</code>가 더 적합한 경우가 많다.</p>
<hr>
<h4 id="notnull">@NotNull</h4>
<pre><code class="language-java">@NotNull
private Integer price;</code></pre>
<p>null 값만 허용하지 않는다.</p>
<p>예를 들어</p>
<table>
<thead>
<tr>
<th>입력값</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td>10000</td>
<td>성공</td>
</tr>
<tr>
<td>0</td>
<td>성공</td>
</tr>
<tr>
<td>null</td>
<td>실패</td>
</tr>
</tbody></table>
<p>즉</p>
<p>숫자 자체는 허용하지만</p>
<p>값이 존재해야 한다.</p>
<hr>
<h4 id="positiveorzero">@PositiveOrZero</h4>
<pre><code class="language-java">@PositiveOrZero</code></pre>
<p>0 이상만 허용한다.</p>
<p>예를 들어</p>
<table>
<thead>
<tr>
<th>가격</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td>10000</td>
<td>성공</td>
</tr>
<tr>
<td>0</td>
<td>성공</td>
</tr>
<tr>
<td>-1000</td>
<td>실패</td>
</tr>
</tbody></table>
<p>책 가격이 음수가 되는 상황을 방지할 수 있다.</p>
<hr>
<h4 id="max">@Max</h4>
<pre><code class="language-java">@Max(1_000_000)</code></pre>
<p>100만원 이하만 허용한다.</p>
<p>예를 들어</p>
<table>
<thead>
<tr>
<th>가격</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td>10000</td>
<td>성공</td>
</tr>
<tr>
<td>999999</td>
<td>성공</td>
</tr>
<tr>
<td>1500000</td>
<td>실패</td>
</tr>
</tbody></table>
<p>비즈니스 규칙을 Annotation 하나로 표현할 수 있다는 점을 학습하였다.</p>
<hr>
<h3 id="valid를-이용한-자동-검증">@Valid를 이용한 자동 검증</h3>
<p>Controller에서는</p>
<pre><code class="language-java">@PostMapping(&quot;/new&quot;)
public String createBook(

@Valid
@ModelAttribute(&quot;bookForm&quot;)
BookFormDTO bookFormDTO,

BindingResult bindingResult
)</code></pre>
<p>사용자가 등록 버튼을 누르면</p>
<p>Spring은 먼저</p>
<pre><code class="language-text">Form 데이터

↓

BookFormDTO 생성

↓

@Valid

↓

Validation 수행</code></pre>
<p>을 실행한다.</p>
<p>예를 들어</p>
<p>사용자가</p>
<pre><code class="language-text">제목

↓

(공백)</code></pre>
<p>을 입력하면</p>
<p><code>@NotBlank</code>가 실패한다.</p>
<p>그러면</p>
<pre><code class="language-text">@NotBlank 실패

↓

BindingResult 저장

↓

Controller 실행</code></pre>
<p>이 된다.</p>
<p>즉</p>
<p>Controller가 직접</p>
<pre><code class="language-java">if(title == null)</code></pre>
<p>처럼 검사하지 않아도</p>
<p>Spring Validation이 자동으로 검증을 수행한다.</p>
<hr>
<h3 id="bindingresult의-역할">BindingResult의 역할</h3>
<p>Validation이 끝난 후</p>
<p>검증 결과는</p>
<p>BindingResult에 저장된다.</p>
<pre><code class="language-java">if(bindingResult.hasErrors()){

return &quot;form&quot;;

}</code></pre>
<p>BindingResult는</p>
<p>Validation 과정에서 발생한 오류들을 저장하는 객체이다.</p>
<p>예를 들어</p>
<p>사용자가</p>
<pre><code class="language-text">제목

↓

공백</code></pre>
<p>을 입력하면</p>
<p>BindingResult 안에는</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Error</th>
</tr>
</thead>
<tbody><tr>
<td>title</td>
<td>제목은 필수입니다</td>
</tr>
</tbody></table>
<p>가 저장된다.</p>
<p>Controller에서는</p>
<pre><code class="language-java">bindingResult.hasErrors()</code></pre>
<p>만 호출하면</p>
<p>오류가 있는지 쉽게 확인할 수 있다.</p>
<hr>
<h3 id="왜-bindingresult가-바로-뒤에-와야-하는가">왜 BindingResult가 바로 뒤에 와야 하는가?</h3>
<pre><code class="language-java">@Valid BookFormDTO dto,

BindingResult bindingResult</code></pre>
<p>처럼</p>
<p>바로 뒤에 작성해야 한다.</p>
<p>만약</p>
<pre><code class="language-java">@Valid BookFormDTO dto,

Model model,

BindingResult bindingResult</code></pre>
<p>처럼 작성하면</p>
<p>Spring은 Validation 결과를 BindingResult에 저장하지 못한다.</p>
<p>그래서 Validation 예외가 발생하여</p>
<p>400 오류가 발생할 수도 있다.</p>
<p>따라서 <strong>BindingResult는 검증 대상 객체 바로 다음에 위치해야 한다</strong>는 점을 이해하였다.</p>
<hr>
<h3 id="thymeleaf와-validation-연동">Thymeleaf와 Validation 연동</h3>
<p>Validation 오류는</p>
<p>Thymeleaf에서 바로 출력할 수 있었다.</p>
<pre><code class="language-html">&lt;input th:field=&quot;*{title}&quot;&gt;

&lt;p
th:if=&quot;${#fields.hasErrors(&#39;title&#39;)}&quot;
th:errors=&quot;*{title}&quot;&gt;
&lt;/p&gt;</code></pre>
<p>사용자가</p>
<pre><code class="language-text">제목

↓

공백</code></pre>
<p>을 입력하면</p>
<p>Spring은 BindingResult에 오류를 저장한다.</p>
<p>Thymeleaf는</p>
<pre><code class="language-text">BindingResult

↓

#fields

↓

title Error

↓

th:errors

↓

화면 출력</code></pre>
<p>순서로 오류를 출력한다.</p>
<p>예를 들어</p>
<p>화면에는</p>
<pre><code class="language-text">제목은 반드시 입력해야 합니다.</code></pre>
<p>가 자동으로 출력된다.</p>
<p>즉</p>
<p>Controller가</p>
<pre><code class="language-java">model.addAttribute(&quot;error&quot;, ...)</code></pre>
<p>를 작성하지 않아도</p>
<p>BindingResult와 Thymeleaf가 자동으로 연결되어 오류를 보여준다.</p>
<hr>
<h3 id="전역-오류global-error">전역 오류(Global Error)</h3>
<p>필드 하나가 아니라</p>
<p>객체 전체를 검사해야 하는 경우도 있다.</p>
<p>이번 프로젝트에서는</p>
<pre><code class="language-java">bindingResult.reject(

&quot;author.noKimjava&quot;,

&quot;김자바는 등록할 수 없습니다&quot;

);</code></pre>
<p>를 사용하였다.</p>
<p>예를 들어</p>
<pre><code class="language-text">제목

↓

김자바

또는

저자

↓

김자바</code></pre>
<p>인 경우</p>
<p>필드 하나의 문제가 아니라</p>
<p>비즈니스 규칙 자체를 위반한 것이다.</p>
<p>그래서</p>
<pre><code class="language-text">BookFormDTO

↓

전체 검사

↓

Global Error</code></pre>
<p>를 생성하였다.</p>
<p>Thymeleaf에서는</p>
<pre><code class="language-html">&lt;div th:if=&quot;${#fields.hasGlobalErrors()}&quot;&gt;

&lt;p
th:each=&quot;err : ${#fields.globalErrors()}&quot;

th:text=&quot;${err}&quot;&gt;
&lt;/p&gt;

&lt;/div&gt;</code></pre>
<p>를 통해</p>
<p>전역 오류를 화면 상단에 출력하였다.</p>
<hr>
<h3 id="validation-group을-이용한-상황별-검증">Validation Group을 이용한 상황별 검증</h3>
<p>프로젝트를 진행하면서 새롭게 학습한 기능 중 하나는 <strong>Validation Group</strong>이었다.</p>
<p>회원가입이나 상품 등록처럼 <strong>등록(Create)</strong> 과 <strong>수정(Update)</strong> 은 입력받아야 하는 값이 서로 다른 경우가 많다.</p>
<p>예를 들어 책 등록 화면에서는 판매 여부(<code>isAvailable</code>)를 입력받지 않아도 되지만, 수정 화면에서는 판매 여부를 변경할 수 있어야 한다.</p>
<p>이처럼 <strong>같은 DTO를 사용하면서도 상황에 따라 다른 검증 규칙을 적용하기 위해 Validation Group을 사용</strong>하였다.</p>
<pre><code class="language-java">public interface Update {
}</code></pre>
<p>DTO에서는 Group을 지정하여 검증을 수행하였다.</p>
<pre><code class="language-java">@NotNull(groups = Update.class)
private Boolean isAvailable;</code></pre>
<p>Validation Group은 검증을 여러 개의 그룹으로 나누는 기능이다.</p>
<p>예를 들어</p>
<p>등록 화면에서는</p>
<table>
<thead>
<tr>
<th>제목</th>
<th>저자</th>
<th>가격</th>
<th>판매여부</th>
</tr>
</thead>
<tbody><tr>
<td>필수</td>
<td>필수</td>
<td>필수</td>
<td>입력 안 함</td>
</tr>
</tbody></table>
<p>이지만</p>
<p>수정 화면에서는</p>
<table>
<thead>
<tr>
<th>제목</th>
<th>저자</th>
<th>가격</th>
<th>판매여부</th>
</tr>
</thead>
<tbody><tr>
<td>필수</td>
<td>필수</td>
<td>필수</td>
<td>필수</td>
</tr>
</tbody></table>
<p>가 될 수 있다.</p>
<p>이 경우 Validation Group이 없다면</p>
<p>등록 화면에서도 판매 여부를 입력해야 하는 문제가 발생한다.</p>
<p>Validation Group을 적용하면</p>
<pre><code class="language-text">등록 요청

↓

기본 Validation만 수행

──────────────

수정 요청

↓

Update Group Validation 수행</code></pre>
<p>처럼 상황에 따라 필요한 검증만 실행된다.</p>
<p>이를 통해 하나의 DTO를 재사용하면서도 기능에 맞는 검증을 수행할 수 있다는 점을 학습하였다.</p>
<hr>
<h3 id="redirectattributes와-prgpost-redirect-get-패턴">RedirectAttributes와 PRG(Post-Redirect-Get) 패턴</h3>
<p>책을 등록한 후에는 바로 목록 화면으로 이동하였다.</p>
<p>이때 <code>RedirectAttributes</code>를 이용하여 메시지를 전달하였다.</p>
<pre><code class="language-java">redirectAttributes.addFlashAttribute(
        &quot;msg&quot;,
        &quot;책이 등록되었습니다.&quot;
);

return &quot;redirect:/books&quot;;</code></pre>
<p>사용자가 등록 버튼을 누르면</p>
<pre><code class="language-text">POST /books/new</code></pre>
<p>요청이 발생한다.</p>
<p>만약 등록이 끝난 뒤</p>
<pre><code class="language-java">return &quot;index&quot;;</code></pre>
<p>를 반환한다면</p>
<p>브라우저는 여전히 POST 요청 상태를 유지한다.</p>
<p>이 상태에서 사용자가 새로고침(F5)을 누르면</p>
<pre><code class="language-text">POST

↓

새로고침

↓

POST 재실행

↓

데이터 중복 저장</code></pre>
<p>문제가 발생한다.</p>
<p>이러한 문제를 해결하기 위해 <strong>PRG(Post-Redirect-Get)</strong> 패턴을 사용한다.</p>
<p>동작 과정은 다음과 같다.</p>
<pre><code class="language-text">사용자 등록

↓

POST /books/new

↓

DB 저장

↓

redirect:/books

↓

GET /books

↓

목록 화면</code></pre>
<p>이렇게 하면 새로고침을 하더라도 GET 요청만 다시 실행되므로 중복 등록이 발생하지 않는다.</p>
<hr>
<h3 id="flash-attribute의-역할">Flash Attribute의 역할</h3>
<p>Redirect를 수행하면 일반적인 Model 데이터는 전달되지 않는다.</p>
<p>따라서</p>
<pre><code class="language-java">redirectAttributes.addFlashAttribute(
        &quot;msg&quot;,
        &quot;등록 완료&quot;
);</code></pre>
<p>를 사용한다.</p>
<p>Flash Attribute는</p>
<pre><code class="language-text">Redirect 이전

↓

Flash Map 저장

↓

Redirect

↓

한 번만 사용

↓

자동 삭제</code></pre>
<p>순서로 동작한다.</p>
<p>목록 화면에서는</p>
<pre><code class="language-html">&lt;section th:if=&quot;${msg != null}&quot;&gt;
    &lt;p th:text=&quot;${msg}&quot;&gt;&lt;/p&gt;
&lt;/section&gt;</code></pre>
<p>을 통해 메시지를 출력하였다. </p>
<p>즉, &quot;등록되었습니다.&quot;와 같은 알림을 한 번만 보여주고 자동으로 사라지는 기능을 구현할 수 있었다.</p>
<hr>
<h3 id="query-method를-이용한-검색-기능">Query Method를 이용한 검색 기능</h3>
<p>이번 프로젝트에서는 Spring Data JPA의 <strong>Query Method</strong>를 활용하여 검색 기능도 구현하였다.</p>
<p>Repository에는 다음과 같은 메서드가 정의되어 있었다.</p>
<pre><code class="language-java">findAllByTitleContaining(String keyword)</code></pre>
<p>Spring Data JPA는 메서드 이름을 분석하여 SQL을 자동으로 생성한다.</p>
<p>예를 들어</p>
<pre><code class="language-java">findAllByTitleContaining(&quot;Java&quot;)</code></pre>
<p>를 호출하면 내부적으로 다음과 같은 SQL과 유사한 쿼리가 실행된다.</p>
<pre><code class="language-sql">SELECT *
FROM book
WHERE title LIKE &#39;%Java%&#39;;</code></pre>
<p>개발자가 직접 SQL을 작성하지 않아도 메서드 이름만으로 검색 기능을 구현할 수 있다는 점을 학습하였다.</p>
<hr>
<h3 id="실제-동작-과정">실제 동작 과정</h3>
<p>목록 화면에서는 검색창을 제공하였다.</p>
<pre><code class="language-html">&lt;form&gt;
    &lt;label&gt;
        키워드 :
        &lt;input name=&quot;keyword&quot;&gt;
    &lt;/label&gt;

    &lt;button&gt;검색&lt;/button&gt;
&lt;/form&gt;</code></pre>
<p>사용자가</p>
<pre><code class="language-text">Java</code></pre>
<p>를 입력하면</p>
<pre><code class="language-text">브라우저

↓

GET /books?keyword=Java

↓

Controller

↓

Repository

↓

findAllByTitleContaining()

↓

DB 조회

↓

검색 결과 출력</code></pre>
<p>순서로 동작한다.</p>
<p>이를 통해 검색 기능도 Spring MVC와 Repository가 자연스럽게 연결되어 동작한다는 점을 이해하였다.</p>
<hr>
<h3 id="등록-화면과-수정-화면-재사용">등록 화면과 수정 화면 재사용</h3>
<p>이번 프로젝트에서는 등록과 수정 화면을 각각 만들지 않고 <strong>하나의 form.html을 재사용</strong>하였다.</p>
<pre><code class="language-html">th:action=&quot;@{${bookId == null ? &#39;/books/new&#39; : &#39;/books/&#39; + bookId}}&quot;</code></pre>
<p>또한 버튼의 글자도 변경하였다.</p>
<pre><code class="language-html">&lt;button
th:text=&quot;${bookId == null ? &#39;등록&#39; : &#39;수정&#39;}&quot;&gt;
&lt;/button&gt;</code></pre>
<p>등록 화면에서는</p>
<pre><code class="language-text">bookId = null</code></pre>
<p>이므로</p>
<pre><code class="language-text">POST /books/new</code></pre>
<p>으로 전송된다.</p>
<p>반대로 수정 화면에서는</p>
<pre><code class="language-text">bookId = 3</code></pre>
<p>이라면</p>
<pre><code class="language-text">POST /books/3</code></pre>
<p>으로 전송된다.</p>
<p>버튼 역시</p>
<pre><code class="language-text">등록

↓

bookId 없음

──────────────

수정

↓

bookId 존재</code></pre>
<p>처럼 자동으로 변경된다.</p>
<p>하나의 HTML을 재사용함으로써 중복 코드를 줄이고 유지보수를 쉽게 하는 방법을 학습하였다.</p>
<hr>
<h3 id="validation-메시지properties-관리">Validation 메시지(properties) 관리</h3>
<p>이번 프로젝트에서는 Validation 오류 메시지를 코드에 직접 작성하지 않고 <code>messages.properties</code> 파일에서 관리하였다.</p>
<pre><code class="language-properties">NotBlank.bookForm.title=책 제목을 꼭 입력해주세요
Size.bookForm.title=책 제목은 {2} 이상 {1} 이하로 입력해야합니다
Max.bookForm.price={0} 미만의 책 가격이어야 합니다</code></pre>
<p>예를 들어 DTO에서</p>
<pre><code class="language-java">@NotBlank
private String title;</code></pre>
<p>검증이 실패하면 Spring은</p>
<pre><code class="language-text">NotBlank.bookForm.title</code></pre>
<p>이라는 키를 찾는다.</p>
<p>그리고</p>
<pre><code class="language-properties">책 제목을 꼭 입력해주세요</code></pre>
<p>를 읽어 사용자에게 출력한다.</p>
<p>또한</p>
<pre><code class="language-properties">Size.bookForm.title2=책 제목은 {min} 이상 {max} 이하로 입력해야합니다</code></pre>
<p>처럼 <code>{min}</code>, <code>{max}</code>와 같은 플레이스홀더를 사용할 수 있다. </p>
<p>예를 들어</p>
<pre><code class="language-java">@Size(min = 2, max = 30)</code></pre>
<p>이라면 화면에는</p>
<pre><code class="language-text">책 제목은 2 이상 30 이하로 입력해야 합니다.</code></pre>
<p>처럼 실제 값이 자동으로 치환되어 출력된다.</p>
<p>이러한 방식은 메시지를 한 곳에서 관리할 수 있기 때문에 유지보수가 쉽고, 국제화(i18n)와도 자연스럽게 연동할 수 있다는 장점이 있다.</p>
<hr>
<h3 id="학습한-내용">학습한 내용</h3>
<table>
<thead>
<tr>
<th>학습 내용</th>
<th>세부 학습 내용</th>
</tr>
</thead>
<tbody><tr>
<td>Validation Group</td>
<td>등록과 수정처럼 상황에 따라 서로 다른 검증 규칙을 적용하는 방법</td>
</tr>
<tr>
<td>RedirectAttributes</td>
<td>Redirect 이후에도 사용자에게 한 번만 메시지를 전달하는 방법</td>
</tr>
<tr>
<td>PRG 패턴</td>
<td>POST 요청 이후 Redirect를 수행하여 중복 등록을 방지하는 방법</td>
</tr>
<tr>
<td>Query Method</td>
<td>메서드 이름만으로 검색 SQL을 자동 생성하는 방법</td>
</tr>
<tr>
<td>Form 재사용</td>
<td>등록과 수정 화면을 하나의 HTML로 처리하여 중복을 줄이는 방법</td>
</tr>
<tr>
<td>Validation Message</td>
<td><code>messages.properties</code>를 이용하여 검증 메시지를 중앙에서 관리하는 방법</td>
</tr>
<tr>
<td>Thymeleaf Validation</td>
<td><code>#fields.hasErrors()</code>, <code>th:errors</code>를 이용하여 검증 오류를 화면에 출력하는 방법</td>
</tr>
</tbody></table>
<hr>
<h3 id="느낀-점">느낀 점</h3>
<p>이번 프로젝트를 통해 <strong>Validation은 단순히 입력값을 검사하는 기능이 아니라, 사용자가 올바른 데이터를 입력하도록 돕고 애플리케이션의 데이터 무결성을 보장하는 중요한 기능</strong>이라는 점을 이해할 수 있었다. 특히 <code>@Valid</code>와 <code>BindingResult</code>를 이용한 자동 검증, Validation Group을 통한 상황별 검증, <code>RedirectAttributes</code>와 PRG 패턴을 활용한 중복 요청 방지, 그리고 <code>messages.properties</code>를 이용한 검증 메시지 관리까지 구현하면서 실제 웹 애플리케이션에서 사용자 경험과 데이터의 신뢰성을 함께 고려하는 방법을 배울 수 있었다. 또한 하나의 <code>form.html</code>을 등록과 수정 화면에서 함께 사용하는 구조를 구현하며 <strong>중복 코드를 줄이고 유지보수성을 높이는 설계 방식</strong>도 함께 익힐 수 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26/08/04 IL(I Learned) - application-yaml과 외부 설정]]></title>
            <link>https://velog.io/@baeksh_8/260804-ILI-Learned-Thymeleaf-2</link>
            <guid>https://velog.io/@baeksh_8/260804-ILI-Learned-Thymeleaf-2</guid>
            <pubDate>Thu, 06 Aug 2026 07:47:37 GMT</pubDate>
            <description><![CDATA[<h2 id="spring-boot-환경-설정과-국제화i18n를-활용한-애플리케이션-구성">Spring Boot 환경 설정과 국제화(i18n)를 활용한 애플리케이션 구성</h2>
<p>이번 프로젝트에서는 화면을 구성하는 것뿐만 아니라 <strong>Spring Boot 애플리케이션의 환경을 효율적으로 관리하는 방법</strong>을 학습하였다. 하나의 설정 파일에 모든 정보를 작성하는 것이 아니라 설정을 역할별로 분리하고, <code>@ConfigurationProperties</code>를 이용하여 여러 설정 값을 객체로 관리하는 방법을 익혔다.</p>
<p>또한 개발 환경과 운영 환경을 구분하기 위한 <strong>Profile</strong>, 동일한 타입의 Bean이 여러 개 존재할 때 사용할 Bean을 선택하는 <strong><code>@Primary</code>와 <code>@Qualifier</code></strong>, 그리고 사용자의 언어에 따라 다른 화면을 제공하는 <strong>국제화(i18n)</strong> 기능까지 실습하였다.</p>
<p>이번 프로젝트를 통해 단순히 기능을 구현하는 것을 넘어 <strong>프로젝트를 체계적으로 관리하고 확장 가능한 구조를 만드는 방법</strong>을 이해할 수 있었다.</p>
<hr>
<h3 id="applicationyml을-이용한-설정-관리">application.yml을 이용한 설정 관리</h3>
<p>Spring Boot에서는 데이터베이스 정보, 서버 포트, API Key와 같은 다양한 설정을 <code>application.yml</code>(또는 <code>application.properties</code>)에 작성한다.</p>
<p>하지만 프로젝트가 커질수록 하나의 파일에 모든 설정을 작성하면 관리가 어려워진다.</p>
<p>이번 프로젝트에서는 설정 파일을 목적에 따라 분리하여 관리하였다.</p>
<p>예를 들어</p>
<pre><code class="language-text">application.yml

↓

application-db.yml

↓

application-app.yml</code></pre>
<p>처럼 역할에 따라 설정을 나누어 관리하였다.</p>
<p>이러한 방식은 프로젝트 규모가 커질수록 설정 파일을 찾기 쉽고, 환경별 설정을 관리하기에도 편리하다는 점을 학습하였다.</p>
<hr>
<h3 id="configurationproperties를-이용한-설정-객체-생성">@ConfigurationProperties를 이용한 설정 객체 생성</h3>
<p>설정 파일의 값을 Java 코드에서 사용할 때는 <code>@ConfigurationProperties</code>를 활용하였다.</p>
<pre><code class="language-java">@ConfigurationProperties(prefix = &quot;app&quot;)
public record AppProperties(
        String message
) {
}</code></pre>
<p><code>prefix = &quot;app&quot;</code>은 설정 파일에서 <code>app</code>으로 시작하는 항목을 하나의 객체로 묶어 준다.</p>
<p>예를 들어 설정 파일에 다음과 같은 내용이 있다.</p>
<pre><code class="language-yaml">app:
  message: Hello Spring Boot</code></pre>
<p>Spring Boot는 애플리케이션이 실행될 때</p>
<pre><code class="language-text">application.yml

↓

app.message

↓

AppProperties.message</code></pre>
<p>순서로 값을 자동으로 바인딩한다.</p>
<p>따라서 Controller에서는 문자열을 직접 읽어오는 것이 아니라 객체를 통해 사용할 수 있다.</p>
<pre><code class="language-java">appProperties.message();</code></pre>
<p>이처럼 설정 값을 하나의 객체로 관리하면 여러 설정을 체계적으로 관리할 수 있으며, 타입이 지정되기 때문에 오타나 형변환 오류도 줄일 수 있다는 점을 배웠다.</p>
<hr>
<h3 id="value를-이용한-설정값-주입">@Value를 이용한 설정값 주입</h3>
<p>이번 프로젝트에서는 <code>@Value</code>도 함께 사용하였다.</p>
<pre><code class="language-java">@Value(&quot;${app.message}&quot;)
private String msg;</code></pre>
<p><code>@Value</code>는 특정 설정 값을 하나만 가져올 때 사용하는 어노테이션이다.</p>
<p>예를 들어</p>
<pre><code class="language-yaml">app:
  message: Hello</code></pre>
<p>가 있다면</p>
<pre><code class="language-java">@Value(&quot;${app.message}&quot;)</code></pre>
<p>를 통해 <code>&quot;Hello&quot;</code>가 주입된다.</p>
<p>실행 과정은 다음과 같다.</p>
<pre><code class="language-text">Spring 실행

↓

application.yml 읽기

↓

app.message 검색

↓

msg 변수에 저장</code></pre>
<hr>
<h3 id="value와-configurationproperties를-함께-학습하며-이해한-점">@Value와 @ConfigurationProperties를 함께 학습하며 이해한 점</h3>
<p>두 방법 모두 설정 값을 읽어오는 기능을 수행하지만 사용하는 목적이 조금 다르다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td><code>@Value</code></td>
<td>하나 또는 두 개 정도의 간단한 설정 값을 사용할 때 적합</td>
</tr>
<tr>
<td><code>@ConfigurationProperties</code></td>
<td>여러 개의 관련 설정을 하나의 객체로 관리할 때 적합</td>
</tr>
</tbody></table>
<p>예를 들어</p>
<pre><code class="language-yaml">app:
  name: Pizza
  version: 1.0
  author: Hong
  company: SSAFY</code></pre>
<p>처럼 여러 개의 설정이 존재한다면</p>
<pre><code class="language-java">AppProperties</code></pre>
<p>하나의 객체로 관리하는 것이 훨씬 효율적이라는 점을 이해하였다.</p>
<hr>
<h3 id="bean-등록과-spring-container">Bean 등록과 Spring Container</h3>
<p>Spring에서는 필요한 객체를 직접 생성하지 않고 <strong>Spring Container</strong>가 생성하고 관리한다.</p>
<p>이번 프로젝트에서는 <code>@Configuration</code>과 <code>@Bean</code>을 이용하여 객체를 등록하였다.</p>
<pre><code class="language-java">@Configuration
public class AppConfig {

    @Bean
    public HelloService helloService() {
        return new HelloService();
    }

}</code></pre>
<p>(프로젝트 구조 기반)</p>
<p>애플리케이션이 실행되면</p>
<pre><code class="language-text">Spring Boot 실행

↓

@Configuration 탐색

↓

@Bean 메서드 실행

↓

객체 생성

↓

Spring Container 저장</code></pre>
<p>순서로 Bean이 등록된다.</p>
<p>이후 Controller나 Service에서는</p>
<pre><code class="language-java">@RequiredArgsConstructor</code></pre>
<p>또는</p>
<pre><code class="language-java">@Autowired</code></pre>
<p>를 이용하여 필요한 Bean을 주입받는다.</p>
<p>이를 통해 객체 생성과 관리를 Spring이 대신 수행하는 <strong>IoC(Inversion of Control)</strong> 구조를 이해할 수 있었다.</p>
<hr>
<h3 id="primary와-qualifier를-이용한-bean-선택">@Primary와 @Qualifier를 이용한 Bean 선택</h3>
<p>동일한 타입의 Bean이 여러 개 존재하면 Spring은 어떤 Bean을 주입해야 할지 결정하지 못한다.</p>
<p>이를 해결하기 위해 <code>@Primary</code>와 <code>@Qualifier</code>를 사용하였다.</p>
<p>예를 들어</p>
<pre><code class="language-text">PizzaService

CheesePizzaService

BulgogiPizzaService</code></pre>
<p>세 개가 모두 같은 인터페이스를 구현했다고 가정하자.</p>
<p>이 경우</p>
<pre><code class="language-java">private final PizzaService pizzaService;</code></pre>
<p>만 작성하면 Spring은 어떤 객체를 주입해야 하는지 알 수 없다.</p>
<h3 id="primary">@Primary</h3>
<pre><code class="language-text">CheesePizzaService

↓

@Primary</code></pre>
<p>를 지정하면</p>
<p>기본적으로 CheesePizzaService가 주입된다.</p>
<h3 id="qualifier">@Qualifier</h3>
<p>특정 Bean을 사용하고 싶다면</p>
<pre><code class="language-java">@Qualifier(&quot;bulgogiPizzaService&quot;)</code></pre>
<p>를 사용하여 원하는 Bean을 직접 선택할 수 있다.</p>
<hr>
<h3 id="spring-profile을-이용한-환경-분리">Spring Profile을 이용한 환경 분리</h3>
<p>애플리케이션은 실행 환경에 따라 서로 다른 설정이 필요하다.</p>
<p>예를 들어</p>
<table>
<thead>
<tr>
<th>환경</th>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td>Local</td>
<td>개발자가 사용하는 환경</td>
</tr>
<tr>
<td>Dev</td>
<td>개발 서버</td>
</tr>
<tr>
<td>Test</td>
<td>테스트 서버</td>
</tr>
<tr>
<td>Prod</td>
<td>운영 서버</td>
</tr>
</tbody></table>
<p>이번 프로젝트에서는 Profile을 이용하여 이러한 환경을 구분하는 방법을 학습하였다.</p>
<p>Profile을 사용하면</p>
<pre><code class="language-text">개발 환경

↓

Dev Bean</code></pre>
<p>운영 환경에서는</p>
<pre><code class="language-text">운영 환경

↓

Prod Bean</code></pre>
<p>이 자동으로 선택된다.</p>
<p>덕분에 개발 환경에서는 테스트용 객체를 사용하고, 운영 환경에서는 실제 서비스를 사용하는 등 환경에 맞는 Bean을 쉽게 적용할 수 있다는 점을 이해하였다.</p>
<hr>
<h3 id="국제화i18n">국제화(i18n)</h3>
<p>이번 프로젝트에서 가장 흥미롭게 학습한 기능은 <strong>국제화(i18n)</strong> 였다.</p>
<p>프로젝트에는</p>
<pre><code class="language-text">messages.properties

messages_ko.properties

messages_en.properties</code></pre>
<p>파일이 존재하였다.</p>
<p>Thymeleaf에서는</p>
<pre><code class="language-html">&lt;h1 th:text=&quot;#{page.headline}&quot;&gt;&lt;/h1&gt;</code></pre>
<p>처럼 작성하였다.</p>
<p>사용자의 브라우저 언어가</p>
<p>한국어라면</p>
<pre><code class="language-text">message_ko.properties

↓

피자 주문 시스템</code></pre>
<p>영어라면</p>
<pre><code class="language-text">message_en.properties

↓

Pizza Order System</code></pre>
<p>이 출력된다.</p>
<p>즉,</p>
<p>같은 HTML을 사용하더라도</p>
<p>브라우저의 Locale에 따라 자동으로 다른 언어가 출력되는 구조라는 점을 이해하였다.</p>
<hr>
<h3 id="messagesource의-동작-과정">MessageSource의 동작 과정</h3>
<p>국제화 기능은 내부적으로 다음과 같은 과정으로 동작한다.</p>
<pre><code class="language-text">브라우저 요청

↓

Accept-Language

↓

Spring LocaleResolver

↓

MessageSource

↓

message_ko.properties

또는

message_en.properties

↓

Thymeleaf

↓

HTML 출력</code></pre>
<p>예를 들어</p>
<p>한국 사용자가 접속하면</p>
<pre><code class="language-text">안녕하세요</code></pre>
<p>영어 사용자라면</p>
<pre><code class="language-text">Hello</code></pre>
<p>가 같은 HTML에서 출력된다.</p>
<p>이 기능을 활용하면 하나의 프로젝트로 여러 국가의 사용자를 지원할 수 있다는 점을 학습하였다.</p>
<hr>
<h3 id="학습한-내용">학습한 내용</h3>
<table>
<thead>
<tr>
<th>학습 내용</th>
<th>세부 학습 내용</th>
</tr>
</thead>
<tbody><tr>
<td><code>application.yml</code></td>
<td>애플리케이션 설정을 역할별로 분리하여 관리하는 방법</td>
</tr>
<tr>
<td><code>@ConfigurationProperties</code></td>
<td>여러 설정 값을 하나의 객체로 바인딩하는 방법</td>
</tr>
<tr>
<td><code>@Value</code></td>
<td>단일 설정 값을 주입받는 방법</td>
</tr>
<tr>
<td>Spring Container</td>
<td>Bean을 생성하고 관리하는 과정</td>
</tr>
<tr>
<td><code>@Bean</code></td>
<td>필요한 객체를 Spring Container에 등록하는 방법</td>
</tr>
<tr>
<td><code>@Primary</code></td>
<td>동일한 타입의 Bean 중 기본으로 사용할 Bean을 지정하는 방법</td>
</tr>
<tr>
<td><code>@Qualifier</code></td>
<td>원하는 Bean을 직접 선택하여 주입하는 방법</td>
</tr>
<tr>
<td>Profile</td>
<td>개발·운영 환경에 따라 다른 Bean과 설정을 사용하는 방법</td>
</tr>
<tr>
<td>국제화(i18n)</td>
<td>Locale에 따라 서로 다른 메시지를 출력하는 방법</td>
</tr>
<tr>
<td>MessageSource</td>
<td>Locale에 맞는 properties 파일을 선택하여 메시지를 제공하는 과정</td>
</tr>
</tbody></table>
<hr>
<h3 id="느낀-점">느낀 점</h3>
<p>이번 프로젝트를 통해 <strong>애플리케이션을 개발할 때 기능 구현뿐 아니라 환경 설정과 유지보수성을 고려한 설계가 중요하다</strong>는 점을 배울 수 있었다. <code>@ConfigurationProperties</code>를 이용하여 설정을 객체로 관리하고, <code>@Value</code>를 통해 필요한 설정만 간단하게 주입하는 방법을 익히면서 프로젝트 규모에 따라 적절한 설정 관리 방법을 선택할 수 있다는 점을 이해하였다. 또한 <code>@Primary</code>, <code>@Qualifier</code>, <code>Profile</code>을 활용하여 실행 환경과 Bean 선택을 유연하게 제어하는 방법을 학습하였고, 국제화(i18n)를 통해 하나의 애플리케이션으로 여러 언어를 지원하는 구조를 경험하면서 <strong>실제 서비스에서 사용자의 환경에 맞춰 애플리케이션을 제공하는 방법</strong>을 이해할 수 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26/08/04 IL(I Learned) - Thymeleaf]]></title>
            <link>https://velog.io/@baeksh_8/260804-ILI-Learned-Thymeleaf</link>
            <guid>https://velog.io/@baeksh_8/260804-ILI-Learned-Thymeleaf</guid>
            <pubDate>Wed, 05 Aug 2026 13:57:18 GMT</pubDate>
            <description><![CDATA[<h2 id="thymeleaf-템플릿-엔진을-활용한-동적-웹-페이지-구성과-spring-mvc-데이터-전달-과정">Thymeleaf 템플릿 엔진을 활용한 동적 웹 페이지 구성과 Spring MVC 데이터 전달 과정</h2>
<p><strong>Thymeleaf 템플릿 엔진</strong>을 활용하여 Spring Boot에서 동적인 웹 페이지를 구성하는 방법을 학습하였다. 기존의 HTML은 화면에 고정된 내용만 출력할 수 있었지만, Thymeleaf를 이용하면 Controller에서 전달한 데이터를 화면에 출력하거나, 반복문과 조건문을 이용하여 상황에 따라 다른 화면을 생성할 수 있다.</p>
<p>또한 Spring MVC의 <code>Model</code> 객체를 이용하여 Controller와 View가 데이터를 주고받는 과정, DTO를 활용한 데이터 전달 방식, 반복문과 조건문을 활용한 동적 화면 구성, 그리고 HTML Escape를 통한 XSS 방지 기능까지 실습하면서 <strong>웹 애플리케이션의 화면이 내부적으로 어떻게 생성되는지</strong> 이해할 수 있었다.</p>
<hr>
<h3 id="thymeleaf-템플릿-엔진의-역할">Thymeleaf 템플릿 엔진의 역할</h3>
<p>Thymeleaf는 Spring Boot에서 가장 많이 사용하는 <strong>서버 사이드(Server Side) 템플릿 엔진</strong>이다.</p>
<p>브라우저가 서버에 요청을 보내면 Controller가 필요한 데이터를 조회하고, Thymeleaf는 그 데이터를 HTML에 삽입하여 완성된 페이지를 브라우저로 전달한다.</p>
<p>전체 동작 과정은 다음과 같다.</p>
<pre><code class="language-text">브라우저 요청

↓

DispatcherServlet

↓

Controller

↓

Service

↓

Repository

↓

DB 조회

↓

Controller(Model 저장)

↓

Thymeleaf

↓

HTML 생성

↓

브라우저 출력</code></pre>
<p>사용자가 최종적으로 보는 HTML은 처음부터 존재했던 것이 아니라 <strong>Controller가 전달한 데이터와 Thymeleaf가 합쳐져 새롭게 생성된 결과물</strong>이라는 점을 이해하였다.</p>
<hr>
<h3 id="controller와-model을-이용한-데이터-전달">Controller와 Model을 이용한 데이터 전달</h3>
<p>Controller는 사용자의 요청을 처리하고, 화면에 필요한 데이터를 조회하여 View에 전달하는 역할을 수행한다.</p>
<pre><code class="language-java">@GetMapping
public String index(Model model) {

    model.addAttribute(&quot;msg&quot;, appProperties.message());

    model.addAttribute(
            &quot;pizzas&quot;,
            pizzaRepository.findAll()
                    .stream()
                    .map(PizzaDTO::fromEntity)
                    .toList()
    );

    return &quot;index&quot;;
}</code></pre>
<p>사용자가</p>
<pre><code>http://localhost:8080/</code></pre><p>으로 접속하면 Spring은 가장 먼저 Controller의 <code>index()</code> 메서드를 실행한다.</p>
<p>이때 Controller는 데이터베이스에서 피자 목록을 조회하고, <code>Model</code> 객체에 데이터를 저장한다.</p>
<pre><code class="language-java">model.addAttribute(&quot;msg&quot;, &quot;환영합니다.&quot;);
model.addAttribute(&quot;count&quot;, 5);</code></pre>
<p>를 실행하면 Model 내부에는 다음과 같이 저장된다.</p>
<table>
<thead>
<tr>
<th>Key</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td>msg</td>
<td>환영합니다.</td>
</tr>
<tr>
<td>count</td>
<td>5</td>
</tr>
</tbody></table>
<p>이후 Thymeleaf는 이 데이터를 읽어 HTML을 생성한다.</p>
<p>예를 들어 HTML에서</p>
<pre><code class="language-html">&lt;p th:text=&quot;${msg}&quot;&gt;&lt;/p&gt;

&lt;p th:text=&quot;${count}&quot;&gt;&lt;/p&gt;</code></pre>
<p>를 작성하면 최종 HTML은</p>
<pre><code class="language-html">&lt;p&gt;환영합니다.&lt;/p&gt;

&lt;p&gt;5&lt;/p&gt;</code></pre>
<p>가 된다.</p>
<p>즉, <strong>Model은 Controller와 View 사이에서 데이터를 전달하는 저장소 역할을 수행한다.</strong></p>
<hr>
<h3 id="model을-사용하는-이유">Model을 사용하는 이유</h3>
<p>Model이 없다면 Controller에서 조회한 데이터를 View가 사용할 수 없다.</p>
<p>예를 들어</p>
<pre><code class="language-java">List&lt;Pizza&gt; pizzas = pizzaRepository.findAll();</code></pre>
<p>만 실행한다면 피자 목록은 Java 메모리에만 존재한다.</p>
<p>HTML은 Java 변수에 직접 접근할 수 없기 때문에 화면에는 아무것도 출력되지 않는다.</p>
<p>그래서</p>
<pre><code class="language-java">model.addAttribute(&quot;pizzas&quot;, pizzas);</code></pre>
<p>를 이용하여</p>
<pre><code>Controller

↓

Model

↓

View</code></pre><p>순서로 데이터를 전달한다.</p>
<hr>
<h3 id="thymeleaf-표현식expression">Thymeleaf 표현식(Expression)</h3>
<p>Thymeleaf는 다양한 표현식을 제공한다.</p>
<pre><code class="language-html">&lt;h1 th:text=&quot;#{page.headline}&quot;&gt;&lt;/h1&gt;

&lt;p th:text=&quot;${msg}&quot;&gt;&lt;/p&gt;

&lt;p&gt;[[${msg2}]]&lt;/p&gt;</code></pre>
<hr>
<h3 id="-표현식"><code>${}</code> 표현식</h3>
<p><code>${}</code>는 Model에 저장된 데이터를 가져오는 표현식이다.</p>
<p>예를 들어</p>
<pre><code class="language-java">model.addAttribute(&quot;name&quot;, &quot;홍길동&quot;);</code></pre>
<p>를 저장하면</p>
<pre><code class="language-html">&lt;p th:text=&quot;${name}&quot;&gt;&lt;/p&gt;</code></pre>
<p>은</p>
<pre><code class="language-html">&lt;p&gt;홍길동&lt;/p&gt;</code></pre>
<p>으로 출력된다.</p>
<hr>
<h3 id="-표현식-1"><code>#{}</code> 표현식</h3>
<p><code>#{}</code>는 국제화(MessageSource) 파일의 내용을 가져온다.</p>
<p>예를 들어</p>
<p>message.properties</p>
<pre><code>page.headline=피자 주문 시스템</code></pre><p>이 있다면</p>
<pre><code class="language-html">&lt;h1 th:text=&quot;#{page.headline}&quot;&gt;&lt;/h1&gt;</code></pre>
<p>은</p>
<pre><code>피자 주문 시스템</code></pre><p>으로 출력된다.</p>
<p>브라우저 언어가 영어라면</p>
<p>message_en.properties의 값을 출력한다.</p>
<p>즉, <strong>같은 HTML이라도 사용자의 언어에 따라 다른 내용을 출력할 수 있다.</strong></p>
<hr>
<h3 id="--표현식"><code>[[ ]]</code> 표현식</h3>
<p><code>[[ ]]</code>는 Thymeleaf의 인라인 표현식이다.</p>
<p>기능은 <code>${}</code>와 비슷하지만 HTML 태그 밖에서도 자연스럽게 사용할 수 있다.</p>
<p>예를 들어</p>
<pre><code class="language-html">&lt;p&gt;안녕하세요 [[${name}]] 님&lt;/p&gt;</code></pre>
<p>이라면</p>
<p>브라우저에는</p>
<pre><code>안녕하세요 홍길동 님</code></pre><p>으로 출력된다.</p>
<hr>
<h3 id="dto를-이용한-화면-데이터-관리">DTO를 이용한 화면 데이터 관리</h3>
<p>프로젝트에서는 Entity를 그대로 View에 전달하지 않고 DTO를 사용하였다.</p>
<pre><code class="language-java">public record PizzaDTO(
        String name,
        int price
) {

    Pizza toEntity() {
        return Pizza.builder()
                .name(name)
                .price(price)
                .build();
    }

    static PizzaDTO fromEntity(Pizza pizza) {
        return new PizzaDTO(
                pizza.getName(),
                pizza.getPrice()
        );
    }
}</code></pre>
<p>DTO(Data Transfer Object)는 데이터를 전달하기 위한 객체이다.</p>
<p>예를 들어 데이터베이스에는</p>
<table>
<thead>
<tr>
<th>id</th>
<th>name</th>
<th>price</th>
<th>created_at</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>불고기</td>
<td>22000</td>
<td>2026-08-01</td>
</tr>
</tbody></table>
<p>가 저장되어 있다고 가정하자.</p>
<p>화면에서는</p>
<pre><code>불고기

22000원</code></pre><p>만 필요하다.</p>
<p>그렇다면</p>
<p>DTO는</p>
<pre><code class="language-text">name

price</code></pre>
<p>만 가지고 있으면 된다.</p>
<p>즉,</p>
<pre><code>Entity

↓

DTO

↓

View</code></pre><p>순서로 데이터를 전달한다.</p>
<p>이를 통해 화면에는 필요한 데이터만 전달할 수 있고, Entity가 직접 노출되지 않아 보안과 유지보수 측면에서도 유리하다는 점을 이해하였다.</p>
<hr>
<h3 id="theach를-이용한-반복-출력">th:each를 이용한 반복 출력</h3>
<p>조회한 피자 목록은 <code>th:each</code>를 이용하여 반복 출력하였다.</p>
<pre><code class="language-html">&lt;div th:each=&quot;pizza, status : ${pizzas}&quot;&gt;

    &lt;code th:text=&quot;${pizza.name}&quot;&gt;&lt;/code&gt;

    &lt;code th:text=&quot;${pizza.price}&quot;&gt;&lt;/code&gt;

&lt;/div&gt;</code></pre>
<p>Controller가</p>
<pre><code class="language-java">model.addAttribute(
    &quot;pizzas&quot;,
    List.of(
        new PizzaDTO(&quot;불고기&quot;,22000),
        new PizzaDTO(&quot;치즈&quot;,18000),
        new PizzaDTO(&quot;페퍼로니&quot;,25000)
    )
);</code></pre>
<p>를 전달했다고 가정하면</p>
<p>Thymeleaf는 내부적으로 다음과 같이 반복한다.</p>
<p>첫 번째 반복</p>
<pre><code>pizza

↓

불고기

22000</code></pre><p>두 번째 반복</p>
<pre><code>pizza

↓

치즈

18000</code></pre><p>세 번째 반복</p>
<pre><code>pizza

↓

페퍼로니

25000</code></pre><p>즉, HTML 요소 하나를 Collection의 크기만큼 복제하여 출력한다.</p>
<p>최종 브라우저에는</p>
<pre><code>불고기   22000

치즈     18000

페퍼로니 25000</code></pre><p>가 출력된다.</p>
<hr>
<h3 id="theach와-status-객체를-이용한-반복-상태-관리">th:each와 status 객체를 이용한 반복 상태 관리</h3>
<p>Thymeleaf의 <code>th:each</code>는 단순히 데이터를 반복해서 출력하는 기능만 제공하는 것이 아니라, 반복문의 현재 상태를 나타내는 <strong>status 객체</strong>도 함께 제공한다.</p>
<pre><code class="language-html">&lt;div th:each=&quot;pizza, status : ${pizzas}&quot;&gt;

    &lt;span th:text=&quot;${status.count}&quot;&gt;&lt;/span&gt;

    &lt;span th:text=&quot;${status.index}&quot;&gt;&lt;/span&gt;

    &lt;span th:text=&quot;${status.first}&quot;&gt;&lt;/span&gt;

    &lt;span th:text=&quot;${status.last}&quot;&gt;&lt;/span&gt;

&lt;/div&gt;</code></pre>
<p>예를 들어 Controller에서 다음과 같은 데이터를 전달했다고 가정하자.</p>
<pre><code class="language-java">List&lt;PizzaDTO&gt; pizzas = List.of(
    new PizzaDTO(&quot;불고기&quot;,22000),
    new PizzaDTO(&quot;치즈&quot;,18000),
    new PizzaDTO(&quot;페퍼로니&quot;,25000)
);</code></pre>
<p>첫 번째 반복에서는</p>
<table>
<thead>
<tr>
<th>속성</th>
<th>값</th>
</tr>
</thead>
<tbody><tr>
<td>pizza</td>
<td>불고기</td>
</tr>
<tr>
<td>count</td>
<td>1</td>
</tr>
<tr>
<td>index</td>
<td>0</td>
</tr>
<tr>
<td>first</td>
<td>true</td>
</tr>
<tr>
<td>last</td>
<td>false</td>
</tr>
</tbody></table>
<p>두 번째 반복에서는</p>
<table>
<thead>
<tr>
<th>속성</th>
<th>값</th>
</tr>
</thead>
<tbody><tr>
<td>pizza</td>
<td>치즈</td>
</tr>
<tr>
<td>count</td>
<td>2</td>
</tr>
<tr>
<td>index</td>
<td>1</td>
</tr>
<tr>
<td>first</td>
<td>false</td>
</tr>
<tr>
<td>last</td>
<td>false</td>
</tr>
</tbody></table>
<p>세 번째 반복에서는</p>
<table>
<thead>
<tr>
<th>속성</th>
<th>값</th>
</tr>
</thead>
<tbody><tr>
<td>pizza</td>
<td>페퍼로니</td>
</tr>
<tr>
<td>count</td>
<td>3</td>
</tr>
<tr>
<td>index</td>
<td>2</td>
</tr>
<tr>
<td>first</td>
<td>false</td>
</tr>
<tr>
<td>last</td>
<td>true</td>
</tr>
</tbody></table>
<p>이처럼 <code>status</code> 객체는 반복문의 진행 상황을 알려주는 다양한 정보를 제공한다.</p>
<hr>
<h3 id="status-객체를-사용하는-이유">status 객체를 사용하는 이유</h3>
<p>반복문의 현재 위치를 알 수 있기 때문에 화면을 더욱 다양하게 구성할 수 있다.</p>
<p>예를 들어</p>
<ul>
<li>첫 번째 상품에는 &quot;BEST&quot; 표시</li>
<li>마지막 상품에는 &quot;NEW&quot; 표시</li>
<li>홀수 번째 행은 회색 배경</li>
<li>짝수 번째 행은 흰색 배경</li>
</ul>
<p>과 같은 화면을 쉽게 구현할 수 있다.</p>
<p>쇼핑몰을 예로 들면</p>
<pre><code class="language-text">1. 아이폰 ⭐BEST

2. 갤럭시

3. 맥북

4. 아이패드 ⭐NEW</code></pre>
<p>처럼 특정 순서에 따라 화면을 다르게 표현할 수 있다.</p>
<hr>
<h3 id="thif와-thunless를-이용한-조건부-출력">th:if와 th:unless를 이용한 조건부 출력</h3>
<p>웹 화면에서는 모든 데이터를 항상 출력하는 것이 아니라 특정 조건에서만 보여 주어야 하는 경우가 많다.</p>
<p>이를 위해 Thymeleaf에서는 <code>th:if</code>와 <code>th:unless</code>를 제공한다.</p>
<pre><code class="language-html">&lt;mark th:if=&quot;${status.first}&quot;&gt;
    첫 번째 메뉴입니다.
&lt;/mark&gt;

&lt;mark th:unless=&quot;${status.first}&quot;&gt;
    첫 번째 메뉴가 아닙니다.
&lt;/mark&gt;</code></pre>
<p><code>th:if</code>는 조건이 <strong>참(true)</strong> 일 때만 HTML 요소를 생성한다.</p>
<p>예를 들어</p>
<pre><code class="language-java">boolean admin = true;</code></pre>
<p>라면</p>
<pre><code class="language-html">&lt;p th:if=&quot;${admin}&quot;&gt;
    관리자 메뉴
&lt;/p&gt;</code></pre>
<p>은 브라우저에서</p>
<pre><code class="language-html">&lt;p&gt;관리자 메뉴&lt;/p&gt;</code></pre>
<p>로 출력된다.</p>
<p>반대로</p>
<pre><code class="language-java">boolean admin = false;</code></pre>
<p>라면</p>
<p>브라우저에는 해당 <code>&lt;p&gt;</code> 태그 자체가 생성되지 않는다.</p>
<p>즉, CSS로 숨기는 것이 아니라 <strong>HTML 자체를 만들지 않는 것</strong>이다.</p>
<hr>
<h3 id="thunless">th:unless</h3>
<p><code>th:unless</code>는 <code>th:if</code>와 반대로 조건이 거짓일 때 출력된다.</p>
<p>예를 들어</p>
<pre><code class="language-java">boolean login = false;</code></pre>
<p>라면</p>
<pre><code class="language-html">&lt;p th:unless=&quot;${login}&quot;&gt;
    로그인 해주세요.
&lt;/p&gt;</code></pre>
<p>는</p>
<pre><code class="language-html">&lt;p&gt;로그인 해주세요.&lt;/p&gt;</code></pre>
<p>가 출력된다.</p>
<hr>
<h3 id="thswitch와-thcase를-이용한-여러-조건-처리">th:switch와 th:case를 이용한 여러 조건 처리</h3>
<p>조건이 여러 개인 경우에는 <code>th:switch</code>를 사용할 수 있다.</p>
<pre><code class="language-html">&lt;section th:switch=&quot;${status.count}&quot;&gt;

    &lt;p th:case=&quot;1&quot;&gt;첫 번째 메뉴&lt;/p&gt;

    &lt;p th:case=&quot;2&quot;&gt;두 번째 메뉴&lt;/p&gt;

    &lt;p th:case=&quot;*&quot;&gt;기타 메뉴&lt;/p&gt;

&lt;/section&gt;</code></pre>
<p>Java의 <code>switch</code> 문과 동일한 방식으로 동작한다.</p>
<p>예를 들어</p>
<pre><code class="language-java">int level = 2;</code></pre>
<p>라면</p>
<pre><code class="language-html">&lt;div th:switch=&quot;${level}&quot;&gt;

&lt;p th:case=&quot;1&quot;&gt;초급&lt;/p&gt;

&lt;p th:case=&quot;2&quot;&gt;중급&lt;/p&gt;

&lt;p th:case=&quot;3&quot;&gt;고급&lt;/p&gt;

&lt;p th:case=&quot;*&quot;&gt;알 수 없음&lt;/p&gt;

&lt;/div&gt;</code></pre>
<p>의 결과는</p>
<pre><code class="language-text">중급</code></pre>
<p>이 된다.</p>
<p><code>*</code>는 Java의 <code>default</code>와 같은 역할을 수행한다.</p>
<hr>
<h3 id="언제-사용하는가">언제 사용하는가?</h3>
<p>예를 들어 회원 등급이 있다면</p>
<table>
<thead>
<tr>
<th>등급</th>
<th>화면 출력</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>Bronze</td>
</tr>
<tr>
<td>2</td>
<td>Silver</td>
</tr>
<tr>
<td>3</td>
<td>Gold</td>
</tr>
<tr>
<td>기타</td>
<td>Unknown</td>
</tr>
</tbody></table>
<p>처럼 하나의 값에 따라 여러 화면을 표현할 수 있다.</p>
<hr>
<h3 id="html-escape와-xss-방지">HTML Escape와 XSS 방지</h3>
<p>이번 프로젝트에서 가장 중요하게 학습한 내용 중 하나는 <strong>HTML Escape</strong>였다.</p>
<p>Controller에서는 다음과 같은 문자열을 View로 전달하였다.</p>
<pre><code class="language-java">String data = &quot;&quot;&quot;
&lt;script&gt;alert(&#39;XSS!&#39;)&lt;/script&gt;
&quot;&quot;&quot;;</code></pre>
<p>그리고 View에서는</p>
<pre><code class="language-html">&lt;p th:text=&quot;${data}&quot;&gt;&lt;/p&gt;

&lt;p&gt;[[${data}]]&lt;/p&gt;</code></pre>
<p>만약 HTML Escape가 존재하지 않는다면</p>
<p>브라우저는</p>
<pre><code class="language-html">&lt;script&gt;alert(&quot;XSS!&quot;)&lt;/script&gt;</code></pre>
<p>를 실행하게 된다.</p>
<p>그러면</p>
<p>사용자의 브라우저에서</p>
<pre><code class="language-text">⚠ alert 창 실행</code></pre>
<p>이 발생한다.</p>
<p>이러한 공격을 <strong>XSS(Cross Site Scripting)</strong> 라고 한다.</p>
<hr>
<h3 id="html-escape가-적용되면">HTML Escape가 적용되면</h3>
<p>Thymeleaf는</p>
<pre><code class="language-html">&lt;script&gt;</code></pre>
<p>를</p>
<pre><code class="language-text">&amp;lt;script&amp;gt;</code></pre>
<p>처럼 변환하여 출력한다.</p>
<p>브라우저는 이것을 HTML 코드가 아닌 단순한 문자열로 인식한다.</p>
<p>즉,</p>
<p>브라우저 화면에는</p>
<pre><code class="language-text">&lt;script&gt;alert(&quot;XSS!&quot;)&lt;/script&gt;</code></pre>
<p>가 그대로 보일 뿐</p>
<p>실행되지는 않는다.</p>
<hr>
<h3 id="html-escape가-중요한-이유">HTML Escape가 중요한 이유</h3>
<p>웹 서비스에서는 사용자가 자유롭게 글을 작성하는 경우가 많다.</p>
<p>예를 들어</p>
<ul>
<li>게시판</li>
<li>댓글</li>
<li>리뷰</li>
<li>문의 게시판</li>
</ul>
<p>등에서 악성 사용자가</p>
<pre><code class="language-html">&lt;script&gt;
location.href=&#39;악성사이트&#39;
&lt;/script&gt;</code></pre>
<p>를 입력한다면</p>
<p>Escape가 없을 경우</p>
<p>다른 사용자의 브라우저에서도 해당 스크립트가 실행될 수 있다.</p>
<p>따라서 Thymeleaf는 기본적으로 HTML Escape를 적용하여 이러한 보안 문제를 예방한다.</p>
<hr>
<h3 id="학습한-내용">학습한 내용</h3>
<table>
<thead>
<tr>
<th>학습 내용</th>
<th>세부 학습 내용</th>
</tr>
</thead>
<tbody><tr>
<td><code>th:each</code></td>
<td>Collection 데이터를 반복 출력하며 HTML 요소를 자동으로 생성하는 방법</td>
</tr>
<tr>
<td><code>status</code> 객체</td>
<td>반복문의 순서, 인덱스, 첫 번째/마지막 여부 등을 활용하여 화면을 제어하는 방법</td>
</tr>
<tr>
<td><code>th:if</code></td>
<td>조건이 참인 경우에만 HTML 요소를 생성하는 방법</td>
</tr>
<tr>
<td><code>th:unless</code></td>
<td>조건이 거짓인 경우에만 HTML 요소를 생성하는 방법</td>
</tr>
<tr>
<td><code>th:switch</code>, <code>th:case</code></td>
<td>여러 조건에 따라 서로 다른 화면을 출력하는 방법</td>
</tr>
<tr>
<td>HTML Escape</td>
<td>HTML 태그를 문자열로 변환하여 브라우저에서 실행되지 않도록 하는 기능</td>
</tr>
<tr>
<td>XSS 방지</td>
<td>악성 스크립트 실행을 방지하여 웹 애플리케이션의 보안을 높이는 방법</td>
</tr>
</tbody></table>
<hr>
<h3 id="느낀-점">느낀 점</h3>
<p>Thymeleaf는 단순히 데이터를 화면에 출력하는 템플릿 엔진이 아니라, <strong>반복문과 조건문을 활용하여 다양한 화면을 동적으로 구성하고, HTML Escape를 통해 웹 보안까지 고려한 서버 사이드 렌더링 기술</strong>이라는 점을 이해할 수 있었다. 특히 <code>th:each</code>와 <code>status</code> 객체를 활용하여 반복 상태를 제어하고, <code>th:if</code>와 <code>th:switch</code>를 이용해 조건에 따라 화면을 다르게 구성하는 방법을 실습하면서, 실제 웹 서비스에서 자주 사용되는 UI 구성 방식을 익힐 수 있었다. 또한 XSS 공격 사례를 통해 HTML Escape의 필요성을 이해하면서, <strong>사용자 입력을 안전하게 처리하는 것이 기능 구현만큼 중요한 요소</strong>라는 점을 배울 수 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26/07/30 IL(I Learned) - RAG (2)]]></title>
            <link>https://velog.io/@baeksh_8/260730-ILI-Learned-RAG-2</link>
            <guid>https://velog.io/@baeksh_8/260730-ILI-Learned-RAG-2</guid>
            <pubDate>Wed, 05 Aug 2026 08:41:13 GMT</pubDate>
            <description><![CDATA[<h2 id="spring-ai-기반-rag-구현과-vector-database-활용">Spring AI 기반 RAG 구현과 Vector Database 활용</h2>
<p>이번에는 RAG의 개념을 이해하는 것에서 나아가 <strong>Spring Boot와 Spring AI를 활용하여 실제 RAG 시스템을 구현하는 과정</strong>을 학습하였다. 단순히 AI에게 질문을 전달하는 것이 아니라 문서를 Vector Database에 저장하고, 문서를 검색하고, 검색된 결과를 AI에게 전달하여 답변을 생성하는 전체 파이프라인을 구현하였다.</p>
<p>특히 이번 프로젝트에서는 <strong>문서를 저장하는 과정(Ingest Pipeline)</strong> 과 <strong>질문에 대해 관련 문서를 검색하는 과정(Retrieval Pipeline)</strong> 을 직접 구현하면서 RAG 시스템이 내부적으로 어떤 단계로 동작하는지 이해할 수 있었다.</p>
<hr>
<h3 id="document-객체-생성과-vectorstore-저장">Document 객체 생성과 VectorStore 저장</h3>
<p>RAG 시스템에서 가장 먼저 수행되는 작업은 문서를 Vector Database에 저장하는 것이다.</p>
<p>이를 위해 Spring AI에서는 <code>Document</code> 객체를 사용하였다.</p>
<pre><code class="language-java">public void save(String content, String category) {

    Document doc = new Document(
            content,
            Map.of(
                    &quot;category&quot;, category,
                    &quot;source&quot;, &quot;manual&quot;)
    );

    vectorStore.add(List.of(doc));
}</code></pre>
<p><code>Document</code>는 단순한 문자열을 저장하는 객체가 아니라 <strong>텍스트와 메타데이터(Metadata)를 함께 관리하는 객체</strong>이다.</p>
<p>이번 프로젝트에서는 Document 생성 시 두 가지 정보를 저장하였다.</p>
<ul>
<li>실제 문서 내용(content)</li>
<li>메타데이터(category, source)</li>
</ul>
<p>예를 들어</p>
<pre><code class="language-text">내용

Spring Boot는...

--------------------

Metadata

category = spring

source = manual</code></pre>
<p>처럼 하나의 Document 안에 저장된다.</p>
<p>이후</p>
<pre><code class="language-java">vectorStore.add(...)</code></pre>
<p>를 호출하면 Spring AI가 내부적으로 다음 과정을 자동으로 수행한다.</p>
<pre><code class="language-text">Document

↓

Embedding Model 호출

↓

Vector 생성

↓

Vector Database 저장</code></pre>
<p>즉, 개발자가 Embedding API를 직접 호출하지 않아도 <code>VectorStore.add()</code>만 호출하면 <strong>문서 임베딩과 저장이 자동으로 수행</strong>된다는 점을 새롭게 학습하였다.</p>
<hr>
<h3 id="metadata를-함께-저장하는-이유">Metadata를 함께 저장하는 이유</h3>
<p>이번 프로젝트에서는 Document에 Metadata를 저장하였다.</p>
<pre><code class="language-java">Map.of(

&quot;category&quot;, category,

&quot;source&quot;, &quot;manual&quot;

)</code></pre>
<p>Metadata는 문서의 부가 정보를 저장하는 영역이다.</p>
<p>예를 들어</p>
<pre><code class="language-text">Spring Boot 문서

↓

category = spring</code></pre>
<pre><code class="language-text">AI 문서

↓

category = ai</code></pre>
<p>처럼 카테고리를 저장할 수 있다.</p>
<p>나중에 검색할 때</p>
<pre><code class="language-text">AI 관련 문서만 검색</code></pre>
<p>처럼 조건을 줄 수 있기 때문에 단순히 내용을 저장하는 것보다 훨씬 효율적인 검색이 가능하다.</p>
<p>실무에서는</p>
<ul>
<li>작성자</li>
<li>생성일</li>
<li>문서 종류</li>
<li>권한</li>
</ul>
<p>등도 Metadata에 저장하여 검색 조건으로 활용한다는 점도 함께 이해하였다.</p>
<hr>
<h3 id="similarity-search-구현">Similarity Search 구현</h3>
<p>문서를 저장한 뒤에는 질문과 가장 유사한 문서를 검색해야 한다.</p>
<pre><code class="language-java">public List&lt;Document&gt; search(String query) {

    return vectorStore.similaritySearch(

            SearchRequest.builder()

                    .query(query)

                    .topK(4)

                    .similarityThreshold(0.3)

                    .build()

    );

}</code></pre>
<p><code>similaritySearch()</code>는 입력한 질문을 먼저 Embedding한 뒤 저장된 모든 문서와 유사도를 계산한다.</p>
<p>동작 과정은 다음과 같다.</p>
<pre><code class="language-text">질문

↓

Embedding

↓

Vector 생성

↓

Vector DB 검색

↓

유사도 계산

↓

Top-K 반환</code></pre>
<p>여기서 중요한 점은 문자열을 비교하는 것이 아니라 <strong>벡터 간의 거리(유사도)</strong> 를 비교한다는 것이다.</p>
<p>예를 들어</p>
<pre><code class="language-text">질문

Spring Boot란?

↓

검색 결과

Spring Boot 설명

92%

↓

Spring Framework 설명

88%

↓

Java 문법

35%</code></pre>
<p>처럼 의미가 비슷한 문서를 찾는다.</p>
<hr>
<h3 id="searchrequest의-역할">SearchRequest의 역할</h3>
<p>이번 프로젝트에서는 검색 조건을 객체로 관리하였다.</p>
<pre><code class="language-java">SearchRequest.builder()

.query(query)

.topK(4)

.similarityThreshold(0.3)</code></pre>
<p>SearchRequest에는 검색 조건이 모두 포함된다.</p>
<h4 id="query">query</h4>
<p>검색할 질문</p>
<h4 id="topk">topK</h4>
<p>가장 유사한 문서를 몇 개 가져올 것인지 결정한다.</p>
<p>예를 들어</p>
<pre><code class="language-text">topK = 4</code></pre>
<p>이면</p>
<p>가장 유사한 문서 4개만 AI에게 전달된다.</p>
<h4 id="similaritythreshold">similarityThreshold</h4>
<p>최소 유사도를 의미한다.</p>
<p>예를 들어</p>
<pre><code class="language-text">0.91

0.85

0.73

0.42

0.21</code></pre>
<p>이라면</p>
<p>Threshold가</p>
<pre><code class="language-text">0.3</code></pre>
<p>이므로</p>
<p>0.21인 문서는 제외된다.</p>
<p>이러한 설정을 통해 AI에게 너무 관련성이 낮은 문서를 전달하지 않도록 제어할 수 있다는 점을 학습하였다.</p>
<hr>
<h3 id="문서-적재ingest-pipeline">문서 적재(Ingest Pipeline)</h3>
<p>이번 프로젝트에서 가장 중요하게 학습한 내용 중 하나는 <strong>문서를 Vector Database에 적재하는 과정</strong>이었다.</p>
<pre><code class="language-java">List&lt;Document&gt; docs = new TextReader(resource).get();

TokenTextSplitter splitter =
        TokenTextSplitter.builder()
                .withChunkSize(chunkSize)
                .build();

List&lt;Document&gt; chunks =
        splitter.apply(docs);</code></pre>
<p>문서를 그대로 저장하지 않고 여러 개의 Chunk로 분리하였다.</p>
<p>전체 과정은</p>
<pre><code class="language-text">TXT 파일

↓

TextReader

↓

Document 생성

↓

Chunk 분할

↓

Embedding

↓

Vector DB 저장</code></pre>
<p>순서로 진행된다.</p>
<hr>
<h3 id="textreader">TextReader</h3>
<pre><code class="language-java">new TextReader(resource)</code></pre>
<p>는</p>
<p>TXT 파일을 읽어서 Document 객체를 생성한다.</p>
<hr>
<h3 id="tokentextsplitter">TokenTextSplitter</h3>
<p>문서를 일정한 크기로 분리하는 역할을 수행한다.</p>
<p>예를 들어</p>
<p>1000자의 문서라면</p>
<pre><code class="language-text">Chunk1

Chunk2

Chunk3

Chunk4</code></pre>
<p>처럼 여러 개로 나눈다.</p>
<hr>
<h3 id="chunk를-나누는-이유">Chunk를 나누는 이유</h3>
<p>LLM은 한 번에 처리할 수 있는 토큰 수가 제한되어 있다.</p>
<p>또한</p>
<p>문서를 너무 크게 저장하면</p>
<p>질문과 관계없는 내용까지 같이 검색된다.</p>
<p>반대로</p>
<p>너무 작게 나누면</p>
<p>문맥(Context)이 끊어질 수 있다.</p>
<p>따라서 적절한 Chunk Size를 설정하는 것이 검색 품질을 높이는 중요한 요소라는 점을 이해하였다.</p>
<hr>
<h3 id="uuid를-이용한-document-관리">UUID를 이용한 Document 관리</h3>
<p>Chunk를 생성한 후</p>
<p>UUID를 이용하여 Document의 ID를 생성하였다.</p>
<pre><code class="language-java">.id(

UUID.nameUUIDFromBytes(

(&quot;sample.txt:&quot; + c.getText())

.getBytes(StandardCharsets.UTF_8)

).toString()

)</code></pre>
<p>UUID를 랜덤하게 생성하지 않고</p>
<p>문서 내용을 기반으로 생성하였다.</p>
<p>그 이유는</p>
<p>같은 문서를 여러 번 적재하더라도</p>
<p>같은 UUID가 생성되기 때문이다.</p>
<p>즉</p>
<pre><code class="language-text">같은 문서

↓

같은 UUID

↓

기존 문서 덮어쓰기</code></pre>
<p>가 가능해진다.</p>
<p>이를 통해 중복 데이터 저장을 방지하는 방법을 학습하였다.</p>
<hr>
<h3 id="questionansweradvisor를-이용한-rag-응답-생성">QuestionAnswerAdvisor를 이용한 RAG 응답 생성</h3>
<p>RAG에서 가장 핵심이 되는 기능은</p>
<p>QuestionAnswerAdvisor였다.</p>
<pre><code class="language-java">ChatResponse response =
        chatClient.prompt()

.advisors(

a -&gt; a.param(

QuestionAnswerAdvisor.FILTER_EXPRESSION,

&quot;category == &#39;%s&#39;&quot;.formatted(category)

)

)</code></pre>
<p>Advisor는 질문을 AI에게 보내기 전에</p>
<p>자동으로 관련 문서를 검색한다.</p>
<p>이번 프로젝트에서는</p>
<pre><code class="language-text">category == spring</code></pre>
<p>처럼 Metadata를 이용하여</p>
<p>특정 카테고리의 문서만 검색하도록 구현하였다.</p>
<p>즉</p>
<pre><code class="language-text">사용자 질문

↓

Metadata Filter

↓

Vector Search

↓

관련 문서

↓

LLM

↓

답변 생성</code></pre>
<p>과정이 자동으로 수행된다.</p>
<hr>
<h3 id="maincontroller의-전체-요청-흐름">MainController의 전체 요청 흐름</h3>
<p>Controller에서는 사용자의 요청을 받아</p>
<p>각 기능을 Service로 전달하였다.</p>
<pre><code class="language-java">@PostMapping(&quot;/chat&quot;)

public String chat(

@RequestParam String question,

@RequestParam String category,

RedirectAttributes redirectAttributes
)</code></pre>
<p>사용자가 질문을 입력하면</p>
<pre><code class="language-text">JSP

↓

MainController

↓

DocumentService

↓

QuestionAnswerAdvisor

↓

VectorStore 검색

↓

LLM

↓

답변 생성

↓

JSP 출력</code></pre>
<p>순서로 처리된다.</p>
<p>Controller는 검색이나 AI 호출을 직접 수행하지 않고</p>
<p>요청을 Service에 전달하는 역할만 담당하였다.</p>
<p>이를 통해 MVC 구조를 유지하면서도 RAG 기능을 자연스럽게 통합하는 방법을 학습하였다.</p>
<hr>
<h3 id="학습한-내용">학습한 내용</h3>
<table>
<thead>
<tr>
<th>학습 내용</th>
<th>세부 학습 내용</th>
</tr>
</thead>
<tbody><tr>
<td>Document</td>
<td>텍스트와 Metadata를 함께 저장하는 객체</td>
</tr>
<tr>
<td>VectorStore</td>
<td>Document 저장 시 자동으로 Embedding을 수행하는 구조</td>
</tr>
<tr>
<td>Similarity Search</td>
<td>질문과 가장 의미가 가까운 문서를 검색하는 과정</td>
</tr>
<tr>
<td>SearchRequest</td>
<td>Query, Top-K, Similarity Threshold를 이용한 검색 조건 설정</td>
</tr>
<tr>
<td>Metadata</td>
<td>카테고리와 출처 정보를 저장하여 검색 범위를 제한하는 방법</td>
</tr>
<tr>
<td>Ingest Pipeline</td>
<td>TextReader → Chunking → Embedding → Vector Database 저장 과정</td>
</tr>
<tr>
<td>TokenTextSplitter</td>
<td>문서를 적절한 크기의 Chunk로 분리하는 방법</td>
</tr>
<tr>
<td>UUID</td>
<td>문서 내용을 기반으로 고유 ID를 생성하여 중복 적재를 방지하는 방법</td>
</tr>
<tr>
<td>QuestionAnswerAdvisor</td>
<td>검색된 문서를 Prompt에 자동으로 포함하여 RAG를 구현하는 방법</td>
</tr>
<tr>
<td>RAG Pipeline</td>
<td>문서 저장과 검색, 생성형 AI를 하나의 흐름으로 연결하는 전체 구조</td>
</tr>
</tbody></table>
<hr>
<h3 id="느낀-점">느낀 점</h3>
<p>이번 프로젝트를 통해 <strong>RAG는 단순히 AI 모델의 성능을 높이는 기술이 아니라, 검색 시스템(Vector Database)과 생성형 AI를 하나의 파이프라인으로 연결하는 구조</strong>라는 점을 깊이 이해할 수 있었다. 특히 문서를 <code>Document</code> 객체로 생성하고, <code>VectorStore</code>가 자동으로 임베딩을 수행하여 저장하는 과정, <code>Similarity Search</code>를 통해 의미적으로 가장 유사한 문서를 검색하는 과정, <code>QuestionAnswerAdvisor</code>가 검색된 문서를 프롬프트에 자동으로 포함시키는 과정을 직접 구현하면서 RAG 시스템의 내부 동작 원리를 체계적으로 학습할 수 있었다. 또한 Chunk Size, Metadata, Top-K, Similarity Threshold와 같은 요소들이 검색 정확도와 AI 답변의 품질에 큰 영향을 미친다는 점을 실습을 통해 확인하면서, <strong>실무에서 RAG를 구축할 때 검색 성능과 문서 관리 전략이 매우 중요하다는 사실</strong>을 배울 수 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26/07/30 IL(I Learned) - RAG (1)]]></title>
            <link>https://velog.io/@baeksh_8/260730-ILI-Learned-RAG-1</link>
            <guid>https://velog.io/@baeksh_8/260730-ILI-Learned-RAG-1</guid>
            <pubDate>Wed, 05 Aug 2026 08:28:21 GMT</pubDate>
            <description><![CDATA[<h2 id="ragretrieval-augmented-generation와-vector-database의-이해-및-spring-ai-기반-rag-구조-학습">RAG(Retrieval-Augmented Generation)와 Vector Database의 이해 및 Spring AI 기반 RAG 구조 학습</h2>
<p>이번에는 기존의 생성형 AI(ChatGPT, Gemini 등)가 가지고 있는 한계를 해결하기 위한 <strong>RAG(Retrieval-Augmented Generation)</strong> 기술을 학습하였다. 이전 프로젝트에서는 LLM에게 사용자의 질문만 전달하여 답변을 생성하는 방식이었다면, 이번 프로젝트에서는 <strong>외부 문서를 Vector Database에 저장한 후 질문과 가장 유사한 문서를 검색하여 함께 전달하는 방식</strong>을 구현하였다.</p>
<p>이를 통해 LLM이 학습하지 않은 최신 정보나 회사 내부 문서와 같은 데이터를 활용하여 보다 정확한 답변을 생성할 수 있는 구조를 이해하였다. 또한 Spring AI에서 제공하는 <code>VectorStore</code>, <code>EmbeddingModel</code>, <code>QuestionAnswerAdvisor</code>, <code>ChatClient</code>를 활용하여 RAG 시스템을 구성하는 방법을 익혔다.</p>
<hr>
<h3 id="ragretrieval-augmented-generation의-개념">RAG(Retrieval-Augmented Generation)의 개념</h3>
<p>이번 프로젝트에서 가장 먼저 학습한 내용은 <strong>RAG의 전체 동작 원리</strong>였다.</p>
<p>기존 LLM은 학습된 데이터만을 기반으로 답변을 생성하기 때문에 최신 정보나 학습되지 않은 내용을 질문하면 잘못된 정보를 생성하는 <strong>Hallucination(환각)</strong> 문제가 발생할 수 있다.</p>
<p>RAG는 이러한 문제를 해결하기 위해 질문과 관련된 문서를 먼저 검색한 후, 검색된 문서를 함께 LLM에 전달하여 답변을 생성하는 방식이다.</p>
<p>RAG의 전체 흐름은 다음과 같다.</p>
<pre><code class="language-text">사용자 질문

↓

질문 임베딩(Embedding)

↓

Vector Database 검색

↓

유사한 문서 추출

↓

질문 + 검색된 문서

↓

LLM

↓

최종 답변</code></pre>
<p>기존에는 AI가 자신의 학습 데이터만을 이용해 답변을 생성했다면, RAG는 <strong>외부 문서를 근거(Context)로 함께 제공</strong>하기 때문에 더욱 신뢰성 있는 답변을 생성할 수 있다는 점을 이해하였다.</p>
<hr>
<h3 id="embedding의-개념-이해">Embedding의 개념 이해</h3>
<p>RAG를 구현하기 위해서는 텍스트를 숫자로 변환하는 과정이 필요하다.</p>
<p>이를 <strong>Embedding</strong>이라고 한다.</p>
<p>Embedding은 사람이 읽는 문자열을 AI가 계산할 수 있는 고차원의 벡터(Vector)로 변환하는 기술이다.</p>
<p>예를 들어</p>
<pre><code class="language-text">&quot;사과&quot;

↓

[0.21, -0.43, 0.81, ...]</code></pre>
<p>처럼 하나의 문장이 수백 또는 수천 개의 실수값으로 변환된다.</p>
<p>이 벡터는 단순한 숫자가 아니라 문장의 의미를 포함하고 있기 때문에 의미가 비슷한 문장끼리는 벡터의 위치도 가까워진다.</p>
<p>이번 프로젝트에서는 Google의 Embedding Model을 이용하여 이러한 벡터를 생성하였다.</p>
<hr>
<h3 id="embeddingservice-구현">EmbeddingService 구현</h3>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class EmbeddingService {

    @Qualifier(&quot;googleGenAiTextEmbedding&quot;)
    private final EmbeddingModel embeddingModel;

    public float[] embed(String text) {
        return embeddingModel.embed(text);
    }
}</code></pre>
<p><code>EmbeddingModel</code>은 텍스트를 벡터로 변환하는 역할을 수행한다.</p>
<p>프로젝트에서는 <code>googleGenAiTextEmbedding</code> Bean을 주입받아 사용하였다.</p>
<pre><code class="language-java">embeddingModel.embed(text);</code></pre>
<p>메서드를 호출하면 입력한 문자열이 AI 모델을 통해 임베딩되고, 결과는 <code>float[]</code> 형태의 벡터로 반환된다.</p>
<p>이 과정에서 개발자는 벡터 계산 과정을 직접 구현하지 않아도 되며, Spring AI가 내부적으로 Google Embedding API와 통신하여 결과를 받아온다.</p>
<hr>
<h3 id="vector-database의-역할">Vector Database의 역할</h3>
<p>이번 프로젝트에서는 생성된 벡터를 저장하기 위해 <strong>Vector Database(VectorStore)</strong> 를 사용하였다.</p>
<p>일반적인 데이터베이스는</p>
<pre><code class="language-text">이름 = 홍길동
나이 = 25</code></pre>
<p>처럼 값을 비교한다.</p>
<p>반면 Vector Database는</p>
<pre><code class="language-text">질문 벡터

↓

문서 벡터

↓

유사도 계산</code></pre>
<p>을 수행한다.</p>
<p>즉, 문장의 의미를 기준으로 가장 비슷한 문서를 찾는 데이터베이스라는 점을 이해하였다.</p>
<hr>
<h3 id="spring-ai의-vectorstore">Spring AI의 VectorStore</h3>
<p>Spring AI에서는 Vector Database를 추상화한 <code>VectorStore</code> 인터페이스를 제공한다.</p>
<pre><code class="language-java">private final VectorStore vectorStore;</code></pre>
<p><code>VectorStore</code>는 개발자가 사용하는 추상 인터페이스이며, 내부적으로는 pgvector와 같은 실제 Vector Database와 연결된다.</p>
<p>덕분에 개발자는 특정 Vector DB 구현체에 의존하지 않고 동일한 코드로 다양한 Vector Database를 사용할 수 있다.</p>
<hr>
<h3 id="chatclient와-questionansweradvisor">ChatClient와 QuestionAnswerAdvisor</h3>
<pre><code class="language-java">@Bean
public ChatClient ragChatClient(
        ChatClient.Builder builder,
        VectorStore vectorStore
) {</code></pre>
<p>이번 프로젝트에서는 ChatClient를 생성하면서 VectorStore를 함께 주입하였다.</p>
<p>이를 통해 ChatClient는 질문을 받을 때마다 VectorStore에서 관련 문서를 검색할 수 있는 구조가 된다.</p>
<hr>
<h2 id="questionansweradvisor">QuestionAnswerAdvisor</h2>
<pre><code class="language-java">.defaultAdvisors(
    QuestionAnswerAdvisor.builder(vectorStore)</code></pre>
<p><code>QuestionAnswerAdvisor</code>는 RAG의 핵심 기능을 담당한다.</p>
<p>사용자가 질문하면 내부적으로 다음과 같은 과정을 자동으로 수행한다.</p>
<pre><code class="language-text">사용자 질문

↓

질문 임베딩

↓

VectorStore 검색

↓

관련 문서 조회

↓

Prompt 생성

↓

LLM 호출</code></pre>
<p>즉, 개발자가 직접</p>
<ul>
<li>문서를 검색하고</li>
<li>Prompt를 조합하는 코드를 작성하지 않아도</li>
</ul>
<p>Advisor가 자동으로 처리해 준다.</p>
<hr>
<h3 id="searchrequest를-이용한-검색-조건-설정">SearchRequest를 이용한 검색 조건 설정</h3>
<p>QuestionAnswerAdvisor에서는 검색 방식을 설정할 수 있다.</p>
<pre><code class="language-java">.searchRequest(
    SearchRequest
        .builder()
        .topK(4)
        .similarityThreshold(0.5)
        .build()
)</code></pre>
<p>여기서 새롭게 학습한 개념은 <strong>Top-K</strong>와 <strong>Similarity Threshold</strong>이다.</p>
<h4 id="top-k">Top-K</h4>
<pre><code class="language-java">.topK(4)</code></pre>
<p>질문과 가장 유사한 문서를 최대 4개까지 검색한다.</p>
<p>예를 들어</p>
<p>검색 결과가</p>
<pre><code class="language-text">문서1 98%

문서2 94%

문서3 91%

문서4 87%

문서5 82%</code></pre>
<p>라면</p>
<p>Top-K가 4이므로 문서1~문서4까지만 AI에게 전달된다.</p>
<hr>
<h4 id="similarity-threshold">Similarity Threshold</h4>
<pre><code class="language-java">.similarityThreshold(0.5)</code></pre>
<p>는 최소 유사도를 의미한다.</p>
<p>예를 들어</p>
<pre><code class="language-text">문서1 0.91

문서2 0.74

문서3 0.52

문서4 0.31</code></pre>
<p>이라면</p>
<p>Threshold가 0.5이므로</p>
<p>문서4는 제외된다.</p>
<p>이를 통해 검색 품질을 높이고 관련성이 낮은 문서가 AI에게 전달되는 것을 방지할 수 있다는 점을 학습하였다.</p>
<hr>
<h3 id="system-prompt를-이용한-rag-응답-제어">System Prompt를 이용한 RAG 응답 제어</h3>
<pre><code class="language-java">.defaultSystem(&quot;&quot;&quot;
주어진 컨텍스트를 기반으로 할 것.
컨텍스트에 없는 내용은 지어내지 말고
&#39;찾을 수 없다&#39;라고 답할 것.
답변은 마크다운으로 작성
&quot;&quot;&quot;)</code></pre>
<p>RAG에서는 검색된 문서를 AI에게 전달하는 것만큼 중요한 것이 <strong>System Prompt</strong>이다.</p>
<p>이번 프로젝트에서는 AI에게 다음과 같은 규칙을 부여하였다.</p>
<ul>
<li>검색된 문서를 기준으로 답변할 것</li>
<li>문서에 없는 내용은 추측하지 말 것</li>
<li>찾을 수 없으면 없다고 답변할 것</li>
<li>마크다운 형식으로 응답할 것</li>
</ul>
<p>이를 통해 Hallucination을 줄이고 신뢰성 있는 답변을 생성하는 방법을 학습하였다.</p>
<hr>
<h3 id="학습한-내용">학습한 내용</h3>
<table>
<thead>
<tr>
<th>학습 내용</th>
<th>세부 내용</th>
</tr>
</thead>
<tbody><tr>
<td>RAG</td>
<td>검색(Retrieval)과 생성(Generation)을 결합한 AI 응답 방식</td>
</tr>
<tr>
<td>Embedding</td>
<td>텍스트를 의미 기반 벡터로 변환하는 과정</td>
</tr>
<tr>
<td>EmbeddingModel</td>
<td>Google Embedding 모델을 이용한 벡터 생성</td>
</tr>
<tr>
<td>VectorStore</td>
<td>벡터를 저장하고 유사도 검색을 수행하는 인터페이스</td>
</tr>
<tr>
<td>ChatClient</td>
<td>RAG 기능이 적용된 AI 클라이언트 구성</td>
</tr>
<tr>
<td>QuestionAnswerAdvisor</td>
<td>질문과 관련된 문서를 검색하여 Prompt에 자동으로 포함</td>
</tr>
<tr>
<td>SearchRequest</td>
<td>Top-K와 Similarity Threshold를 이용한 검색 조건 설정</td>
</tr>
<tr>
<td>System Prompt</td>
<td>AI가 검색된 문서를 기반으로만 답변하도록 제어하는 방법</td>
</tr>
</tbody></table>
<hr>
<h3 id="느낀-점">느낀 점</h3>
<p>이번 프로젝트를 통해 <strong>RAG는 단순히 AI에게 질문을 전달하는 기술이 아니라, 검색 시스템과 생성형 AI를 결합하여 답변의 정확성과 신뢰성을 높이는 구조</strong>라는 점을 이해할 수 있었다. 특히 Embedding을 통해 문장을 의미 기반의 벡터로 변환하고, Vector Database에서 가장 유사한 문서를 검색한 뒤 <code>QuestionAnswerAdvisor</code>가 이를 자동으로 프롬프트에 포함시키는 과정을 학습하면서 Spring AI가 RAG 파이프라인을 얼마나 효율적으로 추상화하고 있는지 체감할 수 있었다. 또한 Top-K와 Similarity Threshold를 조정하여 검색 결과의 품질을 제어하고, System Prompt를 통해 AI가 문서에 없는 내용을 추측하지 않도록 설정하는 방법을 익히면서 <strong>실제 서비스에서 신뢰성 있는 AI 응답을 제공하기 위한 핵심 요소</strong>들을 배울 수 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26/07/29 IL(I Learned) - Spring AI 2 (2)]]></title>
            <link>https://velog.io/@baeksh_8/260729-ILI-Learned-Spring-AI-2-2</link>
            <guid>https://velog.io/@baeksh_8/260729-ILI-Learned-Spring-AI-2-2</guid>
            <pubDate>Thu, 30 Jul 2026 01:13:40 GMT</pubDate>
            <description><![CDATA[<h2 id="spring-data-jpa를-활용한-chat-memory-관리와-객체-중심-데이터-처리">Spring Data JPA를 활용한 Chat Memory 관리와 객체 중심 데이터 처리</h2>
<p>Spring Data JPA를 활용하여 AI Chat Memory를 데이터베이스에 저장하고 관리하는 기능을 구현하였다. 이전에는 MyBatis를 사용하여 SQL을 직접 작성하고 Mapper를 통해 데이터베이스를 제어하였다면, 이번에는 <strong>객체(Entity)를 중심으로 데이터를 관리하는 ORM(Object Relational Mapping) 방식</strong>을 적용하였다.</p>
<p>JPA를 활용하면서 SQL을 직접 작성하지 않고도 Repository를 통해 데이터를 조회·저장·삭제할 수 있었으며, 엔티티(Entity)를 영속성 컨텍스트(Persistence Context)가 관리하는 과정을 직접 실습하였다. 또한 Spring Data JPA가 제공하는 기본 CRUD 기능과 JPQL, 트랜잭션 관리, 영속성 컨텍스트의 Dirty Checking 등 JPA의 핵심 개념을 학습하였다.</p>
<p>이를 통해 데이터베이스의 테이블을 직접 다루는 것이 아니라 <strong>객체를 중심으로 비즈니스 로직을 구현하는 개발 방식</strong>을 이해할 수 있었다.</p>
<hr>
<h3 id="jpa와-ormobject-relational-mapping의-이해">JPA와 ORM(Object Relational Mapping)의 이해</h3>
<p>이번 프로젝트를 진행하면서 가장 먼저 학습한 내용은 ORM(Object Relational Mapping)의 개념이었다.</p>
<p>기존의 JDBC나 MyBatis는 SQL을 직접 작성하여 데이터베이스의 테이블을 조작하는 방식이었다. 개발자가 SELECT, INSERT, UPDATE, DELETE 문을 작성하고, 조회된 결과를 다시 Java 객체로 변환하는 과정을 직접 구현해야 했다.</p>
<p>반면 JPA는 데이터베이스의 테이블을 하나의 객체(Entity)로 매핑하여 관리한다.</p>
<p>예를 들어 Chat Message 테이블은 다음과 같은 Entity로 표현하였다.</p>
<pre><code class="language-java">@Entity
@Table(name = &quot;chat_message&quot;)
public class ChatMessageJPA {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String conversationId;

    private String messageType;

    private String content;

    private int seq;

}</code></pre>
<p><code>@Entity</code>는 해당 클래스가 데이터베이스 테이블과 매핑되는 객체임을 의미한다.</p>
<p><code>@Table</code>은 어떤 테이블과 연결되는지를 지정하며, <code>@Id</code>는 기본 키(Primary Key)를 나타낸다.</p>
<p><code>@GeneratedValue</code>를 사용하여 기본 키 값을 데이터베이스가 자동으로 생성하도록 설정하였다.</p>
<p>이를 통해 SQL을 직접 작성하지 않고도 객체를 저장하거나 조회할 수 있는 JPA의 기본 구조를 이해할 수 있었다.</p>
<hr>
<h3 id="spring-data-jpa-repository-활용">Spring Data JPA Repository 활용</h3>
<p>이번 프로젝트에서는 Spring Data JPA가 제공하는 <code>JpaRepository</code>를 상속받아 Repository를 구현하였다.</p>
<pre><code class="language-java">public interface ChatMemoryJpaRepository
        extends JpaRepository&lt;ChatMessageJPA, Long&gt; {

    List&lt;ChatMessageJPA&gt; findAllByConversationId(
            String conversationId);

    @Query(
        &quot;SELECT DISTINCT cm.conversationId FROM ChatMessageJPA cm&quot;
    )
    List&lt;String&gt; findConversationIds();

    void deleteAllByConversationId(
            String conversationId);

}</code></pre>
<p><code>JpaRepository</code>는 CRUD 기능을 기본적으로 제공한다.</p>
<p>따라서 별도의 SQL을 작성하지 않아도</p>
<ul>
<li>save()</li>
<li>saveAll()</li>
<li>findById()</li>
<li>findAll()</li>
<li>delete()</li>
</ul>
<p>등을 사용할 수 있다.</p>
<p>또한 메서드 이름만으로 SQL이 생성되는 <strong>Query Method</strong> 기능을 활용하였다.</p>
<p>예를 들어</p>
<pre><code class="language-java">findAllByConversationId(...)</code></pre>
<p>메서드는 별도의 SQL을 작성하지 않아도</p>
<pre><code class="language-sql">SELECT *
FROM chat_message
WHERE conversation_id = ?</code></pre>
<p>와 동일한 기능을 수행한다.</p>
<p>이를 통해 SQL 작성량을 크게 줄일 수 있다는 점을 학습하였다.</p>
<hr>
<h3 id="jpql을-이용한-객체-중심-조회">JPQL을 이용한 객체 중심 조회</h3>
<p>기본 CRUD 외에도 직접 조회 조건이 필요한 경우에는 JPQL을 사용하였다.</p>
<pre><code class="language-java">@Query(
&quot;SELECT DISTINCT cm.conversationId
FROM ChatMessageJPA cm&quot;
)</code></pre>
<p>JPQL은 SQL과 비슷하지만 테이블이 아닌 <strong>Entity 객체를 대상으로 조회</strong>한다.</p>
<p>SQL이라면</p>
<pre><code class="language-sql">SELECT DISTINCT conversation_id
FROM chat_message</code></pre>
<p>가 되지만,</p>
<p>JPQL에서는</p>
<pre><code class="language-java">SELECT cm.conversationId
FROM ChatMessageJPA cm</code></pre>
<p>처럼 Entity 이름과 객체의 필드를 사용한다.</p>
<p>이를 통해 JPA는 객체 중심으로 동작한다는 점을 이해하였다.</p>
<hr>
<h3 id="chatmemoryrepository-구현">ChatMemoryRepository 구현</h3>
<p>Spring AI와 JPA를 연결하기 위해 <code>ChatMemoryRepository</code>를 구현하였다.</p>
<pre><code class="language-java">@Repository
@RequiredArgsConstructor
public class JpaChatMemoryRepository
        implements ChatMemoryRepository</code></pre>
<p>Spring AI는 Chat Memory를 저장하는 방식에 대해 인터페이스만 제공한다.</p>
<p>이번 프로젝트에서는 JPA Repository를 이용하여</p>
<ul>
<li>대화 저장</li>
<li>대화 조회</li>
<li>대화 삭제</li>
</ul>
<p>기능을 구현하였다.</p>
<p>이를 통해 Spring AI와 Spring Data JPA를 연결하는 구조를 이해할 수 있었다.</p>
<hr>
<h3 id="entity와-message-객체의-변환">Entity와 Message 객체의 변환</h3>
<p>Spring AI는 <code>Message</code> 객체를 사용하지만 JPA는 Entity를 저장한다.</p>
<p>따라서 두 객체를 서로 변환하는 과정이 필요하였다.</p>
<pre><code class="language-java">public static ChatMessageJPA fromMessage(
        Message message,
        String conversationId,
        int seq)</code></pre>
<p>반대로</p>
<pre><code class="language-java">public Message toMessage()</code></pre>
<p>를 통해 Entity를 Message 객체로 변환하였다.</p>
<p>Entity는 데이터베이스에 저장하기 위한 객체이고,</p>
<p>Message는 Spring AI가 사용하는 객체이다.</p>
<p>따라서</p>
<pre><code>Message

↓

Entity 저장

↓

DB

↓

Entity 조회

↓

Message</code></pre><p>과 같은 변환 과정을 직접 구현하였다.</p>
<p>이를 통해 서로 다른 계층에서 사용하는 객체를 변환하는 방법을 학습하였다.</p>
<hr>
<h3 id="saveall을-이용한-데이터-저장">saveAll()을 이용한 데이터 저장</h3>
<p>Chat Memory는 saveAll()을 이용하여 저장하였다.</p>
<pre><code class="language-java">repository.deleteAllByConversationId(
        conversationId);

...

repository.saveAll(chatMessages);</code></pre>
<p>새로운 대화가 저장될 때</p>
<p>먼저 기존 대화를 삭제하고,</p>
<p>현재 메모리에 존재하는 대화를 모두 저장하였다.</p>
<p>Spring Data JPA에서는</p>
<pre><code class="language-java">repository.saveAll(...)</code></pre>
<p>만 호출하면</p>
<p>Entity 개수만큼 INSERT SQL을 자동으로 생성하여 실행한다.</p>
<p>이를 통해 SQL을 직접 작성하지 않고도 여러 데이터를 저장하는 방법을 학습하였다.</p>
<hr>
<h3 id="트랜잭션transaction-관리">트랜잭션(Transaction) 관리</h3>
<p>이번 프로젝트에서는 저장과 삭제를 하나의 작업으로 처리하기 위해 <code>@Transactional</code>을 사용하였다.</p>
<pre><code class="language-java">@Transactional
public void saveAll(...)</code></pre>
<p>Chat Memory 저장 과정에서는</p>
<ol>
<li>기존 데이터 삭제</li>
<li>새로운 데이터 저장</li>
</ol>
<p>두 작업이 하나의 작업 단위로 처리되어야 한다.</p>
<p>만약 삭제는 성공하고 저장에서 오류가 발생하면 대화 기록이 모두 사라지는 문제가 발생한다.</p>
<p><code>@Transactional</code>을 적용하면 두 작업을 하나의 트랜잭션으로 처리하여 오류 발생 시 전체 작업을 Rollback한다.</p>
<p>이를 통해 데이터의 일관성을 유지하는 트랜잭션의 중요성을 이해할 수 있었다.</p>
<hr>
<h3 id="spring-data-jpa가-제공하는-생산성">Spring Data JPA가 제공하는 생산성</h3>
<p>이번 프로젝트를 진행하면서 가장 크게 느낀 점은 개발 생산성이었다.</p>
<p>MyBatis에서는</p>
<ul>
<li>SQL 작성</li>
<li>Mapper 작성</li>
<li>XML 작성</li>
</ul>
<p>과정을 거쳐야 했다.</p>
<p>반면 JPA에서는</p>
<ul>
<li>Entity 작성</li>
<li>Repository 생성</li>
</ul>
<p>만으로 대부분의 CRUD 기능을 구현할 수 있었다.</p>
<p>또한 Query Method를 활용하면 메서드 이름만으로 SQL이 자동 생성되기 때문에 반복적인 SQL 작성이 크게 줄어드는 것을 경험하였다.</p>
<p>이를 통해 Spring Data JPA가 제공하는 높은 생산성과 유지보수성을 체감할 수 있었다.</p>
<hr>
<h3 id="영속성-컨텍스트persistence-context의-이해">영속성 컨텍스트(Persistence Context)의 이해</h3>
<p>이번 프로젝트에서 새롭게 이해한 개념 중 하나는 <strong>영속성 컨텍스트(Persistence Context)</strong>였다.</p>
<p>JPA에서 조회한 Entity는 단순한 Java 객체가 아니라 영속성 컨텍스트에 의해 관리되는 객체가 된다.</p>
<p>즉, Entity를 조회하면 JPA는 해당 객체를 메모리에서 관리하고 있으며, 트랜잭션이 종료되는 시점까지 객체의 상태 변화를 계속 추적한다.</p>
<p>이러한 기능 덕분에 개발자는 SQL의 UPDATE 문을 직접 작성하지 않고도 객체의 값을 변경하는 것만으로 데이터베이스의 값이 자동으로 수정된다.</p>
<p>비록 이번 Chat Memory 프로젝트에서는 대부분 저장(saveAll)과 삭제(deleteAll)를 중심으로 구현하였지만, 이전 JPA 프로젝트에서 학습한 Dirty Checking 개념과 연결하여 <strong>JPA는 객체의 상태를 관리하는 ORM 프레임워크</strong>라는 점을 더욱 명확하게 이해할 수 있었다.</p>
<hr>
<h3 id="새롭게-학습한-내용">새롭게 학습한 내용</h3>
<table>
<thead>
<tr>
<th>학습 내용</th>
<th>세부 내용</th>
</tr>
</thead>
<tbody><tr>
<td>ORM</td>
<td>객체(Entity)와 테이블을 매핑하여 데이터 관리</td>
</tr>
<tr>
<td>Entity</td>
<td><code>@Entity</code>, <code>@Table</code>, <code>@Id</code>를 이용한 객체 매핑</td>
</tr>
<tr>
<td>Spring Data JPA</td>
<td><code>JpaRepository</code>를 통한 CRUD 자동 제공</td>
</tr>
<tr>
<td>Query Method</td>
<td>메서드 이름만으로 SQL 자동 생성</td>
</tr>
<tr>
<td>JPQL</td>
<td>객체(Entity)를 대상으로 데이터를 조회하는 방법</td>
</tr>
<tr>
<td>ChatMemoryRepository</td>
<td>Spring AI와 JPA를 연결하는 저장소 구현</td>
</tr>
<tr>
<td>Entity 변환</td>
<td>Spring AI Message 객체와 Entity 간의 변환</td>
</tr>
<tr>
<td>saveAll()</td>
<td>Entity 리스트를 한 번에 저장하는 방법</td>
</tr>
<tr>
<td>Transaction</td>
<td>삭제와 저장을 하나의 작업 단위로 처리하여 데이터 일관성 유지</td>
</tr>
<tr>
<td>Persistence Context</td>
<td>Entity의 상태를 관리하고 객체 중심으로 데이터를 처리하는 JPA의 핵심 개념 이해</td>
</tr>
</tbody></table>
<hr>
<h3 id="느낀-점">느낀 점</h3>
<p><strong>JPA는 단순히 SQL을 자동으로 생성해 주는 기술이 아니라 객체 중심으로 데이터를 관리하는 ORM 프레임워크</strong>라는 점을 깊이 이해할 수 있었다. Spring Data JPA의 <code>JpaRepository</code>를 활용하면서 반복적인 CRUD SQL을 작성하지 않아도 대부분의 기능을 구현할 수 있었고, Query Method와 JPQL을 통해 필요한 조회 기능을 객체 중심으로 구현하는 방법도 익힐 수 있었다. 또한 Entity를 영속성 컨텍스트가 관리한다는 개념과 트랜잭션을 통한 데이터 일관성 유지 과정을 학습하면서, JPA가 데이터베이스와 객체 사이의 매핑을 넘어 애플리케이션의 유지보수성과 생산성을 높여 주는 핵심 기술이라는 점을 체감할 수 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26/07/29 IL(I Learned) - Spring AI 2 (1)]]></title>
            <link>https://velog.io/@baeksh_8/260729-ILI-Learned-Spring-AI-2-1</link>
            <guid>https://velog.io/@baeksh_8/260729-ILI-Learned-Spring-AI-2-1</guid>
            <pubDate>Thu, 30 Jul 2026 01:04:35 GMT</pubDate>
            <description><![CDATA[<h2 id="mybatis를-활용한-ai-chat-memory-관리와-데이터-접근-계층-구현">MyBatis를 활용한 AI Chat Memory 관리와 데이터 접근 계층 구현</h2>
<p>Spring AI의 Chat Memory 기능을 MyBatis와 연동하여 AI와 사용자의 대화 내용을 데이터베이스에 저장하고, 이전 대화 내용을 다시 불러와 대화를 이어갈 수 있는 기능을 구현하였다.</p>
<p>기존에는 AI와의 대화가 한 번의 요청과 응답으로 끝나는 구조였다면, 이번에는 <strong>사용자의 대화 기록을 데이터베이스에 영속적으로 저장(Persistence)</strong> 하여 이전 대화를 기억하는 AI 서비스를 구현하는 방법을 학습하였다.</p>
<p>이를 위해 MyBatis를 이용하여 SQL을 직접 작성하고, Mapper 인터페이스를 통해 데이터베이스와 통신하는 구조를 설계하였다. 또한 Spring AI에서 제공하는 <code>ChatMemoryRepository</code> 인터페이스를 직접 구현하여 Spring AI와 MyBatis를 연결하는 방법도 익힐 수 있었다.</p>
<hr>
<h3 id="chat-memory의-개념-이해">Chat Memory의 개념 이해</h3>
<p>가장 먼저 이해해야 했던 개념은 <strong>Chat Memory</strong>였다.</p>
<p>일반적으로 생성형 AI는 사용자가 질문을 보내면 그 질문에 대해서만 답변을 생성한다.</p>
<p>예를 들어</p>
<pre><code>사용자 : 나는 백엔드 개발자야.

AI : 반갑습니다.

-------------------------

사용자 : 내가 방금 무슨 직업이라고 했지?

AI : 알 수 없습니다.</code></pre><p>이처럼 이전 대화를 기억하지 못한다.</p>
<p>하지만 Chat Memory를 사용하면 이전 대화를 저장해 두었다가 다음 요청 시 함께 AI에게 전달한다.</p>
<pre><code>사용자 : 나는 백엔드 개발자야.

AI : 반갑습니다.

-------------------------

사용자 : 내가 방금 무슨 직업이라고 했지?

AI : 백엔드 개발자라고 말씀하셨습니다.</code></pre><p>즉, Chat Memory는 AI가 기억력이 생긴 것처럼 보이도록 이전 대화를 함께 전달하는 기능이라는 점을 이해하였다.</p>
<hr>
<h3 id="spring-ai의-chatmemoryrepository-구현">Spring AI의 ChatMemoryRepository 구현</h3>
<p>Spring AI에서 제공하는 <code>ChatMemoryRepository</code>를 직접 구현하였다.</p>
<pre><code class="language-java">@Repository
@RequiredArgsConstructor
public class MyBatisChatMemoryRepository
        implements ChatMemoryRepository</code></pre>
<p>Spring AI는 Chat Memory를 어디에 저장할지 정해져 있지 않다.</p>
<p>대신</p>
<ul>
<li>InMemory</li>
<li>Redis</li>
<li>MySQL</li>
<li>PostgreSQL</li>
</ul>
<p>등 다양한 저장소를 사용할 수 있도록 <code>ChatMemoryRepository</code> 인터페이스를 제공한다.</p>
<p>이번 프로젝트에서는 MyBatis를 이용하여 MySQL에 저장하기 위해 직접 구현체를 작성하였다.</p>
<p>이를 통해 Spring AI의 인터페이스를 구현하여 원하는 저장 방식을 자유롭게 만들 수 있다는 점을 학습하였다.</p>
<hr>
<h3 id="mapper를-이용한-데이터-접근">Mapper를 이용한 데이터 접근</h3>
<p>MyBatis는 Repository가 SQL을 직접 실행하지 않는다.</p>
<p>반드시 Mapper를 통해 SQL을 실행한다.</p>
<pre><code class="language-java">@Mapper
public interface ChatMessageMapper {

    List&lt;String&gt; findConversationIds();

    List&lt;ChatMessageMyBatis&gt; findByConversationId(String conversationId);

    void insertAll(List&lt;ChatMessageMyBatis&gt; messages);

    void deleteByConversationId(String conversationId);

}</code></pre>
<p>Mapper 인터페이스는 SQL과 Java를 연결해 주는 역할을 한다.</p>
<p>이번 프로젝트에서는</p>
<ul>
<li>저장된 대화 목록 조회</li>
<li>특정 대화 조회</li>
<li>여러 메시지 저장</li>
<li>특정 대화 삭제</li>
</ul>
<p>기능을 Mapper에 정의하였다.</p>
<p>실제 SQL은 XML Mapper에서 작성되고, Java에서는 메서드만 호출하면 SQL이 실행되는 구조라는 점을 이해하였다.</p>
<p>이를 통해 SQL과 Java 코드를 분리하는 MyBatis의 구조를 학습하였다.</p>
<hr>
<h3 id="conversation-id를-이용한-대화-구분">Conversation ID를 이용한 대화 구분</h3>
<p>이번 프로젝트에서는 여러 사용자의 대화를 구분하기 위해 Conversation ID를 사용하였다.</p>
<pre><code class="language-java">public record ChatDTO(
        String message,
        String conversationId
)</code></pre>
<p>Controller에서는</p>
<pre><code class="language-java">dto.withID(session.getId())</code></pre>
<p>를 이용하여 현재 세션 ID를 Conversation ID로 사용하였다. </p>
<p>사용자가 여러 명일 경우 모든 대화를 하나의 테이블에 저장하면 어떤 메시지가 누구의 것인지 구분할 수 없다.</p>
<p>따라서 세션 ID를 Conversation ID로 사용하여</p>
<pre><code>conversation_id

AAA111

AAA111

AAA111

BBB222

BBB222

CCC333</code></pre><p>처럼 사용자별 대화를 구분하였다.</p>
<p>이를 통해 AI 서비스에서도 사용자 식별이 매우 중요하다는 점을 학습하였다.</p>
<hr>
<h3 id="message-객체와-entity-변환">Message 객체와 Entity 변환</h3>
<p>Spring AI는 Message 객체를 사용하지만</p>
<p>MyBatis는 Entity를 저장한다.</p>
<p>따라서 두 객체를 서로 변환하는 과정이 필요하였다.</p>
<pre><code class="language-java">public static ChatMessageMyBatis fromMessage(
        Message message,
        String conversationId,
        int seq
)</code></pre>
<p>그리고</p>
<pre><code class="language-java">public Message toMessage()</code></pre>
<p>AI는 Message 객체를 사용한다.</p>
<p>하지만 데이터베이스에는 객체를 그대로 저장할 수 없기 때문에</p>
<pre><code>conversation_id

message_type

content

seq</code></pre><p>형태로 저장하였다.</p>
<p>필요할 때 다시 Message 객체로 변환하여 Spring AI가 사용할 수 있도록 구현하였다.</p>
<p>이를 통해 객체와 데이터베이스 간의 변환 과정을 직접 구현하는 방법을 학습하였다.</p>
<hr>
<h3 id="saveall-동작-원리">saveAll() 동작 원리</h3>
<p>가장 중요한 로직은 saveAll()이었다.</p>
<pre><code class="language-java">chatMessageMapper.deleteByConversationId(conversationId);

...

chatMessageMapper.insertAll(chatMessages);</code></pre>
<p>새로운 메시지가 저장될 때</p>
<p>기존 데이터를 UPDATE 하는 것이 아니라</p>
<p>1.</p>
<p>기존 대화를 모두 삭제</p>
<p>↓</p>
<p>2.</p>
<p>현재 메모리에 있는 대화를 모두 INSERT</p>
<p>하는 방식을 사용하였다.</p>
<p>예를 들어</p>
<pre><code>USER

안녕

ASSISTANT

안녕하세요.</code></pre><p>가 저장되어 있다가</p>
<p>새로운 질문</p>
<pre><code>오늘 날씨 어때?</code></pre><p>가 들어오면</p>
<p>기존 데이터를 삭제한 뒤</p>
<pre><code>안녕

안녕하세요

오늘 날씨 어때?

맑습니다.</code></pre><p>전체를 다시 저장한다.</p>
<p>이 방식은 코드가 단순하며 MessageWindowChatMemory와 잘 동작하도록 설계되어 있다는 점을 이해하였다.</p>
<hr>
<h3 id="messagewindowchatmemory">MessageWindowChatMemory</h3>
<p>이번 프로젝트에서는 모든 대화를 저장하지 않고 최근 메시지만 기억하도록 설정하였다.</p>
<pre><code class="language-java">MessageWindowChatMemory.builder()

.chatMemoryRepository(chatMemoryRepository)

.maxMessages(3)</code></pre>
<p>maxMessages(3)은</p>
<p>최근 3개의 메시지만 AI에게 전달한다.</p>
<p>예를 들어</p>
<pre><code>1

안녕

2

반가워

3

점심 추천해줘

4

김치찌개 추천

5

오늘 날씨는?</code></pre><p>라면</p>
<p>AI는</p>
<pre><code>점심 추천해줘

김치찌개 추천

오늘 날씨는?</code></pre><p>만 전달받는다.</p>
<p>이를 통해 너무 많은 대화를 보내지 않아</p>
<ul>
<li>응답 속도 향상</li>
<li>토큰 절약</li>
<li>API 비용 감소</li>
</ul>
<p>효과를 얻을 수 있다는 점을 학습하였다.</p>
<hr>
<h3 id="chatclient와-chat-memory-연결">ChatClient와 Chat Memory 연결</h3>
<p>MyBatis로 구현한 Chat Memory는 ChatClient에 연결하여 사용하였다.</p>
<pre><code class="language-java">@Bean

public ChatClient mybatisChatClient(

ChatModel chatModel,

@Qualifier(&quot;mybatisChatMemory&quot;)
ChatMemory memory
)</code></pre>
<p>그리고</p>
<pre><code class="language-java">.advisors(
a -&gt; a.param(
ChatMemory.CONVERSATION_ID,
dto.conversationId()
))</code></pre>
<p>를 이용하여 현재 대화 ID를 전달하였다.</p>
<p>Conversation ID를 Advisor에 전달하면</p>
<p>Spring AI는</p>
<p>1.</p>
<p>Conversation ID로 이전 대화를 조회하고</p>
<p>↓</p>
<p>2.</p>
<p>Prompt 앞에 이전 대화를 붙인 뒤</p>
<p>↓</p>
<p>3.</p>
<p>AI에게 함께 전송한다.</p>
<p>즉, 개발자가 직접 이전 대화를 Prompt에 이어붙이지 않아도 Spring AI가 자동으로 Chat Memory를 활용한다는 점을 학습하였다.</p>
<hr>
<h3 id="새롭게-학습한-내용">새롭게 학습한 내용</h3>
<table>
<thead>
<tr>
<th>학습 내용</th>
<th>세부 내용</th>
</tr>
</thead>
<tbody><tr>
<td>MyBatis Mapper</td>
<td>Mapper 인터페이스를 통해 SQL과 Java를 연결하는 구조</td>
</tr>
<tr>
<td>ChatMemoryRepository</td>
<td>Spring AI 인터페이스를 직접 구현하여 저장소를 개발하는 방법</td>
</tr>
<tr>
<td>Conversation ID</td>
<td>사용자별 대화를 구분하여 여러 사용자의 Chat Memory를 관리하는 방법</td>
</tr>
<tr>
<td>Entity 변환</td>
<td>Spring AI Message 객체와 DB Entity 간의 상호 변환 과정</td>
</tr>
<tr>
<td>MessageWindowChatMemory</td>
<td>최근 대화만 유지하여 토큰 사용량과 비용을 절감하는 방법</td>
</tr>
<tr>
<td>ChatClient Advisor</td>
<td>Conversation ID를 전달하여 이전 대화를 자동으로 불러오는 방법</td>
</tr>
<tr>
<td>트랜잭션 처리</td>
<td>여러 메시지를 하나의 작업 단위로 저장하고 삭제하는 과정에서 데이터의 일관성을 유지하는 방법</td>
</tr>
</tbody></table>
]]></description>
        </item>
    </channel>
</rss>