안녕하세요. 회사와 함께 성장하고 싶은 KOSE입니다.

 

우리가 흔히 사용하는 카카오톡과 같은 채팅 어플은, 실시간 처리가 매우 중요합니다.

친구가 메세지를 보냈는데 새로고침 하기 전까지 메세지가 전송되지 않는다면 해당 어플은 사용하지 않을 것입니다.

이를 방지하기 위해 사용하는 개념이 '소켓'입니다.

 

이번 포스팅은 MSA 아키텍처의 Liar-Game의 실시간 대기실 역할을 수행한 wait-server 프로젝트를 정리하며 웹소켓 연결 과정과 예외처리에 대해서 자세하게 정리하도록 하겠습니다. 제 스프링 부트 버전은 3.0.2로 최신 버전입니다.

(따라서, 최신 3.x.x에 호환되는 기능으로, 아직 레퍼런스가 많이 부족하여 오픈 소스로 참고하시면 좋습니다!!!!)

 

 

1. Socket과 SpringWebSocket

 

소켓은 컴퓨터 네트워크를 통해 다른 컴퓨터나 프로세스와 통신하기 위한 엔드포인트로 서로 다른 시스템 간에 데이터를 교환할 수 있도록 돕는 전송 계층의 프로토콜입니다.

소켓은 TCP 소켓과 UDP 소켓으로 구분할 수 있습니다. TCP 소켓은 연결형 통신으로 신뢰성 있는 데이터 전송을 보장하여 전송 순서와 오류 수정을 처리할 수 있습니다 대표적으로 웹 서버와 브라우저 간의 통신이 있습니다.

UDP 소켓은 데이터의 신뢰성을 보장하지 않지만 낮은 지연 시간을 갖기 때문에 실시간 게임과 같은 스트리밍 서비스에 적합합니다.

 

스프링의 Websocket 모듈은 웹 소켓 프로토콜을 지원하며 서버와 클라이언트 사이의 양방향 통신을 제공하여 빠른 데이터 전달을 가능하도록 합니다.

Spring WeboSocket은  WebSocket 서버 및 클라이언트를 개발하는 데 필요한 다양한 기능을 제공하며, 세션관리, 메시지 매핑으로 클라이언트에게 수신된 메시지를 빠르게 처리할 수 있습니다.

(저는 HTTP/1.1에서 사용하는 WebSocket을 적용하였지만 추후 모든 개발이 완성작업에 들어갈 때, SSL/TLS을 적용하여 HTTP/2를 도입할 예정입니다. 이 글 다음 편으로는 HTTP/1.1과 HTTP/2 사이의 Websocket 성능을 분석하도록 하겠습니다.)

 

 

 

2. Wait-Server 비즈니스 로직 및 WebSocket 적용 부분 정리하기

 

Wait-Server의 역할은 클라이언트가 Liar-Game을 수행하기 전에 게임 대기실 역할을 수행하는 장소입니다. 제가 중학생 때 좋아했던 스타크래프트나 혹은 카트라이더 등은 게임 대기실을 제공하는 서버로 많은 트래픽을 유발할 수 있는 곳입니다. 실시간으로 입장이 가능해야 하고, 클라이언트가 방을 개설하면 방의 리스트가 제공되어야 하며, 같은 대기실에 있는 사람들과 다른 대기실에 존재하는 인원들은 격리된 공간을 제공해야 합니다.

 

뿐만 아니라, 게임 대기실은 인원 제한도 제공해야 합니다. 보통 게임을 생각하면 4명 혹은 5명 인원 제한을 둘 수 있습니다. 

따라서, 소켓으로 특정 대기실에 접속한 인원을 저장하고 인원 대기실의 만원 처리등을 수행해야 하며, 게임 대기실 호스트가 만약 방을 나가게 되면 방 전체를 없애는 핵심 기능도 수행해야 합니다.

 

따라서, Wait-Server의 핵심 비즈니스 로직을 다음과 같이 정리할 수 있습니다.

- 개별적인 게임 대기실은 격리된 공간에서 제공되어야 합니다. 다른 대기실은 서로 데이터를 공유할 수 없습니다.

- 유저(호스트 포함)는 동일 시간에 단 하나의 게임 대기실에만 입장할 수 있습니다. 만약 다른 대기실에 입장 요청을 하면 기존에 있던 게임 대기실에서 자동 퇴장 조치되어야 합니다.

- 게임 대기실은 인원 제한이 있으며 클라이언트가 입장 및 퇴장을 할 때, 빠른 입출력으로 대기실 현황을 업데이트해야 합니다.

- 만약 호스트가 대기실 퇴장을 요청하면 현재 대기실에 존재하는 모든 유저는 대기실에서 퇴장 조치 됩니다.

- 호스트가 게임 가능 최저 인원을 달성하면 게임을 시작할 수 있으며, wait-server에서 game-server로 요청이 위임되어야 합니다.

 

여기서 Controller는 게임 대기실의 리스트를 보여주는 역할을 수행하고 WebSocketHandler는 게임 대기실 내부의 현황을 처리하는 데 사용하였습니다.

 

두 기능을 분리한 이유는 게임 대기실 리스트를 보여주는 역할은 상대적으로 실시간성이 보장되지 않아도 된다고 판단하였습니다.

만약 게임 입장하려고 했는데, 만원이 된 경우 요청이 거절되고 다시 리스트업이 될 수 있습니다. 하지만, 게임 대기실의 경우 입장한 A 클라이언트는 다른 유저가 이 방에 접속했는지 실시간으로 확인할 수 있어야 합니다. 따라서, 원활한 게임 대기실의 역할을 수행하기 위해 양방향성을 제공하는 WebSocket을 활용하고, 비교적 리스트업의 역할을 수행해야 하는 게임 대기실 목록은 Controller로 적용하였습니다. 정리하면, Wait-Server는 게임 대기실 목록은 Controller, 대기실에 입장하는 순간부터는 외부와 격리된 대기실을 제공해야 하므로 WebSocket으로 비즈니스 로직을 구현하였습니다. 

 

 

 

3. WebSocket으로 대기실 구현하기 (Controller / Handler)

 

WebSocket을 사용하려면 의존성 주입을 받아야 합니다.

implementation 'org.springframework.boot:spring-boot-starter-websocket'

 

이후, Configuration을 추가하여 Websocket의 messageBroker를 설정합니다.

@Configuration
@RequiredArgsConstructor
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    private final WebsocketSecurityInterceptor websocketSecurityInterceptor;

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/wait-service/wait-websocket")
                .addInterceptors(new CustomHandshakeInterceptor())
                .setAllowedOriginPatterns("*")
                .withSockJS();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/wait-service/waitroom/sub", "/queue");
        registry.setApplicationDestinationPrefixes("/wait-service");
    }

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(websocketSecurityInterceptor);
    }
}

WebSocketMessageBrokerConfigurer는 WebSocket에서 사용할 MessageBroker를 설정하는 인터페이스입니다.

 

위임 메서드는 다음과 같습니다.

- configureMessageBroker : 메시지 브로커 옵션 설정하는 메서드로 메세지 핸들러의 라우팅 설정 및 브로커가 사용할 목적지 접두사를 정의합니다.

- registerStompEndpoints: 클라이언트가 WebSocket 서버에 연결하기 위한 엔드포인트입니다.

- confgirueWebSocketTransport: WebSocket 전송에 대한 구성을 제공합니다. 

- configureClientInboudChannetl: 클라이언트로부터 수신한 메시지를 처리하는 데 사용되는 채널에 대한 설정입니다.

- configureClientOutBoundChannel: 서버에서 클라이언트로 보내는 메시지를 처리하는데 사용되는 채널을 구성합니다.

 

그렇다면, setApplicationDestinationPrefixes와 enableSimpleBroker는 무슨 차이일까요?

webSocket의 메세지 브로커는 발행 - 구독 시스템을 따릅니다.

따라서, applicationDestinationPrefixes로 설정되어 있는 접두사로 특정 요청을 보내면,
서버는 내부 핸들로를 통해 서버에서 매칭한 구독 접두사와 연결하여 메시지를 구독하고 있는 클라이언트에게 전달합니다.

 

저는 Controller와 Mapping Uri를 동일하게 하기 위해 /wait-service를 prefix로 설정하였고, broker는 /wait-service/waitroom/sub으로 설정하였습니다. /queue가 하는 역할은 클라이언트의 요청에 대한 에러를 처리하기 위해 사용하는 브로커로 클라이언트에게 예외 메시지를 전달하기 위해 사용됩니다.

 

 

 

 

 

핵심 비즈니스 로직은 waitRoom을 생성하면, 호스트는 waitRoom에 참여하고 다른 유저는 대기실 목록을 클릭하면 waitRoom에 참여 가능한지 파악하고 waitRoom에 입장 허가 하거나 불가 정책을 수행합니다.

(해당 비즈니스 로직은 WebSocket 조금만 정리하도록 하겠습니다.!)

 

@Getter
@RedisHash("WaitRoom")
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class WaitRoom extends BaseRedisTemplateEntity {

    @Id
    private String id;

    @Indexed
    private String roomName;

    @Indexed
    private String hostId;

    @Indexed
    private String hostName;

    private int limitMembers;
    private boolean waiting;

    private List<String> members = new CopyOnWriteArrayList<>();

    @JsonSerialize(using = LocalDateTimeSerializer.class)
    @JsonDeserialize(using = LocalDateTimeDeserializer.class)
    private LocalDateTime createdAt;

    @JsonSerialize(using = LocalDateTimeSerializer.class)
    @JsonDeserialize(using = LocalDateTimeDeserializer.class)
    private LocalDateTime modifiedAt;

	--- 중략 ---

    /**
     * 호스트가 방을 만들 경우, WaitRoom에서는 JoinMember(host)를 생성
     */
    public JoinMember createJoinMember() {
        return new JoinMember(this.getHostId(), this.getId());
    }
    
}

 

WaitRoom 객체는 대기실의 기타 정보와 참여한 유저를 등록하고, 입장과 퇴장, 입장 가능 여부의 역할이 있습니다.

따라서, WaitRoom에 관련한 참여 유저 추가, 제거, 호스트의 JoinMember 생성 등의 책임을 부여하였습니다.

 

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@RedisHash("JoinMember")
public class JoinMember {

    @Id
    private String id;
    private String roomId;

    public JoinMember(String userId, String roomId) {
        this.id = userId;
        this.roomId = roomId;
    }

	--- 중략 ---
}

 

/**
 * waitRoom을 저장
 * createWaitRoomDto로 waitRoom의 정보를 얻고, userId로 hostName 불러오기
 * waitRoom을 redis에 저장하고, joinMembers를 생성하여 저장한다.
 */
@Override
public String saveWaitRoomByHost(CreateWaitRoomRequest createWaitRoomRequest) {
    waitRoomJoinPolicyService.createWaitRoomPolicy(createWaitRoomRequest.getUserId());
    MemberNameOnly username = memberService.findUsernameById(createWaitRoomRequest.getUserId());
    WaitRoom waitRoom = saveWaitRoomAndStatusJoin(createWaitRoomRequest, username);
    return waitRoom.getId();
}

/**
 * 호스트가 아닌 다른 유저 대기방 요청 승인
 * 게임이 진행 중이거나 현재 게임 중인 유저인 경우, 현재 게임에 참여할 수 없음.
 */
@Override
public boolean addMembers(CommonWaitRoomRequest dto) {

    if (!validateNotPlaying(dto.getRoomId(), dto.getUserId())) throw new BadRequestException();
    waitRoomJoinPolicyService.joinWaitRoomPolicy(dto.getUserId());
    WaitRoom waitRoom = findById(dto.getRoomId());

    if (isEnableJoinMembers(dto, waitRoom)) {
        return saveWaitRoomAndStatusJoin(dto, waitRoom);
    }
    throw new BadRequestException();

}

 

FacadeService에서는 WaitRoom을 생성하면 Redis에 waitRoom을 저장합니다. 이때 유저 추가가 가능하다면 waitRoom에 userId를 추가하고, 더 이상 참여가 불가하면 userId를 저장하지 않습니다. 

 

먼저 waitRoom을 생성하는 구문은 Controller로 작성하였습니다.

@RestController
@RequestMapping("/wait-service")
@RequiredArgsConstructor
public class WaitRoomController {

    private final WaitRoomFacadeService waitRoomFacadeService;

    @PostMapping("/waitroom/create")
    public ResponseEntity createWaitRoom(@Valid @RequestBody CreateWaitRoomRequest dto) {
        String waitRoomId = waitRoomFacadeService.saveWaitRoomByHost(dto);
        return ResponseEntity.ok().body(SendSuccessBody.of(waitRoomId));
    }

 

클라이언트는 생성된 waitRoomId를 바탕으로 collback으로 uri를 이동한 후 소켓에 연결하여 waitRoom에 join 합니다.

 

@Slf4j
@Controller
@RequiredArgsConstructor
public class WaitRoomSocketHandler {

    private final WaitRoomFacadeService waitRoomFacadeService;
    private final SessionManagingWebSocketHandler sessionManagingWebSocketHandler;

    /**
     * StompHeaderAccessor의 필수 헤더
     * {@code @Header} Authorization: 인증 accessToken
     * {@code @Header} RefreshToken:  인증 refreshToken
     * {@code @Header} UserId: 요청 userId
     * {@code @Header} WaitRoomId: 요청 waitRoomId
     */
    @MessageMapping("/waitroom/pub/{waitRoomId}/join")
    @SendTo("/wait-service/waitroom/sub/{waitRoomId}/join")
    public ChatMessageResponse joinMember(@Valid @RequestBody CommonWaitRoomRequest dto,
                           @DestinationVariable String waitRoomId,
                           @Header("UserId") String userId,
                           StompHeaderAccessor stompHeaderAccessor) {

        log.info("ChatMessageResponse1 message = {}", dto.getUserId());
        if (!userId.equals(dto.getUserId())) throw new WebsocketSecurityException();

        waitRoomFacadeService.addMembers(dto);
        log.info("ChatMessageResponse2 message = {}", dto.getUserId());

        return ChatMessageResponse.of(dto.getUserId(), JOIN);
    }

 

@MessageMapping()은 앞 서 정의한 prefixes를 포함하여 destination으로 매핑하는 역할을 수행합니다.

 

registry.setApplicationDestinationPrefixes("/wait-service");

 

@SendTo()는 broker 메시지를 전송하는 역할을 수행합니다. 클라이언트는 enableSimpleBroker로 설정한 destination에 @SendTo에 정의되는 메시지가 매핑된다면 메세지가 전송되게 됩니다.

registry.enableSimpleBroker("/wait-service/waitroom/sub", "/queue");

 

마찬가지로 조인과, 퇴장, 호스트 방 제거 등을 추가할 수 있습니다.

 

@MessageMapping("/waitroom/pub/{waitRoomId}/join")
@SendTo("/wait-service/waitroom/sub/{waitRoomId}/join")
public ChatMessageResponse joinMember(@Valid @RequestBody CommonWaitRoomRequest dto,
                       @DestinationVariable String waitRoomId,
                       @Header("UserId") String userId,
                       StompHeaderAccessor stompHeaderAccessor) {

    log.info("ChatMessageResponse1 message = {}", dto.getUserId());
    if (!userId.equals(dto.getUserId())) throw new WebsocketSecurityException();

    waitRoomFacadeService.addMembers(dto);
    log.info("ChatMessageResponse2 message = {}", dto.getUserId());

    return ChatMessageResponse.of(dto.getUserId(), JOIN);
}

@MessageMapping("/waitroom/pub/{waitRoomId}/delete")
@SendTo("/wait-service/waitroom/sub/{waitRoomId}/delete")
public ChatMessageResponse deleteWaitRoom(@Valid @RequestBody CommonWaitRoomRequest dto,
                           @DestinationVariable String waitRoomId,
                           @Header("UserId") String userId,
                           StompHeaderAccessor stompHeaderAccessor) {

    if (!userId.equals(dto.getUserId())) throw new WebsocketSecurityException();

    boolean deleteStatus = waitRoomFacadeService.deleteWaitRoomByHost(dto);
    return ChatMessageResponse.of(dto.getUserId(), LEAVE, deleteStatus);
}

@MessageMapping("/waitroom/pub/{waitRoomId}/leave")
@SendTo("/wait-service/waitroom/sub/{waitRoomId}/leave")
public ChatMessageResponse leaveMember(@Valid @RequestBody CommonWaitRoomRequest dto,
                        @DestinationVariable String waitRoomId,
                        @Header("UserId") String userId,
                        StompHeaderAccessor stompHeaderAccessor) {

    if (!userId.equals(dto.getUserId())) throw new WebsocketSecurityException();

    boolean leaveStatus = waitRoomFacadeService.leaveMember(dto);
    return ChatMessageResponse.of(dto.getUserId(), LEAVE, leaveStatus);
}

 

이를 수행하면 다음과 같은 구조도와 예시 모습을 확인할 수 있습니다.

유저는 실시간으로 게임 대기실의 참여 인원을 확인하고 입장하고 퇴장하는 인원을 실시간으로 볼 수 있습니다.

 

 

 

 

4. Websocket 보안 Interceptor 등록하기

 

여기서부터 정말 어려워지는 구간이었습니다.! 이 비즈니스 로직에서는 반드시 필요한 stomp 헤더 정보가 없거나, join 한 유저가 아닌 다른 클라이언트가 접근을 시도한다면 대기실 입장을 거부하는 인터셉터 작성이 필요했습니다. 

 

@Slf4j
@RequiredArgsConstructor
@Component
public class WebsocketSecurityInterceptor implements ChannelInterceptor {

    private final WaitRoomFacadeService waitRoomFacadeService;
    private final AntPathMatcher antPathMatcher;
    private final TokenProviderPolicy tokenProviderPolicy;

    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(message);
        if (StompCommand.CONNECT.equals(headerAccessor.getCommand())) {
            isValidateWaitRoomIdAndJoinMember(headerAccessor);
        }
        return message;
    }

    private void isValidateWaitRoomIdAndJoinMember(StompHeaderAccessor headerAccessor) {
        String accessToken = headerAccessor.getFirstNativeHeader("Authorization");
        String refreshToken = headerAccessor.getFirstNativeHeader("RefreshToken");
        String waitRoomId = headerAccessor.getFirstNativeHeader("WaitRoomId");
        String userId = headerAccessor.getFirstNativeHeader("UserId");
        
        if (accessToken == null || refreshToken == null || waitRoomId == null || userId == null)
            throw new WebsocketSecurityException();

        log.info("validateUserAccessor >>");
        validateUserAccessor(validateTokenAccessor(accessToken, refreshToken), userId);

        String destination = headerAccessor.getDestination();
        log.info("destination = {}", destination);
        log.info("destination >>");
        if (destination == null) throw new WebsocketSecurityException();

        if (isApplyUri(destination)) {
            log.info("isJoinedMember >>");
            waitRoomFacadeService.isJoinedMember(waitRoomId, userId);
        }
    }

    private String validateTokenAccessor(String accessToken, String refreshToken) {
        try {
            String userIdFromAccess = tokenProviderPolicy.getUserIdFromToken(tokenProviderPolicy.removeType(accessToken));
            String userIdFromRefresh = tokenProviderPolicy.getUserIdFromToken(refreshToken);
            
            if (!userIdFromAccess.equals(userIdFromRefresh)) throw new WebsocketSecurityException();

            return userIdFromAccess;
        } catch (Exception e) {
            throw new WebsocketSecurityException();
        }
    }

    private boolean isApplyUri(String destination) {
        return !antPathMatcher.match("/wait-service/waitroom/**/**/join", destination);
    }

    private void validateUserAccessor(String parseUserId, String headerUserId) {
        if (!parseUserId.equals(headerUserId)) throw new WebsocketSecurityException();
    }
}

 

 

STOMP는 WebSocket과 같은 전송계층 프로토콜 위에서 동작하며 양방향 통신을 쉽게 구현할 수 있도록 하는 Simple Text Orientated Messaging Protocol로 텍스트 기반의 메시징 프로토콜입니다.

StompHeaderAccessor를 활용하면 STOMP 프로토콜에 사용하는 Header 등의 정보를 담아 인증 처리 등을 구현할 수 있습니다.

 

<여기서 잠깐! Websocket과 Stomp 추가 정리하기>

 

 WebSocket은 데이터를 전송하는 저수준의 프로토콜입니다. 따라서, STOMP와 같은 상위 프로토콜을 함께 사용하여 메시지 전송 및 라우팅 관리에 필요한 고수준의 기능을 수행할 수 있습니다.

 

STOMP가 WebSocket을 돕는 고수준의 기능은 다음과 같습니다.

- 메시지 교환 패턴 정의: STOMP는 pub-sub와 point-to-point와 같은 메시지 교환 패턴을 정의합니다.

- 메시지 라우팅: STOMP는 메시지 라우팅을 위한 명시적인 목적지를 제공합니다

- 메시지 형식화: STOMP는 메시지를 전송하는데 필요한 명령어, 헤더, 페이로드를 포함합니다.

- 구독관리: STOMP는 클라이언트가 특정 목적에 대해 구독 여부를 설정할 수 있도록 돕습니다.

 

따라서, Websocket Congiruation에서 설정한 엔드포인트로 MessageMapping기능, 즉 라우팅 기능을 수행할 수 있는 이유도 이처럼 Stomp가 작동하여 WebSocket이 메세지를 발행 구독 할 수 있는 기능을 돕기 때문입니다. 

 

 

 

5. WebSocket에서 발생하는 Exception 처리 로직 작성하기

 

일부 커넥션을 유지해야 하는 예외가 있는 반면, 반드시 커넥션을 종료시켜야 하는 예외가 있습니다. 가령 클라이언트의 실수와 같은 예외는 단순한 예외 메시지 전송으로 처리 가능하지만, 헤더 정보를 임의로 바꾸거나 대기실 접속 가능한 유저가 아닌 클라이언트가 접속하는 경우 커넥션을 제거해야 합니다.

 

먼저 소스를 정리한 후 이어서 설명을 진행하도록 하겠습니다.!

 

@Slf4j
@Component
public class CustomWebSocketHandlerDecorator extends WebSocketHandlerDecorator {

    private final ConcurrentHashMap<String, WebSocketSession> sessionMap = new ConcurrentHashMap<>();
    public CustomWebSocketHandlerDecorator(@Qualifier("customWebSocketHandlerDecorator") WebSocketHandler delegate) {
        super(delegate);
    }

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        sessionMap.put(session.getId(), session);
        super.afterConnectionEstablished(session);
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception {
        sessionMap.remove(session.getId());
        super.afterConnectionClosed(session, closeStatus);
    }

    public void closeSession(String sessionId) throws IOException {
        WebSocketSession session = sessionMap.get(sessionId);
        if (session != null && session.isOpen()) {
            session.close();
        }
    }
}

 

WebSocketHandlerDecorator는 Websocket 핸들러 중 하나로 커넥션을 열고 닫고 하는 과정에서 데코레이터 패턴을 구현하며 추가적인 동작을 수행할 수 있도록 돕습니다.

CustomWebSocketHanlderDecorator는  WebSocketHandlerDecorator을 상속하는 클래스로 커넥션을 sessionMap에 등록하고,  커넥션을 강제로 종료하기 위해 closeSession을 호출하여 session.close()를 처리하는 로직을 구현하였습니다.

 

그런데, CustomWebSocketHanlderDecorator에서 WebSocketSession을 제거하는 것이 어떠한 의미를 가지고 있을까요?

 

 

6. WebSocketSession 처리과정 확인하기

 

기본적으로 Websocket은 hanShakeHandler로 DefaultHandShakeHandler를 기본값으로 설정하고 있습니다.

 

.setHandshakeHandler(new DefaultHandshakeHandler(new TomcatRequestUpgradeStrategy()))

 

DefaultHandShakeHandler는 추상 클래스인 AbstractHandShakeHandler를 상속하고 있으며 해당 클래스에서
WebsocketHandler를 파라미터로 받고 있습니다.

 

 

스프링은 WebSocketHandler의 구현체로 stomp를 포함한 여러 프로토콜을 처리하는 역할을 수행하는 SubProtocolWebSocketHandler를 제공하고 있습니다.

여기서 WebSocketSession이 등록되고 삭제되는 로직이 수행되고 있습니다.

 

 

이는 곧 SubProtocolWebSocketHandler가 WebSession을 파라미터로 받고 있기 때문에, decorator로 감싸서 부가 기능을 수행한다면 exception이 발생했을 때 특정 session을 강제 종료 시킬 수 있음을 의미하였습니다. 

 


여기서 확인할 수 있는 것은 WebsocketSession은 추상 클래스를 포함하더라도 18개가 구현되어 있습니다.

자바의 다형성을 활용하면, WebsocketSession을 구현하고 있는 다양한 구현체를  WebSocketSession으로 캐스팅할 수 있습니다. 따라서, 인터페이스를 타입으로 캐스팅하여 호출한 후 해당 인스턴스를 제거하면, 그 주소값이 의미를 잃게 되므로,
다른 곳에서도 특정 WebSocketSession을 사용할 수 없게 되는 것입니다.
(정말 자바 스프링은 최고입니다....!)

 

이전에 등록한 WebsocketConfig에 제가 정의한 CustomWebsocketHandlerDecorator를 빈으로 등록한 후 decorator로 추가하겠습니다.

 

@Override
public void configureWebSocketTransport(WebSocketTransportRegistration registry) {
    registry.addDecoratorFactory(this::customWebSocketHandlerDecorator);
}

@Bean
public WebSocketHandlerDecorator customWebSocketHandlerDecorator(WebSocketHandler webSocketHandler) {
    return new CustomWebSocketHandlerDecorator(webSocketHandler);
}

 

이제 마지막으로 Exception이 발생했을 때 ControllerAdvice처럼 예외 메시지를 처리해 줄 핸들러 매핑을 정의합니다.

 

@MessageExceptionHandler
@SendToUser("/queue/errors")
public String handleException(Throwable exception, StompHeaderAccessor stompHeaderAccessor) throws IOException {

    if (exception instanceof WebsocketSecurityException ||
            exception instanceof NotFoundWaitRoomException ||
            exception instanceof NotFoundUserException) {
        String sessionId = stompHeaderAccessor.getSessionId();
        log.info("session = {}, connection remove", sessionId);
        decorator.closeSession(sessionId);
    }
    else if (exception instanceof CommonException) {
        return "server exception: " + exception.getMessage();
    }
    else {
        String sessionId = stompHeaderAccessor.getSessionId();
        decorator.closeSession(sessionId);
    }

    return "server exception: " + exception.getMessage() + "server session clear";
}

 

저는 WebsocketSecurityException 종류는 가장 큰 문제라 판단하여 세션을 종료하는 로직을 수행하도록 하였고,
그 외의 CommonException 종류에는 메세지를 전송하는 정도로 마치고 나머지는 전부 세션을 끝내는 것으로 하였습니다.

(세션은 종료하면 메시지가 전송되지 않는데 이 부분은 추후 다시 발전해 나가겠습니다.)

 

 

 

7. CORS / Security 설정

 

CORS는 Cross - Origin - Resource Sharing으로 웹 브라우저에서 실행되는 스크립트에서 다른 출처의 자원에 접근할 때 보안적인 문제를 다루는 메커니즘입니다. 만약 도메인이 다른 경우 CORS 문제로 인해 통신에 제약이 있을 수 있습니다. 따라서 API 서버 역할을 수행하는 백엔드는 CORS 설정으로 요청 가능한 도메인을 설정해야 합니다.

만약 외부 클라이언트의 직접 접근을 처리해야 한다면 allowedOriginPatterns 에 "*"와 같은 와일드카드를 생성하여 모든 도메인에 접근을 허용해야 합니다.

하지만 이는 보안에 취약점을 줄 수 있으므로 클라이언트의 직접 접근을 막고 중계 역할을 수행할 수 있는 프론트 서버를 중간에 두었습니다. 

 

@Configuration
public class WebCorsConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOriginPatterns("http://localhost:8000", "http://localhost:3000", ---- 중략 ----)
                .allowedMethods("*")
                .allowedHeaders("*")
                .exposedHeaders("Access-Control-Allow-Origin")
                .allowCredentials(true);
    }
}

 

로컬에서 gateway 역할을 수행하는 서버는 8000 포트, 프론트 서버는 3000번을 활용할 것이기 때문에 allowedOriginPatterns에 해당 주소를 입력하였습니다. 현재 OriginPatterns에는 제가 연결하고자 하는 로컬 서버와 AWS 서버의 도메인만 기록되어 있기 때문에 다른 Method (Get, Post 등), Header는 모두 와일드 카드로 설정하였고, exposeHeaders에 Access-Control-Allow-Origin을 추가하였습니다.

 

Spring Seurity는 정말 다루기 어렵지만 그만큼 뛰어난 보안성을 가지고 있습니다. 

여기서 문제가 되는 부분은 소켓은 먼저 클라이언트 (여기서는 프론트 서버)와 백엔드 서버가 서로 3 - HandShake를 거친 후 응답이 처리되면 소켓 연결을 진행합니다. 이후 메시징 역할을 수행하는 STOMP 프로토콜로 애플리케이션 단에서 데이터를 처리합니다.

 

정리하면 Tcp(Http) -> Tcp(Socket) + Application(Stomp) 순서로 기동 되는 것입니다. 

따라서, Spring Security에서 최초 연결 시에 Http를 처리할 수 있지만 이 부분은 다루기 복잡하다고 판단하여 websocket을 필요로 하는 엔드포인트에 대한 요청은 모두 통과시킨 후, 앞서 정의한 interceptor에서 애플리케이션 계층의 stomp 헤더를 검증하는 것으로 처리하였습니다.
(이 부분은 추후 다시 다루도록 하겠습니다.)

 

public static final List<String> AuthenticationGivenFilterWhitelist = Arrays.asList(
        "/",
        "/static/**",
        "/favicon.ico",
        "/wait-service/wait-websocket/**"
);
private boolean isAuthorizationIssueRequired(String requestURI) {
    return !FilterWhiteList.AuthenticationGivenFilterWhitelist.stream()
            .anyMatch(uri -> antPathMatcher.match(uri, requestURI));
}

antPathMatcher를 활용하여 wait-websocket으로 커넥션 요청이 들어오는 http는 모두 통과시키도록 처리하였습니다.

 

 

 

8. 테스트 

 

길었던 Websocket 관련 로직 작성을 마쳤습니다. 언제나 그렇듯 가장 중요한 것은 작성이 아니라 테스트인 것 같습니다.

Websocket을 테스트하는 과정은 굉장히 까다롭습니다. 세션이 유지되는 것과 제거되는 점, 클라이언트에 전송되는 메시지 등을 판단해야 하기 때문입니다. 스프링에서 단위 테스트를 진행할 수 있지만, 프론트 서버 -> 게이트웨이 -> 백엔드 연결되는 과정을 눈으로 직접 확인해보기 위해 간단하게 vue.js로 프론트 서버를 구축하고 테스트를 진행하도록 하겠습니다.

 

먼저, 현재 프로젝트는 MSA의 wait-server를 담당하고 있지만 인증 토큰 생성은 member-server에서 생성하고 있습니다.

테스트를 위해 member-server를 매번 기동하여 토큰 발급 처리하기 어려움으로 스프링에서 InitDB 클래스를 생성한 후
더미 데이터를 추가하였습니다. @PostConstruct는 모든 스프링빈이 등록되고 의존관계 주입을 마친 후에 해당 메서드가 실행될 수 있도록 처리해 주는 어노테이션입니다.

 

@Component
@RequiredArgsConstructor
public class InitDb {

    private final InitService initService;

    @PostConstruct
    public void init() {
        initService.dbInit();
    }

    @Slf4j
    @Component
    @Transactional
    @RequiredArgsConstructor
    static class InitService {
        private final EntityManager em;
        private final WaitRoomFacadeService waitRoomFacadeService;
        
        --- 중략 ---

 

저는 프론트 서버는 테스트용 정도밖에 다뤄본 경험이 없어서 최대한 로직은 간단하게 Vue.js로 작성하였습니다.

 

<index.js>

module.exports = {
  dev: {

    // Paths
    assetsSubDirectory: 'static',
    assetsPublicPath: '/',
    proxyTable: {
      '/api': {
        target: 'http://localhost:8000',
        // changeOrigin: true,
        pathRewrite: {
          '^/api': ''
        }
      }
    },
    
    host: 'localhost', // can be overwritten by process.env.HOST
    port: 3000, // can be overwritten by process.env.PORT, if port is in use, a free one will be determined
    autoOpenBrowser: false,
    errorOverlay: true,
    notifyOnErrors: true,

 

proxyTable에 등록된 target은 '/api'라는 prefix 요청에 대해 해당 타깃으로 보내겠다는 의미입니다.

저는 client -> front 중계 -> 백엔드 스프링 클라우드 gateway 중계 -> 실제 서비스를 호출하는 방식으로 구현하고 있어서
gateway 서버를 target으로 설정하였습니다.

 

< WaitRoomSocket.vue>

 

methods: {
    connect () {
      const accessToken ='Bearer eyJhbGciOiJIUzUxMiJ9FUiJd' --- 중략 ---
      const refreshToken ='eyJhbGciOiJIUzUxMiJ9.IRdLQw' --- 중략 ---
      const userId = 'c87afd49-956f-4e4c-9829-f2f24a193695'
      const socket = new SockJS('/api/wait-service/wait-websocket', {}, {transports: ['websocket', 'xhr-streaming', 'xhr-polling']})
      // const socket = new WebSocket('/api/wait-service/wait-websocket')

      this.stompClient = Stomp.over(socket)
      this.stompClient.heartbeat.outgoing = 0 // 클라이언트가 서버로 하트비트를 보낼 간격(밀리초)
      this.stompClient.heartbeat.incoming = 0

      const waitRoomId = this.channel
      const headers = {
        'Authorization': accessToken,
        'RefreshToken': refreshToken,
        'UserId': userId,
        'WaitRoomId': waitRoomId,
        'destination': `/wait-service/waitroom/sub/${waitRoomId}/join`
      }

      console.log(`/api/wait-service/waitroom/sub/${waitRoomId}/join`)
      console.log('waitRoomId = ', waitRoomId)
      this.stompClient.connect(headers, (frame) => {
        console.log('frame = ', frame)
        this.stompClient.subscribe(
          `/wait-service/waitroom/sub/${waitRoomId}/join`, (chatMessageResponse) => {
            console.log('log', JSON.stringify(chatMessageResponse))
          }, (error) => {
            console.log(error)
          })

        this.stompClient.subscribe(
          `/user/queue/errors`, headers, (chatMessageResponse) => {
            console.log('log', JSON.stringify(chatMessageResponse))
          }, (error) => {
            console.log(error)
          })

        this.stompClient.send(`/wait-service/waitroom/pub/${waitRoomId}/join`, headers,
          JSON.stringify({
            userId: userId,
            roomId: waitRoomId
          }))
      })
      this.connected = true
    },
    disconnect () {
      if (this.stompClient !== null) {
        this.stompClient.disconnect()
      }
      this.connected = false
      console.log('Disconnected')
    },
    showGreeting (message) {
      this.greetings.push(message)
    }
  }
}

 

순서는 stompClient(Stomp)로 커넥트를 요청한 후, 연결이 되면 콜백으로 subscribe로 메시지 브로커에 등록된 토픽을 구독하고, 서버로 send 요청을 수행하여 필요한 정보를 전달하는 코드입니다.

즉 순서는 connect -> 메시징 구독 -> 메시지 전송으로 이뤄지며 백엔드에서 stomp 헤더를 파싱 하여 인증 정보를 처리하므로
Stomp에 헤더 정보를 추가하였습니다.

(token 관련 인증 정보는 간단하게 String으로 처리하였습니다.)

 

이제 각 의존성이 있는 서버들을 기동한 후, 테스트를 진행하겠습니다.

InitDb로 http로 접근하여 waitRoom을 생성하는 로직을 수행하였고 redis에 다음과 같이 값이 입력되었습니다.

 

 

localhost:3000/waitroom url에 접근하여 방금 등록한 waitRoomId를 입력한 후, connect를 누르고 개발자 모드를 확인하면 같은 결과가 나옵니다.

 

 

최초 webSocket에 연결이 connect 되면, destination에 대한 구독을 실행하고 Send로 자신의 userId와 waitRoomId를 등록하여 해당 방 참여에 대한 메시지를 전달합니다.

 

<Interceptor 통과>

 

 

<Handler 적용>

 

 

<정상 요청 응답 >

 

(이전 세션이 종료되어서 서버를 다시 기동하여 waitRoomId가 바뀐 것입니다.!!!)

 

 

 

<Redis에 저장되지 않은 WaitRoom에 접속 혹은 잘못된 헤더 정보>

 

 

 

그를 보면, WebsocketSecurityException이 터지면서 connection이 remove 되었습니다.

개발자 도구로 확인해 보아도 클라이언트의 커넥션이 종료되었습니다.!

 

 

<Dto의 userId와 waitRoomId가 헤더 정보와 다른 경우>

 

this.stompClient.send(`/wait-service/waitroom/pub/${waitRoomId}/join`, headers,
  JSON.stringify({
    userId: 'wrong !!!',
    roomId: waitRoomId
  }))

 

 

모든 테스트를 성공하였습니다 ㅎㅎㅎ ㅠㅠ!!

 

 

 

9. 정리하며...

 

그 어느 때보다, 정리하며...를 적고 싶었던 시간이었습니다.. ㅎㅎ

거의 30시간 가까이 에러를 맞이하며 수정하고 디버깅하고 다시 해결하고 에러나고의 반복이었던 것 같습니다.

이전에 webSocket을 연결하는 간단한 실습을 한 적이 있었는데, 그 당시에는 여러 비즈니스 로직이 없는

간단한 서버사이드 렌더링으로 채팅방을 구현하는 것이었습니다.

 

이번에는 SpringSecurity, Interceptor, 예외처리를 추가하고 스프링 부트의 최신 버전으로 적용하다보니 정말 많은 에러를 맞이할 수 있었습니다. 에러가 발생하니 이전에 쉽게 지나갔던 부분을 다시 검토할 수 있게 되었고 정말 깊게 스프링의 내부 구조와 우원리를 다시 한번 볼 수 있게 된 계기가 되었습니다. 

 

이번 Websocket에 여러 가지 기능을 붙이며, 소켓이 어떻게 전송이 되는지부터 시작해서 Spring이 어떻게 websocket을 지원하고 있는지, 그리고 에러가 생기면 어떻게 세션을 종료하는지 등을 정말 깊게 공부할 수 있었던 것 같습니다.

 

또한, 정말 자바 스프링의 위대함을 다시 한번 느낄 수 있었습니다.

혹시 "WebsocketSession 인터페이스를 활용하는 구현체를 찾아서 custom 하게 바꾼 후 빈을 등록하면 되지 않을까?"

라는 생각을 가지고 접근을 하니 이러한 생각을 현실로 만들 수 있도록 모든 것을 제공해 주는 스프링에 대해서

다시 한번 감사함을 느낄 수 있었습니다.

 

 

정말 스프링 프레임워크가 발전하고 많은 레퍼런스를 볼 수 있도록 힘 써주신 선임 개발자님들 감사드립니다 ㅠㅠㅠㅠㅠㅠㅠ 

 

긴 글이었지만 읽어주셔서 감사드립니다.!

안녕하세요. 회사와 함께 성장하고 싶은 KOSE입니다.

 

스프링은 다양한 데이터 접근 기술을 제공합니다. 주로 사용하는 기술은 jpa, jdbc, mybatis 등이 있습니다. jpa를 사용하는 간단한 crud를 빠르게 사용할 수 있는 장점이 존재하지만 동적 쿼리를 해결하는 데는 많은 어려움이 있습니다.

 

스프링은 동적 쿼리를 해결하기 위한 수단으로 다양한 기능을 제공합니다. 가장 대표적인 기술은 queryDsl입니다.

queryDsl은 자바 언어로 SQL을 작성할 수 있도록 제공하여 컴파일 시점에 에러를 잡을 수 있고, 다양한 동적 쿼리를 빌더 형태로 작성할 수 있습니다.

 

이번 글은 제가 작성했던 queryDsl 조회 로직을 살펴보고 100만개의 더미데이터에서 성능을 분석하는 시간을 가져보도록 하겠습니다.

 

 

 

1. ERD 스키마와 Result-Server의 역할 정리하기

 

인텔리제이에서는 데이터베이스 스키마를 효과적으로 볼 수 있습니다. 인텔리 제이로 스키마를 분석해 보겠습니다.

  

 

Result-Server의 주된 비즈니스 로직은 AWS SQS로 보내진 메시지를 구독하여 데이터베이스에 값을 저장하고, 조회 로직을 구현하는 것입니다.

 

필요한 테이블은 member, authority, player, player_result, game_result, topic입니다.

MSA 아키텍처에서 member, authority는 member-server의 테이블에서 반드시 필요한 필드로 result-server만의 테이블을 사용하도록 구현하였습니다.

 

playermember일 대 일 관계로, 한 명의 회원은 한 개의 플레이어를 선정한다라는 관계를 설정하였습니다.

player_resultmember다 대 일 관계로, 한 명의 회원은 다수의 게임 결과를 가질 수 있습니다.

game_resultplayer_result일 대 다 관계로, 하나의 게임 결과는 다수의 플레이어 결과를 가질 수 있습니다.

topicgame_result일 대 다의 관계로 하나의 토픽은 다수의 game_result에서 사용될 수 있습니다.

 

저는 game-server에서 AWS SQS로 메시지를 발행하면 result-server가 이를 구독하여, 전송된 게임 결과, 투표 결과 등을 조합하여 결과를 저장하는 시나리오를 생각했습니다. 따라서, game-server는 redis를 사용하여 저장된 다양한 게임 결과를  result-server로 위임하여 result-server에서 모든 결과를 저장하고 클라이언트의 결과 조회나 랭킹 조회 등의 결과 조회 서비스를 담당하는 역할을 부여하였습니다.

 

 

 

2. 100만 개의 더미 데이터 생성하기 (MySQL)

 

약 100만개의 데이터를 자바 코드로 INSERT문을 txt로 작성하였고, LOAD DATA INFILE로 생성된 데이터를 입력하였습니다.

- member 1만 명: member_id (vachar(255)) pk, userId (vachar(255)) (인덱스)

- topic 1만 개: 게임에 사용될 주제 topic_id (BigInt) pk

- game_result 10만 건: 게임 결과 저장 game_result_id (vachar(255)) pk, topic_id fk

- player_result 약 100만 건: 플레이어의 게임 결과 player_result_id (varchar(255)) pk, game_result_id fk, member_id fk 

 

LOAD DATA INFILE '/var/lib/mysql-files/insert_player_result.txt'
    INTO TABLE player_result
    FIELDS TERMINATED BY ', '
    LINES starting by '(' TERMINATED BY '),\n'
    IGNORE 1 LINES;
    
select count(*) from player_result;

 

 

각 더미데이터는 최대한 현실과 비슷하게 작성하기 위해, UUID로 생성된 pk가 겹치지 않고, 실제 제가 저장하는 방식 대로 데이터를 입력하였습니다. player_result의 경우 LOAD DATA INFILE로 더미 데이터를 입력하는데 약 30분 정도 소요되었습니다.

 

 

 

3. 현재 비즈니스 로직 분석하기 

 

회원은 최근 자신이 참여한 게임 결과를 조회할 수 있습니다.

  • 참여한 gameId(게임 Id: 추가 조회용), gameName(게임 이름), topicName(라이어 게임 주제), winner (승리한 역할),
    totalUsers (참여한 총 유저수), myRole (게임에서 내 역할), answer (라이어 투표에서 내 투표의 정답 여부)

조회한 내용을 클라이언트로 전달하기 위해 Dto를 선언하였습니다.

여기서 QueryProjection의 역할은 Q 타입은 기본적으로 Entity로 선언된 class가 Q타입으로 컴파일됩니다.

이때, Dto로 선언된 class로 조회 로직을 작성해야 하는 경우 QueryProjection으로 선언된 Dto는 Q타입을 사용할 수 있게 됩니다.

 

@Getter
@NoArgsConstructor
public class MyDetailGameResultDto {
    private String gameId;
    private String gameName;
    private String topicName;
    private GameRole winner;
    private Integer totalUsers;
    private GameRole myRole;
    private Boolean answer;
    
    @QueryProjection
    public MyDetailGameResultDto(String gameId, String gameName, String topicName, GameRole winner, Integer totalUsers, GameRole myRole, Boolean answer) {
        this.gameId = gameId;
        this.gameName = gameName;
        this.topicName = topicName;
        this.winner = winner;
        this.totalUsers = totalUsers;
        this.myRole = myRole;
        this.answer = answer;
    }
}

 

 

하단의 MyDetailGameResultCond는 최신 순 조회, 이긴 게임 조회, 진 게임 조회, 게임 이름으로 조회를 선택할 수 있도록 한 Condition입니다. 클라이언트는 해당 조회 기능으로 원하는 기능으로 게임을 조회할 수 있습니다.

 

여기서, gameId 혹은 game_result_id를 넣지 않은 이유는, 클라이언트는 복잡한 숫자 혹은 UUID로 된 게임 id를 기억하지 않습니다. 보통 "우리 같이 게임합시다!" 등의 게임 이름을 기억하고 조회하는 경우가 많습니다.

따라서, searchGameName은 이러한 클라이언트의 특성을 분석하여 조회 조건으로 설정하였습니다.

 

userId는 header에서 가져오는 반드시 필요한 변수로 유저 정보 조회를 위한 필수 값으로 이는 유저가 클릭하는 정보가 아닌, 헤더에서 받아온 값을 인터셉터에서 처리하여 cond에 주입하고 있습니다.

 

@Getter
@NoArgsConstructor
public class MyDetailGameResultCond {

    private String userId;
    private Boolean viewLatest;
    private Boolean viewOnlyWin;
    private Boolean viewOnlyLose;
    private String searchGameName;

    @Builder
    public MyDetailGameResultCond(String userId, Boolean viewLatest, Boolean viewOnlyWin, Boolean viewOnlyLose, String searchGameName) {
        this.userId = userId;
        this.viewLatest = viewLatest;
        this.viewOnlyWin = viewOnlyWin;
        this.viewOnlyLose = viewOnlyLose;
        this.searchGameName = searchGameName;
    }
}

 

해당 비즈니스 로직은 스크롤을 내리며 열람할 수 있도록 Slice로 구현되어 있습니다. 따라서, content용 쿼리count용 쿼리를 분리하여 작성하였습니다.

 

@Repository
@RequiredArgsConstructor
public class MyDetailGameResultQueryDslRepositoryImpl implements MyDetailGameResultQueryDslRepository{

    private final JPAQueryFactory query;

    @Override
    public Slice<MyDetailGameResultDto> fetchMyDetailGameResult(MyDetailGameResultCond cond, Pageable pageable) {

        List<MyDetailGameResultDto> content = selectMyDetailGameResultContent(cond, pageable);
        JPAQuery<Long> countQuery = countMyDetailGameResult(cond);

        return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
    }

 

 

먼저, content용 쿼리는 다음과 같습니다.

앞서 정의한 MyDetailGameResultDto에 필요한 정보를 join 연산으로 가져오고 있습니다.

먼저 앞서 정의한 ERD 모델에 따라, userId는 player_result -> meber의 조인으로

userId에 접근하여 인덱스로 pk를 가져옵니다.

game_result -> topic 조인으로 topic.topic_name을 가져오고, 

조인 관계로 game_result의 fk가 있는 player_result에서 필요한 gameRole, answers를 가져옵니다. 

private List<MyDetailGameResultDto> selectMyDetailGameResultContent(MyDetailGameResultCond cond, Pageable pageable) {
    return query
            .select(
                    new QMyDetailGameResultDto(
                            gameResult.gameId,
                            gameResult.gameName,
                            gameResult.topic.topicName,
                            gameResult.winner,
                            gameResult.totalUsers,
                            playerResult.gameRole,
                            playerResult.answers
                    )
            )
            .from(playerResult)
            .join(playerResult.member, member)
            .join(playerResult.gameResult, gameResult)
            .join(gameResult.topic, topic)
            .where(
                    member.userId.eq(cond.getUserId()),
                    playerWinEq(cond.getViewOnlyWin()),
                    playerLoseEq(cond.getViewOnlyLose()),
                    gameNameContains(cond.getSearchGameName())
            )
            .orderBy(createOrderSpecifier(cond))
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();
}

 

BooleanExpression을 활용하여 각 각 동적쿼리를 구현하였습니다.

QueryDsl의 최대 장점은 동적쿼리를 BooleanExpression 형태로 구현할 수 있다는 점입니다.

따라서, 모든 경우의 수를 개발자가 고려하지 않아도 각 상황에 맞도록 쿼리가 조합되어서 나갈 수 있습니다.

 

이 부분에서 기억해야 할 부분은 contains 로직으로 like 역할을 수행하는 gameNameContains()입니다.

(하단에서 이 부분에 대한 성능 튜닝 과정을 진행하도록 하겠습니다.)

 

private BooleanExpression gameNameContains(String searchGameName) {
    return searchGameName != null ? gameResult.gameName.contains(searchGameName) : null;
}

private BooleanExpression playerWinEq(Boolean viewOnlyWin) {
    return viewOnlyWin != null ? playerResult.isWin.eq(true) : null;
}

private BooleanExpression playerLoseEq(Boolean viewOnlyLose) {
    return viewOnlyLose != null ? playerResult.isWin.eq(false) : null;
}

private OrderSpecifier[] createOrderSpecifier(MyDetailGameResultCond cond) {
    List<OrderSpecifier> orderSpecifiers = new ArrayList<>();

    if (cond.getViewLatest() != null) {
        orderSpecifiers.add(new OrderSpecifier(Order.DESC, gameResult.createdAt));
    }

    return orderSpecifiers.toArray(new OrderSpecifier[orderSpecifiers.size()]);
}

 

다음은 Slice에서 페이징 역할을 수행하는 count 로직입니다. 두 로직을 searchGameName()의 여부에 따라 분리한 이유는 불필요한 조인을 줄이기 위함입니다.

 

gameName은 gameResult에서 받아오고 있습니다. 이 경우 playerResult와 gameResult 간의 조인 연산과 where 연산이 필요합니다.  하지만, 이긴 게임 혹은 진 게임을 조회하는 경우는 직접적인 game_result에 대한 접근이 필요하지 않습니다. 왜냐하면, player_result 테이블은 game_result 테이블에 대한 외래키 제약조건이 걸려있습니다. 즉, 외래키가 존재하기 때문에 player_result는 game_result가 존재하는 한 유효한 row를 가질 수 있습니다.

 

카운트 쿼리는 실제 값의 여부가 아닌 페이징을 위한 row의 개수가 중요하므로, 불필요한 조인을 줄일 수 있는 상황이라면 줄이는 것이 성능에 유리하다고 판단하였습니다,

 

private JPAQuery<Long> countMyDetailGameResult(MyDetailGameResultCond cond) {

    if (cond.getSearchGameName() == null) {
        return query
                .select(playerResult.count())
                .from(playerResult)
                .join(playerResult.member, member)
                .where(
                        member.userId.eq(cond.getUserId()),
                        playerWinEq(cond.getViewOnlyWin()),
                        playerLoseEq(cond.getViewOnlyLose())
                );
    }
    else {
        return query
                .select(playerResult.count())
                .from(playerResult)
                .join(playerResult.gameResult, gameResult)
                .join(playerResult.member, member)
                .where(
                        member.userId.eq(cond.getUserId()),
                        gameNameContains(cond.getSearchGameName())
                );
    }
}

 

 

 

4. 성능 분석하기

 

먼저 순수 SQL 실행 계획으로 제가 작성한 쿼리의 효율성을 분석하겠습니다.

count(*) 개수는 '0003c0c8-4af1-4bd0-bdb8-62ceb8308fa5'라는 userId를 가지고 있는 더미 회원의 개수 player_result 개수는 84개입니다.

 

 

 

explain
select sql_no_cache * from player_result p
    join game_result gr on p.game_result_id = gr.game_result_id
    join member m on m.member_id = p.member_id
    join topic t on gr.topic_id = t.topic_id
    where m.user_id = '0003c0c8-4af1-4bd0-bdb8-62ceb8308fa5';

 

 

 

실행 계획을 살펴보면, index, fk, pk, pk로 해당 조건이 수행되어 연산이 계획되어 있습니다. 타입을 확인하면 ref , eq_ref로 인덱스 혹은 fk와 pk를 사용할 때 적용되는 결과가 작성되어 있습니다.

 

성능은 63ms로 기록되었습니다.

 

다음은 최신 순 조회입니다.

 

explain
select sql_no_cache * from player_result p
    join game_result gr on p.game_result_id = gr.game_result_id
    join member m on m.member_id = p.member_id
    join topic t on gr.topic_id = t.topic_id
    where m.user_id = '0003c0c8-4af1-4bd0-bdb8-62ceb8308fa5' order by gr.created_at desc;

 

 

여기서 한 가지 의문이 발생한 점이 있습니다. 분명 game_result는 created_at으로 인덱스를 생성했는데 order by 문에서 인덱스가 설정되지 않은 점입니다. 역시 데이터베이스는 정말 어려운 것 같습니다...!

(이 부분은 추후 다른 데이터를 토대로 다른 글을 작성하도록 하겠습니다.)

 

 

성능은 76ms가 나왔습니다.

 

동적 쿼리 부분에서 많은 문제를 야기할 수 있는 Like연산을 살펴보겠습니다.

 

like 연산은 많은 부하를 가할 수 있는 기능입니다. keyword%의 경우 인덱스를 활용할 수 있다는 장점이 있지만 와일드카드가 앞단에 위치하는 경우 인덱스를 적용할 수 없습니다. 현재의 경우 userId와 각각의 pk로 필터링되는 개수가 많으므로 적은 데이터가 남았지만, 만약 게임 이름으로 검색하는 비즈니스 로직이 추가된다면 많은 성능적 이슈를 발생시킬 수 있습니다.

 

이를 해결하는 방법으로  역 인덱스, 혹은 n-gram 인덱스 등을 활용할 수 있습니다.

역 인덱스는 입력되는 문자열을 기반으로 인덱스를 저장하는 검색 엔진입니다. 형태소 분석기 등과 함께 사용할 경우, 띄어쓰기 기준 혹은 특정 문자열 기준으로 파싱 한 문자열을 인덱스로 저장할 수 있습니다. 이 경우 검색 조건에서 like '%안녕%' 과 같은 데이터가 입력될 때 효율적으로 검색할 수 있습니다.

 

n-gram 인덱스는 '안녕하세요'와 같은 매칭하기 어려운 문자열을 n 단위로 잘라서 인덱스화하는 기법입니다.

'안녕' , '녕하 ', '하세', '세요'와 같이 2 단어로 잘라서 인덱스로 저장하기 때문에 단위별 검색 엔진을 구축할 때 효율적입니다.

 

하지만 꼭 장점만이 있는 것은 아닙니다. 만약 중복되는 이름의 인덱스가 많다면 상황에 따라서는 오히려 최적화가 되지 않을 수도 있습니다.

 

 

 

5. n_gram 인덱스 적용하여 성능 비교하기 

 

저는, 추후 생길 수 있는 게임 검색 조건등을 고려하여, 2-gram 인덱스 적용을 고려해 보았습니다.

 

FullText 등록

 

카디널리티 98678

 

 

다음과 같이 'ㄱㄱ'이 있는 game_name을 검색할 수 있습니다.

 

 

이제 쿼리로 like 연산과 math 연산 간 유의미한 성능 차이가 있는지 파악해 보겠습니다.

 

explain
select sql_no_cache * from player_result p
    join game_result gr on p.game_result_id = gr.game_result_id
    join member m on m.member_id = p.member_id
    join topic t on gr.topic_id = t.topic_id
    where m.user_id = '0003c0c8-4af1-4bd0-bdb8-62ceb8308fa5' and
          gr.game_name like '%ㄱㄱ%';

 

 

 

 

explain
select sql_no_cache * from player_result p
    join game_result gr on p.game_result_id = gr.game_result_id
    join member m on m.member_id = p.member_id
    join topic t on gr.topic_id = t.topic_id
    where m.user_id = '0003c0c8-4af1-4bd0-bdb8-62ceb8308fa5' and
            match (gr.game_name) against ('ㄱㄱ' in boolean mode);

 

 

 

오히려 이 경우 n_gram 인덱스를 사용했을 때, 2초 945로 성능이 더 하락한 것을 볼 수 있습니다.

 

실제, 실행계획을 살펴보면 eq_ref로 game_result 테이블에 대한 pk로 연산이 수행되는 것이 아니라 full_text 인덱스로 검색이 수행되고 있습니다. 즉 player_result의 game_result_id의 fk로 game_result의 pk를 찾아서 먼저 매칭한 후 개수를 줄인 것이 아니라, full text로 'ㄱㄱ'를 검색 후 pk를 찾아서 매칭한 결과입니다.

 

만약 ngram_token이 서로 다른 경우에는 이러한 검색이 효과적일 수 있지만, 중복 값이 많은 경우에는 성능이 더 하락하였습니다.

따라서, n_gram을 적용한다고 해서 성능 향상을 할 수 있었던 것은 아니었습니다. "게임 이름으로 검색"만 수행한다면 유의미한 결과를 도출할 수 있지만 조인이 복잡하게 연결된 상태에서는 성능 보장이 어려웠습니다. 

 

 

 

6. QueryDsl 실행 쿼리 확인하고 성능 파악하기

 

이제 spring QueryDsl로 작성된 코드에서 test 환경으로 변경 후, 실제 쿼리의 동작을 확인하겠습니다.

먼저 like연산이 없는 최신순 조회 로직입니다.

 

@Test
@DisplayName("fetchMyDetailGameResult의 onlyLastViews를 테스트 한다.")
public void fetchMyDetailGameResult_onlyLastViews() throws Exception {
    //given
    Pageable page = PageRequest.of(0, 10);

    //when
    MyDetailGameResultCond cond = new MyDetailGameResultCond(DEV_USER_ID, true, null,
            null, null);

    long before = System.currentTimeMillis();
    Slice<MyDetailGameResultDto> myDetailGameResultDtos =
            myDetailGameResultQueryDslRepository.fetchMyDetailGameResult(cond, page);
    long after = System.currentTimeMillis();

    //then
    assertThat(myDetailGameResultDtos.getContent().get(0)).isNotNull();
    System.out.println("Total Query Time = " + (after - before));
}

 

쿼리가 정상적으로 의도한 결과대로 작성되었습니다. 

 

 

성능도 springBootTest를 기동하고 기타 작업이 수행된 과정으로 인해 741ms가 나왔지만 n_gram_idx를 사용했을 때보다 훨씬 빠른 성능을 보였습니다. 실제 커넥션 얻은 후 쿼리 작동 결과는 279ms입니다. 카운트 쿼리까지 작동이 되었음에도 279ms가 기록된 것은 괜찮은 성능이 유지된 것을 확인할 수 있습니다.

 

 

콘솔에서 확인하면, 84개의 행과 최신순 조회가 올바르게 설정된 것을 확인할 수 있었습니다. 

 

 

다음은 like 연산에 대한 Spring QueryDsl의 결과입니다.

 

@Test
@DisplayName("fetchMyDetailGameResult의 searchGameName을 테스트 한다.")
public void fetchMyDetailGameResult_searchGameName() throws Exception {
    //given
    Pageable page = PageRequest.of(0, 10);

    //when
    MyDetailGameResultCond cond = new MyDetailGameResultCond(DEV_USER_ID, null, null,
            null, "ㄱㄱ");

    long before = System.currentTimeMillis();
    Slice<MyDetailGameResultDto> myDetailGameResultDtos = myDetailGameResultQueryDslRepository
            .fetchMyDetailGameResult(cond, page);
    long after = System.currentTimeMillis();

    //then
    assertThat(myDetailGameResultDtos.getContent().get(0)).isNotNull();
    System.out.println("Total Query Time = " + (after - before));
}

 

성능이 338ms로 2s 보다는 더 빠른 성능이 유지되었습니다.

 

그리고, 앞서 카운트 쿼리에서 game_name가 cond에 설정되어 있는지 유무에 따라 다른 페이징 쿼리를 작성하였었습니다.

이게 유의미한 결과를 가져올 수 있는지 두 쿼리 또한 테스트를 진행해 보았습니다.

 

return query
        .select(playerResult.count())
        .from(playerResult)
        .join(playerResult.member, member)
        .join(playerResult.gameResult, gameResult)
        .where(
                member.userId.eq(cond.getUserId()),
                gameNameContains(cond.getSearchGameName()),
                playerWinEq(cond.getViewOnlyWin()),
                playerLoseEq(cond.getViewOnlyLose())
        );

 

< 조인문 구분 페이징 쿼리 >

 

@Test
@DisplayName("fetchMyDetailGameResult의 onlyLose을 테스트 한다.")
public void fetchMyDetailGameResult_onlyLose() throws Exception {
    //given
    Pageable page = PageRequest.of(0, 10);

    //when
    long before = System.currentTimeMillis();
    MyDetailGameResultCond cond = new MyDetailGameResultCond(DEV_USER_ID, null, null,
            true, null);
    Slice<MyDetailGameResultDto> myDetailGameResultDtos = myDetailGameResultQueryDslRepository.fetchMyDetailGameResult(cond, page);
    long after = System.currentTimeMillis();

    //then
    assertThat(myDetailGameResultDtos.getContent().get(0)).isNotNull();
    System.out.println("Total Query Time = " + (after - before) + "ms");

}

 

분기문 실행

 

분기문 미실행

 

약 100ms 차이가 있었지만 사실 다이나믹한 차이는 보이지 않아서 머쓱했습니다.. ㅎ!! 결과는 상황에 따라 바뀌는데 이 부분은 추후 다시 더미 대용량 데이터를 확보하여 재 테스트 해보겠습니다.!

 

 

 

+ 2023 11/1 추가 !

해당 테스트는 기본적으로 "원하는 검색어%" 형태의 결과를 체크하였습니다.

만약 "%원하는 검색어%"를 처리한다면, 다른 결과가 도출될 수 있습니다.

 

 

 

7. 정리하며...

 

평소에 스프링과 데이터베이스를 다루는 것을 좋아하기 때문에, 이렇게 QueryDsl로 작성된 코드의 실행 계획을 분석하는 것은 정말 즐거운 시간인 것 같습니다.

 

쿼리 실행 계획을 분석하며 제가 했던 계획이 올바르게 실행되지 않은 점도 확인할 수 있었습니다. created_at의 경우 인덱스로 설정하였지만, 인덱스가 활용되지 않은 이유가 쿼리의 문제인지 혹은 데이터 개수 타입, 분포 등의 문제인지 확실하지가 않아서 이 부분은 추후 다시 공부를 진행해야 할 것 같습니다.

 

또한, 언제든지 데이터의 특성에 따라 쿼리 실행 계획 변경은 불가피할 수 있습니다. 꾸준히 계속 테스트하며 상황에 맞는 최적의 쿼리가 설정될 수 있도록 공부하도록 하겠습니다.

 

아직 고려할 사항이 너무나도 많이 남아있습니다.

 

@Override
public void saveAllResultOfGame(SaveResultDto dto) {
    GameResult gameResult = savePolicy.saveGameResult(dto);
    dto.getPlayersInfo()
            .stream()
            .forEach(playerDto -> {
                Long exp = calculateExp(gameResult, playerDto);
                savePolicy.updatePlayer(gameResult, savePolicy.getPlayer(playerDto), playerDto.getGameRole(), exp);
                savePolicy.savePlayerResult(gameResult.getId(), playerDto, exp);
            });
}

 

"ERD에 인덱스로 설정된 gameId의 경우 SQS로 받을 때, uuid로 설정된 gameId를 그대로 uuid로 받아야 할까? atomicLong으로 변환 후 정수로 클러스터드 인덱스를 설정하면 추후 발생할 수 있는 검색 조건에 더 좋은 쿼리 향상을 할 수 있지 않을까?" 등입니다.

 

다음 편에서는 더욱 어려운 QueryDsl 쿼리를 작성하고 성능 분석하는 시간을 가지도록 하겠습니다.

 

 

부족하지만 오늘도 읽어주셔서 감사드립니다.!

  

안녕하세요 회사와 함께 성장하고 싶은 KOSE입니다.

 

백엔드 개발에서 보안은 너무나 중요한 문제입니다. 계정 도용 문제가 발생하거나, 인증되지 않은 사용자가 권한이 없는 서버에 접근한다면, 큰 문제가 발생할 수 있습니다.

저는 개인적으로  개발하는 입장에서 보안 관련한 문제는 가장 피하고 싶은 문제이기도 합니다. 제일 어렵기도 하고 걱정되기도 하고 다루기 부담스러운 주제입니다. 

 

하지만 언제까지 미룰 수만 없듯이 코드를 수정하며 보안적으로 위협이 될 수 있는 부분을 처리하고자 글을 작성하게 되었습니다.

이번 주제는 중복 로그인에 관한 포스팅으로 중복 로그인 요청이 발생했을 때, 어떻게 처리하는 게 효율적인지 고민하고 제가 처리한 방법을 공유하고, 항상 마지막은 로직에 대한 테스트로 마무리하도록 하겠습니다.

 

 

1. 로그인 정책 이해하기

 

현재 저는 개인 프로젝트로 회원 가입 후 로그인을 하면 Jwt를 발급 하는 로직을 구현하고 있습니다.

로그인 시 인증 후 AccessToken, RefreshToken, UserId를 AuthTokenDto에 담아 클라이언트에게 전송하는 방식입니다.

 

Redis에 저장된 토큰 정보가 없다면 발급하고, 값이 있다면 해당 토큰을 클라이언트에게 전송합니다.

로그아웃을 하면, 기존에 있던 토큰이 제거되기에 추후 이전에 발급받은 토큰으로 다른 요청을 수행하게 된다면, 인증 예외를 발생시키는 구조입니다.

 

하지만, 이 코드는 엄청난 보안적 이슈가 있습니다. 만약 중복 로그인 요청이 왔을 때, 여러 세션에서 중복된 로그인이 발생할 수 있습니다. 만약 회원이 도용되었다면, 악의적인 해커가 이용자의 계정 정보를 바탕으로 특정한 활동을 할 수 있습니다.

따라서, 중복 로그인이 발생했을 때, 이전 로그인된 대상을 로그아웃 하되, 중복 로그인으로 인한 로그아웃에 대한 메시지를 전송해야 합니다.

 

물론, 네이버처럼 웹앱 로그인과 웹 로그인, 모바일 웹 로그인 모두 가능해야 하는 상황이 있을 수 있습니다. 하지만 해당 프로젝트는 웹 게임 형태로 구현되기 때문에 동시 로그인은 허용하면 안 되는 문제라고 판단하였습니다.

 

중복 로그인 방지 정책에 반드시 필요하다고 생각한 핵심 비즈니스 로직을 정리한 후, 차근차근 코드를 수정하며 글을 작성하도록 하겠습니다.

 

 

 

2. 공통적인 핵심 비즈니스 로직 정리하기

 

이 프로젝트는 JWT 토큰으로 인증정보를 수행하고 있지만, 로그인한 유저가 여러 명인지 판단할 수 있는 로직이 없으므로 중복 로그인에 대해 구현해야 할 과제를 하나씩 정리하였습니다.

(여기서 "로그인 세션"이라는 단어는 Session 방식의 인증이 아니라, 로그인 한 사용자 정보를 저장하기 위한 세션입니다) 

 

a. 중복 로그인인지 판단할 수 있는 로직 추가하기

 

- 이용자가 로그아웃한 이후 로그인을 요청한 것인지, 아니면 로그인 한 유저가 다시 로그인 요청한 것인지 판단하는 로직을 추가할 필요성이 있습니다.

- 로그인 요청이 오면 로그인 정보를 저장하여 중복 로그인을 체크할 수 있도록 하는 과정을 추가해야 합니다.

 

 

b. 중복 로그인 시 이전 로그인 정보 제거하기

 

- 중복 로그인이 발생한다면, 이전 로그인 정보를 제거해야 합니다. 

- 이전의 로그인 세션과 저장된 accessToken, refreshToken, index 종류를 전부 제거합니다.

- 새로 로그인한 세션을 등록하고 새로운 인증 토큰을 발급합니다.

 

 

c. 중복 로그인으로 인해 로그 아웃된 사용자를 위한 메시지 전송

 

- 만약 정상적인 이용자가 중복 로그인으로 인해 로그아웃된 경우, 어떠한 이유로 로그아웃이 되었는지 판단할 수 있어야 합니다.

하지만, b 번으로 이전 로그인 세션만 제거한다면, 해당 사용자는 유효한 토큰이 아니므로 인증 예외가 발생합니다.

따라서, 중복 로그인으로 인해 로그아웃된 유저가 현재 유효하지 않은 토큰을 가지고 다른 요청을 수행했을 때, 중복 로그인을 알릴 수 있도록 해야 합니다.

- 이에 대한 정책으로, LogoutToken 종류를 생성하여 Redis에 저장할 수 있도록 구현하여 LogoutToken 종류로 인증 요청을 수행하면 DoubleLoginxception을 발생시킵니다.

 

 

d. 동일 아이피로 반복적인 로그인 요청을 수행하는 경우

 

- 계정 도용이 아니라더라도, 동일 ip에서 반복적인 로그인 요청을 수행하는 경우 서버에 많은 부담을 줄 수 있습니다. 이 경우 동일 아이피에 대한 반복적인 요청을 체크하여 마치 크롤링시 발생하는 제한처럼 클라이언트 요청 중지 작업을 수행해야 합니다.

- 이를 구현하기 위해 Controller의 Filter에서 IP 정보를 수집하여 처리하는 과정이 필요했습니다.

 

 

 

3. 중복 로그인 여부 판단할 수 있는 로그인세션  추가하기 

 

로그인 세션은 현재 로그인한 유저의 정보를 저장하는 역할을 수행하고, 중복 로그인을 방지해야 할 책임이 있습니다.

LoginSession 클래스에 userId, remoteAddr, createdAt, expiration, loginStatus를 필드로 설정하여 객체를 생성하였습니다.

 

@Getter
@NoArgsConstructor
public class LoginSession {

    private String userId;
    private String remoteAddr;
    private LoginStatus loginStatus = ON;

    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime createdAt;

    @TimeToLive(unit = TimeUnit.MILLISECONDS)
    private Long expiration;

    public LoginSession(String userId, String remoteAddr, Long expiration) {
        this.userId = userId;
        this.remoteAddr = remoteAddr;
        this.createdAt = LocalDateTime.now();
        this.expiration = expiration;
    }

    public static LoginSession of(String userId, String remoteAddr, Long expiration) {
        return new LoginSession(userId, remoteAddr, expiration);
    }
}

 

LocalDateTime은 redis에 저장될 때 Jackson 라이브러리가 인식하지 못하는 에러가 발생할 수 있으므로 @JsonFormat 어노테이션을 설정하여야 합니다.

ObjectMapper를 스프링 빈으로 등록하는 Config에서 registerModule을 추가합니다.

objectMapper.registerModule(new JavaTimeModule());

 

LoginSessionRepositry의 역할은 LoginSession 정보를 Redis에 저장해야 하므로 CRUD 기능을 추가하였습니다.

 

@Slf4j
@Repository
@RequiredArgsConstructor
public class LoginSessionRepositoryImpl implements LoginSessionRepository {

    private final RedisTemplate<String, Object> redisTemplate;
    private final ObjectMapper objectMapper;

    private final static String LOGIN_SESSION = "LoginSession:";

    @Override
    public void saveLoginSession(LoginSession loginSession) throws JsonProcessingException {
        String key = getLoginSessionKey(loginSession.getUserId());

        redisTemplate.opsForValue().set(key,
                objectMapper.writeValueAsString(loginSession));
        redisTemplate.expire(key, loginSession.getExpiration(), TimeUnit.MILLISECONDS);
    }

    @Override
    public boolean existLoginSession(String userId) {
        return redisTemplate.hasKey(getLoginSessionKey(userId));
    }

    @Override
    public void deleteLoginSession(String userId) {
        if (existLoginSession(userId)) redisTemplate.delete(getLoginSessionKey(userId));
    }

    @Override
    public LoginSession findLoginSessionByUserId(String userId) throws JsonProcessingException {
        if (existLoginSession(userId)) {
            String value = (String) redisTemplate.opsForValue().get(getLoginSessionKey(userId));

            if (value == null) return null;
            return objectMapper.readValue(value, LoginSession.class);
        }
        return null;
    }
    private String getLoginSessionKey(String userId) {
        return LOGIN_SESSION + userId;
    }
}

 

repository는 키를 등록하고, 객체를 json으로 저장하면서 TTL을 등록하는 코드입니다.

앞 서 redisTemplate 트랜젝션 처리하는 글에서 작성했기 때문에 간단하게 정리하면,

redisTemplate.opsForValue()로 키를 등록하고 objectMapper.readValue()로 읽어오는 과정을 수행했습니다.

 

이후 로직에 대한 간단한 테스트로 해당 코드가 정상적으로 작동하는지 테스트하였고 성공하였습니다.!

 

@Test
@DisplayName("saveLoginSession")
public void saveLoginSession() throws Exception {
    //given
    LoginSession loginSession = LoginSession.of(userId, remoteAddr, expiration);

    //when
    loginSessionRepository.saveLoginSession(loginSession);
    LoginSession findLoginSession = loginSessionRepository.findLoginSessionByUserId(userId);

    //then
    assertThat(findLoginSession.getUserId()).isEqualTo(userId);
    assertThat(findLoginSession.getRemoteAddr()).isEqualTo(remoteAddr);
    assertThat(findLoginSession.getExpiration()).isEqualTo(expiration);
    assertThat(findLoginSession.getCreatedAt()).isBefore(LocalDateTime.now());
}

@Test
@DisplayName("deleteLoginSession")
public void deleteLoginSession() throws Exception {
    //given
    LoginSession loginSession = LoginSession.of(userId, remoteAddr, expiration);
    loginSessionRepository.saveLoginSession(loginSession);

    //when
    loginSessionRepository.deleteLoginSession(userId);

    //then
    assertThat(loginSessionRepository.findLoginSessionByUserId(userId)).isNull();
}

 

 

 

4. 로그인세션 저장을 위한 IP 정보 추가하기 

 

SpringMvc에서 HttpServletRequest는 header 정보 이외에 ip, hostName 등을 제공하는 역할을 수행합니다.

String remoteAddr = servletRequest.getRemoteAddr();

 

servletRequest.getRemoteAddr()을 호출하면 ip정보를 얻을 수 있지만, 이 로직에는 간편한 만큼 단점이 존재합니다.

 

네트워크는 다음의 절차에 따라 Tcp(Http) 요청을 수행할 수 있습니다.

a. 특정 데이터를 분할하여 송신 port와 수신 port로 TCP 세그먼트를 생성합니다.

b. IP 계층에서는 Http 프로토콜을 사용하여 송신 ip와 수신 ip를 입력하여 세그먼트를 패킷으로 감쌉니다. 

c. 이더넷 계층에서 프레임으로 감싸며 송신지 맥주소와 수신지의 게이트웨이 맥주소를 입력합니다.

d. ARP와 게이트웨이 라우팅 기능으로 실제 원하는 수신지 ip까지 도달하게 된다면 송신지의 ip정보를 확인할 수 있습니다.

 

저의 프로젝트에서는 client ->  gateway server -> member server 순서로 요청이 위임되기 때문에, 송신지 ip가 바뀔 수 있습니다. 즉 전달되는 데이터는 같을지라도 ip에 대한 정보가 바뀌므로 이를 인지할 수 있도록 헤더를 추가하는 과정이 필요했습니다.

 

이때, 사용되는 헤더는 Fowarded와 X-Forwarded-For 입니다.
RFC 7239 표준에 의하면, Forwarded 방식이 표준으로 정의되어 있습니다. 

 a. 표준화
- Forwarded: 2014년에 발표된 RFC 7239에 정의된 공식 표준입니다.
- X-Forwarded-For: 비공식 확장 헤더이지만 널리 사용되고 있는 헤더입니다.

b. 구조 및 포맷
- Forwarded: 이 헤더는 구조화된  포맷을 사용하는데 for, by, proto, host 등을 세미콜론으로 구분합니다.
- X-Forwarded-For: 각 전송과정에서 사용된 IP 목록을 쉼표로 구분합니다.

 

현재 이 프로젝트는 로그인 Ip 정보만 요청받는 것이 주목적이고, 추후 SSL/TLS를 적용하더라도 보안상 허점이 존재하므로 보다 많은 정보를 얻는 것은 위험할 수 있다고 판단하였습니다. 따라서 저는 X-Forwarded-For 방식을 사용하기로 결정하였습니다. 

 

(물론, X-forwared-For에서 받은 ip가 실제 client의 ip가 아닐 수 있습니다. 프록시 우회를 사용할 경우 ip가 X-forwared-For 헤더에 실제 ip주소를 담는다 하더라도 변조될 가능성이 있기 때문에 클라이언트의 ip라고 확신할 수는 없습니다. 이 부분은 추후 보안해나가겠습니다.)

 

X-Forwarded-For 방식을 사용하면, 최종 백엔드로 도착하기까지 ip에 대한 정보를 X-Forwarded-For 헤더에 아래와 같은 방식으로 담기게 됩니다.

X-Forwarded-For: 192.168.0.0.1, 192.168.0.0.2.....

 

이를 파싱 하기 위해 ", "로 문자열을 나눈 후 생성된 배열에서 0번째 값을 받도록 하였습니다.
(반드시 gateway server에서는 X-Forwarded-For에 대한 값을 추가하여야 합니다)

 

@PostMapping("/login")
public ResponseEntity login(@Validated @RequestBody LoginRequest request,
                            @RequestHeader("X-Forwarded-For") String remoteAddr) throws JsonProcessingException {

    AuthTokenDto authToken = facadeService
            .login(RequestMapperFactory.mapper(request, remoteAddr.split(",")[0].trim()));
    return ResponseEntity.ok().body(SendSuccessBody.of(authToken));
}

 

 

 

5. LogoutSessionAccessToken, LogoutSessionRefreshToken 추가하기

 

@Getter
@NoArgsConstructor
public class LogoutSessionAccessToken extends Token implements Serializable {

    private LogoutSessionAccessToken(String id, String userId, long expiration) {
        super(id, userId, expiration);
    }
    public static LogoutSessionAccessToken of (String logoutSessionAccessToken, String userId, Long expiration) {
        return new LogoutSessionAccessToken(logoutSessionAccessToken, userId, expiration);
    }
}

 

@Getter
@NoArgsConstructor
public class LogoutSessionRefreshToken extends Token implements Serializable {
    
    private LogoutSessionRefreshToken(String id, String userId, long expiration) {
        super(id, userId, expiration);
    }
    public static LogoutSessionRefreshToken of (String logoutSessionRefreshToken, String userId, Long expiration) {
        return new LogoutSessionRefreshToken(logoutSessionRefreshToken, userId, expiration);
    }

}

 

두 토큰의 역할은 로그아웃된 토큰으로 요청이 오면, 중복 로그인으로 로그아웃되었다는 메시지를 발급해주어야 합니다.

따라서 토큰을 Redis에 저장하기 위한 redisTemplate 로직을 작성하고 Service에서 두 토큰 중 어느 하나라도 사용될 시 예외를 발생시키는 로직이 수행되어야 합니다.

 

LogoutSessionAccessToken과 LogoutSessionRefreshToken은 모두 Token을 상속하고 있기 때문에 토큰 저장에 사용한 TokenRepository를 그대로 사용할 수 있었습니다.

 

<TokenRepository.java>

 

@Repository
public interface TokenRepository<T extends Token> {

    void saveToken(String key, T t) throws JsonProcessingException;

    T findTokenByKey(String key, Class<T> clazz) throws JsonProcessingException;

    void saveTokenIdx(String key, T t) throws JsonProcessingException;

    String findTokenIdxValue(String key, Class<T> clazz) throws JsonProcessingException;

    T findTokenByIdx(String key, Class<T> clazz) throws JsonProcessingException;

    void deleteToken(String key, Class<T> clazz) throws JsonProcessingException;

    void deleteTokenIdx(String key, Class<T> clazz) throws JsonProcessingException;

}

 

이후 TokenPolicy에 LogoutSessionTokens라면, 예외를 발생시키는 메서드를 추가하였습니다.

 

public void throwIfLogoutSessionTokens(String accessToken, String refreshToken, String userId)
        throws JsonProcessingException {

    Token logoutAccessToken = tokenRepository.findTokenByKey(accessToken, LogoutSessionAccessToken.class);
    Token logoutRefreshToken = tokenRepository.findTokenByKey(refreshToken, LogoutSessionRefreshToken.class);

    if ((logoutAccessToken != null && logoutAccessToken.getUserId().equals(userId)) ||
            (logoutRefreshToken != null && logoutRefreshToken.getUserId().equals(userId)))
        throw new DoubleLoginException();
}

 

 

 

6. 서비스 로직 작성하기

 

LoginSessionPolicy는 로그인 세션을 어떻게 활용할 것인가에 대한 비즈니스 로직을 작성하였습니다.

앞 서 정리한 계획대로 로그인 세션을 등록하고 중복 로그인이라면 저장된 세션정보와 인증 토큰을 전부 제거합니다. 

그리고 새로운 이용자의 세션 정보와 인증 토큰을 저장합니다.

 

여기서 로그인 세션 등록과 토큰 발급은 객체 지향적으로 분리하는 게 맞는지 아니면 하나의 로직으로 수행해야 하는지 트레이드오프가 발생하였습니다.  이 과정은 단일 트랜젝션 내에서 처리되어야 하므로 코드상 아쉬움이 남지만, TokenPolicy를 의존성 주입받은 LoginSessionPolicy에서 처리하는 것으로 결정하였습니다.

 

@Slf4j
@Service
@RequiredArgsConstructor
public class LoginSessionPolicyImpl implements LoginSessionPolicy {

    private final LoginSessionRepository loginSessionRepository;
    private final TokenPolicy tokenPolicy;

    @Value("${jwt.access-expiration-time}")
    private Long expiration;

    @Override
    public AuthTokenDto loginNewSession(Member member, List<Authority> authorities, String remoteAddr)
            throws JsonProcessingException {

        if (isDoubleLogin(member.getUserId())) {
            logoutSession(member.getUserId());
            tokenPolicy.saveLogoutTokensAndDeleteSavedTokens(member.getUserId());
        }
        loginSession(member.getUserId(), remoteAddr);

        return tokenPolicy.createAuthToken(member.getUserId(), authorities);
    }

    private boolean isDoubleLogin(String userId) {
        return loginSessionRepository.existLoginSession(userId);
    }

    private void loginSession(String userId, String remoteAddr) throws JsonProcessingException {
        loginSessionRepository.saveLoginSession(LoginSession
                .of(userId, remoteAddr, expiration + new Date().getTime()));
    }

    private void logoutSession(String userId) {
        loginSessionRepository.deleteLoginSession(userId);
    }
}

 

<FacadeSerivce.java>

public AuthTokenDto login(LoginFacadeRequest request) throws JsonProcessingException {
    Member member = memberPolicy.findMemberByEmailOrThrow(RequestMapperFactory.mapper(request));
    List<Authority> authorities = memberPolicy.findAuthorityByUserOrThrow(member);

    return loginSessionPolicy.loginNewSession(member, authorities, request.getRemoteAddr());
}

 

faceService는 부가 로직 수행 후, loginSessioPolicy.loginNewSession을 호출하고, AuthTokenDto를 리턴합니다.

 

 

 

7. 서비스 로직 코드 테스트 하기

 

차례대로 LogoutToken 관련 서비스, LoginSessionPolicy, FacadeService 순서로 테스트를 진행하였습니다.

일반적인 순차적 테스트에서는 테스트 성공하였고, 하단은 동시성 테스트 코드입니다.

 

고려해야 할 사항이 많으므로 Assertions 코드가 지저분하지만 확실하게 검증해야 하는 부분이므로 전부 작성하였습니다.

먼저 같은 ip로 몇 초 이내에 반복 요청을 보낸다면 필터가 예외를 발생시키므로 컨트롤러에서 검증을 진행할 수 있습니다.

 

하지만, 만약 예기치 않는 문제가 발생할 수도 있으므로 비즈니스 로직에서도 동시성 문제를 테스트하였습니다.

검증해야 하는 로직은, 마지막(물리적으로 마지막)에 수행되는 익명의 스레드만 로그인 세션을 유지하고 유효한 인증 토큰으로 저장합니다. 물리적으로 이전에 저장된 토큰은 모두 로그아웃된 토큰으로 저장해야 합니다.

 

/**
 * 동시에 로그인 요청이 올 때 (필터에서 처리되지 않는 제약 사항을 넘어서 요청이 수행되었다고 가정)
 * 같은 email, password, 다른 remoteAddr
 * 마지막에 실행되는 익명의 스레드의 로그인 세션만 유지하고 다른 스레드의 로그인 세션은 제거한다.
 * 유효 한인증 토큰은 하나만 유지되고 나머지 토큰은 모두 LogoutSessionToken으로 저장된다.
 */
@Test
@DisplayName("로그인이 여러 번 발생하는 문제에 대해 동시성을 체크한다.")
public void loginNewSession_success_beforeLoginSessionExist_mt() throws Exception {
    //given
    ExecutorService executorService = Executors.newFixedThreadPool(32);
    CountDownLatch latch = new CountDownLatch(count);
    AuthTokenDto[] authTokens = new AuthTokenDto[count];

    //when
    for (int i = 0; i < count; i++) {
        int fIdx = i;
        executorService.submit(() -> {
            try {
                authTokens[fIdx] = loginSessionPolicy
                        .loginNewSession(member, authorities, "127.0.0." + fIdx);
            }
            catch (Exception e) {
                e.printStackTrace();
            }
            finally {
                latch.countDown();
            }
        });
    }
    latch.await();

    LoginSession nowLoginSession = loginSessionPolicy.findLoginSessionByUserId(member.getUserId());

    //then
    int validRemoteAddr = parseInt(nowLoginSession.getRemoteAddr().split("\\.")[3]);
    System.out.println("validRemoteAddr = " + validRemoteAddr);

    for (int i = 0; i < count; i++) {
        if (i != validRemoteAddr) {
            assertThat(tokenRepository
                    .findTokenByKey(authTokens[i].getAccessToken(), AccessToken.class)).isNull();
            assertThat(tokenRepository
                    .findTokenByKey(authTokens[i].getRefreshToken(), RefreshToken.class)).isNull();
            assertThat(tokenRepository
                    .findTokenByKey(authTokens[i].getAccessToken(), LogoutSessionAccessToken.class)).isNotNull();
            assertThat(tokenRepository
                    .findTokenByKey(authTokens[i].getRefreshToken(), LogoutSessionRefreshToken.class)).isNotNull();
        }

        else {
            Token accessToken = tokenRepository.findTokenByIdx(member.getUserId(), AccessToken.class);
            Token refreshToken = tokenRepository.findTokenByIdx(member.getUserId(), RefreshToken.class);

            assertThat(tokenRepository.findTokenByKey(accessToken.getId(), AccessToken.class)).isNotNull();
            assertThat(tokenRepository.findTokenByKey(refreshToken.getId(), RefreshToken.class)).isNotNull();
        }
    }

}

 

비즈니스 로직에는  문제가 없었지만,  실제 토큰을 발급하는 유저아이디와 발급시간이 동일하면 동일한 키가 생성되는 문제가 발생하였습니다. 즉, 이전 요청에 대한 토큰을 제거하고 로그아웃 토큰을 생성하고 새로운 요청에 대한 토큰이 생성될 때 동일한 시간으로 인해 토큰이 겹치게 된 것입니다.

 

 

이 문제를 극복하기 위해 토큰 발급 시간을 기존에 정해둔 이외에 추가로 0 ~ 10만 사이의 숫자를 발급할 수 있도록 설정하였습니다.

 

@Override
public String createToken(String userId, List<Authority> authorities, long tokenTime) {

    Claims claims = Jwts.claims().setSubject(userId);
    claims.put(AUTHORITIES_KEY, getRoles(authorities));

    return Jwts.builder()
            .setClaims(claims)
            .setIssuedAt(new Date())
            .setExpiration(new Date(System.currentTimeMillis() + tokenTime + new Random().nextInt(100000))) 
            .signWith(key, SignatureAlgorithm.HS512)
            .compact();
}

 

그 결과 정상적으로 중복 로그인 세션 및 토큰이 될 때, 토큰이 겹치는 문제를 해결할 수 있었습니다.

10만으로 설정한 이유는 토큰 유효 시간이 AccessToken, RefreshToken이 각각 3일, 7일입니다.
100000 / 1000  수행하면 100초로 실제 정의한 목표 토큰 유효시간에 100초 미만 차이를 주면서 유효한 랜덤 발생을 지킬 수 있다고 생각하였습니다. 

 

 

 

마지막 LoginSessionPolicy를 의존 관계 주입받는 FacadeService에서도 테스트를 진행하였고 성공하였습니다.

 

 

 

8. LoginSessionFilter 등록하기

 

이제 거의 마지막 단계입니다.! 실제 요청을 수행할 때 같은 ip 혹은 같은 email로 여러 번 요청하는 로그인에 대해 필터에서 요청을 차단하는 로직을 작성해야 했습니다.  인터셉터가 아닌 필터를 선택한 이유는, Http 요청에 대해 앞단에서 filter가 수행되기 때문에 Controller를 거치지 않고 차단을 수행할 수 있기 때문입니다.

 

필터에서 고려해야 할 사항은 서버의 성능을 고려하여 최대 얼마의 시간 동안 몇 번의 요청을 차단할 것인지 문제입니다. 

또한, 헤더 정보만 파싱 할 것인지 아니면 body를 복사하여 inputStream을 만든 후 다시 body를 생성해서 보내는 작업을 수행할지에 대한 선택이 필요했습니다.

 

먼저, 헤더 정보만 파싱 하면 10초 동안 같은 ip에서 3번의 요청이 오면 차단하는 기능을 생각하였습니다. ip는 헤더 정보로 필터에서 비교적 간단하게 차단 로직을 수행할 수 있습니다.

 

반면 이메일로 중복 요청을 검사하는 로직은 많은 입출력 비용이  발생합니다. HttpServletRequest의 request는 Consumable로 작동합니다. 즉, 한 번 값을 읽으면 body의 스트림이 소비되므로 filter에서 이메일 중복 요청을 수행하려면 값을 복사해서 사용한 후 다시 inputStream을 생성하여 controller로 보내야 합니다.

 

현재 가용 가능한 서버의 성능을 생각하면 I/O를 생성하는 것은 서버에 많은 부담을 줄 수 있을 것이라 판단하여 요청 본문 처리를 컨트롤러 혹은 서비스 단에서 수행하도록 하고 filter에서는 헤더 정보만 파싱 하도록 처리하는 것으로 결정했습니다.

 

 

코드는 아래와 같습니다. 하나씩 분석해 보겠습니다.

 

@Slf4j
public class LoginSessionFilter extends OncePerRequestFilter {

    private final ConcurrentHashMap<String, LoginSessionCheck> sessionFilter = new ConcurrentHashMap<>();

    @Qualifier("defaultObjectMapper")
    private final ObjectMapper defaultObjectMapper;

    public LoginSessionFilter(@Qualifier("defaultObjectMapper") ObjectMapper defaultObjectMapper) {
        this.defaultObjectMapper = defaultObjectMapper;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        
        String xForHeader = request.getHeader("X-Forwarded-For");
        if (xForHeader == null || xForHeader.isEmpty()) throw new BadRequestException();

        String remoteAddr = xForHeader.split(",")[0].trim();
        clearCheckStatus();
        LocalDateTime now = LocalDateTime.now();
        LoginSessionCheck addrCheck = sessionFilter.get(remoteAddr);

        if (addrCheck != null) {
            setCheckStatus(remoteAddr, addrCheck, now);
        } else {
            sessionFilter.put(remoteAddr, new LoginSessionCheck(now));
        }

        filterChain.doFilter(request, response);
    }

    private void setCheckStatus(String key, LoginSessionCheck addrCheck, LocalDateTime now) {
        if (addrCheck.isBlock()) {
            if (between(addrCheck.getLast(), now).toSeconds() > 20) { // 차단 해제 조건
                addrCheck.setInit(); 
            } else {
                throw new TooManyRequestException(); // 차단 유지
            }
        }
        
        if (addrCheck.getFirst() == null) { // first가 null 인경우
            addrCheck.setFirst(now);
            addrCheck.setLast(now);
        } 
        
        else {
            if (addrCheck.getCount() > 3) { 
                addrCheck.setBlock(true); 
                throw new TooManyRequestException();
            }
            
            if (between(addrCheck.getLast(), now).toSeconds() > 10) { //마지막 요청 후 10초가 지났다면 메모리 낭비를 제거하기 위해 체크 제거
                sessionFilter.remove(key);
            } else {
                addrCheck.incrementAndGetCount();
                addrCheck.setLast(now);
            }
        }
    }

    private void clearCheckStatus() {
        if (sessionFilter.size() > 300) {
            sessionFilter.entrySet().removeIf(entry -> !entry.getValue().isBlock());
        }
    }
    

    @Setter
    @Getter
    @NoArgsConstructor
    static class LoginSessionCheck {
        private LocalDateTime first;
        private LocalDateTime last;
        private AtomicInteger count = new AtomicInteger(0);
        private volatile boolean block;

        public LoginSessionCheck(LocalDateTime now) {
            this.first = now;
            this.last = now;
        }
        public int incrementAndGetCount() {
            return count.incrementAndGet();
        }
        public int getCount() {
            return count.get();
        }

        public void setInit() {
            this.first = null;
            this.last = null;
            this.count = new AtomicInteger(0);
            this.block = false;
        }
    }
}

 

 

ConcurrentHashMap은 스레드에 안전한 hashMap을 제공하는 기능입니다.

ConcurrentHashMap에서 static class인 LoginSessionCheck 인스턴스를 저장하고 있습니다. 
LoginSessionCheck는 first, last, count, block으로 구성되어 있습니다.

first: 최초 요청 시간

last: 마지막 요청 시간

count: 인스턴스가 유효하게 저장되는 기간 동안 요청 수

block: 특정 클라이언트의 ip에 대한 차단 여부

입니다.

 

앞 서 비즈니스 로직에서 정리한 X-Forwarded-For의 헤더로부터 remoteAddr을 가져옵니다. 만약 이 값이 null이라면 
제가 정의한 BadRequestException()을 발생시켜, ControllerAdvice에서 예외를 처리합니다.

 

String xForHeader = request.getHeader("X-Forwarded-For");
if (xForHeader == null || xForHeader.isEmpty()) throw new BadRequestException();

String remoteAddr = xForHeader.split(",")[0].trim();

clearCheckStatus();
LocalDateTime now = LocalDateTime.now();
LoginSessionCheck addrCheck = sessionFilter.get(remoteAddr);

if (addrCheck != null) {
    setCheckStatus(remoteAddr, addrCheck, now);
} else {
    sessionFilter.put(remoteAddr, new LoginSessionCheck(now));
}

 

이 로직은, setCheckStatus를 적용하여, ip에 대한 유효성을 체크합니다.

 

private void setCheckStatus(String key, LoginSessionCheck addrCheck, LocalDateTime now) {
        if (addrCheck.isBlock()) {
            if (between(addrCheck.getLast(), now).toSeconds() > 20) { // 차단 해제 조건
                addrCheck.setInit();
            } else {
                throw new TooManyRequestException(); // 차단 유지
            }
        }

        if (addrCheck.getFirst() == null) { // first가 null 인경우
            addrCheck.setFirst(now);
            addrCheck.setLast(now);
        }

        else {
            if (addrCheck.getCount() > 3) {
                addrCheck.setBlock(true); 
                throw new TooManyRequestException();
            }

            if (between(addrCheck.getLast(), now).toSeconds() > 10) { //마지막 요청 후 10초가 지났다면 메모리 낭비를 제거하기 위해 체크 제거
                sessionFilter.remove(key);
            } else {
                addrCheck.incrementAndGetCount();
                addrCheck.setLast(now);
            }
        }
    }

 

저는 10초 이내에 요청이 4번 이상 발생하면 addr에 대해 block을 설정하고 TooManyRequestException()을 발생시켰습니다. 그리고 그 이외의 요청에 대해서, 마지막(last) 요청보다 시간이 10초 지난 이후에 다시 요청이 온 경우에는  sessionFilter를 remove 하였습니다. 그 이외의 경우에는 count를 증가시키고, 마지막 요청 시간을 현재로 설정하였습니다.

 

만약, block 된 사용자가 접근할 때, 블락당한 지 20초가 안 지났다면 블락을 유효화하고 TooManyRequestException()을 발생시킵니다. 마지막 요청 기준 20초가 지났다면 블락을 해제하고 값을 초기화합니다.

 

중간에 있는 clearCheckStatus()는  key가 300개 이상 초과하면, block 되지 않은 entry를 제거하여 메모리 관리할 수 있도록 하였습니다.

 

private void clearCheckStatus() {
    if (sessionFilter.size() > 300) {
        sessionFilter.entrySet().removeIf(entry -> !entry.getValue().isBlock());
    }
}

 

이 코드를 사용하기 위해 Configuration에서는 필터를 등록하여야 합니다. 필터를 FilterRegistrationBean<LoginSessionFilter>로 빈을 등록하는 이유는 생성자 주입에 필요한 objectMapper의 Qualifier를 명시하여, 여러 개가 bean으로 등록되어 있는 objectMapper에서 defaultObjectMapper를 선택할 수 있도록 하였습니다.

 

또한, FilterRegistrationBean을 활용하면 특정 uri에 매핑되어야 하는 필터를 선정할 때 효율적입니다.

 

@Configuration
public class FilterRegisterConfig {

    @Qualifier("defaultObjectMapper")
    private final ObjectMapper objectMapper;

    public FilterRegisterConfig(@Qualifier("defaultObjectMapper") ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }
    
    @Bean
    public FilterRegistrationBean<LoginSessionFilter> loginSessionFilter(@Qualifier("defaultObjectMapper") ObjectMapper objectMapper) {
        FilterRegistrationBean<LoginSessionFilter> registrationBean = new FilterRegistrationBean<>();

        registrationBean.setFilter(new LoginSessionFilter(objectMapper));
        registrationBean.addUrlPatterns("/member-service/login/**");

        return registrationBean;
    }

}

 

만약 필터 내부에 uri패턴을 매칭시키려면  PatternMatchUtils를 사용해야 합니다. 

하지만 이 경우 비즈니스로직에 uri 패턴 매칭 코드로 if - else를 추가해야 하므로 복잡성이 가해질 수 있습니다. 

 

 

    private boolean isLoginCheckPath(String requestURI) {
        return PatternMatchUtils.simpleMatch(blackList, requestURI);
    }

 

이제, SecurityConfig의 filterChain에 LoginSessionFilter를 등록하도록 하겠습니다.

 

private final FilterRegistrationBean<LoginSessionFilter> loginSessionFilter;

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

    http
            --- 중략 ---
            
            .addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
            .addFilterBefore(authenticationGiveFilter(), UsernamePasswordAuthenticationFilter.class)
            .addFilterBefore(loginSessionFilter.getFilter(), UsernamePasswordAuthenticationFilter.class)
            .httpBasic().disable()
            
            --- 중략 ---
            
            return http.build();
}

 

먼저, CorsFilter -> AuthenticationGiveFilter -> LoginSessionFilter -> UsernamePasswordAuthenticationFilter (spring)

순으로 필터가 적용될 수 있도록 filterChain에서 등록하여야 필터가 정상적으로 역할을 수행할 수 있습니다.

filterRegistrationBean을 의존 관계 주입을 받으면 설정한 필터를. getFilter로 호출할 수 있습니다.

 

 

 

9. LoginSessionFilter 검증

 

코드의 마무리이자 코드의 꽃은 테스트인 것 같습니다. 항상 겁이 나고 피하고 싶은 부분이지만, 코드가 검증되어야 
믿을 수 있는 코드이므로 검증을 진행하겠습니다.

 

Filter는 테스트하기 난감한 부분이 있습니다. 어떻게 HttpServletRequest를 설정해야 하는지부터 막막할 수 있습니다.

위대한 스프링은 이 부분까지 모두 고려하여 MockMvc, RestTemplate를 사용할 수 있습니다.

 

저는 MockMvc를 적용하여 테스트를 진행하였습니다.

필요한 의존성을 주입받고, filter를 등록해야하므로 mockMvc에 addFilter()를 설정합니다.

 

@SpringBootTest
class LoginSessionFilterTest {


    @Autowired FacadeService facadeService;
    @Autowired MemberRepository memberRepository;
    @Autowired RedisTemplate redisTemplate;
    @Autowired FilterRegistrationBean<LoginSessionFilter> loginSessionFilter;
    MockMvc mockMvc;

    String email = "kose@naver.com";
    String password = "abcdefg123456";
    String username = "gosekose";
    String remoteAddr = "127.0.0.1";

    @BeforeEach
    public void init() {
        mockMvc = MockMvcBuilders
                .standaloneSetup(new AuthController(facadeService))
                .addFilter(loginSessionFilter.getFilter())
                .build();
    }

    @AfterEach
    public void tearDown() {
        redisTemplate.delete(redisTemplate.keys("*"));
    }

    @Test
    @DisplayName("정상 요청에 대해 1회 실행 테스트를 진행")
    public void doNormalRequest() throws Exception {
        //given

        facadeService.register(FormRegisterRequest.builder()
                .email(email)
                .password(password)
                .username(username)
                .build());

        //when

        ResultActions actions = mockMvc.perform(post("/member-service/login")
                .contentType(MediaType.APPLICATION_JSON)
                .characterEncoding("UTF-8")
                .accept(MediaType.APPLICATION_JSON)
                .content(new Gson().toJson(new LoginRequest(email, password)))
                .header("X-Forwarded-For", remoteAddr));

        //then
        actions
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.code").value("200"))
                .andExpect(jsonPath("$.message").value("성공"))
                .andExpect(jsonPath("$.body.accessToken").isNotEmpty())
                .andExpect(jsonPath("$.body.refreshToken").isNotEmpty())
                .andExpect(jsonPath("$.body.userId").isNotEmpty());
    }

}

 

먼저 첫번째 테스트에서는 필터가 정상 작동하는 것을 확인할 수 있습니다.

 

이제 필터가 예외를 발생시켰을 때 블락이 되는 코드를 테스트하면 다음과 같습니다.

 

사실 제가 작성한 필터는 명확하게 4번부터 블락이 되어야 한다라는 조건을 수행하기 어렵습니다.

모든 요청에 대해 동기화와 동시성을 충족하면 좋지만, 여러 가지 동시성 및 동기화를 제어하기 위한 부가 기능을 필터에 추가하려면 서버에 많은 부담이 될 수밖에 없습니다.

 

따라서, 필터는 인증처리와 같은 개별적인 인증이 되어야 하는 로직이 아닌, 서로 다른 요청 간 데이터 동기화가 되어야 하는 필터는 반드시 모든 요청에 동기화가 필요하다고 생각하지 않았습니다.

즉 핵심 비즈니스 로직을 수행해야 하는 controller나 service에서 남아 있는 보안적 이슈를 해결하도록 한 번 더 검증하고 필터는 검증 가능한 수준에서 필터링하고 빠르게 컨트롤러로 넘겨주어야 한다고 생각했습니다.

 

따라서, try - catch로 잡은 후 예외가 발생해야 하는 4번째보다 큰 시점에 블록이 한번 걸리기만 하면 서버에 큰 부담이 되지 않으면서 차단을 수행할 수 있다고 판단하였습니다.

 

@Test
@DisplayName("같은 ip로 10초동안 여러번 요청한다. -> TooManyRequest 예외가 발생한다.")
public void requestSameAddr_faile_TooManyRequest() throws Exception {
    //given
    register();

	//when
    int firstErrorCount = 0;
    boolean isFirst = true;
    for (int i = 0; i < 6; i++) {

        try {
            ResultActions actions = mockMvc.perform(post("/member-service/login")
                    .contentType(MediaType.APPLICATION_JSON)
                    .characterEncoding("UTF-8")
                    .accept(MediaType.APPLICATION_JSON)
                    .content(new Gson().toJson(new LoginRequest(email, password)))
                    .header("X-Forwarded-For", remoteAddr));
        } catch (Exception e) {
            if (isFirst) {
                firstErrorCount = i;
                isFirst = false;
            }
        }
    }

	//then
    assertThat(firstErrorCount).isGreaterThanOrEqualTo(4);
}

 

 

테스트 결과 성공하였습니다.!

드디어 정말 길었던 중복 로그인을 위한 비즈니스 로직 변경, 필터 등록이 끝이 났습니다.

 

 

 

10. 정리하며...

 

중복 로그인과 같은 기능은 보안에서 너무나 중요한 과제입니다.

하나의 비즈니스 로직을 수행하기 위해 -> 토큰 저장, 삭제, 중복 로그인 토큰 저장, 예외 알리기 -> 동시성 잡기 -> 필터로 ip 차단하기 등 정말 많은 기능을 추가해야 했습니다.

 

이 과정을 적용하면서 redisTemplate 간의 트랜잭션을 유지하기 위해 loginSessionPolicy에서 락을 걸고 트랜잭션을 잡았습니다.

loginSessionPolicy.loginNewSession(member, authorities, request.getRemoteAddr());

 

또한, 서버의 안정화를 위해 filter에서 ip를 차단하되 생길 수 있는 ip 우회를 방지하기 위해 X-Forwarded-For 헤더를 추가하여 ip 차단 기능을 추가하였습니다.

 

String xForHeader = request.getHeader("X-Forwarded-For");
if (xForHeader == null || xForHeader.isEmpty()) throw new BadRequestException();

String remoteAddr = xForHeader.split(",")[0].trim();

 

filter를 설정하며 request의 Consumable 문제를 해결하며 이메일까지 검증을 할지, 아니면 이 부분은 다른 필터를 적용하거나 컨트롤러에서 처리할 지 많은 고민을 하였습니다. 그 과정에서 Controller가 값을 받지 못하는 에러도 발생하였고 어떻게 처리해야할 지 수많은 코드를 쓰고 지웠던 것 같습니다.

 

filter를 등록할 때, 이전 필터는 PathMatcher 방식을 사용했는데, 스프링이 제공해주는 FilterRegsitrationBean 기능으로 보다 기능을 분리하며 가독성 좋게 작성할 수 있었습니다.

남은 부분도 꾸준히 수정하고 문제를 검토하고 고민하고 코드로 작성하며 해결하도록 하겠습니다.

 

 

이번 글은 정말 길었는데, 읽어주셔서 감사드립니다.

부족한 부분 피드백 주시면 열심히 배우겠습니다.!

감사합니다.!!!

안녕하세요 회사와 함께 성장하고 싶은 KOSE입니다.

 

이번 포스팅은 API 개발의 마지막 관문이라고 할 수 있는 SpringRestDocs를 발급하는 과정을 정리하려고 합니다.

 

API 문서를 개발하는 과정은 까다로운 점들이 있습니다. 먼저 백엔드에서 개발한 api 명세 규칙을 프론트 개발자 혹은 api를 활용하는 클라이언트가 명확하게 이해하여 활용할 수 있도록 작성되어야 합니다. api의 uri, 전달 방식, 헤더 필수 정보, 필요한 파라미터 와 응답 객체 등 많은 내용을 정확하게 전달해야 합니다.

 

스프링에서 api docs를 개발하도록 돕는 기술은 여러 가지가 있는데, 제가 소개드릴 수 있는 기능은 Swagger와 SpringRestDocs입니다. 두 가지 방법은 각각 장단점이 존재하기 때문에 간단하게 정리한 후, 제가 선택한 SpringRestDocs 위주로 작성 방법과 공통 기능 분리하기, 테스트 진행 후 docs 발급 등을 작성하도록 하겠습니다.

 

 

 

1. Swagger vs SpringRestDocs

 

Swagger는 api 문서를 자동으로 생성하여 Swagger UI를 사용하여 생성된 문서를 확인할 수 있습니다. 다른 방법에 비해 어노테이션으로 api를 개발할 수 있기 때문에, 간편한 설정으로 빠른 개발이 가능합니다.

 

<Swagger> 

 

@Slf4j
@Api(value = "auth api")
@RestController
@RequestMapping("/member-service")
@RequiredArgsConstructor
public class AuthController {

    private final FacadeService facadeService;

    /**
     * 회원 가입 요청
     */
    @ApiOperation(value = "Register", response = ResponseEntity.class)
    @PostMapping("/register")
    public ResponseEntity formRegister(@Validated @RequestBody FormRegisterRequest request) {
        facadeService.register(request);
        return ResponseEntity.ok(SendSuccess.of());
    }

 

 

<SpringRestDocs>

 

SpringRestDocs는 테스트 기반으로 api 명세를 발급하는 기술입니다. JUnit 기반 테스트를 진행하여, 클라이언트의 mock 요청에 대해 비즈니스 로직을 수행하여 문제가 없다면 테스트를 성공하고 테스트 결과물로 api 명세를 작성하는 방식입니다.

 

@Test
@DisplayName("RestDocs: fetchPlayerRank / Get")
public void fetchPlayerRank() throws Exception {
    //given
    setUpForRanking();
    Pageable page = PageRequest.of(0, 10);

    //when
    ResultActions result = mockMvc.perform(
            get("/result-service/result/rank")
                    .accept(MediaType.APPLICATION_JSON)
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(new Gson().toJson(page))
                    .header("Authorization", "Bearer AccessToken")
                    .header("RefreshToken", "refreshToken")
                    .header("userId", "user-id"));

 

 

 

2. SpringRestDocs를 선택한 이유

 

저는 백엔드 개발에 TDD 설계가 정말 중요하다고 생각합니다. 저는 기술적으로 굉장히 많이 부족한 상태이고 많이 배워가야 하는 상황에서 제 코드를 맹신한다는 것은 과오임이 분명합니다. 따라서, 제가 작성한 코드가 어느 부분에서든 에러가 발생할 수 있는데, 테스트 없이 api를 발급하는 과정은 리스크가 있다고 생각하였습니다. 또한 , 소비자에게 테스트로 검증되지 않은 api 명세를 제공하는 것은 신뢰도 측면에서 악영향을 미칠 수 있다고 판단하였습니다.

 

SpringRestDocs는 Slice 혹은 Page 처럼 페이징 쿼리가 적용되는 코드에 대해서는 실제 페이징 응답을 전부 제공해야 하는 점에서 개발자의 피로도를 늘릴 수 있습니다. 하지만 역시 스프링은 위대한 기술임을 증명하듯, 이러한 과정을 하나의 유틸성 클래스를 작성함으로써 해결할 수 있습니다.

 

따라서, SpringRestDocs를 개발하는 과정이 보다 복잡해보이지만 상속 기능을 통해 최대한 중복되는 코드를 줄이고,

다음에도 활용 가능한 코드로 구현함으로써 신뢰도와 개발 편리성을 모두 지키는 방식으로 코드를 작성해 보는 시도를 진행하겠습니다. 

 

 

 

3. SpringRestDocs 의존성 주입 받기

 

먼저 제 기본 환경은 다음과 같습니다.

 

OS: Ubuntu22.04 (linux)
Java: openJdk17
SpringBoot: 3.0.2

 

혹시 제 코드를 활용하시더라도 SpringRestDocs가 버전 문제로 인해 진행이 안되실 수 도 있습니다. 이 경우는 구글링이나 Chat GPT의 도움을 받으시면 버전에 맞는 라이브러리를 받을 수 있습니다.

 

(해당 프로젝트에 QueryDsl을 적용하거나 기타 다른 라이브러리를 활용하게 되면 Build.gradle에서 많은 에러가 발생할 수 있습니다. 저는 최종 jar로 빌드하는 과정에서 많은 에러가 발생했습니다. 따라서 제가 적용했을 때 에러가 없었던 환경으로 공유드리기 위해 필요하다고 생각되는 설정으로 제시하였습니다.!)

 

// <-- docs 추가 asciidoctor 이 부분 --> //
plugins {
   id 'java'
   id 'org.springframework.boot' version '3.0.2'
   id 'io.spring.dependency-management' version '1.1.0'
   id "org.asciidoctor.jvm.convert" version "3.3.2"
}

group = 'liar'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

// <-- docs 추가 asciidoctorExt 이 부분 --> //
configurations {
   compileOnly {
      extendsFrom annotationProcessor
   }
   asciidoctorExt
}

// <-- docs 추가 snippetsDir --> //
ext {
    snippetsDir = file('build/generated-snippets') // restdocs
    set('springCloudVersion', "2022.0.1")
}


dependencies {
   implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
   implementation 'org.springframework.boot:spring-boot-starter-web'
   
   // json
   implementation 'com.google.code.gson:gson:2.10.1'

   // mockito
   testImplementation 'org.mockito:mockito-core:4.11.0'
   testImplementation 'org.mockito:mockito-junit-jupiter:4.11.0'
   
   // test
   testImplementation 'org.springframework.boot:spring-boot-starter-test'
   testImplementation 'org.springframework.security:spring-security-test'

   // <-- docs 시작 --> //
   asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor'
   testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
   // <-- docs 끝 --> //

   // lombok
   compileOnly 'org.projectlombok:lombok'
   annotationProcessor 'org.projectlombok:lombok'

   //test lombok
   testCompileOnly 'org.projectlombok:lombok'
   testAnnotationProcessor 'org.projectlombok:lombok'
}

// <-- docs 시작 --> //

tasks.named('test') {
	useJUnitPlatform()
}

sourceSets {
	main {
		java {
			srcDirs = ["$projectDir/src/main/java", "$projectDir/build/generated"]
		}
	}
}

clean {
	delete file('src/main/generated')
}


test {
	outputs.dir snippetsDir
	useJUnitPlatform()

	systemProperty 'spring.config.name', 'application-test'
	systemProperty 'spring.cloud.bootstrap.name', 'bootstrap'
}

asciidoctor {
	inputs.dir snippetsDir
	configurations 'asciidoctorExt'
	dependsOn test
}

bootJar {
	dependsOn asciidoctor
	from ("${asciidoctor.outputDir}/html5") {
		into 'static/docs'
	}
}

bootJar {
	dependsOn asciidoctor

	copy {
		from asciidoctor.outputDir
		into "src/main/resources/static/docs"
	}
}

jar {
	enabled = false
}

tasks.withType(JavaCompile) {
	options.release = 17
}

// <-- docs 시작 --> //

 

 

 

4. 공통 코드 작성하기

 

자바는 상속을 활용할 수 있으므로 공통 로직은 부모 클래스에서 작성한 후, 하위 자식 클래스에서 해당 클래스를 상속하여 공통 로직을 줄이는 방식을 사용하였습니다.

 

@SpringBootTest
@AutoConfigureWebMvc
@AutoConfigureRestDocs(uriScheme = "https", uriHost = "docs.liar.com", uriPort = 443)
@ExtendWith(RestDocumentationExtension.class)
public class CommonRestDocsController {

    protected MockMvc mockMvc;

	@Autowired RedisTemplate redisTemplate;

    @Autowired MemberRepository memberRepository;

    @Autowired TokenRepository tokenRepository;
    
    @Autowired FacadeService facadeService;
    
    @Autowired ObjectMapper objectMapper;

    @BeforeEach
    public void init(WebApplicationContext webApplicationContext,
                     RestDocumentationContextProvider restDocumentationContextProvider) {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext)
                .apply(documentationConfiguration(restDocumentationContextProvider))
                .build();
    }
}

 

어노테이션과 사용되는 파라미터를 하나씩 정리하면 다음과 같습니다.

 

@SpringBootTest: SpringBoot를 Test환경에서 실행하도록 돕는 어노테이션입니다. application을 컨텍스트에 로드하며, 필요한 빈을 주입하여 통합 테스트 환경을 제공해 줍니다.

 

@AutoConfigureWebMvc: MockMvc를 사용하는데 필요한 설정정보를 자동으로 제공하는 역할을 수행합니다. HadlerMapping 혹은 HandlerAdapter 등의 구성요소를 자동으로 주입해 주기 때문에 복잡한 Mvc 관련 코드를 생략할 수 있습니다.

(SpringMvc로 적용되지만 실제 RestController(RestFul)로 작성한 경우도 모두 포함됩니다. SpringMvc라고 표현되는 이유는 SpringWebFlux와 구분하기 위함입니다.)

 

@AutoConfigureRestDocs: SpringRestDocs를 자동으로 구성하여 api 문서화를 돕는 어노테이션입니다. springRestDocs에 필요한 설정 정보를 제공하며, api의 문서를 생성하는 역할을 수행합니다.

 

@ExtendWith(RestDocumentationExtension.class): Junit5 확장 모델을 사용하여 RestFul api 문서화를 돕는 어노테이션입니다. 

 

MockMvc: MockMvc는 SpringMvc를 테스트할 때 사용하는 기술로, 스프링은 DispatcherServlet을 사용하여 Http요청을 처리하는데, MockMvc는 이러한 과정을 Mock(가짜) 객체로  일련의 과정을 처리해 줍니다. 따라서, 테스트 환경에서 보다 간편하게 코드를 작성하는데 도움을 줄 수 있습니다.

 

@BeforeEach의 init() : Spring Mvc application의 Web Context로 앞서 @ExtendWith(RestDocumentationExtension.class) 어노테이션으로 자동 설정받은 restDocumentationContextProvider를 WebApplicationContext에 적용하여 셋업 함으로써, Mock 환경에서 restDocument를 작성할 수 있는 환경을 만들고 build 하여 인스턴스를 생성한 후 mockMvc에 적용하는 과정입니다.

 

 

 

5. SpringRestDocs 적용을 위한 실제 ControllerTest

 

제가 테스트하고자 하는 RestController의 메서드는 다음과 같습니다.

 

/**
 * 회원 가입 요청
 */
@PostMapping("/register")
public ResponseEntity formRegister(@Validated @RequestBody FormRegisterRequest request) {
    facadeService.register(request);
    return ResponseEntity.ok(SendSuccess.of());
}

 

요청 객체는 FormRegisterRequest이고, 응답 객체는 ResponseEntity입니다.

이제, 테스트 코드를 작성하여 SpringRestDocs를 발급하도록 하겠습니다.

 

SpringRestDocs에서 제공되는 라이브러리는 Intellij에서 패키지 자동 추천이 되지 않는 경우가 많이 있었습니다. 저는 처음 적용할 때, 이 부분에서 많이 혼란스러웠습니다. 따라서, 해당 코드는 길 수 있지만 의존성 패키지까지 전부 제시하였습니다.

 

import com.google.gson.Gson;
import liar.memberservice.member.controller.dto.request.FormRegisterRequest;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.ResultActions;

import java.util.UUID;

import static javax.management.openmbean.SimpleType.STRING;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.*;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

class AuthControllerTest extends CommonRestDocsController {

    @Test
    @DisplayName("RestDocs: register / Post")
    public void registerMvc() throws Exception {
        //given
        FormRegisterRequest request = FormRegisterRequest.builder()
                .email("kose@naver.com")
                .username("gosekose")
                .password(UUID.randomUUID().toString())
                .build();

        //when
        ResultActions perform = mockMvc.perform(
                post("/member-service/register")
                        .accept(MediaType.APPLICATION_JSON)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(new Gson().toJson(request))
        );

        //then
        perform
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.code").value("200"))
                .andExpect(jsonPath("$.message").value("성공"))
                .andDo(document("member-register",
                        preprocessRequest(prettyPrint()),
                        preprocessResponse(prettyPrint()),
                        requestFields(
                                fieldWithPath("username").type(STRING).description("회원 이름"),
                                fieldWithPath("email").type(STRING).description("이메일"),
                                fieldWithPath("password").type(STRING).description("패스워드")
                        ),
                        responseFields(
                            fieldWithPath("code").type(STRING).description("응답 상태 코드"),
                            fieldWithPath("message").type(STRING).description("상태 메세지")
                        )));

    }
}

 

코드를 분석하면 다음과 같습니다. mockMvc를 활용하면 SpringMvc의 Controller를 mockMvc.perform()을 통해 테스트할 수 있습니다. 저는 Post요청을 수행하므로, 하단의 패키지를 static import 하였습니다.

 

import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post;

 

post 요청에는 request로 받을 수 있는 accept, contentType, content를 명시할 수 있습니다. 해당 컨트롤러는 json으로 요청받아야 하므로 content에 Request를 Json으로 변환한 값을 주입하였습니다.

 

추가로 post요청에 헤더 정보를 작성할 수 있습니다. 만약 특정 요청에 대해서는 반드시 Authorization, RefreshToken, UserId 헤더 정보가 필요하다고 하면 다음과 같이 header()로 추가할 수 있습니다.

 

ResultActions perform = mockMvc.perform(
        post("/member-service/register")
                .accept(MediaType.APPLICATION_JSON)
                .contentType(MediaType.APPLICATION_JSON)
                .content(new Gson().toJson(request))
                .header("Authorization", "Bearer AccessToken")
                .header("RefreshToken", "refreshToken")
                .header("UserId", "userId"));
);

 

최종적으로 mockMvc.perform()으로 수행된 값은 ResultActions 객체 인스턴스를 반환합니다.

//then 구문에서는 resultActions에 대한 결과를 검증하고 필요한 docs를 발급하는 과정을 작성할 수 있습니다.

 

perform
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.code").value("200"))
        .andExpect(jsonPath("$.message").value("성공"))

 

perform 객체의 andExpect()가 의미하는 바는, 응답 코드와 ResponseEntitiy 응답 객체의 값입니다. 저는 응답 결과로, 정형화된 응답 값을 제공하고자 하였습니다. Body가 필요한 경우는 T 타입이 추가된 SendSuccessBody 클래스 인스턴스를 제공하지만, 현재 응답은 크게 T body가 필요하지 않으므로, SendSuccess.of()를 응답 객체로 제시하였습니다.

 

@NoArgsConstructor
@AllArgsConstructor
public class SendSuccess {
    public String code;
    public String message;

    public static SendSuccess of() {
        return new SendSuccess(SuccessCode.OK, SuccessMessage.OK);
    }
}

 

하단의. andDo()는 document의 이름을 명시할 수 있고 출력 결과를 이쁘게 보여주는 prettyPrint()를 추가할 수 있습니다.

이후, responseFields()에서 응답 결과로 제공하는 필드명과 설명을 제공할 수 있습니다.

fieldWithPath().type().description() 결과는 해당 필드 이름, 제공 타입, 필드의 설명을 각각 작성하는 부분입니다.

 

.andDo(document("member-register",
        preprocessRequest(prettyPrint()),
        preprocessResponse(prettyPrint()),
        responseFields(
            fieldWithPath("code").type(STRING).description("응답 상태 코드"),
            fieldWithPath("message").type(STRING).description("상태 메세지")
        )));

 

 

이후 테스트를 진행하면, 테스트에 성공한 경우 성공 메시지가 나오며, 패키지의 build/generated-snippets 폴더에 adoc 파일이 생성됩니다. 만약 생성되지 않는다면, Intellij의 캐시 무효화 버튼으로 모든 캐시를 제거 후 다시 시작하면 generated-snippets에 정상적으로 adoc 파일이 추가된 것을 확인할 수 있습니다.

 

 

 

 

 

 

6. 중복 코드 제거하기 

 

SpringRestDocs는 중복되는 코드가 많이 발생할 수 있습니다. mockMvc.perform()을 수행하는 과정에 적용되는 Application_Json, andDo()의 preprocessingRequest, preprocessingResponse 등이 그 예에 속합니다.

따라서, 위에서 작성한 코드를 리팩토링하여 반복되는 코드를 정리하였습니다.

 

public <T> MockHttpServletRequestBuilder customPost(String uri, T t) {
    return post(uri)
            .accept(MediaType.APPLICATION_JSON)
            .contentType(MediaType.APPLICATION_JSON)
            .content(new Gson().toJson(t));
}

public <T> ResultActions mockMvcPerformPost(String uri, T t) throws Exception {
    return mockMvc.perform(customPost(uri, t));
}

mockMvc.perform()을 수행하는 코드에서 accept, contentType, conent는 공통적으로 사용하므로 제네릭 메소드를 활용하여 request타입으로 받아서 MockHttpServletRequestBuilder를 리턴합니다.

 

이후 mockMvcPerformPost()도 제네릭 메소드로 선언하여 ResultActions를 리턴하도록 하였습니다.

두 메서드를 분리하여 각각 적용하도록 한 이유는 상황에 따라, MockHttpServletRequestBuilder 타입에 추가로 헤더 정보등을 넣어야 할 수도 있습니다. 이 경우 두 타입을 분리하여 상황에 맞게 적용하기 위함입니다.

 

만약 헤더 정보를 추가해야한다면 자바의 다형성을 활용하여 uri, T 타입 request를 받거나, uri, T 타입 request, AuthTokenDto로 헤더 정보를 받을 수 있습니다.

 

public <T> MockHttpServletRequestBuilder customPost(String uri, T t) {
    return post(uri)
            .accept(MediaType.APPLICATION_JSON)
            .contentType(MediaType.APPLICATION_JSON)
            .content(new Gson().toJson(t));
}

public <T> ResultActions mockMvcPerformPost(String uri, T t) throws Exception {
    return mockMvc.perform(customPost(uri, t));
}

public <T> MockHttpServletRequestBuilder customPost(String uri, T t, AuthTokenDto auth) {
    return post(uri)
            .accept(MediaType.APPLICATION_JSON)
            .contentType(MediaType.APPLICATION_JSON)
            .content(new Gson().toJson(t))
            .header("Authorization", auth.getAccessToken())
            .header("RefreshToken", auth.getRefreshToken())
            .header("UserId", auth.getUserId());

}

public <T> ResultActions mockMvcPerformPost(String uri, T t, AuthTokenDto auth) throws Exception {
    return mockMvc.perform(customPost(uri, t, auth));
}

 

< 최종 수정된 코드 >

//when
ResultActions perform = mockMvcPerformPost("/member-service/register", request);

 

다음은 ResultActions에 공통적으로 호출되는  document()을 커스텀화하였습니다.

static method는 리턴타입이 RestDocumentationResultHandler입니다. 따라서, document() 메서드를 호출한 후 preprocessRequest, preprocessResponse를 추가하였습니다. 

 


snippet의 경우 다양한 코드가 추가될 수 있습니다. 이때 공통적으로 묶을 수 있는 부분은 ResponseFieldsSnippet 리턴타입으로 custom하게 작성할 수 있습니다.

 

public RestDocumentationResultHandler customDocument(String identifier,
                                                     Snippet... snippets) {
    return document(
            identifier,
            preprocessRequest(prettyPrint()),
            preprocessResponse(prettyPrint()),
            snippets
    );
}

public ResponseFieldsSnippet responseCustomFields(FieldDescriptor... fieldDescriptors) {
    FieldDescriptor[] defaultFieldDescriptors = new FieldDescriptor[] {
            fieldWithPath("code").type(STRING).description("응답 상태 코드"),
            fieldWithPath("message").type(STRING).description("상태 메세지")
    };

    return responseFields(defaultFieldDescriptors).and(fieldDescriptors);
}

 

< 최종 수정된 코드 > 

perform
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.code").value("200"))
        .andExpect(jsonPath("$.message").value("성공"))
        .andDo(customDocument("member-register",
                requestFields(
                        fieldWithPath("username").type(STRING).description("회원 이름"),
                        fieldWithPath("email").type(STRING).description("이메일"),
                        fieldWithPath("password").type(STRING).description("패스워드")
                ),
                responseCustomFields()
        ));

 

 

 

7. 페이징 쿼리 응답 추가하기

 

SpringRestDocs에서 가장 복잡한 부분은 페이징 쿼리를 응답을 작성하는 부분입니다. 만약 QueryDsl로 Slice<>, Page<> 형태의 객체를 응답 Body로 제공할 때, SpringRestDocs 테스트를 성공하려면 사용되는 페이징 쿼리를 전부 작성해야 합니다.

 

이 문제는 여간 쉬운 일이 아니고, 테스트마다 모두 작성하는 것은 비효율적입니다.

따라서, 이러한 코드도 앞서 Custom하게 정리했던 방식대로 페이징 쿼리 전용 메서드를 만들 수 있습니다.

 

 

이를 구현하기 위해, CommonRestDocsController에 ResponseFieldsSnippet 리턴타입의 responseFieldsSnippetPageable 메서드를 추가였고 공통적으로 사용되는 페이징 응답을 작성하였습니다.

 


public ResponseFieldsSnippet responseCustomFieldsPageable(FieldDescriptor... fieldDescriptors) {
    FieldDescriptor[] fields = new FieldDescriptor[] {
            fieldWithPath("body.pageable.offset").type(NUMBER).description("The offset of the current page"),
            fieldWithPath("body.pageable.pageNumber").type(NUMBER).description("The number of the current page"),
            fieldWithPath("body.pageable.pageSize").type(NUMBER).description("The size of the current page"),
            fieldWithPath("body.pageable.paged").type(BOOLEAN).description("Whether the current page is paged"),
            fieldWithPath("body.pageable.unpaged").type(BOOLEAN).description("Whether the current page is unpaged"),
            fieldWithPath("body.sort.empty").type(BOOLEAN).description("Whether the current page is sorted"),
            fieldWithPath("body.sort.sorted").type(BOOLEAN).description("Whether the current page is sorted"),
            fieldWithPath("body.sort.unsorted").type(BOOLEAN).description("Whether the current page is sorted"),
            fieldWithPath("body.pageable.sort.empty").type(BOOLEAN).description("Whether the current page is sorted"),
            fieldWithPath("body.pageable.sort.sorted").type(BOOLEAN).description("Whether the current page is sorted"),
            fieldWithPath("body.pageable.sort.unsorted").type(BOOLEAN).description("Whether the current page is sorted"),
            fieldWithPath("body.totalPages").type(NUMBER).description("The total number of pages"),
            fieldWithPath("body.totalElements").type(NUMBER).description("The total number of elements"),
            fieldWithPath("body.last").type(BOOLEAN).description("Whether the current page is the last one"),
            fieldWithPath("body.size").type(NUMBER).description("The size of the current page"),
            fieldWithPath("body.number").type(NUMBER).description("The number of the current page"),
            fieldWithPath("body.numberOfElements").type(NUMBER).description("The number of elements in the current page"),
            fieldWithPath("body.first").type(BOOLEAN).description("Whether the current page is the first one"),
            fieldWithPath("body.empty").type(BOOLEAN).description("Whether the current page is empty")
    };
    return responseFields(fieldDescriptors).and(fields);
}

 

FieldDescriptor 타입의 인자들을 받아서, reponseFields()를 생성한 후 FieldDescriptor 배열을 추가하여 ResponseFieldsSnippet의 인스턴스를 적용하는 방식입니다.

 

이를 활용하여, responseCustomFieldsPageable()은 commonRestDocsController에서 작성한 메서드를 받아서 추가로 필요한 custom 응답값을 추가합니다. 

 

result.andExpect(status().isOk())
        .andDo(customDocument("result-myResult",
                requestFields(
                        fieldWithPath("userId").type(STRING).description("유저 아이디"),
                        fieldWithPath("viewLatest").type(BOOLEAN).description("최신 순 조회"),
                        fieldWithPath("viewOnlyWin").type(BOOLEAN).description("승리한 경기만 조회"),
                        fieldWithPath("viewOnlyLose").type(BOOLEAN).description("패배한 경기만 조회"),
                        fieldWithPath("searchGameName").type(STRING).description("게임 이름을 포함한 경기만 조회")
                ),
                responseCustomFieldsPageable(
                        fieldWithPath("code").type(STRING).description("상태 코드"),
                        fieldWithPath("message").type(STRING).description("상태 메세지"),
                        fieldWithPath("body.content[].gameId").type(STRING).description("게임 아이디"),
                        fieldWithPath("body.content[].gameName").type(STRING).description("게임 이름"),
                        fieldWithPath("body.content[].topicName").type(STRING).description("주제"),
                        fieldWithPath("body.content[].winner").type(STRING).description("승리한 역할"),
                        fieldWithPath("body.content[].totalUsers").type(LONG).description("총 유저 수"),
                        fieldWithPath("body.content[].myRole").type(STRING).description("내 역할"),
                        fieldWithPath("body.content[].answer").type(BOOLEAN).description("내 투표 정답")
                )));

 

따라서, 많은 페이징 쿼리를 작성하더라도 커스텀 메서드를 활용하여 코드의 양을 줄일 수 있었습니다.

 

 

 

7.  정리하며...

 

Swagger나 SpringRestDocs를 사용하는 것은 많은 트레이드오프가 있습니다. 무엇이 더 좋다고 평가하기 어려울 정도로 두 기능 모두 각각 장점들이 너무 뚜렷합니다.  만약 Controller에 대한 테스트를 따로 진행한다면, swagger를 적용하여 빠르고 정확하게 개발을 진행할 수 있습니다.

 

저는 개인적으로 Controller에서 핵심 로직 이외에 부가 기능을 수행하는 어노테이션을 추가하는 것에 부담을 느꼈고, 실제로 아직 실력이 너무나도 부족하기 때문에 테스트를 거치지 않은 제 코드를 믿지 않는 편입니다. 직접 눈으로 확인하고, 에러를 발생시켜 보고 "왜 이건 안되지?" 생각하는 시간이 저에게 발전을 가져다주는 것 같습니다.

 

그러다 보니 의식적으로라도 SpringRestDocs를 활용하여 테스트를 진행한 후 api 명세를 발급하게 되는 것 같습니다.  또한, 복잡하고 중복되는 코드의 경우 메서드로 정리해놓은 후, 해당 메서드를 적용하면 되므로 개발 시간도 단축시킬 수 있었습니다.

 

잘못된 부분은 피드백 주시면 바로 배우겠습니다.!

부족하지만 오늘도 읽어주셔서 감사드립니다!!!

 

안녕하세요 회사와 함께 성장하고 싶은 KOSE입니다.

 

Redis는 인메모리 데이터베이스로 Key-Value 형태로 데이터를 저장하고 조회할 수 있습니다.

Spring에서는 CrudRepository나 RedisTemplate 등으로 Redis에 데이터를 저장할 수 있습니다.

저는 토이 프로젝트에서 간편하게 값을 저장 및 조회할 수 있고 어노테이션으로 인덱스를 간편하게 생성할 수 있는 CrudRepository를 사용하였습니다. 하지만 프로젝트 규모가 커지다보니 MSA 아키텍처 간에 객체에 대한 유기적인 사용이 필요하였습니다.

 

그 예로 AccessToken과 같은 인증 객체가 있습니다. 인증 객체는 회원이 로그인에 성공하면 인증 토큰을 발급합니다. 인증 토큰을 발급하는 주체가 A 서버라고 한다면, 다른 B 혹은 C 등의 서버에서 인증 토큰 객체를 조회하여야 할 수 있습니다.  이 경우 " _class" 형태로 저장된 값은 다른 B, C 서버에서 객체로 역직렬화를 어렵게 합니다. 일반적으로 역직렬화는 클래스 정보가 매핑되어야 객체로의 변환이 가능합니다. 하지만 " _class"로 저장된 값을 서버별로 상이한 class로 매핑하는 과정은 어려운 일이고 해당 객체를 위해 통일된 객체를 선언하는 것도 한계가 있습니다.

 

따라서, 이번 글은 제가 겪었던 CrudRepository에서의 객체 저장 방식과 역직렬화의 한계를 살펴보고, Redistemplate로 수정하면서 자유롭게 객체로 역직렬화를 할 수 있었던 점, 나아가 분산락과 트랜잭션을 적용하고 ThreadLocal로 생명주기를 관리한 것에 대해서 정리하도록 하겠습니다.

 

 

 

1. 직렬화/역직렬화

 

직렬화(Serialization)는 자바 객체를 Byte 형태로 변환화는 과정을 의미합니다. Byte 형태로 객체를 데이터베이스에 저장하기 때문에 용량을 줄일 수 있고 데이터가 문자열 형태로 저장되기 때문에 Redis, MongoDB 등 다양한 저장소에 저장할 수 있습니다.

 

역직렬화는 직렬화 형태로 저장된 데이터를 자바(여기서는 스프링을 사용하므로 자바라고 하겠습니다) 객체로의 변환하는 것을 의미합니다. 직렬화된 데이터를 역직렬화할 때 장점은 데이터를 유연하게 객체화할 수 있다는 점입니다.

직렬화하는 대상에 사용되는 클래스 타입에 제약을 받지 않기에 필요하다면 에러가 발생하지 않는 선에서 유기적으로 객체를 수정할 수 있습니다.

 

 

 

2. CrudRepository의 저장방식

 

CrudRepository를 사용하면 객체를 데이터로 저장할 때 RedisHash 형태로 객체를 저장합니다. RedisHash는 Hash의 Field와 Value로 매핑되는 자료구조로 객체를 Hash화 하여 저장하는 개념입니다. 즉 key-value 구조 안에서 해시 구조를 만들어 저장하는데, 이러한 해시 구조를 활용할 때 장점은 객체의 field를 hash의 field로 저장하기 때문에 데이터를 빠르게 읽어올 수 있습니다.

 

<AccessToken.java>

 

@Getter
@NoArgsConstructor
public class AccessToken extends Token implements Serializable {

    private AccessToken(String id, String userId, long expiration) {
        super(id, userId, expiration);
    }
}

 

 

<AccessTokenRedisRepository.java>

 

public interface AccessTokenRedisRepository extends CrudRepository<AccessToken, String> {
}

 

<Redis-cli 결과>

 

 

객체가 Redis에 저장될 때, 각 필드에 매핑되는 값을 확인할 수 있습니다. CrudRepository를 활용할 때, @Indexed 어노테이션으로 간단하게 Index를 생성하여 값을 저장할 수 있습니다.

 

<Token.java>

 

@Getter
@NoArgsConstructor
@AllArgsConstructor
public abstract class Token {

    @Id
    private String id;

    @Indexed
    private String userId;

    @TimeToLive(unit = TimeUnit.MILLISECONDS)
    private long expiration;
}

 

인덱스는 RDBMS 데이터베이스에서 테이블 내 데이터를 빠른 검색과 정렬을 위한 데이터 구조입니다. 특정 열에 대한 인덱스를 생성하면 해당 열의 값들이 저장되어 빠르게 검색할 수 있습니다. 덱스가 아닌 컬럼에 대한 where 절의 검색 조건은 쿼리 실행 계획을 확인하면 전체 검색으로 실행되는 것을 볼 수 있습니다. 하지만, 인덱스로 선언된 컬럼을 검색하게 되면 값을 Hash화 하여 인덱스의 해시테이블에서 값을 가져오는 개념이기 때문에 빠르고 효율적인 검색이 가능합니다.

 

Redis에서 인덱스는 어떠한 개념일까요 ? RDBMS와 다르게 Redis는 Key-value 형태로 값을 저장합니다. 따로 인덱스 열을 생성하는 개념이 아니라 key-value로 인덱스 값과 해당 key가 참조하고 있는 객체의 Id를 value로 참조하고 있는 형태입니다. Sorted set, Set, Hash는 Redis에서 활용할 수 있는 인덱스의 자료구조 형태입니다.

 

AccessToken은 userId라는 필드를 가지고 있고 해당 필드가 @Indexed로 선언되어 있습니다. accessToken은 해당 토큰의 발급 주체가 누구이냐가 보안성에서 중요한 역할을 수행합니다. 뿐만 아니라 예상하지 못한 문제로 동일 userId로 여러 개의 토큰이 생성될 수 있으므로 이를 제어하기 위해 해당 값을 index 화해서 사용하였습니다.

 

이처럼 CrudRepository는 객체를 해시화하여 Redis에 저장하는 것과 Index를 활용하는 것을 용이하게 해주기 때문에 효율적으로 활용할 수 있습니다.

 

 

 

3. CrudRepository의 한계

 

CrudRepository의 단점은 객체를 해시화하여 저장하기 때문에 "_class"라는 값이 생성되게 됩니다. 이는 곧 유연한 확장성에 어려움을 줄 수 있습니다.  "_class"는  해시화된 데이터를 객체로 변환하는 과정에서 매핑 정보를 담고 있는 class 정보입니다. 만약 A 서버에서 Redis에 객체와 인덱스 정보까지 모두 저장하였다 하더라도 "_class"가 매핑되지 않게 구현된 B Server에서 해당 데이터를 CrudRepository.findById or findByUserId를 활용했을 때 객체를 찾을 수 없는 문제가 발생할 수 있습니다.

 

B 서버는 A 서버의 클래스 정보와 같지 않을 가능성이 매우 높으며 상황에 따라, 해시화되어 있는 필드에 정확하게 일치하는 필드가 없을 수도 있습니다. 즉 유동적으로 변경될 수 있는데, RedisHash로 저장된 값은 이러한 유연한 변경이 어렵습니다.

 

그렇다면 현재 주어진 프로젝트는 객체를 redis에 저장 가능해야하고, "_class"정보가 다르더라도 다른 객체에 매핑이 가능해야 하며, Index까지 활용할 수 있어야 한다면 어떠한 방식으로 객체를 저장해야 할까요?

 

 

 

4. Redistemplate 활용하기

 

Redistemplate를 활용하면 객체가 직렬화를 거치면서 클래스 매핑정보가 저장되지 않게 비교적 쉽게 구현할 수 있습니다. 그리고 역직렬화할 때는 매핑할 객체에 대한 class 정보를 추가할 수 있기에 유연하게 객체를 저장 및 조회할 수 있습니다. 또한 Redistemplate의 key-value를 활용하여 커스텀한 인덱스 형태로 값을 저장할 수 있습니다.

 

이제 코드로 적용해 보도록 하겠습니다.


<RedisConfig.java>

 

@Configuration
public class RedisConfig {

    @Value("${spring.data.redis.host}")
    private String host;

    @Value("${spring.data.redis.port}")
    private int port;

	-- 중략 --
    
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(connectionFactory);
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        redisTemplate.setHashKeySerializer(new StringRedisSerializer());
        redisTemplate.setHashValueSerializer(new StringRedisSerializer());
        return redisTemplate;
    }
}

 

먼저 RedisTemplate를 활용하여 String의 key값으로 Object를 저장하도록 설정합니다.

 

redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setHashValueSerializer(new StringRedisSerializer());

 

Redis는 기본적으로 Byte 형태로 Redis에 저장합니다. 하지만 가독성을 위해, 사용자의 입장에서는 문자열로 보이는게 좋습니다. 따라서, redistemplate는 다음과 같이 StringRedisSerializer()를 제공합니다. 이는 바이트 형태로 보여지는 문자열을 사용자가 쉽게 확인할 수 있도록 보여줍니다.

 

StringRedisSerializer()를 사용하지 않은 경우

 

StringRedisSerializer()를 사용한 경우

 

 

만약 사용하지 않더라도, RedisTemplate는 defaultSerializer로 JdkSerializationRedisSerializer를 제공합니다. 따라서 필수값은 아니지만, 디버깅할 때 매우 유용하게 사용할 수 있으므로 StringRedisSerializer를 적용하였습니다.

 

 

<TokenRedissonRepository.java>

 

@Component
public interface TokenRedisTemplateRepository<T extends Token> {

    void saveToken(String key, T t) throws JsonProcessingException;

    T findTokenByKey(String key, Class<T> clazz) throws JsonProcessingException;

    void saveTokenIdx(String key, T t) throws JsonProcessingException;

    String findTokenIdxValue(String key, Class<T> clazz) throws JsonProcessingException;

    T findTokenByIdx(String key, Class<T> clazz) throws JsonProcessingException;

    void deleteToken(String key, Class<T> clazz) throws JsonProcessingException;
    
    void deleteTokenIdx(String key, Class<T> clazz) throws JsonProcessingException;

}

 

먼저, 제네릭으로 선언하여 Token을 상속하는 AccessToken과 RefreshToken이 TokenRedisRepository를 활용할 수 있도록 선언하였습니다. Crud에 필요한 save, find, delete 등의 기본적인 메서드와 인덱스를 활용하기 위해 인덱스를 저장하고 인덱스로 값을 검색하는 커스텀 메서드를 추가하였습니다.

 

이후, 구현체에서 필요한 코드를 작성하면 다음과 같습니다.

 

@Slf4j
@Component
@RequiredArgsConstructor
public class TokenRedisTemplateRepositoryImpl<T extends Token> implements TokenRedisTemplateRepository<Token> {
    private final RedisTemplate<String, Object> redisTemplate;

    private final ObjectMapper objectMapper;

    private final static String IDX = "idx";

    @Override
    public void saveToken(String key, Token token) throws JsonProcessingException {
        key = getKey(token.getClass().getSimpleName(), key);
        redisTemplate.opsForValue().set(key, objectMapper.writeValueAsString(token));
        redisTemplate.expire(key, token.getExpiration(), TimeUnit.MILLISECONDS);
    }

    @Override
    public Token findTokenByKey(String key, Class<Token> clazz) throws JsonProcessingException {
        key = getKey(clazz.getSimpleName(), key);
        return getObjectValue(key, clazz);
    }

    @Override
    public void saveTokenIdx(String key, Token token) throws JsonProcessingException {
        key = getKey(token.getClass().getSimpleName(), key, IDX);
        redisTemplate.opsForValue().set(key, objectMapper.writeValueAsString(token.getId()));
        redisTemplate.expire(key, token.getExpiration(), TimeUnit.MILLISECONDS);
    }

    @Override
    public String findTokenIdxValue(String key, Class<Token> clazz) throws JsonProcessingException {
        key = getKey(clazz.getSimpleName(), key, IDX);
        return getIdxValue(key);
    }

    @Override
    public Token findTokenByIdx(String key, Class<Token> clazz) throws JsonProcessingException {

        key = findTokenIdxValue(key, clazz);
        if (key == null) return null;

        return findTokenByKey(key, clazz);
    }

    @Override
    public void deleteToken(String key, Class<Token> clazz) throws JsonProcessingException {
        key = getKey(clazz.getSimpleName(), key);
        redisTemplate.delete(key);
    }

    @Override
    public void deleteTokenIdx(String key, Class<Token> clazz) throws JsonProcessingException {
        key = getKey(clazz.getSimpleName(), key, IDX);
        redisTemplate.delete(key);
    }

    private Token getObjectValue(String key, Class<Token> clazz) throws JsonProcessingException {
        String value = (String) redisTemplate.opsForValue().get(key);

        if (value == null || value.isEmpty()) return null;
        return objectMapper.readValue(value, clazz);
    }

    private String getKey(String... keys) {
        return String.join(":", keys);
    }

    private String getIdxValue(String key) throws JsonProcessingException {
        String value = (String) redisTemplate.opsForValue().get(key);
        if (ObjectUtils.isEmpty(value)) return null;
        return objectMapper.readValue(value, String.class);
    }
}

 

redisTemplate.opsForValue(). set()은 key와 객체를 redis에 직렬화하여 값을 저장하는 역할을 수행합니다.

class정보는 objectMapper.writeValueAsString() 단계에서 제거되며 직렬화된 바이트로 redis에 저장됩니다.

값을 가져온 후 필요한 매핑 정보를 제공하기 위해 class 타입을 명시하여 캐스팅할 수 있도록 하였습니다.

redistemplate.delete() 메서드는 key 값에 저장된 value를 지우는 역할을 수행합니다.

 

 

 

6. RedisTemplate 트랜잭션과 동시성 제어하기 

 

accessToken과 accessToken의 index, refreshToken과 refreshToken의 index를 저장 혹은 삭제하는 메서드는 반드시 동일 트랜잭션 내에서 처리되어야 합니다.

뿐만 아니라 동시성 문제도 해결되어야 합니다. 만약 데이터 일관성이 깨지거나 동시성 문제가 발생한다면, index로 token의 key를 찾았지만 key의 값이 없는 문제가 발생하거나 accessToken 혹은 refreshToken만 저장되는 문제가 발생할 수 있습니다. 따라서, redistemplate의 트랜잭션 제어 메서드와 redissonClient를 활용하여 분산락을 적용하였습니다.

 

주의할 점은, 분산락과 트랜잭션의 순서입니다.

 

 

분산락으로 먼저 요청 토큰에 대한 락을 획득하고, redistemplate로 redis에 저장하기 전에 트랜잭션을 시작합니다.
이후, 값이 모두 온전히 저장되면 커밋하고 실패할 경우 롤백합니다 이후 분산락이 해제되는 순서가 보장되어야 합니다.

 

보통 이러한 과정은 횡단 관심사에 속합니다. 메인 핵심 비즈니스 로직은 token 정보를 redis에 저장하는 것입니다.

따라서, 분산락과 트랜잭션의 시작과 종료는 AOP로 적용할 수 있습니다.

 

AOP는 pointcuts과 aspect가 존재하며, pointcuts으로 aop에 적용할 프로젝트 경로, 클래스 혹은 메서드를 적용할 수 있습니다. 이후 aspect에서 부가 기능들을 작성하며 프록시 객체를 호출하여 처리할 수 있습니다.

 

저는 token 정보를 저장하기 위해 CustomAnnotation을 생성하였고 aspect 적용이 필요한 메서드는 어노테이션을 추가하여 aop를 적용하였습니다.

 

<RedisTransactional.java>

 

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RedisTransactional {
}

 

<Pointcuts.java> 

 

@Pointcut("@annotation(liar.memberservice.common.aop.anno.RedisTransactional)")
public void transactionMethod() {};

 

pointcut에서 해당 어노테이션이 저장된 프로젝트 경로를 설정합니다.
이후, Aspect는 transactionMethod()의 경로를 참조하여 aop를 적용할 수 있습니다.

 

여기서 앞 서 정의한 분산락과 트랜잭션의 순서를 고려하여 AOP를 적용해야 합니다.

Aspect는 클래스 단위로 @Order() 어노테이션으로 aop의 순서를 정의할 수 있습니다. 

먼저 시작되어야 할 분산락에 aop를 먼저 적용한 후, 이어서 트랜잭션 aop가 실행되도록 @Order로 순서를 정의하였습니다.

 

<RedissonClientLockAspect.java>

 

@Around("liar.memberservice.common.aop.Pointcuts.transactionMethod() && args(userId, authorities)")
    public AuthTokenDto saveToken(ProceedingJoinPoint joinPoint, String userId, List<Authority> authorities)
            throws Throwable {
        String lockKey = "SaveUserToken: " + userId;
        return getTokenIfExistOrSetAndGetPointTransactionRedissonRLock(joinPoint, lockKey);
    }


    private AuthTokenDto getTokenIfExistOrSetAndGetPointTransactionRedissonRLock(ProceedingJoinPoint joinPoint,
                                                                                 String lockKey) throws Throwable {
        RLock lock = redissonClient.getLock(lockKey);
        try {
            boolean isLocked = lock.tryLock(60, TimeUnit.SECONDS);
            if (!isLocked) throw new RedisLockException();
            Object proceed = joinPoint.proceed();
            if (proceed == null) {
                return null;
            }
            return (AuthTokenDto) proceed;
        } finally {
            if (lock.isHeldByCurrentThread()) lock.unlock();
        }
    }

 

먼저 userId로 락을 설정한 후 joinPoint.proceed()로 JDK dynamic proxy 혹은 CGLIB proxy 객체를 호출하여 로직을 수행합니다.

 

이어서 트랜잭션이 적용될 수 있도록 aop를 추가합니다.

 

	@Around("liar.memberservice.common.aop.Pointcuts.transactionMethod() && args(userId, authorities)")
    public AuthTokenDto runWithTx(ProceedingJoinPoint joinPoint, String userId, List<Authority> authorities) throws Throwable {
        if (!txActive.get()) {
            redisTemplate.multi();
            txActive.set(true);
        }

        try {
            Object proceed = joinPoint.proceed();
            if (proceed != null) {
                return (AuthTokenDto) proceed;
            }
        } catch (Throwable throwable) {
            throw throwable;
        }
        return null;
    }

    @AfterReturning("liar.memberservice.common.aop.Pointcuts.transactionMethod()")
    public void commitTx() {
        if (txActive.get()) {
            try {
                redisTemplate.exec();
            } catch (Exception e) {
                redisTemplate.discard();
            } finally {
                txActive.remove();
            }
        }
    }
    
    @AfterThrowing("liar.memberservice.common.aop.Pointcuts.transactionMethod()")
    public void rollback() {
        try {
            redisTemplate.discard();
        }  catch (Exception e) {
            e.printStackTrace();
        } finally {
            txActive.remove();
        }
    }

 

txActive는 현재 트랜잭션을 나타내는 변수입니다. ThreadLocal로 스레드-세이프한 구조로 생성할 수 있습니다.

ThreadLocal.withInitial(() -> false)로 초기값을 false로 설정한 후 tx.Active가 아직 트랜잭션이 처리되지 않은 경우에
트랜잭션을 시작합니다.


이후 join.proceed()의 프록시 객체를 호출하며 문제가 없다면 @AfterReturning이 실행되며 트랜잭션을 종료하고 
메모리 누수를 막기 위해 생성한 ThreadLocal을 제거합니다. 

 

만약 이 과정에서 예외가 발생하면 rollback()메서드가 호출되거나 try-catch-finally 구문의

redistemplate.discard()로 요청을 롤백한 후 ThreadLocal을 제거합니다.

마지막으로 프록시 객체가 종료되면 먼저 요청했던 RedissonClient 분산락이 해제되며 메서드가 종료됩니다.

 

이제, 마지막으로 Service 로직을 작성하겠습니다.

 

@RedisTransactional
public AuthDto getTokenIfExistOrSetAndGet(String userId, List<Authority> authorities) throws JsonProcessingException {

    AuthDto authDto = findOrDeleteToken(userId);
    if (authDto != null) return authDto;

    return saveAndGetToken(userId, authorities);
}

private AuthDto findOrDeleteToken(String userId) throws JsonProcessingException {
    Token savedAccessToken = tokenRepository.findTokenByIdx(userId, AccessToken.class);
    Token savedRefreshToken = tokenRepository.findTokenByIdx(userId, RefreshToken.class);

    if (savedAccessToken != null && savedRefreshToken != null) {
        return new AuthDto(savedAccessToken.getId(), savedRefreshToken.getId(), userId);
    }

    else if (savedAccessToken != null) {
        tokenRepository.deleteToken(savedAccessToken.getId(), AccessToken.class);
        tokenRepository.deleteTokenIdx(savedAccessToken.getUserId(), AccessToken.class);
    }

    else if (savedRefreshToken != null) {
        tokenRepository.deleteToken(savedRefreshToken.getId(), RefreshToken.class);
        tokenRepository.deleteTokenIdx(savedRefreshToken.getUserId(), RefreshToken.class);
    }
    return null;
}

private AuthDto saveAndGetToken(String userId, List<Authority> authorities) throws JsonProcessingException {
    String accessToken = tokenProviderPolicy.createAccessToken(userId, authorities);
    String refreshToken = tokenProviderPolicy.createRefreshToken(userId, authorities);

    saveTokens(AccessToken.of(accessToken, userId, tokenProviderPolicy.getRemainingTimeFromToken(accessToken)),
            RefreshToken.of(refreshToken, userId, tokenProviderPolicy.getRemainingTimeFromToken(refreshToken)));

    return new AuthDto(accessToken, refreshToken, userId);
}

private void saveTokens(AccessToken accessToken, RefreshToken refreshToken) throws JsonProcessingException {
    tokenRepository.saveToken(accessToken.getId(), accessToken);
    tokenRepository.saveTokenIdx(accessToken.getUserId(), accessToken);
    tokenRepository.saveToken(refreshToken.getId(), refreshToken);
    tokenRepository.saveTokenIdx(refreshToken.getUserId(), refreshToken);
}

 

 

 

7. 테스트

 

먼저 싱글 스레드 방식에서 해당 코드가 문제가 없는지 테스트하고 멀티 스레드 환경으로 테스트를 마무리하도록 하겠습니다.

 

@Test
@DisplayName("토큰이 모두 없다면 생성하여 저장한다.")
public void getTokenIfExistOrSetAndGet_st() throws Exception {
    //given
    AuthTokenDto[] results = new AuthTokenDto[count];
    createMemberAndAuthorities();

    //when
    for (int i = 0; i < count; i++) {
        results[i] = tokenRedisService
                .getTokenIfExistOrSetAndGet(members[i].getUserId(), authorities.get(i));
    }

    //then
    assertionsGetTokenIfNotExists_And_notSameAllTokens(results);

}

private void assertionsGetTokenIfNotExists_And_notSameAllTokens(AuthTokenDto[] results) {
    Set<String> accessTokenSet = new HashSet<>();
    Set<String> refreshTokenSet = new HashSet<>();

    for (int i = 0; i < count; i++) {
        assertThat(results[i]).isNotNull();
        accessTokenSet.add(results[i].getAccessToken());
        refreshTokenSet.add(results[i].getRefreshToken());
    }
    assertThat(accessTokenSet.size()).isEqualTo(count);
    assertThat(refreshTokenSet.size()).isEqualTo(count);

}

private void createMemberAndAuthorities() {
    for (int i = 0; i < count; i++) {
        members[i] = memberRepository.save(Member.builder()
                        .email("kose" + i + "@naver.com")
                        .userId(UUID.randomUUID().toString())
                        .password(UUID.randomUUID().toString())
                .build());
        authorities
                .add(Arrays.asList(authorityRepository.save(new Authority(members[i], Authorities.ROLE_USER))));
    }
}

 

테스트를 진행한 결과, transaction에서 에러가 발생합니다. ㅜㅜ

눈물을 머금고 에러 로그를 분석해 보겠습니다. 

 

 

분명 aop에서 redisTemplate.multi() 실행 후, rollback 혹은 commit에서 redistemplate.discard() , redistemplate.exec()를 선언했는데 무슨 이유일까요?

 

스프링 Redis 공식문서에서 해당 문제를 해결할 수 있었습니다. 
redis에서 트랜잭션 지원을 받기 위해서는 명시적으로 redistemplate를 초기화하여 빈을 등록할 때, setEnableTransactionSupport(true)로 선언해주어야 한다고 합니다. 

 

 

 

 

@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
    RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
    redisTemplate.setConnectionFactory(connectionFactory);
    redisTemplate.setEnableTransactionSupport(true);

    redisTemplate.setKeySerializer(new StringRedisSerializer());
    redisTemplate.setValueSerializer(new StringRedisSerializer());
    redisTemplate.setHashKeySerializer(new StringRedisSerializer());
    redisTemplate.setHashValueSerializer(new StringRedisSerializer());
    return redisTemplate;
}

 

 

따라서, 코드를 수정하고 테스트를 다시 실행하면 분산락 실행 -> 트랜잭션 실행 -> 트랜잭션 커밋 or 롤백 -> 분산락 해제 순서로 적용된 것을 확인할 수 있습니다.

 

여러 개의 에러 테스트도 진행해야 하지만, 분량으로 인해 모든 테스트를 글로 남기기는 한계가 있어서 이 포스팅에서는 동시성 테스트 결과만 남기도록 하겠습니다.

 

@Test
@DisplayName("같은 아이디로 생성요청이 오면, 1회 토큰을 발급하고 나머지 토큰은 동일해야한다. mt")
public void getTokenIfExistOrSetAndGet_mt() throws Exception {
    //given
    ExecutorService executorService = Executors.newFixedThreadPool(32);
    CountDownLatch latch = new CountDownLatch(count);

    AuthTokenDto[] results = new AuthTokenDto[count];
    createMemberAndAuthorities();

    //when
    for (int i = 0; i < count; i++) {
        int finalIdx = i;
        executorService.submit(() -> {
            try {
                results[finalIdx] = tokenRedisService
                        .getTokenIfExistOrSetAndGet(members[0].getUserId(), authorities.get(0));
            }
            catch (Exception e) {
                e.printStackTrace();
            }
            finally {
                latch.countDown();
            }
        });
    }

    latch.await();
    //then

    assertionsCreateTokenOnlyFirstRequestAtSameUserId_and_sameTokensAtSameUserId(results);

}

private void assertionsCreateTokenOnlyFirstRequestAtSameUserId_and_sameTokensAtSameUserId(AuthTokenDto[] results) {
    Set<String> accessTokenSet = new HashSet<>();
    Set<String> refreshTokenSet = new HashSet<>();

    for (int i = 0; i < count; i++) {
        assertThat(results[i]).isNotNull();
        accessTokenSet.add(results[i].getAccessToken());
        refreshTokenSet.add(results[i].getRefreshToken());
    }
    assertThat(accessTokenSet.size()).isEqualTo(1);
    assertThat(refreshTokenSet.size()).isEqualTo(1);
}

 

 

 

동시성 테스트 결과 싱글 스레드에서 발생했던 에러가 동일하게 적용되었습니다. transaction 적용을 위해 redistemplate 초기화 과정에서 명시적으로 트랜잭션을 적용한다고 선언했지만 왜 이번에도 트랜잭션이 유효하지 않다는 문제가 발생한 것일까요?

 

아래부터는 제가 문제를 해결하는 과정에서 생각한 내용입니다. 사실과 다를 수 있습니다.!

 

이 문제는 매우 복잡한 문제로, 멀티 스레드 환경에서 발생하는 문제의 주요 원인은 스레드 간 공유되는 리소스에 대한 동시 접근으로 부터 기인합니다.

 

저는 ThreadLocal로 txActive 변수를 스레드별로 독립적으로 적용하고 있습니다. 하지만, redistemplate는 싱글톤으로 선언되었기 때문에 스레드 스위칭 과정에서 여전히 redistemplate가 공유되어 사용되고 있습니다. 따라서, 스레드 스위칭 과정에서 트랜잭션이 유효하지 않은 스레드가 다음 요청을 수행하고 있기 때문에 커밋이나 롤백되는 과정에서 트랜잭션이 유효하지 않다는 결과를 받은 것입니다.

 

그렇다면 RDBMS의 데이터 저장 기술을 제공하는 JPA는 어떻게 스레드 간 트랜잭션을 공유할 수 있는 것일까요?

Jpa로 RDBMS로 트랜잭션을 관리하면 스프링에서 제공하는 트랜잭션 기능을 사용하는데, 스프링 트랜잭션 매니저가 스레드에 대해서 개별적인 트랜잭션이 적용될 수 있도록 보장합니다. 따라서, 스레드 스위칭이 발생하더라도 이전 트랜잭션이 유효하기 때문에 커밋 및 롤백 시 유효한 데이터 관리가 가능한 것입니다.

 

하지만, Redis는 multi()로 명령어를 시작하는데, Jpa transaction과 다르게 스레드간 트랜잭션이 유효하지 않습니다. 따라서 스레드 스위칭이 발생하는 시점에 이전 트랜잭션이 유효하지 않기 때문에 이러한 에러가 발생합니다.

 

이 문제를 해결하기 위해서는 스레드간 개별적인 redistemplate 연결이 필요하고 개별적인 인스턴스 별로 각각 트랜잭션 연결이 유지되어야 했습니다.

 

 

 

8. ThreadLocal 범위 수정하기 & 테스트

 

멀티 스레드 환경에서 redistemplate에 적용되는 connection을 개별 스레드별로 관리되도록 처리하기 위해서는 ThreadLocal을 활용할 수 있습니다. 스레드 로컬은 주로 로깅이나 트랜잭션과 같이 스레드 간 변수 공유가 있으면 안되는 상황에 적용합니다. 즉 요청을 수행하는 스레드가 해당 요청을 마칠 때까지 단일 스레드 형식으로 처리됩니다. 이 기능을 활용한다면 멀티 스레드에서 발생하는 트랜잭션 공유 문제를 ThreadLocal의 개별 커넥션으로 해결할 수 있습니다.

 

 

 

이제 코드를 다시 수정하겠습니다.

 

@Slf4j
@Aspect
@Component
@Order(2)
@RequiredArgsConstructor
public class RedisTransactionCustom2Aspect {

    private final RedisConnectionFactory connectionFactory;
    private final ThreadLocal<RedisConnection> threadLocal = new ThreadLocal<>();

    private RedisConnection getRedisConnection() {
        RedisConnection redisConnection = threadLocal.get();
        if (redisConnection == null) {
            redisConnection = connectionFactory.getConnection();
            threadLocal.set(redisConnection);
        }

        return redisConnection;
    }

    @Around("liar.memberservice.common.aop.Pointcuts.transactionMethod() && args(userId, authorities)")
    public AuthTokenDto runWithTx(ProceedingJoinPoint joinPoint, String userId, List<Authority> authorities) throws Throwable {
        getRedisConnection().multi();

        try {
            Object proceed = joinPoint.proceed();

            if (proceed != null) {
                return (AuthTokenDto) proceed;
            }
        } catch (Throwable throwable) {
            throw throwable;
        }
        return null;
    }

    @AfterReturning("liar.memberservice.common.aop.Pointcuts.transactionMethod()")
    public void commitTx() {
        try {
            getRedisConnection().exec();
        } catch (Exception e) {
            getRedisConnection().discard();
        } finally {
            threadLocal.remove();
        }
    }
    
    @AfterThrowing("liar.memberservice.common.aop.Pointcuts.transactionMethod()")
    public void rollbackTx() {
        try {
            getRedisConnection().discard();
        }  catch (Exception e) {
            e.printStackTrace();
        } finally {
            threadLocal.remove();
        }
    }
}

 

먼저 ThreadLocal로 RedisConnection의 생명주기를 관리합니다. 현재 ThreadLocal이 비어있다면, RedisConnectionFactory에서 connection을 가져옵니다, 그리고 현재 ThreadLocal에 커넥션을 연결합니다. 이후, 트랜잭션이 시작되는 aop 프록시가 실행될 때, 개별적인 connection이 진행됩니다. 각 스레드는 개별적인 커넥션으로 트랜잭션을 유지하므로 스레드 간 스위칭이 발생하더라도 threadLocal을 획득한 스레드가 해당 요청을 전적으로 관리할 수 있게 됩니다

 

정리하면, 이전 코드는 커넥션은 그대로 유지하되, 스레드 별로 개별적인 트랜잭션을 적용하여 싱글 스레드에서는 트랜잭션이 유지되었지만 멀티 스레드에서는 스레드간 데이터 공유로 인해, 트랜잭션이 적용되지 않는 스레드가 접근하여 커밋이나 롤백을 수행하므로 트랜잭션이 유효하지 않기에 에러가 발생한 것입니다.

 

하지만 수정한 코드는 커넥션 자체부터 스레드 로컬을 적용하였으므로 redistemplate이 생성되는 시점에서 각 커넥션은 개별 스레드만 접근할 수 있도록 처리된 것입니다.

 

 

드디어 길고 길었던 테스트가 성공합니다 ㅠㅠ!

 

 

 

9. 느낀 점

 

백엔드에서 동시성 문제와 트랜잭션 관리는 고질적인 문제이며 가장 어려운 문제임이 틀림없습니다. 이번 문제를 해결하며, 거의 20시간 가까이 소모했던 것 같습니다. 


먼저 crudRepository -> redistemplate로 리펙토링 -> 분산락 트랜잭션 관리 -> 동시성 문제를 위한 ThreadLocal 생명주기 관리까지 하나의 에러를 해결하는 과정에서 정말 많은 부분을 수정하고 배울 수 있었던 것 같습니다.

 

사실 토이프로젝트에서 동시성 문제를 고려하지 않고 작성되는 코드도 많이 있습니다. 하지만, 소비자 입장에서 생각해 봤을 때, 내 중요한 데이터가 원자성이나 무결성을 보장받지 못한다면 그 서버는 경쟁사회에서 도태될 것입니다.

 

백엔드는 화려하게 보이는 것은 없지만 소비자의 신뢰도를 좌지우지하는 정말 중요한 역할을 수행하는 것 같습니다.

이번 문제를 해결하며, 정말 포기하고 싶다는 머리 끝까지 들었습니다. 하지만, 시간이 걸리더라도 반드시 해결하겠다는 마음이 있었고, 이 기회로 저 스스로 한층 더 성장할 수 있으리라 확신을 할 수 있었습니다.

 

나아가 제가 입사하게 될 회사에서 만약 비슷한 문제가 발생한다면, 이번 경험을 토대로 빠른 문제 해결에 이바지할 수 있을 것이라는 생각을 하게 되었습니다.

 

아직 해결되지 않은 문제도 많이 있습니다. ThreadLocal로 매번 커넥션을 연결하는 과정은 서버 내부에서 많은 부하를 일으킬 수 있습니다. 이 문제를 해결하기 위해 또 많은 연구를 할 예정이고 글로 남기도록 하겠습니다.

 

길었지만 읽어주셔서 감사드립니다.!!!

 

 

안녕하세요. 회사와 함께 성장하고 싶은 KOSE입니다.

 

이번 포스팅은 Contoller와 Service를 리펙토링 하여 Controller의 부하를 줄이고 계층 간 분리하는 과정을 작성하고자 합니다.

Spring에서 Controller와 Service는 각각 하는 역할이 다릅니다. Controller는 Service에 의존 관계 주입을 받고 특정 Uri로 오는 요청을 처리하여 응답을 보내는 역할을 수행합니다. Service는 Controller에서 받은 요청을 위임받아 핵심 비즈니스 로직을 수행하여 실행하거나 값을 리턴하는 역할을 수행합니다. 

 

저는 코드를 작성할 때 Controller와 Service를 서로 의존 관계 주입으로 설정할 때 고민되는 것들이 있습니다. 

첫째는 Controller의 역할 과중이고 둘 째는 Dto의 계층적 분리입니다.

따라서, 이번 글은 이 두가지를 해결하기 위해 제가 시도했었던 방식을 공유드리고자 합니다.!

 

 

1. Controller의 부담을 줄이는 Fillter와 Interceptor, ControllerAdvice

 

 

a. Filter

 

Filter는 Servlet 컨테이너에서 요청과 응답을 처리하기 전후에 동작하는 기능입니다. 인코딩 변환, 보안 체크, 로깅 등 작업을 수행합니다. Filter는 Servlet 컨테이너에 등록되며 SpringSecurity를 사용한다면 DispatcherServlet 앞 단에서 적용됩니다.

 

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

    http
            .authorizeHttpRequests((requests) -> requests
                    .requestMatchers(memberServiceWhiteList)
                    .permitAll()
                    .anyRequest()
                    .authenticated()
            )

 

 

b. Interceptor

 

Interceptor는 Spring MVC 요청 전 후에 동작하는 기능입니다. Interceptor도 권한 혹은 인증 등을 처리할 수 있고, 로깅 용도로도 사용됩니다. DispatcherServlet과 Controller 사이에 위치하여 중간 단계에서 요청을 인터셉터하는 역할을 수행합니다.

 

Interceptor는 HandlerInterceptor를 구현하여 생성할 수 있습니다. preHandle은 요청 처리 이전에 적용하는 것으로 보통 preHandle에서 인증 혹은 보안 처리를 담당하고  있습니다.

 

간단한 예시로 하단에는 Authorization이라는 헤더가 존재하는지 여부를 파악하고 없다면 예외처리하여 더 이상 요청이 이뤄지지 않도록 하는 역할을 수행합니다. 주의할 점은 Component 대상으로 Interceptor를 등록하여야 합니다.

 

@Slf4j
@Component
public class WebInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        String userId = request.getHeader("Authorization");
        if (userId == null) {
            log.info("Exception");
            throw new NotFoundUserException();
        }

        log.info("request = {}", request.getRequestURI());
        return true;
    }
}

 

 

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new WebInterceptor());
    }
}

 

 

C. ControllerAdvice

 

ControllerAdvice는 Controller처럼 MVC 패턴에서 컨트롤러의 역할을 수행합니다, ControllerAdvice는 예외처리에 특화된 기능으로 다수의 컨트롤러에서 발생한 공통적인 예외처리를 담당하는 역할을 수행합니다. 

선언은 @RestControllerAdvice 어노테이션을 활용하여 Rest전용 ControllerAdvice를 생성하겠다고 선언합니다.

이 경우, 예외처리를 공통적으로 ControllerAdvice에서 처리하게 됩니다.

 

@Slf4j
@RestControllerAdvice(annotations = RestController.class)
public class ExceptionControllerAdvice {
    
    /**
     *  권한 없는 사용자 접근
     */
    @ResponseStatus(UNAUTHORIZED)
    @ExceptionHandler
    public ResponseEntity<ErrorDto> notExistsRefreshTokenHandler(NotExistsRefreshTokenException e) {
        return new ResponseEntity<>(new ErrorDto(e.getErrorCode(), e.getMessage()), UNAUTHORIZED);
    }

 

ControllerAdvice는 DispatcherServlet과 Controller 사이에 위치하기 때문에 만약 Controller 다음 단에서 예외가 발생한 경우, ControllerAdvice가 해당 요청을 위임받을 수 있습니다.

 

 

만약 Interceptor에서 예외가 발생하면 어떻게 될까요?

이때는 Interceptor에서 예외가 발생하면 DispatcherServlet으로 이동해서 DispatcherServlet에서 예외 처리를 위해 ControllerAdvice를 호출합니다. 즉, Interceptor를 통과해야만 요청이 전달되는 Controller에는 예외가 전달되지 않는 것입니다. 따라서 Interceptor를 도입할 경우 예외처리기를 ControllerAdvice를 이용하는 것이 좋습니다.!

 

 

그렇다면 Filter에서 예외가 발생한 경우는 어떻게 될까요? 
Filter는 Servlet - Filter - DispathcherServlet 사이에 존재하므로 먼저 Servlet에서 예외 처리기를 찾고 없다면 DispathcherServlet의 ControllerAdvice에서 예외처리를 찾습니다. 없다면 마지막으로 다시 Servlet으로 이동하여 예외처리를 진행합니다.

 

그림으로 정리하면 다음과 같습니다.

 

 

 

 

2. Dto 계층적 분리하기

 

Controller에서 계층적으로 역할을 분리하는 과정을 수행했다면, 다음에 발생하는 문제는 Controller와 Service를 연결해 주는 Dto의 존재입니다. Dto는 Data Transfer Object로 계층 간 데이터 교환 혹은 데이터 전송 객체를 의미합니다. 

계층적인 모델링을 구축할 때 조심해야하는 부분이 바로 Service는 Controller에 의존하지 않아야 하는 것입니다.

즉, Service 레이어는 핵심 비즈니스 로직을 수행해야 하는 곳이기 때문에 Controller가 바뀌더라도 Service 단에서 이루어지는 핵심 비즈니스 로직이 영향을 받으면 안 됩니다. 즉 Controller -> Service로의 접근은 가능하더라도 Service -> Controller로의 접근은 지양되며 순환참조를 발생시킬 수도 있습니다.

 

계층으로 확인하면 다음과 같습니다.

 

 

클라이언트의 요청(Request)는 Controller로 전달이 되며 Controller -> Service -> Repository 순서로 Dto가 전달이 되고 다시 역순으로 Dto로 전달이 됩니다.

 

이 과정에서 생기는 트레이드 오프는 Request요청과 Dto가 서로 동일할 때입니다.

가령, Login을 담당하는 Dto가 있다면, 이는 Client의 Request도 동일할 것입니다.

이 경우 "과연 Dto 두 계층을 분리해야 할까요?"입니다.

 

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class LoginDto {

    @NotNull
    private String email;

    @NotNull
    private String password;

}

 

정답은 없는 것 같습니다. 먼저 그대로 사용할 경우 Dto를 다시 Dto로 변환하는 과정이 생략되고 코드가 간단해지기 때문에 불필요한 코드 중복을 줄일 수 있습니다. 반면 Request 요청과 Dto를 분리할 경우 계층적 역할을 구분할 수 있고 무엇보다 만약 Controller에서 요구하는 Request 요청이 수정되었을지라도 핵심 비즈니스 로직을 처리하는 Service에서도 코드를 수정해야 하는 불상사를 줄일 수 있습니다.

 

저는 Controller와 Service 계층의 구분을 명확하게 하고, 추후 생길 수 있는 코드 수정에서 Service단이 영향을 받는 것을 최소화하기 위해 Dto를 분리하는 선택을 하였습니다. 

 

이제 코드로 리펙토링 하는 과정을 정리하도록 하겠습니다.

 

 

 

3. Controller 리펙토링 

 

먼저 이전에 작성했던 코드는 다음과 같습니다.

 

    /**
     *
     * 회원 가입 요청
     * BAD_REQUEST: 400 (요청한 파라미터의 타입 에러 혹은 바인딩 에러)
     * CONFLICT: 409 (요청한 회원가입 이메일 이미 존재)
     * OK: 200 (가입 완료)
     *
     * @return
     */
    @PostMapping("/register")
    public ResponseEntity formRegister(@Valid @RequestBody FormRegisterUserDto dto,
                                       BindingResult bindingResult) {

        if (bindingResult.hasErrors()) throw new BindingInvalidException();

        if (!memberService.registerForm(dto)) {
            throw new UserRegisterConflictException();
        }

        return ResponseEntity.ok(SendSuccess.of());
    }

 

이 코드는 Json으로 폼 회원가입을 요청을 처리하는 Controller입니다.

이 코드에는 BindingError를 처리하는 코드와 registerForm()을 처리하는 코드로 구성되어 있습니다.

Controller에서는 다음과 같이 바인딩 에러를 처리하는 부가기능과 핵심 비즈니스 로직을 수행해야 하는 코드가 같이 있기 때문에 이를 분리하는 과정이 필요하였습니다.

 

먼저, 바인딩 에러와 관련된 부분은 Dto에 작성된 @NotNull과 같은 어노테이션의 제약 조건에 위배되지 않는지 판단하는 로직입니다. 이 코드에서 @Validated 어노테이션을 적용하면 Dto의 제약 조건에 위배된다면 MethodArgumentNotValidException() 예외를 발생시킵니다.

 

앞 서 정의한 ExceptionControllerAdvice를 활용하면, Controller에서 BindingResult를 처리하여 Exception 예외를 발생시키는 코드를 제거할 수 있습니다.

 

코드를 수정하면 다음과 같습니다.

 

@PostMapping("/register")
public ResponseEntity formRegister(@Validated @RequestBody FormRegisterUserDto dto) {
    
    if (!memberService.registerForm(dto)) {
        throw new UserRegisterConflictException();
    }

    return ResponseEntity.ok(SendSuccess.of());
}

 

앞 서 정의한 bindingResult.hasError()를 제외함으로써 코드가 간결해졌습니다.

이제 bindingError를 ExceptionControllerAdvice에서 예외를 처리하도록 하겠습니다.

 

/**
 *  바인딩 에러
 */
@ResponseStatus(BAD_REQUEST)
@ExceptionHandler
public ResponseEntity<ErrorDto> MethodArgumentNotValidExceptionHandler(MethodArgumentNotValidException e) {

    List<String> errorMessages = new ArrayList<>();
    for (FieldError fieldError : e.getBindingResult().getFieldErrors()) {
        errorMessages.add(fieldError.getDefaultMessage());
    }

    return new ResponseEntity<>(new ErrorDto("400", String.join(".", errorMessages)), BAD_REQUEST);
}

 

여기서 확인할 부분은, e.getBindingResult(). getFieldErrors()입니다.

기본적으로 e.getMessage()는 해당 예외에 대한 모든 로그를 제공합니다. 클라이언트는 실제로 잘못 작성한 예외 로그만 보길 원하지만 g.getMessage()로 전송하게 되면 복잡한 예외 메시지까지 전부 전달되게 됩니다.

 

{ "code": "400", "message": "Validation failed for argument [0] in public org.springframework.http.ResponseEntity liar.memberservice.auth.controller.controller.AuthController.formRegister(liar.memberservice.auth.service.dto.FormRegisterUserDto): [Field error in object 'formRegisterUserDto' on field 'password': rejected value [kose1234]; codes [Length.formRegisterUserDto.password,Length.password,Length.java.lang.String,Length]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [formRegisterUserDto.password,password]; arguments []; default message [password],100,10]; default message [password는 10자 이상입니다.]] " }

 

따라서, FiledError에 해당하는 값을 받은 후 getDefaultMessage()를 호출하여 실제 실패한 예외 메시지만 받도록 처리합니다.

 

List<String> errorMessages = new ArrayList<>();
for (FieldError fieldError : e.getBindingResult().getFieldErrors()) {
    errorMessages.add(fieldError.getDefaultMessage());
}

 

 

그 결과 응답 메시지가 제가 원하는 형태로 전달되는 것을 확인할 수 있습니다.

 

 

 

4. FacadeService 도입하여 Dto 리펙토링 

 

현재 컨트롤러는 다양한 Service에 의존성 주입을 받고 있습니다. 실제로 코드상 문제는 없지만, 많은 의존성은 코드의 복잡성을 가중시킬 수 있습니다. 무엇보다 코드를 수정해야할 때, "이 서비스가 무슨 서비스더라...?" 생각하는 시간이 많게 되었던 것 같습니다.

 

따라서, Controller는 FacadeService에만 의존 관계 주입을 받도록 처리하여 FacadeService에서 기타 다른 서비스로 의존성 주입을 받아 처리할 수 있도록 하였습니다.

 

 

FacadeService를 중간단계에 놓은 후, 얻을 수 있었던 장점은 Controller와 Service 사이에 Dto를 분리할 수 있었다는 점입니다.

코드로 확인해 보면 다음과 같습니다.

 

    /**
     *
     * 회원 가입 요청
     * BAD_REQUEST: 400 (요청한 파라미터의 타입 에러 혹은 바인딩 에러)
     * CONFLICT: 409 (요청한 회원가입 이메일 이미 존재)
     * OK: 200 (가입 완료)
     *
     * @return
     */
    @PostMapping("/register")
    public ResponseEntity formRegister(@Validated @RequestBody FormRegisterRequest request) {
        facadeService.register(request);
        return ResponseEntity.ok(SendSuccess.of());
    }

 

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class FacadeService {

    private final MemberService memberService;
    private final TokenService tokenService;

    @Transactional
    public void register(FormRegisterRequest request) {
        if (!memberService.registerForm(RequestMapperFactory.mapper(request))) {
            throw new UserRegisterConflictException();
        }
    }

 

@Component
public class RequestMapperFactory {

    public static FormRegisterUserDto mapper(FormRegisterRequest request) {
        return FormRegisterUserDto.builder()
                .email(request.getEmail())
                .password(request.getPassword())
                .username(request.getUsername())
                .build();
    }

 

컨트롤러는 FacadeService에만 의존하게 되고, FacadService는 memberService, tokenService와 의존 관계 주입을 받습니다. 이 단계에서 Request와 Dto를 변형해 주는 Factory클래스의 mapper 메서드를 도입함으로써 Dto를 분리할 수 있었습니다.

 

그 결과, Dto에 있었던 검증 로직은 FormRegisterRequest에서 처리하게 되고, FormRegisterDto는 Dto 임무만 처리할 수 있도록 할 수 있었습니다.

 

@Getter
@NoArgsConstructor
public class FormRegisterRequest {

    @NotNull
    private String username;

    @NotNull
    private String email;

    @NotNull
    @Length(min = 10, max = 100, message = "password는 10자 이상입니다.")
    private String password;

    @Builder
    public FormRegisterRequest(String username, String email, String password) {
        this.username = username;
        this.email = email;
        this.password = password;
    }

}

 

 

@Getter
@NoArgsConstructor
public class FormRegisterUserDto {
    
    private String username;
    private String email;
    private String password;

    @Builder
    public FormRegisterUserDto(String username, String email, String password) {
        this.username = username;
        this.email = email;
        this.password = password;
    }
}

 

다시 테스트를 진행하면 테스트를 성공한 것을 확인할 수 있습니다.

 

 

 

5. 느낌점

 

리펙토링하는 과정에서 Filter, Interceptor, ControllerAdvice 등의 역할을 한 번 더 짚고 넘어갈 수 있었습니다.

크게 문제가 없다고 넘길 수 있는 부분이었지만, 이 코드보다 더 좋은 선택지는 없을지 고민하고, 한 번 이 방법을 적용해볼까?

하며 코드를 작성하니, 이전에 미처 생각하지 못했던 예외 전파 순서를 깊게 공부해볼 수 있었습니다.

 

Dto는 항상 코드를 작성할 때마다 고민이 되는 부분입니다. mapper를 도입하는 것이 오히려 복잡성을 가중시키는 것 같아보이지만, 추후 인증의 문제로 다른 field가 추가되어야 하는 상황이라면 Dto를 분리한 코드는 이러한 문제에 직면하더라도 수정해야할 코드의 수가 매우 줄어들 것이라고 생각하게 되었습니다.

 

 

부족하지만 오늘도 읽어주셔서 감사드립니다.!

안녕하세요. 회사와 함께 성장하고 싶은 KOSE입니다.

 

이번 포스팅은 관계형 데이터베이스에서 발생하는 동시성 문제를 해결하는 과정을 작성하려고 합니다.

 

다른 서비스들은 모두 동시성 문제를 해결하여 완성시켰지만, member-service 프로젝트는 동시성 문제를 해결하지 않았습니다.

따라서 리펙토링을 수행하며 동시성 문제가 해결되는 과정을 정리하고자 합니다.

 

 

1. 동시성 문제

 

동시성 문제는 가장 까다롭고 어려운 문제입니다. 성능과 안정성을 모두 고려해야 하는 난제로, 관계형 데이터베이스 혹은 인메모리 데이터베이스 등 다양한 분야에서 발생하는 문제입니다. 동시성 문제는 다수의 스레드가 동시에 공유 자원에 접근할 때 발생합니다. Read 모드로 접근하는 경우, 락의 상황에 따라 다르지만 일반적으로 동시성 문제가 발생하지 않는다고 판단합니다.

하지만 Write 모드로 데이터베이스에 접근할 때, 다수의 쓰레드가 동시에 Write 요청을 수행하면 원하지 않는 결과가 발생할 수 있습니다.

 

단일 Thread에서 요청을 수행할 경우, 만약 수량을 10개씩 3번 빼라는 요청이 오면 10개씩 정상적으로 수량이 감소합니다. 

하지만 멀티 쓰레드에서는 수량을 10개씩 3번 빼라는 요청이 오면 어떻게 될까요? 맨 오른쪽 그림과 같이 70이 나온다는 보장을 하기 어렵습니다. 동시에 스레드가 접근할 경우 제일 먼저 나온 100개라는 공유 자원에 동시에 접근할 수 있기 때문입니다. 따라서 A 스레드와 B 스레드는 동시에 100개 자원에 접근하게 되고 수량을 제거하고 90이라는 값을 리턴하게 됩니다. C 스레드는 A, B의 결과 이후에 수행된다고 가정하면 90이라는 값을 읽고 80을 리턴하게 됩니다.

 

 

만약, 선착순이나 한정된 개수만 판매해야하는 비즈니스 로직이 있다면, 혹은 단 하나의 유니크한 값만 가질  수 있는 제약조건이 있는 데이터베이스라면, 동시성 문제가 발생할 때 큰 장애가 일어날 수 있습니다. 따라서 이러한 문제는 Lock을 활용하거나, RedissonClient 등 다양한 방법을 통해 동시성 문제를 제어할 수 있습니다.

 

 

2. 동시성 발생 확인하기

 

먼저, 제가 작성했던 이전 코드는 Member 객체를 저장하는 일반적인 코드입니다.

 

@Getter
@RequiredArgsConstructor
@Entity
public class Member extends BaseEntity implements Persistable {

    @Id
    @Column(name = "member_id")
    private String id;
    
    --- 중략 ---
    
    @Builder
    public Member(String userId, String password, String registrationId, String registerId,
                  String email, String picture, String username) {
        this.id = UUID.randomUUID().toString();
        this.userId = userId;
    
    --- 중략 ---
    
     /**
     * Returns if the {@code Persistable} is new or was persisted already.
     *
     * @return if {@literal true} the object is new.
     */
    @Override
    public boolean isNew() {
        return super.getCreatedAt() == null;
    }
@Repository
public interface MemberRepository extends JpaRepository<Member, String> {

    Member findByEmail(String email);

    Optional<Member> findByRegisterId(String registerId);
    Optional<Member> findByUserId(String userId);
}

 

먼저 싱글 스레드 환경의 테스트에서는 동일한 email로 여러 번 계정 생성 요청이 오면 성공합니다.

 

@Test
@DisplayName("단일 스레드 save")
public void save_single() throws Exception {
    //given
    int result = 0;
    int count = 5;

    Member[] members = new Member[count];
    Member kose = Member.builder().email("kose@naver.com").password("1234").username("kose").build();

    //when
    for (int i = 0; i < count; i++) {
        try{
            Member member = memberRepository.findByEmail("kose@naver.com");

            if (member == null) members[i] = memberRepository.save(kose);
            else members[i] = null;

        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    for (int i = 0; i < count; i++) if (members[i] != null) result++;

    //then
    assertThat(result).isEqualTo(1);

}

 

 

하지만, 아쉽게도 이 코드는 쓰레드에 세이프하지 않은 코드입니다. 멀티 스레드 테스트로 확인해 보겠습니다. 멀티 스레드를 구축하는 데는 다양한 방법이 있습니다. new Thread(){}를 선언하여 내부에 익명 클래스 형태 혹은 람다 형태로 스레드 환경을 만들 수 있습니다.

 

new Thread() {
    
}.start();

new Thread(() -> {
    
}).start();

 

저는 countDownLatch()를 활용하여 테스트 환경을 구축하였습니다.

 

int threadCount = 5;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch countDownLatch = new CountDownLatch(threadCount);

//when
for (int i = 0; i < threadCount; i++) {
    int finalIdx = i;
    executorService.submit(() -> {
        try{

        } catch (Exception e) {

        } finally {
            countDownLatch.countDown();
        }
    });
}

countDownLatch.await();

 

coundDownLatch.countDown()은 스레드 카운트 개수를 줄이는 역할을 수행하고 coundDownLatch.await()는 모든 카운트 개수가 줄어들 때까지 대기하는 역할을 수행합니다.
따라서, await() 이후에 테스트 검증을 위한 처리작업이나 //then 에 해당하는 검증 로직을 추가할 수 있습니다.

 

@Test
@DisplayName("멀티 스레드 save")
public void save_mt() throws Exception {
    //given
    int threadCount = 5;
    ExecutorService executorService = Executors.newFixedThreadPool(32);
    CountDownLatch countDownLatch = new CountDownLatch(threadCount);

    Member[] members = new Member[threadCount];
    Member kose = Member.builder().email("kose@naver.com").password("1234")
            .username("kose").build();

    //when
    for (int i = 0; i < threadCount; i++) {
        int finalIdx = i;
        executorService.submit(() -> {
            try{
                Member member = memberRepository.findByEmail("kose@naver.com");

                if (member == null) members[finalIdx] = memberRepository.save(kose);
                else members[finalIdx] = null;

            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                countDownLatch.countDown();
            }
        });
    }

    countDownLatch.await();

    int result = 0;
    for (int i = 0; i < threadCount; i++) if (members[i] != null) result++;

    //then
    assertThat(result).isEqualTo(1);

}

 

익명 클래스나 람다식에서는 암묵적 fianl 타입의 값이 입력되어야 합니다. 즉, 배열에 선언되는 인덱스는 final 타입으로 선언하여야 의도하지 않은 값 변경을 막고 생명 주기를 명확하게 할 수 있습니다.

 

결과는 다음과 같이 실패합니다. 실패 발생 로그는 id는 primary 키이므로 유니크 제약조건이 적용되는데 같은 3개의 유니크한 값이 리턴되었기 때문입니다. 실제 서비스 환경에서는 큰 장애로 이어질 수 있습니다.

 

 

 

3. 동시성 문제 해결하기 - Lock 비교

 

동시성 문제를 해결하기 위해서는 락 기능을 활용할 수 있습니다. 락은 기본적으로 특정 자원에 대한 변경 요청을 할 때 락을 획득하고 변경을 실행한 후 락을 해제하는 개념입니다. 따라서, 락이 걸려있는 자원에 대한 변경 요청이 오면 락이 해제될 때까지 대기해야 합니다.

 

동시성 문제를 해결하기 위해서는 Pessimistic Lock 혹은 RedissonClient의 분산락 등을 활용할 수 있습니다. 하지만, 이는 간단한 문제가 아니며 작성한 코드와 환경에 따라 달라질 수 있습니다.
(OptimissticLock 도 사용할 수 있는데 이는 버전 관리에 적절하기 때문에 두 가지 방법으로 적용하였습니다)

 

Pessimistic Lock은 비관적인 방법으로 락을 적용하는 방식입니다. 트랜젝션 도중에 다른 트랜젝션이 데이터를 변경할 가능성이 있으면 해당 값에 락을 걸어 변경을 막도록 하는 방법입니다. 따라서, 여러 트랜잭션이 동시에 데이터에 접근하더라도, 락을 걸게 됨으로써 데이터의 동시적 변경 요청을 방지하는데 도움을 줄 수 있습니다.

하지만, 데이터베이스 자체에 락을 걸어버리는 개념이기 때문에 성능 문제나 데드락의 가능성이 높기 때문에 Pessimistic Lock을 사용할 때 충분한 점검이 반드시 필요합니다.

 

두 번째 방법으로, RedissonClient의 분산락을 활용할 수 있습니다.  분산락은 기본적으로 락을 획득하여 락이 유지되는 시간 동안 비즈니스 로직을 처리한 후 락을 해제하는 개념입니다. 실제 분산환경에서 사용하기에 적합하다는 평가를 받고 있습니다. 

하지만, 실제 비즈니스 로직이 수행되는 기간 동안 Redis 클러스터 지연이 발생한다면 락이 해제되는 시점에 다른 요청이 락을 획득하여 공유 자원 변경을 요청할 수도 있습니다. 이 경우 동시성 문제가 발생할 수 도 있습니다. 따라서, Redis로 요청이 수행되는 기간동안 지연이 발생할 가능성을 배제할 수 없기에 적절한 추가 대응 조치가 필요합니다. 

또한, 분산락의 경우는 외부의 Redis를 사용해야 한다는 단점이 있습니다. AWS에 배포하는 경우에 ElasticCache 같은 서버를 하나 더 추가하는 것은 비용적 부담이 될 수밖에 없습니다. 정리하면, Redis 클러스터의 지연 문제가 발생하지 않는지, 또한 외부 Redis 서버를 운영할 수 있는 비용적인 문제가 해결되었는지를 판단하여 적용하여야 합니다.

 

하지만, 아쉽게도 Pessimistic 락 혹은 RedissonClient를 사용하더라도, 동시성 문제가 발생할 수 있습니다.

 

 

실제로 JPA를 이용하여 데이터베이스와 커넥션 풀을 연결하면 데이터베이스 커밋이 완료되면 커넥션이 끊어집니다. 하지만 이는 데이터베이스에 그 값이 바로 저장되었다는 것을 의미하지 않습니다. 실제 DB는 내부적으로 복잡한 과정을 거치고 I/O 연산에서 시간이 소요될 수 있습니다. 만약 DB에 저장되는 과정에 바로 다음 요청이 동시적으로 발생한다면 DB에는 값이 온전히 저장되지 않았기 때문에, findById와 같은 요청으로 값을 찾을 때, 값이 없다고 나올 수 있는 것입니다.

 

따라서, 이 경우에는 두 가지 방법을 모두 사용하여 문제를 해결할 수 있습니다. 하지만 이 두 가지 방법을 모두 사용하는 것은 데드락을 발생시킬 수 있으므로 트랜젝션 관리를 적절하게 처리해주어야 합니다.

 

 

4. 문제 파악 및 문제 해결하기

 

 

@Transactional
    public boolean registerForm(FormRegisterUserDto dto) {

        Member findMember = memberRepository.findByEmail(dto.getEmail());

        if (findMember == null)  {
            Member user = Member.builder()
                    .userId(UUID.randomUUID().toString())
                    .username(dto.getUsername())
                    .password(passwordEncoder.encode(dto.getPassword()))
                    .email(dto.getEmail())
                    .build();

            memberRepository.save(user);
            authorityRepository.save(Authority.builder().member(user).authorities(ROLE_USER).build());

            return true;
        }
        throw new UserRegisterConflictException();
    }

 

이 소스는 email 값을 가져와서 member 객체를 찾은 후, 값이 null이라면, member와 authority를 저장하는 로직입니다.

싱글 스레드에서는 다음과 같이 정상적으로 성공합니다.

 

 

@Test
@DisplayName("싱글 스레드에서 같은 email 저장 요청이 오면  맨처음 이외에 요청은 실패한다.")
public void registerForm_single() throws Exception {
    //given
    int count = 5;
    FormRegisterUserDto dto = new FormRegisterUserDto("kose", "kose@naver.com", "12345678910");

    //when
    memberService.registerForm(dto);

    //then
    for (int i = 0; i < count; i++) {
        Assertions.assertThatThrownBy(() -> {
            memberService.registerForm(dto);
        }).isInstanceOf(UserRegisterConflictException.class);
    }
}

 

 

하지만, 동시성 테스트에서는 실패합니다.

 

@Test
@DisplayName("멀티 스레드 save")
@Transactional
public void save_mt() throws Exception {
    //given
    int result = 0;
    int threadCount = 5;
    boolean[] registers = new boolean[threadCount];

    ExecutorService executorService = Executors.newFixedThreadPool(32);
    CountDownLatch countDownLatch = new CountDownLatch(threadCount);

    FormRegisterUserDto dto = new FormRegisterUserDto("kose", "kose@naver.com", "12345678910");

    //when
    for (int i = 0; i < threadCount; i++) {
        int finalIdx = i;
        executorService.submit(() -> {
            try{
                registers[finalIdx] = memberService.registerForm(dto);
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                countDownLatch.countDown();
            }
        });
    }

    countDownLatch.await();


    for (int i = 0; i < threadCount; i++) {
        if (registers[i]) result++;
    }

    //then
    assertThat(result).isEqualTo(1);
}

 

이는 해당 코드가 스레드 세이프 하지 않기 때문입니다.

 

 

 

4. 코드 수정하기 

 

먼저, 분산락을 적용하기 위해 RedissonClient를 추가하여야 합니다.

 

//redissonClient
implementation 'org.redisson:redisson-spring-boot-starter:3.17.7'
@Configuration
@EnableAspectJAutoProxy
public class RedisAopConfig {

    @Bean
    public RedisLockAspect redisLockAspect(RedissonClient redissonClient) {
        return new RedisLockAspect(redissonClient);
    }

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://localhost:6379");
        return Redisson.create(config);
    }
}

 

 

이후 문제가 되는 코드에 RedissonClient를 적용하여 락을 걸어줍니다.

RegisterUserDto에 email은 고유한 값을 가져야 하므로, 해당 값으로 Key를 생성한 후 try - catch -finally로 
내부 로직을 작성합니다.

 

락을 획득하면 내부 비즈니스 로직이 실행이 되며, 락을 획득하지 못하면 예외가 발생합니다.
finally에서는 락을 획득한 경우에는 락을 해제하는 과정을 추가하여 데드락이 발생하는 문제를 해결하고자 하였습니다.

 

@Transactional
public boolean registerForm(FormRegisterUserDto dto) {
    String lockKey = "registerForm: " + dto.getEmail();
    RLock lock = redissonClient.getLock(lockKey);
    boolean isLocked = false;
    try {
        isLocked = lock.tryLock(20, 3, TimeUnit.SECONDS);
        if (!isLocked) {
            throw new RedisLockException();
        }
        log.info("lockKey = {} 락 획득", lockKey);
        Member findMember = memberRepository.findWithMemberForSave(dto.getEmail());
        if (findMember == null) {
            Member user = Member.builder()
                    .userId(UUID.randomUUID().toString())
                    .username(dto.getUsername())
                    .password(passwordEncoder.encode(dto.getPassword()))
                    .email(dto.getEmail())
                    .build();
            memberRepository.save(user);
            authorityRepository.save(Authority.builder().member(user).authorities(ROLE_USER).build());
            return true;
        }
        throw new UserRegisterConflictException();
    } catch (InterruptedException e) {
        throw new RedisLockException();
    } finally {
        if (isLocked) {
            lock.unlock();
            log.info("lockKey = {} 락 반환", lockKey);
        }
    }
}

 

이후 Pessimistic Lock을 적용하기 위해 Repository를 수정합니다.

 

@Repository
public interface MemberRepository extends JpaRepository<Member, String> {

	--- 중략 ---
    
    @Lock(value = LockModeType.PESSIMISTIC_WRITE)
    @Query("select m from Member m where m.email = :email")
    Member findWithMemberForSave(@Param("email") String email);
}

 

@Lock(value = LockModeType.PESSIMISTIC_WRITE)를 사용하여 락을 획득할 수 있고 내부에는 JPQL로 쿼리를 작성합니다. 이경우 findWithMEmberForSave()가 실행되는 트랜젝션에서는 쓰기 락을 획득하게 되어 다른 요청이 수행될 때 락이 해제될 때까지 기다리게 됩니다.

 

이후 테스트를 진행하면 성공하였습니다 하지만, 로그를 보면 데드락이 발생한 것을 확인할 수 있습니다.

 

 

"이유는 바로, save() 메서드에 있습니다."

 

제가 작성한 로직은

RedissonClient Lock 획득 -> Pessimistic Lock 획득 -> Pessimistic Lock 해제 -> RedissonClientLock 해제입니다.

save()는 해당 엔티티를 영속화시키기는 과정이 추가되지만 실제 데이터베이스에는 전송되지 않습니다. 이는 곧,
member 객체에 대한 데이터를 데이터베이스에 써서 락을 해제하는 과정이 실행되어야 하는데 실제로 쓰기 작업이 수행되지 않았기 때문에 영속성은 있으나 락이 해제되지 않은 것입니다. 따라서, 데드락으로 이어진 것입니다.

 

이것을 해결하기 위해서는 save() -> saveAndFlush()로 데이터베이스에 쓰기 작업을 즉각 수행하도록 처리합니다.

코드를 수정하면 다음과 같습니다.

 

@Transactional
public boolean registerForm(FormRegisterUserDto dto) {
    String lockKey = "registerForm: " + dto.getEmail();
    RLock lock = redissonClient.getLock(lockKey);
    boolean isLocked = false;
    try {
        isLocked = lock.tryLock(20, 3, TimeUnit.SECONDS);
        if (!isLocked) {
            throw new RedisLockException();
        }
        log.info("lockKey = {} 락 획득", lockKey);
        Member findMember = memberRepository.findWithMemberForSave(dto.getEmail());
        if (findMember == null) {
            Member user = Member.builder()
                    .userId(UUID.randomUUID().toString())
                    .username(dto.getUsername())
                    .password(passwordEncoder.encode(dto.getPassword()))
                    .email(dto.getEmail())
                    .build();
            memberRepository.saveAndFlush(user);
            authorityRepository.saveAndFlush(Authority.builder().member(user).authorities(ROLE_USER).build());
            return true;
        }
        throw new UserRegisterConflictException();
    } catch (InterruptedException e) {
        throw new RedisLockException();
    } finally {
        if (isLocked) {
            lock.unlock();
            log.info("lockKey = {} 락 반환", lockKey);
        }
    }
}

 

테스트 결과 데드락이 발생하지 않고 테스트에 성공한 것을 확인할 수 있습니다.

 

 

혹시 그렇다면, saveAndFlush()가 문제였을 수 있다는 생각과 함께 pessimisticLock을 해제해보았습니다.

 

@Query("select m from Member m where m.email = :email")
Member findWithMemberForSave(@Param("email") String email);

 

결과는 테스트 성공하였습니다 ㅜㅜ!!

saveAndFlush는 변경 사항을 즉시 데이터베이스에 반영하는 기능을 수행하기 때문에 락이 해제되어 변경 사항이 즉시 반영되지 않는 경우에 해결할 수 있습니다.!!!

 

 

 

마지막으로 해당 로직을 AOP로 분리하여 횡단 관심사와 핵심 로직을 분리하는 과정을 처리하도록 하겠습니다.

 

@Transactional
public boolean registerForm(FormRegisterUserDto dto) {
    Member findMember = memberRepository.findWithMemberForSave(dto.getEmail());
    if (findMember == null) {
        Member user = Member.builder()
                .userId(UUID.randomUUID().toString())
                .username(dto.getUsername())
                .password(passwordEncoder.encode(dto.getPassword()))
                .email(dto.getEmail())
                .build();
        memberRepository.saveAndFlush(user);
        authorityRepository.saveAndFlush(Authority.builder().member(user).authorities(ROLE_USER).build());
        return true;
    }
    throw new UserRegisterConflictException();
}

 

@Around("execution(* liar.memberservice.auth.service.MemberService.registerForm(..)) && args(dto)")
public boolean registerForm(ProceedingJoinPoint joinPoint, FormRegisterUserDto dto) throws Throwable {

    String lockKey = "registerForm: " + dto.getEmail();
    return (boolean) executeWithRedisLock(joinPoint, lockKey);
}

public Object executeWithRedisLock(ProceedingJoinPoint joinPoint, String lockKey) throws Throwable {
    RLock lock = redissonClient.getLock(lockKey);

    try {
        boolean isLocked = lock.tryLock(20, 3, TimeUnit.SECONDS);
        if (!isLocked) {
            throw new RedisLockException();
        }
        log.info("lockKey = {} 락 획득", lockKey);

        return joinPoint.proceed();

    } finally {
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
            log.info("lockKey = {} 락 반환", lockKey);
        }
    }
}

 

 

테스트가 정상으로 성공하였습니다. ㅠㅠ !!

 

 

5. 느낀점 

 

가장 좋은 방법은 좋은 데이터베이스 환경을 써서 RedissonClient를 적용하여 분산락을 적용하는 것이라고 합니다.

하지만, 제 로컬 환경이나 AWS에 배포된 데이터베이스 환경은 프리티어로 성능이 좋지 않습니다. 이 경우에는 어쩔 수 없이 다른 대안을 적용하여 동시성 문제를 해결하는 과정이 필요하였습니다. 

 

하지만, 락을 여러 개 거는 것은 데드락을 발생시킬 수 있기 때문에, saveAndFlush() 기능을 활용하여 
즉시 업데이트 시키는 과정을 수행할 수 있었습니다. 그 결과, pessimistic 락과 분산락을 함께 적용하지 않고 문제를 해결할 수 있었습니다.

 

물론 AWS에 배포할 때 제 로컬환경에서 적용한 테스트가 다시 실패할 수도 있습니다. ㅜㅜ

이 경우 다시 리팩터링 하며, 다시 추가 글을 작성하도록 하겠습니다.

 

 

부족하지만, 오늘도 읽어주셔서 감사드립니다.!

 

안녕하세요. 회사와 함께 성장하고 싶은 KOSE입니다.
이번 포스팅은 AWS로 서버를 배포하는 과정에서 발생했던 Spring Cloud Gateway의 Authentication 문제에 대해서 다뤄보려고 합니다.

 

 

1. Sprong Cloud Gateway 역할 


Gateway Server는 Spring Cloud로 구성할 수 있으며 MSA 아키텍처에서 중요한 역할을 수행하는 서버입니다. Spring Cloud Gateway는 각 서비스별 요청을 라우팅 하고, 보안 및 로드밸런싱 기능을 제공할 수 있습니다.

 

먼저 라우팅 기능은 MSA 아키텍처로 구성되어 있는 다양한 서비스가 gateway에 연결되어 요청 라우팅을 처리할 수 있습니다.

보안적인 측면에서는 다양한 필터를 통해 요청에 대한 응답이나 보안 처리, 승인 및 거부 등을 할 수 있습니다. 또한 로드 밸런싱의 기능도 포함하고 있습니다. 라운드 로빈, 가중치 로빈 밸런싱 등의 알고리즘을 제공하고 있습니다.

Spring Cloud Gateway는 Discovery Client를 제공합니다. 이를 통해 Eureka 서버에 등록하여 라우팅 되는 서비스 등을 자동화하여 처리할 수 있습니다.

 

제가 적용하고자 한 아키텍처 구조도에서는 GateWay Server의 역할을 확인할 수 있습니다.
각 비즈니스 서비스는 Gateway-server에 라우팅 되어 있고 인증 인가 처리를 Gateway-server에서 담당하고 있습니다.

 

 

 

2. Spring Cloud Gateway 설정의 어려움과 에러 발생

 

저는 Spring Cloud가 익숙하지 않아서 Gateway를 개발하는데 많은 어려움이 있었습니다. ReactiveWebFlux 형태로의 코드 작성이 기반이 되어야 하다 보니 익숙하지 않았고, 도중에 MVC 관련 코드도 많이 있었습니다. 하지만 배우면서 여러 번 작성해 보고 에러를 통해 성장할 수 있기 때문에 이를 꾸준히 리펙토링 하면서 에러를 해결하고자 하였습니다.


제가 겪었던 문제는 Authentication 인증 관련 부분입니다. 저는 Gateway-Server에서 라우팅 역할과 Jwt 인증을 처리하는 역할을 수행하도록 코드를 작성하였습니다. Local 상에서는 문제가 발생하지 않았지만, AWS로 Dev용 Config Server와, Eureka Server를 배포하고 Gateway 서버를 연결하는 과정에서 많은 에러에 봉착하였습니다. 

 

첫 번째는, 모든 Uri에 401 Error가 발생하였습니다. 이는 곧 잘못된 설정 정보로 인한 Gateway-Server가 작동되지 않는다는 것을 의미하였습니다. 디버깅 모드로 Authentication 관련 코드를 찍어보아도, 디버깅을 거치지 않은 채로 Intellij에서 401 에러를 반환하였습니다.

 

두 번째는, 회원가입과 로그인을 마친 유저가 Jwt 토큰으로 인증을 처리한 후 라우팅된 서비스로 요청이 위임되어 로직을 수행할 때, 401 Error가 발생한 것이었습니다. 저는 Gateway에서 인증 정보를 SecurityContext에 저장하지 않고 라우팅된 서비스에서 SecurityContext를 저장하도록 코드를 작성하였습니다. 따라서, Gateway에서는 JWT 토큰을 검증하고 검증된 유저라면 서비스 로직으로 이동하여 SecurityContext에 저장이 되어 비즈니스 로직을 수행할 수 있었어야 했습니다.

하지만,  Jwt 인증이 되어 라우팅이 되어야하는 요청이 401 Error가 발생하였습니다.

 

마지막 문제는 이 모든 것을 해결했을 때, 발견한 문제입니다. gateway의 AuthorizationHeaderFilter를 거치지 않고 요청이 처리되고 있었다는 점입니다. 분명 Jwt 요청이 실패하면 400 error 코드가 발견되어야 하는데 500 에러가 발생하였습니다.

 

3. 문제 해결 과정 - Filter에 대한 이해

 

이 과정에서 먼저 각 필터들의 역할을 정리하는 단계를 수행하였습니다. 이것저것 적용하다 보니 어떠한 역할을 수행하는지 이해하지 못하여 복합적인 문제가 발생하였습니다. 따라서 먼저 Spring Cloud Gateway에서 적용할 수 있는 필터를 정리하고자 합니다.

 

 

a. GatewayFilter

 

GatewatFilter는 Spring Cloud Gateway에서 Http 요청 및 응답을 필터링하는 역할을 수행합니다.
구체적으로 <인증 및 권한 부여, 요청 및 응답 수정, 요청 거부> 등의 역할을 수행하며, LoggingFilter로 로그 기록을 남기거나 ReWritePathFilter로 요청 경로를 수정할 수 있습니다. GatewayFilter의 인터페이스를 구현한다면 추가적인 로직을 작성할 수 있습니다.

 

 

b. AbstractGatewayFilterFactory

 

AbstractGatewatFilterFactory는 Spring Cloud Gateway에서 GatewayFilter를 생성하는 팩토리 클래스입니다.
이 팩토리를 활용하면, 필터 인스턴스를 생성 및 초기화, 구성을 할 수 있습니다. 
구체적으로 , 팩토리로 생성된 인스턴스는 SpringBean으로 등록되며, Spring ApplicationContext에서 사용할 수 있습니다.

또한 인스턴스의 설정 값을 지정할 수 있고, 인스턴스의 종속성을 주입하거나 필터 인스턴스 간의 관계를 설정할 수 있습니다.

 

 

c. SecurityWebFilterChain


SecurityWebFilterChain는 Spring WebFlux에서 사용하는 SecurityFitlerChain입니다.
Spring Security 최신 버전은 SecurityFilterChain을 활용하여 SecurityConfig를 작성하도록 하고 있습니다.
이때, 주의할 점은 SecurityFilterChain은 Spring MVC에서 적용하는 보안 필터 체인이기 때문에 
현재 사용하고 있는 프레임워크에 따라 다른 필터를 적용하여야 합니다.

 

 

4. 문제 해결 과정 - DEBUG 로그 확인

 

logging:
  level:
    org.springframework.security: DEBUG

 

application.yml을 활용하면, 발생 원인을 DEBUG 모드로 확인할 수 있습니다. 이를 적용하여 발생한 원인에 대해서 파악해 보도록 하였습니다. 

 

2023-03-09T22:36:15.512+09:00 DEBUG 52106 --- [ parallel-1] a.DelegatingReactiveAuthorizationManager : Checking authorization on '/member-service/users' using org.springframework.security.authorization.AuthenticatedReactiveAuthorizationManager@840d33d 2023-03-09T22:36:15.512+09:00 DEBUG 52106 --- [ parallel-1] ebSessionServerSecurityContextRepository : No SecurityContext found in WebSession: 'org.springframework.web.server.session.InMemoryWebSessionStore$InMemoryWebSession@3274fd3d' 2023-03-09T22:36:15.512+09:00 DEBUG 52106 --- [ parallel-1] o.s.s.w.s.a.AuthorizationWebFilter : Authorization failed: Access Denied 2023-03-09T22:36:15.513+09:00 DEBUG 52106 --- [ parallel-1] ebSessionServerSecurityContextRepository : No SecurityContext found in WebSession: 'org.springframework.web.server.session.InMemoryWebSessionStore$InMemoryWebSession@3274fd3d'

 

이 문제는 WebSession과 Jwt는 다른 인증 방식이지만, 현재 WebSession에 대한 처리를 하지 않았기 때문에 Security 인증 이후 WebSession에 값을 입력하지 않아 발생한 에러입니다. 즉 SecurityContext가 WebSession에서 값을 찾을 수 없기에 발생한 것이라고 추측할 수 있습니다.

 

2023-03-09T23:05:58.053+09:00 DEBUG 54193 --- [ parallel-4] a.DelegatingReactiveAuthorizationManager : Checking authorization on '/member-service/users' using org.springframework.security.authorization.AuthenticatedReactiveAuthorizationManager@310d058b 2023-03-09T23:05:58.053+09:00 DEBUG 54193 --- [ parallel-4] o.s.s.w.s.a.AuthorizationWebFilter : Authorization failed: Access Denied

 

다음 에러는 Access Denied로 인가 문제가 발생한 부분입니다. Jwt 토큰이 인증되었음에도 불구하고 인가 처리가 되지 않아 Gateway에서 member-service로 라우팅이 되지 않는 문제가 발생하였습니다. 

 

 

5. 문제 해결 과정 - 코드 수정하기 

 

먼저 No SecurityContext found in WebSession 관련 부분입니다.

@Bean
    SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) throws Exception {

        http
                .authorizeExchange()
//                .pathMatchers(memberServiceWhiteList).permitAll()
                .anyExchange().permitAll()
                .and()
                .securityContextRepository(new StatelessWebSessionSecurityContextRepository())
                .csrf(csrf -> csrf.disable())
                .cors().disable()
                .headers()
                .contentSecurityPolicy("script-src 'self'");

        return http.build();

    }

    private static class StatelessWebSessionSecurityContextRepository implements ServerSecurityContextRepository {

        private static final Mono<SecurityContext> EMPTY_CONTEXT = Mono.empty();

        @Override
        public Mono<Void> save(ServerWebExchange exchange, SecurityContext context) {
            return Mono.empty();
        }

        @Override
        public Mono<SecurityContext> load(ServerWebExchange exchange) {
            return EMPTY_CONTEXT;
        }
    }

 

저는 Jwt 토큰을 인증하는 역할만 Gateway에 부여했기 때문에, WebSession에서 인증 정보를 관리할 필요가 없다고 판단하였습니다. 따라서, StatelessWebSessionSecurityContextRepository라는 CustomRepository를 생성하여 Mono.empty() 빈 값을 주입하고 빈 값을 load 하는 코드를 추가하였습니다. 그 결과, WebSession 관련 문제를 해결할 수 있었습니다.

 

 

다음 문제는 AccessDenied 문제입니다.
저는 Jwt 토큰 인증 처리를 앞 서 설명한 AbstractGatewayFilterFactory를 상속한 AuthorizationHeaderFilter에서 이 역할을 수행하도록 구현하였습니다. 따라서 만약 Jwt 토큰에 인증에러가 발생하면 앞 단에서 요청 거부를 처리하기 때문에 SecurityWebFilterChain에서는 이 역할을 수행할 필요가 없었습니다. 뿐만 아니라, 저는 WebSession에 값을 넣지 않는 방법을 이용했기 때문에 Spring은 특정 permitAll()로 선언된 whiteList가 아닌 anyRequest.authenticated()에서 인증이 되지 않았다고 판단이 되어 요청을 거부한 것이었습니다.


따라서 코드를 수정한 결과는 다음과 같습니다.

현재 주석된 부분을 제거한 것입니다. 즉, 현재 모든 요청은 permitall()로 되어 있지만 앞 단의 GatewayFilter에서 jwt 토큰을 검증하여 요청을 승인 및 거부하고 있기 때문에 WebSession에서 에러가 발생하지 않고 Access Denied도 발생하지 않았습니다.

 

    @Bean
    SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) throws Exception {

        http
                .authorizeExchange()
//                .pathMatchers(memberServiceWhiteList).permitAll()
                .anyExchange().permitAll()
                .and()
                .securityContextRepository(new StatelessWebSessionSecurityContextRepository())
                .csrf(csrf -> csrf.disable())
                .cors().disable()
                .headers()
                .contentSecurityPolicy("script-src 'self'");

        return http.build();

    }

 

마지막 문제는 RouteLocater의 위치 문제였습니다.
routes()는 선언된 라우터에 일치하는 라우터에 먼저 적용됩니다.
저는 기존에는 다음과 같이 작성하였습니다. 하지만 이 경우 만약 /member-service/users의 요청이 오면 하단의 요청이
수행되는 것이 아니라 /**에 포함되기 때문에 상단 요청이 먼저 처리되는 것이었습니다. 

 

.route("member-service", r -> r
        .path("/member-service/**")
        .uri("lb://member-service")
)

.route("member-service", r -> r
        .path("/member-service/users")
        .filters(spec -> spec.filter(authorizationHeaderFilter.apply(new AuthorizationHeaderFilter.Config())))
        .uri("lb://member-service"))

 

저는 이 문제를 해결하기 위해 라우터 하는 path를 재조정하여 filter 처리가 필요한 uri를 먼저 선언하여 필터에 적용되도록 수정하였습니다. 

 

@Configuration
public class FilterConfig {

    @Bean
    public RouteLocator gatewayRoutes(RouteLocatorBuilder builder, AuthorizationHeaderFilter authorizationHeaderFilter) {
        return builder.routes()

                .route("member-service", r -> r
                        .path("/member-service/users")
                        .filters(spec -> spec.filter(authorizationHeaderFilter.apply(new AuthorizationHeaderFilter.Config())))
                        .uri("lb://member-service"))
                

                .route("member-service", r -> r
                        .path("/member-service/**")
                        .uri("lb://member-service"))

 

 

6. 코드 테스트

 

이것을 바탕으로 Intellij에서 코드를 검증하는 단계를 거쳤습니다.

 

먼저 gateway-server와 member-service가 정상 작동하는 것을 확인할 수 있습니다.

 

 

### 1. 폼 로그인 성공

POST {{baseUrl}}/login
Content-Type: application/json
Accept: */*
Connection: keep-alive

{
  "email": "kose@naver.com",
  "password": "kose123456"
}

> {%
    client.test("폼 로그인 회원 가입 성공, 토큰 클라이언트 저장", function() {
        client.assert(response.status === 200, "Response status is OK");
        client.global.set("accessToken", response.body.accessToken);
        client.global.set("refreshToken", response.body.refreshToken);
        client.global.set("userId", response.body.userId);
        client.log(client.global.get("accessToken"));
        client.log(client.global.get("refreshToken"));
        client.log(client.global.get("userId"));

    });
%}

 

만약 아이디나 패스워드를 달리하면 다음의 결과가 나옵니다.

 

 

이제 유저 정보를 확인하는 uri입니다.

해당 요청은 accessToken, refreshToken, userId를 필요로 합니다.

 

### 1. 유저 정보 요청

GET {{baseUrl}}/users
Content-Type: application/json
Authorization: Bearer {{accessToken}},
RefreshToken: {{refreshToken}},
userId: {{userId}}
Accept: */*
Connection: keep-alive


> {%
    client.test("유저 정보 요청", function() {
        client.assert(response.status === 200, "Response status is OK");
    });
%}

 

인증 정보를 달리하면 다음과 같이 테스트를 실패합니다. 

 

### 2. 유저 정보 요청 인증 실패

GET {{baseUrl}}/users
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIwNTEyZGQxZi04ZTQ5LTRkYzItYTExZ23fklZTAxODYwYTkxNjIiLCJhdXRoIjpbIlJPTEVfVVNFUiJdLCJpYXQiOjE2NzgzNzkxOTcsImV4cCI6MTY3ODQwMDc5N30.gaz-hMxYNLNd0sNfmiMswYz8FF0t4I7zoGW6gifbfe85iuuGMsdfLdTewbNgQBRZ85z_RA4R_knoHPu8Ym_ftQ
RefreshToken: eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIwNTEyZGQxZi04ZTQ5LTRkYzItYTExZ23fklZTAxODYwYTkxNjIiLCJhdXRoIjpbIlJPTEVfVVNFUiJdLCJpYXQiOjE2NzgzNzkxOTcsImV4cCI6MTY3ODQwMDc5N30.gaz-hMxYNLNd0sNfmiMswYz8FF0t4I7zoGW6gifbfe85iuuGMsdfLdTewbNgQBRZ85z_RA4R_knoHPu8Ym_ftQ,
userId: {{userId}}
Accept: */*
Connection: keep-alive


> {%
    client.test("유저 정보 요청", function() {
        client.assert(response.status === 400, "Response status is OK");
    });
%}

 

7. 느낀 점과 반성

 

최근에 개발을 하며, 좋아 보이는 것 혹은 있어 보이는 설계에 많은 관심이 있었던 것 같습니다.

야생적으로 이것저것 시도해 보는 것도 중요하지만, 그러한 시도 속에서 섬세한 부분에 대한 명확한 이해가 바탕이 되었어야 하는데, 정작 중요한 기술에 대한 견고함이 많이 부족했던 것 같습니다.

Dev 서버를 운영하며 local에서 이상 없었던 부분에 대해서 다시 테스트하고 코드의 견고함을 추가할 수 있었고,

이전 테스트에서 확인하지 못했던 부분까지 확인하니 정말 뿌듯한 하루가 될 수 있었던 것 같습니다.

 

혼자 프로젝트를 설계하고 배포까지 진행하다 보니 많이 힘들고 지치지만,

개발자로 살아갈 수 있다는 것이 내일 또 힘내서 개발할 수 있게 하는 원동력이 되는 것 같습니다.

 

존경하는 영한님의 말씀인 "기술적 겸손함"을 항상 되새기도록 하겠습니다.

오늘은 이만 글을 마치도록 하겠습니다.!

읽어주셔서 감사드립니다.!

안녕하세요. 회사와 함께 성장하고 싶은 KOSE입니다.
이번 글은 Jenkins - Docker -AWS를 활용하여 CI/CD를 구축하는 과정을 정리하고자 합니다.
제 운영 환경은 ubuntu22.04이므로 linux 기준으로 정리하도록 하겠습니다.

 

 

1. Jenkins를 왜 써야 할까요?

 

Jenkins는 빌드와 배포를 자동화해주는 기능을 가지고 있습니다. 
저는 AWS로 배포를 할 때 jenkins를 사용하지 않았었습니다. 사용하기 복잡하고 jar로 빌드하고 도커 이미지로 만들고
바로 서버로 배포하면 되기 때문에 초기 설정하는 과정을 생략하고 진행했습니다.
하지만, 한 두번은 할 수 있지만, 실제 local 서버와 AWS dev 혹은 prod 환경은 매우 다르기 때문에 빌드한 결과물이 실제
dev 서버에서는 제대로 작동하지 않는 문제가 발생했습니다.
이때마다 다시 코드를 수정하고 jar로 빌드하고, 도커로 이미지 생성 후 tar 파일로 변환하고 ssh로 aws로 전송 후
다시 aws 에서 해당 도커 이미지를 실행하는 과정이 매우 복잡했습니다.

이미지로 보면 다음과 같습니다.

 

만약 실제 서버에서 error가 발생한다면 Jar 빌드부터 다시 개발자가 진행해야 했습니다.
그 결과 정말 많은 시간을 투입하게 되었습니다. 시간도 시간이지만, AWS EC2의 경우, 비용이 발생하기 때문에
비효율적인 방법이었습니다.
따라서, 이러한 빌드 -> 배포 -> 운영 하는 과정을 하나로 통합하여 관리할 수 있는 Jenkins를 선택하여 활용하고자 하였습니다. 

 

2. Jenkins 설치

 

sudo apt-get install jenkins

 

설치가 완료되면, localhost:8080으로 접속할 수 있습니다.
접속하면 다음과 같은 화면이 나옵니다.

 

 

만약 도커로 jenkins를 실행하면 console로 확인할 수 있지만, jenkins가 설치되면서 바로 실행된 경우는
직접 terminal에서 password를 확인해야 합니다.

 

sudo cat /var/lib/jenkins/secrets/initialAdminPassword

 

해당 코드를 터미널에 입력하면 패스워드를 확인할 수 있습니다. 터미널 내용을 복사하여 접속하면 초기 플러그인 설정 관련 나옵니다. 
왼쪽에 기본적으로 필요한 플러그인을 설치할 수 있도록 돕는 것을 선택하면 로딩과 함께 플러그인이 설치됩니다.

 

 

도중에 플러그인 설치를 실패할 수 있는데, 넘어가서 다시 플러그인을 재설치할 수 있습니다. 따라서, 기본 플러그인 설치가 실패하더라도 크게 걱정하지 않으셔도 괜찮습니다.!

 

설치가 완료되면 다음과 같은 화면을 확인할 수 있습니다.

 

 

 

하단으로 내리면 plugin을 추가로 설치할 수 있는 항목이 있습니다. 해당 항목을 클릭한 후, 이전에 실패했던 모듈을 추가로 
설치하였습니다.  또한, post build task를 추가하여 빌드 후 작업을 수행할 수 있도록 플러그인을 설정하였습니다.

 

 

 

3. 필요한 SSH 키 준비하기


보통 깃허브를 사용하시면 ssh로 전송을 많이 이용하셨을 것입니다.
ssh 관련 키는 아래 코드로 접속하여 확인할 수 있습니다.

cat id_rsa를 입력하면 해당 private key를 확인할 수 있습니다. 

추후, ssh 인증을 하기 위해 미리 복사를 해두도록 하겠습니다.

(중요한 점은 -----BEGIN OPENSSH PRIVATE KEY-----  -----END OPENSSH PRIVATE KEY-----

jenkins에 ssh 인증 정보를 입력할 때 이것까지 모두 복사하셔야 합니다!)

 

cd ~/.ssh
cat id_rsa

만약 ssh 키가 등록되어 있지 않다면 키를 생성한 후, id_rsa.pub를 github에 등록하여야 합니다.
이번 포스팅에서는 ssh 키를 등록하는 방법은 다루지 않도록 하겠습니다.

 

 

4. Security 및 Credential 등록하기 

 

 

먼저 Configure Global Security를 클릭하여 Git Host Key Verification Configuration 설정을 Accept first Connection으로 설정합니다. Accept first Connection은 최초 요청 검증 후에 추후 요청을 검증하지 않는다는 것입니다. 만약 production 환경이라면 
문제가 발생하지만 현재 저는 dev 환경을 테스트하고 있기 때문에 해당 설정을 하였습니다.

 

 

이후, Manage Credentials을 클릭하여 새로운 Credential을 생성합니다.

 

 

하단의 Global credentials을 클릭하면 아래의 환경으로 이동합니다. 이후 Add Credentials를 클릭합니다.

 

 

현 버전은 최신 Jenkins 버전이기 때문에, username - password 방식은 지원하지 않는다고 하여, SSH 연결을 하는 Credential을 생성하였습니다.

 

 

하단의 key를 등록하는 과정에서 앞 선 3. 번에서 진행했던 id_rsa private key를 복사하여 입력합니다.
이후 등록을 누르면 키가 생성된 것을 확인할 수 있었습니다.

 

 

 

 

5. Item 등록하기

 

대시보드로 이동 후 add Item을 클릭하면 다음의 화면으로 이동합니다.

해당 프로젝트명과 Freestyle project를 클릭합니다.

 

 

이 후, Git repository에서 관리하고 있는 repository를 등록합니다.
(private repository는 ssh 링크와 credential이 필요합니다) 
이 후, private Repository에서 빌드할 브랜치를 설정하여 확인을 누릅니다.
저는 main 브랜치를 사용하였기 때문에 */main으로 설정하였습니다.

 

 

 

빌드 테스트 결과 성공하였습니다.

 

추가적인 build script를 작성하여 실제 jar 파일로 빌드를 하면 다음과 같습니다.

Build Steps에 ./gradlew clean build를 작성하여 저장합니다.

 

이 후 다시 빌드하면, 빌드 성공과 동시에 작업 공간에 빌드 폴더와 파일이 생성되게 됩니다.!

 

script로 빌드를 자동화할 수 있는 것의 장점은 configuration이나 yml 혹은 기타 스프링 빈들이 환경에 따라 다르게 작동될 수 있습니다. 따라서 젠킨스로 여러개의 프로젝트를 개별적으로 빌드하고 관리할 수 있으므로 효율적인 관리 체계를 유지할 수 있습니다.!

 

이 후 2편에서 jenkins에서 빌드 후 AWS로 배포하는 과정을 작성하도록 하겠습니다.!
부족하지만 읽어주셔서 감사드립니다.!

'DevOps' 카테고리의 다른 글

[DevOps] Docker Container  (1) 2023.11.04
[DevOps] 깃 액션/코드 디플로이를 활용한 배포  (0) 2023.10.29

안녕하세요.! 회사와 함께 성장하고 싶은 KOSE입니다.

이번 포스팅은 앞 선 포스팅에서 정리한 RedissonClient로 멀티스레드 환경 동시성 극복하기 2편을 작성하고자 합니다.

(앞선 RedissonClient로 repository에 동시성을 제어하는 포스팅 링크입니다. : https://gose-kose.tistory.com/24)

 

프로젝트를 진행할 때 복잡한 비즈니스 로직 작성은 피할 수 없습니다. 싱글 스레드 환경에서 데이터를 조회하고 수정하는 과정은 순차적으로 이루어지기 때문에 데이터의 정합성을 보장할 수 있습니다. 하지만 멀티 스레드 환경에서는 데이터에 대한 접근과 수정은 데이터의 정합성을 보장하기 어렵습니다.  따라서, synchronized 키워드로 순차처리가 진행되도록 하거나 RedissonClient의 lock을 이용해 분산락 처리로 이를 보완하는 해결책을 생각해 볼 수 있습니다.

이번 글은 제가 프로젝트를 진행하며 겪었던 멀티 스레딩 환경에서 redis에 저장된 값을 조회하고 값을 수정하여 다시 저장하는 메소드의 동시성 문제를 극복한 과정을 정리하도록 하겠습니다.

 

 

1. 공통 코드 

 

@Getter
@AllArgsConstructor
@RedisHash(value = "Vote")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Vote {

    @Id
    private String id;
    @Indexed
    private String gameId;
    private List<VotedResult> votedResults;

	''' 중략 '''
    public void updateVoteResults(String userId, String liarId) {
        votedResults.stream()
                .filter(vote -> vote.getLiarId().equals(liarId))
                .findFirst()
                .ifPresent(votedResult -> {
                    votedResult.addUserId(userId);
                    votedResult.updateCnt();
                });
    }

    public List<VotedResult> getMaxVotedResult() {
        return votedResults.stream()
                .collect(Collectors.groupingBy(VotedResult::getCnt))
                .entrySet()
                .stream()
                .max(Map.Entry.comparingByKey())
                .map(Map.Entry::getValue)
                .orElse(Collections.emptyList());
    }

	''' 중략 '''
}
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class VotedResult {
    private String liarId;
    private List<String> userIds = new ArrayList<>();
    private int cnt;

    public VotedResult(String liarId, int cnt) {
        this.liarId = liarId;
        this.cnt = cnt;
    }

	''' 중략 '''

}
public interface VoteRepository extends CrudRepository<Vote, String> {
    Vote findVoteByGameId(String gameId);
}
@Component
public interface VotePolicy {

    /**
     * game의 모든 턴이 끝나면, vote 객체를 생성하여 저장한다.
     */
    String saveVote(Game game) throws InterruptedException;

    /**
     * 유저가(userId)가 라이어(liar)를 투표하여
     * Vote 객체 값을 저장한다.
     */
    void voteLiarUser(String gameId, String userId, String liarId) throws InterruptedException;

    /**
     * 가장 많은 LiarId 투표를 받은 결과를 출력한다.
     */
    List<VotedResult> getMaxVotedLiarUser(String gameId);


}
@Component
@Slf4j
@Transactional
@RequiredArgsConstructor
public class VotePolicyImpl implements VotePolicy {

    private final VoteRepository voteRepository;
    private final RedissonClient redissonClient;

	''' 중략 '''

    /**
     * 유저가(userId)가 라이어(liar)를 투표하여
     * Vote 객체 값을 저장한다.
     */
    @Override
    public void voteLiarUser(String gameId, String userId, String liarId) {
        Vote vote = voteRepository.findVoteByGameId(gameId);
        vote.updateVoteResults(userId, liarId);
        voteRepository.save(vote);
    }

    /**
     * 가장 많은 LiarId 투표를 받은 결과를 출력한다.
     */
    @Override
    public List<VotedResult> getMaxVotedLiarUser(String gameId) {
        Vote vote = voteRepository.findVoteByGameId(gameId);
        return vote.getMaxVotedResult();
    }
}

 

먼저 CrudRepository를 확장하여 Redis에 값을 저장하는 방식을 사용하였습니다.

앞선 Vote 클래스의 gameId는 @Indexed로 선언되어 있습니다.

만약 Vote가 저장된다면 해당 어노테이션이 해당 필드의 값으로 Index를 Redis에 저장합니다.

 

 

 

2. 싱글 스레드에서 테스트 코드 작성하기

 

제가 테스트 하고자 하는 메서드는 votePolicy의 voteLiarUser()입니다. 해당 메서드에는 findVoteByGameId()로 redis에서 값을 조회하여 객체 인스턴스로 선언한 후, vote 인스턴스의 값을 바꾸고 다시 저장하는 복잡한 로직이 있습니다. 이를 싱글 스레드 환경에서 테스트하면, 다음의 결과를 얻을 수 있습니다.

 

@Test
@DisplayName("단일 스레드 환경에서 liar를 투표한다")
public void voteLiarUser() throws Exception {
    //given
    num = 5;
    votePolicy.saveVote(game);

    //when
    for (int i = 0; i < num; i++) {
        votePolicy.voteLiarUser(game.getId(), String.valueOf(i + 1), "2");
    }
    List<VotedResult> maxVotedLiarUser = votePolicy.getMaxVotedLiarUser(game.getId());

    //then
    assertThat(maxVotedLiarUser.size()).isEqualTo(1);
    assertThat(maxVotedLiarUser.get(0).getLiarId()).isEqualTo("2");
    assertThat(maxVotedLiarUser.get(0).getCnt()).isEqualTo(5);
}

 

즉, 해당 메소드는 싱글 스레드 환경에서 검증된 코드라고 판단할 수 있습니다.

이제, 멀티 스레딩 환경에서 voteLiarUser() 메서드를 테스트하도록 하겠습니다.

 

 

2. Synchronized로 테스트 코드 작성하기

 

@Autowired
    VotePolicy votePolicy;

    @Autowired VotePolicyImpl votePolicyImpl;
    @Autowired VoteRepository voteRepository;
    @Autowired RedissonClient redissonClient;

    private Game game;
    private int num;
    private Thread[] threads;

	''' 중략 '''
    

@Test
@DisplayName("멀티 스레딩 환경에서 liar를 투표한다. : synchronized")
public void voteUser_synchronized() throws Exception {
    //given
    num = 5;
    votePolicy.saveVote(game);

    //when
    for (int i = 0; i < num; i++) {
        int finalIndex = i;
        threads[i] = new Thread(() -> {
            voteLiarUser(game.getId(), String.valueOf(finalIndex + 1), "2");
        });
    }

    runThreads();
    List<VotedResult> maxVotedLiarUser = votePolicy.getMaxVotedLiarUser(game.getId());

    //then
    assertThat(maxVotedLiarUser.size()).isEqualTo(1);
    assertThat(maxVotedLiarUser.get(0).getLiarId()).isEqualTo("2");
    assertThat(maxVotedLiarUser.get(0).getCnt()).isEqualTo(5);
}


private void runThreads() throws InterruptedException {
    for (int i = 0; i < num; i++) threads[i].start();
    for (int i = 0; i < num; i++) threads[i].join();
}

/**
 * Syncronized와 성능 비교를 위한 테스트
 */
private synchronized void voteLiarUser(String gameId, String userId, String liarId) {
    Vote vote = voteRepository.findVoteByGameId(gameId);
    vote.updateVoteResults(userId, liarId);
    voteRepository.save(vote);
}

 

자바에서 synchronized는 반환 타입 앞에 선언하여 해당 메소드를 synchronized 한 방식으로 변경할 수 있습니다.

 

멀티 스레딩에서 해당 키워드는 동시성을 보장하는 기능을 수행할 수 있지만, 치명적인 단점 또한 존재합니다. 

Synchronized는 해당 메서드나 블록이 한번에 한 스레드씩 수행하도록 처리합니다. 따라서, 배타적 실행을 보장합니다. 

하지만, 메소드 단위가 한 스레드씩 수행하도록 하다 보니, 만약 gameId가 다른 요청이 왔을 때도 앞 선 요청이 수행될 때까지 

대기해야 하는 문제가 발생합니다. 이는 곧 성능 문제와 직결될 수 있습니다.

 

 

 

3. RedissonClient로 문제 해결하기

 

해당 문제점을 해결하는 방법은 locKey로 "VoteLiarUser:" + gameId로 설정함으로써, gameId에 따라 분산락을 설정할 수 있습니다.

 

 

RedissonClient를 적용하여 코드를 수정하면 다음과 같습니다.

@Override
public void voteLiarUser(String gameId, String userId, String liarId) throws InterruptedException {
    String lockKey = "VoteLiarUser: " + gameId;
    RLock lock = redissonClient.getLock(lockKey);

    try {
        boolean isLocked = lock.tryLock(2, 3, TimeUnit.SECONDS);
        if (!isLocked) {
            throw new RedisLockException();
        }

        Vote vote = voteRepository.findVoteByGameId(gameId);
        vote.updateVoteResults(userId, liarId);
        voteRepository.save(vote);

    } finally {
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}
@Test
@DisplayName("멀티 스레딩 환경에서 liar를 투표한다. : RedissonClient")
public void voteLiarUser_multiThread() throws Exception {
    //given
    num = 5;
    votePolicy.saveVote(game);

    //when
    for (int i = 0; i < num; i++) {
        int finalIndex = i;
        threads[i] = new Thread(() -> {
            try {
                votePolicy.voteLiarUser(game.getId(), String.valueOf(finalIndex + 1), "2");
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });
    }

    runThreads();
    List<VotedResult> maxVotedLiarUser = votePolicy.getMaxVotedLiarUser(game.getId());

    //then
    assertThat(maxVotedLiarUser.size()).isEqualTo(1);
    assertThat(maxVotedLiarUser.get(0).getLiarId()).isEqualTo("2");
    assertThat(maxVotedLiarUser.get(0).getCnt()).isEqualTo(5);
}

 

 

4.  Aop를 적용하여 분산락 과정을 비즈니스 로직에서 분리하기

 

try - catch - finally 코드는 분산락을 처리하는 과정에서 반복될 수 있습니다. 또한, 핵심 비즈니스 로직을 파악하는데 어려움을 줄 수 있으므로 AOP로 분리하여 사용할 수 있습니다.

@Configuration
@EnableAspectJAutoProxy
public class RedisAopConfig {

    @Bean
    public RedisLockAspect redisLockAspect(RedissonClient redissonClient) {
        return new RedisLockAspect(redissonClient);
    }

}

먼저 RedisLockAspect를 사용하기 위해 config를 작성합니다.

 

@Aspect
@Slf4j
@RequiredArgsConstructor
@Component
public class RedisLockAspect {

    private final RedissonClient redissonClient;

    @Around("execution(* liar.gamemvcservice.game.service.vote.VotePolicy.voteLiarUser(..)) && args(gameId, userId, liarId)")
    public void voteLiarUserWithRedisLock(ProceedingJoinPoint joinPoint, String gameId, String userId, String liarId) throws Throwable {
        String lockKey = "VoteLiarUser: " + gameId;
        RLock lock = redissonClient.getLock(lockKey);

        try {
            boolean isLocked = lock.tryLock(2, 3, TimeUnit.SECONDS);
            if (!isLocked) {
                throw new RedisLockException();
            }
            joinPoint.proceed();
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

 

@Around(pattern)을 사용하여 지정된 패턴에 해당되는 메서드가 실행되기 전, 후 해당 메서드가 실행되도록 구성합니다.

패턴에 해당하는 부분은 pointcut 표현식으로 와일드 카드를 이용하여 작성할 수 있습니다.

  • *: 모든 것
  • ..: 0개 이상

특정 메서드를 지칭하는 것으로 execution을 활용하여 해당 메서드에 대한 접근 제어자, 메서드 인자 등을 설정할 수 있습니다.

중요한 포인트는 joinPoint.proceed();를 호출하여야 aop 프록시가 적용된 후, 해당 메서드를 이어서 진행할 수 있습니다.

@Override
public void voteLiarUser(String gameId, String userId, String liarId) {
    Vote vote = voteRepository.findVoteByGameId(gameId);
    vote.updateVoteResults(userId, liarId);
    voteRepository.save(vote);
}

 

 

5. 결론 

멀티 스레딩 환경에서 동시성 문제를 해결하기 위한 방법은 정말 어려운 문제입니다.

프로젝트를 진행하면서 정말 많은 에러에 부딪쳤고, 어느 정도 해결하는 방법을 찾았지만,

완전한 해결책이 아니며 부수적인 문제 또한 발생할 수 있습니다.

이번 글을 포스팅하며 정말 테스트 코드 작성의 중요성을 다시 한번 느낄 수 있었습니다.

 

부족하지만 읽어주셔서 감사드립니다.! 많은 피드백 부탁드립니다.! 수정 보완하고 많이 배우겠습니다.!

 

 

+ Recent posts