애플리케이션의 인증과 인가는 사용자와 시스템 간의 신뢰를 형성하는 중요한 요소이며, 이를 통해 사용자의 신원 확인 및 접근 권한을 관리함으로써 보안성을 높일 수 있습니다.
이번 포스팅에서는 `Session과 Redis`의 개념을 간단히 살펴본 후, `Redis를 세션 스토리지로 사용하는 로그인 구현`과 Spring Security 없이 `Custom Annotation을 통한 인증/인가`를 구현하는 방법을 알아보겠습니다.
Session 이란?
Session이란, 클라이언트와 서버 간의 상태를 유지하기 위한 방법으로, 사용자가 로그인하여 인증된 후 해당 사용자의 정보를 일정 기간 동안 서버가 기억할 수 있도록 합니다.
HTTP는 기본적으로 비상태성`stateless`을 지니고 있어, 각 요청은 서로 독립적입니다. 따라서 사용자가 로그인한 후에도, 서버는 매번 새로운 요청으로 간주하여 사용자를 기억하지 못합니다.
Session은 이러한 비상태성을 보완하기 위해 도입된 개념으로, `특정 사용자를 식별하고 상태를 유지`할 수 있도록 합니다.
Session의 한계점
- 서버 자원 소모
- 모든 사용자의 세션 정보를 서버가 관리하여, 사용자가 많아질수록 메모리와 CPU 리소스가 많이 소비됩니다.
- 스케일링의 어려움
- Session 정보가 서버 메모리에 저장되기 때문에 서버를 확장할 때 같은 세션 데이터를 공유하는 데 어려움이 생깁니다.
- 클라이언트-서버 분리
- REST API를 사용하는 경우, HTTP의 비상태성을 유지하는 것이 권장되는데, 세션은 상태 유지를 요구하여 해당 정보를 관리하는 서버에서는 RESTful 아키텍처와 충돌할 수 있습니다.
Session의 스케일링 문제를 해결하기 위해서 스티키 세션`Sticky Session`또는 세션 클러스터링`Session Clustering` 방식을 사용하게 됩니다.
스티키 세션 (Sticky Session)?
Sticky Session은 클라이언트가 항상 같은 서버에 연결되어 세션을 유지하는 방식입니다.
Sticky Session은 클라이언트가 항상 같은 서버에 연결되도록 하여, 특정 서버에서 세션 상태를 유지하는 방식입니다.
이는 초기 설정이 간단할 수 있지만, 특정 서버가 장애를 일으키거나 다운될 경우 해당 서버에 연결된 클라이언트의 세션 정보는 손실됩니다. 또한, 부하가 특정 서버에 몰리는 문제도 발생할 수 있습니다.
특정 서버에 과부하가 걸리는 경우 로드 밸런서`Load Balancer`는 이를 감지하여 해당 서버로 향하는 트래픽을 다른 서버로 라우팅 하는데, 이럴 경우 기존 세션 데이터가 유실되며 이용하던 사용자는 재로그인을 하는 등 사용자 경험을 저하시킬 수 있습니다.
이를 보완하여 정합성 이슈를 해결하고, 가용성과 트래픽 분산까지 확보할 수 있는 세션 관리방식을 위해 세션 클러스터링 방식을 사용합니다.
세션 클러스터링 (Session Clustering)?
Session Clustering은 여러 서버에서 세션 데이터를 공유하고 관리하는 방식입니다.
위 이미지는 `All-to-all Session Replication`방식을 사용하여, 각 서버가 서로의 세션 데이터를 동기화하는 방식입니다.
사용자가 다른 서버에 요청을 보낼 때도 동일한 세션 데이터를 사용할 수 있어, 세션의 일관성을 유지할 수 있습니다.
하지만 세션 클러스터링을 구현하기 위해 여러 서버 간에 세션 데이터를 동기화해야 하므로 네트워크 트래픽이 증가하고, 동기화 과정에서 서능 저하가 발생할 수 있습니다.
또한, 세션 정보가 각 서버에 저장되므로 서버 장애 시 해당 서버의 세션 데이터를 다른 서버로 빠르게 이전해야 하며, 이 과정에서 세션 복구 시간이 중요하고, 장애 처리를 위한 추가적인 관리가 필요합니다.
이러한 Session의 한계점을 보완하기 위해 Redis를 Session 스토리지로 활용할 수 있습니다.
Redis 란?
Redis는 메모리 기반의 데이터 저장소로, 빠르고 효율적인 데이터 관리가 가능합니다. `key-value`형태로 데이터를 메모리에 저장하여 디스크 기반의 데이터베이스보다 더욱 빠르게 데이터를 읽고 쓸 수 있는 장점이 있습니다.
또한, 분산 시스템으로 설계되어 여러 서버에서 데이터를 효율적으로 관리하고 확장할 수 있습니다.
특징
- 빠른 성능
- 모든 데이터를 메모리에 저장하여, 디스크 I/O가 필요한 다른 데이터베이스보다 빠릅니다. 이 덕분에 세션 데이터를 실시간으로 처리해야 하는 경우 매우 유용합니다.
- 확장성
- 분산 시스템으로 쉽게 확장할 수 있습니다. 여러 서버에 걸쳐 데이터를 분산 저장할 수 있기 때문에, 세션 데이터를 여러 서버에서 공유하고 관리할 수 있습니다.
- 다양한 데이터 구조 지원
- 단순한 key-value 저장 외에도 리스트, 셋, 해시, 정렬된 셋 등 다양한 데이터 구조를 지원합니다. 이로 인해 세션 데이터를 보다 효율적으로 저장하고 처리할 수 있습니다.
- 영속성 옵션
- 기본적으로 메모리 기반이지만, 데이터를 디스크에 비동기적으로 저장하여 영속성도 지원합니다. 이 기능은 세션 정보를 안정적으로 관리하는데 유리합니다.
Redis를 Session 스토리지로 사용하는 이유
저장소가 RDB라면 디스크 I/O 작업이 많아져 서버의 메모리 부하가 커져 성능에 영향을 끼칠 수 있습니다.
그러나, Redis는 빠른 성능과 높은 확장성을 제공하는 메모리 기반의 데이터 저장소입니다. 디스크 I/O 작업을 하지 않아 메모리 낭비를 줄일 수 있습니다.
세션 데이터를 Redis에 저장하면, 여러 서버 간에 데이터를 효율적으로 관리하고 공유할 수 있으며, 서버가 확장될 때 추가적인 세션 정보 관리가 용이해집니다.
Redis는 분산 시스템으로 여러 서버 간의 세션 데이터를 동기화하고 영속성을 제공하여 세션 정보의 유실을 방지할 수 있습니다.
Redis를 Session 스토리지로 사용한 로그인 구현
Session과 Redis의 개념에 대해 알아보았습니다. 다음으로는 Redis를 Session 스토리지로 사용한 로그인 구현 예제에 대해 알아보겠습니다.
데이터베이스 환경 구성은 docker-compose로 진행하며 동작 흐름은 다음과 같습니다.
개발 환경
SpringBoot 3.3.3
Java 21
Docker 27.3.1
Spring Data JPA
MySQL 8.0
Redis 7.0
구현 예제에 활용되는 라이브러리는 다음과 같습니다.
// JPA
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// MySQL
runtimeOnly 'com.mysql:mysql-connector-j'
// Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis' // Redis
implementation 'org.springframework.session:spring-session-data-redis' // Session
// Validation
implementation 'org.springframework.boot:spring-boot-starter-validation'
// Lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
Entity
Member Entity
@Entity
@Getter
@Table(name = "members")
public class Member extends BaseEntity {
@Column(nullable = false, length = 100)
private String email;
@Column(nullable = false, length = 60)
private String password;
}
Base Entity
@Getter
@MappedSuperclass
public class BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}
Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findByEmail(String email);
}
로그인 요청에 유저의 이메일을 찾기 위한 쿼리메서드를 추가해줍니다.
docker-compose.yml (데이터 베이스 환경)
services:
mysql:
image: mysql:8.0 # 생성이미지:버전
container_name: mysql # 생성 할 컨테이너 이름
restart: always # 수동종료 전까지 항상 켜지도록 유지 (sleep 방지)
ports:
- "23306:3306" # 포트번호 host:docker 로컬 포트에 도커 포트를 마운트
volumes:
- ./db/mysql/data:/var/lib/mysql # 로컬저장경로:도커저장경로 / 컨테이너 종료 후에도 데이터를 로컬에 저장하여 유지 (로컬 경로변경 가능)
- ./db/mysql/init:/docker-entrypoint-initdb.d # 로컬저장경로:도커저장경로 / 해당 경로에 작성된 DDL을 컨테이너 생성 시 자동실행 (.sql .sh 파일 실행) (로컬 경로변경 가능)
environment: #===== 환경변수 =====#
MYSQL_ROOT_PASSWORD: 1234 # root 계정 비밀번호 설정
MYSQL_DATABASE: todo # 데이터베이스 생성 이름
MYSQL_CHARSET: utf8mb4 # 인코딩 문자
MYSQL_COLLATION: utf8mb4_unicode_ci # 대조 문자
TZ: Asia/Seoul # 타임존 설정
redis:
image: redis:7.0
container_name: redis
restart: always
ports:
- "6379:6379" # Redis 기본 포트 [로컬:도커]
volumes:
- ./db/redis/data:/data # Redis 데이터 저장 경로 [로컬:도커]
environment:
TZ: Asia/Seoul
docker-compose 설정 후 해당 경로로 이동하여 아래 명령어로 실행할 수 있습니다.
docker-compose up -d
-d : 백그라운드 실행
Redis 환경 설정
먼저, Session을 인메모리가 아닌 외부 저장소에 저장하기 위해 관련 의존성을 추가합니다.
implementation 'org.springframework.boot:spring-boot-starter-data-redis' // Redis
implementation 'org.springframework.session:spring-session-data-redis' // Session
application.yml
server:
port: 80 # 서버 포트 지정
servlet:
session:
timeout: 1800 # 세션 유효시간 설정(초)
spring:
data:
redis:
host: localhost # Redis 호스트
port: 6379 # Redis 포트(기본)
MemberSession
@Getter
@NoArgsConstructor
public class MemberSession {
private Long memberId;
public MemberSession(Long memberId) {
this.memberId = memberId;
}
}
Redis에 `key-value`형태로 저장될 때 `value`에 저장될 값을 Dto로 생성하였습니다.
RedisConfig
@Configuration
@EnableRedisHttpSession
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String host;
@Value("${spring.data.redis.port}")
private int port;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(
new RedisStandaloneConfiguration(host, port)
);
}
@Bean
public RedisTemplate<String, MemberSession> redisTemplate() {
RedisTemplate<String, MemberSession> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory());
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new Jackson2JsonRedisSerializer<>(MemberSession.class));
return template;
}
}
위에서 작성한 application.yml의 값을 `@Value`를 통해 앞서 설정한 경로를 주입해줍니다.
`@Configuration`을 통해 스프링 Bean에 등록합니다.
`@EnbleRedisHttpSession`을 통해 Redis를 세션저장소로 사용할 수 있도록 합니다.
// maxInactiveIntervalInSeconds를 사용해 세션 유지시간을 설정할 수 있습니다
// default 값은 1800 이며 `초` 단위로 설정합니다
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 3600)
Spring에서 RedisConnectionFactory로 사용할 수 있는 주요 클라이언트 라이브러리는 대표적으로 Lettuce, Jedis가 존재합니다.
필자의 경우 Jedis가 아닌 Lettuce를 선택하였는데 그 이유는 다음과 같습니다.
`Jedis`의 경우, 멀티 쓰레드 환경에서 하나의 Jedis 인스턴스를 공유하고 싶을 때, 쓰레드 안정성을 보장하지 않습니다.
`Jedis`는 멀티 쓰레드 환경에서 pooling 연결 방식입니다.
`Jedis`를 사용하는 각 동시성을 지닌 스레드는 Jedis가 상호 작용하는 동안 자체 Jedis 인스턴스를 가져옵니다. 이는 Redis 연결이 증가하여 Jedis 인스턴스가 늘어날 때마다 물리적인 연결 비용이 발생합니다.
`Lettuce`는 netty 기반으로 멀티 쓰레드 환경에서 상태를 가지고 공유될 수 있습니다.
따라서, 멀티 쓰레드 애플리케이션이 Lettuce와 상호 작용하는 동시성을 가진 쓰레드 개수와 상관없이 하나의 연결만을 사용하면 됩니다.
`redisTemplate` 메서드 에서는 RedisTemplate<String, MemberSession>을 생성하는 설정을 정의하며, MemberSession 객체를 Redis에 저장할 수 있게 합니다.
`template.setConnectionFactory(redisConnectionFactory());`
RedisTemplate은 Redis 서버와의 연결을 위해 ConnectionFactory가 필요합니다.
여기서 redisConnectionFactory 메서드에 반환되는 RedisConnectionFactory 인스턴스를 설정하여, Redis 서버와의 실제 연결을 담당합니다.
`template.setKeySerializer(new StringRedisSerializer());`
Redis는 데이터를 저장할 때 바이트 배열로 직렬화하는데, setKeySerializer는 키에 대해 사용할 직렬화 방식을 설정하는 부분입니다.
StringRedisSerializer는 문자열을 UTF-8 바이트 배열로 직렬화 하여, 키가 Redis에 문자열 형태로 저장되도록 합니다.
`template.setValueSerializer(new Jackson2JsonRedisSerializer<>(MemberSession.class));`
setValueSerializer는 값에 대해 사용할 직렬화 방식을 설정합니다.
여기서 Jackson2JsonRedisSerializer를 사용하여 MemberSession 객체를 JSON 형식으로 직렬화 합니다.
이 설정을 통해 MemberSession 객체를 JSON 형태로 Redis에 저장하고, 필요할 때 다시 객체 형태로 역직렬화하여 불러올 수 있습니다.
여기서 Jackson2JsonRedisSerializer가 아닌 GenericJackson2JsonRedisSerializer()를 사용 할 경우 `치명적인 문제`가 발생할 수 있습니다.
`GenericJackson2JsonRedisSerializer()`사용 시 Redis에 저장되는 값은 다음과 같습니다.
// GenericJackson2JsonRedisSerializer
{
"@class": "com.app.todolist.config.redis.dto.MemberSession",
"memberId": 1
}
Member 객체에서 지정한 Value값 외에 `"@class" : "객체 경로"`가 저장되는 것을 확인할 수 있습니다.
Value값 저장 시 class 경로가 함께 저장되면 만약 `해당 객체의 디렉토리 구조가 변경 될 경우 경로의 값이 달라져 모든 사용자의 세션이 무효화` 될 수 있습니다.
이러한 문제를 해결하기 위해 Jackson2JsonRedisSerializer<>(MemberSession.class)로 클래스를 지정하여 저장합니다.
// Jackson2JsonRedisSerializer
{
"memberId": 4
}
Controller
@Getter
public class MemberLoginRequest {
@NotBlank(message = "이메일을 입력하세요.")
private String email;
@NotBlank(message = "비밀번호를 입력하세요.")
private String password;
public MemberLoginInfo toLogin() {
return new MemberLoginInfo(email, password);
}
}
Controller 레이어에서 로그인에 대한 Request 값을 받아 사용 할 Dto 객체를 생성하였습니다.
해당 Request Dto에서 toLogin을 통해 Service 레이어의 Dto 객체로 데이터를 전달합니다.
@RestController
@RequestMapping("/api/members")
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
@PostMapping("/login")
public void login(@RequestBody @Valid MemberLoginRequest memberLoginRequest,
HttpServletResponse httpServletResponse) {
Cookie cookie = memberService.login(memberLoginRequest.toLogin());
httpServletResponse.addCookie(cookie);
}
}
로그인에 대한 Request 요청 받은 데이터를 Service 레이어의 Dto 객체로 전달하여 로직을 처리합니다.
Service에서 처리 되어 return된 데이터를 쿠키에 추가하여 응답합니다.
Service
@Getter
public class MemberLoginInfo {
private final String email;
private final String password;
public MemberLoginInfo(String email, String password) {
this.email = email;
this.password = password;
}
}
Service 레이어에서 로그인 관련 정보를 담기 위해 사용할 Dto 객체를 생성하였습니다.
public Cookie login(MemberLoginInfo memberLoginInfo) {
Member member = memberRepository.findByEmail(memberLoginInfo.getEmail()).orElseThrow(()
-> new TodoApplicationException(ErrorCode.AUTHENTICATION_FAILED));
if (!BCrypt.checkpw(memberLoginInfo.getPassword(), member.getPassword())) {
throw new TodoApplicationException(ErrorCode.AUTHENTICATION_FAILED);
}
String sessionId = new StandardSessionIdGenerator().generateSessionId();
String sessionKey = "TODO_SESSION:" + sessionId;
redisTemplate.opsForValue().set(sessionKey, new MemberSession(member.getId()), 30L, TimeUnit.MINUTES);
Cookie cookie = new Cookie("SESSION", sessionId);
cookie.setMaxAge(1800);
cookie.setPath("/");
cookie.setHttpOnly(true);
return cookie;
}
Controller 객체로 부터 전달 받은 데이터에서 로그인 요청에 대한 유저의 유효성 검증을 진행 후, 성공 시 SessionId를 생성해 Redis에 저장하고 SessionId를 담은 쿠키를 반환합니다.
필자의 경우 유효성 검증에 대한 예외처리는 커스텀 적용하였습니다.
`String sessionId = new StandardSessionIdGenerator().generateSessionId();`
StandardSessionIdGenerator는 고유한 세션 Id를 생성하기 위한 클래스 입니다.
"TODO_SESSION"이라는 prefix와 생성된 sessionId를 결합하여 생성되며, 이는 Redis의 key로 사용됩니다.
`ex) TODO_SESSION:6BBF250C0BBED73863A9AC99B8A15916`
redisTemplate.opsForValue().set 메서드를 통해 Redis에 sessionKey와 MemberSession 객체를 저장하고, 세션 만료시간을 30분으로 설정합니다.
SessionId를 담은 쿠키를 생성하여 반환합니다.
쿠키의 이름은 "SESSION", 값은 생성한 sessionId 입니다.
`setMaxAge` : 쿠키의 유효시간을 설정합니다. (초)
`setHttpOnly(true)` : 클라이언트의 JavaScript에서 접근하지 못하도록 설정합니다.
`setPath("/")` : Http 쿠키의 path 속성을 설정합니다. 쿠키의 path 속성은 쿠키가 전송될 수 있는 URL 경로를 제한하는 역할을 합니다.
그 밖에 `setSecure(true)` 보안연결(Https)을 통해서만 쿠키 전송 허용, `setDomain("exemple.com")` 특정 도메인에서만 쿠키가 전송되도록 설정이 가능합니다.
Security 없이 CustomAnnotation을 활용한 인증, 인가 구현
Redis를 Session 스토리지로 사용한 로그인 구현에 대해 더욱 자세히 알아보았습니다.
다음으로는 Security 없이 CustomAnnotation을 활용한 인증, 인가에 대해 예제를 통해 알아보도록 하겠습니다.
CheckAuth (커스텀 어노테이션)
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface CheckAuth {
}
해당 인터페이스를 통해 커스텀 어노테이션을 생성합니다.
`@Retention`은 어노테이션이 유지되는 기간을 지정합니다.
`RUNTIME` : 어노테이션이 런타임 동안에도 존재하게 됩니다. 즉, 프로그램이 실행되는 동안 어노테이션을 참조하거나 사용할 수 있게 됩니다.
그 밖에 `SOURCE`는 컴파일 전에 제거, `CLASS`는 컴파일 후 바이트코드에 포함되지만 런타임에 사용되지 않습니다.
`@Target`은 어노테이션을 어떤 요소에 적용할지를 지정합니다.
`ElementType.METHOD`는 어노테이션이 메서드에만 적용될 수 있음을 의미합니다. 따라서 해당 커스텀 어노테이션은 클래스의 필드, 생성자, 매개변수 등 다른 요소에는 적용될 수 없습니다.
현재 필자가 구현한 예제코드의 경우 권한에 대한 정보가 없지만, 해당 인증, 인가에 대한 커스텀 어노테이션을 생성 할 때 권한에 대한 정보도 함께 추가할 수 있습니다.
// ex
// 권한 enum
public enum Role {
USER, // 일반 사용자 권한
ADMIN // 관리자 권한
}
// CustomAnnotation
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface CheckAuth {
Role[] roles() default {}; // 여러 개의 권한을 설정할 수 있음
Role role(); // 단일 권한 설정 시
}
// 사용 예시
@CheckAuth(roles = {Role.ADMIN, Role.USER})
public String exemple() {
// 인증과 인가가 확인된 후 실행될 코드
return "exemple";
}
AuthInterceptor (인터셉터)
@RequiredArgsConstructor
public class AuthInterceptor implements HandlerInterceptor {
private final RedisTemplate<String, MemberSession> redisTemplate;
private static final String SESSION_KEY = "TODO_SESSION:";
private static final String SESSION_COOKIE_NAME = "SESSION";
@Override
public boolean preHandle(HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse,
Object handler) {
if (handler instanceof HandlerMethod handlerMethod) {
CheckAuth checkAuth = handlerMethod.getMethodAnnotation(CheckAuth.class);
if (checkAuth != null) {
String sessionId = getSessionIdFromCookies(httpServletRequest);
if (sessionId == null || redisTemplate.opsForValue().get(SESSION_KEY + sessionId) == null) {
throw new TodoApplicationException(ErrorCode.LOGIN_FORBIDDEN);
}
}
}
return true;
}
private String getSessionIdFromCookies(HttpServletRequest httpServletRequest) {
if (httpServletRequest.getCookies() != null) {
return Arrays.stream(httpServletRequest.getCookies())
.filter(cookie -> SESSION_COOKIE_NAME.equals(cookie.getName()))
.map(Cookie::getValue)
.findFirst()
.orElse(null);
}
return null;
}
}
해당 클래스는 인증 및 세션 관리를 처리합니다.
특정 Http 요청이 들어올 때마다 인증 정보를 확인하는 역할을 하며, @CheckAuth 커스텀 어노테이션이 적용된 메서드가 호출될 때 세션이 유효한지 검사하고, 유효하지 않으면 인증되지 않은 요청으로 처리합니다.
`preHandle`메서드는 요청이 컴트롤러로 전달되기 전에 호출됩니다. Interceptor의 preHandle 메서드를 오버라이드하여, 요청을 처리하기 전에 인증을 체크하는 로직을 추가하였습니다.
`CheckAuth checkAuth = handlerMethod.getMethodAnnotation(CheckAuth.class)`
handler가 HandlerMethod 인스턴스인지 확인 후 Spring MVC의 핸들러 메서드에서 특정 커스텀 어노테이션을 읽어오는 작업을 수행합니다.
이후 @CheckAuth 커스텀어노테이션이 적용되어 있다면 유효성 인증을 수행합니다.
`getSessionIdFromCookies`메서드를 통해 요청에서 전달된 쿠키를 순회하여 SESSION_COOKIE_NAME("SESSION")이름의 쿠키에서 세션 Id 값을 추출합니다.
`httpServletRequest.getCookies()`로 요청에 포함된 모든 쿠키를 가져오고, 이를 스트림 처리하여 원하는 쿠키를 필터링하여 값을 반환합니다. 쿠키가 없다면 `null`을 반환합니다.
LoginMember (커스텀 어노테이션)
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
public @interface LoginMember {
}
해당 커스텀 어노테이션은 메서드의 파라미터에 적용되는 커스텀 어노테이션입니다.
이 어노테이션을 사용하여, 메서드의 파라미터에 특정 정보를 자동으로 주입하는 기능을 구현하고자 추가하였습니다.
LoginMemberArgumentResolver
@Component
@RequiredArgsConstructor
public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {
private final HttpServletRequest httpServletRequest;
private final RedisTemplate<String, MemberSession> redisTemplate;
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(LoginMember.class);
}
@Override
public MemberSession resolveArgument(MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory) {
String sessionId = getSessionIdFromCookies(httpServletRequest);
if (sessionId != null) {
return redisTemplate.opsForValue().get("TODO_SESSION:" + sessionId);
}
return null;
}
private String getSessionIdFromCookies(HttpServletRequest httpServletRequest) {
if (httpServletRequest.getCookies() != null) {
return Arrays.stream(httpServletRequest.getCookies())
.filter(cookie -> "SESSION".equals(cookie.getName()))
.map(Cookie::getValue)
.findFirst()
.orElse(null);
}
return null;
}
}
이 클래스는 @LoginMember 커스텀 어노테이션을 사용한 메서드 파라미터에 로그인된 사용자의 세션 정보를 주입하는 기능을 합니다.
`supportsParameter`
이 메서드는 HandlerMethodArgumentResolver 인터페이스에서 요구하는 메서드로, 특정 파라미터가 이 리졸버로 처리될 수 있는지를 확인합니다.
`parameter.hasParameterAnnotation(LoginMember.class)`는 해당 파라미터에 @LoginMember 어노테이션이 있는지 체크합니다.
`resolveArgument`
이 메서드는 supportsParameter가 true를 반환한 경우 호출됩니다. 즉, @LoginMember 커스텀 어노테이션이 붙은 파라미터가 있을 때 실행됩니다.
CheckAuth와 비슷한 로직이지만 각 사용하는 목적은 다르며 CheckAuth는 인증, 인가 LoginMember는 로그인정보 주입을 목적으로 사용합니다.
커스텀 어노테이션을 활용한 인증, 인가 및 로그인정보 주입 사용 예시
Security 없이 인증, 인가를 위해 커스텀 어노테이션을 구현하는 것에 대해 알아보았습니다.
구현 한 커스텀 어노테이션을 메서드에 적용하여 처리 결과 여부를 알아보도록 하겠습니다.
로그인하지 않은 사용자의 요청 (인증)
@CheckAuth
@PostMapping
public TodoResponse createTodo(@RequestBody @Valid TodoRequest todoRequest,
@LoginMember MemberSession memberSession) {
Todo todo = todoService.createTodo(
todoRequest.toCreate(), memberSession.getMemberId());
return TodoResponse.of(todo);
}
구현한 어노테이션을 Todo 생성 Controller에 추가한 뒤 Postman을 통해 요청 시
인증이 이루어지지 않아 인터셉터에서 기존에 설정한 예외처리 응답이 되는걸 확인할 수 있습니다.
// Interceptor 예외처리 부분
if (sessionId == null || redisTemplate.opsForValue().get(SESSION_KEY + sessionId) == null) {
throw new TodoApplicationException(ErrorCode.LOGIN_FORBIDDEN);
}
// 커스텀 예외처리 응답 값
LOGIN_FORBIDDEN(HttpStatus.FORBIDDEN, "로그인 후 이용해주세요.")
로그인 후 생성 요청 (로그인 정보 주입)
로그인 요청 후 200 OK와 함께 쿠키가 정상적으로 생성 된 모습을 볼 수 있습니다.
로그인 후 요청 시 @CheckAuth의 인증, 인가가 통과되고 @LoginMember를 통해 로그인 된 사용자의 정보가 주입되어 작성자 또한 함께 응답되는 모습을 볼 수 있습니다.
다른 사용자의 정보 조회 시 (인가)
본인이 아닌 다른 사용자의 Todo 조회 시 설정한 예외처리가 되는 것을 확인할 수 있습니다.
인가에 대한 처리 로직은 다음과 같습니다.
@CheckAuth
@GetMapping("/{todoId}")
public TodoResponse todoDetails(@PathVariable Long todoId,
@LoginMember MemberSession memberSession) {
Todo todo = todoService.getTodoDetails(todoId, memberSession.getMemberId());
return TodoResponse.of(todo);
}
상세 조회 Controller 메서드에 @CheckAuth, @LoginMember 커스텀 어노테이션을 생성하여 인증, 인가, 로그인사용자 정보를 주입합니다.
// 해당 Service
public Todo getTodoDetails(Long todoId, Long memberId) {
Todo todo = findTodoById(todoId);
validateTodoOwnership(todo, memberId);
return todo;
}
// Todo 찾기
private Todo findTodoById(Long id) {
return todoRepository.findById(id).orElseThrow(()
-> new TodoApplicationException(ErrorCode.TODO_NOT_FOUND)
);
}
// 인가 유효성 검증
private void validateTodoOwnership(Todo todo, Long memberId) {
if (!todo.getMember().getId().equals(memberId)) {
throw new TodoApplicationException(ErrorCode.PERMISSION_DENIED);
}
}
해당 Service 로직에서 인가 유효성 검증을 진행합니다.
먼저 데이터베이스에 해당 todo의 정보가 있는지 조회 후 해당 todo의 작성자와 로그인 된 사용자를 검증합니다.
// 커스텀 예외처리 응답 값
PERMISSION_DENIED(HttpStatus.FORBIDDEN, "이 작업을 수행할 권한이 없습니다.")
해당 검증을 통해 다른 사용자의 todo는 조회할 수 없고, 본인이 생성한 todo에 한해서 조회가 가능해집니다.
정리
Session은 서버가 사용자의 로그인 상태를 일정 기간 기억하도록 하는 방식으로, Http의 비상태성을 보완해 사용자를 식별하고 상태를 유지합니다. 그러나 서버 자원 소모와 스케일링의 한계가 있습니다.
이번 포스팅에서는 이를 해결하기 위해 Redis를 Session 스토리지로 사용하여 구현하는 방법과 Security 없이 커스텀 어노테이션을 사용하여 인증, 인가를 다루고 로그인 된 사용자의 정보를 주입하는 방법에 대해서도 알아보았습니다.
'Spring' 카테고리의 다른 글
[SpringBoot] 다양한 동시성 제어 방법 (2) | 2024.12.20 |
---|---|
[SpringBoot] Prometheus, Grafana를 이용한 모니터링 (0) | 2024.12.10 |
[Spring] offset, no offset 차이점과 페이지네이션 구현예제 (6) | 2024.10.23 |
[Spring] RequestBody, Response의 값이 null 일 때, JSON의 직렬화 역직렬화 (0) | 2024.10.10 |
[Spring]@DataJpaTest, @SpringBootTest 차이 (2) | 2024.08.28 |