spring_2기[본캠프]/과제

[과제] Spring K사 서버 개발 프로젝트 Day 2

minwoo95 2026. 4. 6. 13:16

https://github.com/MinWoo1995/coffeeShop

 

GitHub - MinWoo1995/coffeeShop

Contribute to MinWoo1995/coffeeShop development by creating an account on GitHub.

github.com

 

트러블슈팅 1 — Spring Boot 버전 문제로 애플리케이션이 실행되지 않음

문제 상황

프로젝트를 생성하고 처음 실행했을 때 아래 에러가 발생했다.

Unable to determine Dialect without JDBC metadata
(please set 'jakarta.persistence.jdbc.url' for common cases
or 'hibernate.dialect' when a custom Dialect implementation must be provided)

application.properties에 DB URL을 분명히 설정했는데 Hibernate가 DB에 접속하지 못하고 Dialect를 판단하지 못하는 상황이었다.

원인 파악

에러 로그를 자세히 보니 이런 줄이 있었다.

spring-boot-4.0.5.jar

Spring Boot 4.x 버전으로 프로젝트가 생성되어 있었다. Spring Boot 4.x는 아직 정식 릴리즈가 아닌 불안정한 버전이라 내부 자동 설정이 제대로 동작하지 않았던 것이다. DB URL을 아무리 잘 설정해도 Spring Boot 자체가 DataSource를 정상적으로 초기화하지 못하니 Hibernate 입장에서는 연결 정보를 받을 수가 없었다.

해결

build.gradle에서 Spring Boot 버전을 안정 버전인 3.4.4로 다운그레이드했다.

plugins {
    id 'org.springframework.boot' version '3.4.4'
    id 'io.spring.dependency-management' version '1.1.7'
    id 'java'
}

Gradle 새로고침 후 재실행하니 정상 기동되었다.

배운 점

에러 메시지만 보고 application.properties 설정 문제라고 단정 지으면 안 된다. 로그 전체를 읽어보면 라이브러리 버전 정보가 찍혀 있고, 거기서 근본 원인을 찾을 수 있다. 에러를 해결할 때는 증상보다 원인을 먼저 파악하는 습관이 중요하다는 것을 배웠다.

 

트러블슈팅 2 — MySQL 연결은 됐는데 테이블이 생성되지 않음

문제 상황

Spring Boot 버전 문제를 해결하고 재실행했더니 애플리케이션은 정상 기동됐다. 그런데 MySQL에서 데이터를 INSERT하려고 하니 이런 에러가 났다.

Table 'coffee_shop.menus' doesn't exist

ddl-auto=create로 설정되어 있으면 JPA가 자동으로 테이블을 만들어야 하는데 테이블이 생성되지 않은 상황이었다.

원인 파악

Menu.java 엔티티 파일의 패키지 선언이 잘못되어 있었다.

// 잘못된 패키지 선언
package domain.menu.entity;

// 올바른 패키지 선언
package com.example.coffeeshop.domain.menu.entity;

Spring Boot는 메인 클래스(CoffeeShopApplication)가 위치한 패키지를 기준으로 하위 패키지를 스캔한다. 메인 클래스가 com.example.coffeeshop에 있는데 엔티티가 domain.menu.entity에 있으면 스캔 범위 밖이라 Spring이 엔티티를 아예 인식하지 못한다. 엔티티를 인식하지 못하니 Hibernate가 테이블을 생성할 수 없었던 것이다.

해결

모든 엔티티 파일의 패키지 선언 앞에 com.example.coffeeshop.를 붙였다. 재시작하니 로그에 create table menus (...) 구문이 찍히면서 테이블이 정상 생성되었다.

배운 점

Spring Boot의 컴포넌트 스캔은 메인 클래스 위치를 기준으로 동작한다. 패키지 구조가 맞지 않으면 Bean 등록 자체가 안 되기 때문에 아무리 코드가 올바르게 작성되어 있어도 동작하지 않는다. 프로젝트 시작 시 패키지 구조를 먼저 잡고 시작하는 것이 중요하다는 것을 배웠다.

 

트러블슈팅 3 — @Transactional import 문제

문제 상황

@Transactional(readOnly = true)를 적용했는데 읽기 전용 최적화가 제대로 동작하지 않는 것 같았다. 코드 상으로는 문제가 없어 보였다.

원인 파악

import 문을 확인해보니 javax.transaction.Transactional을 import하고 있었다.

// 잘못된 import
import javax.transaction.Transactional;

// 올바른 import
import org.springframework.transaction.annotation.Transactional;

Spring Boot 3.x는 Jakarta EE 9 기반이라 javax 패키지가 jakarta로 바뀌었다. 그리고 javax.transaction.Transactional은 JTA 표준 어노테이션이라 readOnly같은 Spring 전용 속성을 지원하지 않는다. Spring의 트랜잭션 관리 기능을 온전히 사용하려면 반드시 org.springframework.transaction.annotation.Transactional을 써야 한다.

해결

import 문을 org.springframework.transaction.annotation.Transactional로 교체했다.

배운 점

같은 이름의 어노테이션이라도 어디서 import하느냐에 따라 동작이 완전히 달라진다. 특히 Spring Boot 3.x로 업그레이드하면서 javax → jakarta 패키지 변경이 있었기 때문에 import 문을 항상 확인하는 습관이 필요하다.

 

트러블슈팅 4 — Redis LocalDateTime 직렬화 실패

문제 상황

인기 메뉴 집계 스케줄러가 실행될 때 아래 에러가 발생했다.

SerializationException: Could not write JSON:
Java 8 date/time type `java.time.LocalDateTime` not supported by default:
add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310"

Redis에 PopularMenuResponse 객체를 저장하려는데 LocalDateTime 필드를 직렬화하지 못하는 상황이었다.

원인 파악

Jackson은 기본 설정으로 Java 8의 날짜/시간 타입(LocalDateTime, LocalDate 등)을 처리하지 못한다. 별도로 JavaTimeModule을 등록해야 한다. Redis에 객체를 JSON으로 저장하는 GenericJackson2JsonRedisSerializer도 내부적으로 Jackson ObjectMapper를 사용하기 때문에 같은 문제가 발생한 것이다.

해결 과정

처음에는 RedisConfig에 JavaTimeModule만 등록했다.

ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());

직렬화(저장)는 성공했는데 이번엔 역직렬화(조회) 시 에러가 났다.

Cannot construct instance of `PopularMenuResponse`
(no Creators, like default constructor, exist)

Jackson이 JSON을 객체로 복원할 때 기본 생성자가 필요한데 @AllArgsConstructor만 있고 @NoArgsConstructor가 없었던 것이다.

두 번째 시도로 @NoArgsConstructor를 추가했다. 그런데 이번엔 내부 클래스인 PopularMenuItem에서 또 같은 에러가 났다. 내부 클래스에도 기본 생성자가 필요했던 것이다.

결국 직렬화 방식 자체를 바꾸는 것이 더 깔끔하다고 판단했다. 객체를 Redis에 직접 저장하는 대신 JSON 문자열로 변환하여 저장하고, 조회 시 다시 객체로 변환하는 방식으로 변경했다.

// 저장: 객체 → JSON 문자열
String json = objectMapper.writeValueAsString(response);
redisTemplate.opsForValue().set(REDIS_KEY, json, 10, TimeUnit.MINUTES);

// 조회: JSON 문자열 → 객체
return objectMapper.readValue((String) cached, PopularMenuResponse.class);

배운 점

Redis에 객체를 저장할 때는 직렬화/역직렬화 전략을 명확히 정해야 한다. GenericJackson2JsonRedisSerializer로 객체를 통째로 저장하면 타입 정보까지 JSON에 포함되어 복잡해지고 예상치 못한 에러가 발생하기 쉽다. JSON 문자열로 변환하여 저장하는 방식이 더 단순하고 명시적이다. 복잡한 방법보다 단순한 방법이 유지보수에 유리하다는 것을 배웠다.

 

트러블슈팅 5 — 테스트 DB 데이터 오염으로 집계 테스트 실패

문제 상황

인기 메뉴 집계 정확성 테스트를 작성하고 실행했더니 아래 에러가 발생했다.

Expected :12L
Actual   :1L

테스트에서 생성한 menuId1이 12인데, 집계 결과의 1위 메뉴 ID가 1로 나온 것이다. 분명히 테스트 코드에서 아메리카노를 5번, 카페라떼를 3번 주문했는데 엉뚱한 메뉴가 1위로 나왔다.

원인 파악

테스트가 실제 운영 DB를 공유하고 있었다. 개발 과정에서 이미 많은 주문 데이터가 DB에 쌓여 있었고, 집계 쿼리가 최근 7일치 전체 orders를 집계하기 때문에 테스트에서 생성한 주문보다 기존 데이터의 주문 횟수가 더 많았던 것이다.

즉 테스트는 독립적인 환경에서 실행되어야 하는데, 실제 DB를 공유하면서 이전 데이터에 오염된 것이다.

해결

@BeforeEach에서 테스트 실행 전 관련 테이블을 모두 초기화했다. 이때 외래 키 제약 조건 때문에 삭제 순서가 중요하다. 참조하는 테이블부터 먼저 삭제해야 한다.

@BeforeEach
void setUp() {
    // 외래 키 순서에 맞게 삭제
    popularMenuCacheRepository.deleteAll();
    pointHistoryRepository.deleteAll();
    orderRepository.deleteAll();
    userRepository.deleteAll();
    menuRepository.deleteAll();

    // 테스트 데이터 세팅
    ...
}

배운 점

테스트는 반드시 독립적이어야 한다. 이전 테스트나 외부 데이터에 의존하면 테스트 결과를 신뢰할 수 없다. 이를 테스트의 격리성(Isolation) 이라고 한다. 실제 DB를 사용하는 통합 테스트에서는 @BeforeEach로 데이터를 초기화하거나, H2 같은 인메모리 DB를 테스트 전용으로 사용하는 방법을 고려해야 한다. 외래 키 순서를 고려한 삭제 순서도 중요하다는 것을 배웠다.

 

트러블슈팅 6 — 동시성 테스트에서 포인트 잔액 부족 에러

문제 상황

주문 동시성 테스트를 작성했는데 테스트가 실패했다. 잔액 10000P에 4500P짜리 메뉴를 동시에 10번 주문하면 2번만 성공해야 하는데, 성공 횟수가 매번 달라지는 상황이었다.

원인 파악

테스트 @BeforeEach에서 포인트 충전 후 메뉴를 생성했는데, 충전과 주문이 같은 DB를 사용하면서 테스트 격리 문제가 발생했다. 또한 비관적 락이 제대로 동작하는지 확인하기 위해 로그를 보니 SELECT ... FOR UPDATE가 정상적으로 실행되고 있었다.

진짜 원인은 테스트 실행 순서였다. 다른 테스트에서 같은 유저의 포인트를 소진한 상태에서 동시성 테스트가 실행되면서 초기 잔액이 의도한 값과 달랐던 것이다.

해결

동시성 테스트에서도 @BeforeEach에 데이터 초기화를 추가하고, 테스트용 유저를 새로 생성하여 다른 테스트와 데이터가 겹치지 않도록 했다.

@BeforeEach
void setUp() {
    popularMenuCacheRepository.deleteAll();
    pointHistoryRepository.deleteAll();
    orderRepository.deleteAll();
    userRepository.deleteAll();
    menuRepository.deleteAll();

    // 테스트마다 새 유저 생성
    User user = User.create("주문동시성테스트");
    userId = userRepository.save(user).getId();
    userService.chargePoint(userId, new ChargeRequest(10000));
    ...
}

배운 점

동시성 테스트는 일반 단위 테스트보다 더 엄격한 격리가 필요하다. 여러 스레드가 동시에 동작하는 환경에서 공유 데이터가 오염되면 테스트 결과를 전혀 신뢰할 수 없다. 동시성 테스트를 작성할 때는 테스트 데이터의 독립성을 반드시 보장해야 한다는 것을 배웠다.

 

 

전체 회고

이번 과제를 통해 단순히 기능을 구현하는 것보다 왜 이런 문제가 발생했는가를 파악하는 것이 훨씬 중요하다는 것을 배웠다.

트러블슈팅 과정에서 가장 많이 한 행동은 에러 메시지를 처음부터 끝까지 읽는 것이었다. 에러 메시지에는 대부분 원인이 담겨 있다. spring-boot-4.0.5.jar라는 한 줄이 버전 문제의 단서였고, package domain.menu.entity라는 패키지 선언 하나가 테이블 생성 실패의 원인이었다.

또한 테스트 격리성의 중요성을 직접 경험했다. 테스트가 외부 데이터에 의존하면 결과를 신뢰할 수 없고, 신뢰할 수 없는 테스트는 없는 것보다 오히려 위험할 수 있다.

앞으로 개발할 때는 에러를 만났을 때 당황하지 않고, 로그를 차분히 읽고 원인을 파악한 뒤 해결책을 찾는 습관을 계속 유지해 나갈 것이다.