Skip to content

Repository files navigation

backend-lab

백엔드 개념을 직접 코드로 재현하며 검증하는 실습 저장소입니다.

학습 노트로 정리한 개념은 시간이 지나면 휘발됩니다. 이 저장소는 그 개념들을 실제 동작하는 코드로 재현하고, 인수 테스트로 검증하는 과정을 기록합니다. 과제는 세 가지 유형을 섞어 진행합니다.

  • 디버깅형 — 겉보기엔 정상인 코드에 숨은 버그를 증상만 보고 추적해 수정
  • 구현형 — 요구사항을 받아 기능을 조립
  • TDD형 — 실패하는 테스트를 먼저 받고 통과시키는 구현

기술 스택

  • Java 21, Spring Boot 4.1, Gradle
  • Spring Web MVC · JDBC · Validation · H2 · Lombok

구조

src/main/java/com/vryez/backendlab/
└── labNN/     # 과제별 독립 패키지 (lab01부터 누적)
docs/
└── labNN-*.md # 과제별 명세 (배경 시나리오 · 요구사항 · 완료 조건)

각 과제는 labNN 패키지에 독립적으로 쌓이고, 검증 테스트는 test 소스의 같은 패키지에 둡니다.

실행

# 전체 테스트
./gradlew test

# 특정 lab만
./gradlew test --tests 'com.vryez.backendlab.lab01.*'

실습 기록

Lab 주제 유형 분야 명세
lab01 싱글톤 빈의 공유 상태로 인한 동시성 버그 디버깅 스프링 핵심원리 docs
lab02 필드 주입 탓에 순수 단위 테스트에서 죽는 서비스 디버깅 스프링 핵심원리 docs
lab03 동영상 검색·등록 API — 요청 파라미터 바인딩과 JSON 응답 TDD 스프링 MVC docs
lab04 재발한 시청 포인트 뒤섞임 — 무상태 설계로 뿌리 뽑기 변형복습 스프링 핵심원리 docs
lab05 커넥션을 삼키는 조회수 API — 커넥션 풀 고갈 디버깅 스프링 DB docs
lab06 거절당한 후원, 사라진 시청자 포인트 — 트랜잭션 롤백 규칙 디버깅 스프링 DB docs
lab07 숨긴 삭제 버튼, 그런데 뚫리는 삭제 API — 관리자 인가 누락 디버깅 스프링 MVC docs
lab08 컨테이너 밖에선 죽는 추천 서비스 — 생성자 주입 전환 변형복습 스프링 핵심원리 docs
lab09 파라미터 없이 열어도 죽지 않는 댓글 목록 — 바인딩과 기본값 변형복습 스프링 MVC docs
lab10 터지는 하트, 모자란 정산 — 동시 집계 유실(LongAdder) 변형복습 스프링 핵심원리 docs
lab11 검색어 하나에 사이트가 멈춘다 — 커넥션 누수 변형복습 스프링 DB docs
lab12 거부된 정산이 삼킨 잔액 — 체크 예외 롤백 누락 변형복습 스프링 DB docs
lab13 남의 이름이 찍힌 후원 영수증 — 공유 상태 뒤섞임 변형복습 스프링 핵심원리 docs
lab14 영상이 늘자 버벅이는 목록 — 페이징·정렬과 기본값 바인딩 변형복습 스프링 MVC docs
lab15 몰릴수록 사라지는 조회수 — 동시 집계 유실(AtomicLong) 변형복습 스프링 핵심원리 docs
lab16 신고영상만 열면 사이트가 느려진다 변형복습 스프링 DB docs
lab17 로그인 안 했는데 남의 계정이 보인다 — ThreadLocal 신원 누수 디버깅 자바 동시성 docs
lab18 타임코드로 이어보기 — 문자열 파라미터를 도메인 타입으로 (커스텀 컨버터) TDD 스프링 MVC docs
lab19 배포 직후 모든 썸네일이 깨졌다 — 생성자에서 읽은 @Value는 null 디버깅 스프링 핵심원리 docs
lab20 감사 로그에 찍힌 유령 관리자 — ThreadLocal 정리 누락 변형복습 자바 동시성 docs
lab21 클라이언트 검증만 믿던 업로드 API가 뚫렸다 — Bean Validation 구현 스프링 MVC docs
lab22 실패한 선물이 삼킨 포인트 — 내부 호출과 프록시 미경유 디버깅 스프링 DB docs
lab23 프론트가 파싱 못하는 에러 응답 — 전역 예외 처리로 에러 계약 통일 구현 스프링 MVC docs
lab24 새 댓글 화면이 오류를 못 살린다 — 에러 계약 통일 재도전 변형복습 스프링 MVC docs
lab25 신고 영상 일괄 비공개가 반쯤만 적용된다 — 트랜잭션 경계와 이중 결함 디버깅 스프링 DB docs

lab01 — 싱글톤 빈의 공유 상태로 인한 동시성 버그

증상: 주문 금액의 10%를 할인해 돌려주는 API가 단건 호출에서는 항상 정확한데, 동시 요청이 몰리면 일부 응답에 다른 주문의 금액이 섞여 나온다. 순차 호출로는 재현되지 않는다.

원인: 스프링 빈은 기본 스코프가 싱글톤이라 인스턴스가 하나뿐인데, 요청별 데이터(amount)를 인스턴스 필드에 저장했다. 동시 요청 시 스레드 B의 쓰기가 스레드 A의 값을 덮어써, A의 응답에 B의 금액이 반영된다. 순차 테스트는 싱글 스레드라 쓰기와 읽기 사이에 끼어드는 스레드가 없어 이 버그를 드러내지 못한다.

해결 과정:

  1. synchronized로 임계 영역을 직렬화 → 테스트는 통과하지만 공유 필드가 남아 있고, 모든 요청이 락 하나에 줄을 서서 처리량이 저하됨 (동시 100요청 테스트 0.94s)
  2. 공유 필드를 제거하고 파라미터·지역변수만으로 계산하는 무상태 리팩터링 → 락 없이 스레드 안전, 테스트 0.31s로 단축

배움: 싱글톤 빈에 가변 상태를 두면 경쟁 상태가 생긴다. 락은 그 상태를 보호하는 것이고 무상태 설계는 그 상태를 제거하는 것 — 제거할 수 있으면 제거가 우선이다. volatile은 가시성만 보장할 뿐 "쓰고 → 나중에 읽는" 복합 연산의 원자성은 보장하지 않으므로 해결책이 아니다.


lab02 — 필드 주입 탓에 순수 단위 테스트에서 죽는 서비스

증상: 할인 계산·저장을 하는 OrderService를 스프링 없이 new로 생성해 호출하면 NullPointerException이 터진다. 같은 로직이 @SpringBootTest 통합 테스트에서는 통과하고 앱도 정상 기동한다.

원인: 의존관계를 필드 주입(@Autowired private ...)으로 받고 있었다. @Autowired 필드는 스프링이 객체를 생성한 뒤 리플렉션으로 채우므로, 컨테이너 없이 new OrderService()로 만들면 두 의존성 필드가 null인 채로 태어난다. 코드는 정상 컴파일되고 컨테이너 위에서는 멀쩡히 돌기 때문에 순수 단위 테스트에서만 증상이 드러난다.

해결 과정:

  1. 필드 주입을 **생성자 주입 + final**로 전환 — 의존성 없이는 객체 생성 자체가 불가능해져, 객체가 항상 완전한 상태로 태어난다
  2. @RequiredArgsConstructor로 생성자 보일러플레이트 제거 (직접 작성한 생성자와 동일한 코드가 컴파일 시점에 생성됨)
  3. 단위 테스트에서 실제 구현체(RateDiscountPolicy, MemoryOrderRepository)를 직접 생성해 주입 → 스프링 없이 통과

배움: 생성자 주입은 주입 누락을 런타임 NPE가 아니라 컴파일 오류로 앞당긴다 — "가장 빠른 오류가 가장 좋은 오류". final은 생성 후 재할당을 원천 차단해 불변을 보장한다. 롬복은 생성자 주입을 택하는 이유(불변·누락 방지·테스트 용이)가 아니라 보일러플레이트를 줄이는 편의일 뿐이다.


lab03 — 동영상 검색·등록 API: 요청 파라미터 바인딩과 JSON 응답 (TDD)

과제: 실패하는 인수 테스트 8개와 미구현 컨트롤러 스텁을 받고, 테스트를 고치지 않은 채 프로덕션 코드만 채워 전부 green으로 만든다. GET /api/videos는 쿼리 파라미터(keyword 필수·누락 시 400, limit 기본 10)로 검색하고, POST /api/videos는 HTML 폼을 객체로 바인딩해 저장한 뒤 201과 생성 리소스를 JSON으로 반환한다.

요청 쪽 — ArgumentResolver가 컨트롤러 파라미터를 채운다:

  • 핸들러의 파라미터는 스프링이 HandlerMethodArgumentResolver로 채워 넣는다. @RequestParam은 요청 파라미터에서 단건 값을 꺼내고, @ModelAttribute는 객체를 생성해 파라미터 이름과 일치하는 setter로 값을 바인딩한다.
  • 쿼리 스트링(?keyword=spring)과 폼 바디(title=...&uploader=...)는 형식이 같아서 스프링이 동일한 '요청 파라미터'로 통합해 취급한다 — 그래서 검색과 폼 등록이 같은 메커니즘으로 처리된다.
  • 애노테이션 생략 시 규칙: 단순 타입(String, int 등) → @RequestParam, 그 외 객체 타입 → @ModelAttribute.
  • @RequestParam은 required 기본값이 true라 누락 시 스프링이 알아서 400을 만든다. 기본값은 null 분기가 아니라 defaultValue = "10"으로 선언한다 — 값이 항상 존재하게 되므로 primitive int로 받을 수 있다.

응답 쪽 — HttpMessageConverter가 반환값을 바디로 쓴다:

  • @RestController(= @Controller + @ResponseBody)에서는 반환값이 뷰 이름으로 해석되지 않고, **HttpMessageConverter**가 HTTP 응답 바디에 직접 write한다. 객체·컬렉션은 MappingJackson2HttpMessageConverter가 JSON으로 직렬화한다 — List<VideoResponse>를 반환했을 뿐인데 JSON 배열이 도착하는 이유.
  • 상태 코드를 동적으로 정하려면 ResponseEntity를 쓴다. ResponseEntity.created(uri)는 201과 Location 헤더를 한 번에 실어주는 REST 정석 응답이다.

배움: 스프링 MVC의 데이터 흐름은 대칭이다 — 들어오는 요청 → 파라미터는 ArgumentResolver가, 나가는 반환값 → 응답 바디는 HttpMessageConverter가 담당한다. 프레임워크가 선언으로 제공하는 것(required, defaultValue, 객체 바인딩)을 코드 분기로 재발명하지 않는 것이 이 계층을 제대로 쓰는 법이다.


lab05 — 커넥션을 삼키는 조회수 API (커넥션 풀 고갈)

증상: 조회수 증가 기능이 처음 3번은 정상 처리되다가, 그 뒤부터 요청이 약 1초 멈춘 뒤 SQLTransientConnectionException(Connection is not available, request timed out after 1000ms)으로 실패한다. 앱을 재시작하면 잠깐 정상으로 돌아왔다가 재발한다. DB 서버와 SQL 자체는 정상.

원인: increaseViewCountfinallyPreparedStatement만 닫고 Connection을 풀에 반납하지 않았다. 풀에서 받은 커넥션은 HikariProxyConnection 프록시라 close()가 물리 종료가 아니라 풀 반납으로 동작하는데, 그 호출이 없으니 호출마다 active 커넥션이 하나씩 쌓여 3회(풀 최대치) 만에 고갈된다. 4번째 getConnection()은 반납을 기다리며 connection-timeout(1000ms)만큼 블로킹하다 예외를 던진다 — "1초 멈춤"의 정체. 재시작하면 풀이 비워져 잠깐 정상으로 보인다.

해결 과정:

  1. 10회 연속 호출하는 재현 테스트를 직접 작성해 4번째 호출부터 실패하는 것을 확인
  2. finally에서 close(con, pstmt, null) 헬퍼로 커넥션을 반납 → 정상·예외 경로 모두에서 반납 보장 (풀 설정 변경 없이 원인 제거)
  3. 모범 구현으로 try-with-resources 리팩터링 — 선언 역순 자동 close로 "반납을 잊을 수 있는 구조" 자체를 제거

배움: 풀 커넥션의 close()는 종료가 아니라 반납이다 — 안 부르면 누수가 풀 크기만큼 쌓여 서비스 전면 장애가 된다. 풀 크기를 늘리는 것은 고갈 시점을 미룰 뿐 해결이 아니다. 리소스 반납은 수동 finally보다 try-with-resources로 구조적으로 보장하고, HikariCP leakDetectionThreshold로 조기 발견 장치를 둔다.


lab06 — 거절당한 후원, 사라진 시청자 포인트 (트랜잭션 롤백 규칙)

증상: 후원 API에서 수신 비활성(gift_enabled=false) 크리에이터에게 후원하면 "후원을 받지 않습니다" 예외가 뜨며 중단되는데, 시청자 포인트는 후원액만큼 차감된 채 되돌아오지 않는다. 정상 후원은 멀쩡하고, 잔액 부족으로 거절될 때도 포인트는 멀쩡하다 — 실패 경로 중 한쪽만 증상이 난다.

원인: @Transactional의 기본 롤백 규칙은 언체크 예외(RuntimeException과 그 자손·Error)만 롤백하고, 체크 예외는 커밋한다 — 스프링은 체크 예외를 '복구 가능한 비즈니스 예외'로 보기 때문이다. 후원 거절이 체크 예외(GiftRejectedException extends Exception)로 던져지므로, 프록시는 커밋을 선택하고 이미 실행된 시청자 차감 UPDATE가 그대로 확정된다. 잔액 부족 경로는 차감 이전에 예외가 나서 증상이 없었을 뿐 — 원자성이 지켜져서가 아니라 우연이다(원자성 착시).

해결 과정:

  1. 거절 시나리오 인수 테스트(후원 거절 후 시청자 1000P 유지)로 차감이 700P로 확정되는 버그를 재현
  2. @Transactional(rollbackForClassName = {"GiftRejectedException"})으로 체크 예외를 롤백 대상에 포함 → 인수 테스트 3/3 통과
  3. 모범 구현: 문자열 방식은 FQN 포함(contains) 매칭이라 동명 예외 오매칭·오타 위험이 있으므로 타입 안전한 rollbackFor = GiftRejectedException.class가 표준. 예외를 RuntimeException 상속으로 바꿔 기본 규칙으로 롤백시키는 것도 정답(실무 대세)

배움: "예외가 나면 무조건 롤백"이 아니다 — 롤백 여부는 트랜잭션 프록시가 예외의 종류로 판정한다. 원자성은 차감·증가 두 UPDATE가 하나의 트랜잭션(논리적 작업 단위)으로 묶여 둘 다 커밋되거나 둘 다 롤백되는 것이고, 검증 순서만 바꿔 증상을 피하는 수정은 다른 실패 지점에서 원자성이 다시 깨지므로 근본 수정이 아니다. 실무에서 이 버그는 결제·정산의 부분 커밋(돈 증발, 대사 불일치) 장애로 나타난다.


lab07 — 숨긴 삭제 버튼, 그런데 뚫리는 삭제 API (관리자 인가 누락)

증상: 영상 삭제는 관리자(ADMIN) 전용이고, 프론트는 GET /lab07/videoscanDelete 값을 보고 관리자에게만 삭제 버튼을 그린다. 일반 회원 화면에는 버튼 자체가 없는데 "내가 올린 영상이 사라졌다"는 신고가 접수됐다 — 접근 로그에는 일반 회원 계정이 삭제 API를 직접 호출했고 서버가 200으로 정상 처리한 기록이 남아 있다. 비로그인 요청은 401로 잘 막힌다.

원인: AdminAuthInterceptor.preHandle이 세션의 loginMember가 null인지(인증 — 로그인 여부)만 확인하고 곧바로 통과시켰다. Role.ADMIN인지 확인하는 인가(권한) 검사가 통째로 빠져 있어, 로그인한 일반 회원(USER)이 /lab07/admin/**을 그대로 통과한다. 로그인 체크 인터셉터를 관리자 가드로 잘못 재사용한 "인증은 됐지만 인가는 안 된" 전형적 혼동. canDelete=false는 화면 편의용 힌트일 뿐 엔드포인트는 UI와 무관하게 항상 열려 있다 — 숨긴 버튼 ≠ 막힌 엔드포인트.

해결 과정:

  1. Postman으로 viewer(USER) 로그인 → DELETE /lab07/admin/videos/1 → 200과 함께 영상이 실제 삭제되는 장애를 재현
  2. preHandle의 인증 검사 다음에 loginMember.getRole() != Role.ADMIN → 403 인가 검사를 추가 — 컨트롤러에 분산하지 않고 인터셉터(공통 관심사) 한 곳에서 처리
  3. 인수 테스트 4/4 통과: 비로그인 401 / USER 403(영상 유지) / ADMIN 200(삭제 확인) / canDelete 힌트 유지

배움: 인증(누구인지 확인)과 인가(그 신원에 권한이 있는지 확인)는 별개의 관문이며, 로그인 통과가 권한 보유를 의미하지 않는다. 상태 코드도 구분된다 — 401은 인증 실패, 403은 인가 실패. 신뢰 경계는 클라이언트가 아니라 서버라서 UI 숨김은 보안이 아니며, 인가는 반드시 서버가 강제해야 한다. 이 유형이 OWASP Top 10 1위 Broken Access Control이고, 스프링 시큐리티로 옮기면 .requestMatchers("/lab07/admin/**").hasRole("ADMIN") 한 줄에 해당한다.


lab17 — 로그인 안 했는데 남의 계정이 보인다 (ThreadLocal 신원 누수)

증상: 로그인 사용자 정보를 어디서나 꺼내 쓰도록 인증 필터가 UserContext를 ThreadLocal에 담는 구조. 혼자 테스트할 땐 마이페이지(GET /api/lab/me)가 항상 정확한데, 베타 오픈 후 비로그인 사용자에게 모르는 사람의 닉네임이 뜬다는 신고가 접수됐다. 항상은 아니고 트래픽이 몰리는 시간대에만 간헐적으로 재현된다.

원인: UserContextFilter가 ThreadLocal에 set만 하고 요청 종료 시 remove하지 않았다. 톰캣은 워커 스레드를 풀로 재사용하므로, 값이 매달린 채 반납된 스레드를 다음 요청(비로그인·미존재 사용자 등 set하지 않는 경로)이 배정받으면 직전 사용자의 신원을 그대로 읽는다. 저부하에선 매 요청이 깨끗한/다른 스레드를 받아 안 겹치고, 부하로 스레드가 재사용될 때만 터진다 — "간헐적"의 정체. 반면 MockMvc는 perform이 호출 스레드에서 동기 실행돼 연속 호출이 같은 스레드를 공유하므로 결정론적으로 재현할 수 있다.

해결 과정:

  1. 무헤더 → 헤더(사용자 1) → 무헤더 3연속 호출 재현 테스트 작성 — 세 번째 요청이 401이 아니라 직전 사용자(철수)의 200으로 응답하는 누수를 확인. 수정을 되돌리면 다시 빨간불이 되는 것까지 검증(버그 판별력 있는 테스트)
  2. 1차 수정: 컨트롤러 me()에 try/finally + clear() → 정상·미존재 사용자 경로는 통과했지만, 인증 후 처리 중 예외가 난 요청 뒤의 비로그인 요청에 직전 신원이 새는 예외 경로 누수 잔존. 필터는 헤더 있는 모든 요청에 set하는데 clear는 엔드포인트 하나만 하기 때문
  3. 최종 수정: 필터에서 chain.doFilter(req, res)try { } finally { UserContextHolder.clear() }로 감싸 요청 경계에서 정리 → 예외 경로 포함 인수 테스트 전부 통과

배움: ThreadLocal은 값이 스레드별로 격리돼 무상태 원칙(여러 스레드가 공유하는 가변 상태 금지)에 어긋나지 않지만, 스레드 풀 환경에서는 요청 경계에서 remove해야 격리가 유지된다. 정리는 값을 소비하는 곳이 아니라 set한 계층이 같은 계층의 finally에서 해야 한다 — 스프링의 SecurityContextHolder·RequestContextHolder가 정확히 이 패턴(필터에서 set, finally에서 clear)이다. 이 버그 클래스는 실무에서 "부하 시간대에 남의 개인정보가 보인다"는 최악의 보안 사고로 나타나며, 재현이 어려워 원인 파악에 오래 걸리는 유형이다.

덧붙임: 채점 중 프로젝트 지뢰도 발견 — @SpringBootTest는 테스트 클래스패스까지 컴포넌트 스캔하므로 테스트 안의 @RestController 중첩 클래스는 이중 등록(Ambiguous mapping)되고, 테스트 컨텍스트가 2개 뜨면 공유 named H2(mem:lab11)에 schema.sql이 재실행돼 전체 테스트가 죽는다.


lab18 — 타임코드로 이어보기: 커스텀 타입 컨버터 (TDD)

과제: 실패하는 인수 테스트 6개를 받고, 이어보기 API GET /lab18/resume?at=03:25가 '분:초' 타임코드를 총 재생 초({"seconds":205})로 응답하게 만든다. 컨트롤러 시그니처는 @RequestParam Timecode at으로 고정 — 문자열로 받아 손으로 파싱하지 않고, Converter<String, Timecode>를 만들어 등록하는 것만이 정답 경로다. 잘못된 형식(03:75, 3:5, abc)은 400.

변환 경로 — ArgumentResolver → ConversionService → 내 컨버터:

  • RequestParamMethodArgumentResolver가 쿼리 스트링에서 문자열을 꺼내고, 목표 타입이 TimecodeConversionService에 변환을 위임한다. WebMvcConfigurer.addFormatters로 등록해 둔 내 컨버터가 여기서 호출된다 (부트에선 컨버터 빈을 @Component로 두면 자동 등록).
  • 적용 범위가 핵심 함정: ConversionService가 쓰이는 곳은 @RequestParam·@PathVariable·@ModelAttribute·뷰 템플릿까지다. @RequestBody JSON 바디는 HttpMessageConverter(Jackson) 담당이라 등록한 컨버터가 절대 적용되지 않는다. JSON 쪽은 @JsonCreator나 커스텀 JsonDeserializer로 따로 가르쳐야 하며, 파싱 로직은 도메인의 parse() 한 곳에 두고 Converter@JsonCreator가 둘 다 그것을 호출하게 만드는 것이 정석이다.

400 vs 500 — 실패의 성격이 상태코드를 가른다:

  • 컨버터가 변환을 시도했다가 IllegalArgumentException을 던지면 MethodArgumentTypeMismatchException으로 감싸여 400 — 클라이언트가 준 값이 잘못. JSON 경로에서 @JsonCreator가 던지면 HttpMessageNotReadableException → 역시 400으로, 규칙이 두 경로에 일관 적용된다.
  • 변환할 컨버터가 애초에 등록돼 있지 않으면 ConversionNotSupportedException500 — 값과 무관한 서버 설정 문제. 스캐폴드 초기 상태에서 6개 테스트가 전부 500으로 RED였던 이유.

배움: split 개수·자리수·숫자 여부·범위라는 검증 4단계는 정규식 (\d+):([0-5]\d) 하나로 접힌다 — [0-5]\d가 "정확히 2자리이면서 00~59"를 선언적으로 표현하므로 검증 순서 고민과 부호 입력(+3:25) 구멍이 함께 사라진다. NumberFormatExceptionIllegalArgumentException의 자식이라 catch-후-재포장은 애초에 불필요했다. 요약하면: 시도하다 실패 = 클라이언트 잘못 = 400, 시도조차 불가 = 서버 설정 문제 = 500.


lab19 — 배포 직후 모든 썸네일이 깨졌다 (생성자에서 읽은 @Value는 null)

증상: 배포 직후부터 영상 목록의 모든 썸네일이 엑스박스. 목록 API(GET /lab19/videos) 응답의 thumbnailUrl이 전부 null/th/v1001.jpg처럼 문자열 null로 시작한다. 앱은 정상 기동하고 에러 로그도 없으며 API는 200을 준다. CDN 주소 설정값(app.cdn-base-url)도 멀쩡히 들어 있다 — 대시보드는 전부 초록불인데 사용자 화면만 깨진 상태.

원인: ThumbnailUrlAssembler@Value 필드 주입으로 받는 cdnBaseUrl생성자에서 읽어 prefix를 계산했다. 스프링 빈은 객체 생성(생성자) → 의존관계·값 주입 → 초기화 콜백 순서로 살기 때문에, 생성자가 도는 시점엔 주입이 아직 일어나지 않은 것이 규칙이다("주입이 느려서"가 아니라 생명주기가 보장하는 결정적 순서 — 100% 재현된다). 그래서 생성자 시점의 필드는 항상 null이고, 그때 계산된 prefix"null/th/"로 굳는다. 이후 실제 값이 주입돼도 이미 계산이 끝난 prefix는 그대로다. null을 문자열에 연결만 할 뿐 메서드를 호출하지 않아 NPE 없이 조용히 기동하는 것이 이 버그의 무서운 점.

해결 과정:

  1. bootRun + curl로 세 항목 모두 null/th/...로 나오는 증상을 재현
  2. prefix 계산을 생성자에서 **@PostConstruct init()**으로 이동 — 초기화 콜백은 주입 완료 이후를 보장하는 자리
  3. 인수 테스트 3/3 통과: 기본 CDN URL 형식 / 설정값 오버라이드 시 응답도 따라감(하드코딩 방지) / 시드·정렬 회귀 없음

배움: 생성과 초기화는 다른 단계다 — 주입된 값에 의존하는 초기화는 생성자가 아니라 초기화 콜백(또는 생성자 주입)에서 해야 한다. 협력 빈을 필드 주입받아 생성자에서 그 메서드를 호출했다면 NPE로 기동 자체가 실패해 시끄럽게 발견됐을 텐데, 문자열 연결은 틀린 값을 조용히 흘려보내 훨씬 위험하다. 모범은 생성자 주입(@Value 파라미터) + final prefix: 테스트에서 new로 바로 생성 가능하고, 불변이 보장되며, 설정 누락 시 기동 시점에 fail-fast로 터져 이런 장애가 원천 차단된다.

덧붙임: 채점 테스트가 설정 오버라이드로 두 번째 컨텍스트를 띄우면서 lab17에서 발견했던 지뢰(공유 named H2에 비멱등 schema.sql 재실행)가 실제로 터져, lab11·lab16 스크립트를 멱등화(IF NOT EXISTS + reset-and-reseed)로 정비했다. 또 lab17의 UserContextFilter@WebMvcTest 슬라이스에 끌려 들어가 lab03·lab09·lab14 테스트 27건이 깨지는 기존 문제를 발견 — 다음 정비 후보.


lab21 — 클라이언트 검증만 믿던 업로드 API가 뚫렸다 (Bean Validation)

과제: 프런트 폼 검증에만 의존하던 영상 업로드 API(POST /lab21/videos)를 QA가 직접 호출해 보니 아무 값이나 DB에 저장되고 있었다 — 대문자·공백 핸들, 음수 재생시간, 목록에 없는 카테고리. 컨트롤러 안 if문 없이 DTO 애노테이션 + @Valid 선언 방식으로 서버 측 검증을 세워, 잘못된 요청은 DB에 닿기 전에 400으로 거부한다.

구현 포인트:

  • title@NotBlank @Size(max = 100)@NotNull은 null만 거부(" " 통과), @NotEmpty는 공백 통과, 사람이 입력하는 필수 문자열엔 @NotBlank가 정답이다.
  • handle(3~30자, 소문자·숫자·하이픈, 하이픈 시작/끝 금지)은 정규식 ^(?=.{3,30}$)[a-z0-9]+(?:-[a-z0-9]+)*$로 잠갔다 — lookahead로 전체 길이를 고정하고, [a-z0-9]+로 시작·끝나는 그룹 구조가 하이픈 경계를 선언적으로 차단한다.
  • durationSec에 JSON 문자열 "abc"가 오는 케이스는 검증이 아니라 역직렬화 단계에서 실패한다(HttpMessageNotReadableException) — Validator에 도달하기 전이라 검증 애노테이션과 무관하게 400이 되고, 이때도 행은 저장되지 않는다.
  • 인수 테스트 6/6 통과: 정상 201 + 행 증가, 거부 전 구간 400 + 행 수 불변(검증이 저장보다 먼저임을 DB로 확인).

배움: @Valid 검증은 스프링 부트가 LocalValidatorFactoryBean을 글로벌 Validator로 등록해 바인딩 성공 후 자동 수행한다 — 컨트롤러는 성공 로직에만 집중하게 된다. 남은 구멍 하나: @Patternnull을 통과시키므로 handle 필드를 아예 빼고 보내면 검증을 지나 DB NOT NULL 제약에서 500이 난다. 필수 문자열엔 @NotBlank를 반드시 병행해야 하며, "검증의 400"과 "DB 제약의 500"은 서로 다른 방어선이다.


lab22 — 실패한 선물이 삼킨 포인트 (내부 호출과 프록시 미경유)

증상: 정지된 크리에이터에게 포인트를 선물하면 "정지된 계정에는 선물할 수 없습니다" 예외로 정상 차단되는데, 보낸 사람 잔액만 선물 금액만큼 줄어든 채 돌아오지 않는다(받는 쪽은 그대로). 단건 요청으로 100% 재현되고, 정상 계정 선물 경로는 멀쩡하다.

원인: 진입 메서드 gift()(트랜잭션 없음)가 @Transactional이 붙은 transfer()같은 클래스 내부 호출(this.transfer())로 불렀다. @Transactional은 프록시 방식 AOP라 컨테이너에는 프록시가 등록되지만, 내부 호출은 this(실제 객체)를 향해 프록시를 거치지 않는다. 트랜잭션이 애초에 시작되지 않아 각 UPDATE가 autocommit으로 즉시 커밋되고, 차감 커밋 → 예외 순서라 되돌릴 트랜잭션 자체가 없다. 함정: 예외가 언체크(IllegalStateException)라 "언체크는 롤백" 규칙상 롤백돼야 할 것처럼 보이지만, 그 규칙은 트랜잭션이 존재할 때만 성립한다 — 예외 종류의 문제가 아니라 '트랜잭션이 시작됐는가'의 문제.

해결 과정:

  1. 실패 경로 재현 테스트 작성 — 예외 발생 + 양쪽 잔액 모두 선물 이전 그대로인지 단언 (버그 상태에서 보낸 쪽 500 차감으로 red)
  2. @Transactional 메서드를 별도 빈 TransferService로 분리 — 호출이 반드시 프록시를 경유하게 만드는 권장 패턴. 수기 보상 로직(try-catch 원복) 없이 인수 테스트 전부 green

배움: 프록시 AOP의 사각지대는 내부 호출이다 — 애노테이션이 붙어 있다는 사실과 그것이 적용된다는 사실은 다르다. 대안(진입 메서드로 애노테이션 이동, self-injection, AspectJ 위빙) 중 클래스 분리가 구조를 가장 명확히 한다. 테스트 습관도 하나: 검증 단언은 양쪽 잔액을 모두, 재조회로 해야 한다 — 수정 전에 잡아둔 record 스냅샷을 다시 읽으면 항상 참인 무의미한 단언이 된다.


lab23 — 프론트가 파싱 못하는 에러 응답 (전역 예외 처리로 에러 계약 통일)

과제: 에러 응답이 케이스마다 제각각(스프링 기본 400 JSON, 필드 정보 없는 400, 없는 영상엔 500)이라 프론트 전역 에러 핸들러가 파싱하지 못한다. 모든 실패를 code/message/fieldErrors 단일 봉투로 통일 — 없는 영상 404, 검증 실패·깨진 JSON·타입 불일치 400, 그 외 500(내부 정보 미노출). 산출물은 봉투 DTO와 @RestControllerAdvice 전역 핸들러.

1차 시도 (1점): 봉투 DTO(ExceptionResponse + FieldError) 설계까지는 스펙과 일치했지만, @ExceptionHandler 메서드 몸통 — 응답 DTO를 만들어 @ResponseStatus와 함께 반환하는 기본형 — 을 손으로 완성하지 못했다. H3 힌트(예외 클래스명 표)까지 받고도 빈 몸통으로 컴파일 실패, 인수 테스트 0/7. 검증 실패·깨진 JSON·타입 불일치에 대응하는 스프링 예외 3종의 매핑이 머리에 없었던 것이 핵심 갭.

재도전 — lab24 변형복습 (4점, 6/6 통과): 다음 날 같은 개념·다른 시나리오(영상 댓글 등록 API)로 해설 없이 처음부터 다시 작성. MethodArgumentNotValidException(400, BindingResult.getFieldErrors() 순회) / HttpMessageNotReadableException(400) / MethodArgumentTypeMismatchException(400) / 도메인 예외 404·409 / catch-all 500까지 핸들러 6개를 세워 전 케이스 통일. 이번에 넘어진 곳은 예외 매핑이 아니라 직렬화였다:

  1. @Builder는 수동 생성자의 기본값(List.of())을 타지 않는다 — 빌더에서 errors를 안 채우면 null이 되어 JSON에서 키가 실종
  2. Lombok @Data중첩 클래스에 적용되지 않는다 — getter 없는 중첩 DTO를 Jackson이 {} 빈 객체로 직렬화

배움: 전역 예외 처리의 뼈대는 "구체 예외 핸들러가 우선, Exception 핸들러는 catch-all"이라는 스프링의 '자세한 것이 우선' 규칙 위에 선다. 깨진 JSON은 역직렬화 단계(객체가 안 만들어짐 → 필드 오류 없음), 검증 실패는 바인딩 후(BindingResult에 필드별 오류 있음)라 같은 400이라도 핸들러를 합치면 안 된다. 그리고 에러 message에 스프링 예외의 getMessage() 원문을 담으면 내부 구현 문자열이 클라이언트로 새어 나간다 — 사용자용 고정 메시지와 서버 로그를 분리할 것. 응답 계약("errors는 절대 null 아님")은 사람이 지키는 규칙이 아니라 타입이 보장하게 만들어야 한다(record + 정적 팩토리).


lab25 — 신고 영상 일괄 비공개가 반쯤만 적용된다 (트랜잭션 경계와 이중 결함)

증상: 관리자가 신고 영상 5건을 일괄 비공개 처리하다 오류를 만났는데, 목록 앞쪽 영상들은 이미 PRIVATE로 바뀌었고 감사용 moderation_log에도 그 영상들 기록이 남았다. 운영 규칙은 전부 성공 아니면 전부 취소인데, 실패한 요청이 절반만 적용된 채 멈춘 것. 처리 가능한 영상만 골라 재요청하면 정상 동작한다.

원인: 완전 롤백을 각각 독립적으로 무너뜨리는 결함이 2개였다. ① @Transactional이 배치 진입 메서드 hideReportedVideos가 아니라 건별 처리 hideOne에 붙어 있고, 진입 메서드가 그것을 this 내부 호출로 부른다 — 프록시를 거치지 않으니 트랜잭션이 아예 시작되지 않고, 매 UPDATE/INSERT가 오토커밋으로 즉시 확정된다. ② 실패 신호 ModerationException이 체크 예외라, 경계를 제대로 묶어도 스프링 기본 정책(언체크만 롤백)상 커밋된다.

해결 과정:

  1. 1차 제출은 hideOne의 자리에 그대로 둔 채 rollbackFor = ModerationException.class만 추가(결함 ② 대응) → 인수 테스트에서 완전 롤백 실패(1·2번이 PRIVATE 잔존). 내부 호출에 가려 어노테이션 전체가 한 번도 발동하지 않는 데드 코드였고, 수정 전과 동작이 동일했다. 3점
  2. 리뷰 후 @Transactional(rollbackFor=...)을 진입 메서드 hideReportedVideos로 이동 → 배치 전체가 한 트랜잭션, 3번 실패 시 1·2번 변경까지 함께 롤백. 인수 테스트 2/2 통과

배움: lab22에서 배운 병소(내부 호출·프록시 미경유)가 다른 옷을 입고 나오자 탐지하지 못했다 — 개념을 말로 아는 것과 코드에서 알아보는 것은 다른 근육이다. 디버깅은 재현 → 수정 → 재재현이 한 세트: 수정 후 증상 재현을 한 번만 돌렸어도 "아무것도 안 바뀌었다"를 즉시 알았다(transaction.interceptor TRACE 로그로 트랜잭션 시작 메서드를 눈으로 확인하는 것도 방법). 커밋/롤백 판단 기준은 '처리 가능한 예외냐'가 아니라 '커밋하면 남는 데이터가 올바른 상태냐' — 잔고 부족(비즈니스 예외)은 데이터를 남기는 게 옳아 커밋, 여기는 all-or-nothing 불변식 위반이라 롤백이며, 그래서 rollbackFor 지정 또는 언체크 전환이 필요하다. 이 패턴이 운영에서 터지면 감사 로그는 '조치 완료'인데 실제로는 절반만 적용된 상태 불일치 장애가 된다. (3점 — 2026-08-29 변형복습 예정)

About

백엔드 개념을 코드로 재현하고 테스트로 검증하는 실습 저장소

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages