거의 쓰이지 않게 된 form 인증 방식
지금처럼 웹 프레임워크 혹은 라이브러리가 없던 시절에는 서버 프로젝트에서 렌더링 엔진을 통해 웹 페이지를 만드는 게 일반적이었다. JSP가 오랫동안 사용됐고 지금 스프링에서는 지금처럼 프론트엔드 프레임워크 혹은 라이브러리가 없던 시절에는 서버 프로젝트에서 렌더링 엔진을 통해 웹 페이지를 만드는 게 일반적이었다.
스프링 프레임워크에서 JSP를 오랫동안 지원하다가 현재는 공식 지원이 끊겨 있다. 타임리프가 JSP와 많이 비슷하고 스프링 프레임워크의 공식 지원받고 있어서 사용처가 많이 보인다. JSP나 타임리프처럼 백엔드에 뷰를 포함할 때의 로그인은 form 방식이 주로 사용됐다. <form> 태그를 통해서 요청을 하면 스프링에서는 UsernamePasswordAuthenticationFilter를 통해 인증을 진행하게 된다.
application/json의 활용
지금 시대에는 프론트엔드를 프레임워크 혹은 라이브러리를 통해서 웹 페이지를 개발하고 백엔드에 Restful하게 리소스를 요청하는 형태가 많다(BFF 같은 디테일로는 가지 말자). 이 때 Content-Type은 application/json를 사용한다. 스프링 프로젝트에서 컨트롤러를 만들 때 이에 맞춰 클래스에 @RestController를 붙이고 메서드의 파라미터는 컨버터들이 자동으로 착착 dto 클래스로 변환한다. 클라이언트가 꼭 웹 브라우저일 필요가 없다는 의미다. 요청할 때 Content-type을 application/json 설정하고 본문만 json 타입만 맞추면 된다. 그래서 데스크탑 앱에서도 이 방법으로 인증 요청을 할 수 있다.
// formLogin, httpBasic은 비활성화하자.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http, AuthenticationManager authenticationManager) throws Exception {
RestApiLoginAuthFilter restApiLoginAuthFilter = new RestApiLoginAuthFilter("/api/v1/auth/login", authenticationManager);
http
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable)
Spring Security
스프링 부트 프로젝트에 Security를 붙이는 순간 기본적으로 모든 요청은 보호된다(Secured). 리소스에 접근하기 위해서는 인증이 필수라는 이야기(Authorization-인가는 논외로 하자).당연히도 웹 소켓 요청도 보호된다. 웹 소켓은 일단 연결이 맺어지고 나서는 자유롭게 서버와 통신할 수 있는데 그 최초 연결 때 Security를 통과하기 위해서는 세션 ID를 가지고 있어야 한다.
Redis
MSA 환경이 아니어도 세션을 Redis에 저장하는 걸 고려할 가치가 있다. 만약 서버를 재시작해야 하는 상황이 생길 때 세션을 Redis에 유지해놓으면 기존 접속자가 강제 로그아웃되지 않기 때문.
인증과 웹 소켓
브라우저에서 서버에 요청을 보낼 때는 로그인 이후에 브라우저가 자동으로 세션 ID를 쿠키에 넣는다. 그래서 일단 로그인을 하고 나면 매 요청때마다 쿠키에서 세션 ID를 꺼내서 유요한 세션인지 서버가 판단할 수 있다.
그러나 웹 소켓은 연결이 맺어지고 나면 자유롭게 통신이 가능하다. 통신할 때마다 세션ID가 필요하지는 않다. 다만 최초 연결 때 세션 ID가 필요하다. 그렇다면 세션은 어떻게 유지해야 할까? 세션에는 만료 시간이란 게 존재해서 웹 소켓 클라이언트가 서버에 접속해있는 동안에는 세션이 유지가 되어야 한다. 웹 소켓 세션 별로 세션 ID를 가지고 있어야 하니까, 별도의 빈에서 웹 소켓의 세션(인증 세션과 다름)의 ID를 키로 설정해서 맵으로 관리하면 되려나? 결론부터 말하면 그럴 필요 없다.
1. 세션 유지는 클라이언트 쪽에서 주기적으로 신호를 보내면 된다. 게임 서버 등에서 클라이언트가 살아있는지 확인할 때 Heartbeat을 주기적으로 보내곤 하는데, 지금 경우는 반대로, 서버가 관리하는 세션을 살려놓기 위해 클라이언트가 신호를 보낸다.
2. 웹 소켓 별로 세션 ID를 유지하기 위해 별도로 맵을 관리하는 빈 객체를 만들 필요가 없다. 웹 소켓의 연결할 때의 커스텀 인터셉터를 구현할 수 있는데, 이 구현체는 HttpSessionHandshakeInterceptor 타입이며 beforeHandshake() 메서드를 오버라이드 할 수 있다. 이 함수의 인자 중에 ServerHttpRequest 객체와, Map 객체가 있는데, ServerHttpRequest 객체에서 세션을 구할 수 있고, Map 객체는 웹 소켓 세션과 연관되어 있어서 이 Map 객체에 인증 세션 ID를 저장하면 웹 소켓 세션이 수립됐을 때 사용할 수 있다.
@Component
public class WebSocketSessionHandshakeInterceptor extends HttpSessionHandshakeInterceptor {
@Override
public boolean beforeHandshake(@Nonnull ServerHttpRequest request, @Nonnull ServerHttpResponse response, @Nonnull WebSocketHandler wsHandler, @Nonnull Map<String, Object> attributes) {
if (request instanceof ServletServerHttpRequest servletServerHttpRequest) { // tomcat
HttpSession session = servletServerHttpRequest.getServletRequest().getSession(false);
if (session != null) {
attributes.put(Constants.HTTP_SESSION_ID.getValue(), session.getId());
return true;
} else {
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return false;
}
} else {
response.setStatusCode(HttpStatus.BAD_REQUEST);
return false;
}
}
}
3. 그렇다면 레디스 세션의 만료 시간은 어떻게 연장하면 좋을까? SessionRepository<? extends Session> 타입의 빈 객체를 주입받을 수 있고 여기에서 세션 ID로 세션 객체를 반환받을 수 있다. 문제는 세션 객체의 lastAccessTime을 갱신하고 나면, SessionRepository 타입의 객체에 save() 함수로 저장할 수 없다는 점이다.
T 타입이 <? extends Session>인 관계로 타입이 특정되지 않아서 저장이 안 된다. 하지만 스프링은 매우 성숙한 프레임워크로서 해결법을 이미 가지고 있다. 레디스 저장소 설정을 할 때 @EnableRedisHttpSession 같은 걸 통해 설정 값을 넣어줄 수 있는데, FlushMode를 FlushMode.IMMEDIATE로 설정하면, 세션 인스턴스 값이 변경되면 알아서 레디스의 값을 변경해준다.
Flush mode for the Redis sessions. The default is ON_SAVE which only updates the backing Redis when SessionRepository.save(Session) is invoked. In a web environment this happens just before the HTTP response is committed. Setting the value to IMMEDIATE will ensure that the any updates to the Session are immediately written to the Redis instance.
Session httpSession = httpSessionRepository.findById(httpSessionId);
if (httpSession != null) {
httpSession.setLastAccessedTime(Instant.now());
}
사실은 레디스 설정을 먼저 해줘야 한다. flushMode의 값을 IMMEDIATE로 지정한다. 이 설정에서 RedisSerializer 빈을 생성하는 걸 볼 수 있는데, 레디스 저장소에 세션을 저장할 때 SecurityContext 객체가 올바르게 직렬화되기 위함이다. 해당 내용은 공식 문서에서도 확인이 가능하다.
@Configuration
@EnableRedisHttpSession(redisNamespace = "message:user_session", maxInactiveIntervalInSeconds = 300, flushMode = FlushMode.IMMEDIATE)
public class RedisSessionConfig {
@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModules(SecurityJackson2Modules.getModules(getClass().getClassLoader()));
return new GenericJackson2JsonRedisSerializer(objectMapper);
}
}
메시지 관리
채팅 프로그램을 관리할 때, 아니 대부분의 통신이 그렇듯 채팅 텍스트 하나만 사용하진 않는다. 위에서 언급했듯, 세션을 살려놓기 위한 신호를 클라이언트가 주기적으로 보내야 한다. 모든 메시지는 json으로 직렬화해서 보내기 때문에 서버든 클라이언트든 수신하는 쪽에서 적절하게 객체로 역직렬화하는 게 중요하다.
직렬화/역직렬화 코드 중복을 막기 위해 대상 클래스의 공통 부분을 수퍼 클래스로 만들어서 이를 상속하게 할 수 있다. 문제는 직렬화는 그냥 하면 되는데, 역직렬화할 때는 수퍼 클래스 타입이 아닌 실제 인스턴스 타입을 알 수가 없다는 점이다. 해결하는 방법은 type 정보를 메타데이터로 추가하는 것이다.
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = MessageRequest.class, name = MessageType.MESSAGE),
@JsonSubTypes.Type(value = KeepAliveRequest.class, name = MessageType.KEEP_ALIVE),
})
public abstract class BaseRequest {
private final String type;
public BaseRequest(@JsonProperty("type") String type) {
this.type = type;
}
public String getType() {
return type;
}
}
public class KeepAliveRequest extends BaseRequest {
public KeepAliveRequest() {
super(MessageType.KEEP_ALIVE);
}
}
세션 유지 기능은 콘솔 클라이언트에서 별도의 스레드로 특정 주기로 서버에게 전송을 한다. 참고로 웹 소켓 세션에서 메시지를 송신할 때 동기 메서드는 thread-safe하지 않기 때문에 웹 소켓 세션의 송신 동작을 여러 스레드가 점유한다면, thread-safe한 동작 방법이 필요하다. 서버 로직에서는 ConcurrentWebSocketSessionDecorator 클래스로 웹 소켓 세션을 감싸서 해결했다. ConcurrentWebSocketSessionDecorator 클래스는 메시지를 송신할 때 메시지를 큐에 보관해서 순차적으로 하나씩 송신한다.
@Override
public void sendMessage(WebSocketMessage<?> message) throws IOException {
if (shouldNotSend()) {
return;
}
this.buffer.add(message);
this.bufferSize.addAndGet(message.getPayloadLength());
if (this.preSendCallback != null) {
this.preSendCallback.accept(message);
}
do {
if (!tryFlushMessageBuffer()) {
if (logger.isTraceEnabled()) {
logger.trace(String.format("Another send already in progress: " +
"session id '%s':, \"in-progress\" send time %d (ms), buffer size %d bytes",
getId(), getTimeSinceSendStarted(), getBufferSize()));
}
checkSessionLimits();
break;
}
}
while (!this.buffer.isEmpty() && !shouldNotSend());
}
private boolean tryFlushMessageBuffer() throws IOException {
if (this.flushLock.tryLock()) {
try {
while (true) {
WebSocketMessage<?> message = this.buffer.poll();
if (message == null || shouldNotSend()) {
break;
}
this.bufferSize.addAndGet(-message.getPayloadLength());
this.sendStartTime = System.currentTimeMillis();
getDelegate().sendMessage(message);
this.sendStartTime = 0;
}
}
finally {
this.sendStartTime = 0;
this.flushLock.unlock();
}
return true;
}
return false;
}
클라이언트에서 보낸 메시지의 타입 검사는 instanceof 연산자로 타입을 체크할 수 있다. 다음 코드를 보면 커스텀 Interceptor를 만들 때 사용했던 attributes 속성을 웹 소켓 세션에서 꺼내 인증 세션 아이디를 구할 수 있다.
BaseRequest baseRequest = objectMapper.readValue(payload, BaseRequest.class);
if (baseRequest instanceof MessageRequest messageRequest) {
// ..
} else if (baseRequest instanceof KeepAliveRequest) {
sessionService.refreshTTL(
(String) socketSession.getAttributes().get(Constants.HTTP_SESSION_ID.getValue()));
}
콘솔 클라이언트가 세션의 생명 연장을 위해 별도의 싱글 스레드를 통해서 신호롤 보낸다는 걸 언급했다. 이는 결국 한 세션의 송신 동작에 두 개의 스레드가 관여할 수 있다는 의미가 되기 때문에 thread-safe한 처리가 필요하다. 서버에서 사용하는 웹 소켓은 스프링에서 제공하는 것이고, 콘솔 클라이언트는 순수 자바 프로젝트로, API가 조금 다른데, 콘솔 클라이언트에서는 표준인 jakarta.websocket의 Session을 사용하고 이 표준 인터페이스의 구현체 중 하나인 glassfish 라이브러리를 사용한다.
표준 API인 RemoteEndPoint.Basic을 통해서 메시지를 송신하는 것은 thread-safe하지 않다는 공식문서의 설명이 있다.
If the websocket connection underlying this RemoteEndpoint is busy sending a message when a call is made to send another one, for example if two threads attempt to call a send method concurrently, or if a developer attempts to send a new message while in the middle of sending an existing one, the send method called while the connection is already busy may throw an IllegalStateException.
때문에 RemoteEndPoint.Async 타입을 사용해야 한다.
The completion handlers for the asynchronous methods may be called with a different thread from that which initiated the send.