<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>IT 연구소</title>
    <link>https://itlab0816.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Thu, 25 Jun 2026 06:11:53 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>IT Lab</managingEditor>
    <item>
      <title>Spring Boot JPA Auditing(@CreatedDate 등)로 생성/수정 시간 자동 기록하기</title>
      <link>https://itlab0816.tistory.com/103</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot 3.x에서 JPA Auditing으로 createdAt/updatedAt을 자동 채우는 방법과 베이스 엔티티 설계, 운영에서 자주 겪는 함정을 정리합니다.&lt;/p&gt;
&lt;h2 id=&quot;도입-문제-상황&quot; data-ke-size=&quot;size26&quot;&gt;도입 (문제 상황)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;엔티티를 저장할 때마다 &lt;code&gt;createdAt&lt;/code&gt;, &lt;code&gt;updatedAt&lt;/code&gt;을 매번 세팅하다 보면, 한두 군데는 꼭 빠지기 마련입니다. 특히 배치/관리자 기능/테스트 코드에서 누락되면 &amp;ldquo;왜 어떤 데이터는 시간이 비어 있지?&amp;rdquo; 같은 운영 이슈로 이어지기도 해요. 이럴 때 JPA Auditing을 쓰면 생성/수정 시간을 거의 자동화할 수 있습니다.&lt;/p&gt;
&lt;h2 id=&quot;핵심-개념--spring-boot-jpa-auditing이-중요한-이유&quot; data-ke-size=&quot;size26&quot;&gt;핵심 개념 &amp;mdash; Spring Boot JPA Auditing이 중요한 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPA Auditing은 엔티티의 &amp;ldquo;언제 생성/수정됐는지(그리고 누가 했는지)&amp;rdquo; 같은 메타 정보를 &lt;b&gt;영속성 이벤트 시점&lt;/b&gt;에 자동으로 채워주는 기능입니다. 핵심은 두 가지예요.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;애플리케이션 코드에서 시간 세팅 책임을 제거&lt;/b&gt;합니다.&lt;br /&gt;서비스/컨트롤러/배치 어디에서 저장하든, 엔티티가 &lt;code&gt;persist&lt;/code&gt;/&lt;code&gt;update&lt;/code&gt; 되는 순간에 동일한 규칙으로 값이 들어갑니다. 마치 &amp;ldquo;DB 트리거&amp;rdquo;처럼 동작하지만, 로직은 애플리케이션에 있어 테스트와 유지보수가 쉬워요.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;베이스 엔티티로 표준화&lt;/b&gt;할 수 있습니다.&lt;br /&gt;여러 엔티티에 &lt;code&gt;createdAt&lt;/code&gt;, &lt;code&gt;updatedAt&lt;/code&gt; 필드를 반복해서 만들지 않고, &lt;code&gt;@MappedSuperclass&lt;/code&gt; 기반의 베이스 클래스로 공통화하면 누락도 줄고 일관성도 좋아집니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 &amp;ldquo;완전 자동&amp;rdquo;이라고 해서 아무 생각 없이 붙이면 운영에서 문제가 생길 수 있는 포인트도 있습니다. 대표적으로는:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;시간대(UTC vs KST) 정책이 불명확해서 데이터가 뒤섞임&lt;/li&gt;
&lt;li&gt;&lt;code&gt;updatedAt&lt;/code&gt;이 기대대로 갱신되지 않는 케이스(변경 감지/더티체킹 조건)&lt;/li&gt;
&lt;li&gt;DB 기본값/트리거와 Auditing이 충돌&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 예제로 가장 흔한 형태(생성/수정 시간 자동화 + 베이스 엔티티)를 Spring Boot 3.x 기준으로 정리해 보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;prolog&quot;&gt;&lt;code&gt;flowchart LR
  A[&quot;Service&quot;] --&amp;gt; B[&quot;Repository save()&quot;]
  B --&amp;gt; C[&quot;JPA EntityManager&quot;]
  C --&amp;gt; D[&quot;AuditingEntityListener&quot;]
  D --&amp;gt; E[&quot;@CreatedDate / @LastModifiedDate set&quot;]
  E --&amp;gt; F[&quot;DB Insert/Update&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPA 저장/수정 이벤트에서 Auditing 리스너가 값을 채운 뒤 DB에 반영되는 흐름입니다.&lt;/p&gt;
&lt;h2 id=&quot;코드-예제--createddatelastmodifieddate-베이스-엔티티로-바로-적용하기&quot; data-ke-size=&quot;size26&quot;&gt;코드 예제 &amp;mdash; @CreatedDate/@LastModifiedDate 베이스 엔티티로 바로 적용하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 코드는 그대로 복붙해서 실행 가능한 최소 구성입니다. (Java 17, Spring Boot 3.x)&lt;/p&gt;
&lt;h3 id=&quot;buildgradle&quot; data-ke-size=&quot;size23&quot;&gt;build.gradle&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;plugins {
    id 'java'
    id 'org.springframework.boot' version '3.3.2'
    id 'io.spring.dependency-management' version '1.1.6'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    runtimeOnly 'com.h2database:h2'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
    useJUnitPlatform()
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;applicationyml&quot; data-ke-size=&quot;size23&quot;&gt;application.yml&lt;/h3&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;spring:
  datasource:
    url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
    driver-class-name: org.h2.Driver
    username: sa
    password:

  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        format_sql: true

logging:
  level:
    org.hibernate.SQL: debug
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운영 DB를 쓰실 때도 Auditing 자체는 동일하지만, 시간대 정책(UTC 고정 등)은 별도로 정하시는 게 중요합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;auditing-활성화-설정&quot; data-ke-size=&quot;size23&quot;&gt;Auditing 활성화 설정&lt;/h3&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;package com.example.demo;

import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@Configuration
@EnableJpaAuditing
public class JpaAuditingConfig {
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;베이스-엔티티-생성수정-시간&quot; data-ke-size=&quot;size23&quot;&gt;베이스 엔티티 (생성/수정 시간)&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;package com.example.demo.domain;

import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.Instant;

@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {

    @CreatedDate
    @Column(name = &quot;created_at&quot;, nullable = false, updatable = false)
    private Instant createdAt;

    @LastModifiedDate
    @Column(name = &quot;updated_at&quot;, nullable = false)
    private Instant updatedAt;

    public Instant getCreatedAt() {
        return createdAt;
    }

    public Instant getUpdatedAt() {
        return updatedAt;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;Instant&lt;/code&gt;를 쓰면 시간대 혼선을 줄이기 좋습니다(UTC 기반).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;createdAt&lt;/code&gt;은 &lt;code&gt;updatable = false&lt;/code&gt;로 막아두면 실수로 덮어쓰는 사고를 줄일 수 있어요.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;예시-엔티티리포지토리&quot; data-ke-size=&quot;size23&quot;&gt;예시 엔티티/리포지토리&lt;/h3&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;package com.example.demo.domain;

import jakarta.persistence.*;

@Entity
@Table(name = &quot;posts&quot;)
public class Post extends BaseTimeEntity {

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

    @Column(nullable = false)
    private String title;

    protected Post() {}

    public Post(String title) {
        this.title = title;
    }

    public Long getId() {
        return id;
    }

    public String getTitle() {
        return title;
    }

    public void changeTitle(String title) {
        this.title = title;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;package com.example.demo.domain;

import org.springframework.data.jpa.repository.JpaRepository;

public interface PostRepository extends JpaRepository&amp;lt;Post, Long&amp;gt; {
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;동작-확인용-컨트롤러&quot; data-ke-size=&quot;size23&quot;&gt;동작 확인용 컨트롤러&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;package com.example.demo.web;

import com.example.demo.domain.Post;
import com.example.demo.domain.PostRepository;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;

import java.time.Instant;
import java.util.Map;

@RestController
public class PostController {

    private final PostRepository postRepository;

    public PostController(PostRepository postRepository) {
        this.postRepository = postRepository;
    }

    @PostMapping(&quot;/posts&quot;)
    public Map&amp;lt;String, Object&amp;gt; create(@RequestParam String title) {
        Post saved = postRepository.save(new Post(title));
        return Map.of(
                &quot;id&quot;, saved.getId(),
                &quot;createdAt&quot;, saved.getCreatedAt(),
                &quot;updatedAt&quot;, saved.getUpdatedAt()
        );
    }

    @Transactional
    @PatchMapping(&quot;/posts/{id}&quot;)
    public Map&amp;lt;String, Object&amp;gt; update(@PathVariable Long id, @RequestParam String title) {
        Post post = postRepository.findById(id).orElseThrow();
        Instant before = post.getUpdatedAt();

        post.changeTitle(title); // 더티체킹으로 update 발생 시 updatedAt 갱신

        return Map.of(
                &quot;id&quot;, post.getId(),
                &quot;updatedAtBefore&quot;, before,
                &quot;updatedAtAfter&quot;, post.getUpdatedAt()
        );
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;한눈에-정리-auditing-vs-db-기본값트리거&quot; data-ke-size=&quot;size23&quot;&gt;한눈에 정리: Auditing vs DB 기본값/트리거&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운영에서 &amp;ldquo;어디에서 시간을 책임질지&amp;rdquo;를 먼저 정하시는 게 좋아요.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;방식&lt;/th&gt;
&lt;th&gt;장점&lt;/th&gt;
&lt;th&gt;단점/주의&lt;/th&gt;
&lt;th&gt;추천 상황&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;JPA Auditing&lt;/td&gt;
&lt;td&gt;애플리케이션 표준화, 테스트 용이, 엔티티 단에서 일관적&lt;/td&gt;
&lt;td&gt;시간대 정책 필요, 일부 업데이트 케이스에서 기대와 다를 수 있음&lt;/td&gt;
&lt;td&gt;대부분의 Spring Boot/JPA 서비스&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DB DEFAULT(now)&lt;/td&gt;
&lt;td&gt;DB가 강제, 타 언어/도구로 넣어도 동일&lt;/td&gt;
&lt;td&gt;수정 시간 자동화는 별도 처리 필요, DB 종속&lt;/td&gt;
&lt;td&gt;생성 시간만 간단히 보장하고 싶을 때&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DB Trigger&lt;/td&gt;
&lt;td&gt;insert/update 모두 강제 가능&lt;/td&gt;
&lt;td&gt;디버깅/테스트 어려움, 운영 변경 부담&lt;/td&gt;
&lt;td&gt;다수 시스템이 같은 테이블을 갱신하는 환경&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bkQvAS/dJMcacWE5dd/h7ZrwNXw4naelmuGdNmsk0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bkQvAS/dJMcacWE5dd/h7ZrwNXw4naelmuGdNmsk0/img.png&quot; data-alt=&quot;JPA Auditing과 DB 트리거/기본값 비교 개념도&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bkQvAS/dJMcacWE5dd/h7ZrwNXw4naelmuGdNmsk0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbkQvAS%2FdJMcacWE5dd%2Fh7ZrwNXw4naelmuGdNmsk0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1536&quot; height=&quot;1024&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;JPA Auditing과 DB 트리거/기본값 비교 개념도&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 id=&quot;실무-팁&quot; data-ke-size=&quot;size26&quot;&gt;실무 팁&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  실무에서는: 시간 타입/시간대(UTC)부터 먼저 합의해 보세요&lt;br /&gt;&lt;code&gt;LocalDateTime&lt;/code&gt;은 &amp;ldquo;시간대 정보가 없는 값&amp;rdquo;이라, 서버/배치/운영자 PC가 서로 다른 시간대를 쓰면 해석이 꼬일 수 있습니다. 가능하면 DB에는 &lt;code&gt;Instant&lt;/code&gt;(UTC)로 저장하고, 화면/API 응답에서만 KST로 변환하는 방식이 운영 사고가 적습니다.&lt;br /&gt;또한 &lt;code&gt;createdAt&lt;/code&gt;/&lt;code&gt;updatedAt&lt;/code&gt; 컬럼에는 인덱스가 필요한지(예: 최신순 조회, 기간 검색)를 초기에 같이 검토해 두는 편이 좋습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  실무에서는: updatedAt이 안 바뀌는 &amp;ldquo;정상 케이스&amp;rdquo;가 있습니다&lt;br /&gt;&lt;code&gt;@LastModifiedDate&lt;/code&gt;는 &amp;ldquo;업데이트 쿼리가 실제로 나갈 때&amp;rdquo; 갱신됩니다. 즉, 엔티티 값을 바꾸지 않았거나(더티 아님), 변경이 감지되지 않는 방식으로 수정했다면(예: 일부 네이티브 쿼리/벌크 업데이트) &lt;code&gt;updatedAt&lt;/code&gt;이 기대대로 안 바뀔 수 있어요.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;벌크 업데이트(JPQL update)는 영속성 컨텍스트를 우회하므로 Auditing도 우회합니다. 이런 경우 &lt;code&gt;updated_at = now()&lt;/code&gt;를 쿼리에 명시하거나, 벌크 작업 후 &lt;code&gt;clear()&lt;/code&gt; 전략을 포함해 설계해 보세요.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 요약&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;JPA Auditing은 생성/수정 시간 기록을 저장 시점에 자동화해 누락과 중복 코드를 줄여줍니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@MappedSuperclass + BaseTimeEntity&lt;/code&gt;로 공통 필드를 표준화하는 게 가장 실용적입니다.&lt;/li&gt;
&lt;li&gt;운영에서는 시간대(UTC) 정책과 벌크 업데이트 시 Auditing 우회 문제를 특히 주의하세요.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 글: [#20 QueryDSL 도입 전 꼭 알아야 할 것들]&lt;/p&gt;</description>
      <category>Spring Boot</category>
      <category>Auditing</category>
      <category>backend</category>
      <category>hibernate</category>
      <category>JPA</category>
      <category>Spring Boot</category>
      <author>IT Lab</author>
      <guid isPermaLink="true">https://itlab0816.tistory.com/103</guid>
      <comments>https://itlab0816.tistory.com/103#entry103comment</comments>
      <pubDate>Tue, 17 Mar 2026 20:00:23 +0900</pubDate>
    </item>
    <item>
      <title>Spring Boot 페이징과 정렬 실전 가이드 (성능 함정: Slice vs Page, count 최적화, keyset pagination)</title>
      <link>https://itlab0816.tistory.com/102</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot 3.x + Spring Data JPA에서 Pageable을 제대로 쓰는 방법과 Page/Slice 선택 기준, count 쿼리 최적화, keyset pagination(커서 페이징)까지 실전 관점으로 정리합니다.&lt;/p&gt;
&lt;h2 id=&quot;도입-문제-상황&quot; data-ke-size=&quot;size26&quot;&gt;도입 (문제 상황)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;목록 API를 만들 때 &lt;code&gt;Pageable&lt;/code&gt;만 붙이면 끝일 것 같지만, 운영에 올리면 갑자기 DB가 느려지거나 count 쿼리가 병목이 되는 경우가 많습니다. 특히 &amp;ldquo;페이지 수를 보여줘야 해서 Page로 했는데 응답이 느려요&amp;rdquo; 같은 상황을 자주 겪으실 거예요. 이번 글에서는 페이징/정렬을 &amp;ldquo;되는 코드&amp;rdquo;가 아니라 &amp;ldquo;성능까지 고려한 코드&amp;rdquo;로 만드는 기준을 잡아봅니다.&lt;/p&gt;
&lt;h2 id=&quot;핵심-개념-spring-data-jpa-페이징정렬에서-진짜-중요한-것들&quot; data-ke-size=&quot;size26&quot;&gt;핵심 개념: Spring Data JPA 페이징/정렬에서 진짜 중요한 것들&lt;/h2&gt;
&lt;h3 id=&quot;1-pageable은-편하지만-page는-공짜가-아닙니다&quot; data-ke-size=&quot;size23&quot;&gt;1) Pageable은 편하지만, Page는 공짜가 아닙니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Pageable&lt;/code&gt;을 Repository 메서드에 받으면 Spring Data JPA가 &lt;code&gt;limit/offset + order by&lt;/code&gt;를 자동으로 만들어 줍니다. 문제는 반환 타입이 &lt;code&gt;Page&amp;lt;T&amp;gt;&lt;/code&gt;일 때입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;Page&amp;lt;T&amp;gt;&lt;/code&gt;는 &lt;code&gt;totalElements&lt;/code&gt;, &lt;code&gt;totalPages&lt;/code&gt;가 필요해서 &lt;b&gt;추가로 count 쿼리&lt;/b&gt;를 실행합니다.&lt;/li&gt;
&lt;li&gt;이 count 쿼리가 단순 테이블이면 괜찮지만, &lt;b&gt;join / group by / distinct&lt;/b&gt;가 섞이면 갑자기 비싸질 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 &lt;code&gt;Slice&amp;lt;T&amp;gt;&lt;/code&gt;는 &amp;ldquo;다음 페이지가 있는지&amp;rdquo;만 판단하기 위해 &lt;b&gt;한 건을 더 조회&lt;/b&gt;하는 방식이라 count 쿼리가 없습니다. 즉, &amp;ldquo;총 페이지 수&amp;rdquo;가 꼭 필요하지 않다면 &lt;code&gt;Slice&lt;/code&gt;가 더 안전한 기본값이 됩니다.&lt;/p&gt;
&lt;h3 id=&quot;2-slice-vs-page-선택-기준-실무에서-자주-헷갈리는-부분&quot; data-ke-size=&quot;size23&quot;&gt;2) Slice vs Page 선택 기준 (실무에서 자주 헷갈리는 부분)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 표처럼 &amp;ldquo;UI/요구사항&amp;rdquo; 기준으로 결정하시면 실수가 줄어듭니다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th&gt;Page&lt;/th&gt;
&lt;th&gt;Slice&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;totalElements/totalPages&lt;/td&gt;
&lt;td&gt;제공함 (count 쿼리 필요)&lt;/td&gt;
&lt;td&gt;제공 안 함&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;성능&lt;/td&gt;
&lt;td&gt;count가 비싸면 느려짐&lt;/td&gt;
&lt;td&gt;대체로 유리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;적합한 UI&lt;/td&gt;
&lt;td&gt;&amp;ldquo;1/123 페이지&amp;rdquo; 같은 페이지 네비게이션&lt;/td&gt;
&lt;td&gt;&amp;ldquo;더보기&amp;rdquo;, 무한 스크롤&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;쿼리 특징&lt;/td&gt;
&lt;td&gt;content 쿼리 + count 쿼리&lt;/td&gt;
&lt;td&gt;content 쿼리(plus 1 row)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&quot;3-count-쿼리-최적화-content-쿼리와-분리해서-생각하기&quot; data-ke-size=&quot;size23&quot;&gt;3) count 쿼리 최적화: &amp;ldquo;content 쿼리&amp;rdquo;와 분리해서 생각하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Page를 써야 한다면, 핵심은 &lt;b&gt;count 쿼리를 단순하게&lt;/b&gt; 만드는 것입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;content 조회는 필요한 join/fetch를 하더라도&lt;/li&gt;
&lt;li&gt;count는 가능하면 &lt;b&gt;join 없이&lt;/b&gt;, 혹은 &lt;b&gt;최소한의 조건만&lt;/b&gt;으로 세는 게 좋습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Data JPA에서는 &lt;code&gt;@Query(value=..., countQuery=...)&lt;/code&gt;로 content와 count를 분리할 수 있습니다. 이게 실무에서 가장 효과가 큰 최적화 포인트 중 하나입니다.&lt;/p&gt;
&lt;h3 id=&quot;4-offset-pagination의-함정과-keyset-pagination커서-페이징-맛보기&quot; data-ke-size=&quot;size23&quot;&gt;4) offset pagination의 함정과 keyset pagination(커서 페이징) 맛보기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;page=5000&lt;/code&gt;처럼 offset이 커지면 DB는 &amp;ldquo;앞의 4999페이지를 건너뛰기&amp;rdquo; 위해 내부적으로 많은 작업을 하게 됩니다. 정렬 컬럼에 인덱스가 있어도 offset이 커질수록 비용이 증가하는 패턴이 흔합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 대안이 &lt;b&gt;keyset pagination&lt;/b&gt;(커서 기반)입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;offset 대신 &amp;ldquo;마지막으로 본 id/시간&amp;rdquo; 같은 커서를 넘깁니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;where id &amp;lt; :cursor order by id desc limit :size&lt;/code&gt; 형태로 동작합니다.&lt;/li&gt;
&lt;li&gt;큰 페이지로 갈수록 느려지는 문제가 줄어듭니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 &amp;ldquo;임의 페이지로 점프&amp;rdquo;는 어렵고, 커서 설계(정렬 키의 유일성 확보)가 필요합니다. 그래서 보통 &lt;b&gt;무한 스크롤/피드형&lt;/b&gt;에 먼저 적용해 보시길 권합니다.&lt;/p&gt;
&lt;pre class=&quot;prolog&quot;&gt;&lt;code&gt;flowchart LR
  A[&quot;Client 요청&quot;] --&amp;gt; B[&quot;Spring Data JPA&quot;]
  B --&amp;gt; C[&quot;Offset pagination: limit/offset + order by&quot;]
  B --&amp;gt; D[&quot;Keyset pagination: where key &amp;lt; cursor + limit&quot;]
  C --&amp;gt; E[&quot;DB: offset 커질수록 부담 증가&quot;]
  D --&amp;gt; F[&quot;DB: 인덱스 타고 다음 구간 조회&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;offset은 뒤로 갈수록 비싸지고, keyset은 &amp;ldquo;다음 구간&amp;rdquo;을 인덱스로 이어서 읽는 구조입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bk7Ws4/dJMcacWE47W/LwQNTYetkWrUMjyEotT4B0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bk7Ws4/dJMcacWE47W/LwQNTYetkWrUMjyEotT4B0/img.png&quot; data-alt=&quot;Offset pagination과 keyset pagination의 성능 차이를 보여주는 개념도&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bk7Ws4/dJMcacWE47W/LwQNTYetkWrUMjyEotT4B0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbk7Ws4%2FdJMcacWE47W%2FLwQNTYetkWrUMjyEotT4B0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1536&quot; height=&quot;1024&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Offset pagination과 keyset pagination의 성능 차이를 보여주는 개념도&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 id=&quot;코드-예제-pageable-slice-vs-page-count-최적화-keyset-pagination&quot; data-ke-size=&quot;size26&quot;&gt;코드 예제: Pageable, Slice vs Page, count 최적화, keyset pagination&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 코드는 Spring Boot 3.x + Java 17 + Spring Data JPA 기준으로 바로 실행 가능한 예시입니다. H2로 동작하며, &lt;code&gt;/posts/page&lt;/code&gt;, &lt;code&gt;/posts/slice&lt;/code&gt;, &lt;code&gt;/posts/keyset&lt;/code&gt; 세 가지 엔드포인트로 비교해 볼 수 있습니다.&lt;/p&gt;
&lt;h3 id=&quot;buildgradle&quot; data-ke-size=&quot;size23&quot;&gt;build.gradle&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;plugins {
    id 'java'
    id 'org.springframework.boot' version '3.3.2'
    id 'io.spring.dependency-management' version '1.1.6'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain { languageVersion = JavaLanguageVersion.of(17) }
}

repositories { mavenCentral() }

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    runtimeOnly 'com.h2database:h2'
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;applicationyml&quot; data-ke-size=&quot;size23&quot;&gt;application.yml&lt;/h3&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;spring:
  datasource:
    url: jdbc:h2:mem:demo;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
    driver-class-name: org.h2.Driver
    username: sa
    password:
  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        format_sql: true
  h2:
    console:
      enabled: true

logging:
  level:
    org.hibernate.SQL: debug
    org.hibernate.orm.jdbc.bind: trace
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;도메인-post&quot; data-ke-size=&quot;size23&quot;&gt;도메인: Post&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;package com.example.demo;

import jakarta.persistence.*;
import java.time.Instant;

@Entity
@Table(indexes = {
        @Index(name = &quot;idx_post_created_id&quot;, columnList = &quot;createdAt, id&quot;)
})
public class Post {

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

    @Column(nullable = false)
    private String title;

    @Column(nullable = false)
    private Instant createdAt;

    protected Post() {}

    public Post(String title, Instant createdAt) {
        this.title = title;
        this.createdAt = createdAt;
    }

    public Long getId() { return id; }
    public String getTitle() { return title; }
    public Instant getCreatedAt() { return createdAt; }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;repository-page--slice--countquery-분리--keyset&quot; data-ke-size=&quot;size23&quot;&gt;Repository: Page / Slice / countQuery 분리 / keyset&lt;/h3&gt;
&lt;pre class=&quot;ceylon&quot;&gt;&lt;code&gt;package com.example.demo;

import org.springframework.data.domain.*;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;

public interface PostRepository extends JpaRepository&amp;lt;Post, Long&amp;gt; {

    // 1) Page: count 쿼리가 함께 나감
    Page&amp;lt;Post&amp;gt; findByTitleContainingIgnoreCase(String keyword, Pageable pageable);

    // 2) Slice: count 쿼리 없이 다음 페이지 여부만 판단
    Slice&amp;lt;Post&amp;gt; findSliceByTitleContainingIgnoreCase(String keyword, Pageable pageable);

    // 3) Page + countQuery 최적화 예시
    // content 쿼리는 정렬/조건을 유지하되, count는 가볍게 분리할 수 있음
    @Query(
        value = &quot;&quot;&quot;
            select p
            from Post p
            where lower(p.title) like lower(concat('%', :keyword, '%'))
            &quot;&quot;&quot;,
        countQuery = &quot;&quot;&quot;
            select count(p.id)
            from Post p
            where lower(p.title) like lower(concat('%', :keyword, '%'))
            &quot;&quot;&quot;
    )
    Page&amp;lt;Post&amp;gt; searchPageOptimizedCount(@Param(&quot;keyword&quot;) String keyword, Pageable pageable);

    // 4) Keyset pagination: &quot;id desc&quot; 기준 커서 페이징 맛보기
    @Query(&quot;&quot;&quot;
        select p
        from Post p
        where (:cursorId is null or p.id &amp;lt; :cursorId)
        order by p.id desc
    &quot;&quot;&quot;)
    Slice&amp;lt;Post&amp;gt; findNextByIdDesc(@Param(&quot;cursorId&quot;) Long cursorId, Pageable pageable);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;controller&quot; data-ke-size=&quot;size23&quot;&gt;Controller&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;package com.example.demo;

import org.springframework.data.domain.*;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RestController
@RequestMapping(&quot;/posts&quot;)
public class PostController {

    private final PostRepository postRepository;

    public PostController(PostRepository postRepository) {
        this.postRepository = postRepository;
    }

    // Page 예시: totalElements/totalPages 필요할 때
    @GetMapping(&quot;/page&quot;)
    public Map&amp;lt;String, Object&amp;gt; page(
            @RequestParam(defaultValue = &quot;&quot;) String q,
            @PageableDefault(size = 20, sort = &quot;id&quot;, direction = Sort.Direction.DESC) Pageable pageable
    ) {
        Page&amp;lt;Post&amp;gt; page = postRepository.searchPageOptimizedCount(q, pageable);

        return Map.of(
                &quot;contentSize&quot;, page.getContent().size(),
                &quot;totalElements&quot;, page.getTotalElements(),
                &quot;totalPages&quot;, page.getTotalPages(),
                &quot;hasNext&quot;, page.hasNext(),
                &quot;contentIds&quot;, page.getContent().stream().map(Post::getId).toList()
        );
    }

    // Slice 예시: 무한 스크롤/더보기
    @GetMapping(&quot;/slice&quot;)
    public Map&amp;lt;String, Object&amp;gt; slice(
            @RequestParam(defaultValue = &quot;&quot;) String q,
            @PageableDefault(size = 20, sort = &quot;id&quot;, direction = Sort.Direction.DESC) Pageable pageable
    ) {
        Slice&amp;lt;Post&amp;gt; slice = postRepository.findSliceByTitleContainingIgnoreCase(q, pageable);

        return Map.of(
                &quot;contentSize&quot;, slice.getContent().size(),
                &quot;hasNext&quot;, slice.hasNext(),
                &quot;contentIds&quot;, slice.getContent().stream().map(Post::getId).toList()
        );
    }

    // Keyset pagination: cursorId를 넘겨서 다음 묶음을 가져옴
    @GetMapping(&quot;/keyset&quot;)
    public Map&amp;lt;String, Object&amp;gt; keyset(
            @RequestParam(required = false) Long cursorId,
            @RequestParam(defaultValue = &quot;20&quot;) int size
    ) {
        // keyset에서는 정렬이 고정되는 경우가 많아 Pageable에 sort를 외부 입력으로 받지 않는 편이 안전합니다.
        Pageable pageable = PageRequest.of(0, size);

        Slice&amp;lt;Post&amp;gt; slice = postRepository.findNextByIdDesc(cursorId, pageable);
        Long nextCursor = slice.getContent().isEmpty()
                ? null
                : slice.getContent().get(slice.getContent().size() - 1).getId();

        return Map.of(
                &quot;cursorId&quot;, cursorId,
                &quot;nextCursorId&quot;, nextCursor,
                &quot;hasNext&quot;, slice.hasNext(),
                &quot;contentIds&quot;, slice.getContent().stream().map(Post::getId).toList()
        );
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;데이터-초기화-실행-시-더미-데이터-생성&quot; data-ke-size=&quot;size23&quot;&gt;데이터 초기화 (실행 시 더미 데이터 생성)&lt;/h3&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;package com.example.demo;

import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.time.Instant;
import java.util.stream.IntStream;

@Configuration
public class DataInit {

    @Bean
    CommandLineRunner init(PostRepository postRepository) {
        return args -&amp;gt; {
            if (postRepository.count() &amp;gt; 0) return;

            Instant now = Instant.now();
            IntStream.rangeClosed(1, 500).forEach(i -&amp;gt; {
                postRepository.save(new Post(&quot;post &quot; + i, now.minusSeconds(i)));
            });
        };
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;애플리케이션-엔트리포인트&quot; data-ke-size=&quot;size23&quot;&gt;애플리케이션 엔트리포인트&lt;/h3&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;호출-예시&quot; data-ke-size=&quot;size20&quot;&gt;호출 예시&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Page: &lt;code&gt;GET /posts/page?q=post&amp;amp;page=0&amp;amp;size=20&amp;amp;sort=id,desc&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Slice: &lt;code&gt;GET /posts/slice?q=post&amp;amp;page=0&amp;amp;size=20&amp;amp;sort=id,desc&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Keyset:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;첫 페이지: &lt;code&gt;GET /posts/keyset?size=20&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;다음 페이지: &lt;code&gt;GET /posts/keyset?cursorId=481&amp;amp;size=20&lt;/code&gt; (응답의 &lt;code&gt;nextCursorId&lt;/code&gt;를 이어서 사용)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;실무-팁&quot; data-ke-size=&quot;size26&quot;&gt;실무 팁&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  실무에서는: &amp;ldquo;Page를 기본값&amp;rdquo;으로 두지 말고, UI 요구사항부터 확인해 보세요&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;페이지 네비게이션(총 페이지 수 노출)이 꼭 필요할 때만 &lt;code&gt;Page&lt;/code&gt;를 쓰는 편이 안전합니다.&lt;/li&gt;
&lt;li&gt;무한 스크롤/더보기라면 &lt;code&gt;Slice&lt;/code&gt;로 시작하면 count 병목을 원천적으로 피할 수 있습니다.&lt;/li&gt;
&lt;li&gt;특히 검색 조건이 복잡해질수록 count는 예상보다 비싸집니다(조인/중복 제거가 끼면 더 심해요).&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  실무에서는: 정렬(sort)을 그대로 외부 입력으로 노출할 때 화이트리스트를 두는 게 좋습니다&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;Pageable&lt;/code&gt;의 &lt;code&gt;sort&lt;/code&gt; 파라미터를 그대로 받으면, 인덱스가 없는 컬럼 정렬로 인해 느려질 수 있습니다.&lt;/li&gt;
&lt;li&gt;컨트롤러에서 허용할 정렬 키를 제한하거나, 아예 정렬을 고정하고(예: &lt;code&gt;id desc&lt;/code&gt;, &lt;code&gt;createdAt desc&lt;/code&gt;) 필요한 경우에만 옵션을 열어두는 방식이 운영에서 안정적입니다.&lt;/li&gt;
&lt;li&gt;keyset pagination은 &amp;ldquo;정렬 키가 유일해야&amp;rdquo; 페이지 중복/누락이 줄어듭니다. 보통 &lt;code&gt;(createdAt, id)&lt;/code&gt;처럼 타이브레이커를 함께 쓰는 패턴을 고려해 보세요.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 요약&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;Page&lt;/code&gt;는 편하지만 count 쿼리 비용이 숨어 있어, 느려질 여지가 큽니다.&lt;/li&gt;
&lt;li&gt;&amp;ldquo;총 페이지 수&amp;rdquo;가 필요 없으면 &lt;code&gt;Slice&lt;/code&gt;가 더 안전한 선택입니다.&lt;/li&gt;
&lt;li&gt;offset이 커지는 목록은 keyset pagination을 검토해 보세요.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 글: [#19 JPA Auditing(@CreatedDate 등)로 자동 기록]&lt;/p&gt;</description>
      <category>Spring Boot</category>
      <category>Pageable</category>
      <category>pagination</category>
      <category>Performance</category>
      <category>Spring Boot</category>
      <category>Spring Data JPA</category>
      <author>IT Lab</author>
      <guid isPermaLink="true">https://itlab0816.tistory.com/102</guid>
      <comments>https://itlab0816.tistory.com/102#entry102comment</comments>
      <pubDate>Tue, 17 Mar 2026 10:00:10 +0900</pubDate>
    </item>
    <item>
      <title>Spring Boot JPA N+1 문제 원인과 해결 전략 (fetch join / EntityGraph / 배치 사이즈)</title>
      <link>https://itlab0816.tistory.com/101</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot 3.x에서 JPA N+1 문제가 발생하는 조건을 정리하고, fetch join과 @EntityGraph로 해결하는 방법, 그리고 batch size로 보완하는 실무 전략을 예제 코드로 설명합니다.&lt;/p&gt;
&lt;h2 id=&quot;도입-문제-상황&quot; data-ke-size=&quot;size26&quot;&gt;도입 (문제 상황)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;목록 API 하나 만들었을 뿐인데, 로컬에서는 빠르다가 데이터가 조금만 늘면 갑자기 DB 쿼리가 수십~수백 번 나가는 경험을 하실 때가 있습니다. 로그를 보면 &amp;ldquo;주 쿼리 1번 + 연관 엔티티 조회 쿼리 N번&amp;rdquo; 패턴이 반복되는데, 이게 대표적인 N+1 문제입니다.&lt;/p&gt;
&lt;h2 id=&quot;핵심-개념-n1이-왜-생기고-무엇을-선택해야-하나요&quot; data-ke-size=&quot;size26&quot;&gt;핵심 개념: N+1이 왜 생기고, 무엇을 선택해야 하나요?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;N+1은 &amp;ldquo;연관 로딩 전략&amp;rdquo;과 &amp;ldquo;조회 방식&amp;rdquo;이 맞물릴 때 발생합니다. 보통은 &lt;code&gt;@ManyToOne(fetch = LAZY)&lt;/code&gt; / &lt;code&gt;@OneToMany(fetch = LAZY)&lt;/code&gt;로 지연 로딩을 해두고(이 자체는 권장), 목록을 가져온 뒤 화면/DTO 구성 과정에서 연관 필드를 접근하면서 추가 쿼리가 터집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 &lt;code&gt;Order&lt;/code&gt; 목록을 20개 조회한 뒤, 각 주문의 &lt;code&gt;member.getName()&lt;/code&gt;을 접근하면:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;주문 목록 1번 쿼리&lt;/li&gt;
&lt;li&gt;각 주문의 member 로딩 20번 쿼리(영속성 컨텍스트에 없는 경우) &amp;rarr; 총 21번이 됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 포인트는 &amp;ldquo;LAZY가 나쁘다&amp;rdquo;가 아니라, &lt;b&gt;LAZY를 유지하되 &amp;lsquo;필요한 화면&amp;rsquo;에서만 계획적으로 한 번에 가져오도록 조회 쿼리를 설계&lt;/b&gt;하는 것입니다. 해결책은 크게 3가지 축으로 정리할 수 있습니다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;전략&lt;/th&gt;
&lt;th&gt;언제 쓰면 좋은가요?&lt;/th&gt;
&lt;th&gt;장점&lt;/th&gt;
&lt;th&gt;주의점&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fetch join&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;특정 화면/기능에서 연관 데이터를 확실히 같이 써야 할 때&lt;/td&gt;
&lt;td&gt;쿼리 1번으로 끝내기 쉬움, 명시적&lt;/td&gt;
&lt;td&gt;컬렉션 fetch join은 페이징이 위험(메모리 페이징/중복 row)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@EntityGraph&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&amp;ldquo;기본 쿼리는 유지&amp;rdquo;하면서 연관 로딩만 옵션으로 붙이고 싶을 때&lt;/td&gt;
&lt;td&gt;Repository 메서드에 선언적으로 적용&lt;/td&gt;
&lt;td&gt;내부적으로 fetch join과 유사, 컬렉션 + 페이징 이슈는 동일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Batch Size (&lt;code&gt;default_batch_fetch_size&lt;/code&gt; / &lt;code&gt;@BatchSize&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;fetch join이 곤란(페이징, 다양한 화면)하지만 N+1을 줄이고 싶을 때&lt;/td&gt;
&lt;td&gt;N+1을 1+N/batch로 완화, 페이징과 공존&lt;/td&gt;
&lt;td&gt;&amp;ldquo;완전 제거&amp;rdquo;가 아니라 &amp;ldquo;완화&amp;rdquo;, IN 쿼리 크기/DB 부하 고려&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&quot;요청-처리-관점에서-n1이-터지는-흐름&quot; data-ke-size=&quot;size23&quot;&gt;요청 처리 관점에서 N+1이 터지는 흐름&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;N+1은 보통 &amp;ldquo;조회 시점&amp;rdquo;이 아니라 &amp;ldquo;연관 필드 접근 시점&amp;rdquo;에 발생합니다. 그래서 컨트롤러/서비스에서 DTO 변환을 하는 순간 갑자기 쿼리가 늘어납니다.&lt;/p&gt;
&lt;pre class=&quot;prolog&quot;&gt;&lt;code&gt;flowchart LR
  A[&quot;Controller&quot;] --&amp;gt; B[&quot;Service: findOrders()&quot;]
  B --&amp;gt; C[&quot;Repository: select orders (1 query)&quot;]
  A --&amp;gt; D[&quot;DTO mapping: order.member.name access&quot;]
  D --&amp;gt; E[&quot;Lazy loading: select member (N queries)&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 다이어그램은 &amp;ldquo;조회 1번 후 DTO 매핑에서 지연 로딩이 연쇄적으로 발생&amp;rdquo;하는 전형적인 N+1 흐름입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kZvdg/dJMcab4wVnl/ECDAtKv8TUc6wYJR4SLAo0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kZvdg/dJMcab4wVnl/ECDAtKv8TUc6wYJR4SLAo0/img.png&quot; data-alt=&quot;N+1 문제 흐름을 보여주는 간단한 요청-조회-지연로딩 구조 다이어그램&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kZvdg/dJMcab4wVnl/ECDAtKv8TUc6wYJR4SLAo0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkZvdg%2FdJMcab4wVnl%2FECDAtKv8TUc6wYJR4SLAo0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1536&quot; height=&quot;1024&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;N+1 문제 흐름을 보여주는 간단한 요청-조회-지연로딩 구조 다이어그램&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 id=&quot;코드-예제-fetch-join--entitygraph--배치-사이즈까지-한-번에-실행해보기&quot; data-ke-size=&quot;size26&quot;&gt;코드 예제: fetch join / @EntityGraph / 배치 사이즈까지 한 번에 실행해보기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 예제는 Spring Boot 3.x + Java 17 + Hibernate(JPA) 기준입니다. H2로 실행 가능하게 구성했고, &lt;code&gt;Order -&amp;gt; Member (ManyToOne)&lt;/code&gt; / &lt;code&gt;Order -&amp;gt; OrderItem (OneToMany)&lt;/code&gt; 관계에서 N+1을 재현한 뒤 해결합니다.&lt;/p&gt;
&lt;h3 id=&quot;buildgradle&quot; data-ke-size=&quot;size23&quot;&gt;build.gradle&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;plugins {
    id 'java'
    id 'org.springframework.boot' version '3.3.2'
    id 'io.spring.dependency-management' version '1.1.6'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}

repositories { mavenCentral() }

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    runtimeOnly 'com.h2database:h2'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;applicationyml&quot; data-ke-size=&quot;size23&quot;&gt;application.yml&lt;/h3&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;spring:
  datasource:
    url: jdbc:h2:mem:nplus1;MODE=MySQL;DB_CLOSE_DELAY=-1
    driver-class-name: org.h2.Driver
    username: sa
    password:

  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        format_sql: true
        highlight_sql: true
        show_sql: true
        # 배치 사이즈 전략(전역): 지연 로딩 컬렉션/프록시를 IN 쿼리로 묶어서 가져옵니다.
        default_batch_fetch_size: 100
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;엔티티&quot; data-ke-size=&quot;size23&quot;&gt;엔티티&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;package com.example.nplus1.domain;

import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = &quot;members&quot;)
public class Member {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    protected Member() {}
    public Member(String name) { this.name = name; }

    public Long getId() { return id; }
    public String getName() { return name; }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;package com.example.nplus1.domain;

import jakarta.persistence.*;

@Entity
@Table(name = &quot;orders&quot;)
public class Order {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // 실무 기본값: LAZY
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;member_id&quot;)
    private Member member;

    protected Order() {}
    public Order(Member member) { this.member = member; }

    public Long getId() { return id; }
    public Member getMember() { return member; }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;package com.example.nplus1.domain;

import jakarta.persistence.*;

@Entity
@Table(name = &quot;order_items&quot;)
public class OrderItem {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String itemName;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;order_id&quot;)
    private Order order;

    protected OrderItem() {}
    public OrderItem(Order order, String itemName) {
        this.order = order;
        this.itemName = itemName;
    }

    public Long getId() { return id; }
    public String getItemName() { return itemName; }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예제는 핵심을 위해 &lt;code&gt;OrderItem&lt;/code&gt; 컬렉션 매핑을 생략했습니다(컬렉션 fetch join 페이징 이슈를 설명하기 위함). 실무에서는 &lt;code&gt;Order&lt;/code&gt;에 &lt;code&gt;@OneToMany(mappedBy=&quot;order&quot;)&lt;/code&gt;가 있을 가능성이 높고, 그 경우 N+1이 더 자주 터집니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;repository-기본-조회--fetch-join--entitygraph&quot; data-ke-size=&quot;size23&quot;&gt;Repository: 기본 조회 / fetch join / EntityGraph&lt;/h3&gt;
&lt;pre class=&quot;lasso&quot;&gt;&lt;code&gt;package com.example.nplus1.repository;

import com.example.nplus1.domain.Order;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

import java.util.List;

public interface OrderRepository extends JpaRepository&amp;lt;Order, Long&amp;gt; {

    // 1) 기본 조회: 이후 member 접근 시 N+1 발생 가능
    List&amp;lt;Order&amp;gt; findTop20ByOrderByIdAsc();

    // 2) fetch join: 필요한 연관을 명시적으로 한 번에 로딩
    @Query(&quot;select o from Order o join fetch o.member order by o.id asc&quot;)
    List&amp;lt;Order&amp;gt; findTop20WithMemberFetchJoin();

    // 3) EntityGraph: 선언적으로 fetch 옵션 부여(내부적으로 fetch join과 유사)
    @EntityGraph(attributePaths = &quot;member&quot;)
    List&amp;lt;Order&amp;gt; findTop20ByOrderByIdAscWithGraph();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;dto--service&quot; data-ke-size=&quot;size23&quot;&gt;DTO + Service&lt;/h3&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;package com.example.nplus1.web;

public record OrderResponse(Long orderId, String memberName) { }
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;package com.example.nplus1.service;

import com.example.nplus1.repository.OrderRepository;
import com.example.nplus1.web.OrderResponse;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@Transactional(readOnly = true)
public class OrderQueryService {
    private final OrderRepository orderRepository;

    public OrderQueryService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    public List&amp;lt;OrderResponse&amp;gt; nPlusOneCase() {
        return orderRepository.findTop20ByOrderByIdAsc()
                .stream()
                // 여기서 member 접근 시점에 추가 쿼리가 발생할 수 있습니다.
                .map(o -&amp;gt; new OrderResponse(o.getId(), o.getMember().getName()))
                .toList();
    }

    public List&amp;lt;OrderResponse&amp;gt; fetchJoinCase() {
        return orderRepository.findTop20WithMemberFetchJoin()
                .stream()
                .map(o -&amp;gt; new OrderResponse(o.getId(), o.getMember().getName()))
                .toList();
    }

    public List&amp;lt;OrderResponse&amp;gt; entityGraphCase() {
        return orderRepository.findTop20ByOrderByIdAscWithGraph()
                .stream()
                .map(o -&amp;gt; new OrderResponse(o.getId(), o.getMember().getName()))
                .toList();
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;controller&quot; data-ke-size=&quot;size23&quot;&gt;Controller&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;package com.example.nplus1.web;

import com.example.nplus1.service.OrderQueryService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
public class OrderController {
    private final OrderQueryService service;

    public OrderController(OrderQueryService service) {
        this.service = service;
    }

    @GetMapping(&quot;/orders/n-plus-one&quot;)
    public List&amp;lt;OrderResponse&amp;gt; nPlusOne() {
        return service.nPlusOneCase();
    }

    @GetMapping(&quot;/orders/fetch-join&quot;)
    public List&amp;lt;OrderResponse&amp;gt; fetchJoin() {
        return service.fetchJoinCase();
    }

    @GetMapping(&quot;/orders/entity-graph&quot;)
    public List&amp;lt;OrderResponse&amp;gt; entityGraph() {
        return service.entityGraphCase();
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;초기-데이터-로더&quot; data-ke-size=&quot;size23&quot;&gt;초기 데이터 로더&lt;/h3&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;package com.example.nplus1;

import com.example.nplus1.domain.Member;
import com.example.nplus1.domain.Order;
import jakarta.persistence.EntityManager;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.annotation.Transactional;

@Configuration
public class DataInit {

    @Bean
    CommandLineRunner init(EntityManager em) {
        return args -&amp;gt; initData(em);
    }

    @Transactional
    void initData(EntityManager em) {
        for (int i = 1; i &amp;lt;= 20; i++) {
            Member m = new Member(&quot;member-&quot; + i);
            em.persist(m);

            Order o = new Order(m);
            em.persist(o);
        }
        em.flush();
        em.clear();
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실행 후:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;/orders/n-plus-one&lt;/code&gt; 호출: &lt;code&gt;orders&lt;/code&gt; 1번 + &lt;code&gt;members&lt;/code&gt; 최대 20번(상황에 따라) 로그가 찍힐 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/orders/fetch-join&lt;/code&gt;, &lt;code&gt;/orders/entity-graph&lt;/code&gt; 호출: 대체로 1번 쿼리로 해결됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;배치-사이즈-전략은-어디에-효과가-있나요&quot; data-ke-size=&quot;size23&quot;&gt;배치 사이즈 전략은 어디에 효과가 있나요?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 예제처럼 &lt;code&gt;ManyToOne&lt;/code&gt;만 있는 경우에도 배치 로딩이 동작할 수 있지만, &lt;b&gt;실무에서 더 체감되는 구간은 컬렉션(OneToMany) 지연 로딩&lt;/b&gt;입니다. 예를 들어 주문 20개를 조회한 뒤 각 주문의 &lt;code&gt;orderItems.size()&lt;/code&gt;를 접근하면 원래는 1 + 20 쿼리인데, &lt;code&gt;default_batch_fetch_size=100&lt;/code&gt;이면 대략 1 + 1(또는 몇 번)로 줄어듭니다.&lt;br /&gt;즉, 배치 사이즈는 &amp;ldquo;N+1을 없애는 칼&amp;rdquo;이라기보다 &amp;ldquo;N을 뭉쳐서 덜 아프게 만드는 진통제&amp;rdquo;에 가깝습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;[IMAGE_PROMPT] 위치: 코드 예제 섹션의 배치 사이즈 설명 문단 아래 alt: default_batch_fetch_size로 N개의 지연 로딩 쿼리가 IN 쿼리 몇 번으로 합쳐지는 그림 prompt: clean minimal tech blog style white background infographic showing N+1 queries collapsing into fewer batched IN queries using default_batch_fetch_size, simple database icon and query bubbles, minimal text style: infographic [/IMAGE_PROMPT]&lt;/p&gt;
&lt;h2 id=&quot;실무-팁&quot; data-ke-size=&quot;size26&quot;&gt;실무 팁&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  실무에서는: fetch join과 페이징은 특히 조심해 보세요&lt;br /&gt;컬렉션(&lt;code&gt;OneToMany&lt;/code&gt;)에 fetch join을 걸고 &lt;code&gt;Pageable&lt;/code&gt;을 적용하면, SQL row가 뻥튀기되어 &lt;b&gt;DB 페이징이 깨지거나(중복 row)&lt;/b&gt; Hibernate가 &lt;b&gt;메모리에서 페이징&lt;/b&gt;하려고 시도할 수 있습니다. 목록 + 페이징이 필요하면 보통은&lt;/p&gt;
&lt;/blockquote&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;루트 엔티티만 페이징 조회 &amp;rarr; 2) 필요한 연관은 배치 로딩으로 완화&lt;br /&gt;같은 2단계 접근이 안전합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  실무에서는: &amp;ldquo;화면/유스케이스 단위&amp;rdquo;로 로딩 전략을 고르세요&lt;br /&gt;엔티티의 fetch 타입을 EAGER로 바꿔서 응급처치하는 경우가 있는데, 이는 다른 화면에서 불필요한 조인/쿼리를 유발해 더 큰 성능 문제로 돌아오는 일이 많습니다(공식적으로도 EAGER 남용은 비추천입니다).&lt;br /&gt;대신 Repository 레벨에서&lt;/p&gt;
&lt;/blockquote&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이 화면은 &lt;code&gt;fetch join&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;저 API는 &lt;code&gt;@EntityGraph&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;페이징 목록은 &lt;code&gt;batch size&lt;/code&gt;&lt;br /&gt;처럼 &lt;b&gt;조회 메서드 단위로 의도를 드러내는 방식&lt;/b&gt;이 유지보수에 유리합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심 요약: N+1은 &amp;ldquo;조회 후 DTO/뷰 구성 중 지연 로딩 접근&amp;rdquo;에서 터지는 경우가 가장 흔합니다.&lt;br /&gt;fetch join과 @EntityGraph는 &amp;ldquo;필요한 연관을 한 번에&amp;rdquo; 가져오는 1차 해결책이고, 배치 사이즈는 페이징/다양한 화면에서 N+1을 완화하는 현실적인 보완책입니다.&lt;br /&gt;EAGER로 덮기보다 &amp;ldquo;유스케이스별 조회 전략&amp;rdquo;으로 제어해 보세요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 글: #18 페이징과 정렬 실전(성능 함정 포함)&lt;/p&gt;</description>
      <category>Spring Boot</category>
      <category>hibernate</category>
      <category>JPA</category>
      <category>N+1</category>
      <category>Spring Boot</category>
      <category>성능최적화</category>
      <author>IT Lab</author>
      <guid isPermaLink="true">https://itlab0816.tistory.com/101</guid>
      <comments>https://itlab0816.tistory.com/101#entry101comment</comments>
      <pubDate>Mon, 16 Mar 2026 21:28:03 +0900</pubDate>
    </item>
    <item>
      <title>Spring Boot JPA 엔티티 설계 원칙(연관관계 포함) &amp;mdash; 실무에서 덜 고생하는 기본기</title>
      <link>https://itlab0816.tistory.com/100</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot 3.x 기준으로 JPA 엔티티를 설계할 때 꼭 지켜야 할 원칙(식별자 전략, 연관관계 방향, Lazy 기본, 엔티티 코드 규칙)을 예제 코드로 정리합니다.&lt;/p&gt;
&lt;h2 id=&quot;도입-문제-상황&quot; data-ke-size=&quot;size26&quot;&gt;도입 (문제 상황)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Data JPA로 Repository는 금방 만들었는데, 엔티티 설계에서부터 막히는 경우가 많아요. 연관관계를 어디에 두어야 할지, &lt;code&gt;LAZY&lt;/code&gt;로 두면 언제 터지고 &lt;code&gt;EAGER&lt;/code&gt;로 두면 왜 느려지는지, 식별자는 어떤 전략이 안전한지 고민이 시작됩니다. 이 글에서는 &amp;ldquo;나중에 운영에서 덜 고생하는&amp;rdquo; 엔티티 설계 원칙을 기준으로 정리해 볼게요.&lt;/p&gt;
&lt;h2 id=&quot;핵심-개념-spring-boot-jpa-엔티티-설계-원칙&quot; data-ke-size=&quot;size26&quot;&gt;핵심 개념 (Spring Boot JPA 엔티티 설계 원칙)&lt;/h2&gt;
&lt;h3 id=&quot;1-엔티티는-db-테이블이-아니라-도메인-모델로-설계합니다&quot; data-ke-size=&quot;size23&quot;&gt;1) 엔티티는 &amp;ldquo;DB 테이블&amp;rdquo;이 아니라 &amp;ldquo;도메인 모델&amp;rdquo;로 설계합니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;엔티티는 단순 DTO가 아니라 상태와 규칙을 가진 도메인 객체에 가까워요. 너무 많은 필드를 공개(setter 남발)하면, 변경 지점이 흩어져서 버그가 늘어납니다.&lt;br /&gt;실무에서는 &lt;b&gt;기본 생성자는 &lt;code&gt;protected&lt;/code&gt;&lt;/b&gt;, 변경은 **의미 있는 메서드(예: &lt;code&gt;changeAddress&lt;/code&gt;)**로 제한하는 방식이 유지보수에 유리합니다.&lt;/p&gt;
&lt;h3 id=&quot;2-식별자id-전략-기본은-identity보다-sequenceuuid를-고민합니다&quot; data-ke-size=&quot;size23&quot;&gt;2) 식별자(@Id) 전략: 기본은 &lt;code&gt;IDENTITY&lt;/code&gt;보다 &lt;code&gt;SEQUENCE&lt;/code&gt;/&lt;code&gt;UUID&lt;/code&gt;를 고민합니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPA 식별자는 &amp;ldquo;엔티티 동일성&amp;rdquo;의 기준이라서, 전략 선택이 성능/운영에 영향을 줍니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;MySQL 계열&lt;/b&gt;: 보통 &lt;code&gt;IDENTITY&lt;/code&gt;(auto_increment)를 많이 씁니다. 다만 &lt;code&gt;IDENTITY&lt;/code&gt;는 INSERT 후에야 PK를 알 수 있어 배치 INSERT 최적화가 제한될 수 있습니다(하이버네이트가 한 번에 모아서 INSERT하기 어려움).&lt;/li&gt;
&lt;li&gt;&lt;b&gt;PostgreSQL/Oracle&lt;/b&gt;: &lt;code&gt;SEQUENCE&lt;/code&gt;가 자연스럽고, JPA도 최적화 옵션(allocations)을 활용하기 좋습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;분산 환경/마이그레이션&lt;/b&gt;: 숫자 증가 PK 대신 &lt;code&gt;UUID&lt;/code&gt;(또는 ULID 등)를 고려하면 충돌 위험을 줄일 수 있습니다. 대신 인덱스/정렬 비용이 늘 수 있어요.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 표처럼 &amp;ldquo;DB/요구사항&amp;rdquo;에 맞춰 선택하는 게 안전합니다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;전략&lt;/th&gt;
&lt;th&gt;장점&lt;/th&gt;
&lt;th&gt;단점/주의&lt;/th&gt;
&lt;th&gt;추천 상황&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;IDENTITY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;단순, MySQL에 익숙&lt;/td&gt;
&lt;td&gt;배치 INSERT 최적화 제약, INSERT 전 id 없음&lt;/td&gt;
&lt;td&gt;단순 CRUD, MySQL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SEQUENCE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;성능/최적화 유리, INSERT 전 id 확보 가능&lt;/td&gt;
&lt;td&gt;DB가 시퀀스를 지원해야 함&lt;/td&gt;
&lt;td&gt;PostgreSQL/Oracle, 트래픽 많은 서비스&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UUID&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;분산/병합에 강함&lt;/td&gt;
&lt;td&gt;인덱스 비대, 정렬 비용&lt;/td&gt;
&lt;td&gt;멀티 리전/데이터 병합/외부 노출 PK 회피&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&quot;3-연관관계-방향-조회가-필요한-쪽에-두되-양방향은-최소화합니다&quot; data-ke-size=&quot;size23&quot;&gt;3) 연관관계 방향: &amp;ldquo;조회가 필요한 쪽&amp;rdquo;에 두되, 양방향은 최소화합니다&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/srMu9/dJMcadA9rFM/AyMHAWLFdK9Akjtni0FiEk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/srMu9/dJMcadA9rFM/AyMHAWLFdK9Akjtni0FiEk/img.png&quot; data-alt=&quot;JPA 연관관계 주인과 mappedBy 개념 다이어그램&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/srMu9/dJMcadA9rFM/AyMHAWLFdK9Akjtni0FiEk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FsrMu9%2FdJMcadA9rFM%2FAyMHAWLFdK9Akjtni0FiEk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1536&quot; height=&quot;1024&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;JPA 연관관계 주인과 mappedBy 개념 다이어그램&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;연관관계는 설계의 핵심인데, 요점은 이거예요.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;단방향이 기본&lt;/b&gt;입니다. 필요할 때만 양방향을 씁니다.&lt;/li&gt;
&lt;li&gt;양방향은 편해 보이지만, 관리 포인트가 늘어요(연관관계 편의 메서드, 무한 루프 직렬화, 예기치 않은 로딩 등).&lt;/li&gt;
&lt;li&gt;&amp;ldquo;항상 함께 조회&amp;rdquo;가 아니라면, &lt;b&gt;컬렉션을 무작정 열어두지 않는&lt;/b&gt; 편이 안전합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 하나 중요한 사실: &lt;b&gt;JPA에서 연관관계의 주인은 외래키를 가진 쪽(@ManyToOne)&lt;/b&gt; 입니다.&lt;br /&gt;즉, &lt;code&gt;Order -&amp;gt; Member&lt;/code&gt;는 &lt;code&gt;Order&lt;/code&gt;가 주인이 되고, &lt;code&gt;Member.orders&lt;/code&gt;는 보통 &lt;code&gt;mappedBy&lt;/code&gt;로 읽기 전용에 가깝습니다.&lt;/p&gt;
&lt;pre class=&quot;coq&quot;&gt;&lt;code&gt;flowchart LR
  A[&quot;Order(주인, FK 보유)&quot;] --&amp;gt;|ManyToOne| B[&quot;Member&quot;]
  B --&amp;gt;|OneToMany(mappedBy)| A
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;연관관계의 주인은 외래키를 가진 쪽이며, 반대편은 &lt;code&gt;mappedBy&lt;/code&gt;로 매핑합니다.&lt;/p&gt;
&lt;h3 id=&quot;4-lazy-로딩은-기본값처럼-사용합니다특히-toonetomany-모두&quot; data-ke-size=&quot;size23&quot;&gt;4) Lazy 로딩은 기본값처럼 사용합니다(특히 ToOne/ToMany 모두)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPA 기본값을 그대로 쓰면 위험한 지점이 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;@ManyToOne&lt;/code&gt;, &lt;code&gt;@OneToOne&lt;/code&gt;의 기본 fetch는 &lt;b&gt;EAGER&lt;/b&gt; 입니다. 그대로 두면 예상치 못한 조인/추가 쿼리로 성능이 흔들릴 수 있어요.&lt;/li&gt;
&lt;li&gt;그래서 실무에서는 &lt;b&gt;ToOne도 명시적으로 &lt;code&gt;fetch = LAZY&lt;/code&gt;&lt;/b&gt; 를 붙이는 습관이 중요합니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@OneToMany&lt;/code&gt;/&lt;code&gt;@ManyToMany&lt;/code&gt;는 기본이 LAZY지만, 컬렉션 접근 시점에 쿼리가 나가므로 서비스 계층에서 트랜잭션 범위를 의식해야 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 글(#17)에서 다룰 N+1 문제가 여기서 시작되는 경우가 많습니다.&lt;/p&gt;
&lt;h3 id=&quot;5-equalshashcode-tostring-json-직렬화는-조심합니다&quot; data-ke-size=&quot;size23&quot;&gt;5) equals/hashCode, toString, JSON 직렬화는 조심합니다&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;엔티티에 &lt;code&gt;toString()&lt;/code&gt;을 자동 생성해 연관관계를 찍으면, LAZY 로딩이 터지거나 무한 루프가 날 수 있어요.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;equals/hashCode&lt;/code&gt;를 연관관계/가변 필드 기반으로 만들면 Set/Map에서 동작이 이상해집니다.&lt;br /&gt;실무에서는 &lt;b&gt;식별자 기반(단, 영속화 전 null 고려)&lt;/b&gt; 또는 &lt;b&gt;비즈니스 키(유일/불변)&lt;/b&gt; 기반으로 신중히 선택합니다.&lt;/li&gt;
&lt;li&gt;API 응답은 엔티티를 그대로 내보내기보다 DTO로 변환하는 편이 안전합니다(직렬화 중 프록시 초기화 문제 예방).&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;코드-예제-spring-boot-3x--java-17-연관관계--lazy--식별자-전략&quot; data-ke-size=&quot;size26&quot;&gt;코드 예제 (Spring Boot 3.x / Java 17, 연관관계 + Lazy + 식별자 전략)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 코드는 &amp;ldquo;주문(Order)&amp;ndash;회원(Member)&amp;ndash;주문상품(OrderItem)&amp;ndash;상품(Product)&amp;rdquo; 모델로, 연관관계 방향과 LAZY 기본 원칙을 담은 실행 가능한 예제입니다.&lt;/p&gt;
&lt;h3 id=&quot;buildgradle&quot; data-ke-size=&quot;size23&quot;&gt;build.gradle&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;plugins {
    id 'java'
    id 'org.springframework.boot' version '3.3.2'
    id 'io.spring.dependency-management' version '1.1.6'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain { languageVersion = JavaLanguageVersion.of(17) }
}

repositories { mavenCentral() }

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    runtimeOnly 'com.h2database:h2'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') { useJUnitPlatform() }
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;applicationyml&quot; data-ke-size=&quot;size23&quot;&gt;application.yml&lt;/h3&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;spring:
  datasource:
    url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
    driver-class-name: org.h2.Driver
    username: sa
    password:

  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        format_sql: true
        highlight_sql: true
        use_sql_comments: true

logging:
  level:
    org.hibernate.SQL: debug
    org.hibernate.orm.jdbc.bind: trace
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;엔티티-코드&quot; data-ke-size=&quot;size23&quot;&gt;엔티티 코드&lt;/h3&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;package com.example.demo.domain;

import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = &quot;members&quot;)
public class Member {

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

    @Column(nullable = false)
    private String name;

    // 양방향은 필요할 때만: 여기서는 &quot;회원의 주문 목록 조회&quot;가 잦다고 가정
    @OneToMany(mappedBy = &quot;member&quot;, orphanRemoval = true)
    private final List&amp;lt;Order&amp;gt; orders = new ArrayList&amp;lt;&amp;gt;();

    protected Member() {}

    public Member(String name) {
        this.name = name;
    }

    public Long getId() { return id; }
    public String getName() { return name; }

    // 연관관계 편의 메서드(양쪽 동기화)
    void addOrder(Order order) {
        orders.add(order);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;package com.example.demo.domain;

import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = &quot;orders&quot;)
public class Order {

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

    // ToOne은 기본이 EAGER이므로 반드시 LAZY 명시
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = &quot;member_id&quot;)
    private Member member;

    @OneToMany(mappedBy = &quot;order&quot;, cascade = CascadeType.ALL, orphanRemoval = true)
    private final List&amp;lt;OrderItem&amp;gt; orderItems = new ArrayList&amp;lt;&amp;gt;();

    protected Order() {}

    private Order(Member member) {
        this.member = member;
        member.addOrder(this);
    }

    public static Order create(Member member) {
        return new Order(member);
    }

    public Long getId() { return id; }
    public Member getMember() { return member; }
    public List&amp;lt;OrderItem&amp;gt; getOrderItems() { return orderItems; }

    public void addItem(Product product, int quantity) {
        OrderItem item = OrderItem.create(this, product, quantity);
        orderItems.add(item);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;package com.example.demo.domain;

import jakarta.persistence.*;

@Entity
@Table(name = &quot;order_items&quot;)
public class OrderItem {

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

    // ToOne LAZY 명시
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = &quot;order_id&quot;)
    private Order order;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = &quot;product_id&quot;)
    private Product product;

    @Column(nullable = false)
    private int quantity;

    protected OrderItem() {}

    private OrderItem(Order order, Product product, int quantity) {
        this.order = order;
        this.product = product;
        this.quantity = quantity;
    }

    public static OrderItem create(Order order, Product product, int quantity) {
        return new OrderItem(order, product, quantity);
    }

    public Long getId() { return id; }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;package com.example.demo.domain;

import jakarta.persistence.*;

@Entity
@Table(name = &quot;products&quot;)
public class Product {

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

    @Column(nullable = false)
    private String name;

    protected Product() {}

    public Product(String name) {
        this.name = name;
    }

    public Long getId() { return id; }
    public String getName() { return name; }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;repository--간단-실행controller&quot; data-ke-size=&quot;size23&quot;&gt;Repository + 간단 실행(Controller)&lt;/h3&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;package com.example.demo.repository;

import com.example.demo.domain.Member;
import org.springframework.data.jpa.repository.JpaRepository;

public interface MemberRepository extends JpaRepository&amp;lt;Member, Long&amp;gt; {}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;package com.example.demo.repository;

import com.example.demo.domain.Product;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ProductRepository extends JpaRepository&amp;lt;Product, Long&amp;gt; {}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;package com.example.demo.repository;

import com.example.demo.domain.Order;
import org.springframework.data.jpa.repository.JpaRepository;

public interface OrderRepository extends JpaRepository&amp;lt;Order, Long&amp;gt; {}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;package com.example.demo.web;

import com.example.demo.domain.Member;
import com.example.demo.domain.Order;
import com.example.demo.domain.Product;
import com.example.demo.repository.MemberRepository;
import com.example.demo.repository.OrderRepository;
import com.example.demo.repository.ProductRepository;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping(&quot;/demo&quot;)
public class DemoController {

    private final MemberRepository memberRepository;
    private final ProductRepository productRepository;
    private final OrderRepository orderRepository;

    public DemoController(MemberRepository memberRepository,
                          ProductRepository productRepository,
                          OrderRepository orderRepository) {
        this.memberRepository = memberRepository;
        this.productRepository = productRepository;
        this.orderRepository = orderRepository;
    }

    @PostMapping(&quot;/seed&quot;)
    @Transactional
    public String seed() {
        Member member = memberRepository.save(new Member(&quot;kim&quot;));
        Product p1 = productRepository.save(new Product(&quot;keyboard&quot;));
        Product p2 = productRepository.save(new Product(&quot;mouse&quot;));

        Order order = Order.create(member);
        order.addItem(p1, 1);
        order.addItem(p2, 2);

        orderRepository.save(order);
        return &quot;ok&quot;;
    }

    @GetMapping(&quot;/orders/{id}&quot;)
    @Transactional(readOnly = true)
    public String read(@PathVariable Long id) {
        Order order = orderRepository.findById(id).orElseThrow();

        // LAZY 로딩은 접근 시점에 쿼리가 나갑니다(트랜잭션 안에서 안전)
        String memberName = order.getMember().getName();
        int itemCount = order.getOrderItems().size();

        return &quot;orderId=&quot; + order.getId() + &quot;, member=&quot; + memberName + &quot;, items=&quot; + itemCount;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id=&quot;실무-팁&quot; data-ke-size=&quot;size26&quot;&gt;실무 팁&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  실무에서는: 엔티티의 기본 규칙을 &amp;ldquo;템플릿&amp;rdquo;으로 정해두면 편합니다&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;ToOne은 무조건 &lt;code&gt;fetch = LAZY&lt;/code&gt;를 명시해 보세요(기본 EAGER 함정 회피).&lt;/li&gt;
&lt;li&gt;기본 생성자는 &lt;code&gt;protected&lt;/code&gt;, setter는 최소화, 생성/변경 메서드로 의도를 드러내면 유지보수 비용이 줄어듭니다.&lt;/li&gt;
&lt;li&gt;API 응답은 엔티티 직접 반환 대신 DTO 변환을 기본 규칙으로 두는 편이 안전합니다(프록시/순환참조 방지).&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  실무에서는: 양방향 연관관계는 &amp;ldquo;읽기 모델&amp;rdquo;에서 특히 조심합니다&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;양방향을 늘리면 편의는 생기지만, N+1/직렬화/연관관계 동기화 같은 비용도 같이 따라옵니다.&lt;/li&gt;
&lt;li&gt;&amp;ldquo;정말 필요한 화면/쿼리&amp;rdquo;가 생길 때 &lt;code&gt;fetch join&lt;/code&gt;이나 &lt;code&gt;EntityGraph&lt;/code&gt;로 해결하는 접근이 더 예측 가능합니다. (다음 글 #17에서 이어집니다)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심 요약: 엔티티는 도메인 모델로 설계하고 setter를 줄이세요.&lt;br /&gt;핵심 요약: 연관관계는 단방향 + ToOne LAZY를 기본으로, 양방향은 최소화하세요.&lt;br /&gt;핵심 요약: 식별자 전략은 DB/운영 요구에 맞춰 선택하고, 기본값의 함정을 의식하세요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 글: [#17 N+1 문제 원인과 해결(fetch join/EntityGraph)]&lt;/p&gt;</description>
      <category>Spring Boot</category>
      <category>Entity</category>
      <category>hibernate</category>
      <category>JPA</category>
      <category>Spring Boot</category>
      <category>연관관계</category>
      <author>IT Lab</author>
      <guid isPermaLink="true">https://itlab0816.tistory.com/100</guid>
      <comments>https://itlab0816.tistory.com/100#entry100comment</comments>
      <pubDate>Thu, 12 Mar 2026 20:00:45 +0900</pubDate>
    </item>
    <item>
      <title>Spring Boot에서 Spring Data JPA 시작하기: Repository 인터페이스(CrudRepository/JpaRepository)와 쿼리 메서드, 페이징 기본</title>
      <link>https://itlab0816.tistory.com/99</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot 3.x에서 Spring Data JPA Repository 인터페이스를 선택하고(CrudRepository/JpaRepository), 쿼리 메서드와 Pageable로 페이징을 빠르게 적용하는 실전 시작 가이드입니다.&lt;/p&gt;
&lt;h2 id=&quot;1-도입-문제-상황&quot; data-ke-size=&quot;size26&quot;&gt;1) 도입 (문제 상황)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPA로 CRUD를 만들려고 보면, &amp;ldquo;EntityManager로 직접 다 짜야 하나요?&amp;rdquo; 같은 고민이 먼저 생깁니다. 또 목록 API를 만들 때마다 페이징/정렬을 매번 수동으로 처리하다 보면 코드가 금방 지저분해져요. 이럴 때 Spring Data JPA의 Repository 인터페이스를 제대로 잡아두면 시작 속도가 확 달라집니다.&lt;/p&gt;
&lt;h2 id=&quot;2-핵심-개념--spring-data-jpa-repository가-중요한-이유&quot; data-ke-size=&quot;size26&quot;&gt;2) 핵심 개념 &amp;mdash; Spring Data JPA Repository가 중요한 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Data JPA의 핵심은 &amp;ldquo;반복되는 데이터 접근 코드를 인터페이스 선언만으로 표준화&amp;rdquo;하는 데 있어요. 구현체를 직접 만들지 않아도, 런타임에 프록시가 생성되어 CRUD, 페이징, 정렬, 쿼리 메서드(메서드 이름 기반 쿼리)까지 제공합니다.&lt;/p&gt;
&lt;h3 id=&quot;crudrepository-vs-jparepository-무엇을-선택할까&quot; data-ke-size=&quot;size23&quot;&gt;CrudRepository vs JpaRepository, 무엇을 선택할까?&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;CrudRepository&lt;/b&gt;: 정말 최소 CRUD에 집중한 인터페이스입니다. 저장/조회/삭제 같은 기본 기능만 필요하면 충분합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;JpaRepository&lt;/b&gt;: CrudRepository를 포함하고, JPA에 특화된 기능(예: &lt;code&gt;flush()&lt;/code&gt;, 배치 삭제, &lt;code&gt;getReferenceById()&lt;/code&gt; 등)을 더 제공합니다. 실무에서는 대부분 &lt;code&gt;JpaRepository&lt;/code&gt;를 기본으로 선택하는 편입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 표처럼 &amp;ldquo;기능 범위&amp;rdquo; 관점으로 보면 선택이 쉬워요.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th&gt;CrudRepository&lt;/th&gt;
&lt;th&gt;PagingAndSortingRepository&lt;/th&gt;
&lt;th&gt;JpaRepository&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;기본 CRUD&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;페이징/정렬&lt;/td&gt;
&lt;td&gt;X&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JPA 특화 기능(flush 등)&lt;/td&gt;
&lt;td&gt;X&lt;/td&gt;
&lt;td&gt;X&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;실무 기본 선택&lt;/td&gt;
&lt;td&gt;제한적&lt;/td&gt;
&lt;td&gt;제한적&lt;/td&gt;
&lt;td&gt;가장 흔함&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&quot;쿼리-메서드query-method-메서드-이름이-곧-쿼리&quot; data-ke-size=&quot;size23&quot;&gt;쿼리 메서드(Query Method): &amp;ldquo;메서드 이름이 곧 쿼리&amp;rdquo;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;findByEmail(...)&lt;/code&gt;, &lt;code&gt;findByStatusAndCreatedAtAfter(...)&lt;/code&gt;처럼 메서드 이름을 규칙에 맞게 만들면, Spring Data가 JPQL을 생성합니다.&lt;br /&gt;비유하자면 &amp;ldquo;SQL을 직접 쓰기 전에, 규칙 기반 검색 UI(필터)를 먼저 제공받는 느낌&amp;rdquo;이에요. 빠르게 시작할 수 있고, 단순 조회는 코드가 매우 깔끔해집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 이름이 너무 길어지거나 복잡한 조인이 필요해지면 유지보수가 어려워지니, 그때는 &lt;code&gt;@Query&lt;/code&gt;(JPQL) 또는 Querydsl 같은 대안을 검토하는 흐름이 자연스럽습니다.&lt;/p&gt;
&lt;h3 id=&quot;페이징-기본-page-vs-slice&quot; data-ke-size=&quot;size23&quot;&gt;페이징 기본: Page vs Slice&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Fnhwo/dJMcaaEwuYI/p0O4ik6Ofcf5K983hijXGk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Fnhwo/dJMcaaEwuYI/p0O4ik6Ofcf5K983hijXGk/img.png&quot; data-alt=&quot;Spring Data JPA Pageable로 페이징/정렬이 적용되는 흐름&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Fnhwo/dJMcaaEwuYI/p0O4ik6Ofcf5K983hijXGk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FFnhwo%2FdJMcaaEwuYI%2Fp0O4ik6Ofcf5K983hijXGk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1536&quot; height=&quot;1024&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Spring Data JPA Pageable로 페이징/정렬이 적용되는 흐름&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;페이징은 &lt;code&gt;Pageable&lt;/code&gt; 하나로 정리됩니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Page&lt;/b&gt;: 전체 개수(count 쿼리)가 필요할 때 사용합니다. &lt;code&gt;getTotalElements()&lt;/code&gt;, &lt;code&gt;getTotalPages()&lt;/code&gt; 등을 제공합니다. (보통 count 쿼리가 추가로 나갑니다)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Slice&lt;/b&gt;: &amp;ldquo;다음 페이지가 있는지&amp;rdquo; 정도만 필요할 때 사용합니다. count 쿼리를 줄여 성능에 유리할 수 있어요.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요청 파라미터로 &lt;code&gt;?page=0&amp;amp;size=20&amp;amp;sort=createdAt,desc&lt;/code&gt; 같은 형태를 그대로 받을 수 있어서, 목록 API 표준화에 특히 좋습니다.&lt;/p&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;flowchart LR
  C[&quot;Client&quot;] --&amp;gt; R[&quot;Controller: Pageable 파라미터 바인딩&quot;]
  R --&amp;gt; S[&quot;Service: 비즈니스 규칙&quot;]
  S --&amp;gt; J[&quot;Repository: JpaRepository&quot;]
  J --&amp;gt; D[&quot;DB&quot;]
  D --&amp;gt; J --&amp;gt; S --&amp;gt; R --&amp;gt; C
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 다이어그램은 Spring Data JPA에서 페이징 요청이 흘러가는 전형적인 경로를 보여줍니다.&lt;/p&gt;
&lt;h2 id=&quot;3-코드-예제--복붙해서-실행-가능한-spring-boot-3x--jpa-repository--쿼리-메서드--페이징&quot; data-ke-size=&quot;size26&quot;&gt;3) 코드 예제 &amp;mdash; 복붙해서 실행 가능한 Spring Boot 3.x + JPA Repository + 쿼리 메서드 + 페이징&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 예제는 &amp;ldquo;회원 목록을 상태/이메일로 검색하고 페이징한다&amp;rdquo;는 가장 흔한 패턴입니다. H2 인메모리 DB로 바로 실행 가능하게 구성했습니다.&lt;/p&gt;
&lt;h3 id=&quot;buildgradle&quot; data-ke-size=&quot;size23&quot;&gt;build.gradle&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;plugins {
    id 'java'
    id 'org.springframework.boot' version '3.3.2'
    id 'io.spring.dependency-management' version '1.1.6'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

    runtimeOnly 'com.h2database:h2'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
    useJUnitPlatform()
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;applicationyml&quot; data-ke-size=&quot;size23&quot;&gt;application.yml&lt;/h3&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;spring:
  datasource:
    url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
    driver-class-name: org.h2.Driver
    username: sa
    password:

  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        format_sql: true

  h2:
    console:
      enabled: true

logging:
  level:
    org.hibernate.SQL: debug
    # 운영에서는 바인딩 로그가 민감할 수 있어요. 개발 환경에서만 제한적으로 사용하세요.
    org.hibernate.orm.jdbc.bind: trace
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;도메인entity&quot; data-ke-size=&quot;size23&quot;&gt;도메인(Entity)&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;package com.example.demo.member;

import jakarta.persistence.*;
import java.time.Instant;

@Entity
@Table(name = &quot;members&quot;, indexes = {
        @Index(name = &quot;idx_members_email&quot;, columnList = &quot;email&quot;),
        @Index(name = &quot;idx_members_status_created&quot;, columnList = &quot;status,createdAt&quot;)
})
public class Member {

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

    @Column(nullable = false, unique = true, length = 100)
    private String email;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private MemberStatus status = MemberStatus.ACTIVE;

    @Column(nullable = false, updatable = false)
    private Instant createdAt = Instant.now();

    protected Member() { }

    public Member(String email, MemberStatus status) {
        this.email = email;
        this.status = status;
    }

    public Long getId() { return id; }
    public String getEmail() { return email; }
    public MemberStatus getStatus() { return status; }
    public Instant getCreatedAt() { return createdAt; }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;package com.example.demo.member;

public enum MemberStatus {
    ACTIVE, INACTIVE
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;repository-jparepository--쿼리-메서드--페이징&quot; data-ke-size=&quot;size23&quot;&gt;Repository: JpaRepository + 쿼리 메서드 + 페이징&lt;/h3&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;package com.example.demo.member;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

public interface MemberRepository extends JpaRepository&amp;lt;Member, Long&amp;gt; {

    // 쿼리 메서드: 상태로 페이징 조회
    Page&amp;lt;Member&amp;gt; findByStatus(MemberStatus status, Pageable pageable);

    // 쿼리 메서드: 이메일 부분 검색 + 페이징
    Page&amp;lt;Member&amp;gt; findByEmailContainingIgnoreCase(String emailKeyword, Pageable pageable);

    // 조건 조합도 가능 (복잡해지면 @Query/Querydsl 고려)
    Page&amp;lt;Member&amp;gt; findByStatusAndEmailContainingIgnoreCase(MemberStatus status, String emailKeyword, Pageable pageable);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;service&quot; data-ke-size=&quot;size23&quot;&gt;Service&lt;/h3&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;package com.example.demo.member;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class MemberService {

    private final MemberRepository memberRepository;

    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @Transactional(readOnly = true)
    public Page&amp;lt;Member&amp;gt; search(MemberStatus status, String q, Pageable pageable) {
        if (status != null &amp;amp;&amp;amp; q != null &amp;amp;&amp;amp; !q.isBlank()) {
            return memberRepository.findByStatusAndEmailContainingIgnoreCase(status, q.trim(), pageable);
        }
        if (status != null) {
            return memberRepository.findByStatus(status, pageable);
        }
        if (q != null &amp;amp;&amp;amp; !q.isBlank()) {
            return memberRepository.findByEmailContainingIgnoreCase(q.trim(), pageable);
        }
        return memberRepository.findAll(pageable);
    }

    @Transactional
    public void seed() {
        if (memberRepository.count() &amp;gt; 0) return;

        memberRepository.save(new Member(&quot;alice@example.com&quot;, MemberStatus.ACTIVE));
        memberRepository.save(new Member(&quot;bob@example.com&quot;, MemberStatus.INACTIVE));
        memberRepository.save(new Member(&quot;carol@example.com&quot;, MemberStatus.ACTIVE));
        memberRepository.save(new Member(&quot;dave@example.com&quot;, MemberStatus.ACTIVE));
        memberRepository.save(new Member(&quot;erin@example.com&quot;, MemberStatus.INACTIVE));
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;controller-pageable-자동-바인딩으로-페이징-api-만들기&quot; data-ke-size=&quot;size23&quot;&gt;Controller: Pageable 자동 바인딩으로 페이징 API 만들기&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;package com.example.demo.member;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping(&quot;/members&quot;)
public class MemberController {

    private final MemberService memberService;

    public MemberController(MemberService memberService) {
        this.memberService = memberService;
    }

    // 예: /members?page=0&amp;amp;size=2&amp;amp;sort=createdAt,desc&amp;amp;status=ACTIVE&amp;amp;q=ex
    @GetMapping
    public Page&amp;lt;MemberResponse&amp;gt; list(
            @RequestParam(required = false) MemberStatus status,
            @RequestParam(required = false) String q,
            Pageable pageable
    ) {
        Page&amp;lt;Member&amp;gt; result = memberService.search(status, q, pageable);
        return result.map(MemberResponse::from);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;package com.example.demo.member;

import java.time.Instant;

public record MemberResponse(Long id, String email, MemberStatus status, Instant createdAt) {
    public static MemberResponse from(Member m) {
        return new MemberResponse(m.getId(), m.getEmail(), m.getStatus(), m.getCreatedAt());
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;애플리케이션-시작-시-더미-데이터-넣기&quot; data-ke-size=&quot;size23&quot;&gt;애플리케이션 시작 시 더미 데이터 넣기&lt;/h3&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;package com.example.demo;

import com.example.demo.member.MemberService;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

@SpringBootApplication
public class DemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }

    @Bean
    CommandLineRunner init(MemberService memberService) {
        return args -&amp;gt; memberService.seed();
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실행 후 아래처럼 호출해 보세요.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;GET /members?page=0&amp;amp;size=2&amp;amp;sort=createdAt,desc&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GET /members?status=ACTIVE&amp;amp;page=0&amp;amp;size=10&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GET /members?q=example&amp;amp;page=0&amp;amp;size=10&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GET /members?status=ACTIVE&amp;amp;q=ex&amp;amp;page=0&amp;amp;size=10&amp;amp;sort=email,asc&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;4-실무-팁&quot; data-ke-size=&quot;size26&quot;&gt;4) 실무 팁&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  실무에서는&lt;br /&gt;&lt;b&gt;쿼리 메서드는 &amp;ldquo;짧고 단순한 검색&amp;rdquo;까지만&lt;/b&gt; 쓰는 게 유지보수에 유리합니다.&lt;br /&gt;&lt;code&gt;findByStatusAndEmailContainingIgnoreCaseAndCreatedAtAfterAnd...&lt;/code&gt;처럼 길어지기 시작하면, 요구사항 변경 때 메서드 폭발이 생겨요. 이 시점부터는 &lt;code&gt;@Query&lt;/code&gt;(JPQL)로 명시하거나, 동적 조건이 많다면 Querydsl을 검토해 보세요.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  실무에서는&lt;br /&gt;&lt;b&gt;Page는 count 쿼리 비용을 꼭 의식&lt;/b&gt;해야 합니다. 목록 화면에서 &amp;ldquo;전체 페이지 수/총 건수&amp;rdquo;가 꼭 필요하지 않다면 &lt;code&gt;Slice&lt;/code&gt;로 바꾸는 것만으로도 트래픽이 큰 서비스에서 DB 부담이 줄어듭니다. 특히 조인이 많은 조회는 count가 더 비쌀 수 있어요.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심 요약: JpaRepository는 CRUD+페이징+JPA 특화 기능까지 제공해 실무 기본 선택으로 적합합니다.&lt;br /&gt;핵심 요약: 쿼리 메서드는 빠른 시작에 좋지만, 길어지면 @Query/Querydsl로 전환하는 기준을 잡아두세요.&lt;br /&gt;핵심 요약: 페이징은 Pageable로 표준화하고, total count가 필요 없으면 Slice로 비용을 줄일 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 글: [JPA 엔티티 설계 원칙(연관관계 포함)]&lt;/p&gt;</description>
      <category>Spring Boot</category>
      <category>JpaRepository</category>
      <category>paging</category>
      <category>Repository</category>
      <category>springboot</category>
      <category>SpringDataJPA</category>
      <author>IT Lab</author>
      <guid isPermaLink="true">https://itlab0816.tistory.com/99</guid>
      <comments>https://itlab0816.tistory.com/99#entry99comment</comments>
      <pubDate>Thu, 12 Mar 2026 10:00:42 +0900</pubDate>
    </item>
    <item>
      <title>Spring Boot에서 @Transactional 제대로 쓰기(전파/읽기전용) &amp;mdash; 프록시부터 실수까지</title>
      <link>https://itlab0816.tistory.com/98</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot 3.x에서 @Transactional이 프록시로 동작하는 방식, readOnly의 실제 효과, 전파 옵션(Propagation) 선택 기준과 현업에서 자주 하는 실수를 코드로 정리합니다.&lt;/p&gt;
&lt;h2 id=&quot;도입-문제-상황&quot; data-ke-size=&quot;size26&quot;&gt;도입 (문제 상황)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스 메서드에 &lt;code&gt;@Transactional&lt;/code&gt;을 붙였는데도 &amp;ldquo;왜 롤백이 안 되지?&amp;rdquo; 혹은 &amp;ldquo;readOnly로 했는데도 업데이트 쿼리가 나가네?&amp;rdquo; 같은 상황을 한 번쯤 겪으셨을 거예요. 특히 계층형 구조(Controller/Service/Repository)를 잘 나눠도, 트랜잭션 경계가 어긋나면 데이터 정합성이 쉽게 깨집니다.&lt;br /&gt;이번 글에서는 &lt;code&gt;@Transactional&lt;/code&gt;이 **어떻게 동작하는지(프록시)**부터 &lt;b&gt;readOnly의 진짜 의미&lt;/b&gt;, &lt;b&gt;전파 옵션 선택&lt;/b&gt;, &lt;b&gt;자주 하는 실수&lt;/b&gt;를 실무 관점으로 정리해 봅니다.&lt;/p&gt;
&lt;h2 id=&quot;핵심-개념-spring-boot-transactional이-왜-중요한가&quot; data-ke-size=&quot;size26&quot;&gt;핵심 개념: Spring Boot @Transactional이 &amp;ldquo;왜&amp;rdquo; 중요한가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;@Transactional&lt;/code&gt;은 단순히 &amp;ldquo;DB 작업을 묶는 애노테이션&amp;rdquo;이 아니라, **데이터 변경의 원자성(Atomicity)**과 **일관성(Consistency)**을 지키는 경계선입니다. 이 경계가 애매하면 다음 문제가 자주 생깁니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;일부만 커밋되고 일부는 실패(부분 성공) &amp;rarr; 장애 대응이 매우 어려움&lt;/li&gt;
&lt;li&gt;읽기 API가 의도치 않게 쓰기 락/플러시를 유발 &amp;rarr; 성능 저하&lt;/li&gt;
&lt;li&gt;트랜잭션이 중첩되면서 롤백 규칙이 꼬임 &amp;rarr; &amp;ldquo;왜 롤백 안 됨?&amp;rdquo; 이슈&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;1-프록시-기반-동작-호출-경로가-핵심입니다&quot; data-ke-size=&quot;size23&quot;&gt;1) 프록시 기반 동작: &amp;ldquo;호출 경로&amp;rdquo;가 핵심입니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring의 선언적 트랜잭션은 기본적으로 &lt;b&gt;AOP 프록시&lt;/b&gt;로 구현됩니다. 즉, 빈을 감싼 프록시가 메서드 호출 전후로 트랜잭션을 시작/커밋/롤백합니다.&lt;/p&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;sequenceDiagram
  participant C as &quot;Controller&quot;
  participant P as &quot;Service Proxy&quot;
  participant S as &quot;Service Target&quot;
  participant TM as &quot;Transaction Manager&quot;
  participant R as &quot;Repository&quot;

  C-&amp;gt;&amp;gt;P: &quot;call service()&quot;
  P-&amp;gt;&amp;gt;TM: &quot;begin&quot;
  P-&amp;gt;&amp;gt;S: &quot;invoke&quot;
  S-&amp;gt;&amp;gt;R: &quot;DB access&quot;
  P-&amp;gt;&amp;gt;TM: &quot;commit/rollback&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c5DxUa/dJMcaiWMHq4/HYbyyKQpBuO6jDNk2lbJTK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c5DxUa/dJMcaiWMHq4/HYbyyKQpBuO6jDNk2lbJTK/img.png&quot; data-alt=&quot;Spring 트랜잭션 프록시가 요청을 가로채 트랜잭션을 시작/커밋하는 흐름&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c5DxUa/dJMcaiWMHq4/HYbyyKQpBuO6jDNk2lbJTK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc5DxUa%2FdJMcaiWMHq4%2FHYbyyKQpBuO6jDNk2lbJTK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1536&quot; height=&quot;1024&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Spring 트랜잭션 프록시가 요청을 가로채 트랜잭션을 시작/커밋하는 흐름&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프록시가 &amp;ldquo;가로채서&amp;rdquo; 트랜잭션을 열고 닫는 흐름입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 포인트는 &lt;b&gt;프록시를 거쳐야만&lt;/b&gt; &lt;code&gt;@Transactional&lt;/code&gt;이 적용된다는 점입니다. 그래서 아래가 대표적인 함정입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;같은 클래스 내부에서 &lt;code&gt;this.someTxMethod()&lt;/code&gt;로 호출(= self-invocation)하면 프록시를 우회 &amp;rarr; 트랜잭션이 안 걸릴 수 있음&lt;/li&gt;
&lt;li&gt;&lt;code&gt;private&lt;/code&gt; 메서드에 붙여도 보통 의미 없음(프록시가 가로채기 어려움)&lt;/li&gt;
&lt;li&gt;Bean으로 관리되지 않는 객체에 붙여도 적용되지 않음&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;2-readonlytrue-쓰기-금지가-아니라-쓰기-최적화-힌트에-가깝습니다&quot; data-ke-size=&quot;size23&quot;&gt;2) readOnly=true: &amp;ldquo;쓰기 금지&amp;rdquo;가 아니라 &amp;ldquo;쓰기 최적화 힌트&amp;rdquo;에 가깝습니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;@Transactional(readOnly = true)&lt;/code&gt;는 DB에 &amp;ldquo;절대 쓰지 마&amp;rdquo;를 강제하는 만능 스위치가 아닙니다. 보통 다음 효과를 기대할 수 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;(JPA/Hibernate) &lt;b&gt;Flush 모드 조정&lt;/b&gt; 등으로 불필요한 변경 감지를 줄여 성능에 도움&lt;/li&gt;
&lt;li&gt;DB 드라이버/DB 설정에 따라 read-only 트랜잭션 최적화가 적용될 수도 있음(항상 보장되진 않음)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 다음은 오해입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;readOnly를 붙이면 UPDATE/INSERT가 무조건 막힌다 &amp;rarr; &lt;b&gt;아닙니다.&lt;/b&gt; (환경/구현체에 따라 다르고, 애플리케이션 레벨에서 완전 차단을 보장하지 않습니다)&lt;/li&gt;
&lt;li&gt;readOnly면 트랜잭션이 없는 것과 같다 &amp;rarr; &lt;b&gt;아닙니다.&lt;/b&gt; 트랜잭션은 열립니다. (일관된 읽기, Lazy 로딩 등에서 의미가 큼)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면 readOnly는 &amp;ldquo;안전장치&amp;rdquo;라기보다 &amp;ldquo;성능과 의도를 표현하는 설정&amp;rdquo;에 가깝습니다. 정말 쓰기를 막고 싶다면 DB 권한/분리(읽기 전용 계정, 리드 레플리카) 같은 운영 설계가 필요합니다.&lt;/p&gt;
&lt;h3 id=&quot;3-전파propagation-이미-트랜잭션이-있을-때-어떻게-할지-결정합니다&quot; data-ke-size=&quot;size23&quot;&gt;3) 전파(Propagation): &amp;ldquo;이미 트랜잭션이 있을 때&amp;rdquo; 어떻게 할지 결정합니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전파 옵션은 &amp;ldquo;현재 호출이 기존 트랜잭션 안에서 실행되는가?&amp;rdquo;를 다룹니다. 실무에서 자주 쓰는 옵션만 비교하면 다음과 같습니다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;전파 옵션&lt;/th&gt;
&lt;th&gt;기존 트랜잭션이 있으면&lt;/th&gt;
&lt;th&gt;없으면&lt;/th&gt;
&lt;th&gt;주 사용처&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;REQUIRED(기본)&lt;/td&gt;
&lt;td&gt;기존에 참여&lt;/td&gt;
&lt;td&gt;새로 생성&lt;/td&gt;
&lt;td&gt;대부분의 서비스 메서드&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;REQUIRES_NEW&lt;/td&gt;
&lt;td&gt;기존을 잠시 중단하고 새로 생성&lt;/td&gt;
&lt;td&gt;새로 생성&lt;/td&gt;
&lt;td&gt;&amp;ldquo;로그는 반드시 남겨야 함&amp;rdquo; 같은 독립 커밋&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SUPPORTS&lt;/td&gt;
&lt;td&gt;있으면 참여&lt;/td&gt;
&lt;td&gt;트랜잭션 없이 실행&lt;/td&gt;
&lt;td&gt;읽기 API에서 선택적으로&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MANDATORY&lt;/td&gt;
&lt;td&gt;반드시 참여&lt;/td&gt;
&lt;td&gt;예외 발생&lt;/td&gt;
&lt;td&gt;&amp;ldquo;무조건 트랜잭션 안&amp;rdquo;을 강제할 때&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NEVER&lt;/td&gt;
&lt;td&gt;트랜잭션이 있으면 예외&lt;/td&gt;
&lt;td&gt;트랜잭션 없이 실행&lt;/td&gt;
&lt;td&gt;트랜잭션을 절대 열면 안 되는 작업(드묾)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 &lt;code&gt;REQUIRES_NEW&lt;/code&gt;는 강력하지만 비용이 있습니다. 커넥션을 새로 확보하고(혹은 별도 트랜잭션을 열고) 기존 트랜잭션과 운명을 분리하므로, 남용하면 성능/데드락/일관성 측면에서 복잡해집니다.&lt;/p&gt;
&lt;h3 id=&quot;4-롤백-규칙-어떤-예외에서-롤백되는가&quot; data-ke-size=&quot;size23&quot;&gt;4) 롤백 규칙: &amp;ldquo;어떤 예외에서 롤백되는가&amp;rdquo;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring의 기본 규칙은 다음입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;RuntimeException / Error&lt;/b&gt; &amp;rarr; 롤백&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Checked Exception&lt;/b&gt; &amp;rarr; 커밋(기본)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Checked Exception에서도 롤백하려면 &lt;code&gt;rollbackFor&lt;/code&gt;를 명시해야 합니다. 이 때문에 &amp;ldquo;예외는 났는데 커밋돼 버림&amp;rdquo; 이슈가 종종 발생합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id=&quot;코드-예제-전파읽기전용프록시-함정까지-한-번에-재현하기-spring-boot-3x&quot; data-ke-size=&quot;size26&quot;&gt;코드 예제: 전파/읽기전용/프록시 함정까지 한 번에 재현하기 (Spring Boot 3.x)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 예제는 H2 + Spring Data JPA로 구성했고, 실행 후 간단한 엔드포인트 호출로 다음을 확인할 수 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;readOnly=true&lt;/code&gt;에서 쓰기를 시도했을 때의 동작(환경에 따라 &amp;ldquo;막힘&amp;rdquo;이 보장되지 않음을 체감)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;REQUIRES_NEW&lt;/code&gt;로 감사 로그를 &amp;ldquo;독립 커밋&amp;rdquo;하는 패턴&lt;/li&gt;
&lt;li&gt;같은 클래스 내부 호출(self-invocation)로 &lt;code&gt;@Transactional&lt;/code&gt;이 적용되지 않는 함정&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;buildgradle&quot; data-ke-size=&quot;size23&quot;&gt;build.gradle&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;plugins {
    id 'java'
    id 'org.springframework.boot' version '3.3.2'
    id 'io.spring.dependency-management' version '1.1.6'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}

repositories { mavenCentral() }

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    runtimeOnly 'com.h2database:h2'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') { useJUnitPlatform() }
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;applicationyml&quot; data-ke-size=&quot;size23&quot;&gt;application.yml&lt;/h3&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;spring:
  datasource:
    url: jdbc:h2:mem:txdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
    driver-class-name: org.h2.Driver
    username: sa
    password:
  jpa:
    hibernate:
      ddl-auto: create-drop
    properties:
      hibernate:
        format_sql: true
  h2:
    console:
      enabled: true

logging:
  level:
    org.hibernate.SQL: debug
    org.hibernate.orm.jdbc.bind: trace
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;도메인리포지토리&quot; data-ke-size=&quot;size23&quot;&gt;도메인/리포지토리&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;package com.example.tx;

import jakarta.persistence.*;
import java.time.Instant;

@Entity
public class Member {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String email;

    protected Member() {}

    public Member(String email) {
        this.email = email;
    }

    public Long getId() { return id; }
    public String getEmail() { return email; }
    public void changeEmail(String email) { this.email = email; }
}

@Entity
public class AuditLog {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String message;

    @Column(nullable = false)
    private Instant createdAt = Instant.now();

    protected AuditLog() {}

    public AuditLog(String message) {
        this.message = message;
    }

    public Long getId() { return id; }
    public String getMessage() { return message; }
    public Instant getCreatedAt() { return createdAt; }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;package com.example.tx;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface MemberRepository extends JpaRepository&amp;lt;Member, Long&amp;gt; {
    Optional&amp;lt;Member&amp;gt; findByEmail(String email);
}

public interface AuditLogRepository extends JpaRepository&amp;lt;AuditLog, Long&amp;gt; {
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;서비스-전파읽기전용프록시-함정&quot; data-ke-size=&quot;size23&quot;&gt;서비스: 전파/읽기전용/프록시 함정&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b53QnQ/dJMcaaEvF2g/yrnObFNUKWeQYyPKyxhUCK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b53QnQ/dJMcaaEvF2g/yrnObFNUKWeQYyPKyxhUCK/img.png&quot; data-alt=&quot;REQUIRED와 REQUIRES_NEW 전파로 트랜잭션이 중단/분리되는 개념도&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b53QnQ/dJMcaaEvF2g/yrnObFNUKWeQYyPKyxhUCK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb53QnQ%2FdJMcaaEvF2g%2FyrnObFNUKWeQYyPKyxhUCK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1536&quot; height=&quot;1024&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;REQUIRED와 REQUIRES_NEW 전파로 트랜잭션이 중단/분리되는 개념도&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;package com.example.tx;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Service
public class MemberService {

    private final MemberRepository memberRepository;
    private final AuditService auditService;

    public MemberService(MemberRepository memberRepository, AuditService auditService) {
        this.memberRepository = memberRepository;
        this.auditService = auditService;
    }

    // 기본 전파(REQUIRED): 대부분의 쓰기 로직은 이 패턴이 기본값입니다.
    @Transactional
    public Long register(String email) {
        Member saved = memberRepository.save(new Member(email));
        return saved.getId();
    }

    // readOnly는 &quot;쓰기 금지&quot;가 아니라 &quot;읽기 최적화 힌트&quot;에 가깝습니다.
    @Transactional(readOnly = true)
    public String findEmailAndTryToWrite(Long memberId, String newEmail) {
        Member m = memberRepository.findById(memberId)
                .orElseThrow(() -&amp;gt; new IllegalArgumentException(&quot;not found&quot;));

        // 의도적으로 수정 시도: 환경에 따라 flush 시점에 예외가 나거나,
        // 또는 특정 설정에서는 UPDATE가 나갈 수도 있습니다(= readOnly가 강제 차단이 아님).
        m.changeEmail(newEmail);

        return m.getEmail();
    }

    // 전파 예시: 메인 로직은 실패해도 감사 로그는 남기고 싶은 경우
    @Transactional
    public void changeEmailWithAudit(Long memberId, String newEmail) {
        Member m = memberRepository.findById(memberId)
                .orElseThrow(() -&amp;gt; new IllegalArgumentException(&quot;not found&quot;));

        m.changeEmail(newEmail);

        // 감사 로그는 독립 트랜잭션으로 커밋
        auditService.writeRequiresNew(&quot;email change requested: memberId=&quot; + memberId);

        // 일부러 실패시켜 롤백 유도
        throw new RuntimeException(&quot;boom - main tx rollback&quot;);
    }

    // 프록시 함정: 같은 클래스 내부 호출은 프록시를 우회할 수 있습니다.
    public void outerCallsInnerDirectly(Long memberId, String newEmail) {
        // 아래 호출은 this.innerTransactional(...)과 동일한 경로가 될 수 있어
        // @Transactional이 기대대로 적용되지 않는 대표 케이스입니다.
        innerTransactional(memberId, newEmail);
    }

    @Transactional
    public void innerTransactional(Long memberId, String newEmail) {
        Member m = memberRepository.findById(memberId)
                .orElseThrow(() -&amp;gt; new IllegalArgumentException(&quot;not found&quot;));
        m.changeEmail(newEmail);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;package com.example.tx;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Service
public class AuditService {

    private final AuditLogRepository auditLogRepository;

    public AuditService(AuditLogRepository auditLogRepository) {
        this.auditLogRepository = auditLogRepository;
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void writeRequiresNew(String message) {
        auditLogRepository.save(new AuditLog(message));
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;컨트롤러-호출용-엔드포인트&quot; data-ke-size=&quot;size23&quot;&gt;컨트롤러: 호출용 엔드포인트&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;package com.example.tx;

import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping(&quot;/tx&quot;)
public class TxController {

    private final MemberService memberService;
    private final MemberRepository memberRepository;
    private final AuditLogRepository auditLogRepository;

    public TxController(MemberService memberService,
                        MemberRepository memberRepository,
                        AuditLogRepository auditLogRepository) {
        this.memberService = memberService;
        this.memberRepository = memberRepository;
        this.auditLogRepository = auditLogRepository;
    }

    @PostMapping(&quot;/members&quot;)
    public Long register(@RequestParam String email) {
        return memberService.register(email);
    }

    @PostMapping(&quot;/readonly-write&quot;)
    public String readOnlyWrite(@RequestParam Long memberId, @RequestParam String newEmail) {
        return memberService.findEmailAndTryToWrite(memberId, newEmail);
    }

    @PostMapping(&quot;/requires-new&quot;)
    public String requiresNew(@RequestParam Long memberId, @RequestParam String newEmail) {
        try {
            memberService.changeEmailWithAudit(memberId, newEmail);
            return &quot;ok&quot;;
        } catch (Exception e) {
            long auditCount = auditLogRepository.count();
            String currentEmail = memberRepository.findById(memberId).map(Member::getEmail).orElse(&quot;n/a&quot;);
            return &quot;main rolled back, auditCount=&quot; + auditCount + &quot;, emailNow=&quot; + currentEmail;
        }
    }

    @PostMapping(&quot;/self-invocation&quot;)
    public String selfInvocation(@RequestParam Long memberId, @RequestParam String newEmail) {
        memberService.outerCallsInnerDirectly(memberId, newEmail);
        return &quot;called&quot;;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;실행확인-방법간단&quot; data-ke-size=&quot;size23&quot;&gt;실행/확인 방법(간단)&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;회원 생성&lt;br /&gt;&lt;code&gt;POST /tx/members?email=a@a.com&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;REQUIRES_NEW 확인&lt;br /&gt;&lt;code&gt;POST /tx/requires-new?memberId=1&amp;amp;newEmail=b@b.com&lt;/code&gt;&lt;br /&gt;&amp;rarr; 메인 변경은 롤백되어 이메일이 그대로일 수 있지만, 감사 로그는 &lt;code&gt;auditCount=1&lt;/code&gt;처럼 남는 것을 확인할 수 있습니다.&lt;/li&gt;
&lt;li&gt;self-invocation 함정은 로그/디버깅으로 확인해 보세요.&lt;br /&gt;&lt;code&gt;/tx/self-invocation&lt;/code&gt; 호출 시 &lt;code&gt;innerTransactional&lt;/code&gt;이 프록시를 거치지 않으면 트랜잭션 경계가 기대와 달라질 수 있습니다. (이 케이스는 &amp;ldquo;왜 트랜잭션이 안 열렸지?&amp;rdquo;를 재현할 때 유용합니다.)&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id=&quot;실무-팁&quot; data-ke-size=&quot;size26&quot;&gt;실무 팁&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  실무에서는&lt;br /&gt;&lt;b&gt;트랜잭션은 Service 계층에만 두는 규칙&lt;/b&gt;을 먼저 세우는 게 좋습니다. Controller에 &lt;code&gt;@Transactional&lt;/code&gt;을 붙이면 웹 요청 범위 전체가 트랜잭션이 되어, 불필요하게 길어지고(외부 API 호출/파일 처리까지 포함), 락 경합이나 커넥션 고갈로 이어지기 쉽습니다. Repository에는 보통 붙이지 않아도 됩니다(호출자가 트랜잭션을 열어주는 구조가 더 예측 가능해요).&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  실무에서는&lt;br /&gt;&lt;code&gt;REQUIRES_NEW&lt;/code&gt;는 &amp;ldquo;감사 로그/알림 기록&amp;rdquo; 같은 &lt;b&gt;독립 커밋이 반드시 필요한&lt;/b&gt; 곳에만 제한적으로 쓰는 게 안전합니다. 남용하면 트랜잭션이 쪼개져서 &amp;ldquo;메인 데이터는 롤백됐는데 부가 데이터는 남는&amp;rdquo; 상태가 늘어나고, 운영 중 정합성 이슈로 되돌아오는 경우가 많습니다. 정말 필요한지 먼저 질문해 보시고, 필요하다면 &amp;ldquo;왜 분리 커밋이 맞는가&amp;rdquo;를 코드 주석/문서로 남겨두는 편이 좋습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 id=&quot;핵심-요약&quot; data-ke-size=&quot;size23&quot;&gt;핵심 요약&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;@Transactional&lt;/code&gt;은 프록시 기반이라 &lt;b&gt;프록시를 거치는 호출 경로&lt;/b&gt;가 아니면 적용되지 않을 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;readOnly=true&lt;/code&gt;는 대개 &lt;b&gt;최적화 힌트&lt;/b&gt;이며 &amp;ldquo;쓰기 완전 차단&amp;rdquo;을 보장하지 않습니다.&lt;/li&gt;
&lt;li&gt;전파 옵션은 트랜잭션 중첩의 규칙이며, &lt;code&gt;REQUIRES_NEW&lt;/code&gt;는 강력하지만 &lt;b&gt;정합성/운영 비용&lt;/b&gt;을 동반합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 글: #15 Spring Data JPA 시작(Repository 인터페이스)&lt;/p&gt;</description>
      <category>Spring Boot</category>
      <category>JPA</category>
      <category>propagation</category>
      <category>readonly</category>
      <category>Spring Boot</category>
      <category>transactional</category>
      <author>IT Lab</author>
      <guid isPermaLink="true">https://itlab0816.tistory.com/98</guid>
      <comments>https://itlab0816.tistory.com/98#entry98comment</comments>
      <pubDate>Wed, 11 Mar 2026 20:00:13 +0900</pubDate>
    </item>
    <item>
      <title>Spring Boot 계층형 구조(Controller/Service/Repository) 잘 나누는 법 &amp;mdash; 책임 분리와 DTO/엔티티 경계</title>
      <link>https://itlab0816.tistory.com/97</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot 3에서 Controller/Service/Repository를 깔끔하게 분리하는 기준과 DTO/엔티티 경계, 서비스 레이어 규칙을 실무 관점에서 정리합니다.&lt;/p&gt;
&lt;h2 id=&quot;도입-문제-상황&quot; data-ke-size=&quot;size26&quot;&gt;도입 (문제 상황)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기능이 몇 개 없을 때는 Controller에서 Repository를 바로 호출해도 잘 돌아가지만, 요구사항이 늘어나면 &amp;ldquo;이 로직은 어디에 둬야 하지?&amp;rdquo;가 빠르게 문제가 됩니다. 특히 DTO와 엔티티를 섞어 쓰기 시작하면, 작은 변경에도 여러 계층이 같이 흔들리면서 유지보수가 어려워져요.&lt;/p&gt;
&lt;h2 id=&quot;핵심-개념-spring-boot-레이어드-아키텍처에서-경계를-지키는-기준&quot; data-ke-size=&quot;size26&quot;&gt;핵심 개념: Spring Boot 레이어드 아키텍처에서 &amp;ldquo;경계&amp;rdquo;를 지키는 기준&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Controller/Service/Repository를 나누는 목적은 &amp;ldquo;코드를 예쁘게 분류&amp;rdquo;하는 게 아니라 &lt;b&gt;변경의 파급 범위를 줄이는 것&lt;/b&gt;입니다. 각 계층이 책임을 넘지 않도록 경계를 세워두면, 요구사항이 커져도 구조가 무너지지 않습니다.&lt;/p&gt;
&lt;h3 id=&quot;spring-boot-controller-책임-http를-도메인-호출로-번역하기&quot; data-ke-size=&quot;size23&quot;&gt;Spring Boot Controller 책임: HTTP를 도메인 호출로 번역하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Controller는 웹 어댑터입니다. 아래 역할에 집중하면 됩니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;요청을 &lt;b&gt;DTO로 받기&lt;/b&gt;(@RequestBody, @ModelAttribute)&lt;/li&gt;
&lt;li&gt;검증(Validation)과 인증/인가 결과를 바탕으로 &lt;b&gt;서비스 호출&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;서비스 결과를 &lt;b&gt;응답 DTO로 변환&lt;/b&gt;해 반환&lt;/li&gt;
&lt;li&gt;HTTP 상태 코드/헤더/에러 응답 형태 결정&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로, Controller가 하면 곤란한 일은 다음과 같습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;트랜잭션 경계 관리(대부분 Service에서)&lt;/li&gt;
&lt;li&gt;비즈니스 규칙(&amp;ldquo;재고가 0이면 주문 불가&amp;rdquo; 같은 규칙)&lt;/li&gt;
&lt;li&gt;JPA 엔티티를 그대로 응답으로 내보내기(지연 로딩, 순환 참조, 내부 필드 노출 위험)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;spring-boot-service-책임-유스케이스업무-흐름와-규칙의-집합&quot; data-ke-size=&quot;size23&quot;&gt;Spring Boot Service 책임: 유스케이스(업무 흐름)와 규칙의 집합&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Service는 &amp;ldquo;무엇을 할지&amp;rdquo;를 표현하는 계층입니다. 흔히 &lt;b&gt;유스케이스 단위로 메서드가 생기고&lt;/b&gt;, 내부에서 여러 Repository/외부 연동을 조합합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;비즈니스 규칙과 흐름(유스케이스) 구현&lt;/li&gt;
&lt;li&gt;트랜잭션 경계 설정(@Transactional)&lt;/li&gt;
&lt;li&gt;도메인 모델(엔티티) 조작 및 상태 변경&lt;/li&gt;
&lt;li&gt;외부 시스템 호출이 있다면 실패/재시도/보상 같은 정책을 한 곳에 모으기&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Service 레이어 규칙을 한 문장으로 정리하면 이렇습니다.&lt;br /&gt;&lt;b&gt;&amp;ldquo;Controller는 얇게, Service는 규칙을 갖고, Repository는 저장만 한다.&amp;rdquo;&lt;/b&gt;&lt;/p&gt;
&lt;h3 id=&quot;spring-boot-repository-책임-저장소-접근을-캡슐화하기&quot; data-ke-size=&quot;size23&quot;&gt;Spring Boot Repository 책임: 저장소 접근을 캡슐화하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Repository는 데이터 접근을 숨기는 레이어입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;엔티티 단위 CRUD, 조회 쿼리 제공&lt;/li&gt;
&lt;li&gt;쿼리 최적화(fetch join, projection 등) 책임&lt;/li&gt;
&lt;li&gt;비즈니스 규칙을 넣지 않기(&amp;ldquo;삭제 가능 여부 판단&amp;rdquo; 같은 로직은 Service로)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;dto엔티티-경계-택배-상자dto와-실제-상품엔티티를-섞지-않기&quot; data-ke-size=&quot;size23&quot;&gt;DTO/엔티티 경계: &amp;ldquo;택배 상자(DTO)&amp;rdquo;와 &amp;ldquo;실제 상품(엔티티)&amp;rdquo;를 섞지 않기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DTO는 계층 사이를 오가는 &amp;ldquo;운반용 상자&amp;rdquo;이고, 엔티티는 &amp;ldquo;실제 도메인 상태&amp;rdquo;입니다. 상자(DTO)를 그대로 창고(Repository/JPA)에 넣거나, 상품(엔티티)을 그대로 고객(HTTP 응답)에 보내면 문제가 생깁니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;엔티티를 API 응답으로 직접 노출하면
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;내부 필드가 새는 보안 이슈&lt;/li&gt;
&lt;li&gt;LAZY 로딩으로 N+1/예외가 발생&lt;/li&gt;
&lt;li&gt;양방향 연관관계로 JSON 무한 루프&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;반대로 요청 DTO를 엔티티처럼 여기면
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;검증/정규화/규칙 적용이 누락되기 쉽고&lt;/li&gt;
&lt;li&gt;&amp;ldquo;API 스펙 변경&amp;rdquo;이 &amp;ldquo;도메인 변경&amp;rdquo;으로 전염됩니다&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;한눈에-비교-계층별-책임과-금지사항&quot; data-ke-size=&quot;size23&quot;&gt;한눈에 비교: 계층별 책임과 금지사항&lt;/h3&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;계층&lt;/th&gt;
&lt;th&gt;주 책임&lt;/th&gt;
&lt;th&gt;주로 다루는 타입&lt;/th&gt;
&lt;th&gt;하면 안 되는 것(대표)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Controller&lt;/td&gt;
&lt;td&gt;HTTP &amp;harr; 유스케이스 연결&lt;/td&gt;
&lt;td&gt;Request/Response DTO&lt;/td&gt;
&lt;td&gt;비즈니스 규칙, 엔티티 직접 반환&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service&lt;/td&gt;
&lt;td&gt;유스케이스/규칙/트랜잭션&lt;/td&gt;
&lt;td&gt;엔티티, 도메인 값 객체&lt;/td&gt;
&lt;td&gt;HTTP 세부사항(상태코드 등), DB 쿼리 최적화 세부&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Repository&lt;/td&gt;
&lt;td&gt;데이터 접근/쿼리&lt;/td&gt;
&lt;td&gt;엔티티, Projection&lt;/td&gt;
&lt;td&gt;비즈니스 규칙, DTO 중심 설계(무분별한 DTO 저장)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 id=&quot;코드-예제-회원-가입조회로-보는-controller-service-repository-분리-spring-boot-3x&quot; data-ke-size=&quot;size26&quot;&gt;코드 예제: 회원 가입/조회로 보는 Controller-Service-Repository 분리 (Spring Boot 3.x)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 예제는 &amp;ldquo;회원 가입&amp;rdquo;과 &amp;ldquo;회원 단건 조회&amp;rdquo;를 통해 DTO/엔티티 경계와 서비스 규칙을 보여줍니다. 그대로 복붙해서 실행할 수 있게 H2 기준으로 구성했습니다.&lt;/p&gt;
&lt;pre class=&quot;puppet&quot;&gt;&lt;code&gt;// build.gradle (Gradle Groovy)
plugins {
    id 'java'
    id 'org.springframework.boot' version '3.3.2'
    id 'io.spring.dependency-management' version '1.1.6'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain { languageVersion = JavaLanguageVersion.of(17) }
}

repositories { mavenCentral() }

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

    runtimeOnly 'com.h2database:h2'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') { useJUnitPlatform() }
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;# application.yml
spring:
  datasource:
    url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
    driver-class-name: org.h2.Driver
    username: sa
    password:
  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        format_sql: true
  h2:
    console:
      enabled: true

logging:
  level:
    org.hibernate.SQL: debug
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;1-엔티티-도메인-상태와-규칙은-엔티티서비스에서-관리&quot; data-ke-size=&quot;size23&quot;&gt;1) 엔티티: 도메인 상태와 규칙은 엔티티/서비스에서 관리&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;package com.example.demo.member;

import jakarta.persistence.*;

@Entity
@Table(name = &quot;members&quot;, uniqueConstraints = {
        @UniqueConstraint(name = &quot;uk_member_email&quot;, columnNames = &quot;email&quot;)
})
public class Member {

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

    @Column(nullable = false, length = 100)
    private String email;

    @Column(nullable = false, length = 30)
    private String name;

    protected Member() { }

    private Member(String email, String name) {
        this.email = email;
        this.name = name;
    }

    public static Member create(String email, String name) {
        // 엔티티는 &quot;상태를 가진 객체&quot;이므로 생성 시점 불변조건을 지키는 데 유리합니다.
        return new Member(email, name);
    }

    public Long getId() { return id; }
    public String getEmail() { return email; }
    public String getName() { return name; }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;2-dto-api-스펙은-dto가-책임지고-엔티티와-분리&quot; data-ke-size=&quot;size23&quot;&gt;2) DTO: API 스펙은 DTO가 책임지고, 엔티티와 분리&lt;/h3&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;package com.example.demo.member.api;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public class MemberDtos {

    public record CreateRequest(
            @NotBlank @Email String email,
            @NotBlank @Size(max = 30) String name
    ) {}

    public record CreateResponse(Long id) {}

    public record DetailResponse(Long id, String email, String name) {}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;3-repository-저장조회만-담당&quot; data-ke-size=&quot;size23&quot;&gt;3) Repository: 저장/조회만 담당&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;package com.example.demo.member;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface MemberRepository extends JpaRepository&amp;lt;Member, Long&amp;gt; {
    Optional&amp;lt;Member&amp;gt; findByEmail(String email);
    boolean existsByEmail(String email);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;4-service-유스케이스--규칙--트랜잭션-경계&quot; data-ke-size=&quot;size23&quot;&gt;4) Service: 유스케이스 + 규칙 + 트랜잭션 경계&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;package com.example.demo.member;

import com.example.demo.member.api.MemberDtos;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class MemberService {

    private final MemberRepository memberRepository;

    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @Transactional
    public MemberDtos.CreateResponse signUp(MemberDtos.CreateRequest req) {
        // 비즈니스 규칙: 이메일은 유일해야 한다
        if (memberRepository.existsByEmail(req.email())) {
            throw new IllegalArgumentException(&quot;이미 사용 중인 이메일입니다.&quot;);
        }

        Member saved = memberRepository.save(Member.create(req.email(), req.name()));
        return new MemberDtos.CreateResponse(saved.getId());
    }

    @Transactional(readOnly = true)
    public MemberDtos.DetailResponse getMember(Long id) {
        Member member = memberRepository.findById(id)
                .orElseThrow(() -&amp;gt; new IllegalArgumentException(&quot;회원을 찾을 수 없습니다. id=&quot; + id));

        // 엔티티를 직접 반환하지 않고 응답 DTO로 변환
        return new MemberDtos.DetailResponse(member.getId(), member.getEmail(), member.getName());
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;5-controller-http-처리--dto-바인딩검증--서비스-호출&quot; data-ke-size=&quot;size23&quot;&gt;5) Controller: HTTP 처리 + DTO 바인딩/검증 + 서비스 호출&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;package com.example.demo.member.api;

import com.example.demo.member.MemberService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.net.URI;

@RestController
@RequestMapping(&quot;/api/members&quot;)
public class MemberController {

    private final MemberService memberService;

    public MemberController(MemberService memberService) {
        this.memberService = memberService;
    }

    @PostMapping
    public ResponseEntity&amp;lt;MemberDtos.CreateResponse&amp;gt; signUp(@Valid @RequestBody MemberDtos.CreateRequest req) {
        MemberDtos.CreateResponse res = memberService.signUp(req);
        return ResponseEntity
                .created(URI.create(&quot;/api/members/&quot; + res.id()))
                .body(res);
    }

    @GetMapping(&quot;/{id}&quot;)
    public ResponseEntity&amp;lt;MemberDtos.DetailResponse&amp;gt; getMember(@PathVariable Long id) {
        return ResponseEntity.ok(memberService.getMember(id));
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;요청-흐름-다이어그램controllerservicerepository&quot; data-ke-size=&quot;size23&quot;&gt;요청 흐름 다이어그램(Controller/Service/Repository)&lt;/h3&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;sequenceDiagram
  participant C as &quot;Controller&quot;
  participant S as &quot;Service&quot;
  participant R as &quot;Repository&quot;
  participant DB as &quot;Database&quot;

  C-&amp;gt;&amp;gt;S: &quot;DTO로 요청 전달&quot;
  S-&amp;gt;&amp;gt;R: &quot;엔티티 조회/저장 요청&quot;
  R-&amp;gt;&amp;gt;DB: &quot;SQL 실행&quot;
  DB--&amp;gt;&amp;gt;R: &quot;결과 반환&quot;
  R--&amp;gt;&amp;gt;S: &quot;엔티티 반환&quot;
  S--&amp;gt;&amp;gt;C: &quot;응답 DTO 반환&quot;
  C--&amp;gt;&amp;gt;C: &quot;HTTP 응답 생성&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Controller는 HTTP를 처리하고, Service는 규칙/흐름을, Repository는 DB 접근을 담당한다는 흐름을 보여줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;[IMAGE_PROMPT] 위치: &quot;요청 흐름 다이어그램&quot; 바로 아래 alt: Controller-Service-Repository 책임 분리 개념도 prompt: clean minimal tech blog style white background layered architecture diagram showing Controller Service Repository Database with arrows, emphasize responsibilities separation, no dense text style: diagram [/IMAGE_PROMPT]&lt;/p&gt;
&lt;h2 id=&quot;실무-팁&quot; data-ke-size=&quot;size26&quot;&gt;실무 팁&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  실무에서는: &amp;ldquo;Service가 비대해질 때&amp;rdquo; 유스케이스 단위로 쪼개 보세요&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;MemberService&lt;/code&gt;에 모든 회원 관련 기능을 넣기 시작하면 금방 1,000줄이 됩니다. 이때는 &lt;code&gt;MemberSignUpService&lt;/code&gt;, &lt;code&gt;MemberQueryService&lt;/code&gt;처럼 &lt;b&gt;유스케이스/관심사 기준으로 분리&lt;/b&gt;하면 테스트도 쉬워지고 충돌도 줄어듭니다.&lt;/li&gt;
&lt;li&gt;단, 너무 잘게 쪼개서 &amp;ldquo;서비스가 서비스 호출&amp;rdquo; 형태로 꼬이면 오히려 추적이 어려워질 수 있으니, &lt;b&gt;팀의 복잡도에 맞는 적정 분리&lt;/b&gt;가 중요합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kRcmR/dJMcac95hzP/9nesNFsiy9L6acP6ivakak/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kRcmR/dJMcac95hzP/9nesNFsiy9L6acP6ivakak/img.png&quot; data-alt=&quot;DTO와 Entity 경계(택배 상자와 상품) 비유 일러스트&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kRcmR/dJMcac95hzP/9nesNFsiy9L6acP6ivakak/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkRcmR%2FdJMcac95hzP%2F9nesNFsiy9L6acP6ivakak%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1536&quot; height=&quot;1024&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;DTO와 Entity 경계(택배 상자와 상품) 비유 일러스트&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  실무에서는: DTO&amp;harr;엔티티 매핑 위치를 팀 규칙으로 고정해 두는 게 효과가 큽니다&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;권장: &lt;b&gt;Controller는 요청 DTO를 그대로 Service로 전달&lt;/b&gt;, Service에서 엔티티 생성/변환을 담당(유스케이스 규칙과 함께 관리).&lt;/li&gt;
&lt;li&gt;조회 성능이 중요할 때는 Repository에서 Projection으로 DTO를 바로 조회하는 전략도 가능하지만, 이 경우 &amp;ldquo;읽기 모델&amp;rdquo;로 명확히 구분해 두지 않으면 계층 경계가 흐려지기 쉽습니다(예: &lt;code&gt;MemberQueryRepository&lt;/code&gt;/&lt;code&gt;MemberReadModel&lt;/code&gt; 같은 네이밍).&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심 요약: Controller는 HTTP에 집중하고, Service는 유스케이스/규칙/트랜잭션을 책임지며, Repository는 저장소 접근만 담당합니다.&lt;br /&gt;핵심 요약: DTO와 엔티티 경계를 지키면 API 변경이 도메인/DB까지 번지는 일을 줄일 수 있습니다.&lt;br /&gt;핵심 요약: 서비스가 커지면 &amp;ldquo;도메인 기준&amp;rdquo;이 아니라 &amp;ldquo;유스케이스 기준&amp;rdquo;으로 분리해 보세요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 글: #14 @Transactional 제대로 쓰기(전파/읽기전용)&lt;/p&gt;</description>
      <category>Spring Boot</category>
      <category>DTO</category>
      <category>JPA</category>
      <category>Spring Boot</category>
      <category>레이어드 아키텍처</category>
      <category>아키텍처</category>
      <author>IT Lab</author>
      <guid isPermaLink="true">https://itlab0816.tistory.com/97</guid>
      <comments>https://itlab0816.tistory.com/97#entry97comment</comments>
      <pubDate>Wed, 11 Mar 2026 10:00:46 +0900</pubDate>
    </item>
    <item>
      <title>Spring Boot 검증(Validation)과 에러 응답 표준화: @Valid부터 @ControllerAdvice까지</title>
      <link>https://itlab0816.tistory.com/96</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot 3에서 @Valid, BindingResult, @ControllerAdvice로 입력 검증을 적용하고, 공통 에러 응답 포맷을 설계해 일관된 API를 만드는 방법을 정리합니다.&lt;/p&gt;
&lt;h2 id=&quot;도입-문제-상황&quot; data-ke-size=&quot;size26&quot;&gt;도입 (문제 상황)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;API를 만들다 보면 &amp;ldquo;필수값이 빠졌는데 500이 떨어져요&amp;rdquo;, &amp;ldquo;어떤 API는 errors 배열이고 어떤 API는 message 하나예요&amp;rdquo; 같은 상황을 자주 만나게 됩니다. 검증은 넣었는데 응답 포맷이 제각각이라 프런트/모바일에서 예외 처리가 더 어려워지기도 해요. 이 글에서는 Spring Boot 3 기준으로 검증과 에러 응답을 한 번에 정리해 보겠습니다.&lt;/p&gt;
&lt;h2 id=&quot;핵심-개념-spring-boot-validation이-중요한-이유와-표준화-포인트&quot; data-ke-size=&quot;size26&quot;&gt;핵심 개념: Spring Boot Validation이 중요한 이유와 표준화 포인트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검증(Validation)은 단순히 &amp;ldquo;값이 비었는지&amp;rdquo;를 확인하는 기능이 아니라, &lt;b&gt;계약(Contract)&lt;/b&gt; 을 지키게 만드는 장치입니다. 클라이언트가 잘못된 요청을 보내면 서버는 예측 가능한 방식으로 거절해야 하고(보통 400), 그 거절 응답은 모든 API에서 일관돼야 합니다.&lt;/p&gt;
&lt;h3 id=&quot;1-valid--validated-검증-트리거를-어디에-거는가&quot; data-ke-size=&quot;size23&quot;&gt;1) @Valid / @Validated: &amp;ldquo;검증 트리거&amp;rdquo;를 어디에 거는가&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;@Valid&lt;/code&gt;는 주로 &lt;b&gt;요청 DTO(@RequestBody, @ModelAttribute)&lt;/b&gt; 에 붙여 검증을 트리거합니다.&lt;/li&gt;
&lt;li&gt;컨트롤러 메서드 파라미터에 &lt;code&gt;@Valid&lt;/code&gt;를 붙이면, Spring이 바인딩 후 Bean Validation(Jakarta Validation)을 실행합니다.&lt;/li&gt;
&lt;li&gt;그룹 검증이 필요하면 &lt;code&gt;@Validated&lt;/code&gt;를 고려하지만, 일반적인 CRUD에서는 &lt;code&gt;@Valid&lt;/code&gt;만으로 충분한 경우가 많습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;2-bindingresult-예외를-던질지-컨트롤러에서-처리할지&quot; data-ke-size=&quot;size23&quot;&gt;2) BindingResult: 예외를 던질지, 컨트롤러에서 처리할지&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;@Valid&lt;/code&gt; 대상 바로 뒤에 &lt;code&gt;BindingResult&lt;/code&gt;(또는 &lt;code&gt;Errors&lt;/code&gt;)를 두면 &lt;b&gt;검증 실패 시 예외를 던지지 않고&lt;/b&gt; 컨트롤러로 흐름이 들어옵니다.&lt;/li&gt;
&lt;li&gt;반대로 &lt;code&gt;BindingResult&lt;/code&gt;가 없으면 검증 실패 시 보통 &lt;code&gt;MethodArgumentNotValidException&lt;/code&gt;(JSON 바디) 또는 &lt;code&gt;BindException&lt;/code&gt;(쿼리/폼)이 발생하고, 이를 전역 예외 처리로 표준화하기 좋습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실무에서는 &amp;ldquo;모든 검증 실패는 전역에서 같은 포맷으로 응답&amp;rdquo;이 목표라면, &lt;b&gt;컨트롤러에서 BindingResult로 분기하지 않고 예외 기반으로 통일&lt;/b&gt;하는 편이 유지보수에 유리한 경우가 많습니다(특정 화면에서만 커스텀 처리 필요할 때만 BindingResult를 씁니다).&lt;/p&gt;
&lt;h3 id=&quot;3-controlleradvice-에러-응답의-관문을-하나로-모으기&quot; data-ke-size=&quot;size23&quot;&gt;3) @ControllerAdvice: 에러 응답의 &amp;lsquo;관문&amp;rsquo;을 하나로 모으기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;@ControllerAdvice&lt;/code&gt; + &lt;code&gt;@ExceptionHandler&lt;/code&gt;는 컨트롤러 전반에서 발생하는 예외를 한 곳에서 잡아 &lt;b&gt;HTTP 상태 코드, 에러 코드, 메시지, 필드 오류 목록&lt;/b&gt;을 표준화합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 검증 실패는 &amp;ldquo;필드별로 무엇이 왜 틀렸는지&amp;rdquo;가 중요합니다. 메시지 하나로 뭉개면 디버깅도 어렵고 UX도 나빠져요. 그래서 보통 다음처럼 나눕니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;top-level&lt;/b&gt;: timestamp, path, errorCode, message&lt;/li&gt;
&lt;li&gt;&lt;b&gt;details&lt;/b&gt;: fieldErrors[{field, rejectedValue, reason}] 또는 violations&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;4-공통-에러-포맷-설계-프런트가-분기하지-않게&quot; data-ke-size=&quot;size23&quot;&gt;4) 공통 에러 포맷 설계: &amp;ldquo;프런트가 분기하지 않게&amp;rdquo;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 항목은 에러 포맷을 설계할 때 자주 쓰는 체크리스트입니다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;권장 이유&lt;/th&gt;
&lt;th&gt;예시&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;errorCode&lt;/td&gt;
&lt;td&gt;화면/클라이언트가 안정적으로 분기&lt;/td&gt;
&lt;td&gt;&lt;code&gt;VALIDATION_FAILED&lt;/code&gt;, &lt;code&gt;RESOURCE_NOT_FOUND&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;message&lt;/td&gt;
&lt;td&gt;사람에게 보여줄 요약&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&quot;요청 값이 올바르지 않습니다.&quot;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fieldErrors&lt;/td&gt;
&lt;td&gt;폼/필드 하이라이트에 필요&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[{ &quot;field&quot;:&quot;email&quot;, &quot;reason&quot;:&quot;must be a well-formed email address&quot; }]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;path&lt;/td&gt;
&lt;td&gt;어떤 API에서 났는지 추적&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&quot;/api/users&quot;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;traceId(선택)&lt;/td&gt;
&lt;td&gt;로그 상관관계&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&quot;a1b2c3...&quot;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에러 포맷은 &amp;ldquo;예쁘게&amp;rdquo;보다 &amp;ldquo;안정적으로&amp;rdquo;가 핵심입니다. 버전이 바뀌어도 &lt;code&gt;errorCode&lt;/code&gt;와 &lt;code&gt;fieldErrors&lt;/code&gt; 구조가 유지되면 클라이언트는 덜 고생합니다.&lt;/p&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;flowchart TD
  A[&quot;Client request&quot;] --&amp;gt; B[&quot;Spring MVC binding&quot;]
  B --&amp;gt; C{&quot;Validation pass?&quot;}
  C -- &quot;Yes&quot; --&amp;gt; D[&quot;Controller logic&quot;]
  C -- &quot;No&quot; --&amp;gt; E[&quot;Exception (MethodArgumentNotValidException)&quot;]
  E --&amp;gt; F[&quot;@ControllerAdvice handler&quot;]
  F --&amp;gt; G[&quot;Standard error response (400)&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/culUoi/dJMcahjkzt1/iFJK6sM5UI5SeZrlkOQsj1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/culUoi/dJMcahjkzt1/iFJK6sM5UI5SeZrlkOQsj1/img.png&quot; data-alt=&quot;Spring Boot Validation error handling flow illustration&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/culUoi/dJMcahjkzt1/iFJK6sM5UI5SeZrlkOQsj1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FculUoi%2FdJMcahjkzt1%2FiFJK6sM5UI5SeZrlkOQsj1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1536&quot; height=&quot;1024&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Spring Boot Validation error handling flow illustration&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검증 실패는 예외로 모아 @ControllerAdvice에서 공통 포맷으로 응답하는 흐름입니다.&lt;/p&gt;
&lt;h2 id=&quot;코드-예제-valid--controlleradvice로-공통-에러-응답-만들기-복붙-실행&quot; data-ke-size=&quot;size26&quot;&gt;코드 예제: @Valid + @ControllerAdvice로 공통 에러 응답 만들기 (복붙 실행)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 예제는 Spring Boot 3.x(Java 17)에서 바로 실행 가능한 최소 구성입니다.&lt;/p&gt;
&lt;h3 id=&quot;buildgradle&quot; data-ke-size=&quot;size23&quot;&gt;build.gradle&lt;/h3&gt;
&lt;pre class=&quot;puppet&quot;&gt;&lt;code&gt;plugins {
    id 'java'
    id 'org.springframework.boot' version '3.3.2'
    id 'io.spring.dependency-management' version '1.1.6'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-validation' // Jakarta Validation
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
    useJUnitPlatform()
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;applicationyml-선택&quot; data-ke-size=&quot;size23&quot;&gt;application.yml (선택)&lt;/h3&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;server:
  port: 8080
spring:
  jackson:
    serialization:
      write-dates-as-timestamps: false
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;예제-api-회원-가입-요청-dto--컨트롤러&quot; data-ke-size=&quot;size23&quot;&gt;예제 API: 회원 가입 요청 DTO + 컨트롤러&lt;/h3&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;package com.example.demo.user;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record CreateUserRequest(
        @NotBlank(message = &quot;이메일은 필수입니다.&quot;)
        @Email(message = &quot;이메일 형식이 올바르지 않습니다.&quot;)
        String email,

        @NotBlank(message = &quot;비밀번호는 필수입니다.&quot;)
        @Size(min = 8, message = &quot;비밀번호는 8자 이상이어야 합니다.&quot;)
        String password,

        @NotBlank(message = &quot;이름은 필수입니다.&quot;)
        @Size(max = 20, message = &quot;이름은 20자 이하여야 합니다.&quot;)
        String name
) {}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;delphi&quot;&gt;&lt;code&gt;package com.example.demo.user;

public record CreateUserResponse(
        Long id,
        String email,
        String name
) {}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;package com.example.demo.user;

import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping(&quot;/api/users&quot;)
public class UserController {

    @PostMapping
    public CreateUserResponse create(@RequestBody @Valid CreateUserRequest request) {
        // 예제이므로 저장 로직은 생략하고 고정 응답
        return new CreateUserResponse(1L, request.email(), request.name());
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;공통-에러-응답-포맷--전역-예외-처리controlleradvice&quot; data-ke-size=&quot;size23&quot;&gt;공통 에러 응답 포맷 + 전역 예외 처리(@ControllerAdvice)&lt;/h3&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;package com.example.demo.error;

import com.fasterxml.jackson.annotation.JsonInclude;

import java.time.Instant;
import java.util.List;

@JsonInclude(JsonInclude.Include.NON_NULL)
public record ApiErrorResponse(
        Instant timestamp,
        int status,
        String errorCode,
        String message,
        String path,
        List&amp;lt;FieldErrorItem&amp;gt; fieldErrors
) {
    public record FieldErrorItem(
            String field,
            Object rejectedValue,
            String reason
    ) {}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;package com.example.demo.error;

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.time.Instant;
import java.util.List;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity&amp;lt;ApiErrorResponse&amp;gt; handleValidation(
            MethodArgumentNotValidException ex,
            HttpServletRequest request
    ) {
        List&amp;lt;ApiErrorResponse.FieldErrorItem&amp;gt; fieldErrors = ex.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(this::toFieldErrorItem)
                .toList();

        ApiErrorResponse body = new ApiErrorResponse(
                Instant.now(),
                HttpStatus.BAD_REQUEST.value(),
                &quot;VALIDATION_FAILED&quot;,
                &quot;요청 값이 올바르지 않습니다.&quot;,
                request.getRequestURI(),
                fieldErrors
        );

        return ResponseEntity.badRequest().body(body);
    }

    private ApiErrorResponse.FieldErrorItem toFieldErrorItem(FieldError fe) {
        Object rejected = fe.getRejectedValue();
        return new ApiErrorResponse.FieldErrorItem(
                fe.getField(),
                rejected,
                fe.getDefaultMessage()
        );
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;실행--테스트&quot; data-ke-size=&quot;size23&quot;&gt;실행 &amp;amp; 테스트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정상 요청:&lt;/p&gt;
&lt;pre class=&quot;elixir&quot;&gt;&lt;code&gt;curl -X POST http://localhost:8080/api/users \
  -H 'Content-Type: application/json' \
  -d '{&quot;email&quot;:&quot;dev@example.com&quot;,&quot;password&quot;:&quot;password123&quot;,&quot;name&quot;:&quot;kim&quot;}'
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검증 실패 요청(비밀번호 짧음, 이메일 형식 오류):&lt;/p&gt;
&lt;pre class=&quot;elixir&quot;&gt;&lt;code&gt;curl -X POST http://localhost:8080/api/users \
  -H 'Content-Type: application/json' \
  -d '{&quot;email&quot;:&quot;not-email&quot;,&quot;password&quot;:&quot;123&quot;,&quot;name&quot;:&quot;&quot;}'
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예상 응답(예시):&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;timestamp&quot;: &quot;2026-03-09T10:12:30.123Z&quot;,
  &quot;status&quot;: 400,
  &quot;errorCode&quot;: &quot;VALIDATION_FAILED&quot;,
  &quot;message&quot;: &quot;요청 값이 올바르지 않습니다.&quot;,
  &quot;path&quot;: &quot;/api/users&quot;,
  &quot;fieldErrors&quot;: [
    { &quot;field&quot;: &quot;email&quot;, &quot;rejectedValue&quot;: &quot;not-email&quot;, &quot;reason&quot;: &quot;이메일 형식이 올바르지 않습니다.&quot; },
    { &quot;field&quot;: &quot;password&quot;, &quot;rejectedValue&quot;: &quot;123&quot;, &quot;reason&quot;: &quot;비밀번호는 8자 이상이어야 합니다.&quot; },
    { &quot;field&quot;: &quot;name&quot;, &quot;rejectedValue&quot;: &quot;&quot;, &quot;reason&quot;: &quot;이름은 필수입니다.&quot; }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id=&quot;실무-팁&quot; data-ke-size=&quot;size26&quot;&gt;실무 팁&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  실무에서는&lt;br /&gt;&lt;b&gt;BindingResult를 남발하면 에러 포맷이 퍼집니다.&lt;/b&gt; 화면별로 &amp;ldquo;이 컨트롤러만 다른 에러 형태&amp;rdquo;가 생기기 쉬워요. 특별한 이유가 없다면 검증 실패는 예외로 통일하고, &lt;code&gt;@ControllerAdvice&lt;/code&gt;에서 한 번에 포맷을 맞추는 쪽이 운영 비용이 낮습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  실무에서는&lt;br /&gt;&lt;b&gt;에러 메시지(localization)와 에러 코드(errorCode)를 분리&lt;/b&gt;해 두는 게 안전합니다. 사용자에게 보여줄 문구는 바뀔 수 있지만, 클라이언트 분기 기준인 &lt;code&gt;errorCode&lt;/code&gt;는 최대한 고정해야 합니다. 메시지는 i18n(메시지 소스)로 관리하고, &lt;code&gt;errorCode&lt;/code&gt;는 enum/상수로 관리해 보세요.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 id=&quot;핵심-요약&quot; data-ke-size=&quot;size23&quot;&gt;핵심 요약&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;@Valid&lt;/code&gt;로 검증을 트리거하고, 검증 실패는 전역 예외 처리로 모아 표준화하는 게 유지보수에 유리합니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@ControllerAdvice&lt;/code&gt;에서 &lt;code&gt;MethodArgumentNotValidException&lt;/code&gt;을 잡아 &lt;code&gt;fieldErrors&lt;/code&gt; 기반의 공통 포맷을 만드세요.&lt;/li&gt;
&lt;li&gt;에러 응답은 &lt;code&gt;errorCode&lt;/code&gt;(기계용)와 &lt;code&gt;message&lt;/code&gt;(사람용)를 분리하면 변경에 강해집니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 글: [계층형 구조(Controller/Service/Repository) 잘 나누는 법]&lt;/p&gt;</description>
      <category>Spring Boot</category>
      <category>ControllerAdvice</category>
      <category>Error Handling</category>
      <category>rest api</category>
      <category>Spring Boot</category>
      <category>validation</category>
      <author>IT Lab</author>
      <guid isPermaLink="true">https://itlab0816.tistory.com/96</guid>
      <comments>https://itlab0816.tistory.com/96#entry96comment</comments>
      <pubDate>Tue, 10 Mar 2026 20:00:02 +0900</pubDate>
    </item>
    <item>
      <title>Spring Boot 요청/응답 바인딩(@RequestBody, @ModelAttribute) 실전 가이드</title>
      <link>https://itlab0816.tistory.com/95</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot 3에서 폼/쿼리/JSON 바인딩 차이와 Jackson 설정 포인트, null 처리 전략을 실전 코드로 정리합니다.&lt;/p&gt;
&lt;h2 id=&quot;도입-문제-상황&quot; data-ke-size=&quot;size26&quot;&gt;도입 (문제 상황)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;API를 만들다 보면 &amp;ldquo;같은 DTO인데 왜 어떤 요청은 바인딩이 되고, 어떤 요청은 400이 나지?&amp;rdquo; 같은 상황을 자주 만나게 됩니다. 특히 폼 전송/쿼리스트링은 잘 되는데 JSON은 갑자기 실패하거나, null 처리 때문에 업데이트 API가 의도치 않게 값을 지워버리는 일도 생깁니다. 이번 글에서는 Spring Boot에서 요청/응답 바인딩을 실전 관점으로 정리해 봅니다.&lt;/p&gt;
&lt;h2 id=&quot;핵심-개념-spring-boot-바인딩이-갈리는-지점-requestbody-vs-modelattribute&quot; data-ke-size=&quot;size26&quot;&gt;핵심 개념: Spring Boot 바인딩이 갈리는 지점 (@RequestBody vs @ModelAttribute)&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ccqAqq/dJMcaioYE69/JhVVW151EkKxfkyZNTX5C0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ccqAqq/dJMcaioYE69/JhVVW151EkKxfkyZNTX5C0/img.png&quot; data-alt=&quot;RequestBody와 ModelAttribute 바인딩 흐름 비교 다이어그램&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ccqAqq/dJMcaioYE69/JhVVW151EkKxfkyZNTX5C0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FccqAqq%2FdJMcaioYE69%2FJhVVW151EkKxfkyZNTX5C0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1536&quot; height=&quot;1024&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;RequestBody와 ModelAttribute 바인딩 흐름 비교 다이어그램&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring MVC에서 바인딩은 크게 두 갈래로 나뉩니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;@ModelAttribute 계열&lt;/b&gt;: 요청 파라미터(query string), 폼 데이터(&lt;code&gt;application/x-www-form-urlencoded&lt;/code&gt;, &lt;code&gt;multipart/form-data&lt;/code&gt;)를 &lt;b&gt;이름 기준으로 객체 필드에 채웁니다.&lt;/b&gt; 내부적으로 &lt;code&gt;WebDataBinder&lt;/code&gt;가 동작합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;@RequestBody 계열&lt;/b&gt;: 요청 본문(body)을 &lt;b&gt;메시지 컨버터(HttpMessageConverter)&lt;/b&gt; 로 읽습니다. JSON이면 보통 Jackson(&lt;code&gt;MappingJackson2HttpMessageConverter&lt;/code&gt;)이 DTO로 역직렬화합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 차이가 중요한 이유는 다음과 같습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;실패 지점이 다릅니다.&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;@ModelAttribute&lt;/code&gt;: 타입 변환 실패(예: &quot;abc&quot; &amp;rarr; int)는 바인딩 에러로 쌓이고, 컨트롤러 진입 후 &lt;code&gt;BindingResult&lt;/code&gt;/예외 처리로 이어집니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@RequestBody&lt;/code&gt;: JSON 파싱/역직렬화 단계에서 실패하면 컨트롤러 진입 전에 400(Bad Request)로 떨어지기 쉽습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; start=&quot;2&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;null의 의미가 달라집니다.&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;@ModelAttribute&lt;/code&gt;는 &amp;ldquo;파라미터가 아예 없으면&amp;rdquo; 보통 해당 필드는 null(또는 primitive면 기본값)로 남습니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@RequestBody&lt;/code&gt;는 &amp;ldquo;필드가 JSON에 없으면 null&amp;rdquo;, &amp;ldquo;필드가 있고 null이면 null&amp;rdquo;인데, 이 둘을 구분해야 &lt;b&gt;PATCH/부분 업데이트&lt;/b&gt;에서 안전합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; start=&quot;3&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;Jackson 설정이 API의 계약을 바꿉니다.&lt;/b&gt;&lt;br /&gt;예를 들어 응답에서 null 필드를 숨길지(&lt;code&gt;NON_NULL&lt;/code&gt;), 날짜 포맷을 통일할지, unknown field를 허용할지 같은 설정은 클라이언트와의 호환성에 직접 영향을 줍니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;한눈에-비교-폼쿼리-vs-json-바인딩&quot; data-ke-size=&quot;size23&quot;&gt;한눈에 비교: 폼/쿼리 vs JSON 바인딩&lt;/h3&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th&gt;주 사용 어노테이션&lt;/th&gt;
&lt;th&gt;입력 형태&lt;/th&gt;
&lt;th&gt;동작 메커니즘&lt;/th&gt;
&lt;th&gt;자주 나는 이슈&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;쿼리스트링/폼&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@ModelAttribute&lt;/code&gt; (또는 생략)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;?name=...&lt;/code&gt; / &lt;code&gt;x-www-form-urlencoded&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;DataBinder 기반 필드 매핑&lt;/td&gt;
&lt;td&gt;타입 변환 오류, 파라미터 누락 시 기본값 문제&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;멀티파트 폼&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@ModelAttribute&lt;/code&gt; + &lt;code&gt;MultipartFile&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;multipart/form-data&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;DataBinder + MultipartResolver&lt;/td&gt;
&lt;td&gt;파일/필드 혼합 시 DTO 설계 난이도&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JSON 본문&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@RequestBody&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;application/json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HttpMessageConverter(Jackson)&lt;/td&gt;
&lt;td&gt;JSON 파싱 오류, unknown field, null 처리/부분 업데이트&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 id=&quot;코드-예제-폼쿼리json-바인딩--jackson-설정--null-처리-전략&quot; data-ke-size=&quot;size26&quot;&gt;코드 예제: 폼/쿼리/JSON 바인딩 + Jackson 설정 + null 처리 전략&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 예제는 그대로 복사해서 실행할 수 있는 Spring Boot 3.x 프로젝트입니다.&lt;br /&gt;(패키지명은 편하신 대로 바꾸셔도 됩니다.)&lt;/p&gt;
&lt;h3 id=&quot;buildgradle&quot; data-ke-size=&quot;size23&quot;&gt;build.gradle&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;plugins {
    id 'java'
    id 'org.springframework.boot' version '3.3.2'
    id 'io.spring.dependency-management' version '1.1.6'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
    useJUnitPlatform()
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;applicationyml-jackson-핵심-설정-포인트&quot; data-ke-size=&quot;size23&quot;&gt;application.yml (Jackson 핵심 설정 포인트)&lt;/h3&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;spring:
  jackson:
    # 응답에서 null 필드를 숨기고 싶을 때 (API 응답을 더 깔끔하게)
    default-property-inclusion: non_null

    # 알 수 없는 필드가 들어오면 실패시키는 옵션
    # 운영에서는 &quot;엄격 모드&quot;가 계약 위반을 빨리 잡아줘서 유리한 경우가 많습니다.
    deserialization:
      fail-on-unknown-properties: true

    # 날짜/시간 직렬화 기본 동작(ISO-8601) 유지
    serialization:
      write-dates-as-timestamps: false
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;요청-바인딩-데모-컨트롤러&quot; data-ke-size=&quot;size23&quot;&gt;요청 바인딩 데모 컨트롤러&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;package com.example.binding;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonSetter;
import com.fasterxml.jackson.annotation.Nulls;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.util.Optional;

@RestController
@RequestMapping(&quot;/api&quot;)
public class BindingController {

    // 1) 쿼리스트링/폼 바인딩: @ModelAttribute
    // 예: GET /api/search?keyword=spring&amp;amp;page=1
    @GetMapping(&quot;/search&quot;)
    public SearchRequest search(@ModelAttribute SearchRequest request) {
        return request;
    }

    // 예: POST /api/form (Content-Type: application/x-www-form-urlencoded)
    // body: name=kim&amp;amp;age=20
    @PostMapping(path = &quot;/form&quot;, consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
    public UserFormRequest form(UserFormRequest request) { // @ModelAttribute 생략 가능
        return request;
    }

    // 2) JSON 바인딩: @RequestBody
    // 예: POST /api/users (Content-Type: application/json)
    @PostMapping(&quot;/users&quot;)
    public CreateUserRequest create(@RequestBody CreateUserRequest request) {
        return request;
    }

    // 3) 멀티파트: DTO + 파일을 섞을 때는 @ModelAttribute가 자연스럽습니다.
    @PostMapping(path = &quot;/upload&quot;, consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public UploadRequest upload(@ModelAttribute UploadRequest request) {
        // 파일은 request.file()로 접근
        return request;
    }

    // 4) 부분 업데이트(PATCH)에서 null 처리 전략 예시
    // - &quot;필드가 아예 없다&quot; = 변경하지 않음
    // - &quot;필드가 null이다&quot; = 정책에 따라 (허용 시) null로 변경
    @PatchMapping(&quot;/users/{id}&quot;)
    public String patch(@PathVariable long id, @RequestBody UpdateUserRequest request) {
        // 실무에서는 여기서 서비스 레이어로 내려가 병합(merge) 로직을 수행합니다.
        // 예시에서는 의미만 보여주기 위해 문자열로 반환합니다.
        return &quot;id=&quot; + id +
                &quot;, name=&quot; + request.name().map(v -&amp;gt; &quot;SET(&quot; + v + &quot;)&quot;).orElse(&quot;NO_CHANGE&quot;) +
                &quot;, nickname=&quot; + request.nickname().map(v -&amp;gt; &quot;SET(&quot; + v + &quot;)&quot;).orElse(&quot;NO_CHANGE&quot;);
    }

    // ===== DTOs =====

    public record SearchRequest(
            String keyword,
            Integer page
    ) {}

    public record UserFormRequest(
            String name,
            Integer age
    ) {}

    public record CreateUserRequest(
            String email,
            String name
    ) {}

    public record UploadRequest(
            String title,
            MultipartFile file
    ) {}

    /**
     * PATCH용 DTO에서 &quot;필드 부재&quot;와 &quot;null&quot;을 구분하고 싶을 때 Optional을 활용하는 패턴입니다.
     * - JSON에 필드가 없으면 Optional.empty()
     * - JSON에 필드가 있고 값이 있으면 Optional.of(value)
     * - JSON에 필드가 있고 null이면: 아래 @JsonSetter로 Optional.empty()로 들어오게(=정책) 만들 수 있습니다.
     *
     * 주의: &quot;null을 명시적으로 보내서 값을 지우는 기능&quot;이 필요하면 이 정책을 바꾸거나 별도 표현이 필요합니다.
     */
    public record UpdateUserRequest(
            @JsonSetter(nulls = Nulls.AS_EMPTY)
            Optional&amp;lt;String&amp;gt; name,

            @JsonSetter(nulls = Nulls.AS_EMPTY)
            Optional&amp;lt;String&amp;gt; nickname
    ) {}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;실행테스트용-요청-예시&quot; data-ke-size=&quot;size23&quot;&gt;실행/테스트용 요청 예시&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;쿼리 바인딩&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;curl &quot;http://localhost:8080/api/search?keyword=spring&amp;amp;page=1&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;폼 바인딩&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;curl -X POST &quot;http://localhost:8080/api/form&quot; \
  -H &quot;Content-Type: application/x-www-form-urlencoded&quot; \
  -d &quot;name=kim&amp;amp;age=20&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;JSON 바인딩&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;scilab&quot;&gt;&lt;code&gt;curl -X POST &quot;http://localhost:8080/api/users&quot; \
  -H &quot;Content-Type: application/json&quot; \
  -d '{&quot;email&quot;:&quot;a@b.com&quot;,&quot;name&quot;:&quot;kim&quot;}'
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;PATCH에서 &amp;ldquo;필드 없음&amp;rdquo; vs &amp;ldquo;null&amp;rdquo; 확인&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;# name만 변경
curl -X PATCH &quot;http://localhost:8080/api/users/1&quot; \
  -H &quot;Content-Type: application/json&quot; \
  -d '{&quot;name&quot;:&quot;new-name&quot;}'

# name을 null로 보냄 (현재 예제 정책에서는 Optional.empty() 처리 = NO_CHANGE로 간주)
curl -X PATCH &quot;http://localhost:8080/api/users/1&quot; \
  -H &quot;Content-Type: application/json&quot; \
  -d '{&quot;name&quot;:null}'
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;flowchart TD
  A[&quot;HTTP Request&quot;] --&amp;gt; B[&quot;HandlerMapping&quot;]
  B --&amp;gt; C[&quot;HandlerAdapter&quot;]
  C --&amp;gt; D{&quot;Binding Type&quot;}
  D --&amp;gt;| &quot;@ModelAttribute&quot; | E[&quot;WebDataBinder\n(query/form -&amp;gt; object)&quot;]
  D --&amp;gt;| &quot;@RequestBody&quot; | F[&quot;HttpMessageConverter\n(Jackson JSON -&amp;gt; object)&quot;]
  E --&amp;gt; G[&quot;Controller Method&quot;]
  F --&amp;gt; G[&quot;Controller Method&quot;]
  G --&amp;gt; H[&quot;Response (Jackson serialize)&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요청 데이터가 어디에서 어떻게 객체로 변환되는지(바인딩 지점)를 보여주는 흐름도입니다.&lt;/p&gt;
&lt;h2 id=&quot;실무-팁&quot; data-ke-size=&quot;size26&quot;&gt;실무 팁&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  실무에서는: PATCH/부분 업데이트에서 null 정책을 먼저 문서로 고정해 두세요&lt;/p&gt;
&lt;/blockquote&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&amp;ldquo;필드 미포함 = 변경 없음&amp;rdquo;은 대부분의 API가 기대하는 동작입니다.&lt;/li&gt;
&lt;li&gt;&amp;ldquo;null 전송 = 값 삭제&amp;rdquo;를 허용할지 여부는 민감합니다(특히 개인정보/필수값). 허용한다면 &lt;b&gt;별도 엔드포인트(예: /nickname:clear)&lt;/b&gt; 나 &lt;b&gt;명시적 연산 모델(JSON Patch)&lt;/b&gt; 을 고려해 보세요.&lt;/li&gt;
&lt;li&gt;Optional을 쓰는 방식은 간단하지만, &amp;ldquo;null을 삭제로 해석&amp;rdquo;해야 하는 요구가 생기면 DTO/정책을 다시 손봐야 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  실무에서는: Jackson의 &lt;code&gt;fail-on-unknown-properties&lt;/code&gt;를 환경별로 다르게 운영하기도 합니다&lt;/p&gt;
&lt;/blockquote&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;클라이언트가 여러 버전으로 섞여 들어오는 환경에서는 unknown field를 무조건 400으로 만들면 장애처럼 보일 수 있습니다.&lt;/li&gt;
&lt;li&gt;반대로 내부 B2B/사내 API라면 엄격 모드가 계약 위반을 빨리 잡아줘서 유지보수성이 좋아집니다.&lt;/li&gt;
&lt;li&gt;타협안으로는 &lt;b&gt;로그/모니터링으로 unknown field를 탐지&lt;/b&gt;하고, 일정 기간 후 엄격 모드로 전환하는 전략이 현실적입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 요약&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;@ModelAttribute&lt;/code&gt;는 쿼리/폼, &lt;code&gt;@RequestBody&lt;/code&gt;는 JSON 본문을 컨버터(Jackson)로 바인딩합니다.&lt;/li&gt;
&lt;li&gt;Jackson 설정은 &amp;ldquo;API 계약&amp;rdquo;에 해당하므로 null/unknown field 정책을 명확히 정해야 합니다.&lt;/li&gt;
&lt;li&gt;부분 업데이트에서는 &amp;ldquo;필드 부재 vs null&amp;rdquo;을 구분하는 전략(Optional 등)을 먼저 정해 두는 게 안전합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 글: #12 검증(Validation)과 에러 응답 표준화&lt;/p&gt;</description>
      <category>Spring Boot</category>
      <category>@ModelAttribute</category>
      <category>@RequestBody</category>
      <category>API 설계</category>
      <category>Jackson</category>
      <category>Spring Boot</category>
      <author>IT Lab</author>
      <guid isPermaLink="true">https://itlab0816.tistory.com/95</guid>
      <comments>https://itlab0816.tistory.com/95#entry95comment</comments>
      <pubDate>Tue, 10 Mar 2026 10:00:25 +0900</pubDate>
    </item>
    <item>
      <title>Spring Boot에서 @Controller vs @RestController 제대로 구분하기 (View 렌더링과 JSON 응답 기준)</title>
      <link>https://itlab0816.tistory.com/94</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot 3에서 @Controller와 @RestController의 차이를 View 렌더링/JSON 응답 관점에서 정리하고, ResponseEntity 사용 기준과 실무 컨벤션까지 예제로 확인합니다.&lt;/p&gt;
&lt;h2 id=&quot;도입-문제-상황&quot; data-ke-size=&quot;size26&quot;&gt;도입 (문제 상황)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;페이지는 잘 떠야 하는데 갑자기 JSON이 내려오거나, 반대로 API를 만들었는데 뷰 이름을 찾다가 404가 나는 경험을 해 보셨을 거예요. 특히 &amp;ldquo;컨트롤러는 그냥 컨트롤러 아닌가?&amp;rdquo;라고 생각하고 &lt;code&gt;@Controller&lt;/code&gt;와 &lt;code&gt;@RestController&lt;/code&gt;를 섞어 쓰면, 작은 차이가 운영에서 큰 장애로 번지기도 합니다.&lt;/p&gt;
&lt;h2 id=&quot;핵심-개념-spring-boot에서-controller와-restcontroller-차이가-중요한-이유&quot; data-ke-size=&quot;size26&quot;&gt;핵심 개념: Spring Boot에서 @Controller와 @RestController 차이가 중요한 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론부터 말하면, 두 애노테이션의 차이는 &amp;ldquo;반환값을 &lt;b&gt;무엇으로 해석하느냐&lt;/b&gt;&amp;rdquo;에 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;@Controller&lt;/code&gt;&lt;br /&gt;기본적으로 &lt;b&gt;View 렌더링(템플릿)&lt;/b&gt; 을 위한 컨트롤러입니다. 메서드가 &lt;code&gt;String&lt;/code&gt;을 반환하면 &amp;ldquo;응답 본문 문자열&amp;rdquo;이 아니라 &lt;b&gt;뷰 이름&lt;/b&gt;으로 해석됩니다.&lt;br /&gt;JSON을 내려주고 싶으면 메서드나 클래스에 &lt;code&gt;@ResponseBody&lt;/code&gt;를 붙여 &amp;ldquo;반환값을 HTTP Body로 쓰겠다&amp;rdquo;를 명시해야 합니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@RestController&lt;/code&gt;&lt;br /&gt;&lt;code&gt;@Controller + @ResponseBody&lt;/code&gt;의 합성 애노테이션입니다. 즉, 반환값은 기본적으로 &lt;b&gt;항상 HTTP Body&lt;/b&gt;로 직렬화되어 내려갑니다(보통 JSON).&lt;br /&gt;API 서버를 만들 때 의도가 분명해지고, 실수로 뷰 리졸버를 타는 사고를 줄여줍니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 차이는 단순 취향 문제가 아니라, &lt;b&gt;요청 처리 파이프라인에서 어떤 HandlerMethodReturnValueHandler가 동작하느냐&lt;/b&gt;를 바꿉니다. &amp;ldquo;문자열을 반환했는데 왜 JSON이 아니지?&amp;rdquo; 같은 혼란은 여기서 시작합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 표로 빠르게 정리해 보겠습니다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th&gt;@Controller&lt;/th&gt;
&lt;th&gt;@RestController&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;기본 목적&lt;/td&gt;
&lt;td&gt;View 렌더링(MVC)&lt;/td&gt;
&lt;td&gt;API(JSON 등)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;반환값 기본 해석&lt;/td&gt;
&lt;td&gt;View 이름(예: &lt;code&gt;&quot;home&quot;&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Response Body(JSON 직렬화)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JSON 응답&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@ResponseBody&lt;/code&gt; 필요&lt;/td&gt;
&lt;td&gt;기본으로 JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;실수 포인트&lt;/td&gt;
&lt;td&gt;&lt;code&gt;String&lt;/code&gt; 반환 시 뷰로 해석되어 404/템플릿 오류&lt;/td&gt;
&lt;td&gt;View 렌더링하려다 문자열이 그대로 내려감&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;추천 사용처&lt;/td&gt;
&lt;td&gt;SSR/템플릿(Thymeleaf 등)&lt;/td&gt;
&lt;td&gt;REST API, BFF API&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 실무에서 자주 엮이는 주제가 &lt;code&gt;ResponseEntity&lt;/code&gt;입니다.&lt;/p&gt;
&lt;h3 id=&quot;responseentity는-언제-쓰는-게-맞을까요&quot; data-ke-size=&quot;size23&quot;&gt;ResponseEntity는 언제 쓰는 게 맞을까요?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ResponseEntity&amp;lt;T&amp;gt;&lt;/code&gt;는 &amp;ldquo;바디(T) + 상태코드 + 헤더&amp;rdquo;를 한 번에 제어하는 도구입니다. 다음 상황이면 쓰는 게 자연스럽습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;상태코드를 명확히 제어&lt;/b&gt;해야 할 때 (201 Created, 204 No Content, 404 Not Found 등)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Location 헤더&lt;/b&gt; 같은 응답 헤더를 세팅해야 할 때&lt;/li&gt;
&lt;li&gt;성공/실패 케이스에 따라 응답 형태가 달라질 때(단, 과도한 남발은 피하는 게 좋아요)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로, 항상 200 OK에 단순 JSON만 내려가는 API라면 DTO를 그대로 반환해도 충분합니다. 코드가 짧아지고, 읽기도 쉬워집니다.&lt;/p&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;flowchart TD
  A[&quot;Controller method return value&quot;] --&amp;gt; B{&quot;Annotation type?&quot;}
  B --&amp;gt;| &quot;@Controller&quot; | C{&quot;Has @ResponseBody?&quot;}
  C --&amp;gt;| &quot;No&quot; | D[&quot;ViewResolver renders template&quot;]
  C --&amp;gt;| &quot;Yes&quot; | E[&quot;HttpMessageConverter writes body (JSON)&quot;]
  B --&amp;gt;| &quot;@RestController&quot; | E
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/p57yC/dJMcajuBQFE/Es9WUPzbKFjSq6Ie4JWHKk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/p57yC/dJMcajuBQFE/Es9WUPzbKFjSq6Ie4JWHKk/img.png&quot; data-alt=&quot;Controller와 RestController 반환 흐름 요약 다이어그램&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/p57yC/dJMcajuBQFE/Es9WUPzbKFjSq6Ie4JWHKk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fp57yC%2FdJMcajuBQFE%2FEs9WUPzbKFjSq6Ie4JWHKk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1536&quot; height=&quot;1024&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Controller와 RestController 반환 흐름 요약 다이어그램&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반환값이 뷰로 갈지(JSON로) 바디로 갈지 결정되는 흐름입니다.&lt;/p&gt;
&lt;h2 id=&quot;코드-예제-view-렌더링controller-vs-json-응답restcontroller--responseentity-기준&quot; data-ke-size=&quot;size26&quot;&gt;코드 예제: View 렌더링(@Controller) vs JSON 응답(@RestController) + ResponseEntity 기준&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 예제는 Spring Boot 3.x(Java 17) 기준으로 그대로 복붙해 실행할 수 있게 구성했습니다. &lt;code&gt;/web&lt;/code&gt;은 뷰 렌더링, &lt;code&gt;/api&lt;/code&gt;는 JSON API를 보여줍니다.&lt;/p&gt;
&lt;h3 id=&quot;buildgradle&quot; data-ke-size=&quot;size23&quot;&gt;build.gradle&lt;/h3&gt;
&lt;pre class=&quot;puppet&quot;&gt;&lt;code&gt;plugins {
    id 'java'
    id 'org.springframework.boot' version '3.3.2'
    id 'io.spring.dependency-management' version '1.1.6'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' // View 렌더링 예제용
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
    useJUnitPlatform()
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;applicationyml&quot; data-ke-size=&quot;size23&quot;&gt;application.yml&lt;/h3&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;spring:
  thymeleaf:
    cache: false # 예제에서는 즉시 반영을 위해 비활성화(운영에서는 true 권장)
server:
  port: 8080
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;실행-클래스&quot; data-ke-size=&quot;size23&quot;&gt;실행 클래스&lt;/h3&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DemoApp {
    public static void main(String[] args) {
        SpringApplication.run(DemoApp.class, args);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;view-렌더링-컨트롤러controller&quot; data-ke-size=&quot;size23&quot;&gt;View 렌더링 컨트롤러(@Controller)&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;package com.example.demo.web;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class PageController {

    @GetMapping(&quot;/web&quot;)
    public String page(Model model) {
        model.addAttribute(&quot;message&quot;, &quot;This is a server-rendered view.&quot;);
        return &quot;home&quot;; // templates/home.html 을 찾습니다.
    }

    @GetMapping(&quot;/web/json&quot;)
    @org.springframework.web.bind.annotation.ResponseBody
    public Message jsonFromController() {
        // @Controller에서도 @ResponseBody를 붙이면 JSON 응답이 됩니다.
        return new Message(&quot;JSON from @Controller + @ResponseBody&quot;);
    }

    public record Message(String text) {}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;api-컨트롤러restcontroller--responseentity&quot; data-ke-size=&quot;size23&quot;&gt;API 컨트롤러(@RestController) + ResponseEntity&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;package com.example.demo.api;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.net.URI;

@RestController
@RequestMapping(&quot;/api/messages&quot;)
public class MessageApiController {

    @GetMapping(&quot;/{id}&quot;)
    public MessageResponse get(@PathVariable long id) {
        // 단순 조회: 200 OK + JSON이면 DTO 반환만으로 충분한 경우가 많습니다.
        return new MessageResponse(id, &quot;hello&quot;);
    }

    @PostMapping
    public ResponseEntity&amp;lt;MessageResponse&amp;gt; create(@RequestBody CreateMessageRequest req) {
        // 생성: 201 Created + Location 헤더를 주고 싶다면 ResponseEntity가 깔끔합니다.
        long newId = 1L; // 예제용
        var body = new MessageResponse(newId, req.text());

        return ResponseEntity
                .created(URI.create(&quot;/api/messages/&quot; + newId))
                .body(body);
    }

    @DeleteMapping(&quot;/{id}&quot;)
    public ResponseEntity&amp;lt;Void&amp;gt; delete(@PathVariable long id) {
        // 삭제 성공: 204 No Content는 ResponseEntity가 의도가 분명합니다.
        return ResponseEntity.noContent().build();
    }

    public record CreateMessageRequest(String text) {}
    public record MessageResponse(long id, String text) {}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;thymeleaf-템플릿-resourcestemplateshomehtml&quot; data-ke-size=&quot;size23&quot;&gt;Thymeleaf 템플릿: resources/templates/home.html&lt;/h3&gt;
&lt;pre class=&quot;dust&quot;&gt;&lt;code&gt;&amp;lt;!doctype html&amp;gt;
&amp;lt;html lang=&quot;en&quot;&amp;gt;
&amp;lt;head&amp;gt;
    &amp;lt;meta charset=&quot;UTF-8&quot;/&amp;gt;
    &amp;lt;title&amp;gt;Home&amp;lt;/title&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
&amp;lt;h1 th:text=&quot;${message}&quot;&amp;gt;placeholder&amp;lt;/h1&amp;gt;
&amp;lt;p&amp;gt;Try: &amp;lt;a href=&quot;/web/json&quot;&amp;gt;/web/json&amp;lt;/a&amp;gt;&amp;lt;/p&amp;gt;
&amp;lt;p&amp;gt;Try: &amp;lt;a href=&quot;/api/messages/1&quot;&amp;gt;/api/messages/1&amp;lt;/a&amp;gt;&amp;lt;/p&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;실행-후-확인&quot; data-ke-size=&quot;size20&quot;&gt;실행 후 확인&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;GET http://localhost:8080/web&lt;/code&gt; &amp;rarr; HTML(View 렌더링)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GET http://localhost:8080/web/json&lt;/code&gt; &amp;rarr; JSON (Controller + ResponseBody)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GET http://localhost:8080/api/messages/1&lt;/code&gt; &amp;rarr; JSON (RestController)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;POST http://localhost:8080/api/messages&lt;/code&gt; with body &lt;code&gt;{&quot;text&quot;:&quot;hi&quot;}&lt;/code&gt; &amp;rarr; 201 + Location 헤더&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;실무-팁&quot; data-ke-size=&quot;size26&quot;&gt;실무 팁&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  실무에서는: &amp;ldquo;패키지/URL로 역할을 분리&amp;rdquo;하는 컨벤션이 사고를 줄여줍니다&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;com.example.xxx.web&lt;/code&gt; 패키지에는 &lt;code&gt;@Controller&lt;/code&gt;만 두고, URL도 &lt;code&gt;/web&lt;/code&gt;, &lt;code&gt;/admin&lt;/code&gt;, &lt;code&gt;/pages&lt;/code&gt;처럼 &lt;b&gt;뷰 성격이 드러나게&lt;/b&gt; 잡아두면 혼선이 줄어듭니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;com.example.xxx.api&lt;/code&gt; 패키지에는 &lt;code&gt;@RestController&lt;/code&gt;만 두고, URL도 &lt;code&gt;/api/**&lt;/code&gt;로 고정해 두면 &amp;ldquo;여긴 무조건 JSON&amp;rdquo;이라는 기대가 생깁니다.&lt;/li&gt;
&lt;li&gt;한 클래스에서 뷰와 API를 섞으면(특히 &lt;code&gt;String&lt;/code&gt; 반환) 리뷰/운영에서 실수가 자주 나옵니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  실무에서는: ResponseEntity를 &amp;ldquo;기본값&amp;rdquo;으로 두기보다 &amp;ldquo;필요할 때만&amp;rdquo; 쓰는 게 유지보수에 유리합니다&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;모든 메서드가 &lt;code&gt;ResponseEntity&amp;lt;...&amp;gt;&lt;/code&gt;면 상태코드/헤더 제어가 쉬워 보이지만, 단순 조회까지 장황해져서 가독성이 떨어질 수 있습니다.&lt;/li&gt;
&lt;li&gt;추천 기준:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;조회(항상 200)&lt;/b&gt; &amp;rarr; DTO 반환&lt;/li&gt;
&lt;li&gt;&lt;b&gt;생성(201, Location)&lt;/b&gt; / &lt;b&gt;삭제(204)&lt;/b&gt; / &lt;b&gt;조건부 응답(404/409 등)&lt;/b&gt; &amp;rarr; ResponseEntity&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;에러 응답은 컨트롤러에서 매번 처리하기보다 &lt;code&gt;@RestControllerAdvice&lt;/code&gt;로 공통화하면 일관성이 좋아집니다(다음 글의 바인딩 주제와도 연결됩니다).&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 요약&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;@Controller&lt;/code&gt;는 기본이 View 렌더링, &lt;code&gt;@RestController&lt;/code&gt;는 기본이 JSON(Response Body)입니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ResponseEntity&lt;/code&gt;는 상태코드/헤더를 &amp;ldquo;의도적으로&amp;rdquo; 제어해야 할 때 특히 유용합니다.&lt;/li&gt;
&lt;li&gt;패키지/URL 컨벤션으로 View와 API를 분리하면 운영 사고가 크게 줄어듭니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 글: [#11 요청/응답 바인딩(@RequestBody, @ModelAttribute) 실전]&lt;/p&gt;</description>
      <category>Spring Boot</category>
      <category>controller</category>
      <category>ResponseEntity</category>
      <category>RestController</category>
      <category>Spring Boot</category>
      <category>Spring MVC</category>
      <author>IT Lab</author>
      <guid isPermaLink="true">https://itlab0816.tistory.com/94</guid>
      <comments>https://itlab0816.tistory.com/94#entry94comment</comments>
      <pubDate>Mon, 9 Mar 2026 20:00:38 +0900</pubDate>
    </item>
  </channel>
</rss>