[{"content":"왜 Hugo인가 정적 사이트 생성기(SSG) 중에서 Hugo를 선택한 이유는 단순하다.\n빠르다: Go 언어 기반으로 수백 개의 글도 수십 ms 안에 빌드된다 배포가 쉽다: GitHub Pages + GitHub Actions로 git push 한 번에 자동 배포 서버가 없다: DB도, Node.js 서버도 없다. 정적 파일만 호스팅하면 된다 무료다: GitHub Pages 무료 플랜으로 충분하다 스택 구성 Hugo (v0.161.1 extended) └── Stack 테마 (git submodule) GitHub Pages (호스팅) GitHub Actions (CI/CD) Cloudflare (DNS, 커스텀 도메인) Giscus (댓글, GitHub Discussions 기반) 초기 세팅 1. Hugo 설치 (macOS) brew install hugo hugo version # hugo v0.161.1+extended darwin/arm64 extended 버전이 필수다. SCSS 컴파일에 필요하다.\n2. 새 사이트 생성 + Stack 테마 적용 hugo new site my-blog cd my-blog git init git submodule add https://github.com/CaiJimmy/hugo-theme-stack themes/stack hugo.toml에 테마 등록:\ntheme = \u0026#34;stack\u0026#34; 3. GitHub 레포 생성 및 배포 설정 \u0026lt;username\u0026gt;.github.io 이름으로 GitHub 레포를 만들고, Settings → Pages에서 GitHub Actions를 소스로 선택한다.\n.github/workflows/hugo.yml:\nname: Deploy Hugo site to Pages on: push: branches: [\u0026#34;main\u0026#34;] workflow_dispatch: permissions: contents: read pages: write id-token: write concurrency: group: \u0026#34;pages\u0026#34; cancel-in-progress: false jobs: build: runs-on: ubuntu-latest env: HUGO_VERSION: 0.161.1 steps: - name: Install Hugo CLI run: | wget -O hugo.deb https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb sudo dpkg -i hugo.deb - name: Checkout uses: actions/checkout@v4 with: submodules: recursive - name: Setup Pages id: pages uses: actions/configure-pages@v5 - name: Build with Hugo run: hugo --minify --baseURL \u0026#34;${{ steps.pages.outputs.base_url }}/\u0026#34; - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: path: ./public deploy: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest needs: build steps: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 git push하면 자동으로 빌드되고 배포된다.\n커스텀 도메인 연결 (Cloudflare) Cloudflare를 DNS로 사용하는 경우 설정은 간단하다.\n1. CNAME 레코드 추가 Type Name Content Proxy CNAME blog \u0026lt;username\u0026gt;.github.io DNS only (회색) 주의: Proxy 상태(주황 구름)로 두면 GitHub Pages의 HTTPS 인증서 발급이 실패한다. 반드시 DNS only로 설정해야 한다.\n2. static/CNAME 파일 생성 blog.yourdomain.com Hugo 빌드 시 이 파일이 public/ 에 복사되어 GitHub Pages에 전달된다.\n3. GitHub 레포 설정 Settings → Pages → Custom domain에 도메인을 입력하면 Enforce HTTPS가 활성화된다.\nhugo.toml 주요 설정 baseURL = \u0026#34;https://blog.yourdomain.com/\u0026#34; locale = \u0026#34;ko-kr\u0026#34; title = \u0026#34;블로그 제목\u0026#34; theme = \u0026#34;stack\u0026#34; paginate = 10 hasCJKLanguage = true # 한국어 단어 수 계산에 필수 [params] mainSections = [\u0026#34;tech\u0026#34;, \u0026#34;til\u0026#34;, \u0026#34;log\u0026#34;] [params.sidebar] subtitle = \u0026#34;블로그 소개 문구\u0026#34; avatar = \u0026#34;https://github.com/\u0026lt;username\u0026gt;.png\u0026#34; [params.article] toc = true readingTime = true [params.colorScheme] toggle = true default = \u0026#34;auto\u0026#34; [params.homepage] grid = true # 2컬럼 그리드 홈 [params.comments] enabled = true provider = \u0026#34;giscus\u0026#34; 콘텐츠 구조 Stack 테마는 content/ 아래 디렉터리가 곧 섹션이 된다.\ncontent/ ├── tech/ # 기술 포스트 │ ├── _index.md # 섹션 제목/설명 │ └── 글.md ├── til/ # Today I Learned ├── log/ # 일상 기록 ├── archives/ # 아카이브 페이지 └── search.md # 검색 페이지 _index.md frontmatter:\n--- title: \u0026#34;Tech\u0026#34; description: \u0026#34;기술 포스트 모음\u0026#34; --- 커스터마이징 Stack 테마의 파일을 직접 수정하면 테마 업데이트 시 덮어씌워진다. 올바른 방법은 프로젝트 루트의 layouts/에 같은 경로로 복사하는 것이다. Hugo는 프로젝트 layouts/를 테마보다 우선시한다.\n예시: 홈 레이아웃 수정 themes/stack/layouts/home.html ← 원본 (건드리지 않는다) layouts/home.html ← 오버라이드 (여기에 수정) 커스텀 CSS assets/scss/custom.scss를 생성하면 Stack이 자동으로 포함시켜준다.\n/* 색상 테마 오버라이드 */ :root { --accent-color: #1B365D; --body-background: #f8f7f2; } macOS 스타일 코드 블록 코드 블록 상단에 macOS 신호등 버튼(빨/노/초)을 추가하는 CSS:\n.article-content .highlight { border-radius: 10px; overflow: hidden; } .article-content .highlight:before { content: \u0026#34;\u0026#34;; display: block; height: 35px; background: url(\u0026#34;data:image/svg+xml;base64,...\u0026#34;) no-repeat 12px center; background-size: 52px; } Giscus 댓글 설정 GitHub Discussions 기반 댓글 시스템이다. 서버가 필요 없다.\n1. 준비 GitHub 레포에서 Discussions 활성화 (Settings → General → Discussions) Giscus 앱 설치 2. repoID와 categoryID 조회 GitHub GraphQL API로 ID를 가져온다:\nquery { repository(owner: \u0026#34;username\u0026#34;, name: \u0026#34;repo-name\u0026#34;) { id discussionCategories(first: 10) { nodes { id name } } } } 3. hugo.toml 설정 [params.comments] enabled = true provider = \u0026#34;giscus\u0026#34; [params.comments.giscus] repo = \u0026#34;username/repo-name\u0026#34; repoID = \u0026#34;R_...\u0026#34; category = \u0026#34;Announcements\u0026#34; categoryID = \u0026#34;DIC_...\u0026#34; mapping = \u0026#34;title\u0026#34; lightTheme = \u0026#34;light\u0026#34; darkTheme = \u0026#34;dark_dimmed\u0026#34; 삽질 모음 세팅하면서 겪은 문제들을 정리했다.\nTOML 위젯 형식 오류 # 잘못된 방법 (문자열 배열) widgets = [\u0026#34;search\u0026#34;, \u0026#34;archives\u0026#34;] # 올바른 방법 (객체 배열) [[params.widgets.homepage]] type = \u0026#34;search\u0026#34; [[params.widgets.homepage]] type = \u0026#34;archives\u0026#34; Stack 아이콘 이름 Stack 테마에서 사용할 수 있는 아이콘은 themes/stack/assets/icons/에 있는 SVG 파일명이다. code, pencil 같은 이름은 없다.\n사용 가능한 예시: home, archives, tag, hash, search, infinity, brand-github\nGitHub Pages 빌드 실패 Error: Unable to locate config file or config directory git submodule update --init --recursive가 워크플로우에 빠졌을 때 발생한다. actions/checkout@v4의 submodules: recursive 옵션을 꼭 확인하자.\nhasCJKLanguage 설정 이 설정이 없으면 한국어 글의 읽기 시간 계산이 정확하지 않다.\nhasCJKLanguage = true 완성된 기능 Hugo + Stack 테마 세팅 GitHub Actions 자동 배포 커스텀 도메인 (Cloudflare DNS) Tech / TIL / Log 섹션 구조 2컬럼 그리드 홈 레이아웃 macOS 스타일 코드 블록 Giscus 댓글 좌측 사이드바 검색창 Archives 페이지 섹션 카드 참고 Hugo 공식 문서 Stack 테마 GitHub Giscus ","date":"2026-05-20T00:00:00+09:00","permalink":"/log/2026-05-20-hugo-blog-setup/","title":"Hugo + Stack 테마로 기술 블로그 만들기"},{"content":"Spring을 써도 Servlet을 알아야 하는 이유 \u0026ldquo;Filter와 Interceptor의 차이가 뭔가요?\u0026rdquo;\n면접에서 자주 나오는 질문이다. 많은 개발자들이 \u0026ldquo;Filter는 Servlet 앞, Interceptor는 Controller 앞\u0026quot;이라고 외워서 답한다. 맞는 말이지만, 왜 그런지를 이해하고 있는 사람은 적다.\n그 이유를 이해하려면 HTTP 요청이 Spring Controller에 닿기까지 거치는 전체 레이어를 알아야 한다. 그리고 그 출발점은 Servlet이다.\nCGI에서 Servlet으로 — 왜 바뀌었나 초창기 웹 서버는 정적 파일만 제공했다. 동적 응답(사용자별 맞춤 페이지, DB 조회 결과 등)이 필요해지자 **CGI(Common Gateway Interface)**가 등장했다.\nCGI의 방식은 단순하다: HTTP 요청이 들어올 때마다 새 프로세스를 생성해서 처리하고, 끝나면 종료한다.\n요청 1 → 프로세스 생성 → 처리 → 프로세스 종료 요청 2 → 프로세스 생성 → 처리 → 프로세스 종료 요청 N → ... 문제는 프로세스 생성 비용이다. 동시 접속자가 늘어나면 서버가 버티지 못한다.\nJava Servlet은 이 문제를 스레드로 해결했다. Servlet 인스턴스는 JVM에 딱 1개만 상주하고, 요청마다 스레드를 하나씩 할당해 처리한다.\nServlet 인스턴스 (1개, JVM에 상주) ├─ 요청 1 → 스레드 1 ├─ 요청 2 → 스레드 2 └─ 요청 N → 스레드 N 인스턴스를 매번 새로 만들지 않으니 메모리와 시간 비용이 크게 줄었다. 이 설계가 오늘날 Spring 애플리케이션의 근간이 된다.\nServlet 생명주기 — 인스턴스는 한 번만 만들어진다 Servlet 인스턴스는 **Servlet Container(Tomcat)**가 관리한다. 생명주기는 세 단계다.\n최초 요청 또는 서버 시작 → init() : 인스턴스 생성 + 초기화 (딱 1회) → service() : 요청마다 호출 (doGet / doPost 등으로 분기) → destroy() : 컨테이너 종료 시 (딱 1회) 코드로 보면 이렇다.\npublic class MyServlet extends HttpServlet { @Override public void init() { // DB 커넥션 풀 초기화 등 1회성 작업 } @Override protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException { res.getWriter().write(\u0026#34;Hello\u0026#34;); } @Override public void destroy() { // 리소스 해제 } } 직접 이런 코드를 쓸 일은 거의 없다. 하지만 Spring의 DispatcherServlet이 내부적으로 이 구조를 그대로 따른다는 점이 핵심이다.\nTomcat의 역할 — Spring Boot가 jar 하나로 뜨는 이유 Servlet Container인 Tomcat은 단순히 Servlet을 실행하는 것 이상의 역할을 한다.\n역할 설명 Servlet 인스턴스 관리 생성, 초기화, 소멸 스레드 풀 관리 요청마다 스레드 할당 HTTP 파싱 원시 HTTP 바이트 스트림 → HttpServletRequest 객체 변환 URL 매핑 어떤 URL을 어떤 Servlet이 처리할지 결정 Filter Chain 실행 Servlet 앞뒤로 Filter 실행 Spring Boot가 별도의 WAS 없이 java -jar 한 줄로 서버를 시작할 수 있는 이유는 **Tomcat을 내장(embedded)**하기 때문이다. 애플리케이션 안에 Tomcat이 들어있으니 따로 설치할 필요가 없다.\nFilter — Spring보다 바깥 레이어 Filter는 Servlet Container 레벨에서 동작한다. Spring Context가 시작되기 전, 즉 DispatcherServlet보다 앞에 위치한다.\nHTTP 요청 → [Filter1 → Filter2 → Filter3] → Servlet(DispatcherServlet) HTTP 응답 ← [Filter1 ← Filter2 ← Filter3] ← Servlet @Component public class LoggingFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { System.out.println(\u0026#34;요청 진입: \u0026#34; + ((HttpServletRequest) request).getRequestURI()); chain.doFilter(request, response); // 다음 Filter 또는 Servlet으로 넘김 System.out.println(\u0026#34;응답 완료\u0026#34;); } } Filter가 적합한 작업:\n인코딩 설정: 모든 요청에 UTF-8 적용 CORS 처리: 응답 헤더 추가 인증 토큰 1차 검사: Authorization 헤더 존재 여부 확인 요청/응답 로깅: URL, 처리 시간 기록 한 가지 중요한 제약이 있다. Filter는 Spring Context 밖에 있기 때문에 Spring의 예외 처리(@ControllerAdvice)가 적용되지 않는다. Filter에서 예외가 발생하면 직접 처리해야 한다.\nSpring Security의 필터체인도 Servlet Filter다. 이 때문에 Security 설정이 Spring MVC(Controller, Interceptor 등)보다 먼저 동작한다.\nDispatcherServlet — Spring MVC의 시작점 DispatcherServlet은 Spring MVC의 핵심이지만, 결국 HttpServlet을 상속한 Servlet이다. Tomcat이 관리하는 수많은 Servlet 인스턴스 중 하나일 뿐이다.\nSpring Boot는 DispatcherServlet을 자동 등록하고, 모든 URL(/)을 이 Servlet이 받도록 설정한다. 이후 요청을 어떤 Controller로 보낼지는 DispatcherServlet 내부의 HandlerMapping이 결정한다.\n전체 요청 흐름 — 한눈에 보기 HTTP 요청이 Controller에 닿기까지 거치는 전체 레이어다.\nHTTP 요청 → Tomcat (HTTP 파싱, 스레드 할당) → Filter Chain (Servlet Container 레벨) → DispatcherServlet (Spring MVC 진입) → Interceptor (preHandle) → AOP (Before Advice) → Controller 메서드 실행 → AOP (After Advice) → Interceptor (postHandle) → DispatcherServlet (ViewResolver 등) → Filter Chain (역순) → HTTP 응답 이 흐름이 Filter / Interceptor / AOP의 차이를 결정한다.\n구분 위치 Spring Bean 접근 Spring 예외 처리 주요 용도 Filter Servlet Container 제한적 X 인코딩, CORS, 인증 토큰 Interceptor Spring MVC O O 로그인 체크, 권한 검사 AOP Spring Bean O O 트랜잭션, 로깅, 성능 측정 마치며 Servlet은 오래된 기술이지만, Spring MVC의 모든 레이어(Filter, DispatcherServlet, Interceptor, AOP)가 이 위에 쌓여있다. 이 구조를 이해하면 \u0026ldquo;Filter와 Interceptor 중 무엇을 써야 하나?\u0026ldquo;라는 질문에 외운 답이 아닌 구조적 이해로 답할 수 있다.\n다음 편에서는 Spring Core — IoC 컨테이너와 의존성 주입이 어떻게 동작하는지 정리한다.\n","date":"2026-05-19T00:00:00Z","permalink":"/tech/2026-05-19-spring-01-servlet-fundamentals/","title":"[Spring 완전 정복 #1] Spring을 제대로 이해하려면 Servlet부터 — HTTP 요청이 Controller에 닿기까지"},{"content":"예외 처리가 흩어져 있으면 생기는 문제 Spring 애플리케이션에서 예외 처리를 각 Controller마다 개별로 하면 두 가지 문제가 생긴다. 에러 응답 형식이 제각각이 되고, 같은 예외를 여러 곳에서 중복 처리하게 된다.\n@ControllerAdvice는 이 문제를 해결한다. 전역 예외 핸들러를 하나 만들어 모든 Controller에서 발생하는 예외를 한 곳에서 처리한다.\n@ControllerAdvice 기본 구조 @RestControllerAdvice // @ControllerAdvice + @ResponseBody public class GlobalExceptionHandler { @ExceptionHandler(UserNotFoundException.class) public ResponseEntity\u0026lt;ErrorResponse\u0026gt; handleUserNotFound(UserNotFoundException e) { return ResponseEntity .status(HttpStatus.NOT_FOUND) .body(ErrorResponse.of(\u0026#34;USER_NOT_FOUND\u0026#34;, e.getMessage())); } @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity\u0026lt;ErrorResponse\u0026gt; handleValidation( MethodArgumentNotValidException e) { List\u0026lt;String\u0026gt; errors = e.getBindingResult().getFieldErrors().stream() .map(fe -\u0026gt; fe.getField() + \u0026#34;: \u0026#34; + fe.getDefaultMessage()) .toList(); return ResponseEntity.badRequest() .body(ErrorResponse.of(\u0026#34;VALIDATION_FAILED\u0026#34;, errors.toString())); } @ExceptionHandler(Exception.class) public ResponseEntity\u0026lt;ErrorResponse\u0026gt; handleException(Exception e) { log.error(\u0026#34;Unhandled exception\u0026#34;, e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(ErrorResponse.of(\u0026#34;INTERNAL_ERROR\u0026#34;, \u0026#34;서버 오류가 발생했습니다\u0026#34;)); } } 예외 처리 계층은 다음과 같다.\nController 예외 발생 → HandlerExceptionResolver (Spring MVC 내부) → @ControllerAdvice → 일관된 에러 응답 반환 Filter에서 발생한 예외는 DispatcherServlet 밖이라 @ControllerAdvice가 잡지 못한다. Filter 내부에서 직접 응답을 작성해야 한다.\n에러 응답 DTO 클라이언트가 에러 상황을 파악할 수 있도록 일관된 형식으로 응답한다.\n@Getter public class ErrorResponse { private final String code; private final String message; private final LocalDateTime timestamp; private ErrorResponse(String code, String message) { this.code = code; this.message = message; this.timestamp = LocalDateTime.now(); } public static ErrorResponse of(String code, String message) { return new ErrorResponse(code, message); } } { \u0026#34;code\u0026#34;: \u0026#34;USER_NOT_FOUND\u0026#34;, \u0026#34;message\u0026#34;: \u0026#34;사용자를 찾을 수 없습니다: id=123\u0026#34;, \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-19T10:30:00\u0026#34; } code는 클라이언트가 에러 종류를 분기 처리하기 위한 값이고, message는 사람이 읽는 설명이다.\nCustom Exception 설계 — ErrorCode Enum 패턴 에러 코드가 늘어날수록 관리가 중요해진다. ErrorCode Enum + BusinessException 기반 클래스 패턴이 실무에서 가장 많이 쓰인다.\n1. ErrorCode Enum @Getter @RequiredArgsConstructor public enum ErrorCode { // 공통 INVALID_INPUT(400, \u0026#34;INVALID_INPUT\u0026#34;, \u0026#34;잘못된 입력입니다\u0026#34;), UNAUTHORIZED(401, \u0026#34;UNAUTHORIZED\u0026#34;, \u0026#34;인증이 필요합니다\u0026#34;), FORBIDDEN(403, \u0026#34;FORBIDDEN\u0026#34;, \u0026#34;권한이 없습니다\u0026#34;), // 사용자 USER_NOT_FOUND(404, \u0026#34;USER_NOT_FOUND\u0026#34;, \u0026#34;사용자를 찾을 수 없습니다\u0026#34;), DUPLICATE_EMAIL(409, \u0026#34;DUPLICATE_EMAIL\u0026#34;, \u0026#34;이미 사용 중인 이메일입니다\u0026#34;), // 주문 ORDER_NOT_FOUND(404, \u0026#34;ORDER_NOT_FOUND\u0026#34;, \u0026#34;주문을 찾을 수 없습니다\u0026#34;), INSUFFICIENT_STOCK(400, \u0026#34;INSUFFICIENT_STOCK\u0026#34;, \u0026#34;재고가 부족합니다\u0026#34;), // 서버 INTERNAL_ERROR(500, \u0026#34;INTERNAL_ERROR\u0026#34;, \u0026#34;서버 오류가 발생했습니다\u0026#34;); private final int status; private final String code; private final String message; } 2. BusinessException 기반 클래스 public class BusinessException extends RuntimeException { private final ErrorCode errorCode; public BusinessException(ErrorCode errorCode) { super(errorCode.getMessage()); this.errorCode = errorCode; } public BusinessException(ErrorCode errorCode, String detail) { super(errorCode.getMessage() + \u0026#34;: \u0026#34; + detail); this.errorCode = errorCode; } public int getStatus() { return errorCode.getStatus(); } public String getCode() { return errorCode.getCode(); } } 3. 도메인별 구체 예외 클래스 public class UserNotFoundException extends BusinessException { public UserNotFoundException(Long userId) { super(ErrorCode.USER_NOT_FOUND, \u0026#34;id=\u0026#34; + userId); } } public class InsufficientStockException extends BusinessException { public InsufficientStockException(Long productId, int requested, int available) { super(ErrorCode.INSUFFICIENT_STOCK, String.format(\u0026#34;productId=%d, 요청=%d, 재고=%d\u0026#34;, productId, requested, available)); } } 4. 서비스에서 사용 @Service public class UserService { public User findById(Long id) { return userRepository.findById(id) .orElseThrow(() -\u0026gt; new UserNotFoundException(id)); } } 5. 핸들러에서 통합 처리 @RestControllerAdvice public class GlobalExceptionHandler { // BusinessException 계층 전체를 한 곳에서 처리 @ExceptionHandler(BusinessException.class) public ResponseEntity\u0026lt;ErrorResponse\u0026gt; handleBusinessException(BusinessException e) { return ResponseEntity .status(e.getStatus()) .body(ErrorResponse.of(e.getCode(), e.getMessage())); } } 새로운 예외가 생겨도 ErrorCode에 추가하고 BusinessException을 상속하면 핸들러 수정 없이 자동으로 처리된다.\nHTTP 상태 코드 — 자주 쓰는 것 코드 의미 사용 상황 200 OK 성공 조회, 수정 성공 201 Created 생성 성공 POST로 리소스 생성 204 No Content 성공, 바디 없음 DELETE 성공 400 Bad Request 잘못된 요청 Validation 실패 401 Unauthorized 미인증 로그인 필요 403 Forbidden 권한 없음 인증됐지만 권한 부족 404 Not Found 리소스 없음 존재하지 않는 ID 409 Conflict 충돌 중복 이메일 500 Internal Error 서버 오류 예상치 못한 예외 401과 403의 차이를 명확히 해두자. 401은 \u0026ldquo;누구인지 모른다 → 로그인 필요\u0026rdquo;, 403은 \u0026ldquo;누구인지 알지만 권한이 없다\u0026quot;는 뜻이다.\n마치며 Spring 예외 처리의 핵심은 두 가지다. @ControllerAdvice로 전역 핸들러를 만들어 에러 응답 형식을 통일하고, ErrorCode Enum + BusinessException 패턴으로 예외를 체계적으로 관리한다. 이 구조를 갖추면 새 예외 추가 시 ErrorCode에 한 줄 추가하는 것으로 끝난다.\n다음 편에서는 Spring 테스트 — 단위/슬라이스/통합 테스트 전략을 정리한다.\n","date":"2026-05-19T00:00:00Z","permalink":"/tech/2026-05-19-spring-10-exception-handling/","title":"[Spring 완전 정복 #10] Spring 예외 처리 — @ControllerAdvice와 Custom Exception 설계"},{"content":"테스트를 왜 계층으로 나누는가 @SpringBootTest를 쓰면 전체 Spring Context를 로드하므로 모든 것을 테스트할 수 있다. 그런데 왜 단위 테스트, 슬라이스 테스트를 따로 쓸까?\n속도 때문이다. @SpringBootTest는 전체 Context를 띄우므로 느리다. 단순 비즈니스 로직 검증에 매번 10초씩 기다리는 것은 비효율적이다. 테스트 종류를 목적에 맞게 선택하면 빠르고 명확한 테스트를 작성할 수 있다.\n단위 테스트 : 빠름 — 비즈니스 로직 @WebMvcTest : 빠름 — API 계약, Validation, HTTP 상태코드 @DataJpaTest : 빠름 — 쿼리 메서드, 커스텀 쿼리 @SpringBootTest : 느림 — 전체 플로우 통합 검증 단위 테스트 — JUnit5 + Mockito Spring Context 없이 순수 Java로 테스트한다. 가장 빠르고 의존성이 없다.\n@ExtendWith(MockitoExtension.class) class OrderServiceTest { @Mock private OrderRepository orderRepository; @Mock private PaymentService paymentService; @InjectMocks // @Mock 객체들을 OrderService에 자동 주입 private OrderService orderService; @Test void 주문_생성_성공() { // given OrderRequest request = new OrderRequest(1L, 2, 10000); Order mockOrder = Order.builder().id(1L).status(OrderStatus.PENDING).build(); given(orderRepository.save(any(Order.class))).willReturn(mockOrder); // when OrderDto result = orderService.createOrder(request); // then assertThat(result.getId()).isEqualTo(1L); verify(orderRepository, times(1)).save(any(Order.class)); } @Test void 재고_부족_시_예외_발생() { given(productRepository.findById(anyLong())) .willReturn(Optional.of(Product.builder().stock(0).build())); assertThatThrownBy(() -\u0026gt; orderService.createOrder(request)) .isInstanceOf(InsufficientStockException.class); } } ArgumentCaptor로 메서드에 전달된 인자를 검증할 수 있다.\nArgumentCaptor\u0026lt;Order\u0026gt; captor = ArgumentCaptor.forClass(Order.class); verify(orderRepository).save(captor.capture()); assertThat(captor.getValue().getStatus()).isEqualTo(OrderStatus.PENDING); @WebMvcTest — Controller 레이어만 테스트 Controller, Filter, @ControllerAdvice만 로드한다. Service, Repository는 @MockBean으로 대체한다. MockMvc로 실제 HTTP 요청처럼 테스트한다.\n@WebMvcTest(OrderController.class) class OrderControllerTest { @Autowired private MockMvc mockMvc; @MockBean // Spring Context에 Mock Bean 등록 private OrderService orderService; @Autowired private ObjectMapper objectMapper; @Test void 주문_생성_API_테스트() throws Exception { // given CreateOrderRequest request = new CreateOrderRequest(1L, 2); OrderDto response = OrderDto.builder().id(1L).status(\u0026#34;PENDING\u0026#34;).build(); given(orderService.createOrder(any())).willReturn(response); // when \u0026amp; then mockMvc.perform(post(\u0026#34;/api/orders\u0026#34;) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isCreated()) .andExpect(jsonPath(\u0026#34;$.id\u0026#34;).value(1L)) .andExpect(jsonPath(\u0026#34;$.status\u0026#34;).value(\u0026#34;PENDING\u0026#34;)) .andDo(print()); } @Test void Validation_실패_시_400_반환() throws Exception { CreateOrderRequest invalidRequest = new CreateOrderRequest(null, 2); mockMvc.perform(post(\u0026#34;/api/orders\u0026#34;) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(invalidRequest))) .andExpect(status().isBadRequest()); } } Spring Security가 설정된 프로젝트에서는 인증 설정이 필요하다.\n@WebMvcTest(OrderController.class) @WithMockUser(roles = \u0026#34;USER\u0026#34;) // 인증된 사용자로 테스트 class OrderControllerTest { ... } @MockBean vs @Mock의 차이: @MockBean은 Spring Context에 Mock을 올린다(슬라이스 테스트에서 사용). @Mock은 Spring 없이 Mockito만으로 사용하는 것이다.\n@DataJpaTest — JPA 레이어만 테스트 JPA 관련 Bean만 로드한다. 기본적으로 인메모리 H2 DB를 사용하고, 각 테스트 후 자동 롤백한다.\n@DataJpaTest class OrderRepositoryTest { @Autowired private OrderRepository orderRepository; @Autowired private TestEntityManager em; @Test void 주문_저장_및_조회() { // given Member member = em.persist(Member.builder().email(\u0026#34;test@test.com\u0026#34;).build()); Order order = Order.builder().member(member).status(OrderStatus.PENDING).build(); // when Order saved = orderRepository.save(order); em.flush(); // 1차 캐시 → DB 즉시 반영 em.clear(); // 1차 캐시 비우기 → 실제 DB 조회 강제 // then Order found = orderRepository.findById(saved.getId()).get(); assertThat(found.getStatus()).isEqualTo(OrderStatus.PENDING); } } em.flush() + em.clear() 패턴이 중요하다. flush 없이 findById를 하면 1차 캐시에서 반환되어 DB에 실제로 저장됐는지 확인할 수 없다. flush로 DB에 반영하고 clear로 캐시를 비운 뒤 다시 조회해야 한다.\n실제 DB로 테스트하려면:\n@DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @ActiveProfiles(\u0026#34;test\u0026#34;) class OrderRepositoryTest { ... } @SpringBootTest — 통합 테스트 전체 Spring Context를 로드한다. 실제 DB와 함께 통합 테스트에 사용한다.\n@SpringBootTest @Transactional // 테스트 후 자동 롤백 class OrderServiceIntegrationTest { @Autowired private OrderService orderService; @Autowired private OrderRepository orderRepository; @Test void 주문_생성_통합_테스트() { CreateOrderRequest request = new CreateOrderRequest(1L, 2); OrderDto result = orderService.createOrder(request); Order savedOrder = orderRepository.findById(result.getId()).get(); assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.PENDING); } } MockMvc와 함께 전체 API 플로우를 테스트할 수 있다.\n@SpringBootTest @AutoConfigureMockMvc class OrderApiIntegrationTest { @Autowired private MockMvc mockMvc; @Test void 전체_주문_플로우_테스트() throws Exception { mockMvc.perform(post(\u0026#34;/api/orders\u0026#34;) .header(\u0026#34;Authorization\u0026#34;, \u0026#34;Bearer \u0026#34; + getToken()) .contentType(MediaType.APPLICATION_JSON) .content(requestJson)) .andExpect(status().isCreated()); } } 4가지 비교 구분 속도 범위 목적 JUnit5 + Mockito 가장 빠름 클래스 단위 비즈니스 로직 @WebMvcTest 빠름 Controller 레이어 API 계약, Validation, 상태코드 @DataJpaTest 빠름 JPA 레이어 쿼리 메서드, 커스텀 쿼리 @SpringBootTest 느림 전체 컨텍스트 E2E 플로우, 통합 검증 자주 쓰는 AssertJ assertThat(result).isEqualTo(expected); assertThat(list).hasSize(3).contains(element); assertThat(list).isEmpty(); assertThatThrownBy(() -\u0026gt; service.method()) .isInstanceOf(SomeException.class) .hasMessageContaining(\u0026#34;에러 메시지\u0026#34;); assertThat(order) .extracting(\u0026#34;status\u0026#34;, \u0026#34;amount\u0026#34;) .containsExactly(OrderStatus.PENDING, 10000); 마치며 테스트는 단계별로 목적에 맞게 작성하는 것이 효율적이다. 비즈니스 로직은 빠른 단위 테스트로, Controller 계층은 @WebMvcTest로, JPA 쿼리는 @DataJpaTest로 검증한다. @SpringBootTest는 전체 플로우 통합 검증에만 사용한다. 이렇게 하면 테스트 속도를 유지하면서 신뢰성 있는 테스트 스위트를 구성할 수 있다.\n","date":"2026-05-19T00:00:00Z","permalink":"/tech/2026-05-19-spring-11-testing/","title":"[Spring 완전 정복 #11] Spring 테스트 전략 — 단위·슬라이스·통합 테스트를 언제 어떻게 쓸까"},{"content":"이메일 발송을 기다려야 할까? 주문 완료 후 이메일 알림을 보내는 상황을 생각해보자. 이메일 발송 API가 외부 서비스를 호출하고 2초가 걸린다면, 사용자는 주문 완료 응답을 받기까지 2초를 기다려야 할까?\n주문 저장과 이메일 발송은 독립적인 작업이다. 이메일이 성공했는지 실패했는지를 주문 API 응답에 포함할 필요가 없다면 비동기로 처리하는 것이 맞다.\n@Async는 메서드를 별도 스레드 풀에서 비동기 실행한다. 호출자는 메서드 완료를 기다리지 않고 즉시 반환받는다.\n기본 설정 @Configuration @EnableAsync // @Async 활성화 public class AsyncConfig { @Bean(name = \u0026#34;mailExecutor\u0026#34;) public TaskExecutor mailExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); // 항상 유지할 스레드 수 executor.setMaxPoolSize(20); // 최대 스레드 수 executor.setQueueCapacity(100); // 큐 대기 용량 executor.setThreadNamePrefix(\u0026#34;mail-async-\u0026#34;); executor.setRejectedExecutionHandler( new ThreadPoolExecutor.CallerRunsPolicy()); // 큐 꽉 차면 호출 스레드가 직접 실행 executor.initialize(); return executor; } } 스레드 풀 동작 방식:\n1. 스레드 수 \u0026lt; CorePoolSize → 새 스레드 생성 2. 스레드 수 \u0026gt;= CorePoolSize → 큐에 대기 3. 큐가 꽉 참 → MaxPoolSize까지 스레드 추가 생성 4. MaxPoolSize도 꽉 참 → RejectedExecutionHandler 실행 @Async 기본 사용 @Service @RequiredArgsConstructor public class NotificationService { @Async(\u0026#34;mailExecutor\u0026#34;) // 지정한 스레드 풀 사용 public void sendWelcomeEmail(String email) { // 이 메서드는 별도 스레드에서 실행 // 호출자는 즉시 반환받음 emailClient.send(email, \u0026#34;환영합니다!\u0026#34;); } } @Service public class UserService { public void registerUser(UserRequest request) { User user = userRepository.save(new User(request)); notificationService.sendWelcomeEmail(user.getEmail()); // 기다리지 않음 return; // 이메일 발송 완료 안 돼도 여기 도달 } } CompletableFuture — 비동기 결과 조합 대시보드처럼 여러 데이터를 합쳐서 보여줘야 할 때, 각 조회를 병렬로 실행하면 시간을 줄일 수 있다.\n@Service public class DashboardService { @Async public CompletableFuture\u0026lt;OrderStats\u0026gt; getOrderStats(Long userId) { return CompletableFuture.completedFuture(orderService.getStats(userId)); } @Async public CompletableFuture\u0026lt;PaymentStats\u0026gt; getPaymentStats(Long userId) { return CompletableFuture.completedFuture(paymentService.getStats(userId)); } public DashboardDto getDashboard(Long userId) throws Exception { CompletableFuture\u0026lt;OrderStats\u0026gt; orderFuture = getOrderStats(userId); CompletableFuture\u0026lt;PaymentStats\u0026gt; paymentFuture = getPaymentStats(userId); // 둘 다 완료될 때까지 대기 CompletableFuture.allOf(orderFuture, paymentFuture).join(); return DashboardDto.of(orderFuture.get(), paymentFuture.get()); // 두 조회가 각각 1초씩 걸린다면: 순차 2초 → 병렬 ~1초 } } 주의사항 3가지 1. 자기 호출 — @Transactional과 같은 함정 @Async도 AOP 프록시로 동작한다. 같은 클래스 안에서 직접 호출하면 프록시를 거치지 않아 동기로 실행된다.\n@Service public class OrderService { public void createOrder(OrderRequest request) { sendNotification(request); // ❌ this.sendNotification() → 동기 실행 } @Async public void sendNotification(OrderRequest request) { ... } } 해결책은 sendNotification()을 별도 Bean(NotificationService)으로 분리하는 것이다.\n2. 예외 처리 — 호출자에게 전파 안 됨 반환값이 없는 @Async 메서드에서 예외가 발생해도 호출자에게 전파되지 않는다. AsyncUncaughtExceptionHandler로 처리해야 한다.\n@Configuration @EnableAsync public class AsyncConfig implements AsyncConfigurer { @Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return (ex, method, params) -\u0026gt; { log.error(\u0026#34;@Async 예외: method={}\u0026#34;, method.getName(), ex); alertService.sendAlert(\u0026#34;비동기 작업 실패: \u0026#34; + method.getName()); }; } } 3. ThreadLocal 전파 안 됨 메인 스레드의 SecurityContext나 ThreadLocal 값이 @Async 스레드에 자동으로 전파되지 않는다.\n@Async public void asyncMethod() { SecurityContextHolder.getContext().getAuthentication(); // null 가능 } 보안 컨텍스트가 필요하다면 스레드 풀에 DelegatingSecurityContextTaskDecorator를 설정해야 한다.\n언제 쓸까 ✅ 적합한 경우 - 이메일/SMS/푸시 알림 발송 - 감사 로그 기록 - 캐시 비동기 갱신 - 외부 API 호출 (응답 즉시 반환 불필요) ❌ 적합하지 않은 경우 - 결과를 즉시 응답에 포함해야 할 때 - 트랜잭션을 호출자와 공유해야 할 때 - 실패 시 호출자가 반드시 알아야 할 때 @Async와 @Transactional을 같이 쓰면 비동기 스레드는 호출자의 트랜잭션을 이어받지 않는다. 새로운 독립 트랜잭션으로 실행된다.\n마치며 @Async는 \u0026ldquo;결과를 기다릴 필요 없는 부가 작업\u0026quot;에 적합하다. 동작 원리는 AOP 프록시이므로 @Transactional과 같은 자기 호출 함정이 있다. 예외는 AsyncUncaughtExceptionHandler로 처리하고, ThreadLocal 전파가 필요하면 별도 설정이 필요하다.\n다음 편에서는 Spring Cache — @Cacheable과 Redis 캐시 전략을 정리한다.\n","date":"2026-05-19T00:00:00Z","permalink":"/tech/2026-05-19-spring-12-async/","title":"[Spring 완전 정복 #12] Spring @Async — 비동기 처리와 CompletableFuture 병렬 실행"},{"content":"같은 데이터를 매번 DB에서 가져와야 할까? 자주 조회되지만 잘 바뀌지 않는 데이터가 있다. 상품 카탈로그, 카테고리 목록, 설정값 같은 것들이다. 이런 데이터를 요청마다 DB에서 가져오는 것은 비효율적이다.\n캐시는 이 데이터를 메모리에 저장해두고, 다음 요청부터는 DB를 거치지 않고 메모리에서 반환한다. Spring Cache 추상화는 @Cacheable 어노테이션 하나로 이 동작을 구현한다.\n@Cacheable — 캐시 조회 → 없으면 실행 → 저장 @Service public class ProductService { @Cacheable(value = \u0026#34;products\u0026#34;, key = \u0026#34;#productId\u0026#34;) public ProductDto getProduct(Long productId) { // 캐시에 없을 때만 실행 (Cache Miss) return productRepository.findById(productId) .map(ProductDto::from) .orElseThrow(() -\u0026gt; new ProductNotFoundException(productId)); } } @Configuration @EnableCaching // 캐시 활성화 public class CacheConfig { } 동작 흐름:\n[첫 번째 호출] → 캐시 조회 → Miss → 메서드 실행 → 결과를 캐시에 저장 → 반환 [두 번째 이후] → 캐시 조회 → Hit → 메서드 실행 없이 캐시 반환 키를 복합적으로 구성할 수 있다.\n@Cacheable(value = \u0026#34;products\u0026#34;, key = \u0026#34;#category + \u0026#39;:\u0026#39; + #page\u0026#34;) public List\u0026lt;ProductDto\u0026gt; getProductsByCategory(String category, int page) { ... } @CacheEvict — 데이터 수정 시 캐시 삭제 @CacheEvict(value = \u0026#34;products\u0026#34;, key = \u0026#34;#productId\u0026#34;) public void updateProduct(Long productId, ProductUpdateRequest request) { // 메서드 실행 후 캐시 삭제 (기본값) // 다음 조회 시 DB에서 새 데이터를 가져와 캐시에 저장 } // 전체 삭제 @CacheEvict(value = \u0026#34;products\u0026#34;, allEntries = true) public void clearAllCache() { } @CachePut — 항상 실행 + 캐시 갱신 @CachePut(value = \u0026#34;products\u0026#34;, key = \u0026#34;#result.id\u0026#34;) public ProductDto createProduct(ProductCreateRequest request) { Product saved = productRepository.save(new Product(request)); return ProductDto.from(saved); // 반환값이 캐시에 저장됨 } @Cacheable은 캐시에 있으면 실행을 건너뛰지만, @CachePut은 항상 실행하고 결과로 캐시를 갱신한다.\n로컬 캐시에서 Redis로 교체 Spring Cache는 추상화 레이어라 구현체를 쉽게 교체할 수 있다. 코드 변경 없이 CacheManager만 바꾸면 된다.\n로컬 캐시 (기본) 기본값은 ConcurrentMapCacheManager다. 별도 설정 없이 동작하지만 서버가 여러 대면 서버마다 다른 캐시를 갖게 되어 데이터 불일치가 생긴다.\nRedis 캐시 (다중 서버 환경) \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-data-redis\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; spring: cache: type: redis redis: time-to-live: 3600000 # 기본 TTL: 1시간 캐시별로 TTL을 다르게 설정하려면:\n@Bean public RedisCacheManager cacheManager(RedisConnectionFactory factory) { Map\u0026lt;String, RedisCacheConfiguration\u0026gt; configs = new HashMap\u0026lt;\u0026gt;(); configs.put(\u0026#34;products\u0026#34;, RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(30)) .serializeValuesWith( RedisSerializationContext.SerializationPair .fromSerializer(new GenericJackson2JsonRedisSerializer()))); configs.put(\u0026#34;users\u0026#34;, RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofHours(1))); return RedisCacheManager.builder(factory) .withInitialCacheConfigurations(configs) .build(); } 캐시 전략 Cache-Aside (Lazy Loading) — 가장 일반적 읽기: 캐시 → Miss면 DB 조회 → 캐시 저장 → 반환 쓰기: DB 업데이트 → 캐시 삭제 (다음 읽기 때 재적재) Spring의 @Cacheable + @CacheEvict 패턴이 이것이다.\nWrite-Through 쓰기: DB 업데이트 + 캐시 동시 업데이트 @CachePut 패턴이다. 읽기가 항상 캐시에서 이루어져 빠르지만, 잘 쓰지 않는 데이터도 캐시에 올라가 공간을 낭비할 수 있다.\n주의사항 3가지 1. 캐시 스탬피드 캐시가 만료되는 순간 대량의 요청이 동시에 DB로 몰리는 현상이다. TTL에 랜덤 jitter를 추가하거나 분산 락으로 하나의 요청만 DB를 조회하도록 처리한다.\n2. 캐시 무효화 누락 @Transactional @CacheEvict(value = \u0026#34;products\u0026#34;, key = \u0026#34;#id\u0026#34;) // 잊으면 안 됨 public void updateProduct(Long id, ProductUpdateRequest request) { productRepository.save(product.update(request)); } 데이터 수정 후 @CacheEvict를 빠트리면 수정 전 데이터가 캐시에 남아 서비스한다.\n3. 자기 호출 @Cacheable도 AOP 프록시로 동작한다. @Transactional, @Async와 동일하게 같은 클래스 내부에서 직접 호출하면 캐시가 적용되지 않는다.\nRedis에 저장하는 DTO는 직렬화 가능해야 한다. Jackson JSON 직렬화 시 기본 생성자가 필요하다.\n마치며 Spring Cache의 핵심은 구현체 독립성이다. 로컬 캐시에서 Redis로 교체할 때 @Cacheable 코드는 전혀 바꾸지 않아도 된다. @Cacheable(조회 캐시), @CacheEvict(삭제), @CachePut(갱신) 세 가지 어노테이션을 목적에 맞게 조합하고, 다중 서버 환경에서는 반드시 Redis 같은 분산 캐시를 사용해야 한다.\n다음 편에서는 Spring Scheduling — @Scheduled와 다중 서버 환경에서의 중복 실행 방지를 정리한다.\n","date":"2026-05-19T00:00:00Z","permalink":"/tech/2026-05-19-spring-13-cache/","title":"[Spring 완전 정복 #13] Spring Cache — @Cacheable로 DB 부하 줄이기, Redis 캐시 전략"},{"content":"매일 새벽 2시에 배치를 실행하려면 통계 집계, 만료 데이터 정리, 리포트 생성 같은 작업은 주기적으로 실행해야 한다. Spring @Scheduled는 메서드에 어노테이션 하나만 붙여서 이런 작업을 등록할 수 있다.\n단 하나의 함정이 있다. 서버가 여러 대라면 모든 서버가 동시에 실행한다.\n기본 설정 @Configuration @EnableScheduling // 스케줄링 활성화 public class SchedulingConfig { // 기본값은 단일 스레드 — 독립 실행이 필요하면 풀 설정 @Bean public TaskScheduler taskScheduler() { ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); scheduler.setPoolSize(5); scheduler.setThreadNamePrefix(\u0026#34;scheduled-\u0026#34;); scheduler.initialize(); return scheduler; } } 기본값이 단일 스레드라는 점을 기억해두자. 스케줄 메서드 A가 10초 걸리면 B는 A가 끝날 때까지 대기한다. 독립적으로 실행돼야 한다면 위처럼 스레드 풀을 설정해야 한다.\n세 가지 실행 방식 fixedRate — 시작 시점 기준 @Scheduled(fixedRate = 5000) // 5초마다 (이전 실행 시작으로부터) public void fixedRateTask() { ... } 실행 시작: 0초 실행 완료: 3초 다음 시작: 5초 (0초 + 5초) 다음 시작: 10초 (5초 + 5초) 실행 시간이 주기를 초과하면 다음 실행이 겹칠 수 있다(스레드 풀이 있을 때).\nfixedDelay — 완료 시점 기준 @Scheduled(fixedDelay = 5000) // 이전 실행 완료 후 5초 public void fixedDelayTask() { ... } 실행 시작: 0초 실행 완료: 3초 다음 시작: 8초 (3초 완료 + 5초) 실행이 아무리 오래 걸려도 완료 후 5초 뒤에 시작한다. 겹칠 위험이 없다.\ncron — 특정 시각에 실행 @Scheduled(cron = \u0026#34;0 0 2 * * *\u0026#34;) // 매일 새벽 2시 public void dailyBatchJob() { ... } @Scheduled(cron = \u0026#34;0 */30 9-18 * * MON-FRI\u0026#34;) // 평일 9~18시 30분마다 public void businessHoursTask() { ... } 크론 표현식 구조 초 분 시 일 월 요일 0 0 2 * * * → 매일 02:00:00 자주 쓰는 표현:\n\u0026#34;0 0 * * * *\u0026#34; // 매 시간 정각 \u0026#34;0 0 0 * * *\u0026#34; // 매일 자정 \u0026#34;0 0 0 1 * *\u0026#34; // 매월 1일 자정 \u0026#34;0 0 9 * * MON-FRI\u0026#34; // 평일 오전 9시 \u0026#34;0 */5 * * * *\u0026#34; // 5분마다 환경별 cron 분리 로컬에서는 빠르게 테스트하고, 운영에서는 실제 스케줄을 적용한다.\n# application.yml schedule: daily-batch: cron: \u0026#34;0 0 2 * * *\u0026#34; # application-local.yml schedule: daily-batch: cron: \u0026#34;0 */1 * * * *\u0026#34; # 1분마다 테스트 @Scheduled(cron = \u0026#34;${schedule.daily-batch.cron}\u0026#34;) public void dailyBatch() { ... } 다중 서버 환경의 중복 실행 문제 서버가 3대면 새벽 2시에 배치가 3번 실행된다.\n서버 A ─┐ 서버 B ─┼─ 모두 02:00에 dailyBatch() 실행 → 데이터 3번 처리 서버 C ─┘ 해결: ShedLock DB나 Redis를 락 저장소로 사용해 단 하나의 서버만 실행을 보장한다.\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;net.javacrumbs.shedlock\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;shedlock-spring\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;net.javacrumbs.shedlock\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;shedlock-provider-jdbc-template\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; -- 락 테이블 (MySQL) CREATE TABLE shedlock ( name VARCHAR(64) NOT NULL, lock_until TIMESTAMP(3) NOT NULL, locked_at TIMESTAMP(3) NOT NULL, locked_by VARCHAR(255) NOT NULL, PRIMARY KEY (name) ); @Configuration @EnableScheduling @EnableSchedulerLock(defaultLockAtMostFor = \u0026#34;10m\u0026#34;) public class SchedulingConfig { @Bean public LockProvider lockProvider(DataSource dataSource) { return new JdbcTemplateLockProvider(dataSource); } } @Service public class BatchJobService { @Scheduled(cron = \u0026#34;0 0 2 * * *\u0026#34;) @SchedulerLock( name = \u0026#34;dailyBatchJob\u0026#34;, // 유니크한 락 이름 lockAtLeastFor = \u0026#34;5m\u0026#34;, // 빠르게 끝나도 5분간 락 유지 (중복 방지) lockAtMostFor = \u0026#34;30m\u0026#34; // 서버가 죽어도 30분 후 자동 해제 ) public void dailyBatchJob() { // 이 서버만 실행 — 다른 서버는 락을 잡지 못해 건너뜀 processOrders(); } } lockAtMostFor이 중요하다. 서버가 배치 실행 중에 죽으면 DB의 락 레코드가 남아있게 된다. 이 설정이 있어야 일정 시간 후 락이 자동으로 해제된다.\n주의사항 예외는 반드시 잡는다 @Scheduled(cron = \u0026#34;0 0 2 * * *\u0026#34;) public void dailyBatch() { try { processBatch(); } catch (Exception e) { log.error(\u0026#34;배치 실패\u0026#34;, e); alertService.sendAlert(\u0026#34;dailyBatch 실패: \u0026#34; + e.getMessage()); } } 예외가 전파되면 다음 실행이 안 될 수 있다. 항상 try-catch로 잡고 알림을 보내는 것이 좋다.\n트랜잭션은 서비스에 위임 @Scheduled(cron = \u0026#34;0 0 2 * * *\u0026#34;) public void dailyBatch() { batchService.processDailyOrders(); // @Transactional이 있는 서비스에 위임 } @Scheduled는 트랜잭션을 자동으로 걸지 않는다. 필요하면 @Transactional을 추가하거나 서비스 레이어에 위임한다.\n마치며 단일 서버라면 @Scheduled만으로 충분하다. 다중 서버 환경이라면 중복 실행 방지가 필수다. ShedLock이 가장 간단한 해결책이다. DB에 락 레코드를 남기는 방식이라 별도 인프라 없이 사용할 수 있다. 복잡한 잡 관리(의존성, 재시도, 히스토리)가 필요하다면 Quartz를 고려한다.\nSpring 시리즈 마지막 편이다. Servlet 기초부터 스케줄링까지 14편을 통해 Spring의 핵심 구조를 정리했다.\n","date":"2026-05-19T00:00:00Z","permalink":"/tech/2026-05-19-spring-14-scheduling/","title":"[Spring 완전 정복 #14] Spring @Scheduled — cron 표현식과 다중 서버 중복 실행 방지"},{"content":"\u0026ldquo;@Service만 붙이면 왜 되는 걸까?\u0026rdquo; Spring을 처음 쓸 때 누구나 한 번쯤 갖는 의문이다. 클래스에 어노테이션 하나 달았을 뿐인데 의존성이 주입되고, @Transactional만 붙이면 트랜잭션이 처리된다.\n이게 \u0026ldquo;마법\u0026quot;처럼 느껴지는 건, 내부 동작을 모르기 때문이다. 이 글에서는 Spring의 핵심 메커니즘 세 가지 — IoC, DI, AOP — 를 코드 수준에서 풀어낸다.\n어노테이션은 라벨일 뿐이다 먼저 오해를 하나 짚고 시작한다. @Service, @Autowired 같은 어노테이션은 그 자체로 아무 일도 하지 않는다.\nJava 어노테이션은 코드에 붙이는 메타데이터다. Spring Container가 없다면 @Service를 붙여도 그냥 라벨이 붙은 일반 클래스일 뿐이다.\nSpring이 하는 일은 애플리케이션 시작 시 리플렉션으로 클래스를 스캔해서 어노테이션을 읽고, 그에 따라 동작을 결정하는 것이다.\n애플리케이션 시작 → @ComponentScan이 지정 패키지 하위를 전부 탐색 → 각 클래스를 리플렉션으로 읽음 → @Service/@Repository 발견 → \u0026#34;Bean으로 등록\u0026#34; → @Autowired 발견 → \u0026#34;등록된 Bean 중 타입 맞는 것을 주입\u0026#34; → @Transactional 발견 → \u0026#34;CGLIB 프록시로 감싸기\u0026#34; Spring이 이 과정을 대신 처리해준다. 개발자가 직접 구현하면 아래처럼 된다.\n// Spring 내부에서 실제로 일어나는 일 (단순화) for (Class\u0026lt;?\u0026gt; clazz : scannedClasses) { if (clazz.isAnnotationPresent(Component.class)) { Object instance = clazz.getDeclaredConstructor().newInstance(); beanContainer.register(clazz, instance); } } 이걸 자동으로 해주는 것이 Spring의 핵심이다.\nIoC — 제어권을 Spring에게 넘기다 전통적인 방식에서는 개발자가 객체를 직접 생성하고 의존성을 연결한다.\npublic class OrderService { // 직접 생성 — 강한 결합 private PaymentService paymentService = new PaymentService(); } 이 코드의 문제는 OrderService가 PaymentService의 구체적인 구현에 묶여있다는 점이다. PaymentService 구현을 KakaopayService로 바꾸려면 OrderService도 수정해야 한다.\n**IoC(Inversion of Control, 제어의 역전)**는 이 제어권을 개발자에서 Spring Container로 넘기는 것이다.\n전통적: 개발자 → 객체 생성 → 의존성 연결 IoC: Spring Container → 객체 생성 → 의존성 연결 → 개발자에게 제공 개발자는 \u0026ldquo;무엇이 필요한지\u0026quot;만 선언하면 된다. \u0026ldquo;어떻게 만들지\u0026quot;는 Spring이 결정한다.\nDI — IoC를 구현하는 방법 DI(Dependency Injection, 의존성 주입)는 IoC를 실현하는 구체적인 방법이다. Spring이 의존성을 주입하는 방식은 세 가지가 있다.\n생성자 주입 (권장) @Service public class OrderService { private final PaymentService paymentService; @Autowired // 생성자가 1개면 생략 가능 public OrderService(PaymentService paymentService) { this.paymentService = paymentService; } } 필드 주입 (비권장) @Service public class OrderService { @Autowired private PaymentService paymentService; // 테스트 어려움 } 생성자 주입을 쓰는 이유가 있다.\n이유 설명 불변성 final 선언 가능 — 런타임 중 변경 불가 필수 의존성 명시 컴파일 시점에 의존성 누락 감지 테스트 용이 new OrderService(mockPaymentService)로 직접 주입 가능 순환 의존성 감지 앱 시작 시 즉시 오류 발생 필드 주입의 치명적 단점은 테스트다. Spring Container 없이 new OrderService()를 하면 paymentService가 null이다. 반면 생성자 주입은 테스트 코드에서도 의존성을 명확히 넘길 수 있다.\nBean — Spring이 관리하는 객체 Bean은 Spring Container가 생성하고 관리하는 객체다. 개발자가 new로 직접 생성하지 않는다.\n등록 방법 컴포넌트 스캔 — 클래스에 어노테이션을 붙이면 자동 등록된다.\n@Component // 일반 컴포넌트 @Service // 서비스 레이어 @Repository // 데이터 접근 레이어 (예외 변환 기능 추가) @Controller // Spring MVC 컨트롤러 자바 설정 — 외부 라이브러리처럼 소스코드를 수정할 수 없을 때 사용한다.\n@Configuration public class AppConfig { @Bean public PaymentService paymentService() { return new KakaopayService(); // 구현체를 여기서 결정 } } 같은 타입 Bean이 여러 개일 때 PaymentService 구현체가 KakaopayService, NaverPayService 두 개라면 @Autowired만으로는 어떤 걸 주입할지 알 수 없다.\n// @Primary — 기본 Bean 지정 @Component @Primary public class KakaopayService implements PaymentService { ... } // @Qualifier — 이름으로 명시적 선택 @Autowired @Qualifier(\u0026#34;naverpay\u0026#34;) private PaymentService paymentService; Singleton Bean의 함정 Spring Bean의 기본 스코프는 Singleton이다. Container당 인스턴스가 1개이고, 모든 요청에서 이 인스턴스를 공유한다.\n@Service public class OrderService { // 위험! 여러 스레드가 이 변수를 동시에 읽고 씀 private int orderCount = 0; public void createOrder() { orderCount++; // 스레드 안전하지 않음 } } Singleton Bean은 **무상태(stateless)**로 설계해야 한다. 인스턴스 변수에 요청별 데이터를 저장하면 안 된다.\nAOP — 비즈니스 로직에서 공통 코드를 분리하다 로깅, 트랜잭션, 인증 체크 같은 코드는 특성이 있다. 비즈니스 로직과 무관하지만 여러 곳에 반복해서 등장한다.\n// AOP 없이 — 모든 메서드마다 반복 public Order createOrder(...) { log.info(\u0026#34;createOrder 시작\u0026#34;); // 로깅 checkAuth(); // 인증 startTransaction(); // 트랜잭션 Order order = doCreateOrder(); // 실제 비즈니스 로직 commitTransaction(); log.info(\u0026#34;createOrder 완료\u0026#34;); return order; } AOP는 이 **횡단 관심사(Cross-cutting Concerns)**를 별도 모듈로 분리해서, 비즈니스 코드가 핵심 로직에만 집중하게 한다.\n프록시가 핵심이다 Spring AOP는 프록시 패턴으로 동작한다. 실제 Bean을 직접 주입하는 대신, Bean을 감싼 프록시 객체를 주입한다. 메서드 호출이 들어오면 프록시가 먼저 받아서 부가 로직을 실행하고, 실제 Bean으로 넘긴다.\n호출자 → [프록시] → 실제 Bean ↑ Advice(부가 로직) 실행 @Aspect @Component public class LoggingAspect { // service 패키지의 모든 메서드 실행 전후 @Around(\u0026#34;execution(* com.example.service.*.*(..))\u0026#34;) public Object around(ProceedingJoinPoint pjp) throws Throwable { long start = System.currentTimeMillis(); Object result = pjp.proceed(); // 실제 메서드 실행 log.info(\u0026#34;{} — {}ms\u0026#34;, pjp.getSignature().getName(), System.currentTimeMillis() - start); return result; } } @Transactional도 AOP다 @Transactional이 붙은 Bean에는 CGLIB 프록시가 주입된다. 트랜잭션 시작/커밋/롤백은 프록시가 처리하고, 실제 메서드는 비즈니스 로직만 담는다.\n@Service public class OrderService { @Transactional public Order createOrder(...) { // 트랜잭션 begin → 프록시가 처리 Order order = orderRepository.save(...); return order; // 트랜잭션 commit → 프록시가 처리 } } 여기서 하나의 함정이 있다. 같은 클래스 내부에서 @Transactional 메서드를 직접 호출하면 트랜잭션이 적용되지 않는다.\n@Service public class OrderService { public void process() { createOrder(); // 프록시를 거치지 않음 → 트랜잭션 미적용! } @Transactional public void createOrder() { ... } } 외부에서 호출할 때만 프록시를 거치기 때문이다. 내부 호출은 this.createOrder()와 같아서 프록시를 건너뛴다.\n마치며 Spring의 핵심은 결국 세 가지다.\nIoC: 객체 생성·의존성 연결의 제어권을 Spring에게 넘긴다. DI: Spring이 필요한 의존성을 주입해준다. 생성자 주입을 쓰자. AOP: 횡단 관심사를 프록시로 분리한다. @Transactional이 대표적이다. 이 구조를 이해하면 @Transactional 자기 호출 버그나 Singleton Bean 상태 문제 같은 Spring의 흔한 함정을 미리 피할 수 있다.\n다음 편에서는 Spring MVC — DispatcherServlet 내부 구조와 요청 처리 흐름을 정리한다.\n","date":"2026-05-19T00:00:00Z","permalink":"/tech/2026-05-19-spring-02-core-ioc-di-aop/","title":"[Spring 완전 정복 #2] Spring Core — IoC, DI, AOP를 코드로 이해하기"},{"content":"모든 요청이 거치는 하나의 관문 Spring MVC 애플리케이션에서 HTTP 요청은 예외 없이 DispatcherServlet을 먼저 통과한다. URL이 /users든 /orders든 상관없다. 이 \u0026ldquo;하나의 관문\u0026quot;이 무엇이고, 내부에서 무슨 일이 일어나는지 이해하면 Filter, Interceptor, AOP 중 어디서 로직을 처리해야 하는지가 자연스럽게 보인다.\nFront Controller 패턴 — 왜 DispatcherServlet이 필요한가 Spring MVC 이전에는 URL마다 Servlet을 따로 만들었다.\n/users → UserServlet /orders → OrderServlet /items → ItemServlet 이 방식의 문제는 공통 처리다. 인증 체크, 로깅, 인코딩 설정을 Servlet마다 중복해서 넣어야 했다.\nFront Controller 패턴은 모든 요청을 하나의 진입점이 받아 적절한 핸들러에 위임한다.\n모든 요청 (/*) → DispatcherServlet → Controller 위임 공통 로직을 DispatcherServlet 한 곳에서 처리하니 중복이 사라진다. Spring Boot는 이 DispatcherServlet을 자동으로 등록해 모든 URL(/)을 받도록 설정한다.\nDispatcherServlet 내부 구성요소 DispatcherServlet은 모든 일을 혼자 처리하지 않는다. 역할별 전문 컴포넌트에게 위임한다.\nDispatcherServlet ├─ HandlerMapping : \u0026#34;이 URL → 어떤 Controller?\u0026#34; ├─ HandlerAdapter : \u0026#34;이 Controller를 어떻게 실행?\u0026#34; ├─ HandlerInterceptor : Controller 실행 전·후 부가 처리 ├─ ViewResolver : \u0026#34;뷰 이름 → 실제 View 파일?\u0026#34; └─ HandlerExceptionResolver : \u0026#34;예외 발생 → 어떻게 응답?\u0026#34; HandlerMapping — @GetMapping(\u0026quot;/users/{id}\u0026quot;)처럼 선언된 매핑 정보를 읽어 요청 URL에 맞는 Controller 메서드를 찾는다.\nHandlerAdapter — 찾은 Controller 메서드를 실제로 실행한다. @RequestBody, @PathVariable, @ModelAttribute 파라미터 바인딩도 여기서 처리한다.\nViewResolver — Controller가 문자열 뷰 이름을 반환할 때 실제 파일 경로로 변환한다. @RestController를 쓰면 이 단계를 건너뛰고 HttpMessageConverter가 JSON으로 직렬화한다.\nHandlerExceptionResolver — @ControllerAdvice + @ExceptionHandler가 이 메커니즘으로 동작한다.\n@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(UserNotFoundException.class) public ResponseEntity\u0026lt;String\u0026gt; handle(UserNotFoundException e) { return ResponseEntity.status(404).body(e.getMessage()); } } 단, Filter에서 발생한 예외는 DispatcherServlet 밖이라 이 핸들러가 잡지 못한다.\n요청 처리 12단계 흐름 HTTP 요청이 응답까지 가는 전체 흐름이다.\n① HTTP 요청 → Tomcat ② Filter Chain (전처리) ③ DispatcherServlet 진입 ④ HandlerMapping → 처리할 Controller 메서드 조회 ⑤ Interceptor.preHandle() — 순서대로 실행 (false 반환 시 여기서 중단) ⑥ HandlerAdapter → Controller 메서드 실행 (AOP 프록시를 통해 Before → 메서드 → After) ⑦ ModelAndView 반환 ⑧ Interceptor.postHandle() — 역순으로 실행 ⑨ ViewResolver → View 렌더링 (REST면 생략) ⑩ Interceptor.afterCompletion() — 역순, 예외 발생해도 항상 실행 ⑪ Filter Chain (후처리, 역순) ⑫ HTTP 응답 postHandle()은 Controller에서 예외가 발생하면 실행되지 않는다. afterCompletion()은 예외 여부와 무관하게 항상 실행된다. 그래서 실행 시간 측정이나 리소스 해제는 afterCompletion()에 넣어야 한다.\nInterceptor — Spring 내부에서 요청을 제어하다 Interceptor는 DispatcherServlet 내부, Spring Context 안에서 동작한다. Spring Bean을 주입받을 수 있고, @ControllerAdvice 예외 처리도 적용된다.\n@Component public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token = request.getHeader(\u0026#34;Authorization\u0026#34;); if (token == null) { response.setStatus(401); return false; // 여기서 중단, Controller까지 가지 않음 } return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { // 요청 처리 시간 로깅 — 예외가 나도 실행됨 } } URL 패턴으로 적용 범위를 지정할 수 있다.\n@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new AuthInterceptor()) .addPathPatterns(\u0026#34;/api/**\u0026#34;) .excludePathPatterns(\u0026#34;/api/login\u0026#34;, \u0026#34;/api/signup\u0026#34;); } } Filter vs Interceptor vs AOP — 결론은 레이어 위치 이 세 가지를 구분하는 가장 명확한 기준은 어느 레이어에 위치하는가다.\n구분 위치 Spring Bean 접근 예외 처리 적합한 용도 Filter Servlet Container (Spring 밖) 제한적 직접 처리 CORS, 인코딩, XSS 방어 Interceptor Spring MVC (DispatcherServlet 내) 가능 @ControllerAdvice 로그인 체크, URL별 접근 제어 AOP Spring Bean 가능 @ControllerAdvice 트랜잭션, 실행 시간 측정, 메서드 로깅 Filter는 Spring Context가 시작되기 전에 동작하므로 Spring Bean 주입이 어렵고, 예외가 발생해도 @ControllerAdvice가 잡지 못한다. 반면 Interceptor와 AOP는 Spring 안에서 동작하므로 Bean 주입과 통합 예외 처리가 모두 가능하다.\nSpring Security의 필터체인이 Filter 레이어에 있는 것도 이 때문이다. 인증되지 않은 요청은 DispatcherServlet까지 도달하지 못하고 Filter에서 차단된다.\n입력값 검증 — Controller 진입 전 자동 검증 Spring MVC는 @Valid로 Controller 파라미터를 자동 검증한다.\npublic class CreateUserRequest { @NotBlank(message = \u0026#34;이름은 필수입니다\u0026#34;) private String name; @Email private String email; @Min(18) private int age; } @RestController public class UserController { @PostMapping(\u0026#34;/api/users\u0026#34;) public ResponseEntity\u0026lt;UserDto\u0026gt; create( @Valid @RequestBody CreateUserRequest request) { // 여기까지 오면 request는 유효한 상태 return ResponseEntity.ok(userService.create(request)); } } 검증 실패 시 MethodArgumentNotValidException이 발생한다. @ControllerAdvice에서 공통 처리하면 된다.\n@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity\u0026lt;ErrorResponse\u0026gt; handleValidation( MethodArgumentNotValidException e) { List\u0026lt;String\u0026gt; errors = e.getBindingResult().getFieldErrors().stream() .map(err -\u0026gt; err.getField() + \u0026#34;: \u0026#34; + err.getDefaultMessage()) .toList(); return ResponseEntity.badRequest().body(new ErrorResponse(\u0026#34;VALIDATION_FAILED\u0026#34;, errors)); } } 마치며 DispatcherServlet은 Spring MVC의 모든 요청 처리를 조율하는 중앙 컨트롤러다. 이 구조를 이해하면 Filter와 Interceptor의 차이, @ControllerAdvice가 Filter 예외를 잡지 못하는 이유, Interceptor 세 메서드의 실행 조건 등이 자연스럽게 설명된다.\n다음 편에서는 Spring Boot — 설정 없이 바로 실행되는 원리(자동설정, 스타터, 내장 서버)를 정리한다.\n","date":"2026-05-19T00:00:00Z","permalink":"/tech/2026-05-19-spring-03-mvc-dispatcherservlet/","title":"[Spring 완전 정복 #3] Spring MVC 내부 구조 — DispatcherServlet이 요청을 처리하는 방법"},{"content":"\u0026ldquo;왜 xml 설정 없이 서버가 뜨는 걸까?\u0026rdquo; Spring Boot를 처음 접하면 놀랍다. main() 메서드 하나, @SpringBootApplication 어노테이션 하나만 있는데 Tomcat이 뜨고, JPA 연결이 설정되고, JSON 변환까지 된다. 아무것도 설정하지 않았는데.\n이 \u0026ldquo;마법\u0026quot;의 이름은 **자동설정(Auto-configuration)**이다.\nSpring Boot 이전 — 설정 지옥 Spring 단독으로 웹 애플리케이션을 만들려면 설정 파일이 여러 개 필요했다.\nweb.xml → DispatcherServlet 등록 applicationContext.xml → Bean 설정 dispatcher-servlet.xml → MVC 설정 pom.xml → 의존성 + 버전 수동 관리 더 큰 문제는 의존성 버전이었다. Spring 버전에 맞는 Hibernate 버전, 그에 맞는 Jackson 버전\u0026hellip; 개발자가 직접 호환성을 맞춰야 했다. 버전 충돌 오류는 흔한 일이었다.\nSpring Boot는 이 \u0026ldquo;설정 지옥\u0026quot;을 없앤다.\n@SpringBootApplication 분해하기 Spring Boot 진입점에 붙이는 이 어노테이션은 실제로 세 개의 어노테이션을 합친 것이다.\n@SpringBootApplication public class MyApplication { public static void main(String[] args) { SpringApplication.run(MyApplication.class, args); } } @SpringBootApplication ├─ @SpringBootConfiguration : @Configuration과 동일 — Bean 설정 클래스 ├─ @EnableAutoConfiguration : 자동설정 활성화 ← 핵심 └─ @ComponentScan : 현재 패키지 하위 컴포넌트 자동 스캔 핵심은 @EnableAutoConfiguration이다. 이것이 \u0026ldquo;클래스패스를 보고 Bean을 자동으로 등록\u0026quot;하는 자동설정을 켠다.\n자동설정 동작 원리 Spring Boot는 애플리케이션이 시작될 때 클래스패스에 어떤 라이브러리가 있는지 확인하고, 그에 맞는 Bean을 자동으로 등록한다.\nspring-boot-starter-web 의존성 추가 → 클래스패스에 Tomcat, Spring MVC 라이브러리 존재 확인 → TomcatAutoConfiguration → 내장 Tomcat Bean 자동 등록 → DispatcherServletAutoConfiguration → DispatcherServlet Bean 자동 등록 → 개발자가 아무것도 안 해도 웹서버가 뜸 자동설정 클래스들은 어떤 조건에서 Bean을 등록할지 @Conditional 계열 어노테이션으로 선언한다.\n@Configuration @ConditionalOnClass(DataSource.class) // DataSource 클래스가 클래스패스에 있고 @ConditionalOnMissingBean(DataSource.class) // DataSource Bean이 아직 없을 때만 public class DataSourceAutoConfiguration { @Bean public DataSource dataSource() { ... } } @ConditionalOnMissingBean이 핵심이다. 개발자가 직접 Bean을 등록하면 자동설정 Bean은 등록되지 않는다. 커스텀 설정이 자동설정을 덮어쓸 수 있는 이유다.\n어노테이션 조건 @ConditionalOnClass 특정 클래스가 클래스패스에 있을 때 @ConditionalOnMissingBean 해당 타입 Bean이 없을 때 @ConditionalOnProperty 특정 프로퍼티 값이 설정되었을 때 @ConditionalOnWebApplication 웹 애플리케이션일 때 어떤 자동설정이 적용됐는지 보려면 --debug 플래그로 실행하면 된다.\n스타터 — 의존성 버전 충돌 해결 스타터는 관련 의존성을 하나의 패키지로 묶어 제공한다.\n\u0026lt;!-- 이것 하나로 Spring MVC + Tomcat + Jackson이 한 번에 --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-web\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 스타터 포함 내용 starter-web Spring MVC, Tomcat, Jackson starter-data-jpa Spring Data JPA, Hibernate, JDBC starter-security Spring Security starter-test JUnit 5, Mockito, AssertJ starter-validation Hibernate Validator, Bean Validation 버전은 spring-boot-dependencies BOM(Bill of Materials)이 관리한다. 개발자는 버전을 명시하지 않아도 Spring Boot가 검증된 호환 버전을 자동으로 선택한다.\n\u0026lt;!-- 버전 명시 불필요 --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.fasterxml.jackson.core\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;jackson-databind\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 내장 서버 — java -jar 한 줄로 실행 Spring Boot는 Tomcat을 jar 안에 내장한다. 별도 WAS 설치 없이 실행된다.\n./mvnw package java -jar target/myapp-0.0.1-SNAPSHOT.jar Tomcat 대신 다른 서버를 쓰고 싶다면 교체할 수 있다.\n\u0026lt;!-- Undertow 사용 (논블로킹, 경량) --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-web\u0026lt;/artifactId\u0026gt; \u0026lt;exclusions\u0026gt; \u0026lt;exclusion\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-tomcat\u0026lt;/artifactId\u0026gt; \u0026lt;/exclusion\u0026gt; \u0026lt;/exclusions\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-undertow\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 프로파일 — 환경별 설정 분리 개발, 스테이징, 운영 환경마다 DB URL, 로그 레벨, 외부 API 키가 다르다. 프로파일로 분리한다.\napplication.yml # 공통 설정 application-dev.yml # 개발 환경 application-prod.yml # 운영 환경 # application-dev.yml spring: datasource: url: jdbc:h2:mem:testdb # 개발은 인메모리 DB logging: level: root: DEBUG # application-prod.yml spring: datasource: url: jdbc:postgresql://prod-db:5432/mydb logging: level: root: WARN 프로파일 활성화는 실행 시 지정한다.\njava -jar myapp.jar --spring.profiles.active=prod # 또는 환경변수로 SPRING_PROFILES_ACTIVE=prod java -jar myapp.jar 설정 우선순위 (높을수록 우선) 1. 커맨드라인 인수 --server.port=9090 2. 환경변수 SERVER_PORT=9090 3. application-{profile}.yml 4. application.yml 운영 환경에서 민감 정보(DB 패스워드, API 키)는 환경변수나 외부 설정 관리 시스템으로 주입하는 것이 안전하다.\n@ConfigurationProperties — 타입 안전한 설정 바인딩 여러 설정값을 하나의 클래스로 묶을 때 @Value보다 @ConfigurationProperties가 낫다.\npayment: api-key: abc123 timeout: 5000 retry-count: 3 @ConfigurationProperties(prefix = \u0026#34;payment\u0026#34;) @Component public class PaymentProperties { private String apiKey; private int timeout; private int retryCount; // getter/setter } @Value(\u0026quot;${payment.api-key}\u0026quot;)로 하나씩 꺼내는 것보다 타입 안전하고, IDE 자동완성도 지원된다.\nActuator — 운영 모니터링 Actuator를 추가하면 애플리케이션 상태를 HTTP 엔드포인트로 조회할 수 있다.\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-actuator\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 엔드포인트 설명 /actuator/health 앱 상태 (UP/DOWN) — 쿠버네티스 헬스체크에 활용 /actuator/metrics JVM 메모리, HTTP 요청 수, 응답 시간 등 /actuator/env 현재 적용된 설정값 (민감 정보 주의) 운영 환경에서는 health, info, metrics만 노출하고 나머지는 차단하는 것이 안전하다.\nmanagement: endpoints: web: exposure: include: health,info,metrics 마치며 Spring Boot가 \u0026ldquo;설정 없이 동작\u0026quot;하는 이유는 세 가지다.\n자동설정: 클래스패스를 보고 @Conditional 조건에 따라 Bean을 자동 등록한다. 개발자 Bean이 있으면 자동설정은 물러난다. 스타터 + BOM: 관련 의존성을 묶어 제공하고, 버전 충돌을 Spring Boot가 관리한다. 내장 서버: Tomcat이 jar 안에 들어있어 별도 WAS 설치가 필요 없다. 다음 편에서는 Spring Transaction — @Transactional의 동작 원리, 전파속성, 격리수준을 정리한다.\n","date":"2026-05-19T00:00:00Z","permalink":"/tech/2026-05-19-spring-04-boot-auto-configuration/","title":"[Spring 완전 정복 #4] Spring Boot — xml 없이 바로 뜨는 이유, 자동설정 원리 완전 분석"},{"content":"@Transactional을 붙이면 어떻게 트랜잭션이 걸리나 @Transactional public void createOrder(OrderRequest request) { orderRepository.save(new Order(request)); paymentRepository.save(new Payment(request)); } 이 어노테이션 하나로 두 save가 하나의 트랜잭션으로 묶인다. 하나라도 실패하면 둘 다 롤백된다. 어떻게?\n답은 AOP 프록시다. @Transactional이 붙은 Bean에는 실제 Bean 대신 CGLIB 프록시가 주입된다. 프록시가 메서드 호출을 가로채 트랜잭션 시작/커밋/롤백을 처리하고, 실제 메서드는 비즈니스 로직만 담는다.\n호출자 → [CGLIB 프록시] ↓ 트랜잭션 begin (Connection.setAutoCommit(false)) ↓ 실제 메서드 실행 ↓ 성공 → commit / RuntimeException → rollback 롤백 기본 규칙 — 체크 예외 함정 롤백 기본 규칙은 직관에서 벗어나는 경우가 있다.\nRuntimeException (unchecked) → 자동 롤백 ✅ Error → 자동 롤백 ✅ CheckedException (checked) → 롤백 안 함 ❌ ← 주의 IOException, SQLException 같은 체크 예외는 기본적으로 롤백되지 않는다. 초기 설계에서 체크 예외를 \u0026ldquo;복구 가능한 예외\u0026quot;로 간주했기 때문이다.\n실무에서는 명시적으로 지정하거나 런타임 예외로 전환하는 경우가 많다.\n// 모든 예외에서 롤백 @Transactional(rollbackFor = Exception.class) // 특정 예외는 롤백 제외 @Transactional(noRollbackFor = IllegalArgumentException.class) readOnly — 조회 성능을 높이는 간단한 방법 @Transactional(readOnly = true) public List\u0026lt;Order\u0026gt; getOrders() { ... } 읽기 전용 트랜잭션을 선언하면 두 가지 이점이 있다.\nJPA Dirty Checking 비활성화 — 일반 트랜잭션에서 JPA는 조회한 엔티티의 초기 상태를 스냅샷으로 저장하고, 트랜잭션 종료 시 변경 여부를 비교한다. readOnly = true면 이 스냅샷 저장 단계가 생략된다.\n읽기 전용 커넥션 라우팅 — DB 리플리케이션 환경에서 읽기 전용 커넥션을 replica로 라우팅할 수 있다.\n조회 메서드에는 습관적으로 readOnly = true를 붙이는 것이 좋다.\n전파속성 — 트랜잭션을 어떻게 이어갈까 전파속성은 @Transactional 메서드가 이미 진행 중인 트랜잭션을 어떻게 처리할지 결정한다.\n전파속성 기존 트랜잭션 있을 때 기존 트랜잭션 없을 때 REQUIRED (기본) 기존 트랜잭션에 합류 새 트랜잭션 생성 REQUIRES_NEW 기존 일시 중단, 새 트랜잭션 생성 새 트랜잭션 생성 NESTED 중첩 트랜잭션 (savepoint) 새 트랜잭션 생성 REQUIRED vs REQUIRES_NEW — 언제 쓸까 가장 중요한 비교다.\n@Service public class OrderService { @Transactional // REQUIRED (기본) public void createOrder() { orderRepository.save(...); notificationService.sendEmail(); // 여기서 예외 → order도 롤백 } } @Service public class NotificationService { @Transactional(propagation = Propagation.REQUIRES_NEW) public void sendEmail() { // 독립 트랜잭션 — 여기서 예외가 나도 createOrder는 영향 없음 emailLogRepository.save(...); } } REQUIRED: 하나의 트랜잭션으로 묶인다. 어디서 예외가 나든 전체 롤백. \u0026ldquo;주문 저장 + 이메일 전송이 함께 성공해야 할 때\u0026rdquo; 사용.\nREQUIRES_NEW: 완전히 독립된 트랜잭션. \u0026ldquo;이메일 전송 실패해도 주문은 저장되어야 할 때\u0026rdquo; 사용.\nNESTED vs REQUIRES_NEW 둘 다 \u0026ldquo;부분 롤백\u0026quot;처럼 보이지만 차이가 있다.\nREQUIRES_NEW: 부모 트랜잭션과 완전히 독립. 부모가 롤백돼도 이미 커밋된 자식은 유지된다.\nNESTED: 부모 트랜잭션 안에서 savepoint를 생성. 자식만 롤백 가능하지만, 부모가 롤백되면 자식도 같이 롤백된다.\n격리수준 — 동시성 문제를 얼마나 막을까 여러 트랜잭션이 동시에 실행될 때 생기는 문제가 세 가지 있다.\n문제 설명 Dirty Read 커밋되지 않은 다른 트랜잭션의 데이터를 읽음 Non-repeatable Read 같은 행을 두 번 조회했는데 값이 다름 (중간에 UPDATE 발생) Phantom Read 같은 조건으로 두 번 조회했는데 행 수가 다름 (중간에 INSERT/DELETE 발생) 격리수준을 높일수록 이 문제를 방지하지만, 잠금이 늘어나 성능이 떨어진다.\n격리수준 Dirty Read Non-repeatable Read Phantom Read 성능 READ_UNCOMMITTED 발생 발생 발생 가장 빠름 READ_COMMITTED 방지 발생 발생 빠름 REPEATABLE_READ 방지 방지 발생 보통 SERIALIZABLE 방지 방지 방지 가장 느림 실무 기본값:\nMySQL InnoDB: REPEATABLE_READ (MVCC로 Phantom Read도 어느 정도 방지) PostgreSQL: READ_COMMITTED @Transactional(isolation = Isolation.READ_COMMITTED) 흔한 함정 3가지 1. 자기 호출 — 가장 자주 빠지는 함정 @Service public class OrderService { public void process() { createOrder(); // this.createOrder() — 프록시를 거치지 않음! } @Transactional public void createOrder() { ... } } process()에서 createOrder()를 직접 호출하면 프록시를 건너뛴다. @Transactional이 무시된다.\n해결책: createOrder()를 별도 클래스로 분리하거나, @Transactional을 process()에 붙인다.\n2. private 메서드에는 동작 안 함 @Transactional private void doSomething() { ... } // 동작 안 함 CGLIB 프록시는 클래스를 상속해서 메서드를 오버라이드한다. private 메서드는 오버라이드할 수 없으니 프록시가 개입할 수 없다.\n3. 전파 함정 — UnexpectedRollbackException @Transactional // REQUIRED — 새 트랜잭션 생성 public void parent() { try { child(); // 예외 catch해도 이미 늦음 } catch (Exception e) { // 여기서 잡아도 트랜잭션은 rollback-only 상태 } // → UnexpectedRollbackException 발생 } @Transactional // REQUIRED — 부모에 합류 public void child() { throw new RuntimeException(); // 부모 트랜잭션을 rollback-only로 마킹 } child()의 예외가 트랜잭션을 rollback-only로 마킹하고 나면, parent()에서 catch해도 커밋이 불가능하다. child()를 REQUIRES_NEW로 분리하거나, 예외 처리 전략을 명확히 해야 한다.\n마치며 @Transactional을 제대로 쓰려면 세 가지를 이해해야 한다.\n동작 원리: AOP 프록시. 외부 호출만 가로채므로 자기 호출, private 메서드에는 동작 안 함. 전파속성: REQUIRED는 운명 공동체, REQUIRES_NEW는 완전 독립. 이메일/알림처럼 부가 작업은 REQUIRES_NEW 고려. 격리수준: 높을수록 안전하지만 성능 저하. DB 기본값(MySQL → REPEATABLE_READ, PostgreSQL → READ_COMMITTED)을 먼저 이해하고 필요할 때만 변경. 다음 편에서는 Spring Data JPA — 영속성 컨텍스트와 N+1 문제를 정리한다.\n","date":"2026-05-19T00:00:00Z","permalink":"/tech/2026-05-19-spring-05-transaction/","title":"[Spring 완전 정복 #5] @Transactional 완전 정복 — 동작 원리, 전파속성, 격리수준"},{"content":"\u0026ldquo;JPA vs MyBatis 뭐가 더 낫나요?\u0026rdquo; 이 질문의 답을 제대로 하려면 두 기술이 각각 어떤 문제를 해결하기 위해 등장했는지 알아야 한다. 역사를 따라가면 답이 보인다.\n1단계: 순수 JDBC — 모든 것을 직접 Java에서 DB에 접근하는 가장 원시적인 방법이다.\npublic Order findById(Long id) { Connection conn = null; PreparedStatement pstmt = null; ResultSet rs = null; try { conn = DriverManager.getConnection(URL, USER, PASSWORD); pstmt = conn.prepareStatement(\u0026#34;SELECT * FROM orders WHERE id = ?\u0026#34;); pstmt.setLong(1, id); rs = pstmt.executeQuery(); if (rs.next()) { Order order = new Order(); order.setId(rs.getLong(\u0026#34;id\u0026#34;)); order.setStatus(rs.getString(\u0026#34;status\u0026#34;)); return order; } return null; } catch (SQLException e) { throw new RuntimeException(e); } finally { // 안 닫으면 커넥션 누수 — 매 메서드마다 이 코드가 반복 if (rs != null) try { rs.close(); } catch (SQLException e) {} if (pstmt != null) try { pstmt.close(); } catch (SQLException e) {} if (conn != null) try { conn.close(); } catch (SQLException e) {} } } 이 코드의 문제점이 한눈에 보인다.\n커넥션 획득 → 쿼리 실행 → 결과 매핑 → 리소스 반납 패턴이 모든 메서드에 반복된다. finally에서 리소스를 수동으로 닫아야 한다. 빠트리면 커넥션 풀 고갈. SQLException이 체크 예외라 모든 메서드가 예외를 처리하거나 throws해야 한다. 2단계: JdbcTemplate — 반복 코드 제거 Spring이 JDBC의 반복 코드를 추상화한 것이 JdbcTemplate이다.\n@Repository public class OrderRepository { private final JdbcTemplate jdbcTemplate; public Order findById(Long id) { return jdbcTemplate.queryForObject( \u0026#34;SELECT * FROM orders WHERE id = ?\u0026#34;, (rs, rowNum) -\u0026gt; { Order order = new Order(); order.setId(rs.getLong(\u0026#34;id\u0026#34;)); order.setStatus(rs.getString(\u0026#34;status\u0026#34;)); return order; }, id ); } } 커넥션 획득/반납, try-finally, SQLException 처리가 사라졌다. Spring이 내부적으로 처리해준다. SQLException은 Spring의 DataAccessException(런타임 예외)으로 변환된다.\n하지만 SQL은 여전히 직접 작성해야 하고, 결과를 객체에 매핑하는 RowMapper 코드도 직접 써야 한다.\n3단계: MyBatis — SQL을 코드에서 분리 MyBatis는 SQL을 XML 파일로 분리하고, 결과 매핑을 자동화한다.\n\u0026lt;!-- OrderMapper.xml --\u0026gt; \u0026lt;mapper namespace=\u0026#34;com.example.mapper.OrderMapper\u0026#34;\u0026gt; \u0026lt;resultMap id=\u0026#34;orderResultMap\u0026#34; type=\u0026#34;Order\u0026#34;\u0026gt; \u0026lt;id property=\u0026#34;id\u0026#34; column=\u0026#34;id\u0026#34;/\u0026gt; \u0026lt;result property=\u0026#34;status\u0026#34; column=\u0026#34;status\u0026#34;/\u0026gt; \u0026lt;result property=\u0026#34;memberId\u0026#34; column=\u0026#34;member_id\u0026#34;/\u0026gt; \u0026lt;/resultMap\u0026gt; \u0026lt;select id=\u0026#34;findById\u0026#34; resultMap=\u0026#34;orderResultMap\u0026#34;\u0026gt; SELECT id, status, member_id FROM orders WHERE id = #{id} \u0026lt;/select\u0026gt; \u0026lt;/mapper\u0026gt; @Mapper public interface OrderMapper { Order findById(Long id); // 인터페이스만 선언하면 구현체 자동 생성 } MyBatis의 진짜 강점은 동적 쿼리다.\n\u0026lt;select id=\u0026#34;findByCondition\u0026#34; resultMap=\u0026#34;orderResultMap\u0026#34;\u0026gt; SELECT * FROM orders \u0026lt;where\u0026gt; \u0026lt;if test=\u0026#34;status != null\u0026#34;\u0026gt;AND status = #{status}\u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;memberId != null\u0026#34;\u0026gt;AND member_id = #{memberId}\u0026lt;/if\u0026gt; \u0026lt;/where\u0026gt; \u0026lt;/select\u0026gt; 조건에 따라 WHERE절을 동적으로 구성하는 것이 직관적이다.\n4단계: JPA — SQL을 아예 작성하지 않는다 JPA(ORM)는 SQL을 직접 작성하지 않고, 객체 간 관계를 그대로 DB에 매핑한다.\n@Entity public class Order { @Id @GeneratedValue private Long id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = \u0026#34;member_id\u0026#34;) private Member member; } // SQL 없이 CRUD orderRepository.save(order); // INSERT 자동 orderRepository.findById(id); // SELECT 자동 order.setStatus(COMPLETED); // UPDATE 자동 (Dirty Checking) orderRepository.delete(order); // DELETE 자동 DB 벤더가 바뀌어도 코드 변경이 없다. 객체 관계를 그대로 코드로 표현할 수 있다.\n단점도 있다. 학습 곡선이 높고(영속성 컨텍스트, 지연 로딩, N+1 등), 복잡한 집계·통계 쿼리는 JPQL이나 QueryDSL이 필요하다.\n4가지 기술 한눈에 비교 기준 JDBC JdbcTemplate MyBatis JPA SQL 작성 직접 직접 직접 (XML) 자동 생성 결과 매핑 수동 RowMapper 자동 (resultMap) 자동 복잡한 쿼리 자유롭게 자유롭게 자유롭게 (동적 쿼리 강점) JPQL/QueryDSL 필요 DB 종속성 높음 높음 높음 낮음 학습 난이도 낮음 낮음 중간 높음 생산성 낮음 중간 중간 높음 (단순 CRUD) JPA vs MyBatis — 이분법이 아니다 \u0026ldquo;JPA vs MyBatis\u0026quot;를 선택의 문제로 보는 건 틀린 프레임이다. 실무에서는 함께 쓰는 경우도 많고, 상황에 따라 선택이 다르다.\nJPA가 유리한 경우: 비즈니스 로직이 복잡하고 객체 중심 설계가 중요한 서비스, 단순 CRUD가 많은 경우.\nMyBatis가 유리한 경우: 복잡한 통계·집계 쿼리가 많은 경우, DBA와 협업하며 SQL을 직접 관리해야 하는 경우, 레거시 DB 스키마에 맞춰야 하는 경우.\n실무에서 가장 많이 쓰는 조합:\n단순 CRUD → JPA Repository 복잡한 조회 → QueryDSL (타입 안전한 JPQL 빌더) 통계·집계 쿼리 → Native Query 또는 MyBatis (별도 모듈) JdbcTemplate은 여전히 쓴다 JPA 프로젝트에서도 JdbcTemplate이 필요한 상황이 있다.\n@Repository public class OrderBatchRepository { private final JdbcTemplate jdbcTemplate; // 수백만 건 상태 일괄 변경 — JPA Dirty Checking으로 하면 메모리 부족 public void bulkUpdateStatus(String oldStatus, String newStatus) { jdbcTemplate.update( \u0026#34;UPDATE orders SET status = ? WHERE status = ?\u0026#34;, newStatus, oldStatus ); } } JPA @Modifying으로 안 되는 벌크 업데이트, Spring Batch 처리, 간단한 유틸리티 쿼리 등에서 여전히 활용된다.\n마치며 각 기술의 진화 방향은 반복 코드 제거 → SQL 관리 편의 → 객체 중심 개발이었다. 이 흐름을 이해하면 \u0026ldquo;왜 JPA를 쓰는가\u0026quot;와 \u0026ldquo;언제 MyBatis가 더 나은가\u0026quot;를 맥락 있게 설명할 수 있다.\n다음 편에서는 Spring Data JPA — 영속성 컨텍스트와 N+1 문제를 자세히 정리한다.\n","date":"2026-05-19T00:00:00Z","permalink":"/tech/2026-05-19-spring-06-data-access-evolution/","title":"[Spring 완전 정복 #6] Java 데이터 접근 기술의 진화 — JDBC에서 JPA까지, 왜 바뀌었나"},{"content":"JPA 면접 = 영속성 컨텍스트 + N+1 JPA 관련 면접 질문은 결국 두 가지로 수렴한다. \u0026ldquo;영속성 컨텍스트가 무엇인지 설명해보세요\u0026quot;와 \u0026ldquo;N+1 문제가 무엇이고 어떻게 해결하나요?\u0026rdquo;\n이 두 가지를 이해하면 Dirty Checking, 지연 로딩, LazyInitializationException, N+1이 모두 연결된 하나의 그림으로 보인다.\nJPA / Hibernate / Spring Data JPA 관계 JPA : Java ORM 표준 인터페이스 (명세) Hibernate : JPA의 대표 구현체 (Spring Boot 기본) Spring Data JPA : JPA를 더 편리하게 추상화한 Spring 모듈 개발자 코드 → Spring Data JPA → JPA → Hibernate → JDBC → DB 영속성 컨텍스트 — JPA의 핵심 영속성 컨텍스트는 Entity 객체를 관리하는 1차 저장소다. 트랜잭션당 하나씩 생성된다.\nEntity 생명주기 비영속 : new Order() → 컨텍스트와 무관한 일반 객체 영속 : save() 또는 조회 후 → 컨텍스트가 관리, 변경 감지 적용 준영속 : 트랜잭션 종료 → 컨텍스트가 더 이상 관리 안 함 삭제 : delete() → DELETE 예약 1차 캐시 같은 트랜잭션 안에서 같은 Entity를 두 번 조회하면 DB를 두 번 치지 않는다.\nOrder order1 = orderRepository.findById(1L); // DB 조회 Order order2 = orderRepository.findById(1L); // 1차 캐시에서 반환 — DB 조회 없음 order1 == order2 // true — 동일 객체 보장 Dirty Checking — save() 없이 UPDATE @Transactional public void updateOrder(Long id, String newStatus) { Order order = orderRepository.findById(id).get(); // 영속 상태 order.setStatus(newStatus); // setter만 호출 // save() 없어도 트랜잭션 종료 시 UPDATE 자동 실행 } 영속성 컨텍스트는 Entity를 조회할 때 스냅샷을 저장한다. 트랜잭션 종료 시 현재 상태와 스냅샷을 비교해 변경이 있으면 UPDATE를 자동으로 실행한다. 이것이 Dirty Checking이다.\nreadOnly = true 트랜잭션에서는 스냅샷을 저장하지 않아 성능이 향상된다. 조회 메서드에 @Transactional(readOnly = true)를 붙이는 이유다.\n지연 쓰기 (Write-behind) @Transactional public void saveOrders() { orderRepository.save(order1); // INSERT 예약 orderRepository.save(order2); // INSERT 예약 orderRepository.save(order3); // INSERT 예약 // 트랜잭션 커밋 시 INSERT 3개 한번에 실행 } 쿼리를 모아뒀다가 커밋 직전에 한번에 DB로 보내 네트워크 왕복 횟수를 줄인다.\n지연 로딩 — 필요할 때만 조회 @Entity public class Order { @ManyToOne(fetch = FetchType.LAZY) // 지연 로딩 @JoinColumn(name = \u0026#34;member_id\u0026#34;) private Member member; } FetchType.LAZY는 Order를 조회해도 Member는 즉시 가져오지 않는다. order.getMember()를 호출하는 순간 DB 조회가 발생한다.\n기본값 주의: @ManyToOne, @OneToOne의 기본값은 EAGER(즉시 로딩)다. 필요한 연관 데이터를 항상 함께 가져와 N+1을 유발할 수 있다. 항상 LAZY로 명시적으로 변경하는 것을 권장한다.\nLazyInitializationException @Transactional public Order getOrder(Long id) { return orderRepository.findById(id).get(); } // 트랜잭션 종료 → 영속성 컨텍스트 닫힘 // 컨트롤러에서 Order order = orderService.getOrder(1L); order.getMember().getName(); // LazyInitializationException! 트랜잭션이 끝나면 영속성 컨텍스트가 닫힌다. 이 상태에서 LAZY 로딩을 시도하면 컨텍스트가 없어 예외가 발생한다. 해결책은 트랜잭션 안에서 필요한 데이터를 미리 로딩하거나 Fetch Join을 쓰는 것이다.\nN+1 문제 — JPA의 가장 흔한 함정 원인 List\u0026lt;Order\u0026gt; orders = orderRepository.findAll(); // 쿼리 1번 (N개 Order 조회) for (Order order : orders) { System.out.println(order.getMember().getName()); // 주문마다 Member 조회 N번 } // 총 1 + N번 쿼리 Order가 100개면 쿼리가 101번 실행된다. 이것이 N+1 문제다.\n해결 방법 1: Fetch Join @Query(\u0026#34;SELECT o FROM Order o JOIN FETCH o.member\u0026#34;) List\u0026lt;Order\u0026gt; findAllWithMember(); -- 실행 쿼리 (1번) SELECT o.*, m.* FROM orders o INNER JOIN member m ON o.member_id = m.id Order와 Member를 JOIN 한 번으로 가져온다. 단, @OneToMany 컬렉션 Fetch Join + 페이징은 메모리에서 처리하므로 위험하다.\n해결 방법 2: @EntityGraph @EntityGraph(attributePaths = {\u0026#34;member\u0026#34;}) @Query(\u0026#34;SELECT o FROM Order o\u0026#34;) List\u0026lt;Order\u0026gt; findAllWithMember(); Fetch Join과 동일하게 동작하지만 어노테이션으로 간결하게 표현.\n해결 방법 3: @BatchSize (컬렉션 + 페이징) @BatchSize(size = 100) @OneToMany(mappedBy = \u0026#34;order\u0026#34;) private List\u0026lt;OrderItem\u0026gt; items; -- N번 쿼리 대신 IN절로 묶어서 실행 SELECT * FROM order_item WHERE order_id IN (1, 2, 3, ..., 100) default_batch_fetch_size를 전역 설정하면 모든 컬렉션에 적용된다.\nspring: jpa: properties: hibernate: default_batch_fetch_size: 100 컬렉션 페이징 + 연관 데이터 조회에 가장 실용적인 해결책이다.\n선택 기준 ToOne 관계 (ManyToOne, OneToOne) → Fetch Join / @EntityGraph 컬렉션 + 페이징 필요 → @BatchSize (전역 설정 권장) 복잡한 조회 → QueryDSL + Fetch Join 연관관계 주인 — mappedBy 규칙 양방향 관계에서 외래 키를 관리하는 쪽이 연관관계의 주인이다.\n// Order (주인) — @JoinColumn 있음, 외래 키 실제 관리 @ManyToOne @JoinColumn(name = \u0026#34;member_id\u0026#34;) private Member member; // Member (주인 아님) — mappedBy로 읽기 전용 선언 @OneToMany(mappedBy = \u0026#34;member\u0026#34;) private List\u0026lt;Order\u0026gt; orders = new ArrayList\u0026lt;\u0026gt;(); 주인에게만 값을 설정해야 DB에 반영된다.\norder.setMember(member); // DB에 반영됨 ← 주인 member.getOrders().add(order); // DB 반영 안 됨 (읽기 전용) 실무 활용 — Entity Auditing 생성일시·수정일시를 자동 관리한다. 거의 모든 Entity에 적용하는 패턴이다.\n@MappedSuperclass @EntityListeners(AuditingEntityListener.class) public abstract class BaseEntity { @CreatedDate @Column(updatable = false) private LocalDateTime createdAt; @LastModifiedDate private LocalDateTime updatedAt; } @Entity public class Order extends BaseEntity { ... } @SpringBootApplication에 @EnableJpaAuditing을 추가하면 된다.\n마치며 영속성 컨텍스트를 이해하면 JPA의 동작 방식이 논리적으로 연결된다.\nDirty Checking: 스냅샷 비교 → save() 없이 UPDATE 1차 캐시: 같은 트랜잭션 내 동일 Entity 재조회 최적화 N+1: LAZY 로딩이 반복 호출될 때 발생 → Fetch Join, @BatchSize로 해결 LazyInitializationException: 트랜잭션 밖에서 LAZY 로딩 시도 → 트랜잭션 안에서 미리 로딩 다음 편에서는 Spring 쿼리 기술 — JPQL, Criteria API, QueryDSL을 비교한다.\n","date":"2026-05-19T00:00:00Z","permalink":"/tech/2026-05-19-spring-07-data-jpa-persistence-context/","title":"[Spring 완전 정복 #7] Spring Data JPA — 영속성 컨텍스트와 N+1 문제 완전 정리"},{"content":"JPQL의 불편함 — 오타가 런타임에 터진다 JPA Repository의 쿼리 메서드로 해결되지 않는 복잡한 조회는 @Query로 JPQL을 직접 작성한다.\n@Query(\u0026#34;SELECT o FROM Order o WHERE o.member.id = :memberId AND o.status = :status\u0026#34;) List\u0026lt;Order\u0026gt; findByMemberAndStatus(@Param(\u0026#34;memberId\u0026#34;) Long memberId, @Param(\u0026#34;status\u0026#34;) OrderStatus status); 이 방식의 문제가 있다.\n컴파일 타임에 오류를 잡을 수 없다. o.member.id를 o.member.idx로 오타를 내도 컴파일은 된다. 런타임에 쿼리가 실행되는 순간에야 오류가 난다.\n동적 쿼리가 불편하다. 조건이 있을 수도 없을 수도 있는 검색 기능을 JPQL로 만들려면 조건마다 쿼리 문자열을 조합해야 한다.\n리팩터링에 취약하다. 엔티티 필드명을 바꾸면 문자열 안의 JPQL도 직접 찾아서 수정해야 한다.\nQueryDSL은 이 문제를 해결한다.\nQueryDSL이란 QueryDSL은 Java 코드로 SQL처럼 읽히는, 타입 안전한 쿼리를 작성하게 해주는 라이브러리다.\n빌드 시 엔티티 클래스로부터 Q클래스를 자동 생성한다. Order 엔티티가 있으면 QOrder가 생성된다. Q클래스의 필드는 엔티티 필드와 1:1 대응한다.\nQOrder order = QOrder.order; QMember member = QMember.member; // 자바 코드로 쿼리를 작성 — 컴파일 타임 타입 체크 queryFactory .selectFrom(order) .join(order.member, member).fetchJoin() .where(order.status.eq(OrderStatus.COMPLETED)) .orderBy(order.createdAt.desc()) .fetch(); order.status.eq(OrderStatus.COMPLETED) — order.status는 Q클래스의 타입 안전한 필드다. 오타를 내면 컴파일 오류가 발생한다.\n기본 설정 \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.querydsl\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;querydsl-jpa\u0026lt;/artifactId\u0026gt; \u0026lt;classifier\u0026gt;jakarta\u0026lt;/classifier\u0026gt; \u0026lt;/dependency\u0026gt; @Configuration public class QueryDslConfig { @Bean public JPAQueryFactory jpaQueryFactory(EntityManager em) { return new JPAQueryFactory(em); } } 동적 쿼리 — QueryDSL의 핵심 강점 검색 조건이 선택적으로 적용되는 동적 쿼리가 QueryDSL의 진짜 강점이다.\n@Repository @RequiredArgsConstructor public class OrderQueryRepository { private final JPAQueryFactory queryFactory; public List\u0026lt;Order\u0026gt; findByCondition(OrderSearchCondition cond) { return queryFactory .selectFrom(order) .join(order.member, member).fetchJoin() .where( statusEq(cond.getStatus()), memberIdEq(cond.getMemberId()) ) .fetch(); } // BooleanExpression — null 반환 시 WHERE 조건에서 자동 제외 private BooleanExpression statusEq(OrderStatus status) { return status != null ? order.status.eq(status) : null; } private BooleanExpression memberIdEq(Long memberId) { return memberId != null ? order.member.id.eq(memberId) : null; } } BooleanExpression을 반환하는 메서드로 분리하면 두 가지 이점이 있다. null을 반환하면 WHERE 조건에서 자동으로 제외된다. 그리고 이 조건 메서드를 여러 쿼리에서 재사용할 수 있다.\nDTO 직접 조회 — 필요한 필드만 엔티티 전체가 아닌 필요한 컬럼만 DTO로 조회할 때 @QueryProjection을 사용한다.\n@QueryProjection // DTO 생성자에 붙이면 QOrderSummary 자동 생성 public class OrderSummary { private Long orderId; private String memberName; private OrderStatus status; @QueryProjection public OrderSummary(Long orderId, String memberName, OrderStatus status) { ... } } public List\u0026lt;OrderSummary\u0026gt; findSummary(OrderStatus status) { return queryFactory .select(new QOrderSummary( order.id, order.member.name, order.status )) .from(order) .join(order.member, member) .where(statusEq(status)) .fetch(); } DTO 생성자 파라미터도 타입 안전하게 체크된다.\n페이징 최적화 public Page\u0026lt;OrderSummary\u0026gt; findSummaryPage(OrderStatus status, Pageable pageable) { List\u0026lt;OrderSummary\u0026gt; content = queryFactory .select(new QOrderSummary(order.id, order.member.name, order.status)) .from(order) .join(order.member, member) .where(statusEq(status)) .offset(pageable.getOffset()) .limit(pageable.getPageSize()) .fetch(); // count 쿼리 분리 (최적화) JPAQuery\u0026lt;Long\u0026gt; countQuery = queryFactory .select(order.count()) .from(order) .where(statusEq(status)); return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); } PageableExecutionUtils.getPage()는 마지막 페이지이거나 첫 페이지에서 결과가 pageSize보다 적으면 count 쿼리를 실행하지 않는다. 불필요한 COUNT 쿼리를 줄이는 최적화다.\nRepository 구조 패턴 // 1. 기본 JPA Repository + 커스텀 인터페이스 public interface OrderRepository extends JpaRepository\u0026lt;Order, Long\u0026gt;, OrderRepositoryCustom { } // 2. 커스텀 인터페이스 선언 public interface OrderRepositoryCustom { List\u0026lt;OrderSummary\u0026gt; findByCondition(OrderSearchCondition cond); } // 3. QueryDSL 구현체 @Repository public class OrderRepositoryImpl implements OrderRepositoryCustom { private final JPAQueryFactory queryFactory; // QueryDSL 쿼리 구현 } 단순 CRUD는 JpaRepository가, 복잡한 조회는 OrderRepositoryImpl이 담당하는 역할 분리다.\n언제 무엇을 쓸까 단순 조회 → JPA 쿼리 메서드 (findByStatus, findByMemberId) 조건 1~2개 → @Query (JPQL) 동적 쿼리 / 복잡한 조인 / DTO 조회 → QueryDSL ← 실무 표준 DB 특화 함수 / 성능 힌트 → Native Query 대용량 통계·배치 → MyBatis (별도 모듈) 기준 JPQL QueryDSL Native Query 타입 안전성 ❌ 문자열 ✅ Q클래스 ❌ 문자열 동적 쿼리 불편 ✅ BooleanExpression 불편 DB 특화 기능 ❌ ❌ ✅ DTO 조회 new 키워드 (불편) ✅ @QueryProjection 수동 매핑 마치며 QueryDSL이 실무 표준이 된 이유는 명확하다. JPQL의 문자열 기반 한계를 타입 안전한 Java 코드로 대체하고, 동적 쿼리를 BooleanExpression 패턴으로 깔끔하게 처리한다.\n다음 편에서는 Spring Security — 인증/인가와 JWT 구현을 정리한다.\n","date":"2026-05-19T00:00:00Z","permalink":"/tech/2026-05-19-spring-08-querydsl/","title":"[Spring 완전 정복 #8] QueryDSL이 실무 표준인 이유 — JPQL의 한계와 타입 안전한 동적 쿼리"},{"content":"인증과 인가 — 순서가 있다 Spring Security를 다루기 전에 개념을 먼저 정리한다.\n인증 (Authentication) : \u0026#34;당신이 누구인지\u0026#34; 확인 → 로그인 인가 (Authorization) : \u0026#34;당신이 무엇을 할 수 있는지\u0026#34; 확인 → 권한 체크 순서는 항상 인증 → 인가다. 누구인지 확인도 안 된 상태에서 권한을 체크할 수 없다.\nSpring Security 전체 구조 HTTP 요청이 Controller에 닿기 전에 Spring Security의 필터 체인이 먼저 처리한다.\nHTTP 요청 ↓ DelegatingFilterProxy ← Servlet Container에 등록된 진입점 ↓ FilterChainProxy ← Spring Security 필터 체인 관리 ↓ SecurityFilterChain ← 실제 보안 필터들 ├── JwtAuthenticationFilter (JWT 검증 — 커스텀) ├── UsernamePasswordAuthenticationFilter (폼 로그인) ├── ExceptionTranslationFilter (인증/인가 예외 처리) └── FilterSecurityInterceptor (URL 기반 인가) ↓ DispatcherServlet ↓ Controller DelegatingFilterProxy는 Servlet Container 레벨의 Filter이지만 내부적으로 Spring Bean인 FilterChainProxy에 처리를 위임한다. Servlet Container와 Spring Context를 연결하는 다리 역할이다.\nSecurityFilterChain 설정 (Spring Boot 3.x) WebSecurityConfigurerAdapter는 deprecated됐다. Spring Boot 3.x에서는 SecurityFilterChain Bean을 직접 등록한다.\n@Configuration @EnableWebSecurity @EnableMethodSecurity // @PreAuthorize 활성화 public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf -\u0026gt; csrf.disable()) // JWT + Stateless → CSRF 불필요 .sessionManagement(session -\u0026gt; session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -\u0026gt; auth .requestMatchers(\u0026#34;/api/auth/**\u0026#34;).permitAll() // 로그인, 회원가입 .requestMatchers(\u0026#34;/api/admin/**\u0026#34;).hasRole(\u0026#34;ADMIN\u0026#34;) .requestMatchers(HttpMethod.GET, \u0026#34;/api/posts/**\u0026#34;).permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .exceptionHandling(ex -\u0026gt; ex .authenticationEntryPoint( new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))); return http.build(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } } 폼 로그인 인증 흐름 로그인 요청이 처리되는 전체 흐름이다.\n[1] POST /login (username, password) [2] UsernamePasswordAuthenticationFilter → 미인증 토큰 생성 [3] AuthenticationManager (ProviderManager) → 인증 Provider 목록 순회 [4] DaoAuthenticationProvider → UserDetailsService 호출 [5] UserDetailsService → DB 조회 → UserDetails 반환 [6] PasswordEncoder.matches(입력PW, 저장된해시PW) 검증 [7] 인증 성공 → Authentication 객체 생성 → SecurityContext 저장 @Service @RequiredArgsConstructor public class CustomUserDetailsService implements UserDetailsService { private final MemberRepository memberRepository; @Override public UserDetails loadUserByUsername(String username) { Member member = memberRepository.findByEmail(username) .orElseThrow(() -\u0026gt; new UsernameNotFoundException( \u0026#34;사용자를 찾을 수 없습니다: \u0026#34; + username)); return User.builder() .username(member.getEmail()) .password(member.getPassword()) // BCrypt 해시 .roles(member.getRole().name()) // \u0026#34;USER\u0026#34; → \u0026#34;ROLE_USER\u0026#34; 자동 변환 .build(); } } JWT 인증 구현 토큰 생성/검증 @Component public class JwtTokenProvider { @Value(\u0026#34;${jwt.secret}\u0026#34;) private String secretKey; @Value(\u0026#34;${jwt.expiration}\u0026#34;) private long expiration; public String generateToken(Authentication authentication) { return Jwts.builder() .setSubject(authentication.getName()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + expiration)) .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); } public String getUsernameFromToken(String token) { return Jwts.parserBuilder() .setSigningKey(getSigningKey()) .build() .parseClaimsJws(token) .getBody() .getSubject(); } public boolean validateToken(String token) { try { Jwts.parserBuilder().setSigningKey(getSigningKey()).build() .parseClaimsJws(token); return true; } catch (ExpiredJwtException e) { throw new CustomException(ErrorCode.TOKEN_EXPIRED); } catch (JwtException e) { throw new CustomException(ErrorCode.INVALID_TOKEN); } } private Key getSigningKey() { return Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); } } JWT 인증 필터 매 요청마다 토큰을 검증하고 SecurityContext에 인증 정보를 설정한다.\n@Component @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider tokenProvider; private final CustomUserDetailsService userDetailsService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token = resolveToken(request); if (StringUtils.hasText(token) \u0026amp;\u0026amp; tokenProvider.validateToken(token)) { String username = tokenProvider.getUsernameFromToken(token); UserDetails userDetails = userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authentication); } filterChain.doFilter(request, response); } private String resolveToken(HttpServletRequest request) { String bearerToken = request.getHeader(\u0026#34;Authorization\u0026#34;); if (StringUtils.hasText(bearerToken) \u0026amp;\u0026amp; bearerToken.startsWith(\u0026#34;Bearer \u0026#34;)) { return bearerToken.substring(7); } return null; } } OncePerRequestFilter를 상속하면 같은 요청에서 필터가 한 번만 실행됨을 보장한다.\n로그인 API @RestController @RequestMapping(\u0026#34;/api/auth\u0026#34;) public class AuthController { @PostMapping(\u0026#34;/login\u0026#34;) public ResponseEntity\u0026lt;TokenResponse\u0026gt; login(@RequestBody LoginRequest request) { Authentication authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( request.getEmail(), request.getPassword())); String token = tokenProvider.generateToken(authentication); return ResponseEntity.ok(new TokenResponse(token)); } } 인가 — 메서드 수준 보안 @EnableMethodSecurity를 활성화하면 메서드에 직접 권한 조건을 선언할 수 있다.\n@PreAuthorize(\u0026#34;hasRole(\u0026#39;ADMIN\u0026#39;)\u0026#34;) @GetMapping(\u0026#34;/api/admin/users\u0026#34;) public List\u0026lt;UserDto\u0026gt; getAllUsers() { ... } // 본인 또는 ADMIN만 접근 @PreAuthorize(\u0026#34;hasRole(\u0026#39;ADMIN\u0026#39;) or #userId == authentication.principal.id\u0026#34;) @GetMapping(\u0026#34;/api/users/{userId}\u0026#34;) public UserDto getUser(@PathVariable Long userId) { ... } SpEL(Spring Expression Language)을 지원하므로 @Secured보다 표현력이 좋다.\n현재 로그인한 사용자 정보는 @AuthenticationPrincipal로 바로 받을 수 있다.\n@GetMapping(\u0026#34;/api/me\u0026#34;) public UserDto getMyProfile(@AuthenticationPrincipal UserDetails userDetails) { String username = userDetails.getUsername(); ... } BCrypt — 패스워드 암호화 @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); // 기본 strength: 10 } // 회원가입 시 String encoded = passwordEncoder.encode(rawPassword); // 로그인 시 검증 boolean matches = passwordEncoder.matches(rawPassword, encodedPassword); BCrypt는 동일한 평문을 암호화해도 매번 다른 해시가 나온다(salt 자동 포함). 역산이 불가능하고, 검증은 반드시 matches()로 해야 한다. 해시값을 직접 비교하면 항상 false다.\nRefresh Token 패턴 Access Token만 사용하면 만료 시 재로그인이 필요하다. Refresh Token 패턴으로 해결한다.\nAccess Token : 짧은 만료 (15분~1시간) — 탈취 위험 최소화 Refresh Token : 긴 만료 (7~30일) — DB/Redis에 저장 [1] 로그인 → Access Token + Refresh Token 발급 [2] API 요청 → Access Token 헤더에 담아 전송 [3] Access Token 만료 → Refresh Token으로 재발급 요청 [4] 서버: DB에서 Refresh Token 검증 → 유효하면 새 Access Token 발급 [5] 로그아웃 → DB에서 Refresh Token 삭제 Access Token은 Stateless라 서버에서 강제 무효화가 불가능하다. Refresh Token을 DB에 저장하면 로그아웃과 강제 만료 처리가 가능해진다.\n마치며 Spring Security는 Filter 레이어에서 동작한다. Controller에 닿기 전에 인증/인가가 처리되는 구조다. JWT 방식의 핵심은 JwtAuthenticationFilter가 매 요청마다 토큰을 검증하고 SecurityContextHolder에 인증 정보를 저장하는 것이다. @PreAuthorize는 AOP로 메서드 단위 인가를 처리한다.\n다음 편에서는 Spring 예외 처리 — @ControllerAdvice와 커스텀 예외 계층 설계를 정리한다.\n","date":"2026-05-19T00:00:00Z","permalink":"/tech/2026-05-19-spring-09-security-jwt/","title":"[Spring 완전 정복 #9] Spring Security + JWT 구현 — 인증 흐름부터 토큰 검증까지"}]