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); // + 대신 {} 플레이스홀더
}
}
로그가 안 뜰 때 체크할 것
- @Slf4j 붙었는지
- IntelliJ Annotation Processing 켜져 있는지
- logging.level.root가 너무 높게(WARN 이상) 잡혀있는지
- 메서드가 진짜 호출됐는지 ← 제일 흔한 원인
특히 @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)
흐름:
- 커스텀 예외 클래스 만들기 (상황을 이름으로 표현)
- 서비스에서 throw (HTTP 코드는 신경 안 씀, 도메인 로직만)
- ErrorResponse DTO 만들기 (응답 형태)
- 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".
오늘 배운 것 정리
- DB 연결 에러는 순서대로 좁혀가면서 확인 (URL 오타 → DB 존재 여부 → 타이밍 → 연결 속성 문법)
- 로그 안 찍히면 "메서드가 진짜 호출됐는지"부터 의심하기
- 서비스는 도메인 로직만, HTTP 상태 코드는 예외 처리기가 담당 — 역할 분리
- 예외 클래스 이름은 상황이 명확히 드러나게, 프레임워크 내장 이름이랑 안 겹치게
'Today I Learn' 카테고리의 다른 글
| TIL — 2026.09.11 (0) | 2026.09.11 |
|---|