하이어코딩 RSS 태그 관리 글쓰기 방명록 mahiru
전체 글 (45)
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
반응형


이 페이지는 리디주식회사에서 제공한 리디바탕 글꼴이 사용되어 있습니다.