1. 개요

이전 포스트에서 웹소켓과 STOMP 동작 원리에 대해 공부했다. 이제 이를 Spring에서 동작하게 코드를 작성하려고 한다. 흐름은 다음과 같이 구현했다.

 

- 페이지 이동 : 사용자 개인 달력 페이지로 이동 + userId 

- 페이지 로딩 시 웹소켓 연결 : 페이지 이동과 함께 url로 넘겨받은 userId를 이용해 websocket 연결 및 구독(sub) 설정

- 버튼 클릭시 알림 수신 : '예산 알림 받기' 버튼 클릭 시 구독한 사용자와 관련된 알림 수신

- 알림 : 웹소켓으로 수신한 메시지를 bootstrap toast로 띄우기

 

 

2. 의존성 추가

웹소켓 연결을 위해 화면까지 구현했기에 view 관련 의존성도 같이 추가했다.

// 웹소켓
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.webjars:stomp-websocket:2.3.3'
implementation 'org.webjars:webjars-locator-core'
implementation 'org.webjars:sockjs-client:1.0.2'

// 화면
developmentOnly 'org.springframework.boot:spring-boot-devtools'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.webjars:bootstrap:3.3.7'
implementation 'org.webjars:jquery:3.1.1-1'
implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect'

 

3. WebSocketConfig

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

    private final StompHandler stompHandler; // jwt 토큰 인증 핸들러

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws-stomp") // "/ws-stomp"로 소켓 연결 설정
                .setAllowedOriginPatterns("*") // sockJS 로 웹소켓 연결 시 cors 설정 안되는 문제 해결
                .withSockJS();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/sub"); // "/sub" 을 구독하고 있으면 메시지 전송 가능
        registry.setApplicationDestinationPrefixes("/app"); // destination 헤더의 prefix가 /app인 경우
    }

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        // 토큰 인증을 위해 stompHandler 추가
        // 연결 전에 핸들러 메서드 실행
        registration.interceptors(stompHandler);
    }
}

 

registerStompEndpoints()

@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
    registry.addEndpoint("/ws-stomp") // "/ws-stomp"로 소켓 연결 설정
            .setAllowedOriginPatterns("*") // sockJS 로 웹소켓 연결 시 cors 설정 안되는 문제 해결
            .withSockJS();
}
  • addEndPoint() : 웹소켓의 엔드포인트를 지정한다. 클라이언트에서 해당 경로로 서버와 handshake 하게 된다
  • setAllowedOriginPatterns() : 허용할 origin 패턴을 지정할 수 있다(CORS 설정)
  • withSockJS() : 브라우저에서 SockJS를 사용한 websocket을 지원하지 않을 때 지원하는 대체 옵션

 

configureMessageBroker()

@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
    registry.enableSimpleBroker("/sub"); // "/sub" 을 구독하고 있으면 메시지 전송 가능
    registry.setApplicationDestinationPrefixes("/app"); // destination 헤더의 prefix가 /app인 경우
}
  • enableSimpleBroker() : 메시지 브로커를 활성화하고, subscribe 메시지 접두사를 설정
  • setApplicationDestinationPrefixes() : 클라이언트가 보낸 메시지에 destination 헤더에 해당 prefix("/app")로 시작하는 메시지를 메시지 브로커에서 처리하게 설정

 

configureClientInboundChannel()

토큰을 가진 로그인에 성공한 유저로 웹소켓을 연결할 것이므로, 토큰을 검증하는 로직이 필요하다.

@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
    // 토큰 인증을 위해 stompHandler 추가
    // 연결 전에 핸들러 메서드 실행
    registration.interceptors(stompHandler);
}

 

StompHandler

위의 configureClientboundChannel() 메서드 내부에서 사용하는 stompHandler의 코드이다.

preSend() 메서드를 오버라이딩해서 CONNECT하는 상황인 경우, 토큰을 검증해줘야 한다. 토큰의 유효성을 검증한 후, 유효하지 않다면 커스텀 예외(AccountBookException)가 발생하게 하였다.

@RequiredArgsConstructor
@Component
public class StompHandler implements ChannelInterceptor {
    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
        if (accessor.getCommand() == StompCommand.CONNECT) { // CONNECT 인 상황에서 토큰 검증
            if (!jwtTokenProvider.isValidateToken(accessor.getFirstNativeHeader("token"))) {
                throw new AccountBookException("", ErrorCode.VALIDATION_EXCEPTION); // 유효하지 않은 토큰에서 예외 발생
            }
        }
        return message;
    }
}

 

4. AlarmController

클라이언트로부터 전달받은 메시지를 처리하는 클래스이다.

@Controller
@RequiredArgsConstructor
public class AlarmController {

    private final SimpMessageSendingOperations messagingTemplate;
    private final AlarmService alarmService;

    @MessageMapping("/v1/budget/alarm/{memberId}")
    public void sendNotification(@DestinationVariable("memberId") Long memberId) {
        SendBudgetAlarmDto dto = alarmService.sendBudgetAlarm(memberId);
        messagingTemplate.convertAndSend("/sub/" + memberId, dto); // 구독한 곳으로 객체 전송
    }
}

 

 SimpMessageSendingOperations 인터페이스

간단한 메시징 프로토콜(예: STOMP)에 대한 Spring Framework 지원과 함께 사용하기 위한 메서드가 포함된 MessageSendingOperations의 specialization이다. 

 

@MessageMapping 

클라이언트가 보낸 메시지의 경로를 매핑해주는 어노테이션이다. 이 프로젝트에서는 memberId를 포함한 "/v1/budget/alarm/{memberId}" 경로로 메시지를 수신한 경우 sendNotification() 메서드를 실행하는 로직으로 진행된다.

 

@DestinationVariable

@PathVariable과 비슷한 어노테이션이다. HTTP method 매핑 시 parameter 값을 가져오기 위해 @PathVariable을 사용하는 것처럼, 메시지에서 매핑된 경로의 parameter를 가져오는 역할을 한다.

 

messagingTemplate.converAndSend(D destination, Object payload)

위의 WebSocketConfigure에서 설정한 엔드포인트(/ws-stomp)로 소켓 연결되면, 파라미터 destination(/sub/{memberId})으로 구독할 것이다. payload에는 alarmService에서 반환된 dto(메시지 내용이 담겨있다)를 넘겨준다. 

 

5. View

view에서 웹소켓 관련된 코드는 다음과 같다. 설명을 위해 일부분 생략했기에 전체 코드는 깃허브 링크에서 확인할 수 있다.

 

header.html

타임리프 레이아웃을 적용했기에 헤더 파일을 따로 만들어 두었는데, 구체적인 코드는 다음과 같다.

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<th:block th:fragment="headerFragment">
    <head>
        <meta http-equiv="Content-Type" content="text/event-stream; charset=utf-8"/>
        <title>AccountBook</title>

        <link href="/static/css/index.css" rel="stylesheet">
        <script src="/static/js/index.js" type="text/javascript"></script>

        <!--아래 순서가 중요하다-->
        <script src="/webjars/jquery/jquery.min.js"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js"></script>
        <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet">
        <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"></script>

        <script src="/webjars/sockjs-client/sockjs.min.js"></script>
        <script src="/webjars/stomp-websocket/stomp.min.js"></script>
    </head>
</th:block>
</html>

 

 

my-accountbook.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="layout/default_layout">
<th:block layout:fragment="content">

<script>
    $(document).ready(function () {
        getMemberId();
    });
</script>

<div id="top" class="container text-center">
    <div class="row">
        <div class="col-2"><a href="/index">AccountBook</a></div>
        <div class="col-8">My AccountBook</div>
        <div class="col-2"><button class="btn btn-light" id="my-page" onclick="redirectMemberId('/my-page/');">마이페이지</button></div>
    </div>
</div>

<div id="middle" class="container text-center" style="padding-top:50px;">
    <div class="row">
        <div class="col align-self-start">
            <div class="row">
                <div class="col-auto"><button class="btn btn-light nav-btn-prev" onclick="goPrev();"><</button></div>
                <div id="year-month" class="col-auto"></div>
                <div class="col-auto"><button class="btn btn-light nav-btn-next" onclick="goNext();">></button></div>
            </div>
        </div>
            <div class="col align-self-end"><button type="button" class="btn btn-success" id="budgetToastBtn" onclick="getAlarm();">예산 알림 받기</button></div>
    </div>
</div>

<div class="calendar">
    <div class="days">
        <div class="day">MON</div>
        <div class="day">TUE</div>
        <div class="day">WED</div>
        <div class="day">THU</div>
        <div class="day">FRI</div>
        <div class="day">SAT</div>
        <div class="day">SUN</div>
    </div>
    <div class="dates"></div>
</div>

<!--예산 알림 토스트-->
<div class="position-fixed top-0 end-0 p-3" style="z-index: 11">
    <div id="budgetToast" class="toast hide" role="alert" aria-live="assertive" aria-atomic="true">
        <div class="toast-header">
            <strong class="me-auto"></strong>
            <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
        </div>
        <div class="toast-body"></div>
    </div>
</div>

</body>
<script>
    var memberNumber = window.location.pathname.split('/')[2]; // 웹소켓 전용 회원id, 식별을 위해 number로 명명
    $(document).ready(function() {
        // 웹소켓 연결하기
        connectWebSocket(memberNumber);
    });

    function connectWebSocket(memberNumber) {
        const socket = new SockJS('http://localhost:8080/ws-stomp');
        stompClient = Stomp.over(socket);
        var accessToken = document.cookie.split('access_token=')[1];
        stompClient.connect({"token" : accessToken},
                             function (frame) {
                                 stompClient.subscribe('/sub/' + memberNumber, function (response) {
                                 
                                 // 수신한 메시지에 토스트 띄우기
                                 document.querySelector('.me-auto').innerHTML = thisMonth + "월 예산알림";
                                 document.querySelector('.toast-body').innerHTML = JSON.parse(response.body).message;
                                 $('.toast').toast('show');
                             });
                        });
    }
    
    function getAlarm() {
        stompClient.send("/app/v1/budget/alarm/" + memberNumber, {}, {});
    }
    
</script>
</th:block>
</html>

 

 

페이지 로딩시 connectWebSocket()으로 웹소켓 연결하기

<script>
    var memberNumber = window.location.pathname.split('/')[2]; // 웹소켓 전용 회원id, 식별을 위해 number로 명명
    $(document).ready(function() {
        // 웹소켓 연결하기
        connectWebSocket(memberNumber);
    });
</script>

 

connectWebSocket() - 클라이언트에서 웹소켓 요청하는 함수

<script>    
    function connectWebSocket(memberNumber) {
        const socket = new SockJS('http://localhost:8080/ws-stomp');
        stompClient = Stomp.over(socket);
        var accessToken = document.cookie.split('access_token=')[1];
        stompClient.connect({"token" : accessToken},
                             function (frame) {
                                 stompClient.subscribe('/sub/' + memberNumber, function (response) {
                                 
                                 // 수신한 메시지에 토스트 띄우기
                                 document.querySelector('.me-auto').innerHTML = thisMonth + "월 예산알림";
                                 document.querySelector('.toast-body').innerHTML = JSON.parse(response.body).message;
                                 $('.toast').toast('show');
                             });
                        });
    }
</script>

서버에서 설정한 url로 SockJS 인스턴스를 만든 후, access token과 함께 connect() 함수를 실행해 웹소켓을 연결하고, 의도한 경로(/sub/{memberNumber})를 구독한다.

 

메시지 수신이 성공적이라면 해당 내용을 토스트로 띄운다.

 

6. 브라우저에서 확인

이제 위에서 설정한 웹소켓이 의도한 대로 움직이는지 브라우저에서 확인해보자. 

 

웹소켓 연결 및 구독

사용자 화면에 이동하면서 url에 넘긴 16(memberId)로 destination에 /sub/16으로 subscribe한 것을 확인할 수 있다.

 

메시지 수신 후 토스트로 띄우기

'예산 알림 받기' 버튼을 클릭하면서 getAlarm() 함수가 실행되면서 생성된 메시지가 구독한 /sub/16으로 성공적으로 수신된 것을 확인할 수 있다. 또한 수신한 메시지를 토스트로 띄어지는 것까지 확인할 수 있다.

 

참고

 

1. 계기

로그인 api만 개발했을 때는 로그인 성공 후 응답에 bearer token을 발급하는 것에서 그쳤지만, 화면까지 같이 개발하면서 로그인 유저와 로그아웃 유저를 식별해야하기에 발급된 토큰을 화면에서 다루는 법을 학습하기 위해 진행했다. 

 

Spring Security로 로그인을 구현했던터라 세션 방식의 로그인을 구현할 수 있었지만, 실무에서는 세션 로그인보다는 api/토큰으로 프론트와 통신하는 작업이 더 많을 것이라 생각해 실무를 위해서 조금 어렵더라도 Spring Security, JWT 토큰, Cookie로 로그인을 구현했다.

 

2. Spring Security로 JWT 토큰 발급 및 검증 구현

이 과정은 설명이 꽤 길어서 별도의 포스트에 기록했다. 아래 링크를 참고하면 된다.

로그인 : https://heyazoo1007.tistory.com/811

로그아웃 : https://heyazoo1007.tistory.com/812

 

3. 로그인 구현

AuthController, signIn()

@Controller
@RequestMapping("/v1/auth")
@RequiredArgsConstructor
public class AuthController {
    private static final int LOGIN_DURATION = 60 *  60 * 10;
    private final AuthService authService;

    ...
    
    @PostMapping("/sign-in")
    @ResponseBody
    public ApiResponse<LinkResponseDto> signIn(
            @Valid @RequestBody LoginRequestDto request, HttpServletResponse response
    ) {
        TokenResponseDto tokenResponseDto = authService.signIn(request.getEmail(), request.getPassword());

        Cookie cookie = new Cookie("access_token", tokenResponseDto.getAccessToken());
        cookie.setMaxAge(LOGIN_DURATION);
        cookie.setPath("/");

        response.addCookie(cookie);

        return ApiResponse.SUCCESS;
    }
}

 

AuthService, signIn()

@Service
@RequiredArgsConstructor
public class AuthService {    
    
    public TokenResponseDto signIn(String email, String password) {
        // 해당 사용자가 존재하지 않는 경우
        Member member = notExistedMember(email);

        // 이메일에 비밀번호가 매치되지 않는 경우
        // 비밀번호가 틀렸는데 아이디 혹은 비밀번호로 출력하는 이유는 혹시 모를 개인 정보 유출 때문
        emailOrPasswordMismatch(password, member.getPassword());

        List<String> roles = new ArrayList<>();
        roles.add(member.getRole().toString());

        return jwtTokenProvider.createToken(email, roles);
    }
}

 

TokenResponseDto

@Builder
@Data
@AllArgsConstructor
public class TokenResponseDto {

    private String accessToken;
}

 

4. 토큰으로 로그인한 유저 확인

위 2, 3의 단계를 설정 완료한 뒤 브라우저에서 로그인을 성공하면 다음과 같이 HTTP 메시지 헤더 Cookie에 JWT access token 값이 들어있는 것을 확인할 수 있다.

 

 

클라이언트에서 보낸 HTTP 메시지 헤더의 Cookie값을 서버에서 유효성 검증을 하기 위해서는 JwtTokenProvider 클래스resolveToken()가 필요한데, 코드는 다음과 같다.

    public String resolveToken(HttpServletRequest request) {
        String cookie = request.getHeader("Cookie");
        if (cookie != null) {
            String bearerToken = cookie.split("access_token=")[1];
            if (StringUtils.hasText(bearerToken)) {
                return bearerToken;
            }
        }
        return null;
    }

 

위의 resolveToken()를 JwtAuthenticationFilter 클래스에서 사용하면 클라이언트에서 넘어온 Cookie에 있는 JWT토큰의 유효성을 검증할 수 있다. 

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public void doFilter(ServletRequest request,
                         ServletResponse response,
                         FilterChain chain
    ) throws IOException, ServletException {

        // 1. Request Header 에서 JWT 토큰 추출
        String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);


        // 2. validateToken 으로 토큰 유효성 검사
        ... (코드 생략)
    }
}

 

5. Cookie로 로그인 유저 식별하기

이 방법은 브라우저에서 Cookie의 값을 가져온 뒤, 서버에서 발급한 access token이 있다면 로그인을 한 것으로 보고 access token이 없다면 로그아웃된 상태로 보는 방법이다. 코드는 다음과 같다.

 

index.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="layout/default_layout">
<th:block layout:fragment="content">
    <script>
        $(document).ready(function() {
            initIndex();
        });
    </script>

    <nav class="navbar navbar-expand-lg navbar-light bg-light">
        <div class="container-fluid">
            <h1 class="navbar-brand">AccountBook</h1>
            <div>
                <!--로그인 전-->
                
                ...(코드 생략)
                
                <!--로그인 후-->
                
                ...(코드 생략)
                
            </div>
        </div>
    </nav>

</th:block>
</html>

 

initIndex()

function initIndex() {
    // httpOnly 로 설정된 Cookie 는 document.cookie 로 읽을 수 없음
    const accessToken = document.cookie.split('access_token=')[1];
	
    ... (코드 생략)
    
    if(accessToken != null) { // 쿠키에 access_token 이 있을 때(로그인 했을 때)
        ... (코드 생략)   
    } else { // 쿠키에 access_token 이 없을 때
        ...(코드 생략)
    }
}

 

사실 위 코드는 cookie에 대한 보안이 없어서 토큰을 브라우저에서 바로 확인할 수 있고, 그렇기에 외부에서 토큰을 탈취하기가 쉬워 보안적으로 좋은 방법은 아니다. 쿠키의 보안을 위해 httpOnly Cookie를 사용할 수도 있지만, 프론트 프레임워크를 사용하지 않는 지금으로썬 브라우저에서 토큰을 확인할 방법이 없기 때문에(httpOnly cookie를 사용하면 document.cookie로 쿠키 값 확인이 안된다) 보안적으로 취약하지만 이 방법이 현재의 최선책이라고 생각해 진행했다. 추후에 관련된 지식이 추가되면 방법을 바꿀 예정이다.

 

 

참고

Security+타임리프+jwt를 이용한 회원가입 및 로그인

 

1. 계기

뱅크샐러드를 모티브로 시작한 프로젝트라 뱅크샐러드처럼 달력 형태로 daily 지출내역을 관리할 수 있으면 좋을 것 같다고 생각했다. 그래서 일단 달마다 시작일과 요일, 전체 날짜가 다르니 동적으로 만들어야할 것 같은데 잘 몰라서 역시 구글링을 했다. javascript로 동적인 날짜 정보를 가져오고, 그 정보를 우리가 아는 달력의 형태처럼 만들기 위해 css를 사용했다. 

 

* '예산 알림 받기' 버튼은 해당 포스트와는 관련 없는 내용이므로 설명을 하지 않았다.*

 

2. 기본 구조 - HTML

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="layout/default_layout">
<th:block layout:fragment="content">

<div id="top" class="container text-center">
    <div class="row">
        <div class="col-2"><a href="/index">AccountBook</a></div>
        <div class="col-8">My AccountBook</div>
        <div class="col-2">마이 페이지</div>
    </div>
</div>

<div id="middle" class="container text-center" style="padding-top:50px;">
    <div class="row">
        <div class="col align-self-start">
            <div class="row">
                <div class="col-auto"><button class="btn btn-light nav-btn-prev" onclick="goPrev();"><</button></div>
                <div id="year-month" class="col-auto"></div>
                <div class="col-auto"><button class="btn btn-light nav-btn-next" onclick="goNext();">></button></div>
            </div>
        </div>
            <div class="col align-self-end"><button type="button" class="btn btn-success" id="budgetToastBtn" onclick="getAlarm();">예산 알림 받기</button></div>
    </div>
</div>

<div class="calendar">
    <div class="days">
        <div class="day">MON</div>
        <div class="day">TUE</div>
        <div class="day">WED</div>
        <div class="day">THU</div>
        <div class="day">FRI</div>
        <div class="day">SAT</div>
        <div class="day">SUN</div>
    </div>
    <div class="dates"></div>
</div>

</th:block>
</html>

 

2-1. 코드 톺아보기

- 레이아웃 코드

thymeleaf로 레이아웃을 만들어 적용했기에 윗부분에 다음과 같은 코드가 있다. 이는 레이아웃을 적용하지 않았다면 필요없다.

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="layout/default_layout">
<th:block layout:fragment="content">

 

- 달력 넘기는 코드

<div id="middle" class="container text-center" style="padding-top:50px;">
    <div class="row">
        <div class="col align-self-start">
            <div class="row">
                <div class="col-auto"><button class="btn btn-light nav-btn-prev" onclick="goPrev();"><</button></div>
                <div id="year-month" class="col-auto"></div>
                <div class="col-auto"><button class="btn btn-light nav-btn-next" onclick="goNext();">></button></div>
            </div>
        </div>
            <div class="col align-self-end"><button type="button" class="btn btn-success" id="budgetToastBtn" onclick="getAlarm();">예산 알림 받기</button></div>
    </div>
</div>

왼쪽 상단에 현재 날짜를 보여주고, 이전달과 다음달로 이동할 수 있는 코드이고, goPrev(), goNext()로 동작한다.

 

- 캘린더 코드

<div class="calendar">
    <div class="days">
        <div class="day">MON</div>
        <div class="day">TUE</div>
        <div class="day">WED</div>
        <div class="day">THU</div>
        <div class="day">FRI</div>
        <div class="day">SAT</div>
        <div class="day">SUN</div>
    </div>
    <div class="dates"></div>
</div>

기본 캘린더 틀이고, 요일을 div로 만들고, 각 날짜를 아래 date 클래스의 div 태그에 동적으로 할당할 예정이다.

 

 

3. Javascript로 날짜 정보 동적으로 가져오기

 

3-1. Javascript 전체 코드

<script>
    var currentYear = ''; // 현재 페이지에 있는 연도
    var currentMonth = ''; // 현재 페이지에 있는 월
    var currentDate = ''; // 현재 페이지에 있는 일
    $(document).ready(function() {
        // 한국 표준시간 가져오기
        var date = new Date();
        var utc = date.getTime() + (date.getTimezoneOffset() * 60 * 1000);
        var kstGap = 9 * 60 * 60 * 1000;
        var today = new Date(utc + kstGap);
        var koreaDate = new Date(today.getFullYear(), today.getMonth(), today.getDate());

        // 연, 월, 일 가져오기
        currentYear = koreaDate.getFullYear();
        currentMonth = koreaDate.getMonth() + 1;
        currentDate = koreaDate.getDate();

        // 페이지 로딩 초기에 달력 가져오기
        renderCalendar(currentYear, currentMonth, currentDate);
    });

    function renderCalendar(currentYear, currentMonth, currentDate) {
    	// zeroFormat()은 따로 정의한 함수, 2024-5를 2024-05로 만들어주는 역할
        $('#year-month').text(currentYear + '.' + zeroFormat(currentMonth));

        // 지난달 마지막 날짜, 요일 구하기
        var startDay = new Date(currentYear, currentMonth - 1, 0);
        var prevDate = startDay.getDate();
        var prevDay = startDay.getDay();

        // 이번 달의 마지막 날짜, 요일 구하기
        var endDay = new Date(currentYear, currentMonth, 0);
        var nextDate = endDay.getDate();
        var nextDay = endDay.getDay();

        calendar = document.querySelector('.dates')
        calendar.innerHTML = '';

		// 이번달의 1일 위치를 맞추기 위해 이전달의 날짜들 위치시키기
        for (var i = prevDate - prevDay + 1; i <= prevDate; i++) {
            calendar.innerHTML = calendar.innerHTML + '<div class="day prev disable"></div>'
        }

		// 이번달의 날짜 위치시키기
        for (var i = 1; i <= nextDate; i++) {
            calendar.innerHTML = calendar.innerHTML + '<button type="button" id=' + i + ' class="day current btn-light" data-bs-toggle="modal" data-bs-target="#dateModal" onclick="modal(this.id);">' + i + '</button> ';
        }
    }
    
    // 이전달로 이동
    function goPrev() {
        currentMonth -= 1
        if (currentMonth == 0) {
            currentYear -= 1;
            currentMonth = 12;
        }
        renderCalendar(currentYear, currentMonth, 1);
    }

    // 다음달로 이동
    function goNext() {
        currentMonth += 1
        if (currentMonth == 13) {
            currentYear += 1;
            currentMonth = 1;
        }
        renderCalendar(currentYear, currentMonth, 1);
    }
    
    function zeroFormat(value) {
        if (value / 10 < 1) {
            return '0' + value;
        } else {
            return value;
        }
    }
</script>

 

3-2. 코드 톺아보기 - 한국 표준시간 데이터 가져오기 

    var currentYear = ''; // 현재 페이지에 있는 연도
    var currentMonth = ''; // 현재 페이지에 있는 월
    var currentDate = ''; // 현재 페이지에 있는 일
    $(document).ready(function() {
        // 한국 표준시간 가져오기
        var date = new Date();
        var utc = date.getTime() + (date.getTimezoneOffset() * 60 * 1000);
        var kstGap = 9 * 60 * 60 * 1000;
        var today = new Date(utc + kstGap);
        var koreaDate = new Date(today.getFullYear(), today.getMonth(), today.getDate());

        // 연, 월, 일 가져오기
        currentYear = koreaDate.getFullYear();
        currentMonth = koreaDate.getMonth() + 1;
        currentDate = koreaDate.getDate();

        // 페이지 로딩 초기에 달력 가져오기
        renderCalendar(currentYear, currentMonth, currentDate);
    });
  • currentYear, currentMonth, currentDate는 현재, 이전달, 다음달에도 사용할 것이기 때문에 전역변수로 설정했다.
  • 한국 표준시간의 달력이 필요하므로 한국 시간으로 계산해 Date 객체를 생성한다. 
  • 주의할 점은 month가 0부터 시작하기 때문에 숫자를 맞추기 위해서 month 값에 +1을 꼭 해주어야 한다.

 

필요한 year, month, date를 renderCalendar()에 넘겨 달력을 만든다.

 

3-3. 코드 톺아보기 - 캘린더 로드하기

    function renderCalendar(currentYear, currentMonth, currentDate) {
    	// zeroFormat()은 따로 정의한 함수, 2024-5를 2024-05로 만들어주는 역할
        $('#year-month').text(currentYear + '.' + zeroFormat(currentMonth));

        // 지난달 마지막 날짜, 요일 구하기
        var startDay = new Date(currentYear, currentMonth - 1, 0);
        var prevDate = startDay.getDate();
        var prevDay = startDay.getDay();

        // 이번 달의 마지막 날짜, 요일 구하기
        var endDay = new Date(currentYear, currentMonth, 0);
        var nextDate = endDay.getDate();
        var nextDay = endDay.getDay();

        calendar = document.querySelector('.dates')
        calendar.innerHTML = '';

		// 이번달의 1일 위치를 맞추기 위해 이전달의 날짜들 위치시키기
        for (var i = prevDate - prevDay + 1; i <= prevDate; i++) {
            calendar.innerHTML = calendar.innerHTML + '<div class="day prev disable"></div>'
        }

		// 이번달의 날짜 위치시키기
        for (var i = 1; i <= nextDate; i++) {
            calendar.innerHTML = calendar.innerHTML + '<button type="button" id=' + i + ' class="day current btn-light" data-bs-toggle="modal" data-bs-target="#dateModal" onclick="modal(this.id);">' + i + '</button> ';
        }
    }

이번달의 전체 날짜를 알게 되었으니 <div class="dates">를 가져와서 날짜를 입력하면 된다. 이때 고려해야할 점은 1일의 요일 위치인데, 이전달을 고려하지 않고 이번달 데이터만 html에 추가하면 1일의 요일이 맞지 않게 된다. 

 

따라서 이전달 정보를 가지고 와서 이번달 1일 전에 태그를 추가함으로써 이번달 1일의 요일을 맞출 수 있다.

// 지난달 마지막 날짜, 요일 구하기
var startDay = new Date(currentYear, currentMonth - 1, 0);
var prevDate = startDay.getDate();
var prevDay = startDay.getDay();
        
// 이번달의 1일 위치를 맞추기 위해 이전달의 날짜들 위치시키고 보이지 않게 disable하기
for (var i = prevDate - prevDay + 1; i <= prevDate; i++) {
    calendar.innerHTML = calendar.innerHTML + '<div class="day prev disable"></div>'
}

 

3-4. 이전달, 다음달로 이동하기

    // 이전달로 이동
    function goPrev() {
        currentMonth -= 1
        if (currentMonth == 0) {
            currentYear -= 1;
            currentMonth = 12;
        }
        renderCalendar(currentYear, currentMonth, 1);
    }

    // 다음달로 이동
    function goNext() {
        currentMonth += 1
        if (currentMonth == 13) {
            currentYear += 1;
            currentMonth = 1;
        }
        renderCalendar(currentYear, currentMonth, 1);
    }

이전달과 다음달로 캘린더 이동은 기본적으로 위에서 구현한 renderCalendar()에 필요한 날짜를 넘겨주면 쉽게 구현할 수 있다. 여기서 주의해야할 점은 날짜 계산을 어떻게 할 지인데, 1년은 1월부터 12월까지이고, 1월 이전에는 전년도의 12월이, 12월의 다음달은 다음년도의 1월이기 때문이다. 이는 먼저 월을 계산한 다음에 이전 년도나 다음년도로 넘어간다면 따로 currentYear, currentMonth를 재설정하는 방식으로 해결했다.

 

 

구현 화면

위 코드를 적용하면 날짜 정보는 동적으로 가져오지만, 아래처럼 우리가 아는 달력 형태가 아닌 데이터가 나열된 형태로만 확인할 수 있다.

 

4.  css로 달력 모양 만들기

/resources/static/css/index.css

.calendar {
    padding-top: 60px;
    position: relative;
    margin: 0 auto;
}

.calendar .days {
    display: flex;
    margin-bottom: 20px;
    padding-bottom: 20px;
    border-bottom: 1px solid #ddd;
}

.calendar .day {
    display:flex;
    align-items: center;
    justify-content: center;
    width: calc(100% / 7);
    text-align: left;
    color: #999;
    font-size: 12px;
    text-align: center;
    border-radius:5px
}

.calendar .dates {
    display: flex;
    flex-flow: wrap;
    height: 290px;
}

 

구현 화면

 

위 css 코드를 적용한 다음에 화면이다. 우리가 알고 있는 일반 달력화면이 되었다! 참고한 블로그에서는 월요일부터 시작하는 달력으로 만들었지만 일요일부터 시작하는 달력을 만들고 싶으면 커스터마이징하면 될 것 같다.

 

 

참고

자바스크립트로 달력 만들기

 

1. 고민

가계부 프로젝트에서 핵심은 지출내역 저장/수정/삭제라고 생각하는데, 수정 화면을 만들 때 고민이 많이 되었다. 가장 고민이 되었던 부분은 수정 페이지로 넘어갔을 때 해당 리소스의 데이터를 어떻게 가져올 것인지였다. 등록 화면은 기존 데이터가 있을 필요없이 그냥 새로운 페이지를 만들면 되는데, 수정 화면은 기존의 리소스 데이터를 가져와서 보여준 뒤 사용자가 변경한 것을 저장하는 방식으로 진행을 해야했다. 

 

진행하면서 가장 고민이 되었던 부분은 기존의 리소스 데이터를 어떤 로직으로 가져와서 보여줄 것인지였다. 처음에는 화면 로딩 시 ajax로 데이터를 가져와서 반영하려고 했으나 아직 미숙해서 그런지 잘되지 않았다. 그래서 이전에 배웠던 Thymeleaf의 th:value 문법Controller에서 Model 객체로 수정하려는 리소스의 데이터를 담아서 화면에 넘겨주는 방식을 선택했다.

 

2. 순서

진행 순서는 다음과 같다.

 

1. '수정' 버튼 클릭

2. 수정화면으로 이동 + 클라이언트는 url로 리소스(지출내역) id를 서버로 전달

3. 서버는 받은 id로 리소스 정보를 db에서 가져와 Controller 단에서 Model에 담아서 전달

4. model에 있는 정보를 화면에서 보여주기

 

2-1. '수정' 버튼 클릭

// 수정 버튼 클릭시 modifyDailyPayment(this.id) 호출
// 여기서 this.id는 리소스의 id가 되게 설정했다
`<button class="btn btn-success btn-payment" id="`+ paymentId + `" onclick="modifyDailyPayments(this.id);">수정</button></li> `;

 

2-2. 수정화면으로 이동 + 클라이언트는 url로 리소스(지출내역) id를 서버로 전달

// 해당 리소스의 수정 화면으로 이동
function modifyDailyPayments(paymentId) {
  location.replace("http://localhost:8080/expenditure/" + paymentId);
}

 

2-3. 서버는 받은 id로 리소스 정보를 db에서 가져와 Controller 단에서 Model에 담아서 전달

@Controller
@RequiredArgsConstructor
public class AccountBookController {    
    
    @GetMapping("/expenditure/{paymentId}")
    public String modifyExpenditure(@PathVariable long paymentId, Model model) {
        GetDailyPaymentsResponseDto payment = dailyPaymentsService
                .getDailyPayment(paymentId);
                
        model.addAttribute("payment", payment);
        return "edit-expenditure";
    }
}

dailyPaymentsService에서 id로 리소스를 식별해 정보를 GetDailyPaymentsReponseDto 객체에 저장한 뒤, Model을 통해 "payment"라는 이름으로 해당 정보를 view로 넘겨준다.

 

- GetDailyPaymentsResponseDto.class

@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class GetDailyPaymentsResponseDto {

    private long dailyPaymentId;
    private Integer paidAmount;
    private String payLocation;
    private String methodOfPayment;
    private Long categoryId;
    private String categoryName;
    private String memo;
    private String date;
    
    public static GetDailyPaymentsResponseDto of(DailyPayments dailyPayments, String categoryName) {
        return GetDailyPaymentsResponseDto
                .builder()
                .dailyPaymentId(dailyPayments.getId())
                .paidAmount(dailyPayments.getPaidAmount())
                .payLocation(dailyPayments.getPayLocation())
                .methodOfPayment(dailyPayments.getMethodOfPayment())
                .categoryId(dailyPayments.getCategoryId())
                .categoryName(categoryName)
                .memo(dailyPayments.getMemo())
                .date(dailyPayments.getDate())
                .build();
    }
}

 

- model.addAttribute()

model.addAttribute("payment", payment);

 

 

필요한 정보를 view에 모두 보냈으면, 수정화면으로 이동하기 위해 "edit-expenditure" 문자열을 반환하면 된다.

 

 

2-4. model에 있는 정보를 화면에서 보여주기

위의 과정을 거치면 기존에 저장되어있던 지출 정보를 수정 화면에서 확인할 수 있다.

아직 드롭박스에 반영하는 법은 잘 모르겠다. 추후 적용 예정!

 

코드

위에서 서버에서 가져온 지출내역을 payment라는 이름으로 view에 넘겼다. 그렇기에 thymeleaf 문법을 이용해 payment의 정보를 적재적소에 위치시켜보자.

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="layout/default_layout">
<th:block layout:fragment="content">

    <div id="top" class="container text-center">
        <div class="row">
            <h3>지출 수정하기</h3>
        </div>
    </div>

    <div id="middle1" class="container text-center" style="padding-top:50px;">
        <div class="row">
            <div class="col-6">금액<input id="paid-amount" th:value="${payment.paidAmount}"></div>
            <div class="col-6">
                카테고리
                <button class="btn btn-light" type="button" th:text="${payment.categoryName}" id="category-radio" data-bs-toggle="modal" data-bs-target="#categoryModal" onclick="getCategoryList();" ></button>
            </div>
        </div>
    </div>

    <div id="middle2" class="container text-center" style="padding-top:50px;">
        <div class="row">
            <div class="col-6">거래처<input id="pay-location" th:value="${payment.payLocation}"></div>
            <div class="col-6">결제 수단<input id="method-payment" th:value="${payment.methodOfPayment}"></div>
        </div>
    </div>

    <div id="middle3" class="container text-center" style="padding-top:50px;">
        <div class="row">
            <div class="col-6">메모<input id="memo" th:value="${payment.memo}"></div>
            <div class="col-6">날짜
                <select id="date-select" class="form-select" onclick="getDate();" th:text="${payment.date}"></select>
            </div>
        </div>
    </div>
    
    ...
    
<th:block>
</html>

 

 

기본적인 thymeleaf 문법은 아래 th:value=..  형식을 따르면 된다.

<input id="paid-amount" th:value="${payment.paidAmount}">

 

 

2-5. 수정 완료

이제 원하는 만큼 정보를 변경한 뒤 아래 "수정하기" 버튼을 클릭하면 수정 api를 전송하면서 수정이 완료된다.

+ Recent posts