로깅 기초와 Log Level
1. sout
1) sout 사용하면 안 되는 이유
- 성능 문제: 내부적으로 I/O 작업 처리 -> 끝날 때까지 다음 작업 기다려야 함
- 로그의 휘발성(저장X): 콘솔 창은 끄거나 서버 재시작하면 날아감
- 필터링 불가능: 프린트 문은 무조건 출력됨 -> 에러만 못 봄
2) 로깅 프레임 워크
- SLF4J (인터페이스) : JPA처럼 로깅에 대한 추상화된 인터페이스(껍데기)
- Logback (구현체): SLF4J의 규격에 맞춰 실제로 로그를 찍어주는 똑똑한 일꾼 (하이버네이트 같은 역할)
- sout보다 빠름 / 버퍼를 사용하거나 비동기 방식 처리
3) SLF4J 어노테이션
- log.info(...)라는 메서드를 호출할 수 있게 '조종기(리모컨)'를 달아주는 역할
- 어떤 색상으로 출력할지", "콘솔에 찍을지 파일로 저장할지", "며칠이 지나면 파일을 삭제할지" 등의 디테일한 규칙(Rule)을 정해주려면 반드시 설정 파일이 필요 = logback-spring.xml
4) xml 만드는 이유
- application.yml에서의 로깅 설정은 아주 기본적인 수준(레벨 조정 정도)만 가능
- "매일 자정마다 로그 파일을 분리하고, 30일이 지난 로그 파일은 자동으로 삭제해라" 같은 복잡한 요구사항이 필수적 = logback-spring.xml 필요
- logback-spring.xml (뼈대 담당) < application.yml (리모컨 담당) : yml이 더 쎔
2. 로그
1) 로그 레벨
- TRACE (추적): 지금 for문 안에서 i가 3이 됐어!" 수준의 아주 상세하고 시시콜콜한 정보입니다. 보통 프레임워크 내부 소스를 뜯어볼 때나 쓰며, 개발자들도 평소엔 잘 안 봄
- DEBUG (디버깅): 개발 단계에서 가장 많이 쓰는 레벨입니다. "DB에 쿼리가 이렇게 날아갔어", "클라이언트가 이런 파라미터를 보냈어" 등 개발자가 버그를 찾기 위해 남기는 기록
- INFO (정보): 운영 서버의 기본 레벨입니다. "서버가 정상적으로 8080 포트에서 켜졌어", "유저 홍길동이 방금 회원가입을 완료했어" 등 시스템의 정상적인 운영 상태를 알려주는 목적
- WARN (경고): 당장 서버가 죽는 건 아닌데... 느낌이 쎄한데?" 하는 상태입니다. 예를 들어 "어떤 유저가 비밀번호를 5회 연속 틀렸어 (해킹 시도 의심)", "DB 연결이 좀 지연되고 있어" 같이 당장 장애는 아니지만 주시해야 할 예외 상황
- ERROR (에러): 비상사태입니다. "DB 연결이 아예 끊어졌어!", "결제 로직에서 NullPointerException이 터졌어!" 등 시스템에 명백한 장애가 발생했을 때 남김
2) 프로필별 로깅 전략

- Local / Dev (개발 환경): DEBUG 레벨로 설정
- 깔때기 구멍을 넓게 엽니다. DEBUG, INFO, WARN, ERROR가 모두 출력됩니다. 개발 중이니 쿼리가 어떻게 나가는지, 데이터가 잘 들어오는지 꼼꼼하게 확인
- Prod (운영 환경): INFO (또는 WARN) 레벨로 설정
- 깔때기 구멍을 확 좁힙니다. 수백만 명의 유저가 접속하는데 DEBUG 로그까지 다 파일로 남기면 디스크 용량이 터져버립니다. 정상 작동 로그(INFO)와 문제 상황(WARN, ERROR)만 깔끔하게 남겨서 시스템의 안정성을 모니터링
AOP 로깅 (API 자동 로깅 구현)
1. 안좋은 예 : 컨트롤러마다 복붙
@RestController
@RequestMapping("/api/members")
@Slf4j
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
@PostMapping("/signup")
public ResponseEntity<MemberResponse> signup(@RequestBody SignupRequest request) {
// [1] 메서드 시작할 때 요청 로그 찍기
log.info("[REQUEST] POST /api/members/signup - 파라미터: {}", request);
MemberResponse response = memberService.signup(request);
// [2] 메서드 끝날 때 응답 로그 찍기
log.info("[RESPONSE] POST /api/members/signup - 결과: {}", response);
return ResponseEntity.ok(response);
}
@GetMapping("/{id}")
public ResponseEntity<MemberResponse> getMember(@PathVariable Long id) {
// [1] 여기도 또 찍기...
log.info("[REQUEST] GET /api/members/{} - 파라미터: {}", id, id);
MemberResponse response = memberService.getMember(id);
// [2] 여기도 또 찍기...
log.info("[RESPONSE] GET /api/members/{} - 결과: {}", id, response);
return ResponseEntity.ok(response);
}
}
2. AOP: 관점 지향 프로그래밍 ex) 공통응답/예외처리 핸들러, @Transactional 등
1) 핵심 관심사
- "회원가입을 시킨다", "게시글을 조회한다" 같이 이 메서드가 존재하는 진짜 목적(비즈니스 로직)
- memberService.signup(request); 가 핵심
2) 횡단 관심사
- 로그를 남긴다", "실행 시간을 측정한다", "트랜잭션을 처리한다" 같이 핵심 목적은 아니지만, 애플리케이션 전반에 걸쳐 공통으로 필요한 부가 기능
3) AOP는 이 뒤엉킨 실타래에서 '로깅(공통 기능)'이라는 실만 쏙 뽑아내어 별도의 독립적인 공간으로 분리
3. AOP 핵심 용어
package com.example.instagramclone.aop;
import com.example.instagramclone.aop.util.LogMaskingUtils;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
@Slf4j
@Aspect // 1. "나는 스프링클러(AOP 모듈)입니다!" 선언
@Component // 2. 스프링 컨테이너에 빈으로 등록
public class ApiLoggingAspect {
// 3. Pointcut: "정확히 어느 화분에 물을 줄 건데?"
@Around("execution(* com.example.instagramclone.controller.rest.*.*(..))")
public Object logApi(ProceedingJoinPoint joinPoint) throws Throwable {
// --- [Advice(전처리): 컨트롤러 실행 전] ---
// 4. 어떤 컨트롤러의 어떤 메서드가 호출되었는지 정보 추출
String className = joinPoint.getTarget().getClass().getSimpleName();
String methodName = joinPoint.getSignature().getName();
// 파라미터 정보 추출
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
String[] parameterNames = signature.getParameterNames();
Object[] args = joinPoint.getArgs();
// 파라미터를 [이름=값] 형태의 예쁜 문자열로 변환 (마스킹 유틸 사용)
String paramsString = LogMaskingUtils.buildMaskedParamsString(parameterNames, args);
// 요청(Request) 로그 출력!
log.info("[API 요청] {} / {} | 파라미터: [{}]", className, methodName, paramsString);
long startTime = System.currentTimeMillis();
// 5. 핵심: 원래 타겟(컨트롤러 메서드) 실행!
Object result = joinPoint.proceed();
// --- [Advice(후처리): 컨트롤러 실행 후] ---
long endTime = System.currentTimeMillis();
long executionTime = endTime - startTime;
// 응답(Response) 로그 및 실행 시간 출력!
log.info("[API 응답] {} / {} (수행시간: {}ms)", className, methodName, executionTime);
return result;
}
}
1) Aspect (@Aspect) : 이 클래스는 부가 기능(로깅)을 담당하는 AOP 모듈이야!"라고 알려주는 역할
- @Component : 스프링이 관리
2) Pointcut (execution(...)) : 어느 위치에서 작동할지 타겟을 정해주는 문법 (외우기 X)
execution(* com.example.instagramclone.controller.rest.*.*(..))
- 맨 앞의 별기호 (*): 리턴 타입은 무엇이든 상관없다.
- com.example...rest: 이 패키지 하위에 있는...
- *.*: 모든 클래스의 모든 메서드에 적용하겠다!
- (..): 파라미터가 몇 개든, 무슨 타입이든 상관없다.
- 즉, "우리 프로젝트의 rest 컨트롤러 패키지 안에 있는 모든 API 메서드에 로그를 달아줘!"라는 뜻
3) Advice (@Around): 타이밍 결정
- @Before(메서드 실행 전), @After(실행 후) 등 여러 가지가 있지만, 로깅이나 실행 시간 측정에는 @Around가 국룰
- @Around는 메서드 실행의 처음부터 끝까지(전/후 모두)를 감싸서 통제
4) joinPoint.proceed(); : 핵심 로직 실행
- proceed()를 호출하기 전의 코드는 컨트롤러가 실행되기 전(Request)에 동작하고, proceed()가 끝나고 반환된 이후의 코드는 컨트롤러가 실행된 후(Response)에 동작
- 시작 시간(startTime)과 종료 시간(endTime)을 구해서, 해당 API가 몇 초 만에 실행되었는지(수행 시간 측정)까지 알아낼 수 있는 것
4. Pointcut 문법
1) Pointcut 문법
- * : 딱 1개의 단어(또는 1개의 뎁스)를 의미합니다.
- .. : 0개 이상의 모든 뎁스(하위 경로 전부)나 모든 파라미터를 의미합니다.
2) 컨트롤러 패키지 다른 경우
- @Around("execution(* com.example.domain..controller.*.*(..))") :
domain 패키지 하위의 어딘가에 있는(..) controller라는 이름의 패키지 안의 모든 클래스와 메서드를 잡아라! 라는 뜻
5. 로그 암호화 필요 (LogMaskingUtils 클래스)
1) @Masking 어노테이션 (마스킹 스티커 발급)
package com.example.instagramclone.aop.annotation;
/**
* 로깅 시 민감한 정보(예: 비밀번호)를 마스킹 처리하기 위한 어노테이션입니다.
* DTO 내부의 필드에 선언하여 사용합니다.
*/
@Target(ElementType.FIELD) // 필드(변수)에만 붙일 수 있도록 제한
@Retention(RetentionPolicy.RUNTIME) // 런타임(프로그램 실행 중)까지 이 스티커가 남아있도록 설정
public @interface Masking {
}
- SignupRequest 같은 DTO를 만들 때 private String password; 위에 @Masking만 붙이기
2) LogMaskingUtils 구현
- DTO 객체의 필드들을 리플렉션으로 읽어 @Masking 애노테이션이 있으면 "******"로 덮어씀
private static String applyMaskingToObject(Object obj) {
Class<?> clazz = obj.getClass();
Field[] fields = clazz.getDeclaredFields();
List<String> fieldStrings = new ArrayList<>();
for (Field field : fields) {
field.setAccessible(true); // private 필드에도 접근할 수 있도록 허용!
try {
Object value = field.get(obj);
// 필드에 @Masking 애노테이션이 붙어있다면 값 숨김 처리!
if (field.isAnnotationPresent(Masking.class) && value != null) {
fieldStrings.add(field.getName() + "='******'");
} else {
fieldStrings.add(field.getName() + "=" + value);
}
} catch (IllegalAccessException e) {
fieldStrings.add(field.getName() + "=ERROR");
}
}
return clazz.getSimpleName() + "{" + StringUtils.collectionToCommaDelimitedString(fieldStrings) + "}";
}
3) 자바 리플렉션(Reflection)
- 구체적인 클래스(Class) 타입을 런타임 이전엔 알지 못하더라도, 객체의 이름, 필드, 메서드 등의 정보에 런타임(프로그램 실행 중)에 동적으로 접근하고 조작할 수 있게 해주는 기술
- getDeclaredFields()로 객체가 가진 모든 필드를 가져오고, isAnnotationPresent()로 특정 스티커(@Masking)가 붙어있는지 동적으로 확인하는 것
Session vs JWT
1. 세션의 한계 - 대규모 트래픽
- 메모피 폭발 (서버가 무거움):상태 유지 > 트래픽이 몰리면 램 용량이 견디지 X
- 스케일 아웃: 컴퓨터 변경 시 유저 기억 X
- 모바일 앱 생태계와 불화: 세션 ID는 주로 웹 브라우저의 '쿠키(Cookie)'라는 공간에 담겨서 자동으로 주고받아짐
> 쿠키 기반 세션 방식은 모바일과 호환성 이슈
2. JWT와 무상태성
1) 무상태성
- 유저가 로그인에 성공 > 서버는 유저의 정보(ID, 권한 등)를 담은 '디지털 출입증(JWT)'을 발급
- 쿠키가 아니라 HTTP Header(Authorization)에 담아 보내기 때문에 모바일 앱에서도 아주 쉽게 사용할 수 있음
- 유저: 출입증 제시 / 서버:출입증 위조여부 확인 후 통과
3. 세션 VS JWT
1) 관리자 백오피스 (Admin System)
- 상황: 사내 직원 100명만 사용하는 어드민 페이지입니다.
- 요구사항: 만약 직원이 퇴사하거나, 해커가 관리자 계정을 탈취했다면 '즉시 강제 로그아웃' 시켜버릴 수 있는 강력한 통제권이 필요합니다.
- 선택: Session (Stateful)
- 이유: 세션은 서버가 사물함을 직접 쥐고 있기 때문에, 문제의 계정 사물함을 부숴버리면(세션 만료) 즉시 권한을 뺏을 수 있습니다. 접속자가 적어 메모리 부담도 없습니다
2) 인스타그램 앱 (B2C Service)
- 상황: 수백만 명의 유저가 사용하는 대국민 앱입니다. (지금 우리가 만드는 프로젝트죠!)
- 요구사항: 트래픽이 폭주할 때마다 서버를 100대, 200대씩 자유롭게 늘렸다 줄였다 해야 합니다. 게다가 웹과 앱(iOS/Android) 클라이언트 모두를 지원해야 하죠.
- 선택: JWT (Stateless) 승리!
- 이유: 서버 메모리(RAM) 비용을 극단적으로 아끼고 무한한 스케일 아웃이 가능하며, 다양한 클라이언트 환경에서도 호환성이 뛰어납니다. 단, 치명적인 단점이 있습니다. 한 번 발급된 JWT 출입증은 유효기간이 끝날 때까지 서버가 강제로 뺏거나 무효화시킬 수 없음
JWT 발급
1. JWT
- JSON 형식의 데이터를 문자열로 인코딩한 토큰
- . (점)을 기준으로 세 개의 구역으로 나뉘어
2. 구성
1) Header
{
"alg": "HS256", // 보통 HMAC SHA-256 알고리즘을 많이 씁니다.
"typ": "JWT"
}
- 역할: 이 토큰의 종류(typ)와 도장을 찍을 때 사용한 암호화 알고리즘(alg) 정보가 들어있습니다.
- 비유: 출입증 껍데기에 적힌 "이것은 출입증이며, A 회사의 도장이 찍혀있음" 이라는 안내 문구입니다.
2) Payload
{
"sub": "user123", // 유저 식별자 (Subject)
"role": "ADMIN", // 권한
"exp": 1711234567 // 만료 시간 (Expiration Time)
}
- 역할: 유저의 정보(ID, 권한, 만료 시간 등)가 실제로 담기는 핵심 공간입니다. 여기에 담는 정보의 한 조각을 '클레임(Claim)'이라고 부릅니다.
- 비유: 출입증 안쪽에 적힌 "이름: 홍길동, 직급: 관리자, 유효기간: 2024년 12월 31일까지" 라는 실제 신상 정보입니다.
3) Signature
- 역할: 이 토큰이 서버가 발급한 '진짜'가 맞는지, 중간에 누군가 Payload를 조작하지 않았는지 검증하는 **'위조 방지 도장'**입니다.
- 비유: 출입증 맨 밑에 찍힌 **'서버 관리자의 인감도장'**입니다. 이 도장은 오직 서버만 알고 있는 **'비밀키(Secret Key)'**로만 찍을 수 있습니다.
3. 토큰 -> 암호화 X
1) 토큰 입력
- Payload의 내용이 평문(Plain Text)으로 보임
- Header와 Payload는 단순히 Base64라는 방식으로 인코딩 -> 암호화X
2) 실무 철칙
- Payload에 절대 민감한 정보 X : 식별이 가능한 최소한의 정보(예: 회원 PK, 역할 등)
- Secret Key를 깃허브(Github)에 올리지 마라
3) 그럼에도 사용하는 이유
- 페이로드 값 변경하고 서버에 보내도 도장이 같은지 확인 후 다르면 SignatureException 터짐
- 도장 위조를 위해 서버 금고에 있는 Secret Key 필요
- JWT = 암호화X , 무결성 유지
4. JWT 토큰 발급하기
package com.example.instagramclone.security.jwt;
@Slf4j
@Component // 빈 등록
public class JwtTokenProvider {
private static final String ISSUER = "InstagramCloneAuthServer"; // 표준 Claim: 발급자 (iss)
@Value("${jwt.secret-key}")
private String secretKeyString; // 대칭키(HS256)용 프로퍼티 복구
@Value("${jwt.access-token-validity-time}")
private long accessTokenValidityInMilliseconds;
@Value("${jwt.refresh-token-validity-time}")
private long refreshTokenValidityInMilliseconds;
private SecretKey key;
@PostConstruct
public void init() {
// [아키텍처 의사결정] 모놀리식 아키텍처 환경에서는 대칭키(HS256)를 사용하는 것이 복잡도 대비 가장 합당합니다.
// MSA 환경(ES256/비대칭키)과의 Trade-off를 고려한 선택입니다.
byte[] keyBytes = Decoders.BASE64.decode(secretKeyString);
if (keyBytes.length < 32) {
throw new IllegalArgumentException("JWT secret-key는 최소 32바이트(256bit) 이상이어야 합니다.");
}
this.key = Keys.hmacShaKeyFor(keyBytes);
log.info("HS256 대칭키(Secret Key)가 성공적으로 초기화되었습니다.");
}
/**
* 회원(User)의 정보를 기반으로 Access Token을 생성합니다.
* @param memberId 회원의 고유 식별자 (PK)
* @param role 회원의 권한 (ROLE_USER 등)
* @return 발급된 Access Token 문자열
*/
public String createAccessToken(Long memberId, String role) {
return buildToken(memberId, role, accessTokenValidityInMilliseconds);
}
/**
* 회원(User)의 정보를 기반으로 Refresh Token을 생성합니다.
* (Refresh Token은 권한 정보 없이 PK만 담는 것이 일반적입니다)
* @param memberId 회원의 고유 식별자 (PK)
* @return 발급된 Refresh Token 문자열
*/
public String createRefreshToken(Long memberId) {
return buildToken(memberId, null, refreshTokenValidityInMilliseconds);
}
/**
* 실제 JWT 토큰 생성을 담당하는 내부 헬퍼 메서드
*/
private String buildToken(Long memberId, String role, long validityTimeInMs) {
// 1. 토큰에 담을 정보(Payload - claims) 세팅
Claims claims = Jwts.claims().setSubject(String.valueOf(memberId)); // PK를 Subject로 지정
if (role != null) {
claims.put("role", role); // 부가 정보(Custom Claim) 추가
}
Date now = new Date();
Date validity = new Date(now.getTime() + validityTimeInMs);
// 2. 토큰 생성 (Header, Payload, Signature 조합)
// 모놀리식: 동일한 Secret Key를 사용하여 서명(HS256)
return Jwts.builder()
.setClaims(claims) // 정보 저장
.setIssuer(ISSUER) // 표준 Claim: 토큰 발급자 명시
.setIssuedAt(now) // 표준 Claim: 토큰 발행 시간 (iat)
.setExpiration(validity) // 표준 Claim: 설정된 만료 시간 (exp)
.setId(UUID.randomUUID().toString()) // 표준 Claim: 토큰 ID (jti)
.signWith(key, SignatureAlgorithm.HS256) // HS256과 대칭키(Secret Key)로 서명
.compact();
}
/**
* 토큰에서 유저의 PK(Subject)를 추출합니다.
*/
public Long getMemberId(String token) {
// 검증 시에도 발급할 때 사용했던 동일한 대칭키 및 Issuer 확인
String subject = Jwts.parserBuilder()
.setSigningKey(key)
.requireIssuer(ISSUER) // 내가 발급한 토큰이 맞는지 확인
.build()
.parseClaimsJws(token)
.getBody()
.getSubject();
return Long.parseLong(subject);
}
/**
* 토큰의 유효성 및 만료 기간을 검사합니다.
*/
public boolean validateToken(String token) {
try {
// 역시 검증이므로 동일한 Secret Key 사용 및 Issuer 확인
Jwts.parserBuilder()
.setSigningKey(key)
.requireIssuer(ISSUER) // 내가 발급한 토큰이 맞는지 확인
.build()
.parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException | SignatureException e) {
log.warn("잘못된 JWT 서명입니다. (위조 의심): {}", e.getMessage());
} catch (ExpiredJwtException e) {
log.info("만료된 JWT 토큰입니다. (권한 회수 및 재로그인 요망): {}", e.getMessage());
} catch (UnsupportedJwtException e) {
log.warn("지원되지 않는 JWT 토큰입니다.: {}", e.getMessage());
} catch (IllegalArgumentException e) {
log.warn("JWT 토큰이 잘못되었습니다.: {}", e.getMessage());
}
return false;
}
}
1) HS256 사용한 이유
- SecretKey 하나만 꽁꽁 숨겨두고, 이 열쇠 하나로 도장도 찍고 검사도 하는 대칭키 알고리즘(HS256)을 쓰는 것
2) Access Token과 Refresh Token의 분리
- 토큰 탈취 시 뺏을 방법 X > Access Token(출입증)의 유효기간은 30분 정도로 아주 짧게 만듬
- 수명이 긴(보통 1주~2주) Refresh Token(재발급 교환권)을 함께 발급 -> HttpOnly 쿠키 등 더 안전한 곳에 숨겨둠
Access Token을 재발급받는 용도
3) 꼼꼼한 예외 처리 (Exception Handling)
- validateToken 메서드를 보면 try-catch로 다양한 예외 촘촘하게 잡음
- ExpiredJwtException (만료됨): 이 예외가 터지면 "아! 토큰 기간이 끝났구나. 클라이언트한테 Refresh Token 가져오라고 해야겠다" 라고 후속 처리를 자연스럽게 할 수 있음
- SignatureException (서명 위조): 이 예외가 터지면 "누군가 토큰을 몰래 조작했구나! 해킹 시도다!" 라고 간주하고 즉시 요청을 차단한 뒤, 보안 로그를 강하게 남겨야 함
4) DB 조회를 줄이려고 유저의 모든 정보를 다 넣어도 될까
- Payload에 너무 많은 정보를 넣어서 토큰 길이가 엄청나게 길어진다면 "좋아요" 버튼 하나를 누를 때마다 수 킬로바이트(KB)의 쓸데없는 텍스트 데이터가 네트워크를 타고 오가게 됨
- 결론: Payload에는 오직 식별이 가능한 최소한의 정보(예: 회원 PK, Role)만 넣는 것이 실무의 정석
'🔌 SPARTA > Courses' 카테고리의 다른 글
| 스탠다드반 10회차: Filters & Proxy Objects (0) | 2026.03.09 |
|---|---|
| 스탠다드반 9회차: TDD와 단위 테스트 (0) | 2026.03.05 |
| Spring Security와 SecurityContext (0) | 2026.03.03 |
| JWT와 Filter (0) | 2026.03.03 |
| [숙련 Spring] Spring Data JPA (0) | 2026.02.18 |