🔌 SPARTA/Courses

스탠다드반 8회차 : 로깅(AOP)와 인증/인가의 시작(JWT)

eunjiom 2026. 3. 4. 16:51

로깅 기초와 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);
    }
}
  1.  

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