웹 소켓을 선택하게 됐는가

위키백과나 블로그들을 보면 기존 통신 방식은 클라이언트가 데이터를 요청하면 서버가 데이터를 제공하는 형태의 단반향 통신이라고 볼 수 있습니다. 만일 이러한 통신 방식을 사용한다면 프런트 엔드 쪽에서 지속적으로 서버에 메시지가 왔는지 확인을 위한 작업이 필수적입니다. 그렇기에 많은 통신이 발생할 것이라고 판단하였습니다. 그렇기에 실시간으로 데이터를 주고받기 위해서는 양반향 통신이 필요하고 생각하였고 웹 검색을 통해 웹 소켓이라는 기술이 실시간 통신을 제공해주기 때문에 적용하자고 판단했습니다.

 

 

 

설계 방식

 

1번째 설계

가장 기초적인 설계로서 STOMP만을 사용한 설계입니다. 어떤 것도 생각하지 않고 웹 소켓을 연결 시키기 위한 작업을 진행하였습니다. 여기서 CORS가 또 한 번 발생하게 되었습니다. Access_Control_Allow_Origin이 *일 경우 CREDENTIALS가 TRUE일 수 없다는 오류이기에 Origin을 프런트 주소로 설정해 주었습니다.

 

이렇게 완성한 결과 정상적으로 프런트 쪽으로 실시간 메시지를 보낼 수 있게 되었습니다. 하지만 2가지 문제점이 발생하였습니다. 

  1. 메시지를 보내는 유저의 채널 이름을 알고 있어야 한다.
  2. 유저가 접속을 했는지에 대한 정보를 알고 있어야 한다.

이 두 가지 문제를 해결하기 위해 2번째 설계를 진행하게 되었습니다.

 

/**
* 
* @param todo id
* @return
*/
// Controller
@PostMapping(value = "/todo/{id}")
public ResponseEntity<?> requestSaveTodoHeart(@PathVariable Long id, @UserAuthToken UserTokenDTO userTokenDTO) {

	String uuid = heartService.saveTodoHeart(id, userTokenDTO);

	messageSender.sendTodoHeartEventMessage(id, userTokenDTO.getUsername() + "님이 좋아요를 눌르셨습니다.");

	return new ResponseEntity<String>(uuid, HttpStatus.OK);
}

 

// sender
private void sendMessage(String userPrivateChannel, String message) {

	if (userPrivateChannel == null) {
		return;
	}
	if (message.equals(null)) {
		return;
	}

	HashMap<String, String> repMessage = new HashMap<String, String>();
	repMessage.put("message", message);
	messagingTemplate.convertAndSend(userPrivateChannel, repMessage);
}

 

 

2번째 설계

 

첫 번째 문제 해결

 

2번째 설계에서는 먼저 유저가 접속한 세션 값을 알아야 한다고 생각했습니다. 연결시 세션값을 통해 데이터를 주고받기 때문에 이 세션을 알고 서버에서 유저 정보와 세션, 채널 주소를 알고 있다면 쉽게 해결할 수 있을 것이라고 생각합니다.

 

그렇기에 ChannelInterceptor를 상속 받아 기능을 구성하고 Socket Config에 configureClientInboundChannel에서 새로 만든 Interceptor을 설정해주면 세션을 가져올 수 있습니다.

 

@Component
public class PersonalChannelInterceptor implements ChannelInterceptor {

	@Autowired
	private MessageChannelService messageChannelService;

	@Autowired
	private AuthenticationJwtProvider authenticationJwtProvider;

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

		return message;
	}
	
	private void requestConnectedAction(StompHeaderAccessor headerAccessor) {

		String token = headerAccessor.getNativeHeader(HttpHeaders.AUTHORIZATION).get(0);
		String session = headerAccessor.getSessionId();
		Long userId = authenticationJwtProvider.resolveTokenToUserTokenDTO(token).getId();

		messageChannelService.saveChannel(userId, token, session);
	}

	private void requestSubscribeAction(StompHeaderAccessor headerAccessor) {
		String session = headerAccessor.getSessionId();
	}
}

 

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

 

2번째 문제 해결

유저의 세션을 알았다고 해도 이 세션이 어떤 유저인지 또한 개인 채널 이름은 무엇인지 알 수 있어야 하기에 Redis를 사용하였습니다. Redis에 저장된 유저의 정보를 가지고 유저가 현재 로그인된 상태인지를 판단하여 유저의 채널에 메시지를 입력하고 현재 접속중이지 않다면 DB에 저장하여 메시지를 보여주는 것이 좋을 것이라고 판단하였습니다.

 

 

@ReidsHash(value = "AccessAuthority", timeToLive = 36000)
public class LoginUserRedisEntity {

	@Id
	private String id;

	private String accessToken;

	private String refreshToken;

	private String accessAddressIP;

}

 

@RedisHash(value = "UserMessageChannel", timeToLive = 360000)
public class MessageChannelEntity {

	@Id
	private Long userid;

	private String userMessageChannel;

	private String userConnectSession;
    
 }

 

/**
* 유저가 로그인을 하지 않았다면 Message DB에 저장
* 
* @param userEntity
* @param message
*/

private void isExsistLoginUserOnServer(UserEntity userEntity, String message) {
		
	if (messageChannelService.isExsistUserInRedis(userEntity.getId())) {
		String userPersonalChannelName = userEntity.getPersonalMessageChannelName();
		sendMessage(userPersonalChannelName, message);
	} else {
		eventMessageService.saveMessage(message, userEntity.getId());
	}
}

 

사용 API

 

프런트 엔드는 SockJS를 사용하였으며 백엔드는 스프링이 제공하는 웹 소켓을 사용하는 것이 현재 프로젝트에서 가장 좋다고 판단하였습니다. 하지만 단순한 소켓을 사용한다면 복잡한 구현이 될 것이며, 정해진 규칙이 없기에 웹 소켓과 더불어 STOMP 기술을 적용하는 것이 좋을 것이라고 생각합니다.

 

 

최종 결과

부족한 부분

메시지를 전달하는 방식을 설계하면서 필요한 것은 아직 인증에대한 부족한 부분이라고 생각합니다. 웹 소켓은 최초 로그인시 1번 열리게 되지만 이후 토큰 값이 유효하지 않는 경우는 어떻게 되는지 확인해보지 못하였으며,  Heart를 추가하는 과정에서 포스트의 유저 ID값을 DB로부터 다시 받아와야 하는 작업이 많기 때문에 쿼리를 여러번 호출하는 상황이 발생되기에 이 부분을 고치는 것이 쿼리를 최소한으로 부르는 필수라고 생각됩니다.

 

 

의존성

 

https://mvnrepository.com/artifact/org.springframework/spring-websocket

 

https://mvnrepository.com/artifact/org.springframework/spring-messaging

 

사용하게 된 이유


Redis를 사용하자고 결정하게 된 이유 3가지 문제를 해결하기 위해서입니다.

  1. 로그인 후 유저가 작성한 데이터의 삽입, 삭제, 변경 등에 대해서 해당 사용자가 맞는지 확인하기 위해서
  2. 1회 발급을 보장하기 위해서
  3. 빠른 처리속도를 위해서

현재 만들고 있는 프로젝트의 인증방식은 JWT 방식을 사용하여 회원의 정보를 관리하도록 설계했습니다. 초기 계획한 프로젝트가 끝나가면서 인증 절차가 부실한 점을 생각하였고 이러한 문제점의 이유와 해결하면서 얻게 되는 이점이 무엇이 있는지 생각해보고자 합니다. 

 

1. 로그인 후 유저가 작성한 데이터의 삽입, 삭제, 변경 등에 대해서 해당 사용자가 맞는지 확인하기 위해서

 

Controller Logic :

// Controller
@PutMapping(value = "/quote/{id}")
public ResponseEntity<?> updateUserQuote(@PathVariable Long id, @RequestBody QuoteDTO quoteDTO,
		@UserAuthToken UserTokenDTO tokenDTO) {

	if (quoteDTO.getIsPublish() == null && quoteDTO.getQuote() == null 
    		&& quoteDTO.getAuthor() == null) {
		
    	  return new ResponseEntity<>(ResponseStatus.FAILURE, HttpStatus.OK);
	}
	userQuoteService.updateQuote(id, quoteDTO, tokenDTO);

	return new ResponseEntity<>(ResponseStatus.SUCCESS, HttpStatus.OK);
}

 

Service Logic :

// Service
@Transactional(rollbackFor = Exception.class)
public QuoteEntity updateQuote(Long id, QuoteDTO quoteDTO, UserTokenDTO tokenDTO) {

	QuoteEntity quoteEntity = userQuoteRepository.findById(id).get();
	Publish publish = quoteEntity.getIsPublish();
	String quote = quoteDTO.getQuote();
	String author = quoteDTO.getAuthor();

	if (quoteDTO.getIsPublish().equals("private")) {
		quoteEntity.setIsPublish(publish.PRIVATE);
	} else {
		quoteEntity.setIsPublish(publish.PUBLISH);
	}

	if (quote != null) {
		quoteEntity.setQuote(quoteDTO.getQuote());
	}
	if (author != null) {
		quoteEntity.setAuthor(quoteDTO.getAuthor());
	}

	return userQuoteRepository.save(quoteEntity);
}

 

Controller 로직에서 유저로부터 얻은 토큰을 해석하여 UserTokenDTO에 담아 Service 로직에서 사용할 수 있도록 설계했습니다.

repository에서 해당 ID의 유저의 정보를 호출하여 객체를 저장하는 로직에서 Toekn정보에서 얻은 유저의 ID를 사용하기 때문에 해당 유저가 수정하는 데이터가 자신의 데이터가 맞는지 확인할 수 없는 방법은 없습니다.

물론 프런트 쪽에서 그러한 로직을 추가하거나 DB에서 꺼낸 QuoteEntity가 현재 Token에 들어있는 유저 ID와 같은지 확인하는 방법이 있지만 모든 Service에 추가해야 하고 새롭게 만들게 될 Service 로직에도 적용해야 하기 때문에  설계가 좀 더 복잡해지고 관리하기 어렵다고 판단하였습니다.

 

 

2. 1회 발급을 보장하기 위해서

 

 

서버로부터 발급된 토큰은 서버가 관리하는 것이 아닌 클라이언트 쪽에서 가지고 있어야 하기 때문에 서버에 데이터를 요청하기 위해서는 어딘가 저장을 해둬야 합니다. 그렇기에 저는 Application Storage에 토큰을 저장하도록 설계했지만 이러한 설계에는 몇 가지의 문제점을 가지고 있었습니다.

  1. 발급한 토큰이 유효기간을 갖고 있지 않다면 무한히 서버에 접근하여 사용할 수 있다는 것입니다.
  2. 또한 크롬 및 기타 익스플로러마다 Storage에 저장되기 때문에 로그인이 요청될 경우 수많은 토큰들이 발생한다는 것입니다.

 

 

물론 유효기간을 설정하면 1번의 문제는 해결될 것입니다. 하지만 2번의 문제는 사용자가 A위치에서 B위치로 옮겨 로그인하더라도 A에 위치한 토큰은 만료될 때까지 서버의 연결은 보장받기 때문에 A에 다른 사용자가 있을 경우 보안에 대한 문제를 얻게 될 것입니다. 

 

 

만일 Redis에 발행된 토큰이 기록된다면 TokenA를 사용한 B의 서버 접근을 차단할 수 있는 효과를 얻을 것이며, 하나의 토큰이 발행되는 것을 보장할 수 있습니다.

 

 

3. 빠른 처리속도를 위해서

 

Redis는 일반 DB와 달리 Memory에 저장되기 때문에 Read / Write 속도가 빠릅니다. 만일 DB에 사용자의 토큰을 저장하여 접근 권한을 확인하게 된다면 하드 디스크로부터 데이터를 Read / Write 하기 때문에 상대적으로 Memory에서 Read / Write를 하는 것보다 느릴 수 있습니다.

또한 Redis는 Sort, Set, Hash를 비롯하여 다양한 자료구조를 제공하기 때문에 2번의 문제를 해결하는데 별도의 알고리즘을 만들 필요 없기 때문에 쉽게 해결할 수 있을 것입니다.

 

 

단점이 있음에도 사용하게 된 이유


Redis의 장점과 JWT의 장점들을 활용하여 이러한 문제의 해결에 도움을 줄 수 있지만, 단점도 분명히 존재합니다.

 

JWT를 사용함으로써 얻는 이익은 서버의 부담을 줄일 수 있는 것입니다. JWT의  Payload안에는 인증에 필요한 정보를 담고 있기 때문에 서버는 따로 인증에 대한 부담을 줄일 수 있지만, 너무 많은 정보를 담게 된다면 부하를 줄 수 있다는 것입니다. 또한 인증을 위해 Payload에 중요한 데이터를 넣게 된다면 사용자의 데이터가 일부 노출되는 상황이 발생할 수도 있습니다.

 

Redis 또한 마찬가지로 Memory에 저장하는 방식이기 Read / Write는 빠르지만 DB처럼 많은 양의 데이터를 저장할 수 없다는 한계를 가지고 있습니다. JWT가 갖고 있는 장점 중 하나인 인증을 위한 별도의 저장소를 관리할 필요가 없다는 장점이 사라지게 됩니다. 

 

그럼에도 사용해야만 하는 이유는 프론트와 백엔드를 분리하였기 때문입니다. 현재 GitHub Page에서 배포중인 프런트는  정적 리소스를 가지고 데이터를 보여주고 있기 때문에 백엔드에서의 인증과 권한이 필수적입니다. 또한 단점보다 장점이 더욱 많기 때문에 설계해야만 했던 이유이기도 합니다.

 

앞으로 추가할 것


Spring Security를 사용하여 인증과 권한을 좀 더 쉽에 제어할 수 있지 않을까 생각되며, Redis Server를 새로 만들어 BackEnd Server와 Redis Server를 분리할 것 같습니다. 현재로서는 돈이 없기 때문에 BackEnd Server와 Redis Server 그리고 Jenkins Server, DB Server가 한 곳에 전부 모여있지만 현재 프로젝트는 MSA를 목표로 만들고 있는 만큼 좀 더 고민을 해봐야 할 것 같습니다.

개요

 

AWS 53에서 도메인을 발급하여 호스팅 영역 레코드를 생성하고 난 후 서로 연결했음에도 내 도메인을 찾지 못했던 문제가 있었고 이를 잊지 않기 위해서 기록하려고 한다.

 

 

문제

 

원인은 레코드를 생성하고 난 후 호스팅 영역에서의 NS와 등록된 도메인의 NS가 불일치로 인해서 연결이 안되었다.

 

해결 방법

등록된 도메인 탭에 가면 생성된 도메인 이름 서버와 호스팅 세부 영역에서의 NS 값이 같아야 문제가 생기지 않는다.

 

개요

aws 서버에서 서비스 배포 중 런타임에 발생하게 될 에러를 보기 위해서 slack과 연동하기로 하였다.

 

 

maven

 

<dependency>
  <groupId>com.slack.api</groupId>
  <artifactId>bolt</artifactId>
  <version>1.27.2</version>
</dependency>
<dependency>
  <groupId>com.slack.api</groupId>
  <artifactId>bolt-servlet</artifactId>
  <version>1.27.2</version>
</dependency>

 

application.properties

 

properties에 토큰과 채널 이름을 정의한다.

#slack 
slack.token= your auth token
slack.channel.monitor= your channel name

 

토큰 생성

 

https://api.slack.com/

 

Slack은 당신을 위한 Digital HQ입니다

Slack은 여러분의 팀과 소통할 새로운 방법입니다. 이메일보다 빠르고, 더 조직적이며, 훨씬 안전합니다.

slack.com

 

1. 로그인 후 your apps 들어가기

 

2. Create New App

 

3. App Create

4. Permissions 설정

 

5. Permissions 설정 후 Install to Workspace 실행하면 토큰이 생성됨

6. Slack 테스트를 위한 서비스 생성

@Service
public class SlackService {
	
	
	@Value(value = "${slack.token}")
	private String token;
	
	@Value(value = "${slack.channel.monitor}")
	private String channel;

	
	public void postSlackMessage(String message) {
		
		try {
			MethodsClient methods = Slack.getInstance().methods(token);
			ChatPostMessageRequest request = 
					ChatPostMessageRequest.builder()
					.channel(channel)
					.text(message)
					.build();
			
			methods.chatPostMessage(request);
			
		}catch (Exception e) {
			// TODO: handle exception
			e.printStackTrace();
		}
	}
}

 

 

 

테스트 성공 후 

어떠한 에러들을 보낼 것인지 정한 것은 없기에 모든 에러 메시지를 Slack에 보내기로 결정함

@RestControllerAdvice
public class GlobalExceptionHandler {
	
	@Autowired
	private SlackService slackService;
    
	/**
	 * 모든 에러를 클라이언트에 보내지 않음
	 * 
	 * @param exception
	 * @return
	 */

	@ExceptionHandler(Exception.class)
	public ResponseEntity<String> globalExceptionErrorHandler(Exception exception){
		String errorMessage = exception.getMessage();
		
		slackService.postSlackMessage(errorMessage);
		
		return new ResponseEntity<String>("server error", HttpStatus.NOT_FOUND);
	}
}

결론

너무 당연한 이야기지만 깃 폴더 구조에서 파일 이름과 요청 경로가 맞아야 한다.

 

 

 

문제 발생

GitHub Page를 사용하면서 경로가 맞지 않다는 오류가 발생했다. 상대 경로로 파일 구조를 맞추었고 다른 파일들도 정상적으로 호출되는 것을 확인했지만 일부 파일들은 경로를 맞춰도 호출이 되지 않았다.

같은 경로에서 다른 파일은 정상적으로 호출된다.

원인

이유는 알 수 없지만 깃과 로컬 파일 이름이 맞지 않았다. 요청되지 않았던 일부 파일을 확인하니 모두 맞지 않는다는 것을 확인했고 모두 수정해 주었다.

 

FETCH를 통해 데이터를 전달할 때 mode라는 것을 알게되었다. 모드에 따라 헤더 요청을 보낼 수 있는 데이터가 달라지느 는 것 같다.

 

CORS가 보낼 수 있는 헤더 데이터

  • `Accept`
  • `Accept-Language`
  • `Content-Language`
  • `Content-Type`

NO-CORS

이외에도 Auhorization도 보낼 수 있다.

 

출처

 

https://fetch.spec.whatwg.org/#cors-safelisted-request-header

 

Fetch Standard

 

fetch.spec.whatwg.org

 

개요

SQL문을 공부하면서 얻은 지식을 정리하고 생각하고 있던 내용과 다른 것이 이해한 자신에게 반성하기 위해서 작성한다.

 

 

조건문에 대해서 내가 이해했던 내용

솔직히 내가 맨 처음으로 이해한 내용에 대해서는 기억이 나지 않는다. 대충 공부했던 부분이었으며 직접 쿼리문을 만들어 본 적이 없기에 2년 넘게 공부했음에도 전부 잘 못 알고 있는 내용인 것처럼 느껴진다.

난 처음으로 조건문등을 비교하면 컬럼에 내용을 값과 비교한다는 것에 대해서 오해를 하고 있었다..

 

AND

이해하고 있었다고 생각했지만 부족한 부분이라고 생각한다. 

이 명령어는 쿼리문을 작성할 때 아주 많이 사용하는 명령어이기에 다시 공부했을 때 다시 알게 된 느낌이다.

이 쿼리문을 보면 TEST 테이블 내용에서 YYYYMM 컬럼 내용이 202207이고 YYYYMM인 컬럼 내용을 전부 가져오라는 뜻이다. 하지만 난 이것을 YYYYMM 컬럼 내용 중에서 202207 ~ 202209 내용을 전부 가져오라는 뜻으로 받아들였다. 그렇지만 저 쿼리문도 잘못되었다. YYYYMM 값이 202207이고 202209인 내용 자체를 생각해보지 못하겠다. 내가 완전히 잘못 알고 있는 내용이었다. 다시는 이러한 실수를 하지 않도록 철저히 공부해야 할 듯한다.

IN

IN : 하나의 컬럼이 여러개의 =조건을 가지는 경우에 

이 쿼리문은 WHERE 절과 같은 역할을 가지고 있다. 단 AND의 역할은 하지 않는다. YYYYMM 컬럼이 202209이거나 202208인 데이터가 있으면 반환하라는 뜻이다.

 

OR

하나 이상의 조건이 맞는다면 값을 출력한다.

OR은 202208이거나 202209의 내용 중 하나라도 참인 데이터 값이 있다면 그 컬럼을 출력한다.

NOT

해당 조건문이 참이라면 거짓인 값을 출력한다.

현재 IN 조건문 안에 들어있는 202208과 202209를 제외한 나머지 데이터를 출력해준다.

 

개요

현재 1개월째 회사를 다니고 있으며 천천히 배워가던 중 svn을 사용하여 commit을 할 일이 생겼다. 사수에게 물어보면 또 혼날 것 같아서 혼자 찾아보았고 이 방법을 까먹지 않게 저장해두려고 한다.

 

 

 

상황

이런 식으로 아이콘이 생성되면 commit을 할 수 있지만 계속해서 E160028이라는 오류 코드가 생성이 되었다. 혹시 다른 파일들도 같은 형상인지 확인하기 위해 해당 파일만 제외하고 나머지를 커밋했지만 정상적으로 동작했다.

 

해결

 

해당 오류는 svn 내에 있는 파일 버전과 현재 로컬에서의 파일 버전이 맞지않아 발생하는 오류라고 한다. 그렇다면 

Team -> Update To ReVersion이라는 기능을 사용하여 버전을 맞춰준 후 커밋을 하는 방식으로 해결했다.

+ Recent posts