하이어코딩 RSS 태그 관리 글쓰기 방명록 mahiru
분류 전체보기 (45)
2025-07-23 00:55:55
728x90
반응형

들어가며

터미널에서 직접 AI에게 코딩 작업을 위임할 수 있다면 어떨까요? Anthropic의 Claude Code는 이러한 상상을 현실로 만든 혁신적인 도구입니다. 오늘은 Claude Code를 설치하고 활용하는 방법을 상세히 알아보겠습니다.

Claude Code란?

Claude Code는 개발자들이 터미널에서 직접 Claude에게 코딩 작업을 위임할 수 있는 Agentic Command Line Tool입니다. 현재 Research Preview 단계로, 얼리 어답터들의 피드백을 받으며 지속적으로 개선되고 있습니다.

주요 특징

  • 🖥️ 터미널 기반: IDE를 떠나지 않고 바로 사용 가능
  • 🤖 자율적 작업 수행: 단순 코드 생성을 넘어 파일 생성, 수정, 실행까지
  • 🔄 컨텍스트 인식: 프로젝트 구조와 기존 코드를 이해하고 작업
  • 💬 대화형 인터페이스: 자연어로 요청하고 실시간 피드백

설치 및 설정

1. 사전 요구사항

 
 
bash
# Node.js 18.0 이상 필요
node --version

# npm 또는 yarn 설치 확인
npm --version

2. Claude Code 설치

 
 
bash
# npm을 사용하는 경우
npm install -g claude-code

# yarn을 사용하는 경우
yarn global add claude-code

3. API 키 설정

Claude Code를 사용하려면 Anthropic API 키가 필요합니다.

 
 
bash
# 환경 변수 설정 (Linux/Mac)
export ANTHROPIC_API_KEY="your-api-key-here"

# Windows PowerShell
$env:ANTHROPIC_API_KEY="your-api-key-here"

영구적으로 설정하려면 .bashrc, .zshrc 또는 시스템 환경 변수에 추가하세요.

4. 초기 설정

 
 
bash
# Claude Code 초기화
claude-code init

# 설정 확인
claude-code config

기본 사용법

1. 프로젝트에서 Claude Code 시작

 
 
bash
# 프로젝트 디렉토리로 이동
cd my-project

# Claude Code 시작
claude-code

2. 기본 명령어

 
 
bash
# 새 파일 생성
> Create a new React component called UserProfile

# 기존 파일 수정
> Add error handling to the fetchData function in api.js

# 리팩토링
> Refactor the authentication logic to use hooks

# 테스트 작성
> Write unit tests for the Calculator class

3. 고급 활용

 
 
bash
# 복잡한 작업 요청
> Create a REST API with Express that handles user authentication, 
  includes JWT tokens, and connects to MongoDB

# 디버깅 도움
> Help me debug why this async function is not returning the expected value

# 코드 리뷰
> Review the security of my login implementation and suggest improvements

실전 예제: Todo 앱 만들기

실제로 Claude Code를 사용해 간단한 Todo 앱을 만들어보겠습니다.

Step 1: 프로젝트 초기화

 
 
bash
mkdir todo-app && cd todo-app
claude-code

Step 2: 기본 구조 생성

 
 
> Create a Node.js Express server with the following:
  - Basic server setup on port 3000
  - Routes for CRUD operations on todos
  - In-memory data storage for now
  - Proper error handling

Step 3: 프론트엔드 추가

 
 
> Create a simple HTML/CSS/JavaScript frontend that:
  - Displays all todos
  - Allows adding new todos
  - Allows marking todos as complete
  - Allows deleting todos
  - Uses fetch API to communicate with the backend

Step 4: 개선사항 적용

 
 
> Add the following improvements:
  - Input validation on the backend
  - Loading states on the frontend
  - Responsive design
  - Local storage fallback

효과적인 사용을 위한 팁

1. 명확한 요청 작성

 
 
bash
# ❌ 모호한 요청
> Make it better

# ✅ 구체적인 요청
> Optimize the database queries in userController.js by adding 
  proper indexing and implementing pagination

2. 단계적 접근

큰 작업은 작은 단위로 나누어 요청하세요:

 
 
bash
# 1단계: 구조 설계
> Design the folder structure for a scalable React application

# 2단계: 핵심 컴포넌트
> Implement the core routing setup with React Router

# 3단계: 상태 관리
> Add Redux Toolkit for state management with user slice

3. 컨텍스트 제공

 
 
bash
# 프로젝트 정보 제공
> This is a e-commerce project using Next.js 14 with TypeScript.
  Create a product listing page with server-side rendering

장점과 한계

장점

  • 빠른 프로토타이핑: 아이디어를 빠르게 구현
  • 학습 도구: 코드 예제와 설명을 실시간으로 확인
  • 반복 작업 자동화: 보일러플레이트 코드 생성
  • 코드 품질 향상: 베스트 프랙티스 자동 적용

현재 한계

  • ⚠️ Research Preview: 아직 실험 단계로 변경 가능성 있음
  • ⚠️ 인터넷 연결 필요: 오프라인에서 사용 불가
  • ⚠️ 대규모 리팩토링: 복잡한 대규모 변경은 수동 작업 필요
  • ⚠️ 특정 프레임워크: 최신 프레임워크는 지원이 제한적일 수 있음

보안 고려사항

1. API 키 관리

 
 
bash
# .env 파일 사용
ANTHROPIC_API_KEY=sk-ant-...

# .gitignore에 추가
echo ".env" >> .gitignore

2. 민감한 정보

  • 프로덕션 데이터베이스 정보 노출 주의
  • 실제 API 키나 비밀번호를 코드에 포함시키지 않기
  • 생성된 코드 검토 후 사용

문제 해결

일반적인 문제들

  1. API 키 인식 오류
 
 
bash
# 환경 변수 확인
echo $ANTHROPIC_API_KEY

# 다시 설정
export ANTHROPIC_API_KEY="your-key"
  1. 권한 오류
 
 
bash
# 전역 설치 권한 문제 해결
sudo npm install -g claude-code
  1. 버전 충돌
 
 
bash
# 캐시 정리 후 재설치
npm cache clean --force
npm install -g claude-code@latest

마무리

Claude Code는 개발자의 생산성을 획기적으로 향상시킬 수 있는 강력한 도구입니다. 아직 Research Preview 단계이지만, 이미 많은 개발자들이 일상적인 코딩 작업에 활용하고 있습니다.

시작은 간단합니다. 터미널을 열고, Claude Code를 설치하고, 자연어로 코딩 작업을 요청해보세요. AI와 함께하는 새로운 개발 경험이 여러분을 기다리고 있습니다.

추가 리소스

728x90
반응형
2025-07-23 00:12:40
728x90
반응형

정부지원사업에 도전하는 예비창업자라면 누구나 고민하는 것이 바로 '어떻게 하면 평가위원들의 마음을 사로잡는 사업계획서를 작성할 수 있을까?'입니다. 오늘은 예비창업패키지 선정을 위한 사업계획서 작성의 핵심 전략을 상세히 알아보겠습니다.

1. 사회적 문제에서 시작하라

개인의 불편함을 넘어 사회의 문제로

성공적인 사업계획서의 첫 번째 특징은 사회적 문제에 대한 깊은 공감입니다. 평가위원들이 가장 먼저 보는 것은 창업자가 인식한 문제의 크기와 중요성입니다.

  • ❌ "내가 불편해서 만들었다"
  • ✅ "우리 사회가 함께 해결해야 할 문제다"

예를 들어, 노인 교육 부족 문제로 인한 자살률 증가, 1인 가구 증가로 인한 펫 케어 서비스 필요성 등 구체적인 통계와 함께 제시하면 설득력이 높아집니다.

문제 제시의 기술

  1. 구체적인 통계 데이터 활용: 단순히 "많다", "부족하다"가 아닌 정확한 숫자로 표현
  2. 시각적 자료 활용: 사진, 그래프 등으로 문제의 심각성을 직관적으로 전달
  3. ESG 관점 포함: 환경, 사회, 거버넌스 측면에서의 문제 해결 방안 제시

2. 실행 능력을 증명하라

아이디어를 현실로 만드는 능력

좋은 아이디어는 많지만, 그것을 실제로 구현할 수 있는 팀은 드뭅니다. 평가위원들은 여러분의 실행 능력을 보고 싶어 합니다.

실행 능력을 보여주는 방법:

  • 이미 진행한 시장 검증 결과 제시
  • MVP(최소기능제품) 개발 현황
  • 고객 인터뷰나 설문조사 결과
  • 관련 분야 경험과 전문성

야놀자의 교훈

유니콘 기업 야놀자도 처음부터 완벽한 서비스를 만든 것이 아닙니다. 고객의 수요를 파악한 후 서비스를 개발했다는 점을 기억하세요. 시장의 요구를 먼저 검증하고, 그에 맞춰 서비스를 개발하는 접근이 필요합니다.

3. 경쟁력 있는 비즈니스 모델을 설계하라

사업과 비즈니스의 차이

  • 사업: 단순히 제품이나 서비스를 판매하는 것
  • 비즈니스: 지속가능한 수익 창출 시스템을 구축하는 것

성공적인 비즈니스 모델의 3가지 조건:

  1. 명확한 가치 제안: 고객이 왜 우리 제품/서비스를 선택해야 하는가?
  2. 수익 모델의 구체성: 어떻게 돈을 벌 것인가?
  3. 모방 불가능성: 경쟁자가 쉽게 따라할 수 없는 차별점은 무엇인가?

선순환 구조 만들기

가치 창출 → 고객 만족 → 수익 증가 → 재투자 → 더 큰 가치 창출

이러한 선순환 구조를 명확히 제시하면 사업의 지속가능성을 효과적으로 전달할 수 있습니다.

4. 창업자와 팀의 역량을 강조하라

왜 당신이어야 하는가?

아무리 좋은 아이템도 결국 사람이 실행합니다. 평가위원들은 "왜 이 팀이 이 문제를 가장 잘 해결할 수 있는가?"를 궁금해합니다.

팀 역량 어필 포인트:

  • 관련 분야 경험과 전문성
  • 문제에 대한 깊은 이해와 열정
  • 팀원 간의 시너지와 역할 분담
  • 외부 전문가 네트워크

전문가와의 협력

특히 기술 기반 창업의 경우, 관련 분야 전문가와의 협력 관계를 구축하는 것이 중요합니다. 초기 단계부터 전문가의 조언을 받고, 이를 사업계획서에 반영하세요.

5. 2025년 예비창업패키지 특별 전략

숫자로 말하라

2025년 트렌드와 시장 데이터를 적극 활용하세요:

  • 1인 가구 증가율
  • 고령화 속도
  • 디지털 전환 지표
  • ESG 관련 정책 변화

지역과의 연계성

창업 지원 특별구역이나 대학과의 연계 가능성을 검토하세요. 지역 특화 산업과의 시너지를 만들 수 있다면 가점 요소가 됩니다.

3년 성장 전략

최소 3년간의 구체적인 성장 로드맵을 제시하세요:

  • 1년차: MVP 개발 및 시장 검증
  • 2년차: 본격적인 시장 진입 및 확대
  • 3년차: 수익화 및 다음 단계 준비

마무리: 평가자의 입장에서 검토하라

사업계획서를 완성했다면, 이제 평가자의 입장에서 다시 한 번 검토해보세요.

체크리스트:

  • 첫 페이지를 읽고 어떤 문제를 해결하는지 명확한가?
  • 왜 이 팀이 해결할 수 있는지 설득력이 있는가?
  • 비즈니스 모델이 현실적이고 지속가능한가?
  • 정부 지원금이 왜 필요한지 타당한가?
  • 3년 후 어떤 모습일지 그려지는가?

예비창업패키지는 단순한 지원금이 아닌, 여러분의 꿈을 현실로 만드는 디딤돌입니다. 철저한 준비와 진정성 있는 사업계획서로 2025년 예비창업패키지에 도전해보세요!


💡 Tip: 사업계획서 작성 후에는 반드시 관련 분야 전문가나 선배 창업자의 피드백을 받아보세요. 객관적인 시각에서의 조언이 합격 가능성을 크게 높여줍니다.

728x90
반응형
2025-07-21 19:39:13
728x90
반응형

🔥 들어가며

"TemplateInputException", "SpelEvaluationException", "Error resolving template"...

Thymeleaf를 사용하다 보면 마주치는 수많은 오류들. 빨간 에러 페이지를 보면 막막하지만, 사실 각 오류는 명확한 원인과 해결책을 가지고 있습니다.

이 글에서는 Thymeleaf 개발 중 자주 발생하는 오류들을 체계적으로 정리하고, 실제 사례와 함께 해결 방법을 제시합니다. 더 이상 Thymeleaf 오류가 두렵지 않게 될 것입니다!

📑 목차

  1. 템플릿 파싱 오류
  2. 표현식 평가 오류
  3. 레이아웃 관련 오류
  4. Fragment 오류
  5. 반복문과 조건문 오류
  6. 날짜/시간 처리 오류
  7. 보안 관련 오류
  8. 디버깅 전략

템플릿 파싱 오류

1. TemplateInputException: Template Not Found

오류 메시지

 
org.thymeleaf.exceptions.TemplateInputException: 
Error resolving template [users/list], template might not exist or might not be accessible by any of the configured Template Resolvers

원인

  • 템플릿 파일 경로 오류
  • 파일명 오타
  • 잘못된 디렉토리 구조

해결 방법

파일 구조 확인

 
src/main/resources/
└── templates/
    ├── users/
    │   ├── list.html    ✅ 올바른 위치
    │   └── form.html
    └── index.html

Controller 반환값 확인

 
java
@Controller
public class UserController {
    
    @GetMapping("/users")
    public String listUsers(Model model) {
        // ❌ 잘못된 방법
        return "users/list.html";  // .html 확장자 포함
        return "/users/list";      // 앞에 슬래시
        
        // ✅ 올바른 방법
        return "users/list";       // templates/ 기준 상대 경로
    }
}

application.properties 설정

 
properties
# 기본 설정 (보통 수정 불필요)
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html

# 캐시 비활성화 (개발 환경)
spring.thymeleaf.cache=false

2. 닫히지 않은 태그 오류

오류 메시지

 
org.xml.sax.SAXParseException: The element type "input" must be terminated by the matching end-tag "</input>"

원인

  • HTML5가 아닌 XML 모드에서 self-closing 태그 미사용
  • 태그 중첩 오류

해결 방법

HTML5 모드 사용

 
properties
# application.properties
spring.thymeleaf.mode=HTML

올바른 태그 사용

 
html
<!-- ❌ 잘못된 방법 (XML 모드) -->
<input type="text" name="username">
<br>
<img src="logo.png">

<!-- ✅ 올바른 방법 1: Self-closing -->
<input type="text" name="username" />
<br />
<img src="logo.png" />

<!-- ✅ 올바른 방법 2: HTML5 모드 사용 -->
<input type="text" name="username">
<br>
<img src="logo.png">

3. 잘못된 속성 구문

오류 메시지

 
org.thymeleaf.exceptions.TemplateProcessingException: 
Could not parse as expression: "${user.name"

원인

  • 따옴표 누락 또는 불일치
  • 중괄호 누락

해결 방법

 
html
<!-- ❌ 잘못된 방법 -->
<p th:text=${user.name}>Name</p>              <!-- 따옴표 누락 -->
<p th:text="${user.name>Name</p>              <!-- 닫는 중괄호 누락 -->
<p th:text='${user.name}'>Name</p>            <!-- 작은따옴표 사용 -->

<!-- ✅ 올바른 방법 -->
<p th:text="${user.name}">Name</p>

<!-- 따옴표 안에 따옴표가 필요한 경우 -->
<p th:text="|User's name: ${user.name}|">Name</p>

표현식 평가 오류

1. SpelEvaluationException: Null Pointer

오류 메시지

 
org.springframework.expression.spel.SpelEvaluationException: 
EL1007E: Property or field 'name' cannot be found on null

원인

  • null 객체의 속성 접근
  • Controller에서 모델 추가 누락

해결 방법

안전한 네비게이션 연산자 사용

 
html
<!-- ❌ 위험한 방법 -->
<p th:text="${user.name}">Name</p>

<!-- ✅ 안전한 방법 1: 조건부 렌더링 -->
<p th:if="${user != null}" th:text="${user.name}">Name</p>

<!-- ✅ 안전한 방법 2: 안전 연산자 -->
<p th:text="${user?.name}">Name</p>

<!-- ✅ 안전한 방법 3: 기본값 제공 -->
<p th:text="${user?.name ?: 'Anonymous'}">Name</p>

<!-- ✅ 안전한 방법 4: Elvis 연산자 -->
<p th:text="${user?.name} ?: 'No name'">Name</p>

중첩 객체 안전 접근

 
html
<!-- ❌ 위험 -->
<p th:text="${user.address.city}">City</p>

<!-- ✅ 안전 -->
<p th:text="${user?.address?.city ?: 'Unknown'}">City</p>

<!-- 더 복잡한 경우 -->
<div th:object="${user}">
    <p th:text="*{address?.city}">City</p>
    <p th:text="*{address?.street}">Street</p>
</div>

2. 타입 변환 오류

오류 메시지

 
org.springframework.expression.spel.SpelEvaluationException: 
EL1030E: The operator 'ADD' is not supported between objects of type 'java.lang.String' and 'java.lang.Integer'

원인

  • 잘못된 타입 간 연산
  • 문자열과 숫자 혼용

해결 방법

 
html
<!-- ❌ 잘못된 방법 -->
<p th:text="${'Total: ' + order.amount}">Total</p>

<!-- ✅ 올바른 방법 1: 문자열 연결 -->
<p th:text="|Total: ${order.amount}|">Total</p>

<!-- ✅ 올바른 방법 2: 연결 연산자 -->
<p th:text="${'Total: ' + #numbers.formatDecimal(order.amount, 1, 2)}">Total</p>

<!-- 숫자 연산 -->
<p th:text="${order.quantity * order.price}">Total Price</p>

<!-- 타입 변환 -->
<p th:text="${#strings.toString(order.quantity)}">Quantity</p>

3. 컬렉션 접근 오류

오류 메시지

 
org.springframework.expression.spel.SpelEvaluationException: 
EL1025E: The collection has '0' elements, index '0' is invalid

원인

  • 빈 컬렉션 접근
  • 잘못된 인덱스

해결 방법

 
html
<!-- ❌ 위험한 방법 -->
<p th:text="${users[0].name}">First User</p>

<!-- ✅ 안전한 방법 1: 크기 확인 -->
<p th:if="${!users.isEmpty()}" th:text="${users[0].name}">First User</p>

<!-- ✅ 안전한 방법 2: 안전 연산자 -->
<p th:text="${users[0]?.name ?: 'No users'}">First User</p>

<!-- ✅ 안전한 방법 3: 조건부 표현 -->
<p th:text="${#lists.size(users) > 0 ? users[0].name : 'Empty'}">First User</p>

<!-- 반복문 사용 -->
<div th:each="user, iterStat : ${users}">
    <p th:if="${iterStat.first}" th:text="${user.name}">First User</p>
</div>

레이아웃 관련 오류

1. Fragment Expression 오류

오류 메시지

 
org.thymeleaf.exceptions.TemplateInputException: 
Error resolving fragment: "${content}": template or fragment could not be resolved

원인

  • Thymeleaf Layout Dialect 의존성 누락
  • 잘못된 fragment 구문

해결 방법

의존성 추가

 
xml
<!-- pom.xml -->
<dependency>
    <groupId>nz.net.ultraq.thymeleaf</groupId>
    <artifactId>thymeleaf-layout-dialect</artifactId>
    <version>3.3.0</version>
</dependency>

레이아웃 파일 (layout/default.html)

 
html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
    <title layout:title-pattern="$CONTENT_TITLE - $LAYOUT_TITLE">My Site</title>
</head>
<body>
    <header>
        <nav><!-- Navigation --></nav>
    </header>
    
    <!-- 콘텐츠가 삽입될 위치 -->
    <main layout:fragment="content">
        Default content
    </main>
    
    <footer>
        <p>&copy; 2024 My Site</p>
    </footer>
</body>
</html>

페이지 파일 (users/list.html)

 
html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="~{layout/default}">
<head>
    <title>User List</title>
</head>
<body>
    <div layout:fragment="content">
        <h1>Users</h1>
        <!-- 페이지 콘텐츠 -->
    </div>
</body>
</html>

2. 순환 참조 오류

오류 메시지

 
org.thymeleaf.exceptions.TemplateProcessingException: 
Circular template reference detected: "fragments/header" -> "layout/main" -> "fragments/header"

원인

  • 템플릿 간 순환 참조
  • 잘못된 include 구조

해결 방법

 
html
<!-- ❌ 순환 참조 발생 -->
<!-- layout/main.html -->
<div th:replace="~{fragments/header :: header}"></div>

<!-- fragments/header.html -->
<div th:fragment="header" th:replace="~{layout/main :: content}">
    <!-- 순환 참조! -->
</div>

<!-- ✅ 올바른 구조 -->
<!-- layout/main.html -->
<body>
    <div th:replace="~{fragments/header :: header}"></div>
    <div layout:fragment="content"></div>
    <div th:replace="~{fragments/footer :: footer}"></div>
</body>

<!-- fragments/header.html -->
<div th:fragment="header">
    <header>
        <!-- 헤더 내용만 포함 -->
    </header>
</div>

Fragment 오류

1. Fragment Not Found

오류 메시지

 
org.thymeleaf.exceptions.TemplateProcessingException: 
Fragment "header" was not found in template "fragments/common"

원인

  • Fragment 이름 오타
  • Fragment 정의 누락

해결 방법

Fragment 정의 (fragments/common.html)

 
html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
    <!-- Fragment 정의 -->
    <div th:fragment="header">
        <header>
            <h1>My Website</h1>
        </header>
    </div>
    
    <!-- 파라미터를 받는 Fragment -->
    <div th:fragment="alert(type, message)">
        <div th:class="|alert alert-${type}|" th:text="${message}">
            Alert message
        </div>
    </div>
    
    <!-- 동적 Fragment -->
    <div th:fragment="dynamic(content)">
        <div class="container" th:utext="${content}">
            Dynamic content
        </div>
    </div>
</body>
</html>

Fragment 사용

 
html
<!-- ✅ 올바른 사용법 -->
<!-- replace: 전체 태그 교체 -->
<div th:replace="~{fragments/common :: header}"></div>

<!-- insert: 태그 내부에 삽입 -->
<div th:insert="~{fragments/common :: header}"></div>

<!-- include: 내용만 포함 (deprecated) -->
<div th:include="~{fragments/common :: header}"></div>

<!-- 파라미터 전달 -->
<div th:replace="~{fragments/common :: alert('success', 'Operation completed!')}"></div>

<!-- 조건부 Fragment -->
<div th:replace="${user.isAdmin()} ? ~{fragments/admin :: menu} : ~{fragments/user :: menu}"></div>

2. Fragment 표현식 오류

오류 메시지

 
org.thymeleaf.exceptions.TemplateProcessingException: 
Exception evaluating SpringEL expression: "fragments/common :: ${fragmentName}"

원인

  • 동적 Fragment 이름 사용 시 구문 오류

해결 방법

 
html
<!-- ❌ 잘못된 방법 -->
<div th:replace="~{fragments/common :: ${fragmentName}}"></div>

<!-- ✅ 올바른 방법 1: 전처리 -->
<div th:replace="~{fragments/common :: __${fragmentName}__}"></div>

<!-- ✅ 올바른 방법 2: 조건부 선택 -->
<div th:replace="${condition} ? ~{fragments/common :: fragment1} : ~{fragments/common :: fragment2}"></div>

<!-- ✅ 올바른 방법 3: Fragment 매개변수 -->
<div th:replace="~{fragments/common :: dynamic(${content})}"></div>

반복문과 조건문 오류

1. th:each 오류

오류 메시지

 
org.thymeleaf.exceptions.TemplateProcessingException: 
Exception evaluating SpringEL expression: "user.name" (template: "users/list" - line 10, col 15)

원인

  • 반복 변수 스코프 오류
  • null 컬렉션 반복

해결 방법

 
html
<!-- ❌ 잘못된 방법 -->
<tr th:each="users : ${user}">  <!-- 변수명 혼동 -->
    <td th:text="${users.name}"></td>
</tr>

<!-- ✅ 올바른 방법 -->
<tr th:each="user : ${users}">
    <td th:text="${user.name}"></td>
</tr>

<!-- null 안전 처리 -->
<tbody th:if="${users != null}">
    <tr th:each="user : ${users}">
        <td th:text="${user.name}"></td>
    </tr>
</tbody>
<tbody th:unless="${users != null}">
    <tr>
        <td colspan="3">No users found</td>
    </tr>
</tbody>

<!-- 상태 변수 사용 -->
<tr th:each="user, iterStat : ${users}" 
    th:class="${iterStat.odd} ? 'odd' : 'even'">
    <td th:text="${iterStat.count}">1</td>
    <td th:text="${user.name}">Name</td>
    <td th:text="${iterStat.first} ? 'FIRST' : ''"></td>
    <td th:text="${iterStat.last} ? 'LAST' : ''"></td>
</tr>

2. 중첩 반복문 오류

원인

  • 변수 이름 충돌
  • 스코프 혼동

해결 방법

 
html
<!-- ❌ 문제가 될 수 있는 방법 -->
<div th:each="category : ${categories}">
    <div th:each="item : ${items}">
        <!-- 어느 스코프의 item인지 불명확 -->
    </div>
</div>

<!-- ✅ 명확한 방법 -->
<div th:each="category : ${categories}">
    <h3 th:text="${category.name}">Category</h3>
    <ul>
        <li th:each="product : ${category.products}">
            <span th:text="${product.name}">Product</span>
            <span th:text="${category.name}">Still accessible</span>
        </li>
    </ul>
</div>

<!-- 인덱스 활용 -->
<table>
    <tr th:each="row, rowStat : ${matrix}">
        <td th:each="cell, colStat : ${row}"
            th:text="|[${rowStat.index},${colStat.index}] = ${cell}|">
            Cell
        </td>
    </tr>
</table>

3. 조건문 오류

오류 메시지

 
org.thymeleaf.exceptions.TemplateProcessingException: 
Cannot execute subtraction: operands are "null" and "1"

원인

  • 조건문에서 null 처리 누락
  • 잘못된 비교 연산

해결 방법

 
html
<!-- ❌ 위험한 조건 -->
<div th:if="${user.age > 18}">Adult</div>

<!-- ✅ 안전한 조건 1 -->
<div th:if="${user != null and user.age != null and user.age > 18}">Adult</div>

<!-- ✅ 안전한 조건 2 -->
<div th:if="${user?.age != null and user.age > 18}">Adult</div>

<!-- 복잡한 조건 -->
<div th:switch="${user?.status}">
    <p th:case="'ACTIVE'" class="text-success">Active</p>
    <p th:case="'INACTIVE'" class="text-warning">Inactive</p>
    <p th:case="'BANNED'" class="text-danger">Banned</p>
    <p th:case="*" class="text-muted">Unknown</p>
</div>

<!-- th:unless 사용 -->
<div th:unless="${#lists.isEmpty(errors)}">
    <ul>
        <li th:each="error : ${errors}" th:text="${error}"></li>
    </ul>
</div>

날짜/시간 처리 오류

1. 날짜 포맷 오류

오류 메시지

 
org.thymeleaf.exceptions.TemplateProcessingException: 
Exception evaluating SpringEL expression: "#dates.format(user.createdAt, 'yyyy-MM-dd')"

원인

  • null 날짜 값
  • 잘못된 날짜 타입
  • Java 8 시간 API 사용

해결 방법

 
html
<!-- ❌ 잘못된 방법 (Java 8+ LocalDateTime에는 #dates 사용 불가) -->
<span th:text="${#dates.format(user.createdAt, 'yyyy-MM-dd')}"></span>

<!-- ✅ Java 8 시간 API 사용 -->
<!-- LocalDateTime -->
<span th:text="${#temporals.format(user.createdAt, 'yyyy-MM-dd HH:mm:ss')}"></span>

<!-- LocalDate -->
<span th:text="${#temporals.format(user.birthDate, 'yyyy-MM-dd')}"></span>

<!-- null 안전 처리 -->
<span th:text="${user.createdAt != null ? #temporals.format(user.createdAt, 'yyyy-MM-dd') : 'N/A'}"></span>

<!-- 다양한 포맷 -->
<span th:text="${#temporals.format(user.createdAt, 'EEEE, MMMM dd, yyyy')}"></span>
<span th:text="${#temporals.format(user.createdAt, 'dd/MM/yyyy HH:mm')}"></span>

<!-- 상대 시간 (커스텀 유틸리티 필요) -->
<span th:text="${@dateUtils.getRelativeTime(user.createdAt)}"></span>

2. 타임존 오류

원인

  • 서버와 클라이언트 타임존 불일치
  • 타임존 변환 누락

해결 방법

 
java
// 커스텀 유틸리티 빈
@Component("dateUtils")
public class DateUtils {
    
    public String formatWithTimezone(LocalDateTime dateTime, String timezone) {
        if (dateTime == null) return "";
        
        ZonedDateTime zdt = dateTime.atZone(ZoneId.systemDefault())
            .withZoneSameInstant(ZoneId.of(timezone));
        
        return DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss z")
            .format(zdt);
    }
    
    public String getRelativeTime(LocalDateTime dateTime) {
        if (dateTime == null) return "";
        
        LocalDateTime now = LocalDateTime.now();
        long minutes = ChronoUnit.MINUTES.between(dateTime, now);
        
        if (minutes < 1) return "방금 전";
        if (minutes < 60) return minutes + "분 전";
        if (minutes < 1440) return (minutes / 60) + "시간 전";
        return (minutes / 1440) + "일 전";
    }
}
 
html
<!-- 사용 예 -->
<span th:text="${@dateUtils.formatWithTimezone(user.createdAt, 'Asia/Seoul')}"></span>
<span th:text="${@dateUtils.getRelativeTime(post.createdAt)}"></span>

보안 관련 오류

1. Spring Security 통합 오류

오류 메시지

 
org.thymeleaf.exceptions.TemplateProcessingException: 
Exception evaluating SpringEL expression: "#authorization.expression('hasRole(''ROLE_ADMIN'')')"

원인

  • Spring Security Dialect 누락
  • 잘못된 권한 표현식

해결 방법

의존성 추가

 
xml
<dependency>
    <groupId>org.thymeleaf.extras</groupId>
    <artifactId>thymeleaf-extras-springsecurity6</artifactId>
</dependency>

올바른 사용법

 
html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<body>
    <!-- 인증 확인 -->
    <div sec:authorize="isAuthenticated()">
        Welcome, <span sec:authentication="name">User</span>!
    </div>
    
    <!-- 권한 확인 -->
    <div sec:authorize="hasRole('ADMIN')">
        <a href="/admin">Admin Panel</a>
    </div>
    
    <!-- 복잡한 권한 표현식 -->
    <div sec:authorize="hasRole('USER') and hasAuthority('WRITE_PRIVILEGE')">
        <button>Create Post</button>
    </div>
    
    <!-- 인증 정보 접근 -->
    <p>Username: <span sec:authentication="principal.username"></span></p>
    <p>Authorities: <span sec:authentication="principal.authorities"></span></p>
    
    <!-- CSRF 토큰 -->
    <meta name="_csrf" th:content="${_csrf.token}"/>
    <meta name="_csrf_header" th:content="${_csrf.headerName}"/>
</body>
</html>

2. XSS 방지 오류

원인

  • 안전하지 않은 HTML 출력
  • 잘못된 이스케이핑

해결 방법

 
html
<!-- ❌ 위험: XSS 취약점 -->
<div th:utext="${userInput}"></div>

<!-- ✅ 안전: 자동 이스케이핑 -->
<div th:text="${userInput}"></div>

<!-- HTML이 필요한 경우: 서버에서 검증 -->
<div th:utext="${@htmlSanitizer.sanitize(userContent)}"></div>

<!-- JavaScript 내 데이터 -->
<script th:inline="javascript">
    /* ✅ 자동 이스케이핑 */
    var username = [[${user.name}]];
    
    /* ❌ 위험: 이스케이핑 없음 */
    var data = [(${userData})];
</script>

<!-- URL 파라미터 -->
<a th:href="@{/users(name=${userName})}">Link</a>

디버깅 전략

1. 디버그 모드 활성화

 
properties
# application.properties
spring.thymeleaf.cache=false
logging.level.org.thymeleaf=DEBUG
logging.level.org.thymeleaf.TemplateEngine.CONFIG=TRACE

2. 유틸리티 객체 활용

 
html
<!-- 객체 정보 확인 -->
<div th:text="${#objects.nullSafe(user, 'NULL')}"></div>
<div th:text="${user.class.name}"></div>

<!-- 컬렉션 정보 -->
<p>Size: <span th:text="${#lists.size(users)}"></span></p>
<p>Empty: <span th:text="${#lists.isEmpty(users)}"></span></p>

<!-- 디버그 정보 출력 -->
<div th:if="${@environment.acceptsProfiles('dev')}">
    <h3>Debug Info</h3>
    <pre th:text="${#objects.toString(user)}"></pre>
    <pre th:text="${#vars}"></pre>
</div>

<!-- 조건부 디버깅 -->
<th:block th:if="${@environment.getProperty('debug.enabled') == 'true'}">
    <div class="debug-panel">
        <h4>Model Attributes</h4>
        <dl th:each="attr : ${#vars.getVariableNames()}">
            <dt th:text="${attr}">name</dt>
            <dd th:text="${#vars.getVariable(attr)}">value</dd>
        </dl>
    </div>
</th:block>

3. 커스텀 에러 페이지

 
html
<!-- templates/error/404.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>Page Not Found</title>
</head>
<body>
    <h1>404 - Page Not Found</h1>
    <p>The requested page could not be found.</p>
    
    <div th:if="${@environment.acceptsProfiles('dev')}">
        <h3>Debug Information</h3>
        <p>Timestamp: <span th:text="${timestamp}"></span></p>
        <p>Path: <span th:text="${path}"></span></p>
        <p>Error: <span th:text="${error}"></span></p>
        <p>Message: <span th:text="${message}"></span></p>
    </div>
</body>
</html>

4. 커스텀 Dialect 생성

 
java
@Component
public class CustomDialect extends AbstractProcessorDialect {
    
    public CustomDialect() {
        super("Custom Dialect", "custom", 1000);
    }
    
    @Override
    public Set<IProcessor> getProcessors(String dialectPrefix) {
        Set<IProcessor> processors = new HashSet<>();
        processors.add(new DebugAttributeTagProcessor(dialectPrefix));
        return processors;
    }
}

public class DebugAttributeTagProcessor extends AbstractAttributeTagProcessor {
    
    private static final String ATTR_NAME = "debug";
    
    public DebugAttributeTagProcessor(String dialectPrefix) {
        super(
            TemplateMode.HTML,
            dialectPrefix,
            null,
            false,
            ATTR_NAME,
            true,
            1000,
            true
        );
    }
    
    @Override
    protected void doProcess(
            ITemplateContext context,
            IProcessableElementTag tag,
            AttributeName attributeName,
            String attributeValue,
            IElementTagStructureHandler structureHandler) {
        
        if ("true".equals(attributeValue)) {
            String debug = String.format(
                "<!-- Debug: %s = %s -->",
                tag.getElementCompleteName(),
                context.getVariable(tag.getElementCompleteName())
            );
            structureHandler.insertImmediatelyBefore(debug, false);
        }
    }
}

사용:

 
html
<div custom:debug="true" th:text="${user.name}">Name</div>

성능 최적화 팁

1. Fragment 캐싱

 
html
<!-- 캐시 가능한 Fragment -->
<div th:fragment="expensive-calculation" th:cache="true" th:cache-ttl="3600">
    <!-- 비용이 큰 연산 결과 -->
</div>

2. 지연 로딩

 
html
<!-- 조건부 Fragment 로딩 -->
<div th:if="${showDetails}">
    <div th:replace="~{fragments/user-details :: details(${user})}"></div>
</div>

<!-- AJAX 로딩 준비 -->
<div id="user-details" data-user-id="${user.id}" data-load-on-demand="true">
    <button onclick="loadUserDetails()">Load Details</button>
</div>

3. 프리컴파일

 
java
@Configuration
public class ThymeleafConfig {
    
    @Bean
    public SpringTemplateEngine templateEngine(ITemplateResolver templateResolver) {
        SpringTemplateEngine engine = new SpringTemplateEngine();
        engine.setTemplateResolver(templateResolver);
        engine.setEnableSpringELCompiler(true); // SpEL 컴파일러 활성화
        return engine;
    }
}

마무리

Thymeleaf 오류는 처음에는 당황스럽지만, 패턴을 이해하면 빠르게 해결할 수 있습니다. 이 가이드를 참고하여 더 안정적이고 유지보수하기 쉬운 템플릿을 작성하시기 바랍니다.

핵심 체크리스트

  • ✅ null 안전 연산자 사용 (?.)
  • ✅ Fragment 경로 정확히 지정
  • ✅ 타입 안전성 확인
  • ✅ 보안 취약점 방지 (th:text 사용)
  • ✅ 적절한 오류 처리
  • ✅ 개발 환경에서 캐시 비활성화

디버깅 순서

  1. 브라우저 개발자 도구 확인
  2. 서버 로그 확인
  3. Thymeleaf 디버그 모드 활성화
  4. 단순한 템플릿으로 테스트
  5. 단계별 문제 격리

참고 자료


태그: #Thymeleaf #SpringBoot #ErrorHandling #WebDevelopment #Debugging

728x90
반응형
2025-07-21 19:32:33
728x90
반응형

📄 들어가며

"전체 사용자 100만 명을 한 번에 조회하면 어떻게 될까요?"

서버는 뻗고, 사용자는 무한 로딩을 보게 될 것입니다. 페이지네이션(Pagination)은 대용량 데이터를 작은 단위로 나누어 처리하는 필수 기술입니다.

이 글에서는 Spring Boot에서 페이지네이션을 구현하는 다양한 방법과 실무에서 마주치는 문제들의 해결책을 제시합니다. 단순한 페이징부터 무한 스크롤, 커서 기반 페이징까지 모든 것을 다룹니다.

📑 목차

  1. 페이지네이션 기초
  2. Spring Data JPA 페이징
  3. REST API 페이징 구현
  4. 프론트엔드 연동
  5. 고급 페이징 기법
  6. 성능 최적화
  7. 실전 예제
  8. 트러블슈팅

페이지네이션 기초

🎯 페이지네이션이란?

페이지네이션은 대량의 데이터를 작은 단위(페이지)로 나누어 제공하는 기술입니다.

 
전체 데이터: 10,000개
페이지 크기: 20개
총 페이지 수: 500페이지

1페이지: 1-20번 데이터
2페이지: 21-40번 데이터
...

페이징 방식 비교

방식장점단점사용 사례

오프셋 기반 구현 간단, 임의 페이지 접근 대용량 시 성능 저하 일반적인 게시판
커서 기반 성능 우수, 일관성 임의 페이지 접근 불가 타임라인, 피드
키셋 기반 매우 빠름 정렬 조건 제한 대용량 로그

Spring Data JPA 페이징

1. 기본 페이징 구현

Entity

 
java
@Entity
@Table(name = "users")
@Getter
@Setter
@NoArgsConstructor
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String username;
    private String email;
    private LocalDateTime createdAt;
    private boolean active;
    
    // 연관관계
    @OneToMany(mappedBy = "user")
    private List<Post> posts = new ArrayList<>();
}

Repository

 
java
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    
    // 기본 페이징
    Page<User> findAll(Pageable pageable);
    
    // 조건부 페이징
    Page<User> findByActiveTrue(Pageable pageable);
    
    // 복잡한 쿼리 + 페이징
    @Query("SELECT u FROM User u WHERE u.createdAt > :date")
    Page<User> findRecentUsers(@Param("date") LocalDateTime date, Pageable pageable);
    
    // 정렬 포함
    @Query("SELECT u FROM User u WHERE u.active = true")
    Page<User> findActiveUsers(Pageable pageable);
    
    // Slice 사용 (다음 페이지 존재 여부만 확인)
    Slice<User> findByEmailContaining(String email, Pageable pageable);
    
    // 카운트 쿼리 분리
    @Query(value = "SELECT u FROM User u JOIN u.posts p WHERE p.published = true",
           countQuery = "SELECT COUNT(u) FROM User u WHERE EXISTS (SELECT 1 FROM Post p WHERE p.user = u AND p.published = true)")
    Page<User> findUsersWithPublishedPosts(Pageable pageable);
}

2. Service 계층

 
java
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class UserService {
    
    private final UserRepository userRepository;
    
    // 기본 페이징
    public Page<UserDto> getUsers(int page, int size) {
        Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
        Page<User> userPage = userRepository.findAll(pageable);
        
        return userPage.map(UserDto::from);
    }
    
    // 동적 정렬
    public Page<UserDto> getUsersWithSort(int page, int size, String sortBy, String direction) {
        Sort.Direction sortDirection = "desc".equalsIgnoreCase(direction) 
            ? Sort.Direction.DESC : Sort.Direction.ASC;
        
        Pageable pageable = PageRequest.of(page, size, Sort.by(sortDirection, sortBy));
        
        return userRepository.findAll(pageable)
            .map(UserDto::from);
    }
    
    // 다중 정렬
    public Page<UserDto> getUsersWithMultiSort(int page, int size) {
        Sort sort = Sort.by(
            Sort.Order.desc("active"),
            Sort.Order.asc("username")
        );
        
        Pageable pageable = PageRequest.of(page, size, sort);
        
        return userRepository.findAll(pageable)
            .map(UserDto::from);
    }
    
    // 검색 + 페이징
    public Page<UserDto> searchUsers(String keyword, Pageable pageable) {
        Specification<User> spec = Specification.where(null);
        
        if (StringUtils.hasText(keyword)) {
            spec = spec.and((root, query, cb) -> 
                cb.or(
                    cb.like(root.get("username"), "%" + keyword + "%"),
                    cb.like(root.get("email"), "%" + keyword + "%")
                )
            );
        }
        
        return userRepository.findAll(spec, pageable)
            .map(UserDto::from);
    }
}

3. DTO와 매핑

 
java
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserDto {
    private Long id;
    private String username;
    private String email;
    private LocalDateTime createdAt;
    private boolean active;
    
    public static UserDto from(User user) {
        return UserDto.builder()
            .id(user.getId())
            .username(user.getUsername())
            .email(user.getEmail())
            .createdAt(user.getCreatedAt())
            .active(user.isActive())
            .build();
    }
}

// 페이지 응답 DTO
@Data
@Builder
public class PageResponse<T> {
    private List<T> content;
    private int pageNumber;
    private int pageSize;
    private long totalElements;
    private int totalPages;
    private boolean last;
    private boolean first;
    private int numberOfElements;
    private boolean empty;
    
    public static <T> PageResponse<T> of(Page<T> page) {
        return PageResponse.<T>builder()
            .content(page.getContent())
            .pageNumber(page.getNumber())
            .pageSize(page.getSize())
            .totalElements(page.getTotalElements())
            .totalPages(page.getTotalPages())
            .last(page.isLast())
            .first(page.isFirst())
            .numberOfElements(page.getNumberOfElements())
            .empty(page.isEmpty())
            .build();
    }
}

REST API 페이징 구현

1. Controller 구현

 
java
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
@Slf4j
public class UserController {
    
    private final UserService userService;
    
    // 기본 페이징
    @GetMapping
    public ResponseEntity<PageResponse<UserDto>> getUsers(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size) {
        
        if (size > 100) {
            size = 100; // 최대 크기 제한
        }
        
        Page<UserDto> userPage = userService.getUsers(page, size);
        return ResponseEntity.ok(PageResponse.of(userPage));
    }
    
    // Pageable 직접 사용
    @GetMapping("/v2")
    public Page<UserDto> getUsersV2(
            @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) 
            Pageable pageable) {
        
        log.info("Pageable: page={}, size={}, sort={}", 
            pageable.getPageNumber(), pageable.getPageSize(), pageable.getSort());
        
        return userService.getUsers(pageable);
    }
    
    // 커스텀 페이지 요청
    @GetMapping("/search")
    public ResponseEntity<Page<UserDto>> searchUsers(
            @RequestParam(required = false) String keyword,
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size,
            @RequestParam(defaultValue = "createdAt") String sortBy,
            @RequestParam(defaultValue = "DESC") String sortDirection) {
        
        Sort.Direction direction = Sort.Direction.fromString(sortDirection);
        Pageable pageable = PageRequest.of(page, size, Sort.by(direction, sortBy));
        
        Page<UserDto> result = userService.searchUsers(keyword, pageable);
        
        // 커스텀 헤더 추가
        HttpHeaders headers = new HttpHeaders();
        headers.add("X-Total-Count", String.valueOf(result.getTotalElements()));
        headers.add("X-Total-Pages", String.valueOf(result.getTotalPages()));
        
        return ResponseEntity.ok()
            .headers(headers)
            .body(result);
    }
    
    // Slice 사용 (무한 스크롤용)
    @GetMapping("/slice")
    public Slice<UserDto> getUsersSlice(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size) {
        
        Pageable pageable = PageRequest.of(page, size);
        return userService.getUsersAsSlice(pageable);
    }
    
    // HATEOAS 지원
    @GetMapping("/hateoas")
    public PagedModel<EntityModel<UserDto>> getUsersWithLinks(
            @PageableDefault(size = 20) Pageable pageable) {
        
        Page<UserDto> userPage = userService.getUsers(pageable);
        
        PagedModel<EntityModel<UserDto>> pagedModel = pagedResourcesAssembler
            .toModel(userPage, user -> EntityModel.of(user,
                linkTo(methodOn(UserController.class).getUser(user.getId())).withSelfRel()
            ));
        
        return pagedModel;
    }
}

2. 전역 페이징 설정

 
java
@Configuration
public class PageableConfig {
    
    @Bean
    public PageableHandlerMethodArgumentResolverCustomizer pageableCustomizer() {
        return customizer -> {
            customizer.setOneIndexedParameters(true);  // 1부터 시작
            customizer.setMaxPageSize(100);           // 최대 크기 제한
            customizer.setFallbackPageable(PageRequest.of(0, 20));
        };
    }
}

3. API 응답 예시

 
json
// GET /api/users?page=0&size=10&sort=username,asc

{
    "content": [
        {
            "id": 1,
            "username": "alice",
            "email": "alice@example.com",
            "createdAt": "2024-01-20T10:00:00",
            "active": true
        },
        // ... 9 more items
    ],
    "pageable": {
        "sort": {
            "sorted": true,
            "ascending": true,
            "unsorted": false
        },
        "pageNumber": 0,
        "pageSize": 10,
        "offset": 0,
        "paged": true,
        "unpaged": false
    },
    "totalElements": 1000,
    "totalPages": 100,
    "last": false,
    "first": true,
    "numberOfElements": 10,
    "size": 10,
    "number": 0,
    "sort": {
        "sorted": true,
        "ascending": true,
        "unsorted": false
    },
    "empty": false
}

프론트엔드 연동

1. JavaScript (Vanilla)

 
javascript
class PaginationHandler {
    constructor(apiUrl, containerId, options = {}) {
        this.apiUrl = apiUrl;
        this.container = document.getElementById(containerId);
        this.currentPage = 0;
        this.pageSize = options.pageSize || 20;
        this.sortBy = options.sortBy || 'id';
        this.sortDirection = options.sortDirection || 'ASC';
    }
    
    async loadPage(page) {
        try {
            const url = new URL(this.apiUrl);
            url.searchParams.set('page', page);
            url.searchParams.set('size', this.pageSize);
            url.searchParams.set('sort', `${this.sortBy},${this.sortDirection}`);
            
            const response = await fetch(url);
            const data = await response.json();
            
            this.renderData(data.content);
            this.renderPagination(data);
            
        } catch (error) {
            console.error('Failed to load page:', error);
        }
    }
    
    renderData(items) {
        this.container.innerHTML = items.map(item => `
            <div class="user-item">
                <h3>${item.username}</h3>
                <p>${item.email}</p>
            </div>
        `).join('');
    }
    
    renderPagination(pageData) {
        const pagination = document.getElementById('pagination');
        let html = '';
        
        // Previous button
        html += `
            <button ${pageData.first ? 'disabled' : ''} 
                    onclick="pagination.loadPage(${pageData.number - 1})">
                Previous
            </button>
        `;
        
        // Page numbers
        const totalPages = pageData.totalPages;
        const currentPage = pageData.number;
        
        for (let i = 0; i < totalPages; i++) {
            if (
                i === 0 || 
                i === totalPages - 1 || 
                (i >= currentPage - 2 && i <= currentPage + 2)
            ) {
                html += `
                    <button class="${i === currentPage ? 'active' : ''}"
                            onclick="pagination.loadPage(${i})">
                        ${i + 1}
                    </button>
                `;
            } else if (i === currentPage - 3 || i === currentPage + 3) {
                html += '<span>...</span>';
            }
        }
        
        // Next button
        html += `
            <button ${pageData.last ? 'disabled' : ''} 
                    onclick="pagination.loadPage(${pageData.number + 1})">
                Next
            </button>
        `;
        
        pagination.innerHTML = html;
    }
}

// 사용
const pagination = new PaginationHandler('/api/users', 'user-list', {
    pageSize: 10,
    sortBy: 'username',
    sortDirection: 'ASC'
});

pagination.loadPage(0);

2. React 구현

 
jsx
import React, { useState, useEffect } from 'react';
import axios from 'axios';

const UserList = () => {
    const [users, setUsers] = useState([]);
    const [pageInfo, setPageInfo] = useState({
        totalElements: 0,
        totalPages: 0,
        number: 0,
        size: 20
    });
    const [loading, setLoading] = useState(false);
    const [searchTerm, setSearchTerm] = useState('');
    const [sortConfig, setSortConfig] = useState({
        field: 'createdAt',
        direction: 'DESC'
    });

    const fetchUsers = async (page = 0) => {
        setLoading(true);
        try {
            const response = await axios.get('/api/users', {
                params: {
                    page,
                    size: pageInfo.size,
                    sort: `${sortConfig.field},${sortConfig.direction}`,
                    keyword: searchTerm
                }
            });
            
            setUsers(response.data.content);
            setPageInfo({
                totalElements: response.data.totalElements,
                totalPages: response.data.totalPages,
                number: response.data.number,
                size: response.data.size
            });
        } catch (error) {
            console.error('Failed to fetch users:', error);
        } finally {
            setLoading(false);
        }
    };

    useEffect(() => {
        fetchUsers();
    }, [sortConfig]);

    const handleSort = (field) => {
        setSortConfig(prev => ({
            field,
            direction: prev.field === field && prev.direction === 'ASC' ? 'DESC' : 'ASC'
        }));
    };

    const renderPagination = () => {
        const pages = [];
        const currentPage = pageInfo.number;
        const totalPages = pageInfo.totalPages;

        // Previous button
        pages.push(
            <button
                key="prev"
                onClick={() => fetchUsers(currentPage - 1)}
                disabled={currentPage === 0}
                className="pagination-btn"
            >
                Previous
            </button>
        );

        // Page numbers
        for (let i = 0; i < totalPages; i++) {
            if (
                i === 0 ||
                i === totalPages - 1 ||
                (i >= currentPage - 2 && i <= currentPage + 2)
            ) {
                pages.push(
                    <button
                        key={i}
                        onClick={() => fetchUsers(i)}
                        className={`pagination-btn ${i === currentPage ? 'active' : ''}`}
                    >
                        {i + 1}
                    </button>
                );
            } else if (i === currentPage - 3 || i === currentPage + 3) {
                pages.push(<span key={`dots-${i}`}>...</span>);
            }
        }

        // Next button
        pages.push(
            <button
                key="next"
                onClick={() => fetchUsers(currentPage + 1)}
                disabled={currentPage === totalPages - 1}
                className="pagination-btn"
            >
                Next
            </button>
        );

        return <div className="pagination">{pages}</div>;
    };

    return (
        <div className="user-list-container">
            <div className="controls">
                <input
                    type="text"
                    placeholder="Search users..."
                    value={searchTerm}
                    onChange={(e) => setSearchTerm(e.target.value)}
                    onKeyPress={(e) => e.key === 'Enter' && fetchUsers(0)}
                />
                <select
                    value={pageInfo.size}
                    onChange={(e) => {
                        setPageInfo(prev => ({ ...prev, size: parseInt(e.target.value) }));
                        fetchUsers(0);
                    }}
                >
                    <option value="10">10 per page</option>
                    <option value="20">20 per page</option>
                    <option value="50">50 per page</option>
                </select>
            </div>

            {loading ? (
                <div className="loading">Loading...</div>
            ) : (
                <table className="user-table">
                    <thead>
                        <tr>
                            <th onClick={() => handleSort('username')}>
                                Username {sortConfig.field === 'username' && (
                                    sortConfig.direction === 'ASC' ? '▲' : '▼'
                                )}
                            </th>
                            <th onClick={() => handleSort('email')}>
                                Email {sortConfig.field === 'email' && (
                                    sortConfig.direction === 'ASC' ? '▲' : '▼'
                                )}
                            </th>
                            <th onClick={() => handleSort('createdAt')}>
                                Created At {sortConfig.field === 'createdAt' && (
                                    sortConfig.direction === 'ASC' ? '▲' : '▼'
                                )}
                            </th>
                        </tr>
                    </thead>
                    <tbody>
                        {users.map(user => (
                            <tr key={user.id}>
                                <td>{user.username}</td>
                                <td>{user.email}</td>
                                <td>{new Date(user.createdAt).toLocaleDateString()}</td>
                            </tr>
                        ))}
                    </tbody>
                </table>
            )}

            <div className="pagination-container">
                <div className="pagination-info">
                    Showing {pageInfo.number * pageInfo.size + 1} to{' '}
                    {Math.min((pageInfo.number + 1) * pageInfo.size, pageInfo.totalElements)} of{' '}
                    {pageInfo.totalElements} entries
                </div>
                {renderPagination()}
            </div>
        </div>
    );
};

// Custom Hook for Pagination
const usePagination = (url, options = {}) => {
    const [data, setData] = useState([]);
    const [page, setPage] = useState(0);
    const [totalPages, setTotalPages] = useState(0);
    const [loading, setLoading] = useState(false);
    const [error, setError] = useState(null);

    const fetchPage = async (pageNumber) => {
        setLoading(true);
        setError(null);
        
        try {
            const response = await axios.get(url, {
                params: {
                    page: pageNumber,
                    size: options.pageSize || 20,
                    ...options.params
                }
            });
            
            setData(response.data.content);
            setPage(response.data.number);
            setTotalPages(response.data.totalPages);
        } catch (err) {
            setError(err);
        } finally {
            setLoading(false);
        }
    };

    useEffect(() => {
        fetchPage(0);
    }, [url]);

    return {
        data,
        page,
        totalPages,
        loading,
        error,
        fetchPage,
        hasNext: page < totalPages - 1,
        hasPrevious: page > 0
    };
};

3. 무한 스크롤 구현

 
jsx
import React, { useState, useEffect, useCallback, useRef } from 'react';

const InfiniteScrollList = () => {
    const [items, setItems] = useState([]);
    const [page, setPage] = useState(0);
    const [hasMore, setHasMore] = useState(true);
    const [loading, setLoading] = useState(false);
    const observer = useRef();

    const lastItemElementRef = useCallback(node => {
        if (loading) return;
        if (observer.current) observer.current.disconnect();
        
        observer.current = new IntersectionObserver(entries => {
            if (entries[0].isIntersecting && hasMore) {
                setPage(prevPage => prevPage + 1);
            }
        });
        
        if (node) observer.current.observe(node);
    }, [loading, hasMore]);

    useEffect(() => {
        loadMore();
    }, [page]);

    const loadMore = async () => {
        setLoading(true);
        try {
            const response = await fetch(`/api/users/slice?page=${page}&size=20`);
            const data = await response.json();
            
            setItems(prev => [...prev, ...data.content]);
            setHasMore(!data.last);
        } catch (error) {
            console.error('Failed to load more items:', error);
        } finally {
            setLoading(false);
        }
    };

    return (
        <div className="infinite-scroll-container">
            {items.map((item, index) => {
                if (items.length === index + 1) {
                    return (
                        <div ref={lastItemElementRef} key={item.id} className="item">
                            {item.username}
                        </div>
                    );
                } else {
                    return (
                        <div key={item.id} className="item">
                            {item.username}
                        </div>
                    );
                }
            })}
            {loading && <div className="loading">Loading more...</div>}
            {!hasMore && <div className="end-message">No more items to load</div>}
        </div>
    );
};

고급 페이징 기법

1. 커서 기반 페이징

 
java
@RestController
@RequestMapping("/api/users/cursor")
public class CursorPaginationController {
    
    @GetMapping
    public CursorPageResponse<UserDto> getUsersByCursor(
            @RequestParam(required = false) Long cursor,
            @RequestParam(defaultValue = "20") int size,
            @RequestParam(defaultValue = "DESC") String direction) {
        
        return userService.getUsersByCursor(cursor, size, direction);
    }
}

@Service
public class UserService {
    
    public CursorPageResponse<UserDto> getUsersByCursor(Long cursor, int size, String direction) {
        List<User> users;
        
        if (cursor == null) {
            // 첫 페이지
            users = userRepository.findTopNByOrderByIdDesc(size + 1);
        } else {
            // 다음 페이지
            if ("DESC".equals(direction)) {
                users = userRepository.findByIdLessThanOrderByIdDesc(cursor, PageRequest.of(0, size + 1));
            } else {
                users = userRepository.findByIdGreaterThanOrderByIdAsc(cursor, PageRequest.of(0, size + 1));
            }
        }
        
        boolean hasNext = users.size() > size;
        if (hasNext) {
            users = users.subList(0, size);
        }
        
        List<UserDto> dtos = users.stream()
            .map(UserDto::from)
            .collect(Collectors.toList());
        
        Long nextCursor = hasNext && !users.isEmpty() 
            ? users.get(users.size() - 1).getId() 
            : null;
        
        return CursorPageResponse.<UserDto>builder()
            .content(dtos)
            .cursor(nextCursor)
            .hasNext(hasNext)
            .build();
    }
}

@Data
@Builder
public class CursorPageResponse<T> {
    private List<T> content;
    private Long cursor;
    private boolean hasNext;
}

2. 키셋 페이징 (Keyset Pagination)

 
java
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    
    @Query("""
        SELECT u FROM User u
        WHERE (:lastId IS NULL OR u.id < :lastId)
        AND (:lastDate IS NULL OR u.createdAt < :lastDate OR 
             (u.createdAt = :lastDate AND u.id < :lastId))
        ORDER BY u.createdAt DESC, u.id DESC
        """)
    List<User> findWithKeyset(
        @Param("lastId") Long lastId,
        @Param("lastDate") LocalDateTime lastDate,
        Pageable pageable
    );
}

@Service
public class KeysetPaginationService {
    
    public KeysetPageResponse<UserDto> getUsersWithKeyset(
            Long lastId, 
            LocalDateTime lastDate, 
            int size) {
        
        List<User> users = userRepository.findWithKeyset(
            lastId, 
            lastDate, 
            PageRequest.of(0, size + 1)
        );
        
        boolean hasNext = users.size() > size;
        if (hasNext) {
            users = users.subList(0, size);
        }
        
        List<UserDto> dtos = users.stream()
            .map(UserDto::from)
            .collect(Collectors.toList());
        
        KeysetPageResponse.Keyset nextKeyset = null;
        if (hasNext && !users.isEmpty()) {
            User lastUser = users.get(users.size() - 1);
            nextKeyset = new KeysetPageResponse.Keyset(
                lastUser.getId(),
                lastUser.getCreatedAt()
            );
        }
        
        return KeysetPageResponse.<UserDto>builder()
            .content(dtos)
            .nextKeyset(nextKeyset)
            .hasNext(hasNext)
            .build();
    }
}

3. 하이브리드 페이징

 
java
@Service
public class HybridPaginationService {
    
    // 처음 몇 페이지는 오프셋, 이후는 커서 기반
    public Page<UserDto> getHybridPage(int page, int size) {
        if (page < 5) {
            // 오프셋 기반 (처음 5페이지)
            return userRepository.findAll(PageRequest.of(page, size))
                .map(UserDto::from);
        } else {
            // 커서 기반으로 전환
            Long cursor = calculateCursorForPage(page, size);
            List<User> users = userRepository.findByIdGreaterThan(
                cursor, 
                PageRequest.of(0, size)
            );
            
            return new PageImpl<>(
                users.stream().map(UserDto::from).collect(Collectors.toList()),
                PageRequest.of(page, size),
                calculateTotalElements()
            );
        }
    }
}

성능 최적화

1. 카운트 쿼리 최적화

 
java
@Repository
public interface OptimizedUserRepository extends JpaRepository<User, Long> {
    
    // 카운트 쿼리 캐싱
    @QueryHints(@QueryHint(name = "org.hibernate.cacheable", value = "true"))
    @Query("SELECT COUNT(u) FROM User u WHERE u.active = true")
    long countActiveUsers();
    
    // 근사치 사용 (PostgreSQL)
    @Query(value = "SELECT reltuples::BIGINT FROM pg_class WHERE relname = 'users'", 
           nativeQuery = true)
    long getApproximateCount();
    
    // 카운트 쿼리 생략
    default Page<User> findAllWithoutCount(Pageable pageable) {
        List<User> users = findAll(pageable).getContent();
        return new PageImpl<>(users, pageable, -1); // total count = -1
    }
}

2. 인덱스 활용

 
java
@Entity
@Table(name = "users", indexes = {
    @Index(name = "idx_created_at", columnList = "created_at"),
    @Index(name = "idx_active_created", columnList = "active, created_at"),
    @Index(name = "idx_email", columnList = "email")
})
public class User {
    // ...
}

// 커버링 인덱스 활용
@Query("""
    SELECT new com.example.dto.UserSummaryDto(u.id, u.username, u.email)
    FROM User u
    WHERE u.active = true
    ORDER BY u.createdAt DESC
    """)
Page<UserSummaryDto> findActiveUserSummaries(Pageable pageable);

3. 프로젝션 최적화

 
java
// DTO Projection으로 필요한 필드만 조회
public interface UserProjection {
    Long getId();
    String getUsername();
    String getEmail();
    @Value("#{target.posts.size()}")
    int getPostCount();
}

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    Page<UserProjection> findAllProjectedBy(Pageable pageable);
}

4. 캐싱 전략

 
java
@Configuration
@EnableCaching
public class CacheConfig {
    
    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager manager = new CaffeineCacheManager();
        manager.setCaffeine(Caffeine.newBuilder()
            .expireAfterWrite(5, TimeUnit.MINUTES)
            .maximumSize(1000));
        return manager;
    }
}

@Service
public class CachedUserService {
    
    @Cacheable(value = "userPages", 
               key = "#pageable.pageNumber + '-' + #pageable.pageSize + '-' + #pageable.sort.toString()")
    public Page<UserDto> getCachedUsers(Pageable pageable) {
        return userRepository.findAll(pageable)
            .map(UserDto::from);
    }
    
    @CacheEvict(value = "userPages", allEntries = true)
    public void evictCache() {
        // 캐시 무효화
    }
}

5. 병렬 처리

 
java
@Service
public class ParallelPaginationService {
    
    @Autowired
    private ThreadPoolTaskExecutor taskExecutor;
    
    public CompletableFuture<PageResponse<UserDto>> getPageAsync(int page, int size) {
        return CompletableFuture.supplyAsync(() -> {
            Page<User> userPage = userRepository.findAll(PageRequest.of(page, size));
            return PageResponse.of(userPage.map(UserDto::from));
        }, taskExecutor);
    }
    
    // 여러 페이지 동시 조회
    public List<PageResponse<UserDto>> getMultiplePages(List<Integer> pageNumbers, int size) {
        List<CompletableFuture<PageResponse<UserDto>>> futures = pageNumbers.stream()
            .map(page -> getPageAsync(page, size))
            .collect(Collectors.toList());
        
        return futures.stream()
            .map(CompletableFuture::join)
            .collect(Collectors.toList());
    }
}

실전 예제

1. 검색 + 필터 + 페이징

 
java
@Data
public class UserSearchRequest {
    private String keyword;
    private List<String> departments;
    private Boolean active;
    private LocalDateTime startDate;
    private LocalDateTime endDate;
    private int page = 0;
    private int size = 20;
    private String sortBy = "createdAt";
    private String sortDirection = "DESC";
}

@Service
public class AdvancedSearchService {
    
    public Page<UserDto> searchUsers(UserSearchRequest request) {
        Specification<User> spec = Specification.where(null);
        
        // 키워드 검색
        if (StringUtils.hasText(request.getKeyword())) {
            spec = spec.and((root, query, cb) -> {
                String pattern = "%" + request.getKeyword().toLowerCase() + "%";
                return cb.or(
                    cb.like(cb.lower(root.get("username")), pattern),
                    cb.like(cb.lower(root.get("email")), pattern)
                );
            });
        }
        
        // 부서 필터
        if (request.getDepartments() != null && !request.getDepartments().isEmpty()) {
            spec = spec.and((root, query, cb) -> 
                root.get("department").in(request.getDepartments())
            );
        }
        
        // 활성 상태 필터
        if (request.getActive() != null) {
            spec = spec.and((root, query, cb) -> 
                cb.equal(root.get("active"), request.getActive())
            );
        }
        
        // 날짜 범위 필터
        if (request.getStartDate() != null) {
            spec = spec.and((root, query, cb) -> 
                cb.greaterThanOrEqualTo(root.get("createdAt"), request.getStartDate())
            );
        }
        
        if (request.getEndDate() != null) {
            spec = spec.and((root, query, cb) -> 
                cb.lessThanOrEqualTo(root.get("createdAt"), request.getEndDate())
            );
        }
        
        // 페이징 및 정렬
        Sort sort = Sort.by(
            Sort.Direction.fromString(request.getSortDirection()), 
            request.getSortBy()
        );
        Pageable pageable = PageRequest.of(request.getPage(), request.getSize(), sort);
        
        return userRepository.findAll(spec, pageable)
            .map(UserDto::from);
    }
}

2. Thymeleaf 페이징 UI

 
html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>User List</title>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
</head>
<body>
<div class="container mt-5">
    <h1>User List</h1>
    
    <!-- Search Form -->
    <form th:action="@{/users}" method="get" class="mb-4">
        <div class="form-row">
            <div class="col-md-4">
                <input type="text" name="keyword" class="form-control" 
                       placeholder="Search..." th:value="${keyword}">
            </div>
            <div class="col-md-2">
                <select name="size" class="form-control">
                    <option value="10" th:selected="${page.size == 10}">10</option>
                    <option value="20" th:selected="${page.size == 20}">20</option>
                    <option value="50" th:selected="${page.size == 50}">50</option>
                </select>
            </div>
            <div class="col-md-2">
                <button type="submit" class="btn btn-primary">Search</button>
            </div>
        </div>
    </form>
    
    <!-- User Table -->
    <table class="table table-striped">
        <thead>
            <tr>
                <th>
                    <a th:href="@{/users(page=${page.number}, size=${page.size}, sort='username,' + ${sortDir})}">
                        Username
                    </a>
                </th>
                <th>
                    <a th:href="@{/users(page=${page.number}, size=${page.size}, sort='email,' + ${sortDir})}">
                        Email
                    </a>
                </th>
                <th>
                    <a th:href="@{/users(page=${page.number}, size=${page.size}, sort='createdAt,' + ${sortDir})}">
                        Created At
                    </a>
                </th>
            </tr>
        </thead>
        <tbody>
            <tr th:each="user : ${page.content}">
                <td th:text="${user.username}"></td>
                <td th:text="${user.email}"></td>
                <td th:text="${#temporals.format(user.createdAt, 'yyyy-MM-dd HH:mm')}"></td>
            </tr>
        </tbody>
    </table>
    
    <!-- Pagination -->
    <nav aria-label="Page navigation">
        <ul class="pagination justify-content-center">
            <!-- First -->
            <li class="page-item" th:classappend="${page.first ? 'disabled' : ''}">
                <a class="page-link" th:href="@{/users(page=0, size=${page.size})}" 
                   th:text="'First'"></a>
            </li>
            
            <!-- Previous -->
            <li class="page-item" th:classappend="${page.first ? 'disabled' : ''}">
                <a class="page-link" 
                   th:href="@{/users(page=${page.number - 1}, size=${page.size})}" 
                   th:text="'Previous'"></a>
            </li>
            
            <!-- Page Numbers -->
            <li class="page-item" 
                th:each="pageNum : ${#numbers.sequence(0, page.totalPages - 1)}"
                th:if="${pageNum >= page.number - 2 and pageNum <= page.number + 2}"
                th:classappend="${pageNum == page.number ? 'active' : ''}">
                <a class="page-link" 
                   th:href="@{/users(page=${pageNum}, size=${page.size})}" 
                   th:text="${pageNum + 1}"></a>
            </li>
            
            <!-- Next -->
            <li class="page-item" th:classappend="${page.last ? 'disabled' : ''}">
                <a class="page-link" 
                   th:href="@{/users(page=${page.number + 1}, size=${page.size})}" 
                   th:text="'Next'"></a>
            </li>
            
            <!-- Last -->
            <li class="page-item" th:classappend="${page.last ? 'disabled' : ''}">
                <a class="page-link" 
                   th:href="@{/users(page=${page.totalPages - 1}, size=${page.size})}" 
                   th:text="'Last'"></a>
            </li>
        </ul>
    </nav>
    
    <!-- Page Info -->
    <div class="text-center">
        <p th:text="'Showing ' + ${page.number * page.size + 1} + ' to ' + 
                   ${page.number * page.size + page.numberOfElements} + ' of ' + 
                   ${page.totalElements} + ' entries'"></p>
    </div>
</div>
</body>
</html>

트러블슈팅

1. N+1 문제 해결

 
java
// 문제: 각 유저의 posts를 조회할 때 N+1 쿼리 발생
Page<User> users = userRepository.findAll(pageable);
users.forEach(user -> user.getPosts().size()); // N개의 추가 쿼리

// 해결 1: Fetch Join (단, 페이징과 함께 사용 시 주의)
@Query("SELECT DISTINCT u FROM User u LEFT JOIN FETCH u.posts")
List<User> findAllWithPosts(); // 페이징 불가

// 해결 2: @EntityGraph
@EntityGraph(attributePaths = {"posts"})
Page<User> findAll(Pageable pageable); // 경고 발생, 메모리에서 페이징

// 해결 3: Batch Size
@BatchSize(size = 100)
@OneToMany(mappedBy = "user")
private List<Post> posts;

// 해결 4: DTO Projection
@Query("""
    SELECT new com.example.dto.UserWithPostCountDto(
        u.id, u.username, COUNT(p)
    )
    FROM User u LEFT JOIN u.posts p
    GROUP BY u.id, u.username
    """)
Page<UserWithPostCountDto> findUsersWithPostCount(Pageable pageable);

2. 대용량 데이터 처리

 
java
// 문제: OutOfMemoryError
List<User> allUsers = userRepository.findAll(); // 100만 건

// 해결 1: 스트림 처리
@Query("SELECT u FROM User u")
@QueryHints(@QueryHint(name = "org.hibernate.fetchSize", value = "1000"))
Stream<User> streamAll();

@Transactional(readOnly = true)
public void processAllUsers() {
    try (Stream<User> users = userRepository.streamAll()) {
        users.forEach(this::processUser);
    }
}

// 해결 2: 청크 단위 처리
public void processInChunks() {
    Pageable pageable = PageRequest.of(0, 1000);
    Page<User> page;
    
    do {
        page = userRepository.findAll(pageable);
        page.getContent().forEach(this::processUser);
        pageable = pageable.next();
    } while (page.hasNext());
}

// 해결 3: ScrollableResults (Hibernate)
@PersistenceContext
private EntityManager em;

public void processWithScroll() {
    Session session = em.unwrap(Session.class);
    ScrollableResults results = session.createQuery("FROM User", User.class)
        .setFetchSize(1000)
        .scroll(ScrollMode.FORWARD_ONLY);
    
    while (results.next()) {
        User user = (User) results.get(0);
        processUser(user);
        
        if (++count % 1000 == 0) {
            session.clear(); // 메모리 정리
        }
    }
    results.close();
}

3. 동적 정렬 보안

 
java
// 문제: SQL Injection 위험
String sort = request.getParameter("sort"); // "username; DROP TABLE users;--"
Pageable pageable = PageRequest.of(0, 20, Sort.by(sort));

// 해결: 화이트리스트 검증
@Component
public class SortValidator {
    
    private static final Set<String> ALLOWED_SORT_FIELDS = Set.of(
        "id", "username", "email", "createdAt"
    );
    
    public Sort validateAndCreateSort(String sortField, String sortDirection) {
        if (!ALLOWED_SORT_FIELDS.contains(sortField)) {
            throw new IllegalArgumentException("Invalid sort field: " + sortField);
        }
        
        Sort.Direction direction;
        try {
            direction = Sort.Direction.fromString(sortDirection);
        } catch (Exception e) {
            direction = Sort.Direction.ASC;
        }
        
        return Sort.by(direction, sortField);
    }
}

마무리

페이지네이션은 단순해 보이지만 실제로는 많은 고려사항이 있는 기술입니다. 이 가이드를 통해 다양한 상황에서 적절한 페이징 전략을 선택하고 구현할 수 있기를 바랍니다.

핵심 체크리스트

  • ✅ 적절한 페이지 크기 설정 (보통 20-50)
  • ✅ 최대 페이지 크기 제한
  • ✅ 카운트 쿼리 최적화
  • ✅ 인덱스 활용
  • ✅ N+1 문제 방지
  • ✅ 보안 (정렬 필드 검증)
  • ✅ 캐싱 전략 수립

페이징 방식 선택 가이드

  • 오프셋 기반: 일반 게시판, 관리자 페이지
  • 커서 기반: 타임라인, 피드, 무한 스크롤
  • 키셋 기반: 대용량 로그, 실시간 데이터

다음 단계

  • Elasticsearch를 활용한 검색 페이징
  • GraphQL에서의 페이지네이션
  • 반응형 스트림(Reactive Streams) 페이징
  • Redis를 활용한 페이지 캐싱

태그: #SpringBoot #Pagination #JPA #REST #Performance

728x90
반응형
2025-07-21 19:26:43
728x90
반응형

🚨 들어가며

"왜 안 되지?"

Spring Boot 개발을 하다 보면 수많은 오류를 만나게 됩니다. 빨간색 에러 로그를 보면 막막하지만, 사실 각 오류는 우리에게 문제를 해결할 힌트를 제공합니다.

이 글에서는 Spring Boot 개발 중 자주 만나는 오류들을 체계적으로 정리하고, 각 오류의 원인과 해결 방법을 실제 사례와 함께 설명합니다. 이 가이드를 통해 더 이상 에러 메시지가 두렵지 않게 될 것입니다!

📑 목차

  1. 시작 단계 오류
  2. 의존성 관련 오류
  3. 데이터베이스 연결 오류
  4. JPA/Hibernate 오류
  5. REST API 오류
  6. 보안 관련 오류
  7. 설정 관련 오류
  8. 런타임 오류

시작 단계 오류

1. ApplicationContext 로드 실패

오류 메시지

 
***************************
APPLICATION FAILED TO START
***************************

Description:
Failed to configure a DataSource: 'url' attribute is not specified and no embedded datasource could be configured.

Reason: Failed to determine a suitable driver class

원인

  • 데이터베이스 설정이 없는데 JPA 의존성이 있음
  • application.properties에 DB 설정 누락

해결 방법

방법 1: 데이터베이스 설정 추가

 
properties
# application.properties
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=

방법 2: 자동 설정 제외

 
java
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

2. 포트 충돌

오류 메시지

 
***************************
APPLICATION FAILED TO START
***************************

Description:
Web server failed to start. Port 8080 was already in use.

Action:
Identify and stop the process that's listening on port 8080 or configure this application to listen on another port.

원인

  • 이미 8080 포트를 다른 프로세스가 사용 중

해결 방법

방법 1: 포트 변경

 
properties
# application.properties
server.port=8081

방법 2: 사용 중인 프로세스 종료

 
bash
# Windows
netstat -ano | findstr :8080
taskkill /PID <PID> /F

# Mac/Linux
lsof -i :8080
kill -9 <PID>

방법 3: 랜덤 포트 사용

 
properties
server.port=0

3. 메인 클래스를 찾을 수 없음

오류 메시지

 
Error: Could not find or load main class com.example.Application
Caused by: java.lang.ClassNotFoundException: com.example.Application

원인

  • 잘못된 패키지 구조
  • 빌드 문제
  • IDE 설정 오류

해결 방법

 
xml
<!-- pom.xml에 메인 클래스 명시 -->
<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
            <configuration>
                <mainClass>com.example.Application</mainClass>
            </configuration>
        </plugin>
    </plugins>
</build>

의존성 관련 오류

1. Bean 생성 실패

오류 메시지

 
***************************
APPLICATION FAILED TO START
***************************

Description:
Field userService in com.example.controller.UserController required a bean of type 'com.example.service.UserService' that could not be found.

The injection point has the following annotations:
    - @org.springframework.beans.factory.annotation.Autowired(required=true)

Action:
Consider defining a bean of type 'com.example.service.UserService' in your configuration.

원인

  • @Service, @Component 어노테이션 누락
  • 컴포넌트 스캔 범위 밖에 클래스 위치
  • 인터페이스 구현체 없음

해결 방법

방법 1: 어노테이션 추가

 
java
@Service  // 이 어노테이션 추가!
public class UserService {
    // ...
}

방법 2: 컴포넌트 스캔 설정

 
java
@SpringBootApplication
@ComponentScan(basePackages = {"com.example", "com.other.package"})
public class Application {
    // ...
}

방법 3: @Bean 직접 정의

 
java
@Configuration
public class AppConfig {
    
    @Bean
    public UserService userService() {
        return new UserService();
    }
}

2. 순환 의존성

오류 메시지

 
***************************
APPLICATION FAILED TO START
***************************

Description:
The dependencies of some of the beans in the application context form a cycle:

┌─────┐
|  userController defined in file [UserController.class]
↑     ↓
|  userService defined in file [UserService.class]
↑     ↓
|  orderService defined in file [OrderService.class]
└─────┘

원인

  • A → B → C → A 형태의 순환 참조
  • 생성자 주입 시 서로 의존

해결 방법

방법 1: @Lazy 사용

 
java
@Service
public class UserService {
    private final OrderService orderService;
    
    public UserService(@Lazy OrderService orderService) {
        this.orderService = orderService;
    }
}

방법 2: Setter 주입 사용

 
java
@Service
public class UserService {
    private OrderService orderService;
    
    @Autowired
    public void setOrderService(OrderService orderService) {
        this.orderService = orderService;
    }
}

방법 3: 설계 개선 (권장)

 
java
// 중간 서비스나 이벤트 기반으로 변경
@Service
public class UserOrderMediator {
    private final UserService userService;
    private final OrderService orderService;
    
    // 순환 의존성 해결
}

3. 중복 Bean 정의

오류 메시지

 
***************************
APPLICATION FAILED TO START
***************************

Description:
Parameter 0 of constructor in com.example.service.NotificationService required a single bean, but 2 were found:
    - emailSender: defined in file [EmailSender.class]
    - smsSender: defined in file [SmsSender.class]

Action:
Consider marking one of the beans as @Primary, updating the consumer to accept multiple beans, or using @Qualifier to identify the bean that should be consumed

원인

  • 같은 타입의 Bean이 여러 개 존재
  • 어떤 Bean을 주입해야 할지 모호함

해결 방법

방법 1: @Primary 사용

 
java
@Component
@Primary  // 기본으로 사용될 Bean 지정
public class EmailSender implements MessageSender {
    // ...
}

방법 2: @Qualifier 사용

 
java
@Service
public class NotificationService {
    
    public NotificationService(@Qualifier("emailSender") MessageSender sender) {
        this.sender = sender;
    }
}

방법 3: 모든 구현체 주입

 
java
@Service
public class NotificationService {
    private final List<MessageSender> senders;
    
    public NotificationService(List<MessageSender> senders) {
        this.senders = senders;
    }
}

데이터베이스 연결 오류

1. 데이터베이스 연결 실패

오류 메시지

 
com.mysql.cj.jdbc.exceptions.CommunicationsException: Communications link failure

The last packet sent successfully to the server was 0 milliseconds ago. The driver has not received any packets from the server.

원인

  • 데이터베이스 서버가 실행되지 않음
  • 잘못된 호스트/포트
  • 방화벽 차단

해결 방법

방법 1: 연결 정보 확인

 
properties
# MySQL
spring.datasource.url=jdbc:mysql://localhost:3306/mydb?useSSL=false&allowPublicKeyRetrieval=true
spring.datasource.username=root
spring.datasource.password=password

# 연결 테스트
spring.datasource.hikari.connection-test-query=SELECT 1

방법 2: 타임아웃 설정

 
properties
spring.datasource.hikari.connection-timeout=30000
spring.datasource.hikari.maximum-pool-size=10

2. 인증 실패

오류 메시지

 
java.sql.SQLException: Access denied for user 'root'@'localhost' (using password: YES)

원인

  • 잘못된 사용자명/비밀번호
  • 권한 부족

해결 방법

 
sql
-- MySQL에서 권한 부여
CREATE USER 'myuser'@'localhost' IDENTIFIED BY 'mypass';
GRANT ALL PRIVILEGES ON mydb.* TO 'myuser'@'localhost';
FLUSH PRIVILEGES;

3. 드라이버 클래스 없음

오류 메시지

 
Cannot load driver class: com.mysql.cj.jdbc.Driver

원인

  • MySQL 드라이버 의존성 누락

해결 방법

 
xml
<!-- pom.xml -->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <scope>runtime</scope>
</dependency>

JPA/Hibernate 오류

1. LazyInitializationException

오류 메시지

 
org.hibernate.LazyInitializationException: could not initialize proxy [com.example.entity.User#1] - no Session

원인

  • 트랜잭션 밖에서 Lazy 로딩 시도
  • 세션이 이미 종료된 상태

해결 방법

방법 1: Eager 로딩 (비추천)

 
java
@Entity
public class Order {
    @ManyToOne(fetch = FetchType.EAGER)  // LAZY → EAGER
    private User user;
}

방법 2: @Transactional 확대

 
java
@Service
@Transactional  // 클래스 레벨에 추가
public class OrderService {
    public OrderDto getOrder(Long id) {
        Order order = orderRepository.findById(id).orElseThrow();
        order.getUser().getName();  // Lazy 로딩 발생
        return new OrderDto(order);
    }
}

방법 3: Fetch Join 사용 (권장)

 
java
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
    
    @Query("SELECT o FROM Order o JOIN FETCH o.user WHERE o.id = :id")
    Optional<Order> findByIdWithUser(@Param("id") Long id);
}

방법 4: DTO Projection

 
java
@Query("SELECT new com.example.dto.OrderDto(o.id, o.amount, u.name) " +
       "FROM Order o JOIN o.user u WHERE o.id = :id")
OrderDto findOrderDto(@Param("id") Long id);

2. TransientPropertyValueException

오류 메시지

 
org.hibernate.TransientPropertyValueException: object references an unsaved transient instance - save the transient instance before flushing

원인

  • 저장되지 않은 엔티티를 참조
  • Cascade 설정 누락

해결 방법

방법 1: Cascade 설정

 
java
@Entity
public class Post {
    @OneToMany(cascade = CascadeType.ALL)  // CASCADE 추가
    private List<Comment> comments;
}

방법 2: 명시적 저장

 
java
@Transactional
public void createPost(Post post) {
    // 연관된 엔티티 먼저 저장
    for (Comment comment : post.getComments()) {
        commentRepository.save(comment);
    }
    postRepository.save(post);
}

3. N+1 문제

증상

 
sql
-- 1번의 쿼리
SELECT * FROM orders;

-- N번의 추가 쿼리
SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 2;
SELECT * FROM users WHERE id = 3;
...

해결 방법

방법 1: Fetch Join

 
java
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.user")
List<Order> findAllWithUser();

방법 2: @EntityGraph

 
java
@EntityGraph(attributePaths = {"user", "items"})
List<Order> findAll();

방법 3: Batch Size

 
properties
spring.jpa.properties.hibernate.default_batch_fetch_size=100

4. 유일성 제약 조건 위반

오류 메시지

 
org.springframework.dao.DataIntegrityViolationException: could not execute statement; SQL [n/a]; constraint [UK_email]; nested exception is org.hibernate.exception.ConstraintViolationException

원인

  • 중복된 값으로 저장 시도

해결 방법

 
java
@Service
public class UserService {
    
    public User createUser(UserDto dto) {
        // 중복 검사
        if (userRepository.existsByEmail(dto.getEmail())) {
            throw new DuplicateEmailException("이미 존재하는 이메일입니다.");
        }
        
        return userRepository.save(new User(dto));
    }
}

REST API 오류

1. 415 Unsupported Media Type

오류 메시지

 
{
    "timestamp": "2024-01-20T10:15:30.123+00:00",
    "status": 415,
    "error": "Unsupported Media Type",
    "path": "/api/users"
}

원인

  • Content-Type 헤더 누락
  • 잘못된 Content-Type

해결 방법

클라이언트 측

 
javascript
fetch('/api/users', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json'  // 필수!
    },
    body: JSON.stringify(data)
});

서버 측

 
java
@PostMapping(value = "/users", 
             consumes = MediaType.APPLICATION_JSON_VALUE,
             produces = MediaType.APPLICATION_JSON_VALUE)
public User createUser(@RequestBody User user) {
    return userService.save(user);
}

2. 400 Bad Request - JSON 파싱 오류

오류 메시지

 
JSON parse error: Unrecognized field "unknownField" (class com.example.dto.UserDto), not marked as ignorable

원인

  • DTO에 없는 필드가 JSON에 포함
  • JSON 형식 오류

해결 방법

방법 1: 알 수 없는 필드 무시

 
java
@JsonIgnoreProperties(ignoreUnknown = true)
public class UserDto {
    // ...
}

방법 2: 전역 설정

 
properties
spring.jackson.deserialization.fail-on-unknown-properties=false

3. 날짜 형식 오류

오류 메시지

 
JSON parse error: Cannot deserialize value of type `java.time.LocalDateTime` from String "2024-01-20"

해결 방법

 
java
public class EventDto {
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime eventDate;
}

// 또는 전역 설정
spring.jackson.date-format=yyyy-MM-dd HH:mm:ss
spring.jackson.time-zone=Asia/Seoul

4. CORS 오류

브라우저 콘솔 오류

 
Access to XMLHttpRequest at 'http://localhost:8080/api/users' from origin 'http://localhost:3000' has been blocked by CORS policy

해결 방법

방법 1: @CrossOrigin

 
java
@RestController
@CrossOrigin(origins = "http://localhost:3000")
public class UserController {
    // ...
}

방법 2: 전역 설정

 
java
@Configuration
public class WebConfig implements WebMvcConfigurer {
    
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedOrigins("http://localhost:3000")
                .allowedMethods("GET", "POST", "PUT", "DELETE")
                .allowedHeaders("*")
                .allowCredentials(true);
    }
}

보안 관련 오류

1. 401 Unauthorized

오류 메시지

 
{
    "timestamp": "2024-01-20T10:15:30.123+00:00",
    "status": 401,
    "error": "Unauthorized",
    "message": "Full authentication is required to access this resource",
    "path": "/api/admin/users"
}

원인

  • 인증 토큰 없음
  • 만료된 토큰

해결 방법

 
java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint(new CustomAuthEntryPoint())
            );
        
        return http.build();
    }
}

2. 403 Forbidden

오류 메시지

 
{
    "timestamp": "2024-01-20T10:15:30.123+00:00",
    "status": 403,
    "error": "Forbidden",
    "message": "Access Denied",
    "path": "/api/admin/users"
}

원인

  • 권한 부족
  • CSRF 토큰 누락

해결 방법

CSRF 비활성화 (REST API)

 
java
http.csrf(csrf -> csrf.disable());

권한 확인

 
java
@PreAuthorize("hasRole('ADMIN')")
@DeleteMapping("/users/{id}")
public void deleteUser(@PathVariable Long id) {
    userService.delete(id);
}

3. JWT 관련 오류

오류 메시지

 
io.jsonwebtoken.ExpiredJwtException: JWT expired at 2024-01-20T10:00:00Z. Current time: 2024-01-20T11:00:00Z

해결 방법

 
java
@Component
public class JwtTokenProvider {
    
    public String validateToken(String token) {
        try {
            Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(token);
            return "valid";
        } catch (ExpiredJwtException e) {
            // 토큰 갱신 로직
            return "expired";
        } catch (JwtException e) {
            return "invalid";
        }
    }
}

설정 관련 오류

1. 프로퍼티 바인딩 오류

오류 메시지

 
***************************
APPLICATION FAILED TO START
***************************

Description:
Binding to target [Bindable@1234 type = com.example.config.AppProperties, value = 'provided', annotations = array<Annotation>[@org.springframework.boot.context.properties.ConfigurationProperties(prefix=app)]] failed:

    Property: app.upload.maxFileSize
    Value: 10MB
    Origin: class path resource [application.yml]:10:20
    Reason: failed to convert java.lang.String to org.springframework.util.unit.DataSize

원인

  • 잘못된 프로퍼티 형식
  • 타입 변환 실패

해결 방법

 
java
@ConfigurationProperties(prefix = "app")
@Validated
public class AppProperties {
    
    @NotNull
    private Upload upload = new Upload();
    
    public static class Upload {
        private DataSize maxFileSize = DataSize.ofMegabytes(10);
        
        // getter/setter
    }
}
 
yaml
# application.yml
app:
  upload:
    max-file-size: 10MB  # 또는 10485760

2. 프로파일 활성화 오류

증상

 
No active profile set, falling back to default profiles: default

해결 방법

방법 1: 실행 시 지정

 
bash
java -jar app.jar --spring.profiles.active=prod

방법 2: 환경 변수

 
bash
export SPRING_PROFILES_ACTIVE=prod

방법 3: application.properties

 
properties
spring.profiles.active=dev

3. 로깅 설정 오류

오류 메시지

 
ERROR in ch.qos.logback.core.joran.spi.Interpreter@10:26 - no applicable action for [springProfile], current ElementPath is [[configuration][springProfile]]

해결 방법

 
xml
<!-- logback-spring.xml (logback.xml이 아님!) -->
<configuration>
    <springProfile name="dev">
        <logger name="com.example" level="DEBUG"/>
    </springProfile>
    
    <springProfile name="prod">
        <logger name="com.example" level="INFO"/>
    </springProfile>
</configuration>

런타임 오류

1. NullPointerException

오류 메시지

 
java.lang.NullPointerException: Cannot invoke "com.example.service.UserService.findById(Long)" because "this.userService" is null

원인

  • Bean 주입 실패
  • static 메소드에서 @Autowired 필드 접근

해결 방법

 
java
@RestController
public class UserController {
    // ❌ 잘못된 방법
    @Autowired
    private static UserService userService;
    
    // ✅ 올바른 방법
    private final UserService userService;
    
    public UserController(UserService userService) {
        this.userService = userService;
    }
}

2. StackOverflowError

오류 메시지

 
java.lang.StackOverflowError
    at com.example.entity.User.toString(User.java:50)
    at com.example.entity.Order.toString(Order.java:45)
    at com.example.entity.User.toString(User.java:50)
    ...

원인

  • 양방향 연관관계에서 toString() 순환 참조
  • 재귀 호출

해결 방법

 
java
@Entity
public class User {
    @OneToMany(mappedBy = "user")
    @ToString.Exclude  // Lombok 사용 시
    private List<Order> orders;
    
    // 또는 수동으로 toString() 구현
    @Override
    public String toString() {
        return "User{id=" + id + ", name='" + name + "'}";
        // orders는 제외
    }
}

3. OutOfMemoryError

오류 메시지

 
java.lang.OutOfMemoryError: Java heap space

원인

  • 메모리 누수
  • 대용량 데이터 한 번에 로딩

해결 방법

JVM 옵션 조정

 
bash
java -Xms512m -Xmx2048m -jar app.jar

페이징 처리

 
java
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    
    @Query("SELECT u FROM User u")
    Stream<User> findAllAsStream();  // 대용량 처리
}

@Transactional(readOnly = true)
public void processAllUsers() {
    try (Stream<User> users = userRepository.findAllAsStream()) {
        users.forEach(this::processUser);
    }
}

4. 트랜잭션 관련 오류

오류 메시지

 
org.springframework.transaction.UnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-only

원인

  • 중첩된 트랜잭션에서 롤백 마크
  • 예외 발생 후 계속 진행

해결 방법

 
java
@Service
public class OrderService {
    
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void processOrder(Order order) {
        try {
            // 주문 처리
            paymentService.processPayment(order);
        } catch (PaymentException e) {
            // 별도 트랜잭션으로 처리
            compensationService.handleFailure(order);
            throw e;
        }
    }
}

디버깅 팁

1. 상세 로그 활성화

 
properties
# application.properties
logging.level.org.springframework=DEBUG
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
logging.level.com.example=TRACE

# 예외 스택 트레이스 전체 출력
server.error.include-stacktrace=always
server.error.include-message=always

2. Actuator 활용

 
xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
 
properties
management.endpoints.web.exposure.include=health,info,beans,env,metrics
management.endpoint.health.show-details=always

3. 조건부 브레이크포인트

 
java
// IntelliJ에서 브레이크포인트 우클릭 → Condition
user.getId() == 123L && user.getStatus() == Status.ERROR

4. 예외 발생 위치 추적

 
java
@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
    
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(Exception e) {
        log.error("Unhandled exception occurred", e);
        
        // 스택 트레이스 분석
        StackTraceElement[] stackTrace = e.getStackTrace();
        String location = stackTrace.length > 0 ? 
            stackTrace[0].toString() : "Unknown";
        
        return ResponseEntity.status(500)
            .body(new ErrorResponse("Internal Error", location));
    }
}

마무리

Spring Boot 개발 중 만나는 오류들은 처음에는 당황스럽지만, 각각의 오류는 명확한 원인과 해결책을 가지고 있습니다. 이 가이드를 참고하여 오류를 빠르게 해결하고, 더 나아가 오류가 발생하지 않는 견고한 코드를 작성하시기 바랍니다.

핵심 팁

  • 🔍 에러 메시지를 꼼꼼히 읽기 - 대부분의 힌트가 들어있음
  • 📝 로그 레벨 조정 - 개발 중에는 DEBUG 레벨 활용
  • 🧪 단위 테스트 작성 - 오류를 사전에 방지
  • 📚 공식 문서 참고 - Spring Boot Reference Documentation
  • 💬 커뮤니티 활용 - Stack Overflow, GitHub Issues

체크리스트

  • ✅ 의존성 버전 호환성 확인
  • ✅ 설정 파일 문법 검증
  • ✅ 데이터베이스 연결 정보 확인
  • ✅ 적절한 예외 처리
  • ✅ 트랜잭션 경계 설정
  • ✅ 보안 설정 검토

태그: #SpringBoot #Troubleshooting #ErrorHandling #Debugging #Java

728x90
반응형
2025-07-21 19:20:17
728x90
반응형

🐳 들어가며

"내 컴퓨터에서는 잘 되는데요?"

개발자라면 누구나 한 번쯤 들어본 말입니다. Docker는 이런 문제를 완벽하게 해결해줍니다. 애플리케이션과 모든 의존성을 하나의 컨테이너로 패키징하여 어디서든 동일하게 실행할 수 있죠.

이 글에서는 Spring Boot 애플리케이션을 Docker로 컨테이너화하는 방법부터 운영 환경에서의 최적화까지, 실무에서 바로 활용할 수 있는 모든 내용을 다룹니다.

📑 목차

  1. Docker 기초 이해하기
  2. Spring Boot Dockerfile 작성
  3. 멀티 스테이지 빌드
  4. Docker Compose로 환경 구성
  5. 개발 환경 최적화
  6. 운영 환경 배포
  7. 보안 및 최적화
  8. 트러블슈팅

Docker 기초 이해하기

🎯 Docker란?

Docker는 애플리케이션을 컨테이너라는 단위로 패키징하고 실행하는 플랫폼입니다.

 
mermaid
graph LR
    A[소스 코드] --> B[Docker Image]
    B --> C[Container 1]
    B --> D[Container 2]
    B --> E[Container 3]

핵심 개념

개념설명비유

Image 읽기 전용 템플릿 설계도
Container 이미지의 실행 인스턴스 실제 건물
Dockerfile 이미지 빌드 명령서 레시피
Registry 이미지 저장소 창고
Volume 영구 데이터 저장소 외장 하드

Docker 설치

Windows/Mac

Docker Desktop 다운로드 및 설치

Linux (Ubuntu)

 
bash
# Docker 설치 스크립트
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh

# 사용자 권한 설정
sudo usermod -aG docker $USER
newgrp docker

# 설치 확인
docker --version
docker run hello-world

Spring Boot Dockerfile 작성

1. 기본 Dockerfile

 
dockerfile
# 기본 Dockerfile (단일 스테이지)
FROM eclipse-temurin:17-jre

# 작업 디렉토리 설정
WORKDIR /app

# JAR 파일 복사
COPY target/*.jar app.jar

# 포트 노출
EXPOSE 8080

# 실행 명령
ENTRYPOINT ["java", "-jar", "app.jar"]

2. 개선된 Dockerfile

 
dockerfile
# 개선된 Dockerfile
FROM eclipse-temurin:17-jre-alpine

# 필수 패키지 설치
RUN apk add --no-cache tzdata curl

# 시간대 설정
ENV TZ=Asia/Seoul
RUN cp /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone

# 애플리케이션 사용자 생성
RUN addgroup -g 1000 spring && \
    adduser -D -u 1000 -G spring spring

# 작업 디렉토리 생성
WORKDIR /app

# 파일 복사 및 권한 설정
COPY --chown=spring:spring target/*.jar app.jar

# 사용자 전환
USER spring:spring

# 헬스체크 추가
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
    CMD curl -f http://localhost:8080/actuator/health || exit 1

# 포트 노출
EXPOSE 8080

# JVM 옵션과 함께 실행
ENTRYPOINT ["java", \
    "-XX:+UseContainerSupport", \
    "-XX:MaxRAMPercentage=75.0", \
    "-Djava.security.egd=file:/dev/./urandom", \
    "-jar", \
    "app.jar"]

멀티 스테이지 빌드

1. Maven 멀티 스테이지 빌드

 
dockerfile
# 1단계: 빌드 스테이지
FROM maven:3.9-eclipse-temurin-17 AS builder

# 작업 디렉토리
WORKDIR /build

# 의존성 캐싱을 위한 pom.xml 먼저 복사
COPY pom.xml .
RUN mvn dependency:go-offline -B

# 소스 코드 복사 및 빌드
COPY src ./src
RUN mvn clean package -DskipTests

# 2단계: 런타임 스테이지
FROM eclipse-temurin:17-jre-alpine

# 필수 패키지 설치
RUN apk add --no-cache tzdata

# 시간대 설정
ENV TZ=Asia/Seoul
RUN cp /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone

# 보안을 위한 non-root 사용자
RUN addgroup -g 1000 spring && \
    adduser -D -u 1000 -G spring spring

WORKDIR /app

# 빌드 스테이지에서 JAR 파일 복사
COPY --from=builder --chown=spring:spring /build/target/*.jar app.jar

# 사용자 전환
USER spring:spring

# 메타데이터
LABEL maintainer="your-email@example.com" \
      version="1.0" \
      description="Spring Boot Application"

# 헬스체크
HEALTHCHECK --interval=30s --timeout=3s --retries=3 --start-period=40s \
    CMD wget --no-verbose --tries=1 --spider http://localhost:8080/actuator/health || exit 1

EXPOSE 8080

# JVM 메모리 최적화
ENV JAVA_OPTS="-XX:+UseContainerSupport \
    -XX:MaxRAMPercentage=75.0 \
    -XX:InitialRAMPercentage=50.0 \
    -XX:+UseG1GC \
    -XX:+UseStringDeduplication"

ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

2. Gradle 멀티 스테이지 빌드

 
dockerfile
# Gradle 버전
FROM gradle:8.5-jdk17 AS builder

WORKDIR /build

# Gradle 캐시 활용
COPY build.gradle settings.gradle ./
COPY gradle ./gradle
RUN gradle dependencies --no-daemon

# 소스 코드 복사 및 빌드
COPY src ./src
RUN gradle clean bootJar --no-daemon

# 런타임 스테이지 (위와 동일)
FROM eclipse-temurin:17-jre-alpine
# ... (이하 동일)

3. Spring Boot 레이어드 JAR 활용

 
dockerfile
# 레이어드 JAR를 활용한 최적화
FROM eclipse-temurin:17-jre-alpine AS builder

WORKDIR /app

# JAR 파일 복사
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} app.jar

# 레이어 추출
RUN java -Djarmode=layertools -jar app.jar extract

# 런타임 스테이지
FROM eclipse-temurin:17-jre-alpine

RUN apk add --no-cache tzdata && \
    addgroup -g 1000 spring && \
    adduser -D -u 1000 -G spring spring

WORKDIR /app

# 레이어별로 복사 (캐시 최적화)
COPY --from=builder --chown=spring:spring /app/dependencies/ ./
COPY --from=builder --chown=spring:spring /app/spring-boot-loader/ ./
COPY --from=builder --chown=spring:spring /app/snapshot-dependencies/ ./
COPY --from=builder --chown=spring:spring /app/application/ ./

USER spring:spring

EXPOSE 8080

ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]

Docker Compose로 환경 구성

1. 개발 환경 구성

 
yaml
# docker-compose.yml
version: '3.8'

services:
  # Spring Boot 애플리케이션
  app:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: spring-app
    ports:
      - "8080:8080"
    environment:
      - SPRING_PROFILES_ACTIVE=docker
      - TZ=Asia/Seoul
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    networks:
      - app-network
    volumes:
      - ./logs:/app/logs
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s

  # MariaDB 데이터베이스
  db:
    image: mariadb:11.2
    container_name: spring-db
    ports:
      - "3306:3306"
    environment:
      MYSQL_ROOT_PASSWORD: rootpass123!
      MYSQL_DATABASE: springdb
      MYSQL_USER: springuser
      MYSQL_PASSWORD: springpass123!
      TZ: Asia/Seoul
    volumes:
      - db_data:/var/lib/mysql
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql
    networks:
      - app-network
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_unicode_ci
      - --max-connections=200

  # Redis 캐시
  redis:
    image: redis:7-alpine
    container_name: spring-redis
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    networks:
      - app-network
    restart: unless-stopped
    command: redis-server --appendonly yes --requirepass redispass123!

  # Nginx 리버스 프록시
  nginx:
    image: nginx:alpine
    container_name: spring-nginx
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./nginx/ssl:/etc/nginx/ssl:ro
      - ./static:/usr/share/nginx/html/static:ro
    depends_on:
      - app
    networks:
      - app-network
    restart: unless-stopped

  # Prometheus 모니터링
  prometheus:
    image: prom/prometheus:latest
    container_name: spring-prometheus
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - prometheus_data:/prometheus
    command:
      - '--config.file=/etc/prometheus/prometheus.yml'
      - '--storage.tsdb.path=/prometheus'
      - '--web.console.libraries=/usr/share/prometheus/console_libraries'
      - '--web.console.templates=/usr/share/prometheus/consoles'
    networks:
      - app-network
    restart: unless-stopped

  # Grafana 대시보드
  grafana:
    image: grafana/grafana:latest
    container_name: spring-grafana
    ports:
      - "3000:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin123!
      - GF_USERS_ALLOW_SIGN_UP=false
    volumes:
      - grafana_data:/var/lib/grafana
      - ./grafana/provisioning:/etc/grafana/provisioning:ro
    depends_on:
      - prometheus
    networks:
      - app-network
    restart: unless-stopped

# 볼륨 정의
volumes:
  db_data:
    driver: local
  redis_data:
    driver: local
  prometheus_data:
    driver: local
  grafana_data:
    driver: local

# 네트워크 정의
networks:
  app-network:
    driver: bridge
    ipam:
      config:
        - subnet: 172.20.0.0/16

2. 환경별 설정

 
yaml
# docker-compose.override.yml (개발 환경)
version: '3.8'

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile.dev
    environment:
      - SPRING_PROFILES_ACTIVE=dev
      - DEBUG=true
    volumes:
      - ./src:/app/src:ro
      - ./target:/app/target
    ports:
      - "5005:5005"  # 디버그 포트
    command: ["java", "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005", "-jar", "app.jar"]
 
yaml
# docker-compose.prod.yml (운영 환경)
version: '3.8'

services:
  app:
    image: your-registry/spring-app:${VERSION:-latest}
    environment:
      - SPRING_PROFILES_ACTIVE=prod
      - JAVA_OPTS=-Xms512m -Xmx2048m
    deploy:
      replicas: 3
      update_config:
        parallelism: 1
        delay: 10s
      restart_policy:
        condition: on-failure
        delay: 5s
        max_attempts: 3
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"

3. Nginx 설정

 
nginx
# nginx/conf.d/default.conf
upstream spring-app {
    least_conn;
    server app:8080 max_fails=3 fail_timeout=30s;
}

server {
    listen 80;
    server_name example.com;
    
    # HTTP를 HTTPS로 리다이렉트
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    server_name example.com;
    
    # SSL 설정
    ssl_certificate /etc/nginx/ssl/cert.pem;
    ssl_certificate_key /etc/nginx/ssl/key.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;
    
    # 보안 헤더
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;
    
    # 정적 파일 처리
    location /static/ {
        alias /usr/share/nginx/html/static/;
        expires 30d;
        add_header Cache-Control "public, immutable";
    }
    
    # API 프록시
    location /api/ {
        proxy_pass http://spring-app;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        
        # 타임아웃 설정
        proxy_connect_timeout 60s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;
        
        # 버퍼 설정
        proxy_buffering on;
        proxy_buffer_size 4k;
        proxy_buffers 8 4k;
    }
    
    # 웹소켓 지원
    location /ws/ {
        proxy_pass http://spring-app;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}

개발 환경 최적화

1. 핫 리로드 설정

 
dockerfile
# Dockerfile.dev
FROM eclipse-temurin:17-jdk

RUN apt-get update && apt-get install -y \
    inotify-tools \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app

# Maven Wrapper 복사
COPY mvnw .
COPY .mvn .mvn

# 의존성 캐싱
COPY pom.xml .
RUN ./mvnw dependency:go-offline

# 소스 코드는 볼륨으로 마운트
VOLUME ["/app/src", "/app/target"]

# 개발 모드 실행
CMD ["./mvnw", "spring-boot:run", "-Dspring-boot.run.profiles=docker"]

2. 효율적인 개발 워크플로우

 
makefile
# Makefile
.PHONY: help build run stop clean logs

help:
	@echo "사용 가능한 명령어:"
	@echo "  make build   - Docker 이미지 빌드"
	@echo "  make run     - 컨테이너 실행"
	@echo "  make stop    - 컨테이너 중지"
	@echo "  make clean   - 컨테이너 및 이미지 삭제"
	@echo "  make logs    - 로그 확인"

build:
	docker-compose build --no-cache

run:
	docker-compose up -d
	@echo "애플리케이션이 http://localhost:8080 에서 실행 중입니다"

stop:
	docker-compose down

clean:
	docker-compose down -v
	docker rmi $$(docker images -q spring-app) 2>/dev/null || true

logs:
	docker-compose logs -f app

# 개발 환경 실행
dev:
	docker-compose -f docker-compose.yml -f docker-compose.override.yml up

# 운영 환경 실행
prod:
	docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d

# 데이터베이스 백업
backup:
	docker exec spring-db mysqldump -u root -prootpass123! springdb > backup_$$(date +%Y%m%d_%H%M%S).sql

# 헬스체크
health:
	@docker-compose ps
	@echo "\n=== 헬스체크 ==="
	@curl -s http://localhost:8080/actuator/health | jq .

3. 개발용 스크립트

 
bash
#!/bin/bash
# dev.sh - 개발 환경 관리 스크립트

set -e

RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'

function print_usage() {
    echo "사용법: ./dev.sh [명령어]"
    echo "명령어:"
    echo "  start    - 개발 환경 시작"
    echo "  stop     - 개발 환경 중지"
    echo "  restart  - 개발 환경 재시작"
    echo "  logs     - 로그 확인"
    echo "  shell    - 컨테이너 쉘 접속"
    echo "  db       - 데이터베이스 접속"
    echo "  clean    - 전체 정리"
}

function start_dev() {
    echo -e "${GREEN}개발 환경을 시작합니다...${NC}"
    docker-compose up -d
    echo -e "${GREEN}완료! http://localhost:8080${NC}"
}

function stop_dev() {
    echo -e "${YELLOW}개발 환경을 중지합니다...${NC}"
    docker-compose down
}

function show_logs() {
    docker-compose logs -f app
}

function enter_shell() {
    docker-compose exec app /bin/sh
}

function enter_db() {
    docker-compose exec db mysql -u springuser -pspringpass123! springdb
}

function clean_all() {
    echo -e "${RED}모든 컨테이너와 볼륨을 삭제합니다. 계속하시겠습니까? (y/N)${NC}"
    read -r response
    if [[ "$response" =~ ^[Yy]$ ]]; then
        docker-compose down -v --rmi all
        echo -e "${GREEN}정리 완료!${NC}"
    fi
}

case "$1" in
    start)
        start_dev
        ;;
    stop)
        stop_dev
        ;;
    restart)
        stop_dev
        start_dev
        ;;
    logs)
        show_logs
        ;;
    shell)
        enter_shell
        ;;
    db)
        enter_db
        ;;
    clean)
        clean_all
        ;;
    *)
        print_usage
        exit 1
        ;;
esac

운영 환경 배포

1. CI/CD 파이프라인

 
yaml
# .github/workflows/docker-build.yml
name: Docker Build and Push

on:
  push:
    branches: [ main, develop ]
    tags: [ 'v*' ]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      
    steps:
    - name: Checkout
      uses: actions/checkout@v3
      
    - name: Set up JDK 17
      uses: actions/setup-java@v3
      with:
        java-version: '17'
        distribution: 'temurin'
        
    - name: Cache Maven dependencies
      uses: actions/cache@v3
      with:
        path: ~/.m2
        key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
        restore-keys: ${{ runner.os }}-m2
        
    - name: Build with Maven
      run: mvn clean package -DskipTests
      
    - name: Run tests
      run: mvn test
      
    - name: Set up Docker Buildx
      uses: docker/setup-buildx-action@v2
      
    - name: Log in to Container Registry
      uses: docker/login-action@v2
      with:
        registry: ${{ env.REGISTRY }}
        username: ${{ github.actor }}
        password: ${{ secrets.GITHUB_TOKEN }}
        
    - name: Extract metadata
      id: meta
      uses: docker/metadata-action@v4
      with:
        images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
        tags: |
          type=ref,event=branch
          type=ref,event=pr
          type=semver,pattern={{version}}
          type=semver,pattern={{major}}.{{minor}}
          type=sha,prefix={{branch}}-
          
    - name: Build and push Docker image
      uses: docker/build-push-action@v4
      with:
        context: .
        push: true
        tags: ${{ steps.meta.outputs.tags }}
        labels: ${{ steps.meta.outputs.labels }}
        cache-from: type=gha
        cache-to: type=gha,mode=max
        build-args: |
          BUILD_DATE=${{ github.event.repository.updated_at }}
          VCS_REF=${{ github.sha }}

2. Kubernetes 배포

 
yaml
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: spring-app
  labels:
    app: spring-app
spec:
  replicas: 3
  selector:
    matchLabels:
      app: spring-app
  template:
    metadata:
      labels:
        app: spring-app
    spec:
      containers:
      - name: app
        image: ghcr.io/yourusername/spring-app:latest
        imagePullPolicy: Always
        ports:
        - containerPort: 8080
          name: http
        env:
        - name: SPRING_PROFILES_ACTIVE
          value: "k8s"
        - name: DB_HOST
          valueFrom:
            secretKeyRef:
              name: db-secret
              key: host
        - name: DB_USERNAME
          valueFrom:
            secretKeyRef:
              name: db-secret
              key: username
        - name: DB_PASSWORD
          valueFrom:
            secretKeyRef:
              name: db-secret
              key: password
        resources:
          requests:
            memory: "512Mi"
            cpu: "250m"
          limits:
            memory: "1Gi"
            cpu: "500m"
        livenessProbe:
          httpGet:
            path: /actuator/health/liveness
            port: 8080
          initialDelaySeconds: 60
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /actuator/health/readiness
            port: 8080
          initialDelaySeconds: 30
          periodSeconds: 5
        volumeMounts:
        - name: app-config
          mountPath: /app/config
          readOnly: true
      volumes:
      - name: app-config
        configMap:
          name: app-config
---
apiVersion: v1
kind: Service
metadata:
  name: spring-app-service
spec:
  selector:
    app: spring-app
  ports:
  - port: 80
    targetPort: 8080
  type: LoadBalancer

3. Docker Swarm 배포

 
yaml
# docker-stack.yml
version: '3.8'

services:
  app:
    image: ghcr.io/yourusername/spring-app:latest
    deploy:
      replicas: 3
      update_config:
        parallelism: 1
        delay: 10s
        failure_action: rollback
      restart_policy:
        condition: on-failure
        delay: 5s
        max_attempts: 3
      placement:
        constraints:
          - node.role == worker
    networks:
      - app-net
    secrets:
      - db_password
      - app_secret
    configs:
      - source: app_config
        target: /app/application.yml
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
      interval: 30s
      timeout: 10s
      retries: 3

networks:
  app-net:
    driver: overlay
    
secrets:
  db_password:
    external: true
  app_secret:
    external: true
    
configs:
  app_config:
    external: true

배포 명령:

 
bash
# Docker Swarm 초기화
docker swarm init

# 시크릿 생성
echo "mydbpass" | docker secret create db_password -
echo "myappsecret" | docker secret create app_secret -

# 설정 생성
docker config create app_config ./application-prod.yml

# 스택 배포
docker stack deploy -c docker-stack.yml myapp

# 서비스 확인
docker service ls
docker service ps myapp_app

보안 및 최적화

1. 이미지 보안 스캔

 
bash
# Trivy를 사용한 취약점 스캔
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
  aquasec/trivy image your-image:tag

# Dockerfile 린트
docker run --rm -i hadolint/hadolint < Dockerfile

2. 이미지 크기 최적화

 
dockerfile
# 최적화된 Dockerfile
FROM eclipse-temurin:17-jre-alpine AS runtime

# 불필요한 파일 제거
RUN apk add --no-cache tzdata && \
    cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \
    echo "Asia/Seoul" > /etc/timezone && \
    apk del tzdata

# distroless 이미지 사용 (더 작은 크기)
FROM gcr.io/distroless/java17-debian11

COPY --from=runtime /etc/localtime /etc/localtime
COPY --from=runtime /etc/timezone /etc/timezone

WORKDIR /app
COPY --chown=nonroot:nonroot target/*.jar app.jar

USER nonroot

EXPOSE 8080

ENTRYPOINT ["java", "-jar", "app.jar"]

3. 보안 설정

 
yaml
# docker-compose.security.yml
version: '3.8'

services:
  app:
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE
    read_only: true
    tmpfs:
      - /tmp
    user: "1000:1000"

4. 리소스 제한

 
yaml
# docker-compose.resources.yml
version: '3.8'

services:
  app:
    deploy:
      resources:
        limits:
          cpus: '1.0'
          memory: 1G
        reservations:
          cpus: '0.5'
          memory: 512M
    ulimits:
      nofile:
        soft: 65536
        hard: 65536

트러블슈팅

1. 빌드 관련 문제

문제: 빌드 캐시로 인한 문제

 
bash
# 캐시 없이 빌드
docker build --no-cache -t myapp .

# 모든 빌드 캐시 삭제
docker builder prune -a

문제: 의존성 다운로드 실패

 
dockerfile
# 타임아웃 증가
RUN mvn dependency:go-offline -B \
    -Dmaven.wagon.http.timeout=120000 \
    -Dmaven.wagon.http.retryHandler.count=3

2. 실행 관련 문제

문제: 메모리 부족

 
bash
# 컨테이너 메모리 사용량 확인
docker stats

# JVM 힙 덤프 생성
docker exec <container-id> jcmd 1 GC.heap_dump /tmp/heapdump.hprof
docker cp <container-id>:/tmp/heapdump.hprof .

문제: 포트 충돌

 
bash
# 사용 중인 포트 확인
sudo lsof -i :8080
netstat -tulpn | grep :8080

# 다른 포트로 실행
docker run -p 8081:8080 myapp

3. 네트워크 문제

문제: 컨테이너 간 통신 불가

 
bash
# 네트워크 확인
docker network ls
docker network inspect bridge

# 컨테이너 네트워크 확인
docker inspect <container> | grep -i network

# ping 테스트
docker exec app ping db

4. 디버깅 팁

 
bash
# 실행 중인 컨테이너에 접속
docker exec -it <container> /bin/sh

# 로그 실시간 확인
docker logs -f <container>

# 특정 시간 이후 로그만 보기
docker logs --since 10m <container>

# 컨테이너 상세 정보
docker inspect <container>

# 프로세스 확인
docker top <container>

# 파일 시스템 변경 사항 확인
docker diff <container>

마무리

Docker는 현대 소프트웨어 개발의 필수 도구입니다. 이 가이드를 통해 Spring Boot 애플리케이션을 효과적으로 컨테이너화하고 배포할 수 있기를 바랍니다.

핵심 체크리스트

  • ✅ 멀티 스테이지 빌드로 이미지 크기 최적화
  • ✅ 보안을 위한 non-root 사용자 실행
  • ✅ 헬스체크 구성
  • ✅ 환경별 설정 분리
  • ✅ CI/CD 파이프라인 구축
  • ✅ 모니터링 및 로깅 설정

다음 단계

  • Kubernetes 오케스트레이션
  • 서비스 메시 (Istio/Linkerd)
  • GitOps (ArgoCD/Flux)
  • 카나리 배포 전략

태그: #Docker #SpringBoot #DevOps #Containerization #Kubernetes

728x90
반응형
2025-07-21 19:07:53
728x90
반응형

🗄️ 들어가며

"개발할 때는 잘 되는데, 배포하니까 데이터가 다 사라졌어요!"

H2 인메모리 데이터베이스로 개발하다가 겪는 흔한 문제입니다. 이제는 실제 운영 환경에서 사용할 수 있는 MariaDB/MySQL로 전환할 때입니다.

이 글에서는 Spring Boot 프로젝트를 H2에서 MariaDB/MySQL로 마이그레이션하는 완벽 가이드를 제공합니다. Docker를 활용한 설치부터 트러블슈팅까지, 실무에서 마주칠 모든 상황을 다룹니다.

📑 목차

  1. MariaDB vs MySQL 선택 가이드
  2. 설치 방법 3가지
  3. Spring Boot 연결 설정
  4. 데이터베이스 설정 최적화
  5. JPA 설정과 Dialect
  6. 마이그레이션 전략
  7. 트러블슈팅
  8. 운영 환경 베스트 프랙티스

MariaDB vs MySQL 선택 가이드

🤔 뭘 선택해야 할까?

구분MariaDBMySQL

라이선스 GPL v2 (완전 오픈소스) GPL + 상업 라이선스 (Oracle)
성능 일반적으로 더 빠름 대규모 엔터프라이즈 최적화
기능 더 많은 스토리지 엔진 Oracle 지원
호환성 MySQL 5.5까지 100% 호환 원조
커뮤니티 활발한 오픈소스 커뮤니티 Oracle 주도

💡 결론

  • 스타트업/중소기업: MariaDB 추천 (무료, 빠름, 기능 풍부)
  • 대기업/레거시: MySQL 추천 (Oracle 지원, 안정성)
  • Spring Boot: 둘 다 완벽 지원! 설정만 약간 다름

설치 방법 3가지

방법 1: Docker 🐳 (추천!)

MariaDB Docker 설치

 
yaml
# docker-compose.yml
version: '3.8'

services:
  mariadb:
    image: mariadb:11.2
    container_name: my-mariadb
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: root1234!
      MYSQL_DATABASE: myapp
      MYSQL_USER: myuser
      MYSQL_PASSWORD: mypass1234!
      TZ: Asia/Seoul
    ports:
      - "3306:3306"
    volumes:
      - mariadb_data:/var/lib/mysql
      - ./init:/docker-entrypoint-initdb.d  # 초기화 SQL
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_unicode_ci
      - --skip-character-set-client-handshake
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 10s
      timeout: 5s
      retries: 3

volumes:
  mariadb_data:
    driver: local

MySQL Docker 설치

 
yaml
# docker-compose.yml
version: '3.8'

services:
  mysql:
    image: mysql:8.0
    container_name: my-mysql
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: root1234!
      MYSQL_DATABASE: myapp
      MYSQL_USER: myuser
      MYSQL_PASSWORD: mypass1234!
      TZ: Asia/Seoul
    ports:
      - "3306:3306"
    volumes:
      - mysql_data:/var/lib/mysql
      - ./init:/docker-entrypoint-initdb.d
    command:
      - --default-authentication-plugin=mysql_native_password
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_unicode_ci
      - --skip-character-set-client-handshake
      - --sql-mode=STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION

volumes:
  mysql_data:
    driver: local

실행 명령어

 
bash
# 시작
docker-compose up -d

# 로그 확인
docker-compose logs -f

# 접속 테스트
docker exec -it my-mariadb mysql -u root -p

# 종료
docker-compose down

# 데이터까지 삭제
docker-compose down -v

방법 2: 로컬 설치

Windows

 
powershell
# Chocolatey 사용
choco install mariadb

# 또는 공식 사이트에서 다운로드
# https://mariadb.org/download/

macOS

 
bash
# Homebrew 사용
brew install mariadb

# 서비스 시작
brew services start mariadb

# 보안 설정
mysql_secure_installation

Ubuntu/Debian

 
bash
# 패키지 업데이트
sudo apt update

# MariaDB 설치
sudo apt install mariadb-server mariadb-client

# 보안 설정
sudo mysql_secure_installation

# 서비스 상태 확인
sudo systemctl status mariadb

방법 3: 클라우드 서비스

AWS RDS

 
terraform
resource "aws_db_instance" "mariadb" {
  identifier     = "myapp-mariadb"
  engine         = "mariadb"
  engine_version = "10.11"
  instance_class = "db.t3.micro"
  
  allocated_storage = 20
  storage_type      = "gp3"
  
  db_name  = "myapp"
  username = "admin"
  password = var.db_password
  
  vpc_security_group_ids = [aws_security_group.rds.id]
  db_subnet_group_name   = aws_db_subnet_group.main.name
  
  skip_final_snapshot = true
  
  tags = {
    Name = "MyApp MariaDB"
  }
}

Spring Boot 연결 설정

1. 의존성 추가

 
xml
<!-- pom.xml -->
<dependencies>
    <!-- MariaDB -->
    <dependency>
        <groupId>org.mariadb.jdbc</groupId>
        <artifactId>mariadb-java-client</artifactId>
        <version>3.3.3</version>
    </dependency>
    
    <!-- 또는 MySQL -->
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>8.0.33</version>
    </dependency>
    
    <!-- Spring Data JPA -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    
    <!-- Connection Pool (HikariCP는 기본 포함) -->
    <!-- 추가 설정만 필요 -->
</dependencies>

Gradle:

 
gradle
dependencies {
    // MariaDB
    implementation 'org.mariadb.jdbc:mariadb-java-client:3.3.3'
    
    // 또는 MySQL
    implementation 'mysql:mysql-connector-java:8.0.33'
    
    // Spring Data JPA
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
}

2. application.yml 설정 (추천)

 
yaml
spring:
  # 프로파일별 설정
  profiles:
    active: local

---
# 로컬 개발 환경
spring:
  config:
    activate:
      on-profile: local
  
  datasource:
    url: jdbc:mariadb://localhost:3306/myapp?useSSL=false&allowPublicKeyRetrieval=true&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul
    username: myuser
    password: mypass1234!
    driver-class-name: org.mariadb.jdbc.Driver
    
    # HikariCP 설정
    hikari:
      pool-name: MyAppHikariPool
      maximum-pool-size: 10
      minimum-idle: 5
      idle-timeout: 300000
      connection-timeout: 20000
      max-lifetime: 1200000
      auto-commit: true
      connection-test-query: SELECT 1
      
  jpa:
    hibernate:
      ddl-auto: update  # 개발: update, 운영: validate
    properties:
      hibernate:
        dialect: org.hibernate.dialect.MariaDBDialect
        format_sql: true
        use_sql_comments: true
        default_batch_fetch_size: 100
        jdbc:
          batch_size: 25
          batch_versioned_data: true
        order_inserts: true
        order_updates: true
    show-sql: true
    open-in-view: false  # 성능 최적화

logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.type.descriptor.sql.BasicBinder: TRACE
    com.zaxxer.hikari: DEBUG

---
# 운영 환경
spring:
  config:
    activate:
      on-profile: prod
      
  datasource:
    url: jdbc:mariadb://${DB_HOST}:${DB_PORT}/${DB_NAME}?useSSL=true&requireSSL=true&useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}
    driver-class-name: org.mariadb.jdbc.Driver
    
    hikari:
      pool-name: ProdHikariPool
      maximum-pool-size: 30
      minimum-idle: 10
      idle-timeout: 600000
      connection-timeout: 30000
      max-lifetime: 1800000
      leak-detection-threshold: 60000
      
  jpa:
    hibernate:
      ddl-auto: validate
    properties:
      hibernate:
        dialect: org.hibernate.dialect.MariaDBDialect
        jdbc:
          batch_size: 50
    show-sql: false
    
logging:
  level:
    org.hibernate: WARN
    com.zaxxer.hikari: INFO

3. application.properties 설정

 
properties
# MariaDB 설정
spring.datasource.url=jdbc:mariadb://localhost:3306/myapp?createDatabaseIfNotExist=true&useSSL=false&allowPublicKeyRetrieval=true&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul
spring.datasource.username=myuser
spring.datasource.password=mypass1234!
spring.datasource.driver-class-name=org.mariadb.jdbc.Driver

# MySQL 설정 (대체)
#spring.datasource.url=jdbc:mysql://localhost:3306/myapp?createDatabaseIfNotExist=true&useSSL=false&allowPublicKeyRetrieval=true&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul
#spring.datasource.username=myuser
#spring.datasource.password=mypass1234!
#spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

# JPA 설정
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MariaDBDialect
#spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect

# HikariCP 설정
spring.datasource.hikari.maximum-pool-size=10
spring.datasource.hikari.minimum-idle=5
spring.datasource.hikari.connection-timeout=20000
spring.datasource.hikari.idle-timeout=300000

데이터베이스 설정 최적화

1. 초기 데이터베이스 생성

 
sql
-- init.sql
-- 데이터베이스 생성
CREATE DATABASE IF NOT EXISTS myapp 
CHARACTER SET utf8mb4 
COLLATE utf8mb4_unicode_ci;

-- 사용자 생성 및 권한 부여
CREATE USER IF NOT EXISTS 'myuser'@'%' IDENTIFIED BY 'mypass1234!';
GRANT ALL PRIVILEGES ON myapp.* TO 'myuser'@'%';
FLUSH PRIVILEGES;

-- 테이블 생성 예시
USE myapp;

CREATE TABLE IF NOT EXISTS users (
    id BIGINT NOT NULL AUTO_INCREMENT,
    username VARCHAR(50) NOT NULL UNIQUE,
    email VARCHAR(100) NOT NULL UNIQUE,
    password VARCHAR(255) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- 인덱스 생성
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_created_at ON users(created_at);

2. 성능 최적화 설정

 
sql
-- MariaDB/MySQL 튜닝 (my.cnf)
[mysqld]
# 기본 설정
character-set-server=utf8mb4
collation-server=utf8mb4_unicode_ci
init-connect='SET NAMES utf8mb4'

# InnoDB 설정
innodb_buffer_pool_size=1G  # RAM의 50-70%
innodb_log_file_size=256M
innodb_flush_log_at_trx_commit=2
innodb_flush_method=O_DIRECT
innodb_file_per_table=1

# 쿼리 캐시 (MySQL 8.0부터 제거됨)
query_cache_type=1  # MariaDB만
query_cache_size=128M

# 연결 설정
max_connections=200
connect_timeout=10
wait_timeout=600
interactive_timeout=600

# 로그 설정
slow_query_log=1
slow_query_log_file=/var/log/mysql/slow.log
long_query_time=2

# 기타 최적화
join_buffer_size=256K
sort_buffer_size=256K
read_buffer_size=256K
read_rnd_buffer_size=256K

JPA 설정과 Dialect

1. Hibernate Dialect 선택

 
java
// Hibernate 6.x 기준
// MariaDB
org.hibernate.dialect.MariaDBDialect         // 자동 버전 감지
org.hibernate.dialect.MariaDB103Dialect      // 10.3+
org.hibernate.dialect.MariaDB106Dialect      // 10.6+

// MySQL  
org.hibernate.dialect.MySQLDialect           // 자동 버전 감지
org.hibernate.dialect.MySQL8Dialect          // 8.0+
org.hibernate.dialect.MySQL57Dialect         // 5.7

2. 커스텀 Dialect

 
java
package com.myapp.config;

import org.hibernate.dialect.MariaDBDialect;
import org.hibernate.dialect.function.StandardSQLFunction;
import org.hibernate.type.StandardBasicTypes;

public class CustomMariaDBDialect extends MariaDBDialect {
    
    public CustomMariaDBDialect() {
        super();
        
        // 커스텀 함수 등록
        registerFunction("group_concat", 
            new StandardSQLFunction("group_concat", StandardBasicTypes.STRING));
        
        registerFunction("json_extract",
            new StandardSQLFunction("json_extract", StandardBasicTypes.STRING));
    }
    
    @Override
    public String getTableTypeString() {
        return " ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci";
    }
}

3. Entity 설정 최적화

 
java
@Entity
@Table(name = "users", indexes = {
    @Index(name = "idx_email", columnList = "email"),
    @Index(name = "idx_created_at", columnList = "createdAt")
})
@EntityListeners(AuditingEntityListener.class)
public class User {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false, unique = true, length = 50)
    private String username;
    
    @Column(nullable = false, unique = true, length = 100)
    private String email;
    
    @Column(nullable = false)
    private String password;
    
    @CreatedDate
    @Column(nullable = false, updatable = false)
    private LocalDateTime createdAt;
    
    @LastModifiedDate
    private LocalDateTime updatedAt;
    
    // JSON 타입 사용 (MariaDB 10.3+)
    @Column(columnDefinition = "JSON")
    @Convert(converter = JsonConverter.class)
    private Map<String, Object> metadata;
    
    // Getters and Setters
}

마이그레이션 전략

1. H2 → MariaDB/MySQL 데이터 마이그레이션

 
java
@Component
@Profile("migration")
public class DataMigrationRunner implements CommandLineRunner {
    
    @Autowired
    @Qualifier("h2DataSource")
    private DataSource h2DataSource;
    
    @Autowired
    @Qualifier("mariadbDataSource")
    private DataSource mariadbDataSource;
    
    @Override
    public void run(String... args) throws Exception {
        // 1. H2에서 데이터 읽기
        List<Map<String, Object>> data = readFromH2();
        
        // 2. MariaDB로 데이터 쓰기
        writeToMariaDB(data);
        
        // 3. 검증
        validateMigration();
    }
    
    private List<Map<String, Object>> readFromH2() {
        JdbcTemplate h2Jdbc = new JdbcTemplate(h2DataSource);
        return h2Jdbc.queryForList("SELECT * FROM users");
    }
    
    private void writeToMariaDB(List<Map<String, Object>> data) {
        JdbcTemplate mariadbJdbc = new JdbcTemplate(mariadbDataSource);
        
        String sql = "INSERT INTO users (username, email, password, created_at) VALUES (?, ?, ?, ?)";
        
        mariadbJdbc.batchUpdate(sql, new BatchPreparedStatementSetter() {
            @Override
            public void setValues(PreparedStatement ps, int i) throws SQLException {
                Map<String, Object> row = data.get(i);
                ps.setString(1, (String) row.get("username"));
                ps.setString(2, (String) row.get("email"));
                ps.setString(3, (String) row.get("password"));
                ps.setTimestamp(4, (Timestamp) row.get("created_at"));
            }
            
            @Override
            public int getBatchSize() {
                return data.size();
            }
        });
    }
}

2. Flyway를 이용한 마이그레이션

 
xml
<!-- pom.xml -->
<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-core</artifactId>
</dependency>
<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-mysql</artifactId>
</dependency>
 
yaml
# application.yml
spring:
  flyway:
    enabled: true
    baseline-on-migrate: true
    locations: classpath:db/migration
    sql-migration-prefix: V
    sql-migration-suffixes: .sql
    validate-on-migrate: true
 
sql
-- src/main/resources/db/migration/V1__init_schema.sql
CREATE TABLE IF NOT EXISTS users (
    id BIGINT NOT NULL AUTO_INCREMENT,
    username VARCHAR(50) NOT NULL UNIQUE,
    email VARCHAR(100) NOT NULL UNIQUE,
    password VARCHAR(255) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- src/main/resources/db/migration/V2__add_user_roles.sql
CREATE TABLE IF NOT EXISTS roles (
    id BIGINT NOT NULL AUTO_INCREMENT,
    name VARCHAR(50) NOT NULL UNIQUE,
    PRIMARY KEY (id)
);

CREATE TABLE IF NOT EXISTS user_roles (
    user_id BIGINT NOT NULL,
    role_id BIGINT NOT NULL,
    PRIMARY KEY (user_id, role_id),
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
    FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
);

트러블슈팅

1. 연결 문제

오류: Access denied for user

 
Access denied for user 'root'@'localhost' (using password: YES)

해결방법:

 
bash
# MariaDB/MySQL 접속
sudo mysql -u root

# 권한 재설정
ALTER USER 'root'@'localhost' IDENTIFIED BY 'newpassword';
FLUSH PRIVILEGES;

# 또는 새 사용자 생성
CREATE USER 'myuser'@'%' IDENTIFIED BY 'mypass1234!';
GRANT ALL PRIVILEGES ON *.* TO 'myuser'@'%' WITH GRANT OPTION;
FLUSH PRIVILEGES;

오류: Unknown database

 
Unknown database 'myapp'

해결방법:

 
properties
# URL에 createDatabaseIfNotExist 추가
spring.datasource.url=jdbc:mariadb://localhost:3306/myapp?createDatabaseIfNotExist=true

오류: Public Key Retrieval

 
Public Key Retrieval is not allowed

해결방법:

 
properties
# 개발 환경에서만 사용
spring.datasource.url=jdbc:mariadb://localhost:3306/myapp?allowPublicKeyRetrieval=true&useSSL=false

2. 문자 인코딩 문제

오류: Incorrect string value

 
Incorrect string value: '\xF0\x9F\x98\x80' for column 'content'

해결방법:

 
sql
-- 데이터베이스 인코딩 변경
ALTER DATABASE myapp CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- 테이블 인코딩 변경
ALTER TABLE posts CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- 컬럼 인코딩 변경
ALTER TABLE posts MODIFY content TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

3. 타임존 문제

오류: The server time zone value 'KST' is unrecognized

 
The server time zone value 'KST' is unrecognized

해결방법:

 
properties
# URL에 serverTimezone 추가
spring.datasource.url=jdbc:mariadb://localhost:3306/myapp?serverTimezone=Asia/Seoul

# 또는 UTC 사용
spring.datasource.url=jdbc:mariadb://localhost:3306/myapp?serverTimezone=UTC

4. Dialect 문제

오류: Unable to resolve name MariaDB103Dialect

 
Unable to resolve name [org.hibernate.dialect.MariaDB103Dialect] as strategy [org.hibernate.dialect.Dialect]

해결방법:

 
properties
# Hibernate 6.x용 Dialect 사용
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MariaDBDialect

# 또는 MySQL
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect

5. 성능 문제

N+1 쿼리 문제

 
java
// 문제: Lazy Loading으로 인한 N+1
@Entity
public class Post {
    @ManyToOne(fetch = FetchType.LAZY)
    private User author;
    
    @OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
    private List<Comment> comments;
}

// 해결: Fetch Join 사용
@Query("SELECT p FROM Post p LEFT JOIN FETCH p.author LEFT JOIN FETCH p.comments WHERE p.id = :id")
Optional<Post> findByIdWithAuthorAndComments(@Param("id") Long id);

// 또는 @EntityGraph 사용
@EntityGraph(attributePaths = {"author", "comments"})
Optional<Post> findById(Long id);

운영 환경 베스트 프랙티스

1. 환경 변수 사용

 
yaml
spring:
  datasource:
    url: jdbc:mariadb://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:myapp}
    username: ${DB_USERNAME:myuser}
    password: ${DB_PASSWORD:mypass1234!}

2. 연결 풀 모니터링

 
java
@Configuration
public class DataSourceConfig {
    
    @Bean
    @ConfigurationProperties("spring.datasource.hikari")
    public HikariDataSource dataSource() {
        return DataSourceBuilder.create()
            .type(HikariDataSource.class)
            .build();
    }
    
    @Bean
    public MeterRegistry meterRegistry() {
        return new SimpleMeterRegistry();
    }
    
    @EventListener
    public void handleContextRefresh(ContextRefreshedEvent event) {
        HikariDataSource dataSource = event.getApplicationContext()
            .getBean(HikariDataSource.class);
        
        dataSource.setMetricRegistry(meterRegistry());
    }
}

3. 읽기/쓰기 분리

 
java
@Configuration
public class DataSourceConfig {
    
    @Bean
    @Primary
    @ConfigurationProperties(prefix = "spring.datasource.write")
    public DataSource writeDataSource() {
        return DataSourceBuilder.create().build();
    }
    
    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.read")
    public DataSource readDataSource() {
        return DataSourceBuilder.create().build();
    }
    
    @Bean
    public DataSource routingDataSource(
            @Qualifier("writeDataSource") DataSource writeDataSource,
            @Qualifier("readDataSource") DataSource readDataSource) {
        
        Map<Object, Object> dataSourceMap = new HashMap<>();
        dataSourceMap.put("write", writeDataSource);
        dataSourceMap.put("read", readDataSource);
        
        RoutingDataSource routingDataSource = new RoutingDataSource();
        routingDataSource.setTargetDataSources(dataSourceMap);
        routingDataSource.setDefaultTargetDataSource(writeDataSource);
        
        return routingDataSource;
    }
}

4. 백업 전략

 
bash
#!/bin/bash
# backup.sh

DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="/backup/mariadb"
DB_NAME="myapp"
DB_USER="backup_user"
DB_PASS="backup_pass"

# 백업 실행
mysqldump -u $DB_USER -p$DB_PASS \
  --single-transaction \
  --routines \
  --triggers \
  --events \
  --add-drop-database \
  --databases $DB_NAME | gzip > "$BACKUP_DIR/backup_$DATE.sql.gz"

# 30일 이상된 백업 삭제
find $BACKUP_DIR -name "backup_*.sql.gz" -mtime +30 -delete

5. 모니터링

 
sql
-- 느린 쿼리 확인
SELECT * FROM mysql.slow_log ORDER BY query_time DESC LIMIT 10;

-- 현재 실행 중인 쿼리
SHOW FULL PROCESSLIST;

-- 테이블 상태 확인
SHOW TABLE STATUS FROM myapp;

-- 인덱스 사용 통계
SELECT * FROM information_schema.index_statistics 
WHERE table_schema = 'myapp' 
ORDER BY rows_read DESC;

마무리

H2에서 MariaDB/MySQL로의 전환은 실제 운영 환경으로 나아가는 중요한 단계입니다. 이 가이드를 통해 안정적이고 확장 가능한 데이터베이스 환경을 구축할 수 있기를 바랍니다.

체크리스트

  • ✅ Docker 또는 로컬 DB 설치
  • ✅ Spring Boot 의존성 및 설정
  • ✅ 문자 인코딩 (utf8mb4) 설정
  • ✅ 타임존 설정
  • ✅ 연결 풀 최적화
  • ✅ 백업 전략 수립
  • ✅ 모니터링 도구 설정

다음 단계

  • Redis 캐싱 적용
  • 읽기 전용 복제본 구성
  • 파티셔닝 전략
  • 샤딩 구현

태그: #SpringBoot #MariaDB #MySQL #Database #JPA #Docker

728x90
반응형
2025-07-20 19:54:04
728x90
반응형

🎨 들어가며

백엔드 API는 완성했는데, 이제 어떻게 화면에 보여줄까요? React나 Vue.js를 배워야 할까요?

잠깐! Spring Boot와 함께라면 Thymeleaf로 충분합니다. 서버 사이드 렌더링의 강력함과 Bootstrap의 예쁜 디자인을 결합하여, 빠르게 실무에서 사용 가능한 웹 애플리케이션을 만들어보겠습니다.

이 글에서는 Spring Boot CRUD API에 Thymeleaf 템플릿 엔진을 적용하여 완전한 웹 애플리케이션으로 변신시키는 과정을 다룹니다.

📑 목차

  1. Thymeleaf란?
  2. 프로젝트 설정
  3. Controller 리팩토링
  4. 레이아웃 템플릿 만들기
  5. CRUD 페이지 구현
  6. 폼 유효성 검증
  7. JavaScript 연동
  8. 에러 처리
  9. 실전 팁

Thymeleaf란?

🌿 특징

Thymeleaf는 Java 템플릿 엔진으로, HTML을 그대로 사용하면서 동적 콘텐츠를 생성할 수 있습니다.

 
html
<!-- 일반 HTML처럼 보이지만 -->
<p>안녕하세요, <span th:text="${user.name}">손님</span>님!</p>

<!-- 서버에서 렌더링되면 -->
<p>안녕하세요, <span>김한동</span>님!</p>

장점

  • Natural Template: 브라우저에서 바로 열어도 깨지지 않음
  • Spring Boot 통합: 설정이 간단하고 Spring과 완벽 호환
  • 서버 사이드 렌더링: SEO 최적화, 초기 로딩 속도 빠름
  • 학습 곡선 완만: HTML만 알면 바로 시작 가능

단점

  • ❌ SPA(Single Page Application) 구현 어려움
  • ❌ 복잡한 상호작용은 JavaScript 필요
  • ❌ 페이지 전체 새로고침

프로젝트 설정

1. 의존성 추가

 
xml
<dependencies>
    <!-- Spring Boot Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    
    <!-- Thymeleaf -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>
    
    <!-- Thymeleaf Layout Dialect -->
    <dependency>
        <groupId>nz.net.ultraq.thymeleaf</groupId>
        <artifactId>thymeleaf-layout-dialect</artifactId>
        <version>3.3.0</version>
    </dependency>
    
    <!-- Bootstrap (WebJar) -->
    <dependency>
        <groupId>org.webjars</groupId>
        <artifactId>bootstrap</artifactId>
        <version>5.3.0</version>
    </dependency>
    
    <!-- jQuery (WebJar) -->
    <dependency>
        <groupId>org.webjars</groupId>
        <artifactId>jquery</artifactId>
        <version>3.7.1</version>
    </dependency>
</dependencies>

2. application.properties 설정

 
properties
# Thymeleaf 설정
spring.thymeleaf.cache=false  # 개발 중에는 캐시 비활성화
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html
spring.thymeleaf.mode=HTML
spring.thymeleaf.encoding=UTF-8
spring.thymeleaf.servlet.content-type=text/html

# 정적 리소스 설정
spring.web.resources.static-locations=classpath:/static/
spring.web.resources.cache.period=0

3. 프로젝트 구조

 
src/main/resources/
├── templates/
│   ├── layout/
│   │   └── layout.html         # 공통 레이아웃
│   ├── students/
│   │   ├── list.html          # 목록 페이지
│   │   ├── form.html          # 등록/수정 폼
│   │   └── detail.html        # 상세 페이지
│   ├── error/
│   │   ├── 404.html          # 404 에러 페이지
│   │   └── 500.html          # 500 에러 페이지
│   └── index.html             # 홈페이지
└── static/
    ├── css/
    │   └── style.css          # 커스텀 CSS
    ├── js/
    │   └── app.js             # 커스텀 JavaScript
    └── images/
        └── logo.png           # 이미지 파일

Controller 리팩토링

REST Controller → View Controller 변환

기존의 @RestController를 @Controller로 변경하고 뷰를 반환하도록 수정합니다.

 
java
package com.university.controller;

import com.university.dto.StudentDto;
import com.university.service.StudentService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import jakarta.validation.Valid;

@Controller  // @RestController 대신 @Controller
@RequestMapping("/students")
@RequiredArgsConstructor
public class StudentController {
    
    private final StudentService studentService;
    
    // 학생 목록 페이지
    @GetMapping
    public String listStudents(
            @RequestParam(required = false) String search,
            Model model) {
        
        if (search != null && !search.isEmpty()) {
            model.addAttribute("students", studentService.searchStudents(search));
            model.addAttribute("search", search);
        } else {
            model.addAttribute("students", studentService.getAllStudents());
        }
        
        return "students/list";  // templates/students/list.html
    }
    
    // 학생 등록 폼
    @GetMapping("/new")
    public String showCreateForm(Model model) {
        model.addAttribute("student", new StudentDto());
        model.addAttribute("pageTitle", "학생 등록");
        return "students/form";
    }
    
    // 학생 등록 처리
    @PostMapping
    public String createStudent(
            @Valid @ModelAttribute("student") StudentDto studentDto,
            BindingResult result,
            RedirectAttributes redirectAttributes) {
        
        if (result.hasErrors()) {
            return "students/form";
        }
        
        try {
            studentService.createStudent(studentDto);
            redirectAttributes.addFlashAttribute("successMessage", 
                "학생이 성공적으로 등록되었습니다.");
            return "redirect:/students";
        } catch (Exception e) {
            redirectAttributes.addFlashAttribute("errorMessage", 
                "등록 중 오류가 발생했습니다: " + e.getMessage());
            return "redirect:/students/new";
        }
    }
    
    // 학생 수정 폼
    @GetMapping("/edit/{id}")
    public String showEditForm(@PathVariable Long id, Model model) {
        StudentDto student = studentService.getStudentById(id);
        model.addAttribute("student", student);
        model.addAttribute("pageTitle", "학생 정보 수정");
        return "students/form";
    }
    
    // 학생 수정 처리
    @PostMapping("/edit/{id}")
    public String updateStudent(
            @PathVariable Long id,
            @Valid @ModelAttribute("student") StudentDto studentDto,
            BindingResult result,
            RedirectAttributes redirectAttributes) {
        
        if (result.hasErrors()) {
            return "students/form";
        }
        
        try {
            studentService.updateStudent(id, studentDto);
            redirectAttributes.addFlashAttribute("successMessage", 
                "학생 정보가 수정되었습니다.");
            return "redirect:/students";
        } catch (Exception e) {
            redirectAttributes.addFlashAttribute("errorMessage", 
                "수정 중 오류가 발생했습니다: " + e.getMessage());
            return "redirect:/students/edit/" + id;
        }
    }
    
    // 학생 삭제
    @PostMapping("/delete/{id}")
    public String deleteStudent(
            @PathVariable Long id,
            RedirectAttributes redirectAttributes) {
        
        try {
            studentService.deleteStudent(id);
            redirectAttributes.addFlashAttribute("successMessage", 
                "학생이 삭제되었습니다.");
        } catch (Exception e) {
            redirectAttributes.addFlashAttribute("errorMessage", 
                "삭제 중 오류가 발생했습니다: " + e.getMessage());
        }
        
        return "redirect:/students";
    }
    
    // 학생 상세 정보
    @GetMapping("/{id}")
    public String viewStudent(@PathVariable Long id, Model model) {
        model.addAttribute("student", studentService.getStudentById(id));
        return "students/detail";
    }
}

레이아웃 템플릿 만들기

공통 레이아웃 (layout/layout.html)

 
html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    
    <!-- 페이지별 제목 -->
    <title layout:title-pattern="$CONTENT_TITLE - $LAYOUT_TITLE">학생 관리 시스템</title>
    
    <!-- Bootstrap CSS -->
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" 
          rel="stylesheet">
    
    <!-- Bootstrap Icons -->
    <link rel="stylesheet" 
          href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
    
    <!-- Custom CSS -->
    <link th:href="@{/css/style.css}" rel="stylesheet">
    
    <!-- 페이지별 추가 CSS -->
    <th:block layout:fragment="css"></th:block>
</head>
<body>
    <!-- Navigation -->
    <nav class="navbar navbar-expand-lg navbar-dark bg-primary">
        <div class="container">
            <a class="navbar-brand" th:href="@{/}">
                <i class="bi bi-mortarboard-fill"></i>
                학생 관리 시스템
            </a>
            
            <button class="navbar-toggler" type="button" 
                    data-bs-toggle="collapse" data-bs-target="#navbarNav">
                <span class="navbar-toggler-icon"></span>
            </button>
            
            <div class="collapse navbar-collapse" id="navbarNav">
                <ul class="navbar-nav ms-auto">
                    <li class="nav-item">
                        <a class="nav-link" th:href="@{/}" 
                           th:classappend="${#httpServletRequest.requestURI == '/'} ? 'active'">
                            <i class="bi bi-house"></i> 홈
                        </a>
                    </li>
                    <li class="nav-item">
                        <a class="nav-link" th:href="@{/students}"
                           th:classappend="${#strings.contains(#httpServletRequest.requestURI, '/students')} ? 'active'">
                            <i class="bi bi-people"></i> 학생 관리
                        </a>
                    </li>
                    <li class="nav-item">
                        <a class="nav-link" th:href="@{/students/new}">
                            <i class="bi bi-person-plus"></i> 학생 등록
                        </a>
                    </li>
                </ul>
            </div>
        </div>
    </nav>
    
    <!-- Alert Messages -->
    <div class="container mt-3">
        <!-- Success Message -->
        <div th:if="${successMessage}" 
             class="alert alert-success alert-dismissible fade show" role="alert">
            <i class="bi bi-check-circle"></i>
            <span th:text="${successMessage}"></span>
            <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
        </div>
        
        <!-- Error Message -->
        <div th:if="${errorMessage}" 
             class="alert alert-danger alert-dismissible fade show" role="alert">
            <i class="bi bi-exclamation-circle"></i>
            <span th:text="${errorMessage}"></span>
            <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
        </div>
    </div>
    
    <!-- Main Content -->
    <main class="container my-4">
        <div layout:fragment="content">
            <!-- 각 페이지의 콘텐츠가 여기에 삽입됩니다 -->
        </div>
    </main>
    
    <!-- Footer -->
    <footer class="bg-light py-4 mt-5">
        <div class="container text-center">
            <p class="text-muted mb-0">
                &copy; <span th:text="${#dates.year(#dates.createNow())}"></span> 
                학생 관리 시스템. All rights reserved.
            </p>
        </div>
    </footer>
    
    <!-- Bootstrap JS Bundle -->
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
    
    <!-- jQuery -->
    <script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
    
    <!-- Custom JS -->
    <script th:src="@{/js/app.js}"></script>
    
    <!-- 페이지별 추가 JavaScript -->
    <th:block layout:fragment="script"></th:block>
</body>
</html>

CRUD 페이지 구현

1. 학생 목록 페이지 (students/list.html)

 
html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="~{layout/layout}">
<head>
    <title>학생 목록</title>
</head>
<body>
<div layout:fragment="content">
    <!-- Page Header -->
    <div class="row mb-4">
        <div class="col">
            <h1 class="h2">
                <i class="bi bi-people-fill"></i> 학생 목록
            </h1>
        </div>
        <div class="col-auto">
            <a th:href="@{/students/new}" class="btn btn-primary">
                <i class="bi bi-plus-circle"></i> 새 학생 등록
            </a>
        </div>
    </div>
    
    <!-- Search Form -->
    <div class="card mb-4">
        <div class="card-body">
            <form th:action="@{/students}" method="get" class="row g-3">
                <div class="col-md-10">
                    <div class="input-group">
                        <span class="input-group-text">
                            <i class="bi bi-search"></i>
                        </span>
                        <input type="text" name="search" class="form-control" 
                               placeholder="이름, 학번, 이메일로 검색..."
                               th:value="${search}">
                    </div>
                </div>
                <div class="col-md-2">
                    <button type="submit" class="btn btn-primary w-100">검색</button>
                </div>
            </form>
        </div>
    </div>
    
    <!-- Students Table -->
    <div class="card">
        <div class="card-header">
            <span class="badge bg-secondary" th:text="${#lists.size(students)} + '명'"></span>
            의 학생이 등록되어 있습니다.
        </div>
        <div class="card-body p-0">
            <div class="table-responsive">
                <table class="table table-hover mb-0">
                    <thead class="table-light">
                        <tr>
                            <th>학번</th>
                            <th>이름</th>
                            <th>이메일</th>
                            <th>학과</th>
                            <th>전화번호</th>
                            <th>상태</th>
                            <th>등록일</th>
                            <th class="text-center">액션</th>
                        </tr>
                    </thead>
                    <tbody>
                        <!-- 학생 데이터 반복 -->
                        <tr th:each="student : ${students}" 
                            th:onclick="'window.location.href=\'' + @{/students/{id}(id=${student.id})} + '\''"
                            style="cursor: pointer;">
                            <td th:text="${student.studentId}">20240001</td>
                            <td>
                                <strong th:text="${student.name}">홍길동</strong>
                            </td>
                            <td>
                                <a th:href="'mailto:' + ${student.email}" 
                                   th:text="${student.email}"
                                   onclick="event.stopPropagation();">hong@example.com</a>
                            </td>
                            <td th:text="${student.department}">컴퓨터공학과</td>
                            <td th:text="${student.phoneNumber}">010-1234-5678</td>
                            <td>
                                <span class="badge"
                                      th:classappend="${student.status == 'ACTIVE'} ? 'bg-success' : 
                                                     (${student.status == 'INACTIVE'} ? 'bg-warning' : 'bg-secondary')"
                                      th:text="${student.status == 'ACTIVE'} ? '활동중' : 
                                              (${student.status == 'INACTIVE'} ? '비활동' : '졸업')">
                                    활동중
                                </span>
                            </td>
                            <td th:text="${#temporals.format(student.joinDate, 'yyyy-MM-dd')}">
                                2024-01-01
                            </td>
                            <td class="text-center" onclick="event.stopPropagation();">
                                <div class="btn-group btn-group-sm" role="group">
                                    <a th:href="@{/students/{id}(id=${student.id})}" 
                                       class="btn btn-outline-info" title="상세보기">
                                        <i class="bi bi-eye"></i>
                                    </a>
                                    <a th:href="@{/students/edit/{id}(id=${student.id})}" 
                                       class="btn btn-outline-primary" title="수정">
                                        <i class="bi bi-pencil"></i>
                                    </a>
                                    <button type="button" 
                                            class="btn btn-outline-danger delete-btn" 
                                            th:data-id="${student.id}"
                                            th:data-name="${student.name}"
                                            title="삭제">
                                        <i class="bi bi-trash"></i>
                                    </button>
                                </div>
                            </td>
                        </tr>
                        
                        <!-- 데이터가 없을 때 -->
                        <tr th:if="${#lists.isEmpty(students)}">
                            <td colspan="8" class="text-center py-5">
                                <div class="text-muted">
                                    <i class="bi bi-inbox display-1"></i>
                                    <p class="mt-3">등록된 학생이 없습니다.</p>
                                    <a th:href="@{/students/new}" class="btn btn-primary">
                                        첫 학생 등록하기
                                    </a>
                                </div>
                            </td>
                        </tr>
                    </tbody>
                </table>
            </div>
        </div>
    </div>
    
    <!-- Delete Confirmation Modal -->
    <div class="modal fade" id="deleteModal" tabindex="-1">
        <div class="modal-dialog">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title">
                        <i class="bi bi-exclamation-triangle text-danger"></i>
                        삭제 확인
                    </h5>
                    <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
                </div>
                <div class="modal-body">
                    <p>정말로 <strong id="deleteStudentName"></strong> 학생을 삭제하시겠습니까?</p>
                    <p class="text-danger mb-0">
                        <small>이 작업은 되돌릴 수 없습니다.</small>
                    </p>
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
                        취소
                    </button>
                    <form id="deleteForm" method="post" style="display: inline;">
                        <button type="submit" class="btn btn-danger">
                            <i class="bi bi-trash"></i> 삭제
                        </button>
                    </form>
                </div>
            </div>
        </div>
    </div>
</div>

<!-- Page Specific Scripts -->
<th:block layout:fragment="script">
<script>
$(document).ready(function() {
    // 삭제 버튼 클릭 이벤트
    $('.delete-btn').click(function() {
        const id = $(this).data('id');
        const name = $(this).data('name');
        
        $('#deleteStudentName').text(name);
        $('#deleteForm').attr('action', '/students/delete/' + id);
        
        const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
        deleteModal.show();
    });
    
    // 검색어 하이라이트
    const searchTerm = '[[${search}]]';
    if (searchTerm) {
        $('.table tbody td').each(function() {
            const text = $(this).text();
            const regex = new RegExp('(' + searchTerm + ')', 'gi');
            const highlighted = text.replace(regex, '<mark>$1</mark>');
            $(this).html(highlighted);
        });
    }
});
</script>
</th:block>
</body>
</html>

2. 학생 등록/수정 폼 (students/form.html)

 
html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="~{layout/layout}">
<head>
    <title th:text="${pageTitle}">학생 정보</title>
</head>
<body>
<div layout:fragment="content">
    <div class="row justify-content-center">
        <div class="col-md-8">
            <div class="card">
                <div class="card-header">
                    <h3 class="mb-0" th:text="${pageTitle}">학생 정보</h3>
                </div>
                <div class="card-body">
                    <form th:action="${student.id != null} ? @{/students/edit/{id}(id=${student.id})} : @{/students}"
                          th:object="${student}" 
                          method="post" 
                          novalidate>
                        
                        <!-- 학번 -->
                        <div class="mb-3">
                            <label for="studentId" class="form-label">
                                학번 <span class="text-danger">*</span>
                            </label>
                            <input type="text" 
                                   class="form-control" 
                                   id="studentId"
                                   th:field="*{studentId}"
                                   th:classappend="${#fields.hasErrors('studentId')} ? 'is-invalid'"
                                   placeholder="20240001"
                                   required>
                            <div class="invalid-feedback" 
                                 th:if="${#fields.hasErrors('studentId')}" 
                                 th:errors="*{studentId}">
                                학번 오류 메시지
                            </div>
                        </div>
                        
                        <!-- 이름 -->
                        <div class="mb-3">
                            <label for="name" class="form-label">
                                이름 <span class="text-danger">*</span>
                            </label>
                            <input type="text" 
                                   class="form-control" 
                                   id="name"
                                   th:field="*{name}"
                                   th:classappend="${#fields.hasErrors('name')} ? 'is-invalid'"
                                   placeholder="홍길동"
                                   required>
                            <div class="invalid-feedback" 
                                 th:if="${#fields.hasErrors('name')}" 
                                 th:errors="*{name}">
                                이름 오류 메시지
                            </div>
                        </div>
                        
                        <!-- 이메일 -->
                        <div class="mb-3">
                            <label for="email" class="form-label">
                                이메일 <span class="text-danger">*</span>
                            </label>
                            <input type="email" 
                                   class="form-control" 
                                   id="email"
                                   th:field="*{email}"
                                   th:classappend="${#fields.hasErrors('email')} ? 'is-invalid'"
                                   placeholder="hong@example.com"
                                   required>
                            <div class="invalid-feedback" 
                                 th:if="${#fields.hasErrors('email')}" 
                                 th:errors="*{email}">
                                이메일 오류 메시지
                            </div>
                        </div>
                        
                        <!-- 학과 -->
                        <div class="mb-3">
                            <label for="department" class="form-label">학과</label>
                            <select class="form-select" 
                                    id="department" 
                                    th:field="*{department}">
                                <option value="">선택하세요</option>
                                <option value="컴퓨터공학과">컴퓨터공학과</option>
                                <option value="전자공학과">전자공학과</option>
                                <option value="기계공학과">기계공학과</option>
                                <option value="경영학과">경영학과</option>
                                <option value="국제학부">국제학부</option>
                            </select>
                        </div>
                        
                        <!-- 전화번호 -->
                        <div class="mb-3">
                            <label for="phoneNumber" class="form-label">전화번호</label>
                            <input type="tel" 
                                   class="form-control" 
                                   id="phoneNumber"
                                   th:field="*{phoneNumber}"
                                   th:classappend="${#fields.hasErrors('phoneNumber')} ? 'is-invalid'"
                                   placeholder="010-1234-5678">
                            <div class="invalid-feedback" 
                                 th:if="${#fields.hasErrors('phoneNumber')}" 
                                 th:errors="*{phoneNumber}">
                                전화번호 오류 메시지
                            </div>
                        </div>
                        
                        <!-- 상태 (수정 시에만 표시) -->
                        <div class="mb-3" th:if="${student.id != null}">
                            <label for="status" class="form-label">상태</label>
                            <select class="form-select" 
                                    id="status" 
                                    th:field="*{status}">
                                <option value="ACTIVE">활동중</option>
                                <option value="INACTIVE">비활동</option>
                                <option value="GRADUATED">졸업</option>
                            </select>
                        </div>
                        
                        <!-- 자기소개 -->
                        <div class="mb-3">
                            <label for="introduction" class="form-label">자기소개</label>
                            <textarea class="form-control" 
                                      id="introduction" 
                                      th:field="*{introduction}"
                                      rows="3"
                                      placeholder="간단한 자기소개를 작성해주세요."></textarea>
                        </div>
                        
                        <!-- 버튼 -->
                        <div class="d-grid gap-2 d-md-flex justify-content-md-end">
                            <a th:href="@{/students}" class="btn btn-secondary">
                                <i class="bi bi-x-circle"></i> 취소
                            </a>
                            <button type="submit" class="btn btn-primary">
                                <i class="bi bi-check-circle"></i>
                                <span th:text="${student.id != null} ? '수정' : '등록'">등록</span>
                            </button>
                        </div>
                    </form>
                </div>
            </div>
        </div>
    </div>
</div>

<!-- Page Specific Scripts -->
<th:block layout:fragment="script">
<script>
$(document).ready(function() {
    // 전화번호 자동 포맷
    $('#phoneNumber').on('input', function() {
        let value = $(this).val().replace(/[^0-9]/g, '');
        let formatted = '';
        
        if (value.length <= 3) {
            formatted = value;
        } else if (value.length <= 7) {
            formatted = value.slice(0, 3) + '-' + value.slice(3);
        } else if (value.length <= 11) {
            formatted = value.slice(0, 3) + '-' + value.slice(3, 7) + '-' + value.slice(7);
        }
        
        $(this).val(formatted);
    });
    
    // 클라이언트 사이드 유효성 검증
    $('form').on('submit', function(e) {
        let isValid = true;
        
        // 학번 검증
        const studentId = $('#studentId').val();
        if (!/^\d{8}$/.test(studentId)) {
            $('#studentId').addClass('is-invalid');
            isValid = false;
        }
        
        // 이메일 검증
        const email = $('#email').val();
        if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
            $('#email').addClass('is-invalid');
            isValid = false;
        }
        
        if (!isValid) {
            e.preventDefault();
        }
    });
});
</script>
</th:block>
</body>
</html>

3. 학생 상세 페이지 (students/detail.html)

 
html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="~{layout/layout}">
<head>
    <title>학생 상세 정보</title>
</head>
<body>
<div layout:fragment="content">
    <div class="row">
        <div class="col-md-8 mx-auto">
            <!-- 학생 정보 카드 -->
            <div class="card mb-4">
                <div class="card-header bg-primary text-white">
                    <h3 class="mb-0">
                        <i class="bi bi-person-badge"></i>
                        학생 정보
                    </h3>
                </div>
                <div class="card-body">
                    <div class="row mb-3">
                        <div class="col-md-4 text-center mb-3">
                            <div class="avatar-placeholder">
                                <i class="bi bi-person-circle" style="font-size: 8rem;"></i>
                            </div>
                            <h4 class="mt-3" th:text="${student.name}">홍길동</h4>
                            <span class="badge" 
                                  th:classappend="${student.status == 'ACTIVE'} ? 'bg-success' : 
                                                 (${student.status == 'INACTIVE'} ? 'bg-warning' : 'bg-secondary')"
                                  th:text="${student.status == 'ACTIVE'} ? '활동중' : 
                                          (${student.status == 'INACTIVE'} ? '비활동' : '졸업')">
                                활동중
                            </span>
                        </div>
                        <div class="col-md-8">
                            <table class="table table-borderless">
                                <tr>
                                    <th class="text-muted" style="width: 30%;">학번</th>
                                    <td th:text="${student.studentId}">20240001</td>
                                </tr>
                                <tr>
                                    <th class="text-muted">이메일</th>
                                    <td>
                                        <a th:href="'mailto:' + ${student.email}" 
                                           th:text="${student.email}">
                                            hong@example.com
                                        </a>
                                    </td>
                                </tr>
                                <tr>
                                    <th class="text-muted">학과</th>
                                    <td th:text="${student.department ?: '-'}">컴퓨터공학과</td>
                                </tr>
                                <tr>
                                    <th class="text-muted">전화번호</th>
                                    <td>
                                        <a th:if="${student.phoneNumber}" 
                                           th:href="'tel:' + ${student.phoneNumber}" 
                                           th:text="${student.phoneNumber}">
                                            010-1234-5678
                                        </a>
                                        <span th:unless="${student.phoneNumber}">-</span>
                                    </td>
                                </tr>
                                <tr>
                                    <th class="text-muted">가입일</th>
                                    <td th:text="${#temporals.format(student.joinDate, 'yyyy년 MM월 dd일')}">
                                        2024년 01월 01일
                                    </td>
                                </tr>
                            </table>
                        </div>
                    </div>
                    
                    <!-- 자기소개 -->
                    <div th:if="${student.introduction}" class="mt-4">
                        <h5 class="text-muted">자기소개</h5>
                        <div class="card bg-light">
                            <div class="card-body">
                                <p class="mb-0" th:text="${student.introduction}">
                                    자기소개 내용이 여기에 표시됩니다.
                                </p>
                            </div>
                        </div>
                    </div>
                    
                    <!-- 타임스탬프 -->
                    <div class="text-muted small mt-4">
                        <div th:if="${student.createdAt}">
                            등록일시: <span th:text="${#temporals.format(student.createdAt, 'yyyy-MM-dd HH:mm:ss')}"></span>
                        </div>
                        <div th:if="${student.updatedAt}">
                            수정일시: <span th:text="${#temporals.format(student.updatedAt, 'yyyy-MM-dd HH:mm:ss')}"></span>
                        </div>
                    </div>
                </div>
                <div class="card-footer">
                    <div class="d-flex justify-content-between">
                        <a th:href="@{/students}" class="btn btn-secondary">
                            <i class="bi bi-arrow-left"></i> 목록으로
                        </a>
                        <div>
                            <a th:href="@{/students/edit/{id}(id=${student.id})}" 
                               class="btn btn-primary">
                                <i class="bi bi-pencil"></i> 수정
                            </a>
                            <button type="button" 
                                    class="btn btn-danger"
                                    th:data-id="${student.id}"
                                    th:data-name="${student.name}"
                                    onclick="confirmDelete(this)">
                                <i class="bi bi-trash"></i> 삭제
                            </button>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
</div>

<!-- Page Specific Scripts -->
<th:block layout:fragment="script">
<script>
function confirmDelete(button) {
    const id = button.getAttribute('data-id');
    const name = button.getAttribute('data-name');
    
    if (confirm(`정말로 ${name} 학생을 삭제하시겠습니까?`)) {
        const form = document.createElement('form');
        form.method = 'POST';
        form.action = `/students/delete/${id}`;
        document.body.appendChild(form);
        form.submit();
    }
}
</script>
</th:block>
</body>
</html>

폼 유효성 검증

DTO with Validation

 
java
package com.university.dto;

import jakarta.validation.constraints.*;
import lombok.Data;
import java.time.LocalDate;

@Data
public class StudentDto {
    
    private Long id;
    
    @NotBlank(message = "학번은 필수입니다")
    @Pattern(regexp = "^\\d{8}$", message = "학번은 8자리 숫자여야 합니다")
    private String studentId;
    
    @NotBlank(message = "이름은 필수입니다")
    @Size(min = 2, max = 50, message = "이름은 2~50자 사이여야 합니다")
    private String name;
    
    @NotBlank(message = "이메일은 필수입니다")
    @Email(message = "올바른 이메일 형식이 아닙니다")
    private String email;
    
    @Size(max = 50, message = "학과명은 50자를 초과할 수 없습니다")
    private String department;
    
    @Pattern(regexp = "^(010-\\d{4}-\\d{4})?$", 
             message = "전화번호는 010-xxxx-xxxx 형식이어야 합니다")
    private String phoneNumber;
    
    @Size(max = 500, message = "자기소개는 500자를 초과할 수 없습니다")
    private String introduction;
    
    private String status = "ACTIVE";
    
    private LocalDate joinDate;
}

전역 유효성 검증 메시지

src/main/resources/messages.properties:

 
properties
# 공통 메시지
NotBlank={0}은(는) 필수 입력 항목입니다.
NotNull={0}은(는) null일 수 없습니다.
Size={0}은(는) {2}자 이상 {1}자 이하여야 합니다.
Email=올바른 이메일 형식이 아닙니다.
Pattern={0}의 형식이 올바르지 않습니다.

# 필드별 메시지
studentId=학번
name=이름
email=이메일
department=학과
phoneNumber=전화번호
introduction=자기소개

# 커스텀 메시지
student.duplicate.studentId=이미 존재하는 학번입니다.
student.duplicate.email=이미 존재하는 이메일입니다.
student.notfound=학생을 찾을 수 없습니다.

JavaScript 연동

static/js/app.js

 
javascript
// 전역 설정
$(document).ready(function() {
    // CSRF 토큰 설정 (Spring Security 사용 시)
    const token = $("meta[name='_csrf']").attr("content");
    const header = $("meta[name='_csrf_header']").attr("content");
    
    if (token && header) {
        $.ajaxSetup({
            beforeSend: function(xhr) {
                xhr.setRequestHeader(header, token);
            }
        });
    }
    
    // 툴팁 초기화
    const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
    tooltipTriggerList.map(function (tooltipTriggerEl) {
        return new bootstrap.Tooltip(tooltipTriggerEl);
    });
    
    // 알림 메시지 자동 숨김
    setTimeout(function() {
        $('.alert').fadeOut('slow');
    }, 5000);
});

// 공통 함수
const StudentApp = {
    // Ajax 요청 래퍼
    ajax: function(url, method, data, successCallback, errorCallback) {
        $.ajax({
            url: url,
            method: method,
            data: JSON.stringify(data),
            contentType: 'application/json',
            success: successCallback,
            error: function(xhr, status, error) {
                if (errorCallback) {
                    errorCallback(xhr, status, error);
                } else {
                    alert('오류가 발생했습니다: ' + error);
                }
            }
        });
    },
    
    // 폼 데이터를 JSON으로 변환
    formToJson: function(formSelector) {
        const formData = {};
        $(formSelector).serializeArray().forEach(function(item) {
            formData[item.name] = item.value;
        });
        return formData;
    },
    
    // 로딩 표시
    showLoading: function() {
        $('body').append('<div class="loading-overlay"><div class="spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div></div>');
    },
    
    hideLoading: function() {
        $('.loading-overlay').remove();
    }
};

에러 처리

에러 페이지

templates/error/404.html:

 
html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="~{layout/layout}">
<head>
    <title>페이지를 찾을 수 없습니다</title>
</head>
<body>
<div layout:fragment="content">
    <div class="text-center py-5">
        <h1 class="display-1">404</h1>
        <p class="fs-3"><span class="text-danger">앗!</span> 페이지를 찾을 수 없습니다.</p>
        <p class="lead">요청하신 페이지가 존재하지 않습니다.</p>
        <a th:href="@{/}" class="btn btn-primary">홈으로 돌아가기</a>
    </div>
</div>
</body>
</html>

GlobalExceptionHandler

 
java
@ControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler(StudentNotFoundException.class)
    public String handleStudentNotFound(
            StudentNotFoundException ex,
            RedirectAttributes redirectAttributes) {
        redirectAttributes.addFlashAttribute("errorMessage", ex.getMessage());
        return "redirect:/students";
    }
    
    @ExceptionHandler(DataIntegrityViolationException.class)
    public String handleDataIntegrityViolation(
            DataIntegrityViolationException ex,
            RedirectAttributes redirectAttributes) {
        redirectAttributes.addFlashAttribute("errorMessage", 
            "데이터 무결성 오류가 발생했습니다. 입력값을 확인해주세요.");
        return "redirect:/students";
    }
}

실전 팁

1. Thymeleaf 유용한 표현식

 
html
<!-- 조건부 렌더링 -->
<div th:if="${user.role == 'ADMIN'}">관리자 메뉴</div>
<div th:unless="${user.role == 'ADMIN'}">일반 사용자 메뉴</div>

<!-- 삼항 연산자 -->
<span th:text="${user.active} ? '활성' : '비활성'"></span>

<!-- Switch 문 -->
<div th:switch="${user.status}">
    <p th:case="'ACTIVE'">활동중</p>
    <p th:case="'INACTIVE'">비활동</p>
    <p th:case="*">알 수 없음</p>
</div>

<!-- 컬렉션 반복 with 인덱스 -->
<tr th:each="item, stat : ${items}">
    <td th:text="${stat.index}">0</td>
    <td th:text="${stat.count}">1</td>
    <td th:text="${item.name}">이름</td>
    <td th:text="${stat.first} ? '첫번째' : ''"></td>
    <td th:text="${stat.last} ? '마지막' : ''"></td>
</tr>

<!-- Fragment 파라미터 -->
<div th:replace="~{fragments/pagination :: pagination(${page}, ${totalPages})}"></div>

<!-- JavaScript 인라인 -->
<script th:inline="javascript">
    const userId = /*[[${user.id}]]*/ 0;
    const userName = /*[[${user.name}]]*/ 'Guest';
</script>

2. 성능 최적화

 
properties
# 프로덕션 환경 설정
spring.thymeleaf.cache=true
spring.web.resources.cache.period=31536000
spring.web.resources.chain.strategy.content.enabled=true
spring.web.resources.chain.strategy.content.paths=/**

3. 보안 강화

 
html
<!-- CSRF 토큰 -->
<meta name="_csrf" th:content="${_csrf.token}"/>
<meta name="_csrf_header" th:content="${_csrf.headerName}"/>

<!-- XSS 방지 -->
<p th:text="${userInput}">자동 이스케이프</p>
<p th:utext="${trustedHtml}">HTML 허용 (주의!)</p>

4. 국제화 (i18n)

messages.properties:

 
properties
app.title=학생 관리 시스템
student.list.title=학생 목록
student.form.title=학생 정보

messages_en.properties:

 
properties
app.title=Student Management System
student.list.title=Student List
student.form.title=Student Information

사용:

 
html
<h1 th:text="#{student.list.title}">학생 목록</h1>

마무리

Thymeleaf는 Spring Boot와 완벽하게 통합되어 빠르게 웹 애플리케이션을 개발할 수 있게 해줍니다. 특히 관리자 페이지나 내부 시스템처럼 복잡한 상호작용이 필요 없는 경우에는 최적의 선택입니다.

장점 정리

  • 빠른 개발: 설정이 간단하고 바로 시작 가능
  • 서버 사이드 렌더링: SEO 친화적, 초기 로딩 빠름
  • Spring 통합: Spring Security, Validation 등과 완벽 호환
  • Natural Template: 디자이너와 협업 용이

언제 사용할까?

  • 관리자 대시보드
  • 기업 내부 시스템
  • SEO가 중요한 콘텐츠 사이트
  • 빠른 프로토타이핑

다음 단계

  • Spring Security 통합
  • WebSocket으로 실시간 기능 추가
  • PDF/Excel 내보내기
  • 파일 업로드 구현

태그: #SpringBoot #Thymeleaf #Bootstrap #CRUD #WebDevelopment #Java

작성일: 2025년 1월 20일

이 포스트가 도움이 되셨다면 ⭐️ 스타를 눌러주세요!

728x90
반응형
2025-07-11 09:30:30
728x90
반응형

Spring Boot H2 Console 사용 완벽 가이드

🗄️ H2 Console이란?

H2 Database는 자바 기반의 경량 인메모리 데이터베이스로, 개발 중에 매우 유용합니다. H2 Console은 웹 기반 데이터베이스 관리 도구로, 별도의 프로그램 설치 없이 브라우저에서 직접 데이터베이스를 조회하고 관리할 수 있습니다.

1. H2 Console 설정하기

application.properties 설정

 
properties
# H2 Console 활성화
spring.h2.console.enabled=true

# H2 Console 경로 설정 (기본값: /h2-console)
spring.h2.console.path=/h2-console

# 외부 접속 허용 (개발 환경에서만 사용)
spring.h2.console.settings.web-allow-others=true

# H2 Database 설정
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=

# JPA 설정
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true

2. H2 Console 접속하기

Step 1: 애플리케이션 실행

 
bash
mvn spring-boot:run
# 또는
./mvnw spring-boot:run

Step 2: 브라우저에서 H2 Console 열기

 
http://localhost:8080/h2-console

Step 3: 로그인 화면 설정

이미지 표시

로그인 화면에서 다음과 같이 입력합니다:

필드값설명
Driver Class org.h2.Driver H2 드라이버 클래스
JDBC URL jdbc:h2:mem:testdb 인메모리 DB URL
User Name sa 기본 사용자명
Password (비워둠) 비밀번호 없음

⚠️ 중요: JDBC URL은 application.properties의 spring.datasource.url과 정확히 일치해야 합니다!

Step 4: Connect 클릭

3. H2 Console 주요 기능

3-1. 테이블 목록 보기

접속 후 왼쪽 패널에서 테이블 목록을 확인할 수 있습니다:

 
MEMBERS
PROJECTS  
EVENTS
PROJECT_MEMBERS (조인 테이블)

3-2. 테이블 구조 확인

테이블 이름을 클릭하면 자동으로 SQL이 생성됩니다:

 
sql
-- MEMBERS 테이블 구조 보기
SHOW COLUMNS FROM MEMBERS;

-- 또는 테이블 생성 스크립트 보기
SCRIPT NODATA;

3-3. 데이터 조회

 
sql
-- 모든 회원 조회
SELECT * FROM MEMBERS;

-- 조건부 조회
SELECT * FROM MEMBERS WHERE STATUS = 'ACTIVE';

-- 조인 쿼리
SELECT m.*, p.TITLE 
FROM MEMBERS m
JOIN PROJECT_MEMBERS pm ON m.ID = pm.MEMBER_ID
JOIN PROJECTS p ON pm.PROJECT_ID = p.ID;

3-4. 데이터 삽입

 
sql
-- 회원 추가
INSERT INTO MEMBERS (STUDENT_ID, NAME, EMAIL, DEPARTMENT, ROLE, STATUS, JOIN_DATE) 
VALUES ('20240001', '김한동', 'kim@handong.edu', '전산전자공학부', 'MEMBER', 'ACTIVE', CURRENT_DATE);

-- 여러 회원 한번에 추가
INSERT INTO MEMBERS (STUDENT_ID, NAME, EMAIL, DEPARTMENT, ROLE, STATUS, JOIN_DATE) VALUES
('20240002', '이한동', 'lee@handong.edu', '전산전자공학부', 'MEMBER', 'ACTIVE', CURRENT_DATE),
('20240003', '박한동', 'park@handong.edu', 'AI융합교육원', 'VICE_PRESIDENT', 'ACTIVE', CURRENT_DATE),
('20240004', '최한동', 'choi@handong.edu', '경영경제학부', 'SECRETARY', 'ACTIVE', CURRENT_DATE);

3-5. 데이터 수정

 
sql
-- 회원 정보 수정
UPDATE MEMBERS 
SET ROLE = 'PRESIDENT', 
    UPDATED_AT = CURRENT_TIMESTAMP 
WHERE STUDENT_ID = '20240001';

-- 여러 회원 상태 변경
UPDATE MEMBERS 
SET STATUS = 'INACTIVE' 
WHERE JOIN_DATE < '2023-01-01';

3-6. 데이터 삭제

 
sql
-- 특정 회원 삭제
DELETE FROM MEMBERS WHERE ID = 1;

-- 조건부 삭제
DELETE FROM MEMBERS WHERE STATUS = 'GRADUATED';

4. 실전 활용 팁

4-1. 초기 데이터 설정

src/main/resources/data.sql 파일을 생성하면 애플리케이션 시작 시 자동 실행됩니다:

 
sql
-- data.sql
INSERT INTO MEMBERS (STUDENT_ID, NAME, EMAIL, DEPARTMENT, PHONE_NUMBER, ROLE, STATUS, JOIN_DATE, INTRODUCTION) VALUES
('20240001', '김한동', 'kim@handong.edu', '전산전자공학부', '010-1234-5678', 'PRESIDENT', 'ACTIVE', '2024-03-01', '안녕하세요, 회장 김한동입니다.'),
('20240002', '이한동', 'lee@handong.edu', '전산전자공학부', '010-2345-6789', 'VICE_PRESIDENT', 'ACTIVE', '2024-03-01', '부회장을 맡고 있습니다.'),
('20240003', '박한동', 'park@handong.edu', 'AI융합교육원', '010-3456-7890', 'SECRETARY', 'ACTIVE', '2024-03-15', '총무를 담당하고 있습니다.'),
('20240004', '최한동', 'choi@handong.edu', '경영경제학부', '010-4567-8901', 'MEMBER', 'ACTIVE', '2024-04-01', '열심히 활동하겠습니다!'),
('20240005', '정한동', 'jung@handong.edu', '기계제어공학부', '010-5678-9012', 'MEMBER', 'ACTIVE', '2024-04-15', '미디어 제작에 관심이 많습니다.');

INSERT INTO PROJECTS (TITLE, DESCRIPTION, PROJECT_TYPE, STATUS, START_DATE, END_DATE, LEADER_ID) VALUES
('2024 홍보 영상 제작', '학회 홍보를 위한 공식 영상 제작 프로젝트', '영상', 'IN_PROGRESS', '2024-05-01', '2024-06-30', 1),
('웹사이트 리뉴얼', '학회 공식 웹사이트 전면 개편', '웹개발', 'PLANNING', '2024-07-01', '2024-09-30', 2),
('사진전 준비', '연말 사진전시회 준비', '사진', 'PLANNING', '2024-10-01', '2024-12-20', 3);

INSERT INTO EVENTS (TITLE, DESCRIPTION, LOCATION, EVENT_DATE_TIME, EVENT_TYPE, MAX_PARTICIPANTS, CURRENT_PARTICIPANTS, ORGANIZER_ID) VALUES
('영상 편집 워크샵', 'Premiere Pro 기초부터 심화까지', '뉴턴홀 313호', '2024-05-15 14:00:00', 'WORKSHOP', 30, 15, 1),
('정기 총회', '2024년 상반기 정기 총회', '학생회관 세미나실', '2024-06-01 18:00:00', 'MEETING', 50, 0, 1),
('네트워킹 파티', '신입 회원 환영회 겸 네트워킹', '카페 한동', '2024-05-20 19:00:00', 'SOCIAL', 40, 25, 4);

-- 프로젝트 참여자 매핑
INSERT INTO PROJECT_MEMBERS (PROJECT_ID, MEMBER_ID) VALUES
(1, 1), (1, 2), (1, 4),
(2, 2), (2, 3), (2, 5),
(3, 3), (3, 4), (3, 5);

4-2. 유용한 쿼리 모음

 
sql
-- 1. 회원별 참여 프로젝트 수
SELECT m.NAME, COUNT(pm.PROJECT_ID) as PROJECT_COUNT
FROM MEMBERS m
LEFT JOIN PROJECT_MEMBERS pm ON m.ID = pm.MEMBER_ID
GROUP BY m.ID, m.NAME
ORDER BY PROJECT_COUNT DESC;

-- 2. 역할별 회원 수
SELECT ROLE, COUNT(*) as MEMBER_COUNT
FROM MEMBERS
WHERE STATUS = 'ACTIVE'
GROUP BY ROLE;

-- 3. 이번 달 예정된 이벤트
SELECT * FROM EVENTS
WHERE MONTH(EVENT_DATE_TIME) = MONTH(CURRENT_DATE)
AND YEAR(EVENT_DATE_TIME) = YEAR(CURRENT_DATE)
ORDER BY EVENT_DATE_TIME;

-- 4. 프로젝트별 참여 인원
SELECT p.TITLE, p.STATUS, COUNT(pm.MEMBER_ID) as PARTICIPANT_COUNT
FROM PROJECTS p
LEFT JOIN PROJECT_MEMBERS pm ON p.ID = pm.PROJECT_ID
GROUP BY p.ID, p.TITLE, p.STATUS;

-- 5. 참가 가능한 이벤트 (정원 미달)
SELECT TITLE, EVENT_DATE_TIME, 
       MAX_PARTICIPANTS - CURRENT_PARTICIPANTS as AVAILABLE_SEATS
FROM EVENTS
WHERE EVENT_DATE_TIME > CURRENT_TIMESTAMP
AND CURRENT_PARTICIPANTS < MAX_PARTICIPANTS
ORDER BY EVENT_DATE_TIME;

4-3. 데이터 내보내기

 
sql
-- 전체 데이터를 SQL 스크립트로 내보내기
SCRIPT;

-- 특정 테이블만 내보내기
SCRIPT SIMPLE NODATA FROM MEMBERS;

-- CSV로 내보내기
CALL CSVWRITE('~/members.csv', 'SELECT * FROM MEMBERS');

5. 문제 해결

5-1. H2 Console 접속 불가

문제: localhost:8080/h2-console 접속 시 404 에러

해결방법:

 
properties
# application.properties 확인
spring.h2.console.enabled=true

5-2. Database "testdb" not found

문제: JDBC URL이 맞지 않음

해결방법:

 
properties
# application.properties의 URL과 동일하게 입력
spring.datasource.url=jdbc:h2:mem:testdb

5-3. 테이블이 보이지 않음

문제: DDL이 실행되지 않음

해결방법:

 
properties
# JPA 설정 확인
spring.jpa.hibernate.ddl-auto=create-drop
# 또는
spring.jpa.hibernate.ddl-auto=update

5-4. 한글 깨짐

문제: 한글 데이터가 ???로 표시

해결방법:

 
properties
# URL에 인코딩 추가
spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;INIT=CREATE SCHEMA IF NOT EXISTS testdb\\;SET SCHEMA testdb

6. 보안 주의사항

🚨 운영 환경에서는 H2 Console 비활성화!

 
properties
# application-prod.properties
spring.h2.console.enabled=false

프로파일별 설정

 
properties
# application-dev.properties (개발용)
spring.h2.console.enabled=true
spring.h2.console.settings.web-allow-others=true

# application-prod.properties (운영용)
spring.h2.console.enabled=false

7. H2 Console 단축키

단축키기능
Ctrl + Enter 쿼리 실행
Ctrl + Space 자동완성
Ctrl + Shift + F 쿼리 포맷팅
Ctrl + H 히스토리
F5 스키마 새로고침

마무리

H2 Console은 Spring Boot 개발 시 데이터베이스를 빠르게 확인하고 테스트하는 데 매우 유용한 도구입니다. 특히 개발 초기 단계에서 엔티티 매핑이 제대로 되었는지, 관계 설정이 올바른지 확인할 때 필수적입니다.

핵심 포인트

  1. JDBC URL을 정확히 일치시켜야 합니다
  2. 개발 환경에서만 H2 Console을 활성화하세요
  3. 초기 데이터는 data.sql로 관리하면 편리합니다
  4. 복잡한 쿼리는 미리 저장해두고 사용하세요

이제 H2 Console을 활용해서 Spring Boot 개발을 더욱 효율적으로 진행해보세요! 🚀

728x90
반응형
2025-07-10 20:23:28
728x90
반응형

🌱 들어가며

"Spring Boot로 백엔드 개발을 시작하고 싶은데 어디서부터 시작해야 할지 모르겠어요."

많은 주니어 개발자들이 하는 고민입니다. 이 글에서는 Spring Boot를 사용해 CRUD(Create, Read, Update, Delete) REST API를 만드는 방법을 처음부터 끝까지 실습해보겠습니다. 실제 프로젝트에서 사용할 수 있는 학회 관리 시스템을 예제로 진행합니다.

📑 목차

  1. 프로젝트 개요
  2. 개발 환경 설정
  3. 프로젝트 생성
  4. 엔티티 설계
  5. Repository 구현
  6. Service 계층 구현
  7. Controller 구현
  8. 예외 처리
  9. 테스트
  10. Docker 배포

프로젝트 개요

🎯 목표

대학 학회 관리를 위한 REST API 백엔드 구축

📋 주요 기능

  • 회원 관리 (학회원 등록, 조회, 수정, 삭제)
  • 프로젝트 관리 (작품/프로젝트 CRUD)
  • 이벤트 관리 (행사 일정 CRUD)

🛠 기술 스택

  • Java 17
  • Spring Boot 3.2.x
  • Spring Data JPA
  • H2 Database (개발용)
  • Maven
  • Docker (배포용)

개발 환경 설정

필수 설치 프로그램

 
bash
# Java 17 설치 확인
java -version

# Maven 설치 확인
mvn -version

# Docker 설치 확인 (선택사항)
docker --version

IDE 추천

  • IntelliJ IDEA (Community Edition 무료)
  • Visual Studio Code + Spring Boot Extension Pack
  • Eclipse + Spring Tools Suite

프로젝트 생성

1. Spring Initializr 사용

start.spring.io 접속 후 다음과 같이 설정:

 
yaml
Project: Maven
Language: Java
Spring Boot: 3.2.5
Project Metadata:
  Group: com.university
  Artifact: club-management
  Name: club-management
  Package name: com.university.clubmanagement
  Packaging: Jar
  Java: 17

Dependencies:
  - Spring Web
  - Spring Data JPA
  - H2 Database
  - Lombok
  - Spring Boot DevTools

2. 프로젝트 구조

 
club-management/
├── src/
│   ├── main/
│   │   ├── java/
│   │   │   └── com/university/clubmanagement/
│   │   │       ├── controller/
│   │   │       ├── dto/
│   │   │       ├── entity/
│   │   │       ├── exception/
│   │   │       ├── repository/
│   │   │       ├── service/
│   │   │       └── ClubManagementApplication.java
│   │   └── resources/
│   │       ├── application.properties
│   │       └── data.sql (선택사항)
│   └── test/
├── pom.xml
└── README.md

3. pom.xml 구성

 
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.5</version>
        <relativePath/>
    </parent>
    
    <groupId>com.university</groupId>
    <artifactId>club-management</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>club-management</name>
    <description>Club Management System</description>
    
    <properties>
        <java.version>17</java.version>
    </properties>
    
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>
        
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </dependency>
        
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
    </dependencies>
    
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

4. application.properties 설정

 
properties
# Server Configuration
server.port=8080
spring.application.name=club-management

# Database Configuration (H2)
spring.datasource.url=jdbc:h2:mem:clubdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=

# JPA Configuration
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

# H2 Console Configuration
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
spring.h2.console.settings.web-allow-others=true

# Logging
logging.level.com.university.clubmanagement=DEBUG
logging.level.org.springframework.web=DEBUG

엔티티 설계

1. BaseEntity (공통 필드)

 
java
package com.university.clubmanagement.entity;

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

import java.time.LocalDateTime;

@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {
    
    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;
    
    @LastModifiedDate
    private LocalDateTime updatedAt;
}

2. Member 엔티티

 
java
package com.university.clubmanagement.entity;

import jakarta.persistence.*;
import lombok.*;

import java.time.LocalDate;

@Entity
@Table(name = "members")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Member extends BaseEntity {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false, unique = true, length = 20)
    private String studentId;
    
    @Column(nullable = false, length = 50)
    private String name;
    
    @Column(nullable = false, unique = true, length = 100)
    private String email;
    
    @Column(length = 50)
    private String department;
    
    @Column(length = 20)
    private String phoneNumber;
    
    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private MemberRole role = MemberRole.MEMBER;
    
    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private MemberStatus status = MemberStatus.ACTIVE;
    
    @Column(nullable = false)
    private LocalDate joinDate = LocalDate.now();
    
    @Column(columnDefinition = "TEXT")
    private String introduction;
}

3. Enum 클래스들

 
java
package com.university.clubmanagement.entity;

public enum MemberRole {
    PRESIDENT("회장"),
    VICE_PRESIDENT("부회장"),
    SECRETARY("총무"),
    MEMBER("일반회원");
    
    private final String description;
    
    MemberRole(String description) {
        this.description = description;
    }
    
    public String getDescription() {
        return description;
    }
}
 
java
package com.university.clubmanagement.entity;

public enum MemberStatus {
    ACTIVE("활동중"),
    INACTIVE("비활동"),
    GRADUATED("졸업");
    
    private final String description;
    
    MemberStatus(String description) {
        this.description = description;
    }
    
    public String getDescription() {
        return description;
    }
}

4. Project 엔티티

 
java
package com.university.clubmanagement.entity;

import jakarta.persistence.*;
import lombok.*;

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "projects")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Project extends BaseEntity {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false, length = 200)
    private String title;
    
    @Column(columnDefinition = "TEXT")
    private String description;
    
    @Column(length = 50)
    private String projectType;
    
    @Column(length = 500)
    private String thumbnailUrl;
    
    @Column(length = 500)
    private String projectUrl;
    
    private LocalDate startDate;
    private LocalDate endDate;
    
    @Enumerated(EnumType.STRING)
    @Column(length = 20)
    private ProjectStatus status = ProjectStatus.PLANNING;
    
    @ManyToMany
    @JoinTable(
        name = "project_members",
        joinColumns = @JoinColumn(name = "project_id"),
        inverseJoinColumns = @JoinColumn(name = "member_id")
    )
    private List<Member> participants = new ArrayList<>();
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "leader_id")
    private Member leader;
}
 
java
package com.university.clubmanagement.entity;

public enum ProjectStatus {
    PLANNING("기획중"),
    IN_PROGRESS("진행중"),
    COMPLETED("완료"),
    CANCELLED("취소");
    
    private final String description;
    
    ProjectStatus(String description) {
        this.description = description;
    }
    
    public String getDescription() {
        return description;
    }
}

5. Event 엔티티

 
java
package com.university.clubmanagement.entity;

import jakarta.persistence.*;
import lombok.*;

import java.time.LocalDateTime;

@Entity
@Table(name = "events")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Event extends BaseEntity {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false, length = 200)
    private String title;
    
    @Column(columnDefinition = "TEXT")
    private String description;
    
    @Column(length = 100)
    private String location;
    
    @Column(nullable = false)
    private LocalDateTime eventDateTime;
    
    @Enumerated(EnumType.STRING)
    @Column(length = 30)
    private EventType eventType;
    
    private Integer maxParticipants;
    
    @Builder.Default
    private Integer currentParticipants = 0;
    
    @Column(length = 500)
    private String imageUrl;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "organizer_id")
    private Member organizer;
}
 
java
package com.university.clubmanagement.entity;

public enum EventType {
    WORKSHOP("워크샵"),
    SEMINAR("세미나"),
    EXHIBITION("전시회"),
    COMPETITION("대회"),
    SOCIAL("친목행사"),
    MEETING("정기모임");
    
    private final String description;
    
    EventType(String description) {
        this.description = description;
    }
    
    public String getDescription() {
        return description;
    }
}

Repository 구현

1. MemberRepository

 
java
package com.university.clubmanagement.repository;

import com.university.clubmanagement.entity.Member;
import com.university.clubmanagement.entity.MemberRole;
import com.university.clubmanagement.entity.MemberStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
    
    // 학번으로 회원 찾기
    Optional<Member> findByStudentId(String studentId);
    
    // 이메일로 회원 찾기
    Optional<Member> findByEmail(String email);
    
    // 학번 중복 확인
    boolean existsByStudentId(String studentId);
    
    // 이메일 중복 확인
    boolean existsByEmail(String email);
    
    // 활성 회원만 조회
    List<Member> findByStatus(MemberStatus status);
    
    // 역할별 회원 조회
    List<Member> findByRole(MemberRole role);
    
    // 부서별 회원 조회
    List<Member> findByDepartment(String department);
    
    // 이름으로 검색 (부분 일치)
    List<Member> findByNameContaining(String name);
    
    // 복잡한 검색 쿼리
    @Query("SELECT m FROM Member m WHERE " +
           "(:name IS NULL OR m.name LIKE %:name%) AND " +
           "(:department IS NULL OR m.department = :department) AND " +
           "(:role IS NULL OR m.role = :role) AND " +
           "(:status IS NULL OR m.status = :status)")
    List<Member> searchMembers(@Param("name") String name,
                              @Param("department") String department,
                              @Param("role") MemberRole role,
                              @Param("status") MemberStatus status);
}

2. ProjectRepository

 
java
package com.university.clubmanagement.repository;

import com.university.clubmanagement.entity.Project;
import com.university.clubmanagement.entity.ProjectStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.time.LocalDate;
import java.util.List;

@Repository
public interface ProjectRepository extends JpaRepository<Project, Long> {
    
    // 프로젝트 타입별 조회
    List<Project> findByProjectType(String projectType);
    
    // 상태별 프로젝트 조회
    List<Project> findByStatus(ProjectStatus status);
    
    // 특정 회원이 참여한 프로젝트
    @Query("SELECT p FROM Project p JOIN p.participants m WHERE m.id = :memberId")
    List<Project> findByParticipantId(@Param("memberId") Long memberId);
    
    // 특정 회원이 리더인 프로젝트
    List<Project> findByLeaderId(Long leaderId);
    
    // 기간별 프로젝트 조회
    @Query("SELECT p FROM Project p WHERE p.startDate >= :startDate AND p.endDate <= :endDate")
    List<Project> findProjectsBetweenDates(@Param("startDate") LocalDate startDate,
                                          @Param("endDate") LocalDate endDate);
    
    // 진행중인 프로젝트
    @Query("SELECT p FROM Project p WHERE p.status = 'IN_PROGRESS' AND :currentDate BETWEEN p.startDate AND p.endDate")
    List<Project> findActiveProjects(@Param("currentDate") LocalDate currentDate);
}

3. EventRepository

 
java
package com.university.clubmanagement.repository;

import com.university.clubmanagement.entity.Event;
import com.university.clubmanagement.entity.EventType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.time.LocalDateTime;
import java.util.List;

@Repository
public interface EventRepository extends JpaRepository<Event, Long> {
    
    // 이벤트 타입별 조회
    List<Event> findByEventType(EventType eventType);
    
    // 특정 기간의 이벤트 조회
    @Query("SELECT e FROM Event e WHERE e.eventDateTime BETWEEN :start AND :end ORDER BY e.eventDateTime")
    List<Event> findEventsBetween(@Param("start") LocalDateTime start, 
                                 @Param("end") LocalDateTime end);
    
    // 예정된 이벤트 조회
    @Query("SELECT e FROM Event e WHERE e.eventDateTime > :now ORDER BY e.eventDateTime")
    List<Event> findUpcomingEvents(@Param("now") LocalDateTime now);
    
    // 주최자별 이벤트 조회
    List<Event> findByOrganizerId(Long organizerId);
    
    // 참가 가능한 이벤트 (정원 미달)
    @Query("SELECT e FROM Event e WHERE e.maxParticipants > e.currentParticipants AND e.eventDateTime > :now")
    List<Event> findAvailableEvents(@Param("now") LocalDateTime now);
}

Service 계층 구현

1. MemberService

 
java
package com.university.clubmanagement.service;

import com.university.clubmanagement.dto.MemberCreateDto;
import com.university.clubmanagement.dto.MemberResponseDto;
import com.university.clubmanagement.dto.MemberUpdateDto;
import com.university.clubmanagement.entity.Member;
import com.university.clubmanagement.entity.MemberStatus;
import com.university.clubmanagement.exception.DuplicateResourceException;
import com.university.clubmanagement.exception.ResourceNotFoundException;
import com.university.clubmanagement.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.stream.Collectors;

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberService {
    
    private final MemberRepository memberRepository;
    
    @Transactional
    public MemberResponseDto createMember(MemberCreateDto createDto) {
        log.debug("Creating new member with studentId: {}", createDto.getStudentId());
        
        // 중복 검사
        if (memberRepository.existsByStudentId(createDto.getStudentId())) {
            throw new DuplicateResourceException("이미 존재하는 학번입니다: " + createDto.getStudentId());
        }
        
        if (memberRepository.existsByEmail(createDto.getEmail())) {
            throw new DuplicateResourceException("이미 존재하는 이메일입니다: " + createDto.getEmail());
        }
        
        Member member = Member.builder()
                .studentId(createDto.getStudentId())
                .name(createDto.getName())
                .email(createDto.getEmail())
                .department(createDto.getDepartment())
                .phoneNumber(createDto.getPhoneNumber())
                .introduction(createDto.getIntroduction())
                .build();
        
        Member savedMember = memberRepository.save(member);
        log.info("Member created successfully with id: {}", savedMember.getId());
        
        return MemberResponseDto.from(savedMember);
    }
    
    public List<MemberResponseDto> getAllMembers() {
        return memberRepository.findAll().stream()
                .map(MemberResponseDto::from)
                .collect(Collectors.toList());
    }
    
    public MemberResponseDto getMemberById(Long id) {
        Member member = memberRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("회원을 찾을 수 없습니다. ID: " + id));
        return MemberResponseDto.from(member);
    }
    
    public MemberResponseDto getMemberByStudentId(String studentId) {
        Member member = memberRepository.findByStudentId(studentId)
                .orElseThrow(() -> new ResourceNotFoundException("회원을 찾을 수 없습니다. 학번: " + studentId));
        return MemberResponseDto.from(member);
    }
    
    @Transactional
    public MemberResponseDto updateMember(Long id, MemberUpdateDto updateDto) {
        log.debug("Updating member with id: {}", id);
        
        Member member = memberRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("회원을 찾을 수 없습니다. ID: " + id));
        
        // 이메일 변경 시 중복 검사
        if (updateDto.getEmail() != null && !updateDto.getEmail().equals(member.getEmail())) {
            if (memberRepository.existsByEmail(updateDto.getEmail())) {
                throw new DuplicateResourceException("이미 존재하는 이메일입니다: " + updateDto.getEmail());
            }
            member.setEmail(updateDto.getEmail());
        }
        
        // 나머지 필드 업데이트
        if (updateDto.getName() != null) {
            member.setName(updateDto.getName());
        }
        if (updateDto.getDepartment() != null) {
            member.setDepartment(updateDto.getDepartment());
        }
        if (updateDto.getPhoneNumber() != null) {
            member.setPhoneNumber(updateDto.getPhoneNumber());
        }
        if (updateDto.getIntroduction() != null) {
            member.setIntroduction(updateDto.getIntroduction());
        }
        if (updateDto.getRole() != null) {
            member.setRole(updateDto.getRole());
        }
        if (updateDto.getStatus() != null) {
            member.setStatus(updateDto.getStatus());
        }
        
        Member updatedMember = memberRepository.save(member);
        log.info("Member updated successfully with id: {}", updatedMember.getId());
        
        return MemberResponseDto.from(updatedMember);
    }
    
    @Transactional
    public void deleteMember(Long id) {
        log.debug("Deleting member with id: {}", id);
        
        Member member = memberRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("회원을 찾을 수 없습니다. ID: " + id));
        
        // Soft delete: 상태만 변경
        member.setStatus(MemberStatus.INACTIVE);
        memberRepository.save(member);
        
        log.info("Member soft deleted successfully with id: {}", id);
    }
    
    public List<MemberResponseDto> getActiveMembers() {
        return memberRepository.findByStatus(MemberStatus.ACTIVE).stream()
                .map(MemberResponseDto::from)
                .collect(Collectors.toList());
    }
    
    public List<MemberResponseDto> searchMembers(String name, String department, String role, String status) {
        // 검색 파라미터 변환
        var memberRole = role != null ? MemberRole.valueOf(role) : null;
        var memberStatus = status != null ? MemberStatus.valueOf(status) : null;
        
        return memberRepository.searchMembers(name, department, memberRole, memberStatus).stream()
                .map(MemberResponseDto::from)
                .collect(Collectors.toList());
    }
}

2. DTO 클래스들

 
java
package com.university.clubmanagement.dto;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class MemberCreateDto {
    
    @NotBlank(message = "학번은 필수입니다")
    @Pattern(regexp = "^[0-9]{8}$", message = "학번은 8자리 숫자여야 합니다")
    private String studentId;
    
    @NotBlank(message = "이름은 필수입니다")
    @Size(min = 2, max = 50, message = "이름은 2~50자 사이여야 합니다")
    private String name;
    
    @NotBlank(message = "이메일은 필수입니다")
    @Email(message = "올바른 이메일 형식이 아닙니다")
    private String email;
    
    @Size(max = 50, message = "학과명은 50자를 초과할 수 없습니다")
    private String department;
    
    @Pattern(regexp = "^01[0-9]-[0-9]{3,4}-[0-9]{4}$", message = "올바른 전화번호 형식이 아닙니다")
    private String phoneNumber;
    
    @Size(max = 500, message = "자기소개는 500자를 초과할 수 없습니다")
    private String introduction;
}
 
java
package com.university.clubmanagement.dto;

import com.university.clubmanagement.entity.Member;
import com.university.clubmanagement.entity.MemberRole;
import com.university.clubmanagement.entity.MemberStatus;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.LocalDate;
import java.time.LocalDateTime;

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class MemberResponseDto {
    
    private Long id;
    private String studentId;
    private String name;
    private String email;
    private String department;
    private String phoneNumber;
    private MemberRole role;
    private MemberStatus status;
    private LocalDate joinDate;
    private String introduction;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
    
    public static MemberResponseDto from(Member member) {
        return MemberResponseDto.builder()
                .id(member.getId())
                .studentId(member.getStudentId())
                .name(member.getName())
                .email(member.getEmail())
                .department(member.getDepartment())
                .phoneNumber(member.getPhoneNumber())
                .role(member.getRole())
                .status(member.getStatus())
                .joinDate(member.getJoinDate())
                .introduction(member.getIntroduction())
                .createdAt(member.getCreatedAt())
                .updatedAt(member.getUpdatedAt())
                .build();
    }
}
 
java
package com.university.clubmanagement.dto;

import com.university.clubmanagement.entity.MemberRole;
import com.university.clubmanagement.entity.MemberStatus;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class MemberUpdateDto {
    
    @Size(min = 2, max = 50, message = "이름은 2~50자 사이여야 합니다")
    private String name;
    
    @Email(message = "올바른 이메일 형식이 아닙니다")
    private String email;
    
    @Size(max = 50, message = "학과명은 50자를 초과할 수 없습니다")
    private String department;
    
    @Pattern(regexp = "^01[0-9]-[0-9]{3,4}-[0-9]{4}$", message = "올바른 전화번호 형식이 아닙니다")
    private String phoneNumber;
    
    @Size(max = 500, message = "자기소개는 500자를 초과할 수 없습니다")
    private String introduction;
    
    private MemberRole role;
    private MemberStatus status;
}

Controller 구현

1. MemberController

 
java
package com.university.clubmanagement.controller;

import com.university.clubmanagement.dto.MemberCreateDto;
import com.university.clubmanagement.dto.MemberResponseDto;
import com.university.clubmanagement.dto.MemberUpdateDto;
import com.university.clubmanagement.service.MemberService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@Slf4j
@RestController
@RequestMapping("/api/members")
@RequiredArgsConstructor
@CrossOrigin(origins = "*")
public class MemberController {
    
    private final MemberService memberService;
    
    @PostMapping
    public ResponseEntity<MemberResponseDto> createMember(@Valid @RequestBody MemberCreateDto createDto) {
        log.info("POST /api/members - Creating new member");
        MemberResponseDto response = memberService.createMember(createDto);
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }
    
    @GetMapping
    public ResponseEntity<List<MemberResponseDto>> getAllMembers(
            @RequestParam(required = false) String name,
            @RequestParam(required = false) String department,
            @RequestParam(required = false) String role,
            @RequestParam(required = false) String status) {
        
        log.info("GET /api/members - Fetching all members");
        
        List<MemberResponseDto> members;
        if (name != null || department != null || role != null || status != null) {
            members = memberService.searchMembers(name, department, role, status);
        } else {
            members = memberService.getAllMembers();
        }
        
        return ResponseEntity.ok(members);
    }
    
    @GetMapping("/{id}")
    public ResponseEntity<MemberResponseDto> getMemberById(@PathVariable Long id) {
        log.info("GET /api/members/{} - Fetching member by id", id);
        MemberResponseDto response = memberService.getMemberById(id);
        return ResponseEntity.ok(response);
    }
    
    @GetMapping("/student/{studentId}")
    public ResponseEntity<MemberResponseDto> getMemberByStudentId(@PathVariable String studentId) {
        log.info("GET /api/members/student/{} - Fetching member by student ID", studentId);
        MemberResponseDto response = memberService.getMemberByStudentId(studentId);
        return ResponseEntity.ok(response);
    }
    
    @PutMapping("/{id}")
    public ResponseEntity<MemberResponseDto> updateMember(
            @PathVariable Long id,
            @Valid @RequestBody MemberUpdateDto updateDto) {
        log.info("PUT /api/members/{} - Updating member", id);
        MemberResponseDto response = memberService.updateMember(id, updateDto);
        return ResponseEntity.ok(response);
    }
    
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteMember(@PathVariable Long id) {
        log.info("DELETE /api/members/{} - Deleting member", id);
        memberService.deleteMember(id);
        return ResponseEntity.noContent().build();
    }
    
    @GetMapping("/active")
    public ResponseEntity<List<MemberResponseDto>> getActiveMembers() {
        log.info("GET /api/members/active - Fetching active members");
        List<MemberResponseDto> members = memberService.getActiveMembers();
        return ResponseEntity.ok(members);
    }
}

2. ProjectController와 EventController도 유사하게 구현

예외 처리

1. Custom Exceptions

 
java
package com.university.clubmanagement.exception;

public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String message) {
        super(message);
    }
}
 
java
package com.university.clubmanagement.exception;

public class DuplicateResourceException extends RuntimeException {
    public DuplicateResourceException(String message) {
        super(message);
    }
}

2. Global Exception Handler

 
java
package com.university.clubmanagement.exception;

import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFoundException(ResourceNotFoundException ex) {
        log.error("Resource not found: {}", ex.getMessage());
        
        ErrorResponse errorResponse = ErrorResponse.builder()
                .timestamp(LocalDateTime.now())
                .status(HttpStatus.NOT_FOUND.value())
                .error("Resource Not Found")
                .message(ex.getMessage())
                .build();
        
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
    }
    
    @ExceptionHandler(DuplicateResourceException.class)
    public ResponseEntity<ErrorResponse> handleDuplicateResourceException(DuplicateResourceException ex) {
        log.error("Duplicate resource: {}", ex.getMessage());
        
        ErrorResponse errorResponse = ErrorResponse.builder()
                .timestamp(LocalDateTime.now())
                .status(HttpStatus.CONFLICT.value())
                .error("Duplicate Resource")
                .message(ex.getMessage())
                .build();
        
        return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse);
    }
    
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationExceptions(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getAllErrors().forEach((error) -> {
            String fieldName = ((FieldError) error).getField();
            String errorMessage = error.getDefaultMessage();
            errors.put(fieldName, errorMessage);
        });
        
        ErrorResponse errorResponse = ErrorResponse.builder()
                .timestamp(LocalDateTime.now())
                .status(HttpStatus.BAD_REQUEST.value())
                .error("Validation Failed")
                .message("입력값 검증에 실패했습니다")
                .details(errors)
                .build();
        
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
    }
    
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGlobalException(Exception ex) {
        log.error("Unexpected error occurred", ex);
        
        ErrorResponse errorResponse = ErrorResponse.builder()
                .timestamp(LocalDateTime.now())
                .status(HttpStatus.INTERNAL_SERVER_ERROR.value())
                .error("Internal Server Error")
                .message("서버 오류가 발생했습니다")
                .build();
        
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
    }
}
 
java
package com.university.clubmanagement.exception;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;
import java.util.Map;

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ErrorResponse {
    private LocalDateTime timestamp;
    private int status;
    private String error;
    private String message;
    private Map<String, String> details;
}

테스트

1. API 테스트 파일 (test-api.http)

 
http
### 회원 생성
POST http://localhost:8080/api/members
Content-Type: application/json

{
  "studentId": "20240001",
  "name": "김한동",
  "email": "kim@handong.edu",
  "department": "전산전자공학부",
  "phoneNumber": "010-1234-5678",
  "introduction": "안녕하세요! 미디어 제작에 관심이 많습니다."
}

### 모든 회원 조회
GET http://localhost:8080/api/members

### 특정 회원 조회
GET http://localhost:8080/api/members/1

### 회원 정보 수정
PUT http://localhost:8080/api/members/1
Content-Type: application/json

{
  "email": "kim.updated@handong.edu",
  "department": "AI융합교육원",
  "role": "VICE_PRESIDENT"
}

### 회원 삭제
DELETE http://localhost:8080/api/members/1

### 회원 검색
GET http://localhost:8080/api/members?name=김&department=전산전자공학부&status=ACTIVE

### 활성 회원만 조회
GET http://localhost:8080/api/members/active

2. 애플리케이션 실행 및 테스트

 
bash
# 프로젝트 빌드
mvn clean package

# 애플리케이션 실행
mvn spring-boot:run

# 또는 JAR 파일 직접 실행
java -jar target/club-management-0.0.1-SNAPSHOT.jar

3. H2 Console 접속

Docker 배포

1. Dockerfile

 
dockerfile
# 빌드 스테이지
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN mvn clean package -DskipTests

# 실행 스테이지
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app

# 필요한 패키지 설치
RUN apk add --no-cache tzdata && \
    cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \
    echo "Asia/Seoul" > /etc/timezone

# JAR 파일 복사
COPY --from=build /app/target/*.jar app.jar

# 애플리케이션 실행 사용자 생성
RUN addgroup -g 1000 spring && \
    adduser -D -u 1000 -G spring spring
USER spring:spring

# 포트 노출
EXPOSE 8080

# JVM 옵션 설정
ENV JAVA_OPTS="-Xms256m -Xmx512m"

# 애플리케이션 실행
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

2. docker-compose.yml

 
yaml
version: '3.8'

services:
  app:
    build: .
    container_name: club-management-api
    ports:
      - "8080:8080"
    environment:
      - SPRING_PROFILES_ACTIVE=docker
      - TZ=Asia/Seoul
    networks:
      - club-network
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:8080/actuator/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s

networks:
  club-network:
    driver: bridge

3. 배포 명령어

 
bash
# Docker 이미지 빌드
docker build -t club-management:latest .

# Docker 컨테이너 실행
docker run -d -p 8080:8080 --name club-api club-management:latest

# Docker Compose로 실행
docker-compose up -d

# 로그 확인
docker logs -f club-api

# 컨테이너 중지
docker stop club-api

# 컨테이너 삭제
docker rm club-api

마무리

🎯 학습한 내용

  1. Spring Boot 프로젝트 구조 설계
  2. JPA를 활용한 엔티티 매핑
  3. RESTful API 설계 원칙
  4. 계층형 아키텍처 (Controller-Service-Repository)
  5. 예외 처리와 유효성 검증
  6. Docker를 통한 컨테이너화

🚀 다음 단계

  • Spring Security를 활용한 인증/인가
  • JWT 토큰 기반 인증
  • Swagger/OpenAPI 문서화
  • Redis 캐싱
  • PostgreSQL 마이그레이션
  • GitHub Actions CI/CD 파이프라인
  • Kubernetes 배포

📚 추가 학습 자료

728x90
반응형


이 페이지는 리디주식회사에서 제공한 리디바탕 글꼴이 사용되어 있습니다.