들어가며

프로젝트에서 사용자 인증 시스템을 구현하면서 여러 인증 방식을 비교하고, JWT 기반 인증을 선택한 경험을 공유합니다. 특히 초기 구현에서 JWT의 본질을 제대로 이해하지 못해 겪었던 시행착오와, 이를 개선해나간 과정을 다룹니다.


1. 인증 방식 비교 및 기술 선택

여러가지 인증 방법의 장단점을 비교합니다. 


1.1 쿠키 기반 인증

쿠키란 서버가 사용자의 웹 브라우저에 전송하는 작은 데이터 조각이다. 브라우저는 그 데이터 조각들을 저장해 놓았다가, 동일한 서버에 재 요청 시 저장된 데이터를 함께 전송한다. 이를 이용하면 사용자의 로그인 상태를 유지할 수 있다.

상태가 없는 HTTP 프로토콜에서 상태 정보를 기억시켜주기 때문에 과거엔 클라이언트 측에 정보를 저장할 때 쿠키를 주로 사용하곤 했다. 

 

요청 예시 

GET /api/data HTTP/1.1
Host: api.example.com
Cookie: sessionId=abc123; userId=456; token=xyz789
Accept: application/json

 

쿠키를 사용하는 게 데이터를 클라이언트 측에 저장할 수 있는 유일한 방법이었을 때는 이 방법이 타당했으나 위의 예시를 보면 알 수 있듯 모든 요청마다 쿠키가 함께 전송되기 때문에,  성능이 떨어지는 원인이 될 수 있다. 

뿐만아니라 쿠키가 클라이언트에 저장되고 자동으로 전송되는 구조 자체를 바꾸지는 못하므로 탈취가 쉬워 보안 측면에서 한계를 가진다. 이러한 이유로 본 프로젝트에서는 쿠키 기반 인증을 주요 인증 방식으로 사용하기에는 한계가 있다고 판단했다.

 

출처: https://developer.mozilla.org/ko/docs/Web/HTTP/Guides/Cookies


1.2 세션 기반 인증

세션은 하나의 고정된 정의가 있다 라고 하기보다는 “클라이언트와 서버 사이에서 유지되는 상태를 설명하는 개념” 에 가깝다. 

각 사용자마다 서버에 생성되는 상태 저장 공간을 세션이라고 한다. 그리고 수만 개의 세션 중 어느 것이 요청을 보낸 사용자의 세션인지를 찾기 위해 쿠키를 사용한다.

그런데 실제 서비스에서는 트래픽을 감당하기 위해 서버를 여러 대로 늘리는 스케일 아웃을 많이 사용하는데 

이때 세션 기반 인증 시스템은 치명적인 구조적 한계에 부딪힌다. 세션이 특정 서버의 메모리에 갇혀있기 때문에 발생 만약서버 A 가 고장 나서 죽으면 그 안에 있던 모든 세션 정보도 함께 증발하고 해당 사용자들은 강제로 로그아웃 된다.

그래서 이를 해결하기 위해 서버 메모리를 쓰지 않고 빠르고 공용화된 외부 저장소를 하나 둔다. Redis같은 초고속 인메모리 DB 를 별도로 설치하여 여기에 세션을 저장한다. 웹 서버를 100대로 늘려도 세션 공유 설정이 필요 없고 Redis 주소만 알려주면 된다. 

서버가 상태를 가지게 된다는 단점과 소규모 프로젝트에서는 구조적인 복잡성이 증가한다는 점을 고려해야 했다. 

 

출처: https://medium.com/@heizence6626/%EC%9D%B8%EC%A6%9D-%EC%9D%B8%EA%B0%80-%EA%B8%B0%EB%B3%B8-%EA%B0%9C%EB%85%90-%EC%84%B8%EC%85%98%EA%B3%BC-%EC%BF%A0%ED%82%A4-b0cec237618f


1.3 JWT(Json Web Token)

jwt는 온라인 네트워크에서 정보를 안전하게 통신할 때 사용하는 인터넷 표준 토큰이다. 필요한 정보를 모두 자체적으로 들고 있어서 서버 측에서 세션을 저장할 필요가 없다. 이런 특징 덕분에 JWT를 이용하면 서버 부하를 줄일 수 있고 확장성을 확보할 수 있다. 

그러나 신뢰할 수 있는 서비스를 사용하지 않으면 잘못된 구현이 취약점을 초래할 수 있습다. 다시말해 올바르게 구현된 경우 JWT는 강력한 보안 기능을 제공하지만 잘못된 설계는 오히려 세션 방식과 다를 바 없는 구조가 되거나 잘못된 키 관리, 또는 토큰 검증 오류등의 이유로 심각한 취약점을 유발할 수 있다. 

 

출처:

https://docs.tosspayments.com/resources/glossary/jwt

https://blog.logto.io/ko/jwtleul-eonje-sayonghaeya-hanayo


2. JWT를 선택한 이유

팀 프로젝트에서 Spring Security 기반의 REST API 서버를 만들어야 했고, 인증 구조를 직접 이해하고 설계해 보는 것을 학습 목표로 두고 있었다.

JWT를 사용하지 않고 Spring Security로 로그인을 구현했을 때, 로그인 이후 API 요청마다 필터 체인을 통과해야하고 UserDetailsService를 통해 SecurityContext에서 사용자 정보를 다시 조회하는 구조를 경험했다. 이 과정에서 자연스럽게 다음과 같은 의문이 들었다. "이미 로그인 시 인증이 완료되었는데, 왜 매번 요청마다 DB를 다시 조회해야할까?" 이 질문을 계기로 JWT의 핵심 가치가 단순한 토큰 발급이 아니라, 토큰 자체를 신뢰하는 구조라는 점을 이해하게 되었고, JWT를 도입하기로 결정했다.


3. 트러블 슈팅 - jwt의 본질을 이해하지 못한 초기 구현 

3.1 문제 상황

JWT를 처음 도입했을 당시에는 JWT를 ‘인증된 결과물’로 완전히 이해하지 못한 상태였다. 로그인 시 토큰을 발급했지만, API 요청이 들어올 때마다 토큰에서 userId를 추출한 뒤 다시 DB에서 사용자 엔티티를 조회하고 있었다.

결과적으로 JWT를 사용하면서도 API 요청마다 DB 조회가 발생했고, 구조적으로는 세션 방식과 큰 차이가 없는 상태가 되었다.

 

그 당시의 잘못된 구현 코드 

public Authentication getAuthentication(String token) {
       Claims claims = Jwts.parserBuilder()
          .setSigningKey(key)
          .build()
          .parseClaimsJws(token)
          .getBody();

       Collection<? extends GrantedAuthority> authorities =
          Arrays.stream(claims.get("auth").toString().split(","))
             .map(SimpleGrantedAuthority::new)
             .collect(Collectors.toList());

       // jwt로 부터 사용자 정보를 받아온 후 다시 DB를 조회하는 코드를 작성했다. 
       // UsernamePasswordAuthenticationToken의 인증 객체로 전달하기 위해 User를 필요로 했기 때문이다.
      
       User user = userRepository.findByEmail(claims.getSubject())
          .orElseThrow(() -> new JwtException("존재하지 않는 사용자"));

       CustomUserDetails principal = new CustomUserDetails(user);

       return new UsernamePasswordAuthenticationToken(principal, token, authorities);
    }

 


3.2 원인 분석

문제의 원인은 JWT를 단순한 식별자처럼 사용하고 있었다는 점이었다. JWT의 핵심은 서버가 서명한 토큰은 위변조 여부만 검증되면 신뢰할 수 있다는 점인데, 이것을 간과하고 불필요하게 DB에서 다시 사용자 정보를 조회했다. 결과적으로 jwt의 stateless 장점을 전혀 살리지 못했다. 인증 단계와 인가 그리고 비즈니스 로직을 명확히 구분하지 못한 상태였다.


3.3 해결 방법

구조를 다음과 같이 수정했다.

  • 로그인 시에만 DB를 통해 사용자 인증 수행
  • 인증 성공 시 필요한 최소 정보(userId, role 등)를 포함한 JWT 발급
  • API 요청 시에는 토큰의 유효성만 검증
  • 토큰 정보로 Authentication 객체를 생성하여 SecurityContext에 저장

신뢰할 수 있는 토큰임을 증명하기 위해 유효성을 세밀하게 검증한다 

public boolean validateToken(String token) {
		try {
			Jwts.parserBuilder()
				.setSigningKey(key)
				.build()
				.parseClaimsJws(token);
			return true;
		} catch (SignatureException e) {
			log.error("JWT 서명이 유효하지 않습니다.");
			throw new CustomJwtException(ErrorCode.TOKEN_INVALID);

		} catch (MalformedJwtException e) {
			log.error("잘못된 형식의 토큰입니다.");
			throw new CustomJwtException(ErrorCode.TOKEN_INVALID);

		} catch (ExpiredJwtException e) {
			log.error("JWT 토큰이 만료되었습니다.");
			throw new CustomJwtException(ErrorCode.TOKEN_EXPIRED);

		} catch (UnsupportedJwtException e) {
			log.error("지원되지 않는 JWT 유형입니다.");
			throw new CustomJwtException(ErrorCode.TOKEN_INVALID);

		} catch (IllegalArgumentException e) {
			log.error("토큰이 비어있습니다.");
			throw new CustomJwtException(ErrorCode.TOKEN_EMPTY);

		} catch (Exception e) {
			log.error("유효하지 않은 토큰입니다");
			throw new CustomJwtException(ErrorCode.TOKEN_INVALID);
		}

	}

 

그리고 이후 API 요청 과정에서는 인증을 위해 DB를 조회하지 않도록 개선했다.

public Authentication getAuthentication(String token) {
		Claims claims = Jwts.parserBuilder()
			.setSigningKey(key)
			.build()
			.parseClaimsJws(token)
			.getBody();

		Collection<? extends GrantedAuthority> authorities =
			Arrays.stream(claims.get("auth").toString().split(","))
				.map(SimpleGrantedAuthority::new)
				.collect(Collectors.toList());

		Long userId = Long.parseLong(claims.get("userId").toString());
		UserRole userRole = UserRole.valueOf(claims.get("auth").toString().replace("ROLE_", ""));
		CustomUserDetails userDetails = new CustomUserDetails(userId, userRole);

		return new UsernamePasswordAuthenticationToken(userDetails, token, authorities);
	}

 

위의 인증 형태를 구성하기 위해 JWT 발급할때 최소 정보(userId, role 등)만을 포함한다. 

public String createToken(CustomUserDetails user) {
    String authority = user.getRole().getAuthority();

    long now = (new Date()).getTime();
    Date validity = new Date(now + this.tokenValidityInMilliseconds);

    return Jwts.builder()
       .setSubject(user.getUsername())
       .claim("auth", authority)
       .claim("userId", user.getUserId())
       .signWith(key, SignatureAlgorithm.HS512)
       .setExpiration(validity)
       .compact();
}

4. 배운점과 회고 - 개념 이해의 중요성

JWT를 단순히 사용하는 것과, 제대로 이해하고 사용하는 것은 전혀 다르다는 점을 체감했다. 어떤 문제를 해결하기 위해 존재하는지를 이해하는 것이 중요하다는 것을 배웠다. JWT를 도입했지만 그 본질을 이해하지 못해 DB 조회를 계속하는 실수를 했다. "이미 인증된 토큰"이라는 JWT의 핵심 개념을 제대로 이해한 후에야, Stateless 인증의 진가를 발휘할 수 있었다. 인증과 인가의 역할을 구분하고, 각 단계에서 무엇을 신뢰해야 하는지 고민한 경험은 이후 다른 기술을 학습하는 데에도 중요한 기준점이 될 것이라 생각한다.


5. TODO

현재 프로젝트에서는 인증 구조가 비교적 단순하여 Facade 패턴을 적용하지 않았다. 추후 OAuth 연동이나 인증 방식이 확장된다면, 인증 관련 로직의 재사용성과 구조 개선을 위해 Facade 패턴 도입을 검토할 계획이다.

+ Recent posts