2026. 7. 15. 15:42ㆍ부트캠프/TIL(Today I Learned)
오늘의 학습
DDD 발표 및 특강 준비
이번 팀 프로젝트에서 내가 맡은 영역은 공통 설정(BaseEntity, Auditing, Logging), 유저 도메인, 리뷰 도메인이었다.
로깅(Logback + Filter + MDC) 집중 학습
구현한 내용
- Filter를 최상단에 배치하여 모든 요청을 가로채도록 설정
- 요청 하나당 로그 한 줄만 남기도록 구조화
- MDC(Mapped Diagnostic Context)를 이용해 traceId, userId를 로그에 포함
- 컨트롤러에 도달하지 못하고 실패하는 401 Unauthorized도 기록되도록 처리
로그 예시
BEFORE
[INFO] GET /api/orders
AFTER
[INFO] traceId=ab12c3 userId=42 GET /api/orders 200 18ms
덕분에 문제가 발생한 요청을 추적하기 훨씬 쉬워졌다.
공통 설정 개선
설계
BaseEntity에 Auditing을 적용해서 생성자, 수정자, 생성 시간, 수정 시간이 자동으로 기록되도록 했다.
또한 시간 타입을 Instant로 통일했다.
선택 이유
- 로컬 환경은 KST(UTC+9)
- 배포 환경은 UTC
- LocalDateTime을 사용하면 환경마다 다른 값이 저장될 수 있음
- Instant는 항상 UTC 기준이라 일관성을 보장
유저 도메인 설계
1. 회원 탈퇴(Soft Delete)
주문 이력이 있기 때문에 물리 삭제는 불가능했다.
문제는 아이디·닉네임·이메일에 unique 제약이 걸려 있어서 탈퇴한 계정의 정보가 영원히 재사용되지 못한다는 점이었다.
그래서 탈퇴 시 다음과 같이 치환했다.
del_42
- PK인 id를 사용하므로 유일성 보장
- 닉네임 20자 제한 안에 들어감
- 어떤 사용자가 탈퇴했는지 역추적 가능
2. 회원가입 엔드포인트 분리
role을 Request Body로 받지 않고, 역할별로 엔드포인트를 분리했다.
막고 싶었던 위험
POST /signup
{
"role": "MASTER"
}
이 한 줄로 관리자 권한을 얻는 권한 상승 취약점이 발생할 수 있다.
리뷰 도메인 설계
핵심
리뷰는 storeId 기준이 아니라 orderId 기준으로 작성하도록 설계했다.
왜 orderId인가?
주문하지 않은 사용자가 별점 테러를 하는 것을 방지하기 위해서다.
검증 순서
1단계
본인 주문인가?
2단계
배달 완료인가?
3단계
이미 작성했는가?
리뷰 정책
항목정책
| 별점 | 필수 |
| 내용 | 선택(null 허용) |
| 사장 답글 | 1:1 제한 |
별점만 남기고 바로 제출할 수 있도록 한 이유는 리뷰 작성률을 높이기 위해서다. 실제 배달앱들도 같은 방식을 사용한다.
사장 답글은 DB unique 제약 + 서비스 로직 양쪽에서 중복을 막았다.
보안 처리냐? 최적화냐? 거짓 딜레마
authenticated() 하나 붙였는데 왜 401이 403이 됐을까?
JWT 인증을 만들다 보면 한 번쯤 이런 상황을 겪는다.
.requestMatchers(HttpMethod.POST, "/api/auth/logout").authenticated()
딱 이 한 줄 추가했는데 갑자기 잘 나오던 401 Unauthorized가 403 Forbidden으로 바뀐다.
나도 처음에는
"authenticated()가 문제인가?"
싶어서 한참 삽질했다.
근데 결론부터 말하면 문제는 authenticated()가 아니다.
진짜 문제는 예외를 처리하는 위치였다.
원래는 왜 잘 됐을까?
처음에는 이렇게 되어 있었다.
.requestMatchers(HttpMethod.POST, "/api/auth/logout").permitAll()
그러면 Spring Security는 그냥 요청을 통과시킨다.
흐름으로 보면 이런 식이다.
Client
│
▼
Security Filter
│
▼
Controller
│
▼
JwtUtil에서 토큰 검사
│
▼
예외 발생
│
▼
GlobalExceptionHandler
│
▼
401 응답
예를 들어 JwtUtil에서
if (token == null) {
throw new UnauthorizedException("로그인이 필요합니다.");
}
이런 예외를 던지면
@RestControllerAdvice
가 받아서 JSON 형태로 401을 만들어 준다.
그래서 응답도 깔끔하게 나온다.
authenticated()를 붙이면?
보안을 위해 이렇게 바꿨다.
.requestMatchers(HttpMethod.POST, "/api/auth/logout").authenticated()
그런데 이제는 흐름이 달라진다.
Client
│
▼
Security Filter
│
인증 실패
│
응답 종료
여기서 중요한 건 컨트롤러까지 가지 않는다는 것이다.
즉,
@RestControllerAdvice
도 실행되지 않는다.
왜냐하면 @RestControllerAdvice는 DispatcherServlet 안에서만 동작하기 때문이다.
필터에서 요청이 끝나 버렸으니까 예외 처리기까지 갈 일이 없는 거다.
그래서 처음에는 이런 생각을 했다.
그냥
authenticated()
를 빼면 되지 않을까?
그러면 다시
Controller
↓
JWT 검사
↓
401
응답은 예쁘게 나온다.
근데 보안은 별로다.
인증도 안 된 요청이 컨트롤러까지 들어오기 때문이다.
인증이라는 건 원래 컨트롤러보다 먼저 끝나야 한다.
그럼 authenticated()를 유지하면?
이번에는
Filter
↓
403
또는
기본 HTML
이 나온다.
그래서
"보안이랑 응답 품질은 둘 중 하나만 선택해야 하나?"
라고 생각하기 쉽다.
근데 이게 완전 착각이었다.
진짜 문제는 예외 처리기가 없었던 거다.
원래는
Controller
↓
GlobalExceptionHandler
에서 예외를 처리했다.
그런데 JWT 검사를
Controller
↓
Filter
로 올렸다.
근데 예외 처리기는 그대로 아래에 있었다.
결국
JWT 검사
↑
Filter
↓
예외 처리기 없음
이 상태가 된 거다.
검사만 올리고 예외 처리기는 안 올린 거다.
해결 방법은 AuthenticationEntryPoint
Spring Security에는 필터에서 발생한 인증 예외를 처리하는 전용 클래스가 있다.
바로
AuthenticationEntryPoint
다.
예를 들어
@Component
public class JwtAuthenticationEntryPoint
implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception)
throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("""
{
"message":"로그인이 필요합니다."
}
""");
}
}
그리고 SecurityConfig에서 등록해 준다.
http.exceptionHandling(exception -> exception
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
);
그러면 흐름은 이렇게 바뀐다.
Client
│
▼
Security Filter
│
인증 실패
│
AuthenticationEntryPoint
│
401 JSON 응답
컨트롤러까지 가지 않으면서도 JSON 응답을 깔끔하게 받을 수 있다.
그럼 403은 언제 나오는 걸까?
401이랑 403은 의미가 다르다.
401
로그인 자체가 안 된 상태다.
- 토큰이 없음
- 토큰이 만료됨
- 토큰이 잘못됨
이런 경우다.
반대로 403은
로그인은 했는데 권한이 없는 경우다.
예를 들어
ROLE_USER
계정이
ROLE_ADMIN
전용 API를 호출하면 403이 나온다.
이때는 AuthenticationEntryPoint가 아니라 AccessDeniedHandler가 동작한다.
이번에 삽질하면서 깨달은 점
처음에는
보안
VS
응답 품질
둘 중 하나를 선택해야 하는 줄 알았다.
근데 아니었다.
문제는 예외 처리기의 위치였다.
JWT 검사를 필터로 올렸으면 예외 처리도 필터에서 해야 한다.
결국 이번에 얻은 결론은 딱 하나다.
방어를 앞당겼으면 에러 처리도 같이 앞당겨야 한다.
authenticated()를 빼는 게 해결책이 아니라, AuthenticationEntryPoint를 등록해서 보안도 지키고 응답도 깔끔하게 만드는 게 맞는 방법이었다.
오늘 배운 점
설계에서 가장 중요한 건 "왜"
- 시간 타입 하나도 운영 환경까지 고려해야 한다.
- 로깅 위치 하나에 따라 수집 가능한 장애 정보가 달라진다.
- 회원 탈퇴 방식은 비즈니스 데이터(주문 이력)와 제약 조건(unique)을 함께 고려해야 한다.
- 리뷰 기준을 orderId로 바꾸는 것만으로 구매 인증이라는 강력한 정책을 구현할 수 있다.
어려웠던 점
- LocalDateTime과 Instant의 차이를 실제 운영 환경 관점에서 이해하는 데 시간이 걸렸다.
- Filter → Security → Controller 순서에서 어떤 예외가 어디서 발생하는지 추적하는 과정이 복잡했다.
- Soft Delete 후 unique 값을 어떻게 처리할지 처음에는 떠오르지 않았는데, del_{id} 방식으로 정리하면서 해결됐다.
- 발표 준비를 하면서 기능 설명이 아니라 설계 의도를 설명해야 한다는 점이 생각보다 어려웠다.
내일 목표
- DDD 발표 리허설
- 1분 / 30초 버전 각각 연습하기
- 로깅 구조 정리
- MDC, traceId 흐름 다이어그램으로 정리하기
- JWT 인증 흐름 복습
- 필터에서 인증 객체가 SecurityContext에 들어가는 과정 다시 보기
- 리뷰 도메인 테스트 코드 보강
- 중복 리뷰 작성 / 미완료 주문 리뷰 작성 케이스 추가
- 특강 내용 정리
- DDD에서 Aggregates와 Domain Service 개념 다시 정리하기
한 줄 회고
오늘은 운영 환경과 보안
로깅과 Soft Delete를 정리하고 발표를 고려한 전체적인 주문관리 시나리오를 익히는데 집중했다.
'부트캠프 > TIL(Today I Learned)' 카테고리의 다른 글
| [내일배움캠프_사전캠프_8기] TIL Days-21 Docker, Redis, MSA 진짜 시작 (0) | 2026.07.21 |
|---|---|
| [내일배움캠프_사전캠프_8기]_Days_19 TIL_ 팀 프로젝트-발표 (0) | 2026.07.20 |
| [내일배움캠프_사전캠프_8기]_Days_17 TIL_ 팀 프로젝트-Day9 (0) | 2026.07.14 |
| [내일배움캠프_사전캠프_8기]_Days_16 TIL_ 팀 프로젝트-Day8 (0) | 2026.07.13 |
| [내일배움캠프_사전캠프_8기]_Days_15 TIL_ 팀 프로젝트-Day7 (0) | 2026.07.10 |