<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>은지옴</title>
    <link>https://eunjiom.tistory.com/</link>
    <description>은지 왔다 감</description>
    <language>ko</language>
    <pubDate>Sat, 27 Jun 2026 13:27:28 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>eunjiom</managingEditor>
    <image>
      <title>은지옴</title>
      <url>https://tistory1.daumcdn.net/tistory/8475023/attach/5731ebc4351f4eb481e7195908cc8a3f</url>
      <link>https://eunjiom.tistory.com</link>
    </image>
    <item>
      <title>[   ONESTOP ] Spring Boot 모니터링(Prometheus + Grafana) + Swagger JWT 인증 설정</title>
      <link>https://eunjiom.tistory.com/54</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 모니터링 &amp;mdash; Prometheus + Grafana&lt;/h2&gt;
&lt;h3 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;서비스 운영 중에 CPU, 메모리, HTTP 요청 수, 응답 시간, DB 커넥션 풀 상태 등을 수치로 파악하지 못하면 장애가 발생했을 때 원인을 찾기 어렵다.&lt;/li&gt;
&lt;li&gt;특히 커머스 서비스는 주문/결제 흐름이 복잡해서 병목 지점을 모니터링으로 사전에 파악하는 게 중요하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;기술 선택 이유 &amp;mdash; Prometheus + Grafana&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Spring Boot Actuator가 /actuator/prometheus 엔드포인트로 메트릭을 Prometheus 포맷으로 노출하면, Prometheus가 주기적으로 수집(scrape)하고, Grafana가 시각화하는 구조다.&lt;/li&gt;
&lt;li&gt;Spring Boot와 공식 연동: micrometer-registry-prometheus 의존성 하나로 JVM, HikariCP, HTTP 요청, Spring Security 필터 체인까지 별도 코드 없이 자동 수집된다.&lt;/li&gt;
&lt;li&gt;오픈소스 무료: 상용 APM(New Relic, Datadog)은 트래픽 기반 과금이라 사이드 프로젝트에서 부담스럽다. Prometheus + Grafana는 완전 무료다.&lt;/li&gt;
&lt;li&gt;대시보드 생태계: Grafana 커뮤니티에 Spring Boot 전용 대시보드(Dashboard ID: 4701, 12900 등)가 공유되어 있어 import 한 번으로 바로 쓸 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;다른 선택지와 비교&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&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;Prometheus + Grafana&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;Datadog&lt;/td&gt;
&lt;td&gt;SaaS, 에이전트 방식, 풍부한 기능&lt;/td&gt;
&lt;td&gt;팀 규모가 크고 예산 있을 때&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New Relic&lt;/td&gt;
&lt;td&gt;SaaS, APM 특화&lt;/td&gt;
&lt;td&gt;코드 레벨 성능 분석 필요할 때&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CloudWatch&lt;/td&gt;
&lt;td&gt;AWS 네이티브&lt;/td&gt;
&lt;td&gt;AWS 인프라에 올인한 경우&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ELK Stack&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 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;application-local.yml&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;management:
  endpoints:
    web:
      exposure:
        include: health, info, metrics, prometheus
  endpoint:
    prometheus:
      enabled: true
    health:
      show-details: when-authorized
&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;monitoring/prometheus.yml (프로젝트 루트)&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;global:
  scrape_interval: 15s

scrape_configs:
  - job_name: 'one-stop'
    metrics_path: '/actuator/prometheus'
    static_configs:
      - targets: ['host.docker.internal:8080']
&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;docker-compose.yml 추가&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;prometheus:
  image: prom/prometheus:latest
  container_name: one-stop-prometheus
  ports:
    - &quot;9090:9090&quot;
  volumes:
    - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml
  extra_hosts:
    - &quot;host.docker.internal:host-gateway&quot;

grafana:
  image: grafana/grafana:latest
  container_name: one-stop-grafana
  ports:
    - &quot;3000:3000&quot;
  environment:
    - GF_SECURITY_ADMIN_USER=${GRAFANA_ADMIN_USER}
    - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD}
  volumes:
    - grafana-data:/var/lib/grafana
  depends_on:
    - prometheus
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;prometheus.yml을 src 안에 넣지 않은 이유&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;prometheus.yml은 Java 코드가 아니라 Prometheus 컨테이너가 읽는 인프라 설정 파일이다&lt;/li&gt;
&lt;li&gt;Spring 앱과 관계가 없으므로 docker-compose.yml과 같은 레벨에 monitoring/ 폴더를 만들어 관리했다&lt;/li&gt;
&lt;li&gt;인프라 설정과 애플리케이션 코드를 분리하는 게 유지보수에 좋다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;코드 리뷰봇 피드백 반영&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. Grafana 계정 하드코딩 &amp;rarr; 환경변수 분리&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;처음에 GF_SECURITY_ADMIN_USER=admin, GF_SECURITY_ADMIN_PASSWORD=admin을 docker-compose.yml에 직접 작성했다&lt;/li&gt;
&lt;li&gt;리뷰봇이 보안 취약점으로 지적했고, .env 파일로 분리했다. .env는 .gitignore에 포함되어 있어 깃허브에 올라가지 않고 팀 내에서 슬랙으로 공유했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# 수정 전
environment:
  - GF_SECURITY_ADMIN_USER=admin
  - GF_SECURITY_ADMIN_PASSWORD=admin

# 수정 후
environment:
  - GF_SECURITY_ADMIN_USER=${GRAFANA_ADMIN_USER}
  - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. management.metrics.export.prometheus.enabled Deprecated 경고 제거&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;Spring Boot 3.x에서 해당 설정이 Deprecated됐다. micrometer-registry-prometheus 의존성만 있으면 자동 활성화되므로 해당 설정 라인을 제거했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. health.show-details: always &amp;rarr; when-authorized&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;always로 설정하면 비인증 사용자도 DB, Redis 연결 상태 등 내부 정보를 볼 수 있다. 운영 환경 보안을 위해 JWT 토큰을 보유한 인증된 사용자만 상세 정보를 조회할 수 있도록 when-authorized로 변경했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. Swagger &amp;mdash; JWT 인증 버튼 추가 (SpringDoc OpenAPI)&lt;/h2&gt;
&lt;h3 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;팀원이 Swagger UI에서 API를 테스트할 때 JWT 토큰을 어디에 입력해야 하는지 몰라 혼선이 생겼다&lt;/li&gt;
&lt;li&gt;SwaggerConfig에 SecurityScheme을 추가하면 우측 상단에   Authorize 버튼이 생기고, 토큰을 한 번 입력하면 모든 API 요청 헤더에 Authorization: Bearer {token}이 자동으로 붙는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;기술 선택 &amp;mdash; SpringDoc OpenAPI&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Spring Boot 3.x에서 Swagger를 사용하는 방법은 두 가지다.&lt;/li&gt;
&lt;/ul&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;라이브러리&lt;/td&gt;
&lt;td&gt;특징&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SpringDoc OpenAPI&lt;/td&gt;
&lt;td&gt;Spring Boot 3.x 공식 지원, 활발한 유지보수&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Springfox&lt;/td&gt;
&lt;td&gt;Spring Boot 2.x 시대 주류, 3.x 미지원&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Spring Boot 3.x 프로젝트이므로 SpringDoc을 선택했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;설정 코드&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Configuration
public class SwaggerConfig {

    @Bean
    public OpenAPI openAPI() {
        SecurityScheme securityScheme = new SecurityScheme()
            .type(SecurityScheme.Type.HTTP)
            .scheme(&quot;bearer&quot;)
            .bearerFormat(&quot;JWT&quot;)
            .in(SecurityScheme.In.HEADER)
            .name(&quot;Authorization&quot;);

        SecurityRequirement securityRequirement = new SecurityRequirement()
            .addList(&quot;bearerAuth&quot;);

        return new OpenAPI()
            .info(new Info()
                .title(&quot;OneStop API&quot;)
                .description(&quot;쿠팡 클론 커머스 플랫폼 API&quot;)
                .version(&quot;v1.0&quot;))
            .addSecurityItem(securityRequirement)
            .components(new Components()
                .addSecuritySchemes(&quot;bearerAuth&quot;, securityScheme));
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;각 설정의 의미&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;SecurityScheme.Type.HTTP + scheme(&quot;bearer&quot;) &amp;mdash; HTTP Bearer 토큰 방식임을 명시&lt;/li&gt;
&lt;li&gt;bearerFormat(&quot;JWT&quot;) &amp;mdash; 문서 표시용 힌트 (실제 검증에 영향 없음)&lt;/li&gt;
&lt;li&gt;SecurityRequirement.addList(&quot;bearerAuth&quot;) &amp;mdash; 전역으로 모든 API에 인증 적용&lt;/li&gt;
&lt;li&gt;components().addSecuritySchemes(...) &amp;mdash; Swagger UI에 Authorize 버튼 등록&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;http://localhost:8080/swagger-ui/index.html 접속&lt;/li&gt;
&lt;li&gt;우측 상단   Authorize 클릭&lt;/li&gt;
&lt;li&gt;로그인 API 응답으로 받은 accessToken 입력 (Bearer 없이 토큰만)&lt;/li&gt;
&lt;li&gt;Authorize 클릭 후 닫기&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>  PROJECT/Team Projects</category>
      <author>eunjiom</author>
      <guid isPermaLink="true">https://eunjiom.tistory.com/54</guid>
      <comments>https://eunjiom.tistory.com/54#entry54comment</comments>
      <pubDate>Thu, 21 May 2026 20:10:12 +0900</pubDate>
    </item>
    <item>
      <title>[   ONESTOP ] Spring Data JPA 집계 쿼리 - @Query, JPQL, N+1 트레이드오프</title>
      <link>https://eunjiom.tistory.com/53</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;구현 기능&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;관리자가 주문/배송 현황을 한 번에 조회할 수 있는 대시보드 API를 구현했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;markdown&quot;&gt;&lt;code&gt;GET /api/admin/dashboard
- 상태별 주문 수 (PENDING_PAYMENT, PAID, CANCELLED)
- 오늘 주문 수
- 오늘 매출 (취소 주문 제외)
- 상태별 배송 수 (ACCEPT, INSTRUCT, DEPARTURE, DELIVERING, FINAL_DELIVERY)
&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;이슈에는 /orders와 /deliveries 두 개로 분리하려 했는데, 대시보드 특성상 한 번에 보여주는 게 더 자연스럽다고 판단해서 하나의 엔드포인트로 통합했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;사용한 기술과 선택 이유&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. Spring Data JPA 쿼리 메서드&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;주문 수 집계에 사용한 방식&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;long countByStatus(OrderStatus status);
long countByCreatedAtBetween(LocalDateTime start, LocalDateTime end);
&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;Spring Data JPA의 메서드 네이밍 규칙을 활용했다. 별도의 JPQL 없이 메서드 이름만으로 쿼리가 자동 생성된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;왜 선택했나?&lt;/b&gt; 단순한 count 쿼리는 메서드 네이밍으로 충분히 표현 가능하고 코드가 간결하다. 복잡한 조건이 없는 경우 JPQL을 직접 작성하는 것보다 유지보수가 쉽다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. @Query (JPQL)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;오늘 매출 집계에 사용한 방식&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Query(&quot;select coalesce(sum(o.finalPrice), 0) from Order o &quot; +
       &quot;where o.createdAt between :start and :end &quot; +
       &quot;and o.status = :status&quot;)
Long sumFinalPriceByCreatedAtBetween(
    @Param(&quot;start&quot;) LocalDateTime start,
    @Param(&quot;end&quot;) LocalDateTime end,
    @Param(&quot;status&quot;) OrderStatus status
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;왜 JPQL을 선택했나?&lt;/b&gt; sum + coalesce + 상태 필터를 조합해야 하는데 메서드 네이밍만으로는 표현이 불가능하다. 이런 경우 @Query로 직접 작성하는 게 맞다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;처음에 status를 하드코딩했다가 수정한 이유&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 아래처럼 작성했다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;// 처음 코드 (잘못된 방법)
@Query(&quot;...and o.status = 'PAID'&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;AI 리뷰에서 지적받았다. OrderStatus enum이 바뀌면 쿼리 문자열도 같이 수정해야 하는 문제가 생긴다. 파라미터로 받도록 수정해서 재사용성을 높였다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;coalesce를 쓴 이유&lt;/b&gt; 주문이 하나도 없을 때 sum은 null을 반환한다. coalesce(sum(...), 0)으로 감싸면 null 대신 0을 반환해서 NPE를 방지할 수 있다. 추가로 서비스 레이어에서도 Optional.ofNullable(...).orElse(0L)로 한 번 더 null 안전 처리를 했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. @Modifying(clearAutomatically = true)&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;less&quot;&gt;&lt;code&gt;@Modifying(clearAutomatically = true)
@Query(&quot;update Product p set p.status = :status where p.seller.id = :sellerId&quot;)
int updateStatusBySellerId(...);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;clearAutomatically = true란?&lt;/b&gt; 배치 업데이트 후 영속성 컨텍스트를 자동으로 클리어해서 이후 조회 시 DB의 최신 상태를 반환하도록 보장한다. 이 옵션이 없으면 업데이트가 반영됐음에도 캐시된 이전 데이터를 읽어올 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;다른 기술과 비교&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;집계 방식 비교&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 101px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;b&gt;방식&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;장점&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;단점&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;사용하면 좋은 곳&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;JPA 쿼리 메서드&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;간결, 유지보수 쉬움&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;복잡한 쿼리 표현 불가&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;단순 count, exists&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;@Query JPQL&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;복잡한 쿼리 표현 가능, 타입 안전&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;직접 작성 필요&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;sum, 복합 조건 쿼리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;QueryDSL&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;동적 쿼리, 컴파일 타임 오류 감지&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;설정 복잡, 학습 필요&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;검색 조건이 동적인 경우&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;Native Query&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;DB 최적화 쿼리 직접 사용&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;DB 종속성, 타입 불안전&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;극한의 성능 최적화 필요 시&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;대시보드는 고정된 집계 쿼리라 QueryDSL이나 Native Query까지 쓸 필요는 없었다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;대시보드 데이터 조회 방식 비교&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;방법 1. Enum 순회 + 개별 count 쿼리 (현재 방식)&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;maxima&quot;&gt;&lt;code&gt;Map&amp;lt;OrderStatus, Long&amp;gt; orderStatusCount = Stream.of(OrderStatus.values())
        .collect(Collectors.toMap(
                status -&amp;gt; status,
                status -&amp;gt; orderRepository.countByStatus(status)
        ));
&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;상태가 3개면 쿼리 3번 발생한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;방법 2. GROUP BY로 한 번에 조회&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;n1ql&quot;&gt;&lt;code&gt;@Query(&quot;select o.status, count(o) from Order o group by o.status&quot;)
List&amp;lt;Object[]&amp;gt; countGroupByStatus();
&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;쿼리 1번으로 전체 상태별 카운트를 가져올 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;왜 현재 방식을 선택했나?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI 리뷰에서 N+1 문제를 지적받았지만 MVP 단계에서는 무시했다. 이유는 두 가지다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫째, 대시보드는 관리자만 보는 화면이라 호출 빈도가 낮다. 상태 수만큼 쿼리가 나가더라도 실제 성능 영향이 거의 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘째, Object[] 형태로 받아서 Map으로 변환하는 코드가 오히려 더 복잡해진다. 상태가 3~5개 수준에서는 가독성이 더 중요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트래픽이 많아지거나 대시보드가 더 복잡해지면 그때 GROUP BY 방식으로 리팩토링하면 된다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;AI 리뷰 보고 수정한 것&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;status 하드코딩 제거&lt;/h3&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// 수정 전 (잘못된 방법)
@Query(&quot;...and o.status = 'PAID'&quot;)
Long sumFinalPriceByCreatedAtBetween(LocalDateTime start, LocalDateTime end);

// 수정 후
@Query(&quot;...and o.status = :status&quot;)
Long sumFinalPriceByCreatedAtBetween(
    @Param(&quot;start&quot;) LocalDateTime start,
    @Param(&quot;end&quot;) LocalDateTime end,
    @Param(&quot;status&quot;) OrderStatus status
);
&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;OrderStatus.PAID를 파라미터로 넘기도록 변경했다. 이렇게 하면 enum 값이 바뀌어도 컴파일 타임에 오류가 잡히고, 다른 상태의 매출도 조회할 수 있어 재사용성이 높아진다.&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>  PROJECT/Team Projects</category>
      <author>eunjiom</author>
      <guid isPermaLink="true">https://eunjiom.tistory.com/53</guid>
      <comments>https://eunjiom.tistory.com/53#entry53comment</comments>
      <pubDate>Tue, 19 May 2026 20:06:20 +0900</pubDate>
    </item>
    <item>
      <title>[   ONESTOP ] CI/CD부터 관리자 API까지 - 기술 선택의 이유</title>
      <link>https://eunjiom.tistory.com/52</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. CI 빌드 실패 수정&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제 상황&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GitHub Actions CI 빌드가 계속 실패했다. 원인은 세 가지였다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;봇이 자동 생성한 DefaultSecurityConfigTest.java가 SecurityConfig가 없다는 전제로 작성되어 있었음&lt;/li&gt;
&lt;li&gt;CI 환경에는 MySQL과 .env 파일이 없어서 DB 연결 실패&lt;/li&gt;
&lt;li&gt;Redis 서비스가 CI 환경에 없어서 연결 실패&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 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;DefaultSecurityConfigTest.java 삭제&lt;/li&gt;
&lt;li&gt;application-test.yml 생성 (H2 인메모리 DB로 대체)&lt;/li&gt;
&lt;li&gt;OneStopApplicationTests.java에 @ActiveProfiles(&quot;test&quot;) 추가&lt;/li&gt;
&lt;li&gt;ci.yml에 Redis 서비스 추가&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 H2를 선택했나?&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 101px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;기술&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;장점&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;단점&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;사용하면 좋은 곳&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;H2 인메모리 DB&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;별도 설치 불필요, 빠름&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;MySQL과 완전히 동일하지 않음&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;CI/CD 테스트 환경&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;TestContainers&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;실제 MySQL 사용 가능&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;컨테이너 실행 시간 필요, 설정 복잡&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;통합 테스트, 실제 DB 동작 검증 필요 시&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;MockBean&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;의존성 없이 테스트&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;실제 DB 동작 검증 불가&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;단위 테스트&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&amp;nbsp;CI 환경에서는 빠른 빌드가 중요하고 간단한 컨텍스트 로드 테스트만 필요해서 H2를 선택했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. SUPER_ADMIN Role 접근 권한 추가&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;변경 내용&lt;/h3&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// 변경 전
.requestMatchers(&quot;/api/admin/**&quot;).hasRole(&quot;ADMIN&quot;)

// 변경 후
.requestMatchers(&quot;/api/admin/**&quot;).hasAnyRole(&quot;ADMIN&quot;, &quot;SUPER_ADMIN&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;SUPER_ADMIN은 ADMIN 권한을 포함하는 최고 관리자 역할로 /api/admin/** 접근이 정책상 필요하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;AI 리뷰 대응&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;봇이 &quot;권한 과부여 위험&quot;이라고 경고했지만 SUPER_ADMIN이 ADMIN보다 높은 권한을 가지는 것은 의도된 정책이므로 무시했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 판매자 승인/반려 API&lt;/h2&gt;
&lt;h3 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;GET /api/admin/sellers?status=PENDING - 대기 중인 판매자 목록&lt;/li&gt;
&lt;li&gt;POST /api/admin/sellers/{sellerId}/approve - 판매자 승인&lt;/li&gt;
&lt;li&gt;POST /api/admin/sellers/{sellerId}/reject - 판매자 반려&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;AI 리뷰 보고 수정한 것&lt;/h3&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;처음에 단순 substring으로 처리했는데 AI 리뷰에서 null 체크와 형식 검증이 없다고 지적했다. 아래와 같이 개선했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;private static String maskBusinessNumber(String businessNumber) {
    if (businessNumber == null || businessNumber.isBlank()) {
        log.warn(&quot;사업자등록번호가 비어있습니다.&quot;);
        return MASKED_DEFAULT;
    }
    String digits = businessNumber.replaceAll(&quot;[^0-9]&quot;, &quot;&quot;);
    if (digits.length() != 10) {
        log.warn(&quot;올바르지 않은 사업자등록번호 포맷입니다. 길이: {}&quot;, digits.length());
        return MASKED_DEFAULT;
    }
    return digits.substring(0, 3) + &quot;-****-&quot; + digits.substring(8);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 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;null/빈 문자열 체크&lt;/li&gt;
&lt;li&gt;하이픈 제거 후 순수 숫자만 추출 (123-45-67890 형식도 대응)&lt;/li&gt;
&lt;li&gt;10자리 아닌 경우 기본 마스킹값 반환&lt;/li&gt;
&lt;li&gt;@Slf4j는 record에서 지원 안 됨 &amp;rarr; class로 변경&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;AI 리뷰 무시한 것&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;인증/인가 누락&lt;/b&gt;: SecurityConfig에서 /api/admin/** 전체를 이미 막고 있어 컨트롤러 레벨 @PreAuthorize는 이중 처리&lt;/li&gt;
&lt;li&gt;&lt;b&gt;낙관적 락&lt;/b&gt;: 관리자 기능이라 동시 요청 가능성 낮음, MVP 단계에서 불필요&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 상품 승인/반려 + FORCE_INACTIVE API&lt;/h2&gt;
&lt;h3 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;GET /api/admin/products - 승인 요청된 상품 목록 (페이징)&lt;/li&gt;
&lt;li&gt;POST /api/admin/products/{productId}/approve - 상품 승인&lt;/li&gt;
&lt;li&gt;POST /api/admin/products/{productId}/reject - 상품 반려&lt;/li&gt;
&lt;li&gt;POST /api/admin/products/{productId}/force-inactive - 상품 강제 비활성화&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;AI 리뷰 보고 수정한 것&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 에러코드 구체화&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;처음에 forceInactive에서 ADMIN_003(이미 거절된 판매자)을 재사용하고 있었다. 전용 에러코드를 추가했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;gcode&quot;&gt;&lt;code&gt;ADMIN_007 - 이미 강제 비활성화된 상품
ADMIN_008 - 이미 승인된 상품
ADMIN_009 - 이미 반려된 상품
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 페이징 적용&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;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;sqf&quot;&gt;&lt;code&gt;@PageableDefault(size = 20, sort = &quot;id&quot;, direction = Sort.Direction.DESC) Pageable pageable
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. 중복 코드 제거&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;상품 조회 + 예외처리가 3개 메서드에서 반복되어 공통 메서드로 추출했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;private Product findProductOrThrow(Long productId) {
    return productRepository.findById(productId)
            .orElseThrow(() -&amp;gt; new CustomException(ErrorCode.PRODUCT_001));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 판매자 강제 비활성화 API&lt;/h2&gt;
&lt;h3 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;처음에 SellerStatus에 FORCE_INACTIVE를 추가하려 했다. 하지만 이미 UserStatus.SUSPENDED가 있어서 이를 활용하는 게 더 깔끔하다고 판단했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;판매자 강제 비활성화 = User.status &amp;rarr; SUSPENDED
판매자 소속 상품 전체 &amp;rarr; FORCE_INACTIVE
정지 해제 후에도 상품은 FORCE_INACTIVE 유지 (재판매 시 관리자 승인 필요)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;AI 리뷰 보고 수정한 것: N+1 문제 해결&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;기존 코드 (N+1 문제)&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;productRepository.findAllBySellerId(seller.getId())
    .forEach(Product::forceInactive);
&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;상품이 100개면 UPDATE 쿼리가 100번 나간다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;개선 후 (배치 업데이트)&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;productRepository.updateStatusBySellerId(seller.getId(), ProductStatus.FORCE_INACTIVE);
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Modifying(clearAutomatically = true)
@Query(&quot;update Product p set p.status = :status where p.seller.id = :sellerId&quot;)
int updateStatusBySellerId(@Param(&quot;sellerId&quot;) Long sellerId, @Param(&quot;status&quot;) ProductStatus status);
&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;쿼리 1번으로 모든 상품 상태를 일괄 변경한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;N+1 문제란?&lt;/b&gt; 1번의 쿼리로 N개의 데이터를 가져온 뒤 각각 추가 쿼리가 N번 발생하는 문제다. @Modifying + JPQL 배치 업데이트로 쿼리 1번에 처리하도록 개선했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;clearAutomatically = true란?&lt;/b&gt; 배치 업데이트 쿼리 실행 후 영속성 컨텍스트를 자동으로 클리어해서 이후 조회 시 DB의 최신 상태를 보장한다. 이 옵션이 없으면 업데이트 후에도 캐시된 이전 상태를 읽어올 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>  PROJECT/Team Projects</category>
      <author>eunjiom</author>
      <guid isPermaLink="true">https://eunjiom.tistory.com/52</guid>
      <comments>https://eunjiom.tistory.com/52#entry52comment</comments>
      <pubDate>Mon, 18 May 2026 20:33:02 +0900</pubDate>
    </item>
    <item>
      <title>[   ONESTOP ] 정책 설계 &amp;mdash; 옵션 중복 방지와 상태 관리</title>
      <link>https://eunjiom.tistory.com/51</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 상품 옵션 최소 1개 &amp;mdash; 무옵션(기본) 처리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무옵션 상품도 product_item에 기본 옵션 1개는 있어야 재고 관리가 가능함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재고(stock)가 product_item에 있기 때문에 옵션이 0개면 재고를 어디에 붙일지 애매해짐. 무옵션 상품은 아래처럼 처리하기로 결정함&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;option_value_1: &quot;기본&quot;
option_value_2~5: null
stock: 100
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. Composite Unique Constraint &amp;mdash; 옵션 조합 중복 불가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동일 상품 내에서 옵션 조합이 중복되면 재고 관리가 불가능해짐&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 색상(빨강) + 사이즈(M) 조합이 두 개 존재하면 어느 쪽 재고를 차감해야 할지 알 수 없음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;선택한 방법 - Composite Unique Constraint&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;product_item 테이블에 (product_id, option_value_1 ~ option_value_5) 복합 유니크 제약 적용&lt;/li&gt;
&lt;li&gt;DB 레벨에서 중복을 막아서 동시에 두 요청이 들어와도 하나는 반드시 실패하므로 정합성 보장&lt;/li&gt;
&lt;li&gt;애플리케이션 레벨에서만 막으면 동시성 문제에서 취약함&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 상품 승인/반려 플로우 &amp;mdash; 기술 선택&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;상태 관리 &amp;mdash; Enum 상태머신&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;String 컬럼&lt;/b&gt; - 잘못된 값이 들어올 수 있고 허용되지 않는 상태 전이를 막기 어려움&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Enum 상태머신&lt;/b&gt; - 허용된 상태값만 존재하고 컴파일 시점에 오류를 잡을 수 있음. 상태 전이 로직을 Enum 안에 캡슐화해서 전이 규칙을 강제할 수 있어서 선택함&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;public enum ProductStatus {
    TEMP_SAVED, APPROVE_REQUESTED, APPROVED,
    REJECTED, DISCONTINUED, FORCE_INACTIVE;

    public boolean canRequestApproval() {
        return this == TEMP_SAVED || this == REJECTED;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;반려 시 기존 정보 유지 &amp;mdash; Soft Update&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;이력 테이블&lt;/b&gt; - 변경 전 데이터를 별도 테이블에 저장하는 방식. 데이터 추적은 가능하지만 MVP 단계에서는 오버스펙&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Soft Update&lt;/b&gt; - 상태값만 REJECTED로 바꾸고 기존 정보를 유지하는 방식. 반려 후 수정 없이 오랜 시간 두더라도 별도 만료 처리 없이 REJECTED 상태로 유지되고 판매자가 언제든 수정 후 재승인 요청 가능. 만료 기간이 필요하면 스케줄러를 추가하면 되지만 MVP에서는 불필요하다고 판단해서 선택함&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;동시성 &amp;mdash; 낙관적 락 (@Version)&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;비관적 락&lt;/b&gt; - 승인/반려는 자주 일어나는 작업이 아니라서 매번 DB를 잠그면 오버헤드가 큼&lt;/li&gt;
&lt;li&gt;&lt;b&gt;낙관적 락&lt;/b&gt; - @Version 필드로 충돌을 감지하고 충돌 시 예외를 던져서 처리함. 관리자 동시 접근 가능성이 낮아서 충돌이 드물고 재시도 비용이 낮기 때문에 선택함&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>  PROJECT/Team Projects</category>
      <author>eunjiom</author>
      <guid isPermaLink="true">https://eunjiom.tistory.com/51</guid>
      <comments>https://eunjiom.tistory.com/51#entry51comment</comments>
      <pubDate>Fri, 15 May 2026 20:53:04 +0900</pubDate>
    </item>
    <item>
      <title>[   ONESTOP ] Docker Compose + GitHub Actions로 팀 개발 환경 &amp;amp; CI 구축하기</title>
      <link>https://eunjiom.tistory.com/50</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 로컬 개발 환경 세팅 (Docker Compose)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Docker Compose를 선택한 이유&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬에서 MySQL과 Redis를 구성하는 방법이 세 가지 있었음&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; - 가장 단순하지만 팀원마다 버전이 달라질 수 있고 OS별로 설치 방법이 달라 환경 불일치 문제가 생김&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Docker 단독 실행&lt;/b&gt; - 컨테이너마다 명령어를 따로 입력해야 하고 설정이 코드로 관리되지 않아 팀원 간 공유가 어려움&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Docker Compose&lt;/b&gt; - 하나의 yml 파일로 정의하고 docker compose up -d 한 줄로 실행 가능, 팀원 5명이 동일한 환경을 보장할 수 있어서 선택함&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이미지 버전을 latest 대신 고정한 이유&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;latest는 팀원마다 설치 시점에 따라 버전이 달라질 수 있어 환경 불일치 문제가 생김&lt;/li&gt;
&lt;li&gt;redis:7.0, mysql:8.0으로 고정해 팀원 모두 동일한 버전 사용 보장&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;application-local.yml / application-example.yml 분리한 이유&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;application-local.yml - DB 접속 정보, JWT Secret Key 등 민감 정보 포함, .gitignore로 깃에 올라가지 않게 막음&lt;/li&gt;
&lt;li&gt;application-example.yml - 민감 정보를 플레이스홀더로 대체해 팀원들이 참고할 수 있게 공유&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 트러블슈팅 &amp;mdash; Docker MySQL 포트 충돌&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제 상황&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Error response from daemon: ports are not available: exposing port TCP 0.0.0.0:3306
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;원인&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;로컬에 MySQL이 이미 설치되어 있고 Windows 서비스로 실행 중이었음&lt;/li&gt;
&lt;li&gt;mysqld.exe가 이미 3306 포트를 점유하고 있어 Docker와 충돌 발생&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결 과정&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;vala&quot;&gt;&lt;code&gt;# 1. 포트 점유 프로세스 확인
netstat -ano | findstr :3306

# 2. PID로 프로세스 이름 확인
tasklist | findstr 7100
# mysqld.exe 7100

# 3. 강제 종료
taskkill /PID 7100 /F

# 4. Docker Compose 재실행
docker compose up -d
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;배운 점&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Windows 서비스에서 중지해도 프로세스가 살아있는 경우가 있음&lt;/li&gt;
&lt;li&gt;netstat으로 포트를 점유한 PID를 직접 찾아서 taskkill로 종료해야 함&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. GitHub Actions CI 파이프라인 구성&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;GitHub Actions를 선택한 이유&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Jenkins&lt;/b&gt; - 별도 서버 설치가 필요하고 초기 설정이 복잡함&lt;/li&gt;
&lt;li&gt;&lt;b&gt;CircleCI&lt;/b&gt; - 클라우드 기반이라 별도 서버가 필요 없지만 GitHub 연동에 별도 설정이 필요하고 무료 플랜 빌드 시간 제한이 있음&lt;/li&gt;
&lt;li&gt;&lt;b&gt;GitHub Actions&lt;/b&gt; - GitHub 안에 내장되어 있어 .github/workflows/ 폴더에 yml 파일만 추가하면 바로 동작, 팀 전원이 GitHub을 사용하고 있어서 선택함&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Gradle 캐시를 설정한 이유&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;CI 환경은 매번 새로운 서버에서 실행되어 캐시 없이는 매번 수백 개의 라이브러리를 새로 다운받아야 함&lt;/li&gt;
&lt;li&gt;의존성 파일의 해시값을 캐시 키로 사용해서 의존성이 변경됐을 때만 새로 다운받도록 설정&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Dockerfile 멀티 스테이지 빌드를 선택한 이유&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;단일 스테이지&lt;/b&gt; - 빌드 도구(Gradle)와 실행 파일이 같은 이미지에 포함되어 최종 이미지 크기가 커짐&lt;/li&gt;
&lt;li&gt;&lt;b&gt;멀티 스테이지 빌드&lt;/b&gt; - 빌드 스테이지와 실행 스테이지를 분리해 jar 파일만 실행 이미지에 담아 크기를 최소화함&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;dockerfile&quot;&gt;&lt;code&gt;# 빌드 스테이지
FROM gradle:8.14-jdk17 AS build
WORKDIR /app
COPY . .
RUN gradle bootJar --no-daemon

# 실행 스테이지
FROM openjdk:17-jdk-slim
WORKDIR /app
RUN useradd --create-home --shell /usr/sbin/nologin appuser
COPY --from=build /app/build/libs/app.jar app.jar
EXPOSE 8080
USER appuser
ENTRYPOINT [&quot;java&quot;, &quot;-jar&quot;, &quot;app.jar&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 코드 리뷰 반영&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Dockerfile non-root user 추가&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;root 권한으로 실행하면 컨테이너가 해킹됐을 때 서버 전체가 위험해질 수 있음&lt;/li&gt;
&lt;li&gt;appuser 계정을 만들어 그 계정으로 실행하도록 변경&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;*&lt;b&gt;.jar 와일드카드 &amp;rarr; app.jar 고정&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;plain.jar 같은 불필요한 파일이 함께 생성되어 의도하지 않은 파일이 복사될 위험이 있었음&lt;/li&gt;
&lt;li&gt;build.gradle에 jar 파일명을 app.jar로 고정&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;bootJar {
    archiveFileName = 'app.jar'
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;application-example.yml 플레이스홀더 문법 수정&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;{DB_PASSWORD} 형식은 Spring이 환경변수로 인식하지 못하는 잘못된 문법이었음&lt;/li&gt;
&lt;li&gt;${DB_PASSWORD} 형식으로 수정&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;QueryDSL 생성 경로 변경&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;src/main/generated - 소스 트리 안에 생성 파일이 위치해 실수로 깃에 커밋될 위험이 있었음&lt;/li&gt;
&lt;li&gt;build/generated/querydsl - gradle clean 시 자동으로 삭제되어 안전&lt;/li&gt;
&lt;/ul&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;GitHub Actions 리뷰 봇이 의존성이 삭제됐다고 잘못 판단해 의존성이 없다는 걸 검증하는 테스트 파일을 자동으로 생성함&lt;/li&gt;
&lt;li&gt;실제로는 의존성을 추가한 PR이라 봇이 반대로 동작한 것이었음&lt;/li&gt;
&lt;li&gt;해당 파일 삭제 후 기본 테스트 파일 원래 내용으로 복구&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;오늘의 회고&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;처음으로 Docker Compose로 팀 개발 환경을 구성해봄&lt;/li&gt;
&lt;li&gt;포트 충돌 문제를 직접 겪으면서 netstat과 taskkill 명령어를 배움&lt;/li&gt;
&lt;li&gt;보안을 고려한 Dockerfile 작성법을 코드 리뷰를 통해 배움&lt;/li&gt;
&lt;li&gt;리뷰 봇이 항상 옳은 게 아니라는 것도 배움&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>  PROJECT/Team Projects</category>
      <author>eunjiom</author>
      <guid isPermaLink="true">https://eunjiom.tistory.com/50</guid>
      <comments>https://eunjiom.tistory.com/50#entry50comment</comments>
      <pubDate>Thu, 14 May 2026 20:59:45 +0900</pubDate>
    </item>
    <item>
      <title>[   ONESTOP ] Spring Boot 프로젝트 기술 선택 이유 정리</title>
      <link>https://eunjiom.tistory.com/49</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;의존성 선택&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;JWT (jjwt 0.12.6)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인증/인가에 세션 대신 JWT를 씀&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세션 방식은 서버가 상태를 저장해야 해서 서버 여러 대 운영할 때 세션 공유 문제가 생김. JWT는 토큰 자체에 userId, role 정보가 담겨 있어서 서버가 상태를 저장 안 해도 됨. BUYER / SELLER / ADMIN 세 가지 Role을 하나의 토큰으로 구분 가능&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Session + Redis도 고려했는데, Redis 장애 시 전체 인증이 터지는 리스크가 있어서 제외함&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;QueryDSL 5.1.0&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상품 조회에서 카테고리, 키워드, 가격 범위, 정렬 조건이 동적으로 조합되는 쿼리가 필요했음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPQL은 조건마다 분기 처리가 복잡하고 문자열을 이어붙이는 방식이라 타입 안전하지 않음. QueryDSL은 Java 코드로 쿼리를 조립하니까 컴파일 시점에 오류를 잡을 수 있고 조건을 동적으로 추가/제거하기 쉬움&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPA Specification도 있는데, 복잡한 조건 조합에서 가독성이 떨어지고 조인 많아지면 한계가 있어서 제외함&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Redisson 3.27.2 (분산락)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쿠폰 선착순 발급에서 수천 명 동시 요청 시 중복 발급 문제를 막기 위해 분산락이 필요했음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 Redis 클라이언트인 Lettuce로도 구현 가능하지만 락 획득 실패 시 재시도 로직을 직접 짜야 함. Redisson은 분산락이 내장되어 있고 tryLock으로 타임아웃, 대기 시간 설정이 간편함. Pub/Sub 기반 락 해제 알림을 지원해서 불필요한 폴링도 없음&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Resilience4j 2.2.0 (Circuit Breaker)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI 리뷰 요약이 장애 나도 상품 구매는 정상 동작해야 하는 요구사항이 있었음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI API 타임아웃이나 Rate Limit 발생 시 요청이 계속 쌓이면 서버 전체에 영향을 줄 수 있어서 Circuit Breaker가 필요했음&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Netflix Hystrix도 있는데 2018년부터 유지보수가 중단됨. Resilience4j는 Spring Boot 3.x 호환되고 Retry, Rate Limiter, Bulkhead도 함께 제공해서 선택&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;패키지 구조 &amp;mdash; Feature-based&lt;/h2&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;domain/
├── auth/
├── user/
├── product/
├── order/
├── delivery/
├── admin/
└── ai/
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Layer-based(controller / service / repository 계층별 분리)가 아닌 Feature-based를 선택한 이유는 두 가지임&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. Git 충돌 최소화&lt;/b&gt; 팀원 5명이 각자 도메인을 맡아서 개발하는 구조인데, Layer-based면 같은 controller/ 폴더에 여러 명이 동시에 파일을 추가하게 됨. Feature-based는 각자 담당 도메인 폴더 안에서만 작업하니까 충돌이 거의 없음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. MSA 전환 용이&lt;/b&gt; 나중에 MSA로 전환할 때 order/ 패키지 전체를 별도 서비스로 떼어낼 수 있음. Layer-based는 기능이 여러 레이어에 흩어져 있어서 분리가 어려움&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;global/은 Security, JWT, 공통 예외처리처럼 전 도메인에서 공통으로 쓰는 코드를 별도 분리함. infra/는 스케줄러, 모니터링처럼 특정 도메인에 속하지 않는 시스템 운영 코드를 분리함&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;공통 응답 형식 &amp;amp; 예외 처리&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;ApiResponse&amp;lt;T&amp;gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;팀원들이 각자 개발하면 응답 형식이 제각각이 될 수 있어서 통일함&lt;/p&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;// 성공
{ &quot;success&quot;: true, &quot;data&quot;: { ... } }

// 실패
{ &quot;success&quot;: false, &quot;message&quot;: &quot;에러 메시지&quot; }
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@JsonInclude(NON_NULL)을 전역 설정이 아닌 클래스에 직접 붙인 이유는, 전역으로 설정하면 의도적으로 null을 반환해야 하는 경우에 제어가 어려워지기 때문&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;ErrorCode Enum&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTTP 상태코드, 에러 코드, 메시지를 하나의 Enum으로 묶어서 관리함&lt;/p&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;throw new CustomException(ErrorCode.USER_NOT_FOUND);
// &amp;rarr; 404, &quot;MEMBER_001&quot;, &quot;존재하지 않는 회원입니다.&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문자열 하드코딩은 중복과 오타 위험이 있고, 상수 클래스(static final)는 세 가지 정보를 묶어 관리하기 어려워서 Enum을 선택함. 컴파일 시점에 오류를 잡을 수 있는 것도 장점&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;GlobalExceptionHandler&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@RestControllerAdvice로 전역 예외 핸들러를 만들어서 예외 처리 로직을 한 곳에 모음&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;CustomException &amp;mdash; 비즈니스 로직 예외&lt;/li&gt;
&lt;li&gt;MethodArgumentNotValidException &amp;mdash; @Valid 유효성 검증 실패&lt;/li&gt;
&lt;li&gt;Exception &amp;mdash; 그 외 예상치 못한 예외 (500)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@RestControllerAdvice를 선택한 이유는 @ControllerAdvice + @ResponseBody가 합쳐진 것으로, REST API 서버라 뷰 없이 모든 응답이 JSON이기 때문&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Spring Security + JWT 설정&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;BCryptPasswordEncoder&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;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;방식&lt;/td&gt;
&lt;td&gt;문제점&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MD5 / SHA-256&lt;/td&gt;
&lt;td&gt;솔트 없이 동일한 비밀번호가 항상 같은 해시값 &amp;rarr; 레인보우 테이블, 브루트포스 취약&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PBKDF2&lt;/td&gt;
&lt;td&gt;반복 횟수 직접 설정 필요, Spring Security 통합 번거로움&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BCrypt&lt;/td&gt;
&lt;td&gt;매번 랜덤 솔트 자동 생성, 연산 느리게 설계, cost factor 조절 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;gt; Spring Security 공식 권장 방식이라 BCrypt를 선택함&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;SessionCreationPolicy.STATELESS&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세션 기반 인증은 서버가 여러 대일 때 세션 공유 문제가 생기고 Redis 같은 별도 저장소가 필요해짐. JWT는 토큰 서명만 검증하면 되니까 서버가 상태를 저장할 필요가 없음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SessionCreationPolicy.STATELESS 설정으로 Spring Security가 세션을 아예 생성하지 않게 해서 불필요한 메모리 사용도 없앰&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;authenticationEntryPoint / accessDeniedHandler 직접 구현&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Security 기본 동작은 인증 실패 시 302 리다이렉트인데, REST API에서 302를 받으면 클라이언트 처리가 어려움&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 상황을 분리해서 JSON으로 응답하도록 직접 구현함&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;authenticationEntryPoint &amp;rarr; 토큰 없거나 유효하지 않을 때 (401, AUTH_007)&lt;/li&gt;
&lt;li&gt;accessDeniedHandler &amp;rarr; 로그인은 됐지만 권한이 없을 때 (403, AUTH_011)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트에서 에러 코드만 보고 401이면 로그인 페이지로, 403이면 권한 없음 메시지를 표시할 수 있음&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Access Token / Refresh Token 분리&lt;/h3&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;Access Token: 15분 (탈취 시 피해 최소화)&lt;/li&gt;
&lt;li&gt;Refresh Token: 14일 (사용자 편의성 유지)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@PostConstruct로 SecretKey를 초기화한 이유는, @Value로 주입받은 값은 빈 생성 시점에 주입되기 때문에 생성자에서 바로 쓰면 null이 됨. @PostConstruct는 빈이 완전히 초기화된 후 실행되니까 정상적으로 SecretKey를 생성할 수 있음&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;아직 구현 안 한 것들&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;JwtAuthFilter (OncePerRequestFilter)&lt;/b&gt; &amp;mdash; User 엔티티 완성 후 구현 예정. 요청마다 JWT 검증하고 SecurityContext에 인증 정보 저장하는 역할&lt;/li&gt;
&lt;li&gt;&lt;b&gt;CustomUserDetails / CustomUserDetailsService&lt;/b&gt; &amp;mdash; DB에서 사용자 조회해서 Security 컨텍스트에 올려주는 역할&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Redis Refresh Token 저장&lt;/b&gt; &amp;mdash; 현재는 강제 로그아웃(계정 정지, 다른 기기 로그인 차단)이 불가능한 상태. refresh:{userId} 키로 Redis에 저장하면 서버에서 강제 만료 처리 가능&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>  PROJECT/Team Projects</category>
      <author>eunjiom</author>
      <guid isPermaLink="true">https://eunjiom.tistory.com/49</guid>
      <comments>https://eunjiom.tistory.com/49#entry49comment</comments>
      <pubDate>Wed, 13 May 2026 21:10:06 +0900</pubDate>
    </item>
    <item>
      <title>[   ONESTOP ] 동시성 제어 전략</title>
      <link>https://eunjiom.tistory.com/48</link>
      <description>&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;재고 차감 &amp;mdash; 비관적 락&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;상황&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문이 동시에 들어오면 여러 요청이 재고를 동시에 읽고 차감한다. 재고가 1개 남은 상품에 100명이 동시에 주문하면 모두 재고가 1개라고 읽은 뒤 차감을 시도해서 재고가 마이너스가 되는 문제가 생긴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;낙관적 락을 쓰면?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재고 차감은 동시 주문이 몰릴 때 충돌이 거의 확실하게 발생한다. 낙관적 락은 충돌이 나면 재시도를 하는데, 100명이 동시에 요청하면 대부분의 요청이 충돌 &amp;rarr; 재시도 &amp;rarr; 또 충돌을 반복하면서 재시도가 폭발적으로 증가한다. 결국 성능이 오히려 더 나빠진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Redis 분산락을 쓰면?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재고는 DB에 저장되는 데이터다. Redis 분산락으로 락을 잡고 DB에서 재고를 읽고 차감하는 구조가 되는데, 이렇게 하면 Redis &amp;rarr; DB 두 번의 네트워크 비용이 발생하고 Redis 장애 시 재고 차감 자체가 불가능해진다. 재고는 DB 레벨에서 직접 락을 거는 게 더 안전하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;비관적 락을 선택한 이유&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재고 차감은 충돌이 확실한 구간이고, 반드시 하나만 성공해야 한다. DB 레벨에서 SELECT FOR UPDATE로 행을 잠그면 다른 요청은 락이 풀릴 때까지 대기하고, 순서대로 정확하게 차감된다. 처리량이 줄어드는 단점이 있지만 재고 정합성이 100% 보장된다는 게 더 중요한 상황이다.&lt;/p&gt;
&lt;pre id=&quot;code_1778587478303&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query(&quot;SELECT p FROM ProductItem p WHERE p.id = :id&quot;)
ProductItem findByIdWithLock(@Param(&quot;id&quot;) Long id);&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;포인트 차감 &amp;mdash; 낙관적 락&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;상황&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문 시 포인트를 차감하거나 배송완료 시 포인트를 적립할 때 동시성 문제가 발생할 수 있다. 같은 사용자의 포인트를 동시에 두 곳에서 차감하면 잔액이 맞지 않는 문제가 생긴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;비관적 락을 쓰면?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;포인트는 한 사용자가 동시에 여러 곳에서 차감 요청을 보낼 가능성이 낮다. 충돌 가능성이 낮은 상황에서 매번 DB 행을 잠그면 불필요한 대기가 발생하고 성능이 떨어진다. 재고처럼 수백 명이 동시에 같은 데이터를 건드리는 상황이 아니기 때문에 오버헤드가 크다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Redis 분산락을 쓰면?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;포인트는 사용자별로 독립된 데이터다. Redis 분산락까지 쓰는 건 구현 복잡도만 높아지고 실익이 없다. Redis 장애 시 포인트 차감이 불가능해지는 리스크도 생긴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;낙관적 락을 선택한 이유&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;충돌 가능성이 낮은 상황에서는 락 없이 읽고 쓰되, 충돌이 났을 때만 재시도하는 낙관적 락이 적합하다. @Version 컬럼으로 충돌을 감지하고, 충돌 시 @Retryable로 최대 3회 재시도한다. 평소 트래픽에서는 락 비용 없이 빠르게 처리되고, 드물게 충돌이 나도 재시도로 처리된다.&lt;/p&gt;
&lt;pre id=&quot;code_1778587529835&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Entity
public class Point {
    @Version
    private int version;
}

@Retryable(value = ObjectOptimisticLockingFailureException.class, maxAttempts = 3)
public void usePoint(Long userId, int amount) { ... }&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;쿠폰 발급 &amp;mdash; Redis 분산락&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;상황&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;선착순 쿠폰 발급에서 수천 명이 동시에 같은 쿠폰을 발급받으려고 요청한다. 발급 가능 수량이 100장인데 동시에 1000명이 요청하면 중복 발급이 발생해서 100장을 초과할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;비관적 락을 쓰면?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB 비관적 락은 단일 서버에서는 유효하다. 하지만 서버가 여러 대인 환경에서는 각 서버의 DB 커넥션이 다르기 때문에 락이 서버 간에 공유되지 않는다. 서버 A에서 락을 잡아도 서버 B에서 동시에 락을 잡을 수 있어서 중복 발급이 발생한다. 또한 수천 명이 동시에 요청할 때 DB 락으로 직렬화하면 DB 커넥션 풀이 고갈될 위험이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;낙관적 락을 쓰면?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쿠폰 발급은 충돌이 확실하게 발생하는 상황이다. 수천 명이 동시에 같은 쿠폰의 issued_quantity를 읽고 수정하려 하기 때문에 대부분의 요청이 충돌 &amp;rarr; 재시도를 반복한다. 재시도가 폭발적으로 증가해서 오히려 서버에 더 큰 부하를 준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Redis 분산락을 선택한 이유&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis의 SETNX 명령어는 원자적으로 실행되기 때문에 여러 서버 인스턴스가 동시에 실행되는 환경에서도 하나의 요청만 락을 획득할 수 있다. DB에 직접 락을 걸지 않아서 DB 부하도 줄어들고, 서버 간 락 공유도 보장된다. 쿠폰 재고를 Redis에 DECR로 관리하면 원자적 차감까지 가능해서 DB 조회 없이 빠르게 수량 제어가 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1778587550440&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;RLock lock = redissonClient.getLock(&quot;lock:coupon:&quot; + couponId);
try {
    if (lock.tryLock(1, 3, TimeUnit.SECONDS)) {
        // 쿠폰 발급 로직
    }
} finally {
    lock.unlock();
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;정리&lt;/b&gt;&lt;/h4&gt;
&lt;div&gt;
&lt;div&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;재고&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;차감포인트&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&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;충돌 빈도&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;멀티 서버&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;선택한 락&lt;/td&gt;
&lt;td&gt;비관적 락&lt;/td&gt;
&lt;td&gt;낙관적 락&lt;/td&gt;
&lt;td&gt;Redis 분산락&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;선택 이유&lt;/td&gt;
&lt;td&gt;정합성 100% 보장&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;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;세 가지 기능이 모두 동시성 문제가 있지만 충돌 빈도, 데이터 특성, 서버 구조가 다르기 때문에 각각 다른 전략을 적용했다. 하나의 락 전략으로 모든 상황을 커버하려 하면 불필요한 오버헤드가 생기거나 정합성이 깨진다. &lt;br /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>  PROJECT/Team Projects</category>
      <author>eunjiom</author>
      <guid isPermaLink="true">https://eunjiom.tistory.com/48</guid>
      <comments>https://eunjiom.tistory.com/48#entry48comment</comments>
      <pubDate>Tue, 12 May 2026 21:07:59 +0900</pubDate>
    </item>
    <item>
      <title>[CH 6 실전] 서버 개발 과제</title>
      <link>https://eunjiom.tistory.com/47</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;☕ 커피 주문 시스템&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot + JPA + MySQL을 이용해 포인트 기반 커피 주문 시스템을 만들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메뉴 조회, 포인트 충전, 주문/결제, 인기 메뉴 집계까지 전 과정을 기록한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;설계 내용&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;ERD&lt;/h3&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;users
├── id          BIGINT PK AUTO_INCREMENT
└── point       INT

menus
├── id          BIGINT PK AUTO_INCREMENT
├── name        VARCHAR
└── price       INT

orders
├── id          BIGINT PK AUTO_INCREMENT
├── user_id     BIGINT FK &amp;rarr; users.id
├── total_price INT
└── created_at  DATETIME

order_items
├── id          BIGINT PK AUTO_INCREMENT
├── order_id    BIGINT FK &amp;rarr; orders.id
├── menu_id     BIGINT FK &amp;rarr; menus.id
└── price       INT&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;API 목록&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;GET /coffee/menus &amp;mdash; 커피 메뉴 목록 조회&lt;/li&gt;
&lt;li&gt;POST /coffee/points/charge &amp;mdash; 포인트 충전&lt;/li&gt;
&lt;li&gt;POST /coffee/orders &amp;mdash; 커피 주문/결제&lt;/li&gt;
&lt;li&gt;GET /coffee/menus/popular &amp;mdash; 인기 메뉴 조회&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;설계 의도&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도메인별로 패키지를 나눴다. menu, order, point, user 각각이 자신의 controller / service / repository / dto / domain을 갖도록 구성했다. 한 도메인의 코드가 다른 도메인을 침범하지 않아서 나중에 수정할 때 영향 범위를 파악하기 쉽다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;order_items 테이블에 주문 당시 가격을 별도 컬럼으로 저장한다. 나중에 메뉴 가격이 변경되어도 과거 주문 금액이 달라지면 안 되기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;포인트 충전과 차감 로직은 서비스가 아닌 User 엔티티 안에 메서드로 넣었다. 서비스에서 직접 필드를 건드리는 것보다 도메인 객체가 자신의 상태를 스스로 변경하는 게 객체지향적으로 더 맞다고 판단했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RealTimeOrderSender는 주문 완료 후 외부 데이터 수집 플랫폼으로 전송하는 역할을 따로 분리했다. 현재는 Mock(로그 출력)으로 구현되어 있고, 나중에 실제 HTTP Client나 Kafka로 교체할 때 이 클래스만 바꾸면 된다.&lt;/p&gt;
&lt;h3 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;b&gt;Spring Data JPA&lt;/b&gt; &amp;mdash; 반복 SQL을 줄이고, JPQL로 복잡한 집계 쿼리도 객체지향적으로 표현할 수 있다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Record DTO&lt;/b&gt; &amp;mdash; 불변 객체로 DTO를 간결하게 표현할 수 있다. getter, 생성자 같은 보일러플레이트 코드가 필요 없다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;@RestControllerAdvice&lt;/b&gt; &amp;mdash; 예외 처리를 컨트롤러마다 따로 작성하면 중복이 많아지고 응답 형식도 제각각이 된다. 한 곳에서 모든 예외를 일관된 형식으로 처리하기 위해 사용했다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;인기 메뉴 집계 방식&lt;/b&gt; &amp;mdash; 별도 집계 테이블 없이 order_items를 GROUP BY로 집계하는 방식을 선택했다. 항상 실시간 주문 이력 기반이라 따로 동기화할 필요가 없고 데이터 일관성이 자연스럽게 보장된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 프로젝트 세팅&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;패키지 구조&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;com.example.coffeeapi

├── common
│   ├── config
│   ├── exception
│   └── response
│
├── menu
│   ├── controller
│   ├── service
│   ├── repository
│   ├── domain
│   └── dto
│
├── point
├── order
├── event
└── user&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;패키지는 아래 명령어로 한 번에 생성했다.&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;mkdir -p src/main/java/com/example/coffeeapi/{menu,order,point,user}/{controller,service,repository,dto,domain}

mkdir -p src/main/java/com/example/coffeeapi/common/{config,exception,response}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;application.yml&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ddl-auto: update&lt;/code&gt;로 설정해서 엔티티 변경 시 테이블이 자동으로 업데이트되도록 했다. &lt;code&gt;sql.init.mode: always&lt;/code&gt;는 애플리케이션 시작 시 data.sql을 자동 실행하기 위해 추가했다. data.sql에 초기 메뉴와 유저 데이터를 넣어두면 매번 수동으로 INSERT할 필요가 없다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;spring:
  sql:
    init:
      mode: always
  datasource:
    url: jdbc:mysql://localhost:3306/coffee
    username:
    password:

  jpa:
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        format_sql: true
    show-sql: true&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;DB 초기 데이터&lt;/h3&gt;
&lt;pre class=&quot;n1ql&quot;&gt;&lt;code&gt;create database coffee;&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;-- data.sql
INSERT INTO users (point) VALUES (0);
INSERT INTO menus (name, price) VALUES ('아메리카노', 3000), ('카페라떼', 4000), ('콜드브루', 4500);&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 커피 메뉴 조회 API&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;GET /coffee/menus&lt;/code&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 메뉴 목록을 조회하는 API다. 단순 조회라 JPA의 findAll()을 사용했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Entity&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Getter
@NoArgsConstructor
@Entity
@Table(name = &quot;menus&quot;)
public class Menu {

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

    // 메뉴 이름
    private String name;

    // 메뉴 가격
    private int price;

    public Menu(String name, int price) {
        this.name = name;
        this.price = price;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;MenuRepository&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;public interface MenuRepository extends JpaRepository&amp;lt;Menu, Long&amp;gt; {
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;MenuResponse&lt;/h3&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;public record MenuResponse(
        Long menuId,
        String name,
        int price
) {
    public static MenuResponse from(Menu menu) {
        return new MenuResponse(
                menu.getId(),
                menu.getName(),
                menu.getPrice()
        );
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;MenuService&lt;/h3&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
public class MenuService {

    private final MenuRepository menuRepository;

    public List&amp;lt;MenuResponse&amp;gt; getMenus() {
        return menuRepository.findAll()
                .stream()
                .map(MenuResponse::from)
                .toList();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;MenuController&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@RestController
@RequiredArgsConstructor
@RequestMapping(&quot;/coffee/menus&quot;)
public class MenuController {

    private final MenuService menuService;

    @GetMapping
    public List&amp;lt;MenuResponse&amp;gt; getMenus() {
        return menuService.getMenus();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Postman 테스트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Method: GET | URL: http://localhost:8080/coffee/menus&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;[
    {
        &quot;menuId&quot;: 1,
        &quot;name&quot;: &quot;Americano&quot;,
        &quot;price&quot;: 3000
    },
    {
        &quot;menuId&quot;: 2,
        &quot;name&quot;: &quot;Latte&quot;,
        &quot;price&quot;: 4000
    }
]&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 포인트 충전 API&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;POST /coffee/points/charge&lt;/code&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;userId와 충전 금액을 받아 포인트를 적립하는 API다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;User Entity&lt;/h3&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;@Getter
@NoArgsConstructor
@Entity
@Table(name = &quot;users&quot;)
public class User {

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

    // 사용자 보유 포인트
    private int point;

    public User(int point) {
        this.point = point;
    }

    // 포인트 충전
    public void chargePoint(int amount) {
        this.point += amount;
    }

    // 포인트 사용
    public void usePoint(int amount) {
        if (this.point &amp;lt; amount) {
            throw new IllegalArgumentException(&quot;포인트가 부족합니다.&quot;);
        }
        this.point -= amount;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;PointChargeRequest&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Bean Validation으로 입력값을 검증했다. &lt;code&gt;@NotNull&lt;/code&gt;로 userId 필수값을 체크하고, &lt;code&gt;@Min(1)&lt;/code&gt;로 충전 금액이 1 이상인지 검증한다. 서비스 코드에 검증 로직을 직접 작성하지 않아도 돼서 코드가 깔끔해진다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;public record PointChargeRequest(

        @NotNull(message = &quot;사용자 ID는 필수입니다.&quot;)
        Long userId,

        @Min(value = 1, message = &quot;충전 금액은 1 이상이어야 합니다.&quot;)
        int amount
) {
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;PointService&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;@Transactional&lt;/code&gt;을 붙인 이유는 JPA 더티 체킹 때문이다. 트랜잭션이 열려 있어야 엔티티 변경을 감지해서 커밋 시점에 UPDATE 쿼리를 자동으로 실행한다. 트랜잭션 없이 chargePoint()를 호출하면 DB에 반영되지 않는다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
public class PointService {

    private final UserRepository userRepository;

    @Transactional
    public PointResponse charge(PointChargeRequest request) {

        // 사용자 조회
        User user = userRepository.findById(request.userId())
                .orElseThrow(() -&amp;gt; new IllegalArgumentException(&quot;사용자를 찾을 수 없습니다.&quot;));

        // 포인트 충전
        user.chargePoint(request.amount());

        return new PointResponse(user.getId(), user.getPoint());
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;PointController&lt;/h3&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@RestController
@RequiredArgsConstructor
@RequestMapping(&quot;/coffee/points&quot;)
public class PointController {

    private final PointService pointService;

    @PostMapping(&quot;/charge&quot;)
    public PointResponse charge(
            @RequestBody @Valid PointChargeRequest request
    ) {
        return pointService.charge(request);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Postman 테스트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Method: POST | URL: http://localhost:8080/coffee/points/charge&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;// Request Body
{
  &quot;userId&quot;: 1,
  &quot;amount&quot;: 10000
}

// Response
{
    &quot;userId&quot;: 1,
    &quot;point&quot;: 10000
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 주문 및 결제 API&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;POST /coffee/orders&lt;/code&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;userId와 menuIds를 받아 포인트를 차감하고 주문을 생성하는 API다. 구현 순서는 아래와 같다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;사용자 조회&lt;/li&gt;
&lt;li&gt;메뉴 조회&lt;/li&gt;
&lt;li&gt;메뉴 존재 여부 검증&lt;/li&gt;
&lt;li&gt;총 금액 계산&lt;/li&gt;
&lt;li&gt;포인트 차감&lt;/li&gt;
&lt;li&gt;주문 생성 및 저장&lt;/li&gt;
&lt;li&gt;실시간 데이터 전송&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;포인트 차감 &amp;rarr; 주문 생성 &amp;rarr; 저장을 하나의 &lt;code&gt;@Transactional&lt;/code&gt; 안에서 처리했다. 중간에 예외가 발생하면 전체가 롤백되기 때문에 포인트만 차감되고 주문이 생성되지 않는 상황을 방지할 수 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;CoffeeOrder Entity&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테이블명을 &lt;code&gt;orders&lt;/code&gt;로 지정했다. order는 SQL 예약어라 그대로 쓰면 에러가 난다. &lt;code&gt;cascade = CascadeType.ALL&lt;/code&gt;을 설정해서 주문 저장 시 OrderItem도 함께 저장되도록 했다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Getter
@NoArgsConstructor
@Entity
@Table(name = &quot;orders&quot;)
public class CoffeeOrder {

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

    private Long userId;
    private int totalPrice;
    private LocalDateTime createdAt;

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

    public CoffeeOrder(Long userId, int totalPrice) {
        this.userId = userId;
        this.totalPrice = totalPrice;
        this.createdAt = LocalDateTime.now();
    }

    public void addOrderItem(OrderItem orderItem) {
        orderItems.add(orderItem);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;OrderItem Entity&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;price 컬럼을 별도로 저장해서 주문 당시 가격을 스냅샷으로 남긴다. &lt;code&gt;FetchType.LAZY&lt;/code&gt;로 지연 로딩을 설정해서 불필요한 JOIN 쿼리가 나가지 않도록 했다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Getter
@NoArgsConstructor
@Entity
@Table(name = &quot;order_items&quot;)
public class OrderItem {

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

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

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;menu_id&quot;)
    private Menu menu;

    // 주문 당시 가격 스냅샷
    private int price;

    public OrderItem(CoffeeOrder order, Menu menu, int price) {
        this.order = order;
        this.menu = menu;
        this.price = price;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;RealTimeOrderSender&lt;/h3&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Slf4j
@Component
public class RealTimeOrderSender {

    public void send(Long userId, List&amp;lt;Long&amp;gt; menuIds, int totalPrice) {
        // Mock &amp;mdash; 실제 환경에서는 HTTP Client 또는 Kafka로 교체
        log.info(
                &quot;실시간 주문 데이터 전송 완료 userId={}, menuIds={}, totalPrice={}&quot;,
                userId, menuIds, totalPrice
        );
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;OrderService&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
public class OrderService {

    private final UserRepository userRepository;
    private final MenuRepository menuRepository;
    private final OrderRepository orderRepository;
    private final RealTimeOrderSender realTimeOrderSender;

    @Transactional
    public OrderResponse order(OrderRequest request) {

        // 사용자 조회
        User user = userRepository.findById(request.userId())
                .orElseThrow(() -&amp;gt;
                        new IllegalArgumentException(&quot;사용자를 찾을 수 없습니다.&quot;));

        // 메뉴 조회
        List&amp;lt;Menu&amp;gt; menus = menuRepository.findAllById(request.menuIds());

        // 존재하지 않는 메뉴 검증
        if (menus.size() != request.menuIds().size()) {
            throw new IllegalArgumentException(&quot;존재하지 않는 메뉴가 포함되어 있습니다.&quot;);
        }

        // 총 금액 계산
        int totalPrice = menus.stream().mapToInt(Menu::getPrice).sum();

        // 포인트 차감
        user.usePoint(totalPrice);

        // 주문 생성
        CoffeeOrder order = new CoffeeOrder(user.getId(), totalPrice);

        for (Menu menu : menus) {
            order.addOrderItem(new OrderItem(order, menu, menu.getPrice()));
        }

        // 주문 저장 (cascade로 OrderItem도 함께 저장)
        CoffeeOrder savedOrder = orderRepository.save(order);

        // 실시간 데이터 전송
        realTimeOrderSender.send(user.getId(), request.menuIds(), totalPrice);

        return new OrderResponse(savedOrder.getId(), totalPrice);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;OrderController&lt;/h3&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@RestController
@RequiredArgsConstructor
@RequestMapping(&quot;/coffee/orders&quot;)
public class OrderController {

    private final OrderService orderService;

    @PostMapping
    public OrderResponse order(@RequestBody @Valid OrderRequest request) {
        return orderService.order(request);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Postman 테스트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Method: POST | URL: http://localhost:8080/coffee/orders&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;// Request Body
{
  &quot;userId&quot;: 1,
  &quot;menuIds&quot;: [1, 2]
}

// Response
{
    &quot;orderId&quot;: 1,
    &quot;totalPrice&quot;: 7000
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;전역 예외 처리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예외 처리를 각 컨트롤러마다 따로 작성하면 중복 코드가 많아지고 응답 형식도 제각각이 된다. &lt;code&gt;@RestControllerAdvice&lt;/code&gt;를 사용해서 한 곳에서 모든 예외를 일관된 형식으로 처리했다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@RestControllerAdvice
public class GlobalExceptionHandler {

    // 비즈니스 규칙 위반 (포인트 부족, 존재하지 않는 사용자/메뉴 등)
    @ExceptionHandler(IllegalArgumentException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResponse handleIllegalArgumentException(IllegalArgumentException e) {
        return new ErrorResponse(e.getMessage());
    }

    // @Valid 검증 실패 (필수값 누락, 최솟값 위반 등)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResponse handleValidationException(MethodArgumentNotValidException e) {
        String message = e.getBindingResult().getFieldError().getDefaultMessage();
        return new ErrorResponse(message);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;delphi&quot;&gt;&lt;code&gt;public record ErrorResponse(
        String message
) {
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 트러블슈팅&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제 1 &amp;mdash; 빈 메뉴 목록으로 주문이 생성되는 문제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;menuIds를 빈 배열로 보냈을 때 에러가 나지 않고 totalPrice가 0인 주문이 생성되는 문제가 있었다.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;// Request
{
  &quot;userId&quot;: 1,
  &quot;menuIds&quot;: []
}

// 문제 있는 응답 &amp;mdash; 주문이 그냥 생성됨
{
    &quot;orderId&quot;: 2,
    &quot;totalPrice&quot;: 0
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;menuIds가 비어있어도 서비스 로직이 그대로 실행되기 때문이었다. OrderRequest에 &lt;code&gt;@NotEmpty&lt;/code&gt;를 추가해서 빈 배열이 들어오면 요청 자체를 막도록 했다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@NotEmpty(message = &quot;메뉴는 최소 1개 이상 선택해야 합니다.&quot;)
List&amp;lt;Long&amp;gt; menuIds&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;// 수정 후 응답
{
    &quot;message&quot;: &quot;메뉴는 최소 1개 이상 선택해야 합니다.&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제 2 &amp;mdash; 존재하지 않는 메뉴 ID로 주문이 생성되는 문제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;없는 menuId를 요청에 담아 보내면 역시 에러 없이 totalPrice가 0인 주문이 생겼다.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;// Request
{
  &quot;userId&quot;: 1,
  &quot;menuIds&quot;: [90]
}

// 문제 있는 응답 &amp;mdash; 주문이 그냥 생성됨
{
    &quot;orderId&quot;: 7,
    &quot;totalPrice&quot;: 0
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPA의 &lt;code&gt;findAllById()&lt;/code&gt;는 존재하지 않는 ID를 무시하고 찾은 것만 반환하기 때문에 발생한 문제였다. 요청한 menuIds 수와 실제 조회된 menus 수를 비교하는 검증 로직을 서비스에 추가했다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;if (menus.size() != request.menuIds().size()) {
    throw new IllegalArgumentException(&quot;존재하지 않는 메뉴가 포함되어 있습니다.&quot;);
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;// 수정 후 응답
{
    &quot;message&quot;: &quot;존재하지 않는 메뉴가 포함되어 있습니다.&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제 3 &amp;mdash; 포인트 부족 시 주문이 생성되는 문제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;포인트가 부족한 상태에서 주문을 시도하면 에러 없이 주문이 생성되는 문제가 있었다.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;// 포인트가 1000인 상태에서 7000짜리 주문 시도
{
  &quot;userId&quot;: 1,
  &quot;menuIds&quot;: [1, 2]
}

// 문제 있는 응답 &amp;mdash; 주문이 그냥 생성됨
{
    &quot;orderId&quot;: 3,
    &quot;totalPrice&quot;: 7000
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;포인트 차감 전에 잔액 체크 로직이 없었기 때문이었다. User 엔티티의 &lt;code&gt;usePoint()&lt;/code&gt; 메서드에 잔액이 부족하면 예외를 던지는 로직을 추가했다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;public void usePoint(int amount) {
    if (this.point &amp;lt; amount) {
        throw new IllegalArgumentException(&quot;포인트가 부족합니다.&quot;);
    }
    this.point -= amount;
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;// 수정 후 응답
{
    &quot;message&quot;: &quot;포인트가 부족합니다.&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 인기 메뉴 조회 API&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;GET /coffee/menus/popular&lt;/code&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최근 7일간 주문 횟수 기준 상위 3개 메뉴를 반환하는 API다. 별도 집계 테이블 없이 order_items를 GROUP BY로 집계하는 방식을 선택했다. 항상 실시간 주문 이력 기반이라 데이터 일관성이 자연스럽게 보장된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPQL에서 &lt;code&gt;Pageable&lt;/code&gt;을 활용하면 LIMIT을 직접 쓰지 않아도 TOP N을 쉽게 가져올 수 있다. JPQL의 new 키워드로 쿼리 결과를 바로 DTO로 매핑했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;PopularMenuResponse&lt;/h3&gt;
&lt;pre class=&quot;delphi&quot;&gt;&lt;code&gt;public record PopularMenuResponse(
        Long menuId,
        String name,
        Long orderCount
) {
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;OrderItemRepository &amp;mdash; JPQL 집계 쿼리&lt;/h3&gt;
&lt;pre class=&quot;n1ql&quot;&gt;&lt;code&gt;public interface OrderItemRepository extends JpaRepository&amp;lt;OrderItem, Long&amp;gt; {

    @Query(&quot;&quot;&quot;
        SELECT new com.example.coffeeapi.menu.dto.PopularMenuResponse(
            m.id,
            m.name,
            COUNT(oi.id)
        )
        FROM OrderItem oi
        JOIN oi.menu m
        JOIN oi.order o
        WHERE o.createdAt &amp;gt;= :sevenDaysAgo
        GROUP BY m.id, m.name
        ORDER BY COUNT(oi.id) DESC
        &quot;&quot;&quot;)
    List&amp;lt;PopularMenuResponse&amp;gt; findPopularMenus(
            LocalDateTime sevenDaysAgo,
            Pageable pageable
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;MenuService 수정&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;private final OrderItemRepository orderItemRepository;

public List&amp;lt;PopularMenuResponse&amp;gt; getPopularMenus() {

    // 최근 7일 기준 시각 계산
    LocalDateTime sevenDaysAgo = LocalDateTime.now().minusDays(7);

    // Pageable로 TOP3만 가져옴
    return orderItemRepository.findPopularMenus(
            sevenDaysAgo,
            PageRequest.of(0, 3)
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;MenuController 수정&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@GetMapping(&quot;/popular&quot;)
public List&amp;lt;PopularMenuResponse&amp;gt; getPopularMenus() {
    return menuService.getPopularMenus();
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Postman 테스트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Method: GET | URL: http://localhost:8080/coffee/menus/popular&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;[
    {
        &quot;menuId&quot;: 1,
        &quot;name&quot;: &quot;Americano&quot;,
        &quot;orderCount&quot;: 4
    },
    {
        &quot;menuId&quot;: 2,
        &quot;name&quot;: &quot;Latte&quot;,
        &quot;orderCount&quot;: 3
    }
]&lt;/code&gt;&lt;/pre&gt;</description>
      <category>  SPARTA/Assignments</category>
      <author>eunjiom</author>
      <guid isPermaLink="true">https://eunjiom.tistory.com/47</guid>
      <comments>https://eunjiom.tistory.com/47#entry47comment</comments>
      <pubDate>Mon, 11 May 2026 00:25:34 +0900</pubDate>
    </item>
    <item>
      <title>스케줄러와 Quartz (타임세일 스케줄러 기술 선택)</title>
      <link>https://eunjiom.tistory.com/46</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1. 배경&lt;/b&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;li&gt;이를 구현하기 위한 스케줄러 기술을 선택하는 과정에서 아래 네 가지 방식을 검토함&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2. 검토한 방식&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;1) 조회 시점 계산 방식&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;상품 조회 시 현재 시간과 saleStartTime, saleEndTime을 비교해 판매전 / 판매중 / 판매종료 상태를 동적으로 판단하는 방식&lt;/li&gt;
&lt;li&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;조회 시 BETWEEN saleStartTime AND saleEndTime 조건이 들어가는데, 이 경우 인덱스를 제대로 활용하지 못해 상품 수가 많아질수록 풀스캔이 발생할 수 있음&lt;/li&gt;
&lt;li&gt;서버 시간과 DB 시간이 다를 경우, 동일한 상품이 서버에서는 판매중으로 보이고 DB에서는 판매전으로 처리되는 불일치가 생길 수 있음&lt;/li&gt;
&lt;li&gt;실제 상품 상태값이 DB에 반영되지 않기 때문에 다른 도메인에서 상태를 재사용하거나 관리자 화면에서 현재 상태를 바로 확인하기 어려움&lt;/li&gt;
&lt;li&gt;특가 시작/종료를 상태 변경 이벤트로 다루기보다 응답 시 계산으로 처리하게 되어 도메인 의미가 약해질 수 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;2)&amp;nbsp; Redis 기반 예약 키 + 폴링 방식&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;특가 상품 시작/종료 시간을 Redis에 키 또는 ZSET 형태로 저장한 뒤, 주기적으로 현재 시각과 비교하여 상태를 변경하는 방식&lt;/li&gt;
&lt;li&gt;Redis가 메모리 기반이라 빠르고, 재고 차감/분산락/동시성 제어 구조와 자연스럽게 연결할 수 있다는 점이 장점이었음&lt;/li&gt;
&lt;li&gt;하지만 Redis에 예약 정보를 저장해도 그 시각에 실행할 처리기가 별도로 필요하고, 결국 일정 주기로 예약 키를 확인하는 폴링이 들어가야 함&lt;/li&gt;
&lt;li&gt;이는 원하던 정확한 시각에 상태 변경과는 차이가 있었고, 1초나 1분 단위 폴링은 불필요한 반복 조회로 이어질 수 있어 비효율적이라 판단함&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;3) @Scheduled 기반 폴링 스케줄러&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;Spring의 @Scheduled를 사용해 1분마다 특가 상품 전체를 조회하고, 시작/종료 대상 상품을 찾아 상태를 바꾸는 방식&lt;/li&gt;
&lt;li&gt;구현 자체는 단순하지만 전체 상품을 주기적으로 반복 조회해야 하므로 상품 수가 많아질수록 비효율이 커질 수 있음&lt;/li&gt;
&lt;li&gt;또한 여러 서버 환경에서는 동일한 스케줄러가 중복 실행될 위험도 존재함&lt;/li&gt;
&lt;li&gt;시간이 되면 딱 상태를 바꾸는 방식을 원했기 때문에 최종 선택에서 제외함&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;4) Quartz 예약 실행 방식&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;특가 상품을 등록하는 시점에 시작 Job과 종료 Job을 함께 등록해두고, 지정된 시각에 정확히 한 번 실행하여 상태를 변경하는 방식&lt;/li&gt;
&lt;li&gt;주기적으로 상품 전체를 확인하지 않아도 되고, 시작 시간에는 READY &amp;rarr; ON_SALE, 종료 시간에는 ON_SALE &amp;rarr; SALE_ENDED처럼 도메인 상태를 명확히 다룰 수 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3. 최종 선택 - Quartz&lt;/b&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;li&gt;따라서 주기적으로 상태를 확인하는 폴링 방식보다, 상품 등록 시점에 시작/종료 작업 자체를 예약하는 방식이 더 적합하다고 판단함&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;1) 폴링 없이 예약 실행 가능&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;@Scheduled나 Redis 기반 폴링처럼 주기적으로 전체 상품을 조회하지 않고, 상품별 시작/종료 시각에 맞춰 Job을 직접 예약할 수 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;2) 상태 변경의 의미가 명확함&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;Quartz를 사용하면 시작 시각에 READY &amp;rarr; ON_SALE, 종료 시각에 ON_SALE &amp;rarr; SALE_ENDED로 실제 상태값이 전환되어 도메인 의미가 분명해짐&lt;/li&gt;
&lt;li&gt;조회 시점 계산 방식처럼 서버/DB 시간 불일치 문제나 인덱스 미활용 문제도 발생하지 않음&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;3) 역할 분리가 자연스러움&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;/li&gt;
&lt;li&gt;이 둘을 같은 기술로 억지로 해결하기보다 역할을 분리하는 것이 더 자연스럽다고 판단함&lt;/li&gt;
&lt;li&gt;시작/종료 예약 &amp;rarr; Quartz&lt;/li&gt;
&lt;li&gt;재고 차감/동시성/분산락 &amp;rarr; Redis&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;4) 불필요한 반복 조회를 줄일 수 있음&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;Quartz는 상품별 시작/종료 시각에 필요한 작업만 실행하기 때문에, 반복 조회 기반 방식보다 불필요한 부하를 줄일 수 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;4. 쿠폰 스케줄러와 다르게 Quartz를 쓴 이유&lt;/b&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;쿠폰 스케줄러는 Spring @Scheduled + cron 방식으로 구현했음&lt;/li&gt;
&lt;li&gt;타임세일 스케줄러는 Quartz로 구현했음&lt;/li&gt;
&lt;li&gt;같은 프로젝트 안에서 스케줄러 기술이 다른 이유는 두 도메인의 성격 자체가 다르기 때문임&lt;/li&gt;
&lt;li&gt;구분 쿠폰 스케줄러 타임세일 스케줄러&lt;/li&gt;
&lt;/ul&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;실행 시점&lt;/td&gt;
&lt;td&gt;고정된 주기 (매주 월요일 00시 등)&lt;/td&gt;
&lt;td&gt;상품마다 다른 시작/종료 시각&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;등록 방식&lt;/td&gt;
&lt;td&gt;코드에 cron 표현식으로 고정&lt;/td&gt;
&lt;td&gt;상품 등록 시점에 동적으로 Job 생성&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;실행 횟수&lt;/td&gt;
&lt;td&gt;반복 실행&lt;/td&gt;
&lt;td&gt;각 상품당 정확히 1회 실행&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;적합한 기술&lt;/td&gt;
&lt;td&gt;@Scheduled + cron&lt;/td&gt;
&lt;td&gt;Quartz&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;쿠폰은 매주 월요일 00시처럼 반복적이고 고정된 주기에 실행하면 되는 작업임&lt;/li&gt;
&lt;li&gt;반면 타임세일은 상품마다 시작 시간과 종료 시간이 다르고, 각각 딱 한 번만 실행되어야 하는 작업임&lt;/li&gt;
&lt;li&gt;@Scheduled는 미리 정해진 시간에 반복 실행하는 구조라 동적으로 시각을 등록하는 게 어렵고, 상품마다 다른 시각에 맞춰 Job을 만들려면 Quartz처럼 실행 시점을 동적으로 지정할 수 있는 도구가 필요했음&lt;/li&gt;
&lt;li&gt;그래서 두 도메인에 각각 성격에 맞는 기술을 선택함&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>  SPARTA/Dev Notes</category>
      <author>eunjiom</author>
      <guid isPermaLink="true">https://eunjiom.tistory.com/46</guid>
      <comments>https://eunjiom.tistory.com/46#entry46comment</comments>
      <pubDate>Tue, 21 Apr 2026 15:43:08 +0900</pubDate>
    </item>
    <item>
      <title>[플러스 Spring] 코드 개선 과제</title>
      <link>https://eunjiom.tistory.com/45</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. @Transactional의 이해&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1) 문제사항 + 원인&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;POST&amp;nbsp;/todos&amp;nbsp;호출&amp;nbsp;시&amp;nbsp;아래&amp;nbsp;에러&amp;nbsp;발생&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1775187969242&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Connection is read-only. Queries leading to data modification are not allowed&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;TodoService&amp;nbsp;클래스&amp;nbsp;레벨에&amp;nbsp;@Transactional(readOnly&amp;nbsp;=&amp;nbsp;true)&amp;nbsp;가&amp;nbsp;선언되어&amp;nbsp;있어서&amp;nbsp;클래스&amp;nbsp;안의&amp;nbsp;모든&amp;nbsp;메서드가&amp;nbsp;읽기&amp;nbsp;전용&amp;nbsp;트랜잭션으로&amp;nbsp;실행&lt;/li&gt;
&lt;li&gt;saveTodo()는&amp;nbsp;DB에&amp;nbsp;INSERT가&amp;nbsp;필요한&amp;nbsp;쓰기&amp;nbsp;작업인데,&amp;nbsp;읽기&amp;nbsp;전용&amp;nbsp;설정과&amp;nbsp;충돌해&amp;nbsp;에러&amp;nbsp;발생&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1775189068834&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 문제 코드 &amp;mdash; saveTodo()도 읽기 전용으로 실행됨
@Service
@Transactional(readOnly = true)
public class TodoService {
    public TodoSaveResponse saveTodo(...) {
        ...
        Todo savedTodo = todoRepository.save(newTodo); // &amp;larr; 여기서 에러
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2) 알아야 할 개념&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;@Transactional(readOnly&amp;nbsp;=&amp;nbsp;true)&amp;nbsp;는&amp;nbsp;DB&amp;nbsp;연결을&amp;nbsp;읽기&amp;nbsp;전용으로&amp;nbsp;설정해&amp;nbsp;flush를&amp;nbsp;막는&amp;nbsp;성능&amp;nbsp;최적화&amp;nbsp;옵션임.&amp;nbsp;클래스&amp;nbsp;레벨&amp;nbsp;선언은&amp;nbsp;전체&amp;nbsp;메서드에&amp;nbsp;적용되고,&amp;nbsp;메서드&amp;nbsp;레벨&amp;nbsp;@Transactional로&amp;nbsp;오버라이드&amp;nbsp;가능&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3) 해결방법&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;saveTodo()&amp;nbsp;메서드에&amp;nbsp;@Transactional&amp;nbsp;별도&amp;nbsp;추가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4) 코드 설명&lt;/p&gt;
&lt;pre class=&quot;lasso&quot;&gt;&lt;code&gt;// 변경 전 &amp;mdash; readOnly로 INSERT 불가
public TodoSaveResponse saveTodo(...) { ... }

// 변경 후
@Transactional
public TodoSaveResponse saveTodo(...) { ... }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5) 왜 이 지식이 필요한가&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;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. JWT의 이해&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1) 문제사항 + 원인&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;User 테이블에 nickname 컬럼이 없고 JWT claim에도 포함되지 않아, 프론트에서 닉네임을 꺼내 쓸 수 없었음.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2) 알아야 할 개념&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;JWT Payload에 claim 키-값 쌍을 자유롭게 담을 수 있음&lt;/li&gt;
&lt;li&gt;서버가 토큰 발급 시 정보를 claim에 넣으면, 클라이언트가 토큰 디코딩으로 꺼내 씀&lt;/li&gt;
&lt;li&gt;claim이 많을수록 토큰 크기가 커지므로 필요한 정보만 담는 것이 원칙&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3) 해결방법&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;User 엔티티, AuthUser, SignupRequest, JwtUtil, AuthService, JwtFilter 전체에 nickname 필드 추가 및 JWT claim으로 발급&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4) 코드 설명&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// User 엔티티 &amp;mdash; nickname 컬럼 추가

private String nickname;

public User(String email, String password, String nickname, UserRole userRole) {
    this.nickname = nickname;
    ...
}

// AuthUser.java &amp;mdash; nickname 필드 추가

    private final String nickname; // 추가
    private final UserRole userRole;

    public AuthUser(Long id, String email, String nickname, UserRole userRole) {
        this.id = id;
        this.email = email;
        this.nickname = nickname;
        this.userRole = userRole;
   
  
// SignupRequest &amp;mdash; nickname 필드 추가

    @NotBlank
    private String nickname; // 닉네임 필드 추가
    
// JwtUtil &amp;mdash; nickname claim 추가
public String createToken(Long userId, String email, UserRole userRole, String nickname) {
    return BEARER_PREFIX + Jwts.builder()
        .claim(&quot;nickname&quot;, nickname)
        ...
        .compact();
}

// AuthService &amp;mdash; 회원가입/로그인 시 nickname 사용
@Transactional
public SignupResponse signup(SignupRequest signupRequest) {
    ...
    User newUser = new User(
            signupRequest.getEmail(),
            encodedPassword,
            signupRequest.getNickname(), // 닉네임 추가
            userRole
    );
    User savedUser = userRepository.save(newUser);

    String bearerToken = jwtUtil.createToken(
            savedUser.getId(),
            savedUser.getEmail(),
            userRole,
            savedUser.getNickname()); // 닉네임 추가

    return new SignupResponse(bearerToken);
}

public SigninResponse signin(SigninRequest signinRequest) {
    ...
    String bearerToken = jwtUtil.createToken(
            user.getId(), user.getEmail(), user.getUserRole(), user.getNickname()); // 닉네임 추가
    return new SigninResponse(bearerToken);
}

// JwtFilter &amp;mdash; claims에서 nickname 파싱 &amp;rarr; SecurityContext에 저장
String nickname = claims.get(&quot;nickname&quot;, String.class);
AuthUser authUser = new AuthUser(userId, email, nickname, userRole);
UsernamePasswordAuthenticationToken authentication =
    new UsernamePasswordAuthenticationToken(authUser, null, ...);
SecurityContextHolder.getContext().setAuthentication(authentication);

// AuthUserArgumentResolver &amp;mdash; SecurityContext에서 꺼내기
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
return (AuthUser) authentication.getPrincipal();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5) &lt;span style=&quot;letter-spacing: 0px;&quot;&gt;왜 이 지식이 필요한가&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;JWT는 stateless 인증 방식의 핵심&lt;/li&gt;
&lt;li&gt;claim에 자주 쓰는 정보를 담으면 DB 부하를 줄일 수 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. JPA의 이해&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1) 문제사항 + 원인&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;GET /todos API에 weather, 수정일 기간 필터가 없었음&lt;/li&gt;
&lt;li&gt;각 조건은 있을 수도 없을 수도 있는 선택적 필터여야 했음&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2) 알아야 할 개념&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;JPQL의 :param IS NULL OR 조건 패턴을 쓰면 파라미터가 null이면 조건을 무시하고, 값이 있으면 필터링함&lt;/li&gt;
&lt;li&gt;LEFT JOIN FETCH로 연관 엔티티도 한 번에 가져와 N+1을 방지할 수 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3) 해결방법&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;TodoRepository에 searchTodos() JPQL 추가, Controller &amp;rarr; Service &amp;rarr; Repository 계층 모두 파라미터 전달 수정&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4) 코드 설명&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// TodoRepository
@Query(&quot; SELECT t FROM Todo t LEFT JOIN FETCH t.user &quot; +
        &quot;WHERE (:weather IS NULL OR t.weather = :weather)&quot; +
        &quot;AND (:startDate IS NULL OR t.modifiedAt &amp;gt;= :startDate)&quot; +
        &quot;AND (:endDate IS NULL OR t.modifiedAt &amp;lt;= :endDate)&quot; +
        &quot;ORDER BY t.modifiedAt DESC &quot;)
Page&amp;lt;Todo&amp;gt; searchTodos(
    @Param(&quot;weather&quot;) String weather,
    @Param(&quot;startDate&quot;) LocalDateTime startDate,
    @Param(&quot;endDate&quot;) LocalDateTime endDate,
    Pageable pageable
);

// TodoController &amp;mdash; 선택적 파라미터 추가
@GetMapping(&quot;/todos&quot;)
public ResponseEntity&amp;lt;Page&amp;lt;TodoResponse&amp;gt;&amp;gt; getTodos(
    @RequestParam(defaultValue = &quot;1&quot;) int page,
    @RequestParam(defaultValue = &quot;10&quot;) int size,
    @RequestParam(required = false) String weather,
    @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDateTime startDate,
    @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDateTime endDate
) {
    return ResponseEntity.ok(todoService.getTodos(page, size, weather, startDate, endDate));
}

// TodoService.java &amp;mdash; 검색 조건 파라미터 추가
public Page&amp;lt;TodoResponse&amp;gt; getTodos(int page, int size,
                                   String weather,
                                   LocalDateTime startDate,
                                   LocalDateTime endDate) {
    Pageable pageable = PageRequest.of(page - 1, size);
    Page&amp;lt;Todo&amp;gt; todos = todoRepository.searchTodos(weather, startDate, endDate, pageable);

    return todos.map(todo -&amp;gt; new TodoResponse(
            todo.getId(),
            todo.getTitle(),
            todo.getContents(),
            todo.getWeather(),
            new UserResponse(todo.getUser().getId(), todo.getUser().getEmail()),
            todo.getCreatedAt(),
            todo.getModifiedAt()
    ));
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5) 왜 이 지식이 필요한가&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;JPQL IS NULL 패턴으로 쿼리 하나로 다양한 필터 조합을 처리할 수 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4. 컨트롤러 테스트의 이해&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1) 문제사항 + 원인&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;todo_단건_조회_시_todo가_존재하지_않아_예외가_발생한다() 테스트 실패&lt;/li&gt;
&lt;li&gt;InvalidRequestException 발생 시 200 OK를 기대했지만, GlobalExceptionHandler가 400 BAD_REQUEST를 반환하기 때문&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2) 알아야 할 개념&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;@WebMvcTest는 Web 레이어만 띄우는 슬라이스 테스트이고, GlobalExceptionHandler도 함께 로드됨. 테스트 코드의 기대값은 실제 시스템 동작과 반드시 일치해야 함&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3) 해결방법&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;기대 상태 코드와 응답 body를 BAD_REQUEST(400)으로 수정.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4) 코드 설명&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 변경 전 (잘못된 기대값)
.andExpect(status().isOk())
.andExpect(jsonPath(&quot;$.status&quot;).value(HttpStatus.OK.name()))
.andExpect(jsonPath(&quot;$.code&quot;).value(HttpStatus.OK.value()))

// 변경 후 (실제 동작과 일치)
.andExpect(status().isBadRequest())
.andExpect(jsonPath(&quot;$.status&quot;).value(HttpStatus.BAD_REQUEST.name()))
.andExpect(jsonPath(&quot;$.code&quot;).value(HttpStatus.BAD_REQUEST.value()))
.andExpect(jsonPath(&quot;$.message&quot;).value(&quot;Todo not found&quot;))&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5) &lt;span style=&quot;letter-spacing: 0px;&quot;&gt;왜 이 지식이 필요한가&lt;/span&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;/li&gt;
&lt;li&gt;GlobalExceptionHandler가 반환하는 형식을 이해하고 기대값을 정확히 작성해야 함&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;5. AOP의 이해&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1) 문제사항 + 원인&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;AOP가 @After + UserController.getUser() 대상으로 잘못 설정되어 있었음&lt;/li&gt;
&lt;li&gt;실제로는 UserAdminController.changeUserRole() 실행 전에 동작해야 했음&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2) 알아야 할 개념&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;AOP 어드바이스 타입: @Before(실행 전), @After(실행 후), @Around(전후 모두)&lt;/li&gt;
&lt;li&gt;Pointcut 표현식 execution(* 패키지.클래스.메서드(..)) 으로 대상 메서드를 지정함&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3) 해결방법&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;@After &amp;rarr; @Before, Pointcut 대상을 UserAdminController.changeUserRole()로 수정&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4) 코드 설명&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// 변경 전
@After(&quot;execution(* org.example.expert.domain.user.controller.UserController.getUser(..))&quot;)

// 변경 후
@Before(&quot;execution(* org.example.expert.domain.user.controller.UserAdminController.changeUserRole(..))&quot;)
public void logAfterChangeUserRole(JoinPoint joinPoint) { ... }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5) 왜 이 지식이 필요한가&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;Pointcut 표현식을 정확히 작성해야 의도한 메서드에만 AOP가 적용됨&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;6. JPA Cascade&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1) 문제사항 + 원인&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Todo 저장 시 managers 컬렉션에 cascade 설정이 없어서 Manager가 DB에 저장되지 않았음&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2) 알아야 할 개념&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;JPA cascade(영속성 전이)는 부모 엔티티의 상태 변화를 자식에 자동 전파함&lt;/li&gt;
&lt;li&gt;CascadeType.PERSIST를 설정하면 save() 시 자식도 함께 INSERT됨&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3) 해결방법&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;@OneToMany에 cascade = CascadeType.PERSIST 추가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4) 코드 설명&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 변경 전
@OneToMany(mappedBy = &quot;todo&quot;)
private List&amp;lt;Manager&amp;gt; managers = new ArrayList&amp;lt;&amp;gt;();

// 변경 후
@OneToMany(mappedBy = &quot;todo&quot;, cascade = CascadeType.PERSIST)
private List&amp;lt;Manager&amp;gt; managers = new ArrayList&amp;lt;&amp;gt;();

// 생성자에서 Manager 자동 추가
public Todo(String title, String contents, String weather, User user) {
    this.user = user;
    this.managers.add(new Manager(user, this));
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5) 왜 이 지식이 필요한가&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;cascade를 모르면 연관 엔티티를 매번 직접 save()해야 하고, 빠뜨리면 데이터 누락 발생&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;7. N+1 문제&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1) 문제사항 + 원인&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;댓글 목록 조회 후 루프에서 comment.getUser() 호출 시마다 User 조회 쿼리가 추가 발생 (N+1)&lt;/li&gt;
&lt;li&gt;Repository 쿼리에서 JOIN만 사용해 User를 즉시 로딩하지 않았기 때문&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2) 알아야 할 개념&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;JOIN &amp;mdash; 조건 필터링만, 연관 엔티티는 Lazy 유지 &amp;rarr; 접근 시 추가 쿼리 발생&lt;/li&gt;
&lt;li&gt;JOIN FETCH &amp;mdash; 연관 엔티티를 즉시 함께 조회 &amp;rarr; 쿼리 1번으로 해결&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3) 해결방법&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;CommentRepository JPQL에서 JOIN &amp;rarr; JOIN FETCH로 변경&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4) 코드 설명&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// 변경 전 &amp;mdash; N+1 발생
@Query(&quot;SELECT c FROM Comment c JOIN c.user WHERE c.todo.id = :todoId&quot;)

// 변경 후 &amp;mdash; 즉시 로딩
@Query(&quot;SELECT c FROM Comment c JOIN FETCH c.user WHERE c.todo.id = :todoId&quot;)
List&amp;lt;Comment&amp;gt; findByTodoIdWithUser(@Param(&quot;todoId&quot;) Long todoId);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5) 왜 이 지식이 필요한가&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은 운영 환경에서 DB 과부하로 이어짐&lt;/li&gt;
&lt;li&gt;Lazy 로딩 + 루프 접근 패턴이 보이면 JOIN FETCH를 검토해야 함&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;8. QueryDSL&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1) 문제사항 + 원인&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;findByIdWithUser()가 JPQL 문자열로 작성되어 컴파일 시점에 오타를 잡을 수 없었고, LEFT JOIN만 사용해 N+1 위험도 있었음&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2) 알아야 할 개념&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;QueryDSL은 자바 코드로 타입 안전하게 쿼리를 작성하는 라이브러리&lt;/li&gt;
&lt;li&gt;Q클래스로 필드명 오타를 컴파일 시점에 잡을 수 있음&lt;/li&gt;
&lt;li&gt;fetchJoin()은 JPQL의 JOIN FETCH와 동일하게 즉시 로딩&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3) 해결방법&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;TodoRepositoryCustom 인터페이스, TodoRepositoryImpl 구현체 생성, QueryDslConfig 빈 등록, 기존 JPQL 쿼리 삭제&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4) 코드 설명&lt;/p&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;// build.gradle
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor &quot;com.querydsl:querydsl-apt:5.0.0:jakarta&quot;
annotationProcessor &quot;jakarta.annotation:jakarta.annotation-api&quot;
annotationProcessor &quot;jakarta.persistence:jakarta.persistence-api&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// QueryDslConfig &amp;mdash; JPAQueryFactory 빈 등록
@Configuration
public class QueryDslConfig {

    @PersistenceContext
    private EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }
}

// TodoRepositoryCustom.java &amp;mdash; 인터페이스 생성
public interface TodoRepositoryCustom {
    Optional&amp;lt;Todo&amp;gt; findByIdWithUser(Long id);
}

// TodoRepositoryImpl &amp;mdash; QueryDSL 구현
@Override
public Optional&amp;lt;Todo&amp;gt; findByIdWithUser(Long todoId) {
    Todo result = queryFactory
        .selectFrom(todo)
        .leftJoin(todo.user, user).fetchJoin()
        .where(todo.id.eq(todoId))
        .fetchOne();
    return Optional.ofNullable(result);
}

// TodoRepository.java &amp;mdash; Custom 인터페이스 extends 추가, 기존 JPQL 삭제&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5)&amp;nbsp; 왜 이 지식이 필요한가&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;복잡한 쿼리를 JPQL 문자열로 관리하면 유지보수가 어렵고 런타임 에러 위험이 있음&lt;/li&gt;
&lt;li&gt;QueryDSL로 타입 안전성과 가독성을 모두 챙길 수 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;9. Spring Security&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1) 문제사항 + 원인&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;기존 Servlet Filter와 AuthUserArgumentResolver로 인증/인가를 처리하던 구조를 Spring Security로 전환&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2) 알아야 할 개념 : Spring Security 핵심 흐름&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;SecurityFilterChain : URL별 인증/인가 규칙 설정&lt;/li&gt;
&lt;li&gt;OncePerRequestFilter&amp;nbsp;:&amp;nbsp;JWT&amp;nbsp;검증&amp;nbsp;후&amp;nbsp;SecurityContextHolder에&amp;nbsp;인증&amp;nbsp;정보&amp;nbsp;저장&lt;/li&gt;
&lt;li&gt;UsernamePasswordAuthenticationToken&amp;nbsp;:&amp;nbsp;Security가&amp;nbsp;인식하는&amp;nbsp;인증&amp;nbsp;객체&lt;/li&gt;
&lt;li&gt;SecurityContextHolder&amp;nbsp;:&amp;nbsp;현재&amp;nbsp;요청의&amp;nbsp;인증&amp;nbsp;정보&amp;nbsp;보관소&lt;/li&gt;
&lt;li&gt;AuthUserArgumentResolver&amp;nbsp;:&amp;nbsp;SecurityContext에서&amp;nbsp;AuthUser를&amp;nbsp;꺼내&amp;nbsp;컨트롤러에&amp;nbsp;주입&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3) 해결방법&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;SecurityConfig 추가, JwtFilter를 OncePerRequestFilter 상속으로 전환, AuthUserArgumentResolver 수정&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4) 코드 설명&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-security'

// SecurityConfig ㅡ 신규 생성
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
        .csrf(AbstractHttpConfigurer::disable)
        .sessionManagement(s -&amp;gt; s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests(auth -&amp;gt; auth
            .requestMatchers(&quot;/auth/**&quot;).permitAll()
            .requestMatchers(&quot;/admin/**&quot;).hasRole(&quot;ADMIN&quot;)
            .anyRequest().authenticated()
        )
        .addFilterBefore(new JwtFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class);
    return http.build();
}

// JwtFilter &amp;mdash; Servlet Filter &amp;rarr; OncePerRequestFilter로 전환
@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter { // OncePerRequestFilter 상속

    private final JwtUtil jwtUtil;

    @Override
    public void doFilterInternal(HttpServletRequest request,
                                 HttpServletResponse response,
                                 FilterChain filterChain) throws ServletException, IOException {

        String url = request.getRequestURI();

        if (url.startsWith(&quot;/auth&quot;)) {
            filterChain.doFilter(request, response);
            return;
        }

        String bearerJwt = request.getHeader(&quot;Authorization&quot;);

        if (bearerJwt == null) {
            response.sendError(HttpServletResponse.SC_BAD_REQUEST, &quot;JWT 토큰이 필요합니다.&quot;);
            return;
        }

        String jwt = jwtUtil.substringToken(bearerJwt);

        try {
            Claims claims = jwtUtil.extractClaims(jwt);

            if (claims == null) {
                response.sendError(HttpServletResponse.SC_BAD_REQUEST, &quot;잘못된 JWT 토큰입니다.&quot;);
                return;
            }

            Long userId = Long.parseLong(claims.getSubject());
            String email = claims.get(&quot;email&quot;, String.class);
            String nickname = claims.get(&quot;nickname&quot;, String.class); // nickname 파싱
            String roleValue = claims.get(&quot;userRole&quot;, String.class);
            UserRole userRole = UserRole.valueOf(roleValue);

            AuthUser authUser = new AuthUser(userId, email, nickname, userRole);

            // Security가 인식하는 인증 객체 생성
            UsernamePasswordAuthenticationToken authenticationToken =
                    new UsernamePasswordAuthenticationToken(
                            authUser,    // principal &amp;mdash; AuthUser 객체
                            null,        // credentials &amp;mdash; JWT 방식은 불필요
                            List.of(new SimpleGrantedAuthority(&quot;ROLE_&quot; + userRole.name()))
                    );

            SecurityContextHolder.getContext().setAuthentication(authenticationToken);
            filterChain.doFilter(request, response);

        } catch (SecurityException | MalformedJwtException e) {
            log.error(&quot;Invalid JWT signature&quot;, e);
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, &quot;유효하지 않는 JWT 서명입니다.&quot;);
        } catch (ExpiredJwtException e) {
            log.error(&quot;Expired JWT token&quot;, e);
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, &quot;만료된 JWT 토큰입니다.&quot;);
        } catch (UnsupportedJwtException e) {
            log.error(&quot;Unsupported JWT token&quot;, e);
            response.sendError(HttpServletResponse.SC_BAD_REQUEST, &quot;지원되지 않는 JWT 토큰입니다.&quot;);
        } catch (Exception e) {
            log.error(&quot;Internal server error&quot;, e);
            response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
        }
    }
}

// AuthUserArgumentResolver &amp;mdash; SecurityContext에서 꺼내기
public class AuthUserArgumentResolver implements HandlerMethodArgumentResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        boolean hasAuthAnnotation = parameter.getParameterAnnotation(Auth.class) != null;
        boolean isAuthUserType = parameter.getParameterType().equals(AuthUser.class);

        if (hasAuthAnnotation != isAuthUserType) {
            throw new AuthException(&quot;@Auth와 AuthUser 타입은 함께 사용되어야 합니다.&quot;);
        }
        return hasAuthAnnotation;
    }

    @Override
    public Object resolveArgument(
            @Nullable MethodParameter parameter,
            @Nullable ModelAndViewContainer mavContainer,
            NativeWebRequest webRequest,
            @Nullable WebDataBinderFactory binderFactory
    ) {
        // 기존: HttpServletRequest에서 setAttribute된 값을 꺼내는 방식
        // 변경: SecurityContextHolder에서 인증 정보를 꺼내는 방식
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        if (authentication == null || authentication.getPrincipal() == null) {
            throw new AuthException(&quot;인증 정보가 없습니다.&quot;);
        }

        return (AuthUser) authentication.getPrincipal();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5) 왜 이 지식이 필요한가&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Spring Security는 실무 표준&lt;/li&gt;
&lt;li&gt;SecurityContextHolder 개념을 이해해야 인증 정보를 올바르게 전달할 수 있음&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>  SPARTA/Assignments</category>
      <author>eunjiom</author>
      <guid isPermaLink="true">https://eunjiom.tistory.com/45</guid>
      <comments>https://eunjiom.tistory.com/45#entry45comment</comments>
      <pubDate>Fri, 3 Apr 2026 13:13:04 +0900</pubDate>
    </item>
  </channel>
</rss>