<![CDATA[null]]>http://localhost:2368/http://localhost:2368/favicon.pngnullhttp://localhost:2368/Ghost 5.65Mon, 20 Nov 2023 13:02:44 GMT60<![CDATA[실시간 채팅 서비스 도입에 관하여]]>

상황, 문제 정의

프로젝트 진행 도중 실시간 채팅 서비스를 구축 하는 과정을 정리해 봤습니다. 코드 보다는 채팅을 진행하는 과

]]>
http://localhost:2368/silsigan-caeting-seobiseu-doibe-gwanhayeo/655b55460f7bfd7d38f3486fMon, 20 Nov 2023 12:51:30 GMT

상황, 문제 정의

프로젝트 진행 도중 실시간 채팅 서비스를 구축 하는 과정을 정리해 봤습니다. 코드 보다는 채팅을 진행하는 과정에서 기술을 쓴 이유와 겪었던 고민이나 장애를 정리해봤습니다.

채팅 서비스에 웹 소켓 , STOMP PUB/SUB 구조를 통한 서비스를 구현 하기 까지  과정과  Redis도입 과정과 실시간 채팅 구현 과정에서 고민을 정리했습니다.

먼저 채팅 서비스는 크게 3가지로 나누어져 있습니다.

  • STOMP ,Redis를 이용한 Pub/Sub구독
  • 채팅 로그(다른 회원에게 보낼 채팅 메세지 이 메세지는 DB에 저장한다.) 보내기
  • 추가적으로 채팅을 보낼 시 알림 계획( Spring Event를 활용한다.)

고민, 구현 과정

첫 번째 먼저 든 생각은 채팅을 하기 위해서는 polling, websocket 두 가지 중 선택해야겠다는 생각이 들었습니다.

  • WebSocket 방식은 Http가아닌  WS프로토콜을 사용하고 단순한 API로 통신이 가능하고 양방향성과 실시간성을 가지고 있습니다.
  • polling 방식(Long polling포함)은 자체가 서버에 주기적으로 HTTP 요청을 보내는 방식입니다.

요청을 지속적 연결을 통해 보내는 것이 아닌 일정 시간을 가지고 보냅니다.

이점이 실시간 채팅과는 맞지 않는다는 생각이 들었습니다. 또한 오히려  데이터가 자주 변경되면 서버에 부담감을 주어 비용도 만만치 않다고 합니다.

이벤트가 발생하게 되면 클라이언트가 요청을 동시에 보내게 되기 때문에 서버의 부담이 급증하게 됩니다. 만약 ScaleOut으로 서버를 확장한다면 부담이 더 커지게 됩니다.

참고:윈도우 폴링vs웹 소켓

최종적으로는 양방향성과 실시간성, 확정성을 고려해 Spring Websoket과 STOMP를 활용했습니다.

  • 사실 뚜렷하게 목적이 없다면 Http 프로토콜을 사용하는 Polling 방식도 좋은 방식이라는 생각이 들었습니다.  WebSocket의 양방향 연결 자원에 대한 지속적인 업데이트가 필요하지 않다면 즉 지금 저의 채팅 서비스가 1:N이나 실시간이 그렇게 중요하지 않고 단순 1:1 기능만 있다면 굳이 Web Socket은 필요하지 않겠다는 생각을 했습니다.

내가 작성한 메세지를 상대방에게 실시간으로  전송해야 할 때 여러 사용자가 접속할 경우를 대비하기 위해서는 STOMP 또한 사용 해야 합니다. STOMP 사용할 경우

  • 클라이언트와 서버 간의 통신에서 일관성을 유지(메시지 형식을 지정할 수 있습니다.)
  • STOMP의 PUB/SUB을 사용해  메세지 처리에 용이합니다.
  • 메시지 브로커 기능을 수행할 수 있습니다.

Spring에 있는**@EnableWebScoketMessageBroker** 어노테이션을 활용해 구현했습니다.

참고: Stomp

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

  @Override
  public void configureMessageBroker(MessageBrokerRegistry config) {
    config.enableSimpleBroker("/topic");
    config.setApplicationDestinationPrefixes("/app");
  }

  @Override
  public void registerStompEndpoints(StompEndpointRegistry registry) {
    registry.addEndpoint("/gs-guide-websocket");
  }

}

문제 발생

WebSocket STOMP를 활용해 구독/발행(토픽 기준)을 통한 웹소켓 연결  채팅을 simple WebSocket Client을 통해 잘 전달 되는 것 까지 확인했지만.

지금 현재 환경에서의 메시지 브로커, 메시지 큐는 스프링 부트 서버의 내부 메모리에 존재하기 때문에 만약 다수의 서버가 있을 경우 운영하기 쉽지 않다는 문제점이 발생합니다.

그렇다면 이 시점에 일종의 외부 메시지 브로커 기능을 하는 서버를 띄울 필요가 있다는 생각이 들었습니다.

스프링 문서의 그림(외부 브로커 연동전)

(외부 브로커 연동 후)

위의 그림과 같이 메시지 발행 시 인 메모리 기반일 경우 유실 될 가능성이 있지만 외부 브로커를 통해 PUB/SUB 하도록 합니다.  또한 메시지 브로커를 통해 구독하기 때문에  수평적 확장을 통해 인프라를 구축할 때 더 용이 합니다.

개선하기 (feat: Redis)

메시지 브로커 기능을 RabbitMQ, Redis, kafka 등이 많이 쓰이지만 Rdis를 도입 하기로 했습니다. 캐시의 역할도 가능하기 때문에 이를 생각했습니다. (이후에 진행하며 여기 서도 문제를 겪게 되는데요. redis는 인 메모리 기반이기 때문에 서버가 다운 될시 Redis내의 모든 데이터가 날라 가는 경우가 생기기도 합니다.)

또한 Redis-Cluster를 활용해 분산 저장이 가능합니다. 서비스가 수평적 확장 가능성이 크다면 확실히 채팅 구독이나, Global Session을 다루기 쉬워집니다.

즉 단순 Spring Stomp를 이용한 인 메모리 저장 방식에서 벗어나 외부 Message Broker를 활용하여 아래와 같은 이점을 챙겼습니다.

  • 수평적 확장 시 이점
  • 서버가 다수일 경우 한 곳에서 PUB/SUB 하기 때문에 관리하기 용이하다.
  • 인 메모리 방식이 아닐 경우 PUB/SUB 할 때 내용을 저장 가능하다.

다음 WebSocketController를 구현 한 후 소켓을 통해 메시지가 들어오면 받아서 해당되는 채널로 전달하도록 합니다. 채팅 방 정보나 채팅 방 로그는 Mysql에 저장하도록 합니다.

참고

메시지/이벤트 브로커 두 가지로 구분할 수 있습니다.

Kafka(이벤트 브로커 ,메시지 브로커), RabbitMQ,Redis(메세지 브로커)• 메시지 브로커는 이벤트 브로커가 될 수 없으나, 이벤트 브로커는 메시지 브로커가 될 수 있습니다(메시지는 삭제되고, 이벤트는 삭제되지 않기 때문)

정리

프로젝트에 채팅 서비스를 도입하면서 기술적 스택에 대한 고민과 실시간 성을 고려하여 다른 기술로 변경하는 과정을 정리했습니다.

정리하는 시간을 가져보며 단순하게 Webscoket이 폴링,SSE 방식보다 더 나은 기술이고 채팅에서는 Websocket을 무조건 사용하는 것이 아닌  구현해보지는 못했지만 자신의 서비스의 아키텍처 규모나 해당 도메인의 중요도, 실질적인 구현 계획 시간을 고려하여 채팅과 관련된 기술을 채택하는 것이 좋다는 생각이 들었습니다.

추가적으로 다른 메시지 브로커 역할이 가능한 Kafka와 Redis의 차이에 대해 밑의 블로그를 통해 학습했습니다. Spring Session에서 Redis를 통해 Session을 저장 하기도 하는데 Redis의 Group이 없고 모든 Subscriber에서 발생되어야 하는 경우 강점이 있는 특징을 사용한 것 같습니다.

또한   채팅 서비스에서 회원가입당 Subscriber수 와 관계없이 메시지 축하 알림이 하나만 가야 하는 경우  즉 발행된 이벤트에 대해, 특정 작업이 한 번만 발생하여야 할 때 Kafka 도입도 고려해볼 수 있을 거 같습니다.

PUB/SUB, 잘 알고 쓰자!
Kafka와 Redis 사이에서 고민하는 당신을 위한 글
]]>
<![CDATA[채팅 도메인 고민과 의존성 문제(feat: Spring Event)]]>

상황, 문제 정의

프로젝트 진행 도중 실시간 채팅 서비스를 구축 하는 과정을 정리해 봤습니다. 코드 보다는 채팅을 진행하는 과

]]>
http://localhost:2368/domein/6552313e3b0dff41f08f2351Wed, 15 Nov 2023 12:47:31 GMT

상황, 문제 정의

프로젝트 진행 도중 실시간 채팅 서비스를 구축 하는 과정을 정리해 봤습니다. 코드 보다는 채팅을 진행하는 과정에서 기술을 쓴 이유와 겪었던 고민이나 장애를 정리해봤습니다.

채팅 서비스에 웹 소켓 , STOMP PUB/SUB 구조를 통한 서비스를 구현 하기 까지  과정과  Redis도입 과정과 도메인 의존성 문제를 개선하는 과정과 최종적으로 이벤트 적용, 트랜잭션 분리, 비동기 처리하는 과정을 정리해 봤습니다.

먼저 채팅 서비스는 크게 3가지로 나누어져 있습니다.

  • STOMP ,Redis를 이용한 Pub/Sub구독
  • 채팅 로그(다른 회원에게 보낼 채팅 메세지 이 메세지는 DB에 저장한다.) 보내기
  • 추가적으로 채팅을 보낼 시 알림 계획

첫 번째 시도

첫 번째 먼저 든 생각은 채팅을 하기 위해서는 polling, websocket 두 가지 중 선택해야겠다는 생각이 들었습니다.

  • WebSocket 방식은 Http가아닌  WS프로토콜을 사용하고 단순한 API로 통신이 가능하고 양방향성과 실시간성을 가지고 있습니다.
  • polling 방식(Long polling포함)은 자체가 서버에 주기적으로 HTTP 요청을 보내는 방식입니다.

요청을 지속적 연결을 통해 보내는 것이 아닌 일정 시간을 가지고 보냅니다.

이점이 실시간 채팅과는 맞지 않는다는 생각이 들었습니다. 또한 오히려  데이터가 자주 변경되면 서버에 부담감을 주어 비용도 만만치 않다고 합니다.

이벤트가 발생하게 되면 클라이언트가 요청을 동시에 보내게 되기 때문에 서버의 부담이 급증하게 됩니다. 만약 ScaleOut으로 서버를 확장한다면 부담이 더 커지게 됩니다.

참고:윈도우 폴링vs웹 소켓

최종적으로는 양방향성과 실시간성, 확정성을 고려해 Spring Websoket과 STOMP를 활용했습니다.

  • 사실 뚜렷하게 목적이 없다면 Http 프로토콜을 사용하는 Polling 방식도 좋은 방식이라는 생각이 들었습니다.  WebSocket의 양방향 연결 자원에 대한 지속적인 업데이트가 필요하지 않다면 즉 지금 저의 채팅 서비스가 1:N이나 실시간이 그렇게 중요하지 않고 단순 1:1 기능만 있다면 굳이 Web Socket은 필요하지 않겠다는 생각을 했습니다.

내가 작성한 메세지를 상대방에게 실시간으로  전송해야 할 때 여러 사용자가 접속할 경우를 대비하기 위해서는 STOMP 또한 사용 해야 합니다. STOMP 사용할 경우

  • 클라이언트와 서버 간의 통신에서 일관성을 유지(메시지 형식을 지정할 수 있습니다.)
  • STOMP의 PUB/SUB을 사용해  메세지 처리에 용이합니다.
  • 메시지 브로커 기능을 수행할 수 있습니다.

Spring에 있는**@EnableWebScoketMessageBroker** 어노테이션을 활용해 구현했습니다.

참고: Stomp

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

  @Override
  public void configureMessageBroker(MessageBrokerRegistry config) {
    config.enableSimpleBroker("/topic");
    config.setApplicationDestinationPrefixes("/app");
  }

  @Override
  public void registerStompEndpoints(StompEndpointRegistry registry) {
    registry.addEndpoint("/gs-guide-websocket");
  }

}

문제 발생

WebSocket STOMP를 활용해 구독/발행(토픽 기준)을 통한 웹소켓 연결  채팅을 simple WebSocket Client을 통해 잘 전달 되는 것 까지 확인했지만.

지금 현재 환경에서의 메시지 브로커, 메시지 큐는 스프링 부트 서버의 내부 메모리에 존재하기 때문에 만약 다수의 서버가 있을 경우 운영하기 쉽지 않다는 문제점이 발생합니다.

그렇다면 이 시점에 일종의 외부 메시지 브로커 기능을 하는 서버를 띄울 필요가 있다는 생각이 들었습니다.

스프링 문서의 그림(외부 브로커 연동전)

(외부 브로커 연동 후)

위의 그림과 같이 메시지 발행 시 인 메모리 기반일 경우 유실 될 가능성이 있지만 외부 브로커를 통해 PUB/SUB 하도록 합니다.  또한 메시지 브로커를 통해 구독하기 때문에  수평적 확장을 통해 인프라를 구축할 때 더 용이 합니다.

RabbitMQ, Redis, kafka 등이 많이 쓰이지만 Rdis를 도입 하기로 했습니다. 캐시의 역할도 가능하기 때문에 이를 생각했습니다. (이후에 진행하며 여기 서도 문제를 겪게 되는데요. redis는 인 메모리 기반이기 때문에 서버가 다운 될시 Redis내의 모든 데이터가 날라 가는 경우가 생기기도 합니다.)

또한 Redis-Cluster를 활용해 분산 저장이 가능합니다.

즉 단순 Spring Stomp를 이용한 인 메모리 저장 방식에서 벗어나 외부 Message Broker를 활용하여 아래와 같은 이점을 챙겼습니다.

  • 수평적 확장 시 이점
  • 서버가 다수일 경우 한 곳에서 PUB/SUB 하기 때문에 관리하기 용이하다.
  • 인 메모리 방식이 아닐 경우 PUB/SUB 할 때 내용을 저장 가능하다.

다음 WebSocketController를 구현 한 후 소켓을 통해 메시지가 들어오면 받아서 해당되는 채널로 전달하도록 합니다. 채팅 방 정보나 채팅 방 로그는 Mysql에 저장하도록 합니다.

참고

메시지/이벤트 브로커 두 가지로 구분할 수 있습니다.

Kafka(이벤트 브로커 ,메시지 브로커), RabbitMQ,Redis(메세지 브로커)• 메시지 브로커는 이벤트 브로커가 될 수 없으나, 이벤트 브로커는 메시지 브로커가 될 수 있습니다(메시지는 삭제되고, 이벤트는 삭제되지 않기 때문)

두 번째 시도

위의 상황을 정리해 보자면 Web Socket, Stomp 프로토콜을 사용하였고 Spring에서 지원해주는 내부 메시지 브로커 기능을 외부 메시지 브로커(Redis)를 사용하는 단계까지 진행했습니다.

이를 토대로 채팅 도메인을 개발하였습니다. 구조를 간단하게 요약하자면 밑과 같습니다.

⎿ product// 중고 상품.
⎿ member // 회원.
⎿ town// 동네 지역.
⎿ oauth // 회원 인증인가.
⎿ interested// 관심 상품.
///채팅 도메인
⎿ chatlog// 채팅 로그.
⎿ chatroom// 채팅 방.

chatlog와 chatroom 도메인 쪽의 기능을 크게

  • 채팅 읽기,보내기, 방 만들기 기능과 추가적으로 알림 ,안 읽은 메시지를 기획 했습니다.

문제 발생

하지만 여기서 추가적인 문제가 발생합니다. ChatService부분에서 여러 의존성이 겹치는 구간이 생겼습니다.

public class ChatService {

    private final ChannelTopic channelTopic;
    private final ChatRoomRedisRepository chatRoomRedisRepository;
    private final MemberRepository memberRepository;
    private final ProductService productService;
    private final ChatRoomService chatRoomService;
    private final RedisTemplate redisTemplate;
		.... 생략
}

또한 채팅 서비스 로직이 한 트렌젝션이 모두 묶이다 보니 여러 문제가 발생했는데요.

간단하게 의사 코드로 정리 해보자면

  • 사용자가 채팅 메시지를 발송한다.
  • 채팅 알람을 상대방이 받을 수 있다.
  • 채팅 메시지가 저장되어야 한다.
  • 채팅방 MetaInfo를 ‘안 읽은 메시지’ 개수와 ‘채팅방에 마지막으로 발송된 메시지’가 갱신되어야 한다.

원인

해결 방법

'서비스 간의 강한 의존성을 줄이자'

결과

]]>
<![CDATA[Oauth로그인 리팩토링]]>프로젝트를 진행 하며 회원 가입 서비스의 일부분인 Oauth 로그인 부분을 총 3 단계에 걸쳐 리팩 토링을 진행하였고 그 과정에서

]]>
http://localhost:2368/posts/650fddfcf73c6c4074cc43f8Sun, 24 Sep 2023 06:59:20 GMT프로젝트를 진행 하며 회원 가입 서비스의 일부분인 Oauth 로그인 부분을 총 3 단계에 걸쳐 리팩 토링을 진행하였고 그 과정에서 겪은 고민이나 해결 과정을 정리해보고자 합니다.

문제 정의

로그인 서비스를 진행하며 회원 가입을 Oauth를 통해 하기로 결정했습니다. 최초 Github를 통해 하기로 결정했고,  Github를 통해 회원 정보를 받아 Jwt를 통해 로그인 서비스를 구축했습니다.

문제 발생

팀원들 과의 의논 후 추가적인 회원 가입 플랫폼 확장을 위해 Kakao Oauth 또한 도입 하기로 했습니다. 여기서 문제가 발생했습니다.  확장성을 고려하지 않은 설계에 의해 다른 플랫폼 회원 가입시 API를 하나 더 추가 해야했고  기존의 코드에 중복이 많이 발생하게 되었습니다.

원인

    @Transactional
    public MemberLoginResponse login(String code) {
        AccessTokenResponseDTO token = oauth.getToken(code);
        logger.debug("token access 토큰 = {}", token);
        OAuthMemberInfoDTO memberInfo = oauth.getUserInfo(token.getAccessToken());

        if (MemberExists(memberInfo)) {
            Member member = findMemberByMemberName(memberInfo.getLogin());
            Member updateMember = memberRepository.save(member.update(memberInfo, token.getAccessToken()));
            String jwtToken = jwtService.createToken(updateMember);
            return MemberLoginResponse.of(updateMember, jwtToken);
        }

        Member member = memberRepository.save(Member.create(memberInfo, token.getAccessToken()));
        String jwtToken = jwtService.createToken(member);
        
        return MemberLoginResponse.of(member, jwtToken);
    }
  @Operation(
            summary = "깃허브 로그인",
            tags = "member",
            description = "사용자 깃허브를 통한 로그인"
    )
    @PostMapping("/auth/login")
    public BasicResponse<MemberLoginResponse> login(@RequestParam String code) throws IOException, InterruptedException {
        MemberLoginResponse memberResponseDTO = memberService.login(code);

        return BasicResponse.<MemberLoginResponse>builder()
                .success(true)
                .message("")
                .apiStatus(20000)
                .httpStatus(HttpStatus.OK)
                .data(memberResponseDTO)
                .build();
    }

컨트롤러와 서비스를 위와 같이 구현했습니다. Service 레이어에서 code를 매개변수로 받기 때문에 이 코드는 Oauth를 해주는 플랫폼으로 부터 받는 코드이고 이코드로는 사실 어떤 플랫폼으로 부터 받은 코드인지는 Spring에서 확인 할 수 가 없었습니다. 즉 Kakao 회원 가입 전용 Api를 만든다 하더라도 Service에서는 Kakao에서 온 것인지 Github에서 온 것인지 알 수 가 없습니다.

첫 번째 해결책

처음 든 생각은 단순히 API를 하나 더 만들자 였습니다. Kakao 전용 Api를 만들고 이를 구분 할 수 있는 인터페이스 타입을 만들어 Service 레이에서 타입을 구분하자 라는 생각했습니다.

컨트롤러에 Api를 하나 더 추가

  @Operation(
            summary = "카카오 로그인",
            tags = "members",
            description = "사용자 카카오를 통한 로그인"
    )
    @PostMapping("/auth/kakao/login")
    public BasicResponse<MemberLoginResponse> kakaoLogin(@RequestBody KakaoRequestCode params) {
        log.debug("프론트로 부터 받은 코드 = {}", params);
        MemberLoginResponse memberResponseDTO = memberService.login(params);

        return BasicResponse.send("카카오 로그인", memberResponseDTO);
    }

위와 같은 Api를 하나 더 만들었습니다. 사실상 github와 호출하는 uri와 request dto만 다를 뿐 같은 login 메서드를 사용해 return합니다.

각 플랫폼은 인터페이스를 구현하도록 한다.

@Getter
@NoArgsConstructor
public class KakaoRequestCode implements OAuthLoginParams {
    private String authorizationCode;

    @Override
    public OAuthProvider oAuthProvider() {
        return OAuthProvider.KAKAO;
    }


    @Override
    public MultiValueMap<String, String> makeBody() {
        MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
        body.add("code", authorizationCode);
        return body;
    }
}
public interface OAuthLoginParams {
    OAuthProvider oAuthProvider();

    MultiValueMap<String, String> makeBody();
}
//이 인터페이스를 카카오와 깃허브가 각각 구현하도록 합니다.
  public interface Oauth {
    
    OAuthProvider oAuthProvider();

    AccessTokenResponseDTO getToken(OAuthLoginParams params);

    OAuthInfoResponse getUserInfo(String accessToken);
}
 

위와 같이 OAuthLoginParams  인터페이스를 통해  각 플랫폼을 비교하고 서비스에서 각 플랫폼에 맞게 끔 requestOAuthInfoService.request(params, userAgent); 에 맞는 OAuthInfoResponse(카카오나, 깃허브로 부터 받은 유저 정보)를 받도록 합니다.

@Component
public class RequestOAuthInfoService {
    private final Map<OAuthProvider, Oauth> clients;

    public RequestOAuthInfoService(List<Oauth> clients) {
        this.clients = clients.stream().collect(
                Collectors.toUnmodifiableMap(Oauth::oAuthProvider, Function.identity())
        );
    }

    public OAuthInfoResponse request(OAuthLoginParams params) {
        Oauth client = clients.get(params.oAuthProvider());
        AccessTokenResponseDTO token = client.getToken(params);
        return client.getUserInfo(String.valueOf(token));
    }
}

위 서비스 클레스에서 parms를 비교하여 각 oauth에 맞는 Dto를 반환하도록 합니다.

  @Transactional
    public MemberLoginResponse login(OAuthLoginParams params, String userAgent) throws IOException {
        OAuthInfoResponse oAuthInfoResponse = requestOAuthInfoService.request(params, userAgent);

      ````생략
        return MemberLoginResponse.of(member, jwtToken);
    }

이로서 Reuqest에 따라 각 플렛폼을 구분 하여 그에 맞는 dto를 반환 할 수 있게 되었습니다.

두 번째 문제

하지만 다음과 같이 해도 문제가 완전히 해결되지는 않습니다. 사실 플렛폼이 추가된다고 Api가 늘어나는 건 자연스럽지 못한 흐름이라고 생각했습니다.

요구 사항이 변경 될 시 만약 Naver나 Google같은 다른 서비스가 추가 된다고 가정했을 때 지금의 구조에서는 플랫폼이 추가 될 때마다 Api를 추가 해야하는데 이는 테스트 관점에서 봤을 때도 비효율적이고 굳이 반복되는 지루한 코드를 써야 하는 비효율성도 있습니다.  

타입이 다르다 하더라도  login서비스 코드는 작은 부분 빼고는 다 같습니다. 메서드는 하나로 유지 하고 , 또한 불필요한 클래스나 인터페이스를 죽여 역할과 협력에 맞게  리팩토링이 필요한 시점이라 판단했습니다.

정리해보자면

  1. 컨트롤러의 메서드는 플렛폼이 늘어나도 하나로 유지하자.
  2. 코드의 가독성이 떨어지고 클래스들의 역할이 모호한 부분이 많다. 리팩토링이 필요하다.

두 번째 해결책

    @PostMapping("/{provider}/login")
    public BasicResponse<LoginResponse> login(@PathVariable OAuthProvider provider,
                                              @RequestBody @Valid LoginRequest request,
                                              @NotNullParam(message = "code 값은 반드시 들어와야 합니다.") String code) {
        LoginResponse login = authService.login(provider, request, code);
        return BasicResponse.send(HttpStatus.OK.value(), "카카오 로그인", login);
    }

"/{provider}/login"  provider를 @PathVariable로 받는 형식으로 변경합니다.

OAuthProvider는 Enum을 통해 밑에와 같이 구현해 줍니다.


@Getter
@RequiredArgsConstructor
public enum OAuthProvider {

    KAKAO("kakao"),
    GITHUB("github");

    private final String name;
    private OAuthRequester oAuthRequester;

    public static OAuthProvider of(final String name) {
        return Arrays.stream(OAuthProvider.values())
                .filter(provider -> provider.name.equals(name))
                .findFirst()
                .orElseThrow(() -> new NotFoundException(ErrorMessage.OAUTH_PROVIDER_NOT_FOUND));
    }

    private void injectOAuthClient(OAuthRequester oAuthRequester) {
        this.oAuthRequester = oAuthRequester;
    }

    @RequiredArgsConstructor
    @Component
    static class OAuthClientInjector {

        private final GithubRequester githubRequester;
        private final KakaoRequester kakaoRequester;

        @PostConstruct
        public void injectOAuthClient() {
            Arrays.stream(OAuthProvider.values()).forEach(oAuthProvider -> {
                if (oAuthProvider == GITHUB) {
                    oAuthProvider.injectOAuthClient(githubRequester);
                }
                if (oAuthProvider == KAKAO) {
                    oAuthProvider.injectOAuthClient(kakaoRequester);
                }
            });
        }
    }
}

Injection 해주는 내부 클래스를 구현한 후 @PostConstruct를통해(Spring에서 라이프 사이클을 고려한 권장하는 방법)을 통해  oAuthRequster 인터페이스에 컨트롤러에서 들어온 oAuthProvider의 enum값에 맞는 값을 inject해줍니다. 이렇게 된다면 최종 적으로  oAuthProvider의 필드인 oAuthRequester에 플랫폼에 맞는 알맞은 값이 들어오게 됩니다. 그 후

 @Transactional
    public LoginResponse login(OAuthProvider oAuthProvider, LoginRequest request, String code) {
        OAuthRequester oAuthRequester = oAuthProvider.getOAuthRequester();
        OauthTokenResponse tokenResponse = oAuthRequester.getToken(code);
		```생략
    }

oAuthProvider.getOAuthRequester() 를 통해 oAuthRequester를 가져온 후 토큰을 얻어 오면됩니다. 이 때 getToken은  결국 get을 통해 가져온 플랫폼에 그전과 같이 회원 정보를 요청 하게 됩니다.


결과

이를 통해 최종적으로 네이버나 구글 과 같이 다른 플랫폼을 추가 하고 싶을 경우 서비스, 컨트롤러 레이어 코드는 건들지 않고 Enum에 추가 해준 후 OAuthRequester의 구현체만 추가해 주면 됩니다.

이번 리팩토링을 통해 확실히 인터페이스와, Enum을 활용해 코드의 중복을 줄이고 Controller, Service 코드는 변경이 없다는 점이 좋았습니다.

깃허브 출처: https://github.com/masters2023-2nd-project-02/second-hand

]]>