Day90

강태훈·2026년 5월 11일

nbcamp TIL

목록 보기
90/97

트러블슈팅 가이드

커피숍 주문 시스템 개발 중 발생한 문제와 해결 방법을 정리한 문서입니다.


📋 목차

  1. Gradle 빌드 오류
  2. 데이터베이스 연결 오류
  3. 비밀번호 인증 실패
  4. 한글 인코딩 깨짐
  5. QueryDSL Q클래스 생성 오류
  6. JWT 토큰 생성 오류
  7. @CurrentUser 어노테이션 오류
  8. 주문 생성 시 Repository 오류
  9. Docker MySQL 재시작 후 데이터 손실
  10. 다중 항목 주문 시스템 전환

1. Gradle 빌드 오류

문제 상황

Execution failed for task ':compileJava'.
> Could not resolve all files for configuration ':annotationProcessor'.

IntelliJ에서 Gradle 빌드 시 QueryDSL 관련 오류 발생.

원인

  • com.ewerk.gradle.plugins.querydsl 플러그인이 Gradle 8.x와 호환되지 않음
  • 플러그인 방식의 QueryDSL 설정이 최신 Gradle 버전과 충돌

해결 방법

build.gradle 수정:

// ❌ 제거: 호환되지 않는 플러그인
// id 'com.ewerk.gradle.plugins.querydsl' version '1.0.10'

dependencies {
    // ✅ 추가: Annotation Processor 방식
    implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
    annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta'
    annotationProcessor 'jakarta.annotation:jakarta.annotation-api'
    annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
}

// ✅ 추가: Q클래스 생성 경로 설정
def querydslDir = "$buildDir/generated/querydsl"

sourceSets {
    main.java.srcDirs += [querydslDir]
}

tasks.withType(JavaCompile) {
    options.generatedSourceOutputDirectory = file(querydslDir)
}

clean {
    delete file(querydslDir)
}

Q클래스 생성:

./gradlew clean compileJava

생성 위치: build/generated/querydsl/


2. 데이터베이스 연결 오류

문제 상황

Communications link failure
The last packet sent successfully to the server was 0 milliseconds ago.

애플리케이션 실행 시 MySQL 연결 실패.

원인

  1. Docker 컨테이너가 실행되지 않음
  2. application.yaml의 DB 설정이 .env 파일과 불일치

해결 방법

1단계: Docker 컨테이너 확인

# 컨테이너 상태 확인
docker-compose ps

# 컨테이너가 없으면 실행
docker-compose up -d

# 로그 확인
docker-compose logs mysql

2단계: application.yaml 수정

spring:
  datasource:
    url: jdbc:mysql://localhost:3307/coffee_order?useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8
    username: root
    password: 1234
    driver-class-name: com.mysql.cj.jdbc.Driver

3단계: .env 파일 확인

DB_PORT=3307
DB_USERNAME=root
DB_PASSWORD=1234
DB_NAME=coffee_order

참고:

  • 로컬 MySQL이 3306 포트를 사용 중이므로 Docker는 3307 포트 사용
  • username은 coffee_user가 아닌 root 사용 (간단한 로컬 개발 환경)

3. 비밀번호 인증 실패

문제 상황

이메일 또는 비밀번호가 일치하지 않습니다

Postman에서 로그인 시도 시 계속 실패. DB에 저장된 비밀번호와 입력한 비밀번호가 일치하는데도 실패.

원인

  • DB에 저장된 BCrypt 해시값이 잘못됨
  • SQL 초기화 스크립트에서 사용한 해시값이 실제 비밀번호와 매칭되지 않음

해결 방법

1단계: 올바른 BCrypt 해시 생성

온라인 BCrypt 생성기 또는 Java 코드로 생성:

BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
String hash = encoder.encode("admin123");
System.out.println(hash);

2단계: SQL 스크립트 수정

-- ❌ 잘못된 해시
INSERT INTO users (email, password, name, role) VALUES
('admin@coffee.com', '$2a$10$wronghash...', '관리자', 'ADMIN');

-- ✅ 올바른 해시
INSERT INTO users (email, password, name, role) VALUES
('admin@coffee.com', '$2a$10$YourCorrectHashHere...', '관리자', 'ADMIN');

3단계: DB 재생성

# Docker 컨테이너 중지 및 볼륨 삭제
docker-compose down -v

# 다시 시작 (초기화 스크립트 재실행)
docker-compose up -d

검증된 비밀번호:

  • admin123$2a$10$... (관리자)
  • user123$2a$10$... (일반 사용자)

4. 한글 인코딩 깨짐

문제 상황

{
  "menuId": 8,
  "name": "ì•„ì´ìŠ¤í‹°",
  "description": "ìƒí¼í•œ ì•„ì´ìŠ¤í‹°"
}

Postman에서 메뉴 조회 시 한글이 깨져서 표시됨. DB에는 정상적으로 저장되어 있음.

원인

  1. SQL 초기화 스크립트 파일이 UTF-8로 저장되지 않음
  2. MySQL 연결 시 characterEncoding 설정 누락
  3. Spring Boot 응답 인코딩 설정 누락

해결 방법

1단계: SQL 파일 인코딩 확인

# IntelliJ에서 파일 인코딩 확인
# 우측 하단 인코딩 표시 → UTF-8로 변경
# "Reload" 선택 시 기존 내용 손상 가능
# "Convert" 선택 권장

2단계: SQL 스크립트에 인코딩 설정 추가

-- 파일 최상단에 추가
SET NAMES utf8mb4;
SET CHARACTER SET utf8mb4;

CREATE DATABASE IF NOT EXISTS coffee_order
    CHARACTER SET utf8mb4
    COLLATE utf8mb4_unicode_ci;

3단계: application.yaml 수정

spring:
  datasource:
    url: jdbc:mysql://localhost:3307/coffee_order?useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8

4단계: WebConfig 추가 (선택사항)

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
        converters.stream()
            .filter(converter -> converter instanceof MappingJackson2HttpMessageConverter)
            .forEach(converter -> {
                MappingJackson2HttpMessageConverter jsonConverter = 
                    (MappingJackson2HttpMessageConverter) converter;
                jsonConverter.setDefaultCharset(StandardCharsets.UTF_8);
            });
    }
}

5단계: DB 재생성

docker-compose down -v
docker-compose up -d

참고:

  • SQL 파일을 UTF-8로 저장하는 것이 가장 중요
  • 기존 데이터가 깨진 경우 반드시 DB 재생성 필요
  • 새로 생성한 메뉴는 정상 표시되지만 초기 데이터는 깨질 수 있음

5. QueryDSL Q클래스 생성 오류

문제 상황

import static kr.spartaclub.coffeeorder.domain.user.entity.QUser.user;
// Cannot resolve symbol 'QUser'

QueryDSL 사용 시 Q클래스를 찾을 수 없음.

원인

  • Q클래스가 생성되지 않음
  • IDE가 생성된 Q클래스를 인식하지 못함

해결 방법

1단계: Q클래스 생성

./gradlew clean compileJava

2단계: IDE 새로고침

IntelliJ IDEA:
1. File → Invalidate Caches / Restart
2. 또는 Gradle 탭 → Reload All Gradle Projects

3단계: 생성 확인

build/generated/querydsl/
└── kr/spartaclub/coffeeorder/domain/
    ├── user/entity/QUser.java
    ├── menu/entity/QMenu.java
    └── ...

4단계: import 수정

// ✅ 올바른 import
import static kr.spartaclub.coffeeorder.domain.user.entity.QUser.user;

// QueryDSL 사용
JPAQueryFactory queryFactory;
List<User> users = queryFactory
    .selectFrom(user)
    .where(user.email.eq("test@example.com"))
    .fetch();

6. JWT 토큰 생성 오류

문제 상황

java.lang.UnsupportedOperationException
    at java.base/java.util.Collections$UnmodifiableMap.put

JWT 토큰 생성 시 Claims 객체에 값을 추가할 수 없음.

원인

  • Jwts.claims() 메서드가 불변(Immutable) Map을 반환
  • 생성 후 값을 추가하려고 시도하여 오류 발생

해결 방법

❌ 잘못된 코드:

Claims claims = Jwts.claims();
claims.put("userId", userId);  // UnsupportedOperationException
claims.put("email", email);
claims.put("role", role);

✅ 올바른 코드:

// Builder 패턴 사용
String token = Jwts.builder()
    .claim("userId", userId)
    .claim("email", email)
    .claim("role", role)
    .setIssuedAt(new Date())
    .setExpiration(new Date(System.currentTimeMillis() + expiration))
    .signWith(key, SignatureAlgorithm.HS256)
    .compact();

7. @CurrentUser 어노테이션 오류

문제 상황

java.lang.ClassCastException: class java.lang.String cannot be cast to class java.lang.Long

@CurrentUser Long userId 사용 시 String을 Long으로 캐스팅할 수 없다는 오류.

원인

  • @CurrentUser 어노테이션이 email(String)을 반환하도록 구현됨
  • 컨트롤러에서는 userId(Long)를 기대함

해결 방법

1단계: UserPrincipal 클래스 생성

@Getter
@AllArgsConstructor
public class UserPrincipal {
    private Long userId;
    private String email;
    private String role;
}

2단계: JwtTokenProvider 수정

public Authentication getAuthentication(String token) {
    Claims claims = parseClaims(token);
    
    UserPrincipal principal = new UserPrincipal(
        claims.get("userId", Long.class),
        claims.get("email", String.class),
        claims.get("role", String.class)
    );
    
    return new UsernamePasswordAuthenticationToken(
        principal, 
        "", 
        getAuthorities(claims)
    );
}

3단계: CurrentUserArgumentResolver 수정

@Override
public Object resolveArgument(
    MethodParameter parameter,
    ModelAndViewContainer mavContainer,
    NativeWebRequest webRequest,
    WebDataBinderFactory binderFactory
) {
    Authentication authentication = SecurityContextHolder
        .getContext()
        .getAuthentication();
    
    if (authentication == null || 
        !(authentication.getPrincipal() instanceof UserPrincipal)) {
        return null;
    }
    
    return authentication.getPrincipal();  // UserPrincipal 반환
}

4단계: 컨트롤러 수정

// ❌ 이전
@GetMapping
public ResponseEntity<?> getOrders(@CurrentUser Long userId) {
    // ...
}

// ✅ 현재
@GetMapping
public ResponseEntity<?> getOrders(@CurrentUser UserPrincipal principal) {
    Long userId = principal.getUserId();
    // ...
}

8. 주문 생성 시 Repository 오류

문제 상황

org.springframework.data.repository.query.QueryCreationException: 
Could not create query for public abstract long 
kr.spartaclub.coffeeorder.domain.order.repository.OrderRepository.countByMenuId(java.lang.Long); 
No property 'menuId' found for type 'Order'

애플리케이션 실행 시 OrderRepository의 메서드를 찾을 수 없음.

원인

  • Order 엔티티에서 menuId 필드를 제거했지만
  • OrderRepository에 countByMenuId() 메서드가 남아있음
  • 다중 항목 주문 시스템으로 전환하면서 발생한 불일치

해결 방법

1단계: 불필요한 메서드 제거

// OrderRepository.java

// ❌ 제거: Order 엔티티에 menuId가 없음
// long countByMenuId(Long menuId);
// List<Order> findByMenuIdOrderByOrderTimeDesc(Long menuId, Pageable pageable);

// ✅ 유지: 필요한 메서드만
List<Order> findByUserIdOrderByOrderTimeDesc(Long userId, Pageable pageable);
Page<Order> findByUserIdAndOrderTimeBetween(
    Long userId, 
    LocalDateTime startDate, 
    LocalDateTime endDate, 
    Pageable pageable
);

2단계: 메뉴별 통계는 OrderItem 사용

// OrderItemRepository.java
List<OrderItem> findByMenuId(Long menuId);
long countByMenuId(Long menuId);

3단계: 애플리케이션 재시작

./gradlew clean build
./gradlew bootRun

9. Docker MySQL 재시작 후 데이터 손실

문제 상황

docker-compose restart
# 재시작 후 테이블은 있지만 데이터가 없음

Docker 컨테이너 재시작 후 초기 데이터가 사라짐.

원인

  • docker-compose restart는 초기화 스크립트를 재실행하지 않음
  • 초기화 스크립트는 컨테이너 최초 생성 시에만 실행됨

해결 방법

데이터 완전 초기화가 필요한 경우:

# 1. 컨테이너와 볼륨 삭제
docker-compose down -v

# 2. 다시 시작 (초기화 스크립트 실행됨)
docker-compose up -d

# 3. 로그 확인
docker-compose logs mysql

데이터 유지하면서 재시작:

# 볼륨 유지하고 재시작
docker-compose restart mysql

수동으로 데이터 재삽입:

# MySQL 컨테이너 접속
docker exec -it coffee-order-mysql mysql -uroot -p1234 coffee_order

# SQL 파일 실행
source /docker-entrypoint-initdb.d/01-init.sql;

참고:

  • -v 옵션은 볼륨(데이터)을 삭제함
  • 개발 중에는 down -vup -d로 깨끗하게 초기화하는 것을 권장
  • 프로덕션에서는 절대 -v 옵션 사용 금지

10. 다중 항목 주문 시스템 전환

문제 상황

단일 메뉴만 주문 가능한 시스템을 여러 메뉴를 한 번에 주문할 수 있도록 변경 필요.

변경 사항

1단계: 데이터베이스 스키마 변경

orders 테이블:

-- ❌ 이전
CREATE TABLE orders (
    order_id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT NOT NULL,
    menu_id BIGINT NOT NULL,  -- 제거
    price INT NOT NULL,        -- 제거
    order_time DATETIME NOT NULL,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- ✅ 현재
CREATE TABLE orders (
    order_id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT NOT NULL,
    total_price INT NOT NULL,  -- 추가
    order_time DATETIME NOT NULL,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

order_items 테이블 추가:

CREATE TABLE order_items (
    order_item_id BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_id BIGINT NOT NULL,
    menu_id BIGINT NOT NULL,
    quantity INT NOT NULL,
    price INT NOT NULL,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (order_id) REFERENCES orders(order_id) ON DELETE CASCADE,
    FOREIGN KEY (menu_id) REFERENCES menus(menu_id) ON DELETE RESTRICT
);

2단계: 엔티티 수정

Order 엔티티:

@Entity
@Table(name = "orders")
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "order_id")
    private Long id;
    
    @Column(name = "user_id", nullable = false)
    private Long userId;
    
    // ❌ 제거
    // private Long menuId;
    // private Integer price;
    
    // ✅ 추가
    @Column(name = "total_price", nullable = false)
    private Integer totalPrice;
    
    @Column(name = "order_time", nullable = false)
    private LocalDateTime orderTime;
}

OrderItem 엔티티 추가:

@Entity
@Table(name = "order_items")
public class OrderItem extends BaseTimeEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "order_item_id")
    private Long id;
    
    @Column(name = "order_id", nullable = false)
    private Long orderId;
    
    @Column(name = "menu_id", nullable = false)
    private Long menuId;
    
    @Column(nullable = false)
    private Integer quantity;
    
    @Column(nullable = false)
    private Integer price;
    
    public int getSubtotal() {
        return price * quantity;
    }
}

3단계: DTO 수정

CreateOrderRequest:

// ❌ 이전
public class CreateOrderRequest {
    private Long menuId;
}

// ✅ 현재
public class CreateOrderRequest {
    @NotEmpty(message = "주문 항목은 최소 1개 이상이어야 합니다")
    private List<OrderItemRequest> items;
    
    @Getter
    @AllArgsConstructor
    public static class OrderItemRequest {
        @NotNull(message = "메뉴 ID는 필수입니다")
        private Long menuId;
        
        @NotNull(message = "수량은 필수입니다")
        @Positive(message = "수량은 1개 이상이어야 합니다")
        private Integer quantity;
    }
}

OrderResponse:

// ✅ 현재
@Getter
@Builder
public class OrderResponse {
    private Long orderId;
    private Integer totalPrice;
    private Integer remainingBalance;
    private LocalDateTime orderTime;
    private List<OrderItemResponse> items;
    
    @Getter
    @Builder
    public static class OrderItemResponse {
        private Long menuId;
        private String menuName;
        private Integer quantity;
        private Integer price;
        private Integer subtotal;  // price * quantity
    }
}

4단계: 서비스 로직 수정

@Transactional
public OrderResponse createOrder(Long userId, CreateOrderRequest request) {
    // 1. 모든 메뉴 조회 및 검증
    List<Long> menuIds = request.getItems().stream()
        .map(item -> item.getMenuId())
        .collect(Collectors.toList());
    
    Map<Long, Menu> menuMap = menuRepository.findAllById(menuIds)
        .stream()
        .collect(Collectors.toMap(Menu::getId, menu -> menu));
    
    // 2. 총 금액 계산
    int totalPrice = 0;
    List<OrderItemResponse> itemResponses = new ArrayList<>();
    
    for (OrderItemRequest itemRequest : request.getItems()) {
        Menu menu = menuMap.get(itemRequest.getMenuId());
        if (menu == null) {
            throw new MenuNotFoundException(itemRequest.getMenuId());
        }
        if (!menu.isAvailable()) {
            throw new OrderException(ErrorCode.ORDER_002);
        }
        
        int subtotal = menu.getPrice() * itemRequest.getQuantity();
        totalPrice += subtotal;
        
        itemResponses.add(OrderItemResponse.of(
            menu.getId(),
            menu.getName(),
            itemRequest.getQuantity(),
            menu.getPrice()
        ));
    }
    
    // 3. 포인트 차감
    int remainingBalance = userPointService.usePoint(userId, totalPrice, "주문 결제");
    
    // 4. 주문 생성
    Order order = Order.create(userId, totalPrice);
    Order savedOrder = orderRepository.save(order);
    
    // 5. 주문 상세 생성
    for (OrderItemRequest itemRequest : request.getItems()) {
        Menu menu = menuMap.get(itemRequest.getMenuId());
        OrderItem orderItem = OrderItem.create(
            savedOrder.getId(),
            menu.getId(),
            itemRequest.getQuantity(),
            menu.getPrice()
        );
        orderItemRepository.save(orderItem);
        
        // 6. 메뉴 통계 업데이트 (수량만큼)
        updateMenuStatistics(menu.getId(), itemRequest.getQuantity());
    }
    
    return OrderResponse.of(savedOrder, itemResponses, remainingBalance);
}

5단계: Repository 정리

// OrderRepository.java
// ❌ 제거: Order에 menuId가 없음
// long countByMenuId(Long menuId);
// List<Order> findByMenuIdOrderByOrderTimeDesc(Long menuId, Pageable pageable);

// OrderItemRepository.java 추가
public interface OrderItemRepository extends JpaRepository<OrderItem, Long> {
    List<OrderItem> findByOrderId(Long orderId);
    List<OrderItem> findByOrderIdIn(List<Long> orderIds);
    List<OrderItem> findByMenuId(Long menuId);
}

6단계: 데이터베이스 마이그레이션

# 1. 기존 데이터 백업 (필요시)
docker exec coffee-order-mysql mysqldump -uroot -p1234 coffee_order > backup.sql

# 2. 컨테이너와 볼륨 삭제
docker-compose down -v

# 3. 새 스키마로 재시작
docker-compose up -d

# 4. 확인
docker exec -it coffee-order-mysql mysql -uroot -p1234 coffee_order
SHOW TABLES;
DESC orders;
DESC order_items;

7단계: 문서 업데이트

  • ERD 설계서: orders, order_items 테이블 구조 수정
  • API 명세서: 주문 생성 요청/응답 형식 수정
  • 기능 명세서: F-008 주문 생성 로직 수정
  • README: 주요 특징에 "다중 항목 주문" 추가

🔍 일반적인 디버깅 팁

1. 로그 확인

# 애플리케이션 로그
./gradlew bootRun

# Docker 로그
docker-compose logs -f mysql
docker-compose logs -f redis
docker-compose logs -f kafka

2. 데이터베이스 직접 확인

# MySQL 접속
docker exec -it coffee-order-mysql mysql -uroot -p1234 coffee_order

# 테이블 확인
SHOW TABLES;
DESC users;
SELECT * FROM users;

3. Redis 확인

# Redis CLI 접속
docker exec -it coffee-order-redis redis-cli -a redis_password

# 키 확인
KEYS *
GET lock:user:1:point

4. Kafka 확인

# Kafka UI 접속
http://localhost:8989

# 또는 CLI
docker exec -it coffee-order-kafka bash
kafka-topics --bootstrap-server localhost:9092 --list

5. 포트 충돌 확인

# Windows
netstat -ano | findstr :8080
netstat -ano | findstr :3307

# 프로세스 종료
taskkill /PID <PID> /F

📚 참고 자료

공식 문서

프로젝트 문서


0개의 댓글