동기화란 ? 
프로세스 또는 스레드들이 수행되는 시점을 조절하여 서로가 알고 있는 정보가 일치하는 것을 의미

 

우리가 알다시피 컴퓨터는 프로세스 또는 스레드로 작업을 실행합니다. 

 

예를 들어  a = 2 라는 자원이 있다고 해보겠습니다.

P1이라는 프로세스는 a라는 값을 이용해서 어떠한 로직을 수행합니다.

그런데 만약 그 사이 P2라는 프로세스가 a의 값을 3으로 바꾸게 된다면 어떻게 될까요?

아마 프로그램은 우리가 원하는 형태의 값을 반환하지 않을 것입니다.

 

이때 우리는 a를 공유 자원이라고 말하고,

이렇게 두개 이상의 프로세스 혹은 스레드가 동기화 없이 접근하려는 현상을 race condition(경쟁 상태)라고 합니다.

그리고 이러한 문제를 해결하는 것. 즉 서로가 알고있는 정보를 일치시키는 것을 synchronization(동기화)라고 합니다.

 

참고 자료 : https://ooeunz.tistory.com/94

 


백엔드팀은 WebRTC를 위한 시그널링 서버 부분으로 요청타입에 따라 분기 처리하는 SignalHandler 클래스를 구현했다. 

그런데, 시그널링 과정에서 비정상적으로 웹소켓 연결이 끊기는 이슈가 발생했다. 기술 멘토링에서 시니어 개발자 님도 피드백을 주셨던 부분인데, 근본적으로 웹소켓이 끊기는 이유를 찾아야 한다고 하셨다. 그런데, 도무지 모르겠다. ㅠ 

 

왜 끊기는지 우리도 몰루 ... ㅠ ㅠ알려주세요 ... ! 

 

그래서 구글링을 통해 임시 방편으로 동기화 키워드인 synchronized에 대해 알게 되었다.

아래 첨부된 코드와 같이 sendMessege 메소드 자체에 스레드를 동기화 시켜주는 synchronized를 걸어주었다. 

이 방법은 성능을 많이 저하 시키기 때문에 최대한 지양해야 하는 방법이다. 그렇지만 다른 방법 모두 실패했기 때문에, 일단 사용! 

 


// 기능 : WebRTC를 위한 시그널링 서버 부분으로 요청타입에 따라 분기 처리
@Slf4j
@Component
public class SignalHandler extends TextWebSocketHandler {

    @Autowired
    private GameRoomService gameRoomService;
    @Autowired
    private RepositoryService repositoryService;
    private final SessionRepository sessionRepository = SessionRepository.getInstance();  // 세션 데이터 저장소
    private final ObjectMapper objectMapper = new ObjectMapper();
    private static final String MSG_TYPE_JOIN_ROOM = "join_room";
    private static final String MSG_TYPE_OFFER = "offer";
    private static final String MSG_TYPE_ANSWER = "answer";
    private static final String MSG_TYPE_CANDIDATE = "candidate";

    @Override
    public void afterConnectionEstablished(final WebSocketSession session) {
        // 웹소켓이 연결되면 실행되는 메소드
    }

    // 시그널링 처리 메소드
    @Override
    protected void handleTextMessage(final WebSocketSession session, final TextMessage textMessage) {

        try {
            WebSocketMessage message = objectMapper.readValue(textMessage.getPayload(), WebSocketMessage.class);
            String userName = message.getSender();
            Long roomId = message.getRoomId();

            switch (message.getType()) {
                // 처음 입장
                case MSG_TYPE_JOIN_ROOM:

                    if (sessionRepository.hasRoom(roomId)) {
                        // 해당 챗룸이 존재하면
                        // 세션 저장 1) : 게임방 안의 session List에 새로운 Client session정보를 저장
                        sessionRepository.addClient(roomId, session);
                    } else {
                        // 해당 챗룸이 존재하지 않으면
                        // 세션 저장 1) : 새로운 게임방 정보와 새로운 Client session정보를 저장
                        sessionRepository.addClientInNewRoom(roomId, session);
                    }

                    // 세션 저장 2) : 이 세션이 어느 방에 들어가 있는지 저장
                    sessionRepository.saveRoomIdToSession(session, roomId);

                    // 세션 저장 3) : 방 안에 닉네임들 저장
                    sessionRepository.addNicknameInRoom(session.getId(), message.getNickname());

                    Map<String, WebSocketSession> joinClientList = sessionRepository.getClientList(roomId);

                    // 방안 참가자 중 자신을 제외한 나머지 사람들의 Session ID를 List로 저장
                    List<String> exportSessionList = new ArrayList<>();
                    for (Map.Entry<String, WebSocketSession> entry : joinClientList.entrySet()) {
                        if (entry.getValue() != session) {
                            exportSessionList.add(entry.getKey());
                        }
                    }

                    Map<String, String> exportNicknameList = new HashMap<>();
                    for (Map.Entry<String, WebSocketSession> entry : joinClientList.entrySet()) {
                        if (entry.getValue() != session) {
                            exportNicknameList.put(entry.getKey(), sessionRepository.getNicknameInRoom(entry.getKey()));
                        }
                    }

                    // 접속한 본인에게 방안 참가자들 정보를 전송
                    sendMessage(session,
                            new WebSocketResponseMessage().builder()
                                    .type("all_users")
                                    .sender(userName)
                                    .data(message.getData())
                                    .allUsers(exportSessionList)
                                    .allUsersNickNames(exportNicknameList)
                                    .candidate(message.getCandidate())
                                    .sdp(message.getSdp())
                                    .build());

                    break;

                case MSG_TYPE_OFFER:
                case MSG_TYPE_ANSWER:
                case MSG_TYPE_CANDIDATE:

                    if (sessionRepository.hasRoom(roomId)) {
                        Map<String, WebSocketSession> oacClientList = sessionRepository.getClientList(roomId);

                        if (oacClientList.containsKey(message.getReceiver())) {
                            WebSocketSession ws = oacClientList.get(message.getReceiver());
                            sendMessage(ws,
                                    new WebSocketResponseMessage().builder()
                                            .type(message.getType())
                                            .sender(session.getId())            // 보낸사람 session Id
                                            .senderNickName(message.getNickname())
                                            .receiver(message.getReceiver())    // 받을사람 session Id
                                            .data(message.getData())
                                            .offer(message.getOffer())
                                            .answer(message.getAnswer())
                                            .candidate(message.getCandidate())
                                            .sdp(message.getSdp())
                                            .build());
                        }
                    } else {
                        throw new CustomException(CHAT_ROOM_NOT_FOUND);
                    }
                    break;

                default:

                    log.info("======================================== DEFAULT");
                    log.info("============== 들어온 타입 : " + message.getType());
            }
        } catch (JsonProcessingException e) {
            log.info("=================== SignalHandler Json처리 에러 : {} ", e.getMessage());
        }
    }

    // 웹소켓 연결이 끊어지면 실행되는 메소드
    @Override
    public void afterConnectionClosed(final WebSocketSession session, final CloseStatus status) {
        String nickname = sessionRepository.getNicknameInRoom(session.getId());
        // 끊어진 세션이 어느방에 있었는지 조회
        Long roomId = sessionRepository.getRoomId(session);

        // 1) 방 참가자들 세션 정보들 사이에서 삭제
        sessionRepository.deleteClient(roomId, session);

        // 2) 별도 해당 참가자 세션 정보도 삭제
        sessionRepository.deleteRoomIdToSession(session);

        // 3) 별도 해당 닉네임 리스트에서도 삭제
        sessionRepository.deleteNicknameInRoom(session.getId());

        // 본인 제외 모두에게 전달
        for(Map.Entry<String, WebSocketSession> oneClient : sessionRepository.getClientList(roomId).entrySet()){
            sendMessage(oneClient.getValue(),
                    new WebSocketResponseMessage().builder()
                            .type("leave")
                            .sender(session.getId())
                            .receiver(oneClient.getKey())
                            .build());
        }
        Member member = repositoryService.findMemberByNickname(nickname);
        List<GameRoomAttendee> gameRoomAttendeeList = repositoryService.findAttendeeByRoomId(roomId);
        for(GameRoomAttendee gameRoomAttendee : gameRoomAttendeeList) {
            if(nickname.equals(gameRoomAttendee.getMemberNickname())){
                gameRoomService.roomExit(roomId, member);
            }
        }
    }

    // 메세지 발송
    private synchronized void sendMessage(WebSocketSession session, WebSocketResponseMessage message) {
        try {
            String json = objectMapper.writeValueAsString(message);
            session.sendMessage(new TextMessage(json));
        } catch (IOException e) {
            log.info("============== 발생한 에러 메세지: {}", e.getMessage());
        }
    }
}

나몰닭 서비스는 게임이다보니, 게임룸을 입장할 경우 충돌이 발생할 수 있다고 가정하였다. 실제로 팀원들과 테스트해 본 결과 게임룸에 여러명이 동시 입장을 시도할 경우 서버가 터져버리는 버그가 발생하였다. 우리 팀은 게임룸 입장 로직에 아래와 같이 비관적 락을 DB에 걸어주었다. 아래의 조치를 통해 DB엔 트랜잭셔널 직렬화가 잘 처리되었지만, 웹소켓 연결은 여전히 충돌이 일어났다. 웹소켓 동시성 제어 관련은 다음 게시글에 포스팅하기로 하고, 오늘은 일단 DB관련 동시성 제어에 대해 포스팅 해보려 한다.

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select b from GameRoom b where b.gameRoomId = :gameRoomId")
Optional<GameRoom> findByGameRoomId2(Long gameRoomId); // 게임룸 단건 조회

 

비관적 락

  • 자원 요청에 따른 동시성문제가 발생할것이라고 예상하고 락을 걸어버리는 방법론
  • 트랜잭션의 충돌이 발생한다고 가정 = 사용자들이 같은 데이터를 동시에 수정 할 것이라고 가정
  • 하나의 트랜잭션이 자원에 접근시 락을 걸고, 다른 트랜잭션이 접근하지 못하게 하는 방식 = 한사용자가 데이터를 읽는 시점에 Lock을 걸고 조회 또는 갱신 처리가 완료될 때 까지 유지
  • 데이터베이스에서 Shared Lock(공유, 읽기 잠금) 이나 Exclusive Lock(배타, 쓰기 잠금) 을 건다.
  • Shared Lock 의 경우, 다른 트랜잭션에서 읽기만 가능. 또한 Exclusive lock 적용이 불가능(읽는동안 변경하는것을 막기 위해)
  • Exclusive lock 의 경우. 다른 트랜잭션에서 읽기, 쓰기가 둘다 불가능. 또한 Shared, Exclusive Lock 적용이 추가적으로 불가능 (쓰는동안 읽거나, 다른 쓰기가 오는것을 막기위해)
 

장점

  • 충돌이 자주 발생하는 환경에 대해서는 롤백의 횟수를 줄일 수 있으므로 성능에서 유리
  • 데이터 무결성을 보장하는 수준이 매우 높다.

단점

  • 데이터 자체에 락을 걸어버리므로 동시성이 떨어져 성능 손해를 많이 보게 된다. 특히 읽기가 많이 이루어지는 데이터베이스의 경우에는 손해가 더 두드러짐.
  • 첫번째 사용자가 트랜잭션을 완료하기 전까지 다른 사용자들이 데이터를 수정할수 없기때문에 제어를 잘못하면 동시성을 저해 받게 된다.
  • 서로 자원이 필요한 경우에, 락이 걸려있으므로 데드락이 일어날 가능성이 있다.

 

낙관적 락

  • 자원에 락을 걸어서 선점하지말고, 동시성 문제가 발생하면 그때 가서 처리 하자는 방법론 
  • 낙관적 동시성 제어는 사용자들이 동시에 데이터를 수정하지 않을 것이라고 가정한다. = 트랜잭션의 충돌이 발생하지 않을것이라고 기대
  • 일단 충돌이 나는것을 막지 않고, 충돌이 난것을 감지하면 그때 처리
  • 따라서 데이터를 읽을때는 Lock을 설정하지 않는다. 그러므로 데이터를 수정하고자 하는 시점에 앞서 반드시 읽은데이터가 다른 사용자에 의해 변경 되었는지를 검사해야 한다.
  • 일반적으로 version 의 상태를 보고 충돌을 확인하며, 충돌이 확인된경우 롤백을 진행시킴 (hashcode나 timestamp를 이용해서 충돌을 확인할 수 도 있다.)
  • DB단에서 동시성을 처리하는것이 아닌, 어플리케이션단에서 처리
  • 이때 여러 작업이 묶인 트랜잭션으로 요청이 간 경우가 실패한경우, 개발자가 직접 롤백 처리 를 해주어야합니다.

 

장점

  • 충돌이 안난다는 가정하에, 동시 요청에 대해서 처리 성능이 좋다.

단점

  • 잦은 충돌이 일어나는경우 롤백처리에 대한 비용이 많이 들어 오히려 성능에서 손해를 볼 수 있다.
  • 롤백 처리를 구현하는게 복잡할 수 있다.

 

언제 사용할까?

  • 비관적락 은 데이터의 무결성이 중요하고, 충돌이 많이 발생하여 잦은 롤백으로 인한 효율성 문제가 발생하는것이 예상되는 시나리오에서좋다.
  • 비관적 동시성 제어는 동시성이 저하 되지만 데이터를 일일이 검사하지 않아도 된다. 만약 데이터 정합성이 중요한 업무(금융) 라면 비관적 동시성 제어로 동시성이 저하 되더라도 for update 문으로 예외처리를 하여 오히여 동시성을 높이면서 좋은 데이터 정합성을 가질 수 있다.
  • 낙관적락 은 실제로 데이터 충돌이 자주 일어나지 않을것이라고 예상되는 시나리오에서 좋다.
  • 낙관적 동시성 제어크게 경합이 벌어지지 않는 업무 ( 쇼핑몰) 등에서 사용하고, Lock이 짧아져 동시성을 높이는것이 좋다.
    하지만 상품 조회시점과 결제 시점에 가격이 다를 수 있으므로 반드시 데이터 수정시 일관성 검사를 거쳐야만 한다.

 

 


[ 참고 자료 ]

 

https://velog.io/@kw78999/DB-%EB%B9%84%EA%B4%80%EC%A0%81-vs-%EB%82%99%EA%B4%80%EC%A0%81-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%A0%9C%EC%96%B4

 

[DB] 비관적 vs 낙관적 동시성 제어

n-Tier구조가 지배적인 요즘은 DBMS의 트랜잭션 고립화 수준을 변경하는 방법을 사용하기가 어렵다.그리하여 개발자가 직접 동시성 제어를 개발한다.비관적 동시성 제어비관적 동시성 제어는 사

velog.io

https://unluckyjung.github.io/db/2022/03/07/Optimistic-vs-Pessimistic-Lock/

 

낙관적(Optimistic) 락과 비관적(Pessimisitc)락

낙관적(Optimistic) 락과 비관적(Pessimisitc)락 두 락의 차이를 알아봅니다.

unluckyjung.github.io

https://cult.honeypot.io/reads/optimistic-vs-pessimistic-concurrency/

 

Optimistic vs Pessimistic Concurrency: What Every Developer Should Know | .cult by Honeypot

Here are the differences between optimistic and pessimistic concurrency.

cult.honeypot.io

 

  • 최종발표회 준비에 많은 시간을 쏟기 보다 그 이후에 면접을 보고 개발자로 커리어를 시작하는게 중요
  • 따라서 추가기능이나 구현을 먼저 하는 방향으로
  • 그리고 이력서를 미리 준비할 것 ! ( 포맷 미리 잡고 준비하기 )
  • 최종발표회는 기술적으로 까다롭게 물어보지 않는 편, 다음주는 이력서 작성법, 모의면접 잘 활용해서 취업에 집중 하기 !

  • 우리 프로젝트에서 기술적으로 강점을 삼을 만할 항목을 정리해 주세요. (최종 발표 및 면접에서 프로젝트를 진행할 때 어떤 도전을 했는지 말할 수 있는 좋은 소스가 됩니다)
    • [ FE ] : 더 나은 유저 경험을 위한 무한 스크롤 기능
    • [ FE / BE] : 잦은 로그아웃 방지를 위한 Refresh Token 사용
    • [ FE / BE ] : WebSocket을 이용한 채팅 서비스 구현 및 화상 채팅을 위한 WebRTC 사용
    • [ BE ] : 서비스 로직 동시성 제어를 위한 DB 비관적 락 및 동기화 작업
    • [ BE ] : S3 엔드포인트 노출을 방지하기 위한 CloudFront 사용
    • [ BE ] : 개발 환경에 집중하기 위한 CI / CD 인프라 구축
    • [ BE ] : SockJS와 STOMP를 이용한 텍스트 채팅

  • 프로젝트에 적용했던 핵심 기술 목록을 작성해 주시고, 각 기술을 도입하게된 의사결정 과정을 정리해주세요.구분 요구사항 선택지 핵심 기술을 선택한 이유 및 근거
구분 요구사항 선택지 핵심 기술을 선택한 이유 및 근거
FE / BE 화상 채팅 기능 구현을 위한 WebRTC (SFU / MESH / MCU) MESH (P2P) vs SFU   실시간성이 중요한 게임 서비스임을 고려했을 때, 실시간성이 가장 낮고 중앙 서버에서 데이터 혼합 및 가공에 많은 비용이 요구되는 MCU는 제외하기로 했다. Mesh와 SFU 방식을 놓고 고민하게 됨.
  나몰닭 서비스 특성 상 한 방에 최대 인원이 4명인 점을 고려했을 때 클라이언트의 부하가 심하지 않을 것이라고 판단했고, 서버에 거치는 일 없이 바로 peer끼리 정보를 주고 받기 때문에 실시간성이 중요한 게임 서비스에 적합하다고 판단했다. 또한 서버의 부하를 줄일 수 있다는 장점까지 포함하여 Mesh 방식을 선택했다.
FE / BE 유저에게 잦은 로그인을 요구하지 않기 위한 Refresh Token 사용 Redis를 활용한 Refresh Token   Access Token의 유효 시간을 길게 설정하면 유저에게 잦은 로그인을 요구하지 않아도 되지만 토큰이 탈취되었을 때 남은 유효 기간 동안 탈취자가 마음대로 사용할 수 있다는 단점이 있다. 이를 극복하기 위하여 Access Token의 유효 기간은 2시간, Refresh Token의 유효 기간은 1주일로 설정하여 Access Token이 만료되면 자동으로 재발급 해줄 수 있도록 설계했다.
  Refresh Token을 저장할 DB로는 Redis를 선정했는데, Redis 자체의 TTL 기능으로 토큰 생명 주기 관리가 용이하기 때문에 선정했다. Refresh Token을 client 쪽에서는 Cookie에 저장했다. local / session stor
BE 방 입장 시 트랜잭션이 겹쳐서 최대 인원을 초과하는 이슈를 발생. 동시성을 해결하기 위한 DB락이 필요 Pessimistic Lock vs Optimistic Lock   나몰닭의 메인 서비스인 게임을 하기 전에 필수적으로 이루어져야하는 방 입장하기 로직이기 때문에 동시적으로 여러 트랜잭션이 DB에 접근하는 상황이 많을 것이라고 생각.
  충돌이 발생했을 경우 오버헤드가 생기는 Optimistic Lock보다 충돌이 발생하지 않았을 경우 오버헤드가 발생하는 Pessimistic Lock이 더 효율적이라고 판단했다.
BE 웹 시그널링 서버 연결 시 간헐적으로 끊기는 이슈 synchronized 한 개의 스레드만 활용하고 있어서 동시에 메소드를 여러번 실행하면 간헐적으로 서버가 다운되는 상황이 발생. synchronized를 통한 동기화 작업으로 동시에 여러번 실행되지 않도록 수정
BE S3 이미지 다운로드 시 엔드포인트가 그대로 노출되는 이슈 CloudFront, Route53 AWS CloudFront를 이용하요 속도 개선 및 엔드포인트를 감추는 용도로 사용. CloudFrontd의 배포 도메인을 감추기 위해서 Route53을 이용하여 도메인 변경
BE 문자 채팅을 위한 SockJS와 STOMP 사용 SockJS & STOMP, Only Websocket 메시징 전송을 효율적으로 처리하기 위해 pub/sub 구조로 메시지를 공급하는 STOMP를 사용하여 기존 WebSocket만을 사용 했을 때 보다 쉽게 메세징 처리. WebSocket을 지원하지 않는 브라우저에서도 기능을 사용할 수 있도록 도우며, 낮은 대기 시간과 크로스 브라우징을 지원하는 SockJS를 선택해서 사용

멘토님이 주신 질문 ( 면접 활용 )

질문1) 왜 낙관적 락 대신 비관적 락을 사용했는지?

- 우리 서비스는 여러명이 동시에 입장할 경우 무조건 충돌이 발생할 것으로 예상했다.

- 따라서 미리 락을 걸어주는게 더 적합하다고 판단.

- 충돌이 발생했을 경우 오버헤드가 생기는 Optimistic Lock보다 충돌이 발생하지 않았을 경우 오버헤드가 발생하는 Pessimistic Lock이 더 효율적이라고 판단했다.

 

질문2) 어떤 방식으로 CI/CD를 구현 했는지?그 과정에서 어떤 트러블 슈팅이 있었는지?

( - 문제를 해결했던 경험은 사소하더라도 잘 정리를 해놨다가 어필을 하는게 좋다.

 - 면접의 80프로는 문제 해결 경험을 묻는 경우이다. )

- 깃헙 액션과 AWS의 Code Deploy 를 사용하여 구현했다.

- EC2 인스턴스에 배포 파일이 들어가나 실행되지 않는 이슈가 있었다.

- 원인은 gradle.yml 파일 순서가 잘못되어 있었다. 빌드 전 암호화된 properties 파일이 들어갔어야 하는데 순서가 빌드 후에 작성되어 있었다. 당연히 properties에 들어가있는 키들이 적용이 안 되어 우분투에 있는 배포 파일을 수동 실행시켜도 오류가 떴음. gradle.yml 파일 내 스텝 순서 변경해서 이슈를 해결했다.

 

질문3) 리프레시 토큰을 어디다 저장하고 있는지 ? 

- 현재 클라이언트는 쿠키에 서버에는 Redis DB에 저장하고 있다.

- 리프레시 토큰을 클라이언트에 보낼 필요가 없다. 서버에만 저장하면 됨.

- 액세스 토큰으로만 인증요청이 가능하기 때문, 따라서 클라이언트에 리프레시토큰을 제외하고 액세스토큰만 담아주는 로직으로 변경할 것, (클라이언트 단에는 리프레시 토큰을 들고있으면 안된다. 노출이되면 안됨.) 

 

 

질문4) 싱크로나이즈드 성능 저하 문제가 있는데 최선의 방법이었는지?

-웹소켓 끊기는 이슈는 다른 원인이 있을 것이다. ( 문제 코드 전달 드리기 ) 

   

추가 조언

  • 보통 서비스가 커지면 scale up 보다는 scale out을 많이 하기 때문에 도커를 사용을 권장함. ( 경험해 보기 )
  • 다음주(마지막)에 취업과 관련한 정보를 알려줄 예정 - 이력서, 면접, 좋은회사 고르는 , 추천

토큰 생명주기 관리를 위해 Redis TTL(Time To Live) 기능을 사용하는 방향으로 수정해 보았다.

TTL기능을 사용하기 위해선 CrudRepository를 상속받아야 한다. 그래서 수정된 코드는 아래와 같다!

 

1. RefreshToken (entity)

// 기능 : Redis에 Refresh Token 저장
@Getter
@Setter
@NoArgsConstructor
@RedisHash(value = "refreshToken", timeToLive = 7 * 24 * 60 * 60L ) // 초단위 = 7일
public class RefreshToken {
    @Id
    private String email;
    private String refreshToken;

    public RefreshToken(String email, String token) {
        this.refreshToken = token;
        this.email = email;
    }

    public RefreshToken updateToken(String token) {
        this.refreshToken = token;
        return this;
    }
}

 

2. RefreshTokenRepository

@Repository
public interface RefreshTokenRepository extends CrudRepository<RefreshToken, String> {
    void deleteById(String email);
}

 

3. RefreshTokenRepository

// 기능 : Redis에 Refresh Token 저장
@Service
@RequiredArgsConstructor
public class RefreshTokenService {
    private final RefreshTokenRepository refreshTokenRepository;

    // 특정 RefreshToken 조회
    public RefreshToken findByEmail(String email){
        return refreshTokenRepository.findById(email).orElseGet(
                ()-> null
        );
    }

    // RefreshToken 저장
    public void saveRefreshToken(RefreshToken refreshToken){
        RefreshToken refreshToken1 = new RefreshToken(refreshToken.getEmail(), refreshToken.getRefreshToken());
        refreshTokenRepository.save(refreshToken1);
    }

    // RefreshToken 삭제
    public void deleteRefreshToken(String email){
        refreshTokenRepository.deleteById(email);
    }
}

 

 

전체 코드는 아래 깃헙을 참고해 주세요 : ) ! 

https://github.com/namoldak/Backend

 

GitHub - namoldak/Backend

Contribute to namoldak/Backend development by creating an account on GitHub.

github.com

 

1. RefreshToken (Entity)

@Getter
@Setter
public class RefreshToken implements Serializable {

    private static final long serialVersionUID = 6494678977089006639L;
    private String refreshToken;
    private String email;
    
    public RefreshToken(String token, String email) {
        this.refreshToken = token;
        this.email = email;
    }

    public RefreshToken updateToken(String token) {
        this.refreshToken = token;
        return this;
    }
}
  • redis에 저장되는 객체들은 Serialize 가능해야함
  • 이 값을 지정해주지 않으면 컴파일러가 계산한 값이 부여됨(변동성있음) 게다가 컴파일러는 Serializable class 혹은 Outer Class를 참고하여 만들기 때문에 이 클래스가 변동이 되면
  • serialVersionUID도 변경이 있을 수 있음 (역시 변동성이 있음)
  • 이 UID가 달라지면 기존에 저장된 객체를 읽을 수가 없게 됨. (저장하는 쪽, 불러오는 쪽 컴파일러가 다를 경우, 저장하는 시기의 클래스 내용과 불러오는 시기의 클래스의 내용이 다를 경우 등)
  • 데이터를 저장하는데 있어 이런 변동성은 위험하기에 serialVersionUID을 지정.

출처 : 소영님 

  • 아래는 3조 분들이 노션에 작성한 부분 ! ( 출처 : 눈치코치캐치 노션)
  • ChatRoom 에 Serializable 를 구체화한 이유
    Redis의 저장방식은 byte array 형식이기 때문에 직렬화를 하여 캐싱을 할 때 넘겨줘야 합니다.
    Java에서의 객체 직렬화는 Serializable 인터페이스를 구현한 것에서만 가능하다고 이해를 하고 있습니다.
    그래서 Redis를 이용하는 ChatRoom 에서 Serializable 를 구체화 했습니다.

 

2. TokenDto

@Getter
@NoArgsConstructor
public class TokenDto {
    private String accessToken;
    private String refreshToken;

    public TokenDto(String accessToken, String refreshToken) {
        this.accessToken = accessToken;
        this.refreshToken = refreshToken;
    }
}

 

 

3. RefreshTokenRepository

// 기능 : Redis에 Refresh Token 저장
@Repository
@RequiredArgsConstructor
public class RefreshTokenRepository {
    private final RedisTemplate<String, String> redisTemplate;
    private long expiredTime = 3 * 60L; // 리프레시토큰 유효시간 : 3분

    // 특정 RefreshToken 조회
    public RefreshToken findByEmail(String email){
        RefreshToken refreshToken = new RefreshToken(redisTemplate.opsForValue().get(email), email);
        return refreshToken;
    }

    // RefreshToken 저장
    public RefreshToken saveRefreshToken(RefreshToken refreshToken){
        redisTemplate.opsForValue().set(refreshToken.getEmail(), refreshToken.getRefreshToken(),expiredTime,TimeUnit.SECONDS); // 리프레시 토큰 유효시간 설정 : 3분 이부분 추후에 수정 필요 !
        return refreshToken;
    }

    // RefreshToken 삭제
    public void deleteRefreshToken(String email){
        redisTemplate.delete(email);
    }

}

 

1. findByEmail()

  • redisTemplate.opsForValue() 를 이용해 Redis에 String 형태의 리프레시토큰을 저장하고, 사용자를 식별해 줄 이메일을 저장해준다.

2. saveRefreshToken()

  • 리프레시 토큰을 레디스와 저장함과 동시에 유효시간을 저장해준다. 유효시간이 끝나면 자동 삭제 된다.
  • 테스트용이기 때문에 일단 exprited time을 3분으로 설정해둠. 실제 서비스에는 1주 - 2주 정도로 할 예정.

3. deleteRefreshToken

  • 로그아웃 하는 경우 리프레시 토큰을 삭제해줌

 

opsForValue() 참고자료

더보기
  • RedisTemplate은 Redis 서버에 Redis 커맨드를 수행하기 위한 high-level-abstractions을 제공하고 Object 직렬화, Connection management를 수행한다.
  • 또한 Redis 서버에 데이터 CRUD를 위해, Redis의 다섯가지 데이터 유형에 대한 Operation Interface를 제공하는데 아래와 같다.
< 메소드명반환 오퍼레이션Redis 자료구조 >
opsForValue() ValueOperations String
opsForList() ListOperations List
opsForSet() SetOperations Set
opsForZSet() ZSetOperations Sorted Set
opsForHash() HashOperations Hash
  • RedisTemplate: RedisTemplate은 Thread-Safe 하며 재사용 가능
  • JacksonJsonSerializer: JSON 포맷으로 데이터를 저장하는 경우

 

 

※ Redis 내장 함수를 통해 저장되어있는 데이터를 유효시간이 지나면 자동 삭제할 수 있는데, 두 가지 방법이 있다. 지금 작성되어있는 방법이 아니라 다른 방법으로 수정할 예정이라 수정이 끝나는 대로 비교해서 다시 포스팅 하는 걸로 ! 투비컨티뉴 ....! 수정이 끝나는 대로 깃헙 링크를 올리겠습니다 : ) 

 


[ 참고 자료 ]

https://devlog-wjdrbs96.tistory.com/375

 

[Spring] Spring Data Redis로 자료구조 사용해보기

Spring Data Redis로 자료구조 다루어 보기 Redis 는 간단하게 말하면 Key-Value 형태의 영속성을 지원하는 인메모리 데이터 저장소입니다. 이번 글에서는 Spring Data Redis를 활용해서 List, Set, Sorted Set, Map을

devlog-wjdrbs96.tistory.com

 

+ Recent posts