본문 바로가기
Today I Learn

TIL - 2026.09.10

by 준모 2026. 9. 10.

TIL - 2026.09.10

Spring Boot + MySQL 붙이면서 겪은 트러블슈팅부터 예외 처리 설계까지 정리.


1. Docker MySQL 트러블슈팅

컨테이너 이름 충돌

docker: Error response from daemon: Conflict. The container name "/mysql" is already in use...

같은 이름의 컨테이너가 이미 있으면(실행 중이든 중지 상태든) 재사용 불가.

docker rm -f mysql                 # 삭제하고 새로 생성
docker start mysql                 # 기존 거 그대로 재시작
docker rename mysql mysql_old      # 이름 바꿔서 둘 다 유지

실행 중인지 확인

docker ps          # 실행 중인 것만
docker ps -a       # 중지된 것까지 전체
docker logs mysql   # 초기화/기동 로그 확인
docker port mysql   # 포트 매핑 확인

"Communications link failure" (타이밍 문제) MySQL 컨테이너를 새로 띄우면 내부적으로 임시 서버 기동 → 종료 → 재시작 과정을 거침. 이 사이에 연결하려 하면 실패함. → 해결: 컨테이너 띄우고 5~10초 정도 기다렸다가 앱 실행


2. application.properties

기본 골격:

spring.datasource.url=jdbc:mysql://localhost:3306/test?createDatabaseIfNotExist=true
spring.datasource.username=root
spring.datasource.password=12345678
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.jpa.hibernate.ddl-auto=create
spring.jpa.show-sql=true

"Unknown database 'test'" — 컨테이너 새로 띄워도 DB는 자동 생성 안 됨

  • ?createDatabaseIfNotExist=true URL에 추가 (제일 간단)
  • 또는 직접 생성:
    docker exec -it mysql mysql -uroot -p12345678CREATE DATABASE test;
    

줄바꿈 빠뜨리는 실수 조심 — 복붙하다가 이렇게 붙어버리는 경우 있음

❌ ...createDatabaseIfNotExist=truespring.datasource.username=root

한 줄에 설정 하나씩, 줄바꿈 잘 됐는지 확인.

프로젝트 여러 개면 DB 이름도 따로 — 같은 test DB 쓰면 테이블 구조 충돌 가능성 있음


3. 로그 남기기 (@Slf4j)

@Slf4j
@Service
public class GameServiceImpl {
    public void someMethod() {
        log.info("게임 서비스 시작");
        log.info("floor: {}", floor);   // + 대신 {} 플레이스홀더
    }
}

로그가 안 뜰 때 체크할 것

  1. @Slf4j 붙었는지
  2. IntelliJ Annotation Processing 켜져 있는지
  3. logging.level.root가 너무 높게(WARN 이상) 잡혀있는지
  4. 메서드가 진짜 호출됐는지 ← 제일 흔한 원인

특히 @Valid 검증에서 걸리면 메서드 본문 진입도 못 하고 예외 터짐. 예를 들어 Integer 필드에 @Size 잘못 붙이면 UnexpectedTypeException 남. 숫자 검증은 @Min, @Max, @Positive 써야 함.


4. REST API URL 설계

URL = 명사(자원), 행위 = HTTP 메서드

GET    /games          목록 조회
GET    /games/{id}     단건 조회
POST   /games          생성
PUT    /games/{id}     전체 수정
PATCH  /games/{id}     일부 수정
DELETE /games/{id}     삭제

URL에 get, create, delete 같은 동사 넣지 않기. 계층은 경로로: /games/{gameId}/cards


5. Stream 정렬

// map 전에 정렬 (엔티티 기준)
games.stream()
    .sorted(Comparator.comparing(Game::getId).reversed())
    .map(game -> new GameSummaryResponse(...))
    .toList();

// map 후에 정렬 (DTO 기준)
games.stream()
    .map(game -> new GameSummaryResponse(...))
    .sorted(Comparator.comparing(GameSummaryResponse::getId).reversed())
    .toList();
  • .sorted() 인자 없이 쓰려면 Comparable 구현 필요 (안 하면 ClassCastException)
  • 리포지토리에서 이미 정렬해서 가져왔으면(findAllByOrderByIdAsc) map만 해도 순서 유지되니 .sorted() 필요 없음
  • DB에서 정렬하는 게 메모리에서 정렬하는 것보다 보통 나음

6. @Valid 검증

public ResponseEntity<GameDetailResponse> createGame(@Valid @RequestBody CreateRequest request) { ... }
  • @RequestBody → JSON을 객체로 변환
  • @Valid → 그 객체에 붙은 검증 애노테이션(@NotBlank, @Size 등)을 실제로 실행시킴

@Valid 없으면 검증 애노테이션 붙여놔도 그냥 무시됨. 검증 실패하면 MethodArgumentNotValidException 터지면서 메서드 본문엔 아예 못 들어감 → 기본 400 응답.


7. 예외 처리 (@RestControllerAdvice)

흐름:

  1. 커스텀 예외 클래스 만들기 (상황을 이름으로 표현)
  2. 서비스에서 throw (HTTP 코드는 신경 안 씀, 도메인 로직만)
  3. ErrorResponse DTO 만들기 (응답 형태)
  4. GlobalExceptionHandler로 예외 → HTTP 응답 변환
public class GameNotFoundException extends RuntimeException {
    public GameNotFoundException(Long gameId) {
        super("존재하지 않는 게임입니다. id=" + gameId);
    }
}

클래스 이름을 ResponseStatusException처럼 Spring 내장 클래스랑 겹치게 짓지 말 것 — 헷갈림

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(GameNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleGameNotFound(
            GameNotFoundException e, HttpServletRequest request) {
        return respond(HttpStatus.NOT_FOUND, e.getMessage(), request);
    }

    @ExceptionHandler(GameAlreadyFinishedException.class)
    public ResponseEntity<ErrorResponse> handleGameFinished(
            GameAlreadyFinishedException e, HttpServletRequest request) {
        return respond(HttpStatus.CONFLICT, e.getMessage(), request);
    }

    private ResponseEntity<ErrorResponse> respond(HttpStatus status, String message, HttpServletRequest request) {
        return ResponseEntity.status(status).body(new ErrorResponse(status, message, request.getRequestURI()));
    }
}

어떤 핸들러가 실행되는지 — 던져진 예외의 실제 타입부터 부모 클래스로 거슬러 올라가며, 가장 구체적으로 맞는 @ExceptionHandler를 찾음. 정확히 맞는 게 없으면 Exception.class 같은 상위 타입으로 fallback.

상태 코드 정하는 기준 — 누구 잘못인가

상황 코드

요청한 자원이 없음 404
요청 값 자체가 잘못됨 400
이미 존재/상태 충돌 409
인증/권한 없음 401 / 403
서버 내부 예상 못한 에러 500

HttpServletRequest request — 새로 생기는 게 아니라, 이 예외를 유발한 원본 요청 객체가 그대로 전달됨. 요청 하나당 하나 생성돼서 컨트롤러 → 서비스 → 예외 전파까지 계속 같은 객체 유지됨.

ErrorResponse의 "error" 필드 — status.getReasonPhrase()로 가져온 값. HTTP 표준에 이미 정의된 상태 코드 설명 문자열이라 직접 타이핑한 게 아님. HttpStatus.NOT_FOUND → "Not Found", CONFLICT → "Conflict".


오늘 배운 것 정리

  1. DB 연결 에러는 순서대로 좁혀가면서 확인 (URL 오타 → DB 존재 여부 → 타이밍 → 연결 속성 문법)
  2. 로그 안 찍히면 "메서드가 진짜 호출됐는지"부터 의심하기
  3. 서비스는 도메인 로직만, HTTP 상태 코드는 예외 처리기가 담당 — 역할 분리
  4. 예외 클래스 이름은 상황이 명확히 드러나게, 프레임워크 내장 이름이랑 안 겹치게

'Today I Learn' 카테고리의 다른 글

TIL — 2026.09.11  (0) 2026.09.11