개요

이번엔 스트리밍 대해서도 공부할겸 비디오 스트리밍 서버를 만들어볼겁니다. 아직 만들고있는 중이라 차근차근 하나씩 배우고 바꿔가며 만들어보겠습니다. 이번 글에서는 JavaScript를 이용해 비디오 플레이어를 만들어 보겠습니다. 아 그전에 이번 프로젝트에 대해서 설명하겠습니다.

 

 

구조

최종적으로 이런 구조로 만들어보고싶은데 가능할지는.. 잘 모르겠습니다. 하나하나 해보면서 최대한 해봐야죠.

 

이렇게 구성한 이유는 몇가지가 있습니다.

 

  1. 사용자가 동영상을 업로드하면 동영상을 변환하는 과정은 비동기로 처리하도록 했습니다.
    • 720p 10분짜리의 영상을 변환하는데 약 3-4분 정도 걸렸고, 서버에 부담을 많이 되어 비동기로 작업을 진행했습니다.
    • 비동기로 작업했을 때 3개 이상의 작업을 동시에 처리하면 서버부하가 걸리는 것을 확인했고, Thread를 제한했습니다.
    • 단일 서버에서 고작 2개의 동영상만 동시에 업로드 된다는 것은 말이 안되기에 변환 작업을 담당하는 서버를 두어 동시에 여러 데이터를 처리하도록 구조를 변경했습니다.
    • 유저는 업로드 '요청'을 하면 DB에서 동영상이 업로드 되었는지 여부를 지속적으로 확인하여 업로드가 완료되었는지 실패되었는지 확인할 수 있습니다.
  2. Storage는 아직 공부를 하지 못한 부분이지만 우선 위의 구현이 끝나면 RAID(?) 뭐 그걸로 해서 실제로 구축해보려고합니다.

 

적응형 스트리밍

이번 프로젝트에서는 비디오를 실행할 적응형 스트리밍에 대해서 알고 넘어가야합니다. 적응형 스트리밍은 인터넷 환경에서 끊김 없는 비디오 스트리밍을 제공하기 위한 기술입니다. 단순히 비디오를 HTTP로 다운로드를 해서 재생하는 것과는 달리 네트워크 상태에 따라 동적으로 비디오 품질을 조절할 수 있습니다.

 

 

무엇이 다른건가?

기존의 방식은 하나의 동영상 데이터가 선택되어 다운로드를 하며 플레이 하는 방식입니다.

만약 유저의 네트워크 상태가 좋지 않다면 동영상을 제대로 시청할 수 없게 될 수 있습니다. 또한 동영상의 길이가 30분이라고 했을때 유저가 1분만에 페이지를 나가거나 동영상을 넘겨 20분부터 시청한다면 시청하지 않은 비디오의 데이터가 낭비되는 문제가 생길 수 있겠죠. 

이런 문제를 해결하기 위해 적응형 스트리밍 기술이 만들어졌습니다.

 

 

 

특징

 

컨텐츠 보호

적응형 스트리밍 기술은 동영상을 인코딩 하여 사용되기 때문에 원본 동영상을 사용하지 않습니다.

 

동영상 분할

동영상을 작은 조각으로 나누어서 사용됩니다. 이를 세그먼트(조각) 분할이라고 합니다.

  • 하나의 비디오 파일을 여러 개의 작은 조각(세그먼트)으로 나눔
  • 보통 2-10초 길이의 세그먼트 사용
  • 각 세그먼트는 독립적으로 재생 가능

품질 인코딩

  • 동일한 영상을 여러 가지 품질(비트레이트)로 인코딩
  • 예: 1080p, 720p, 480p, 360p 등
  • 각 품질별로 세그먼트 생성

매니페스트 파일

  • 모든 품질의 세그먼트 정보를 담은 인덱스 파일
  • 재생 가능한 품질 목록, 세그먼트 URL, 재생 시간 등 포함
  • 프로토콜별로 다른 형식 사용 (HLS: .m3u8, DASH: .mpd)

동적 품질 전환

  • 클라이언트가 네트워크 상태 모니터링
  • 대역폭에 따라 적절한 품질의 세그먼트 요청
  • 재생 중에도 끊김 없이 품질 전환 가능

 

HLS와 DASH

현재 널리 사용되는 적응형 스트리밍 프로토콜입니다.

  1. HLS (HTTP Live Streaming)
    • Apple이 개발한 프로토콜
    • iOS 기기와의 호환성이 뛰어남
    • 가장 널리 사용되는 스트리밍 프로토콜
    • .m3u8 재생목록 파일과 .ts 세그먼트 파일 사용
  2. DASH (Dynamic Adaptive Streaming over HTTP)
    • MPEG에서 표준화한 프로토콜
    • Netflix, YouTube 등에서 사용
    • .mpd 매니페스트 파일과 .mp4 세그먼트 사용

 

구현하기

이제 비디오 플레이어를 만들어봅시다.

 

Hls.js

저는 HLS를 사용할겁니다. 웹에서 HLS 를 다루기 위해서는 Hls.js 를 사용해야 합니다.

 

GitHub - video-dev/hls.js: HLS.js is a JavaScript library that plays HLS in browsers with support for MSE.

HLS.js is a JavaScript library that plays HLS in browsers with support for MSE. - video-dev/hls.js

github.com

<script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script>

이렇게 넣고 시작합시다.

 

 

기본적인 사용방법은 다음과 같습니다.

if (!Hls.isSupported()) return; // Hls 지원 여부
const hls = new Hls({
    debug: true,  // 디버깅 여부
    playlistLoadPolicy: {
        default: {
            // 첫 번째 바이트를 받을 때까지 기다리는 최대 시간 (10초)
            maxTimeToFirstByteMs: 10000,

            // 전체 로딩이 완료될 때까지 기다리는 최대 시간 (10초)
            maxLoadTimeMs: 10000,

            // 타임아웃 발생 시 재시도 설정
            timeoutRetry: {
                maxNumRetry: 2,      // 최대 2번 재시도
                retryDelayMs: 0,     // 재시도 사이의 대기 시간 없음
                maxRetryDelayMs: 0   // 최대 대기 시간 제한 없음
            },

            // 에러 발생 시 재시도 설정
            errorRetry: {
                maxNumRetry: 3,      // 최대 3번 재시도
                retryDelayMs: 1000,  // 재시도 전 1초 대기
                maxRetryDelayMs: 8000 // 재시도 간 최대 대기 시간 8초
            }
        }
    }
});

hls.loadSource(document.querySelector('#videoPlayer'));
hls.attachMedia('/video/asdf.m3u8');

 

 

먼저 Hls 가 지원되는지 확인하고 Hls 설정해줍니다. 설정할게 없다면 new Hls() 이렇게만 해도 됩니다.

loadSource에 <video></video> 요소를 넣어주고 attachMedia에 플레이리스트 경로를 넣어주면 됩니다. 

 

 

HTML

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Custom Video Player</title>
    <link rel="stylesheet" href="/css/video.css">
    <script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script>
    <script type="module" src="/js/video.js"></script>

</head>
<body>
<div class="video-container">
    <div class="video-wrapper">
        <video id="videoPlayer" crossorigin="anonymous" aria-id=""></video>
        <div class="thumbnail-box">
            <img class="thumbnail-image" src="" alt="">
            <button type="button" id="playButton">
                <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512">
                    <path d="M73 39c-14.8-9.1-33.4-9.4-48.5-.9S0 62.6 0 80L0 432c0 17.4 9.4 33.4 24.5 41.9s33.7 8.1 48.5-.9L361 297c14.3-8.7 23-24.2 23-41s-8.7-32.2-23-41L73 39z"></path>
                </svg>
            </button>
        </div>
        <div class="panel disabled">
            <div class="play-pause-event">
                <svg class="animate-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path d="M73 39c-14.8-9.1-33.4-9.4-48.5-.9S0 62.6 0 80L0 432c0 17.4 9.4 33.4 24.5 41.9s33.7 8.1 48.5-.9L361 297c14.3-8.7 23-24.2 23-41s-8.7-32.2-23-41L73 39z"/></svg>
            </div>
            <div class="loading-spinner disabled">
                <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
                    <path d="M304 48a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zm0 416a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zM48 304a48 48 0 1 0 0-96 48 48 0 1 0 0 96zm464-48a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zM142.9 437A48 48 0 1 0 75 369.1 48 48 0 1 0 142.9 437zm0-294.2A48 48 0 1 0 75 75a48 48 0 1 0 67.9 67.9zM369.1 437A48 48 0 1 0 437 369.1 48 48 0 1 0 369.1 437z"/>
                </svg>
            </div>
            <div class="controller">
                <div class="timeline">
                    <div class="buffer-progress"></div>
                    <div class="progress"></div>
                </div>
                <div class="control-box">
                    <div class="left-control-box">
                        <button class="play-pause" aria-keyshortcuts="space">
                            <svg class="animate-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path d="M73 39c-14.8-9.1-33.4-9.4-48.5-.9S0 62.6 0 80L0 432c0 17.4 9.4 33.4 24.5 41.9s33.7 8.1 48.5-.9L361 297c14.3-8.7 23-24.2 23-41s-8.7-32.2-23-41L73 39z"/></svg>
                        </button>
                        <div class="time-display">0:00 / 0:00</div>
                        <div class="volume-container">
                            <button class="volume-btn">
                                <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon><line x1="23" y1="9" x2="17" y2="15"></line><line x1="17" y1="9" x2="23" y2="15"></line></svg>
                            </button>
                            <div class="volume-slider">
                                <div class="volume-progress"></div>
                            </div>
                        </div>
                    </div>
                    <div class="right-control-box">
                        <div class="quality-container">
                            <button class="quality-btn"></button>
                            <div class="quality-options disabled"></div>
                        </div>
                        <button class="fullscreen">
                            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M136 32c13.3 0 24 10.7 24 24s-10.7 24-24 24L48 80l0 88c0 13.3-10.7 24-24 24s-24-10.7-24-24L0 56C0 42.7 10.7 32 24 32l112 0zM0 344c0-13.3 10.7-24 24-24s24 10.7 24 24l0 88 88 0c13.3 0 24 10.7 24 24s-10.7 24-24 24L24 480c-13.3 0-24-10.7-24-24L0 344zM424 32c13.3 0 24 10.7 24 24l0 112c0 13.3-10.7 24-24 24s-24-10.7-24-24l0-88-88 0c-13.3 0-24-10.7-24-24s10.7-24 24-24l112 0zM400 344c0-13.3 10.7-24 24-24s24 10.7 24 24l0 112c0 13.3-10.7 24-24 24l-112 0c-13.3 0-24-10.7-24-24s10.7-24 24-24l88 0 0-88z"/></svg>
                        </button>
                    </div>
                </div>
            </div>
        </div>
    </div>
</div>

</body>
</html>

 

비디오 플레이어의 디자인은 유튜브 플레이어를 참고해서 만들었습니다. <video></video> 를 덮는 panel을 두고 동영상을 조작할 수 있도록 만들었습니다.

 

 

CSS

.video-container * {
    box-sizing: border-box;
}

.video-container button {
    background: none;
    border: none;
    cursor: pointer;
    color: white;

    width: 30px;
    height: 30px;
    padding: 5px;
}
.video-container .disabled {
    display: none;
}
.video-container button > svg {
    pointer-events: none;
    fill: white;
}
.video-container svg {
    width: 20px;;
    height: 20px;
}

.video-container {
    max-width: 800px;
    margin: 20px auto;
    overflow: hidden;
    border-radius: 12px;
    box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}

.video-wrapper {
    position: relative;
    width: 100%;
}

#videoPlayer {
    width: 100%;
    display: block;
}

.thumbnail-box {
    position: absolute;
    top: 0; bottom: 0;
    left: 0; right: 0;
}
.thumbnail-box img {
    width: 100%;
    height: 100%;
}
#playButton {
    position: absolute;
    transform: translate(-50%, -50%);
    top: 50%; left: 50%;
    width: 60px; height: 60px;
    border-radius: 100%;
    background-color: #ec2020;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
}

#playButton:hover {
    background-color: #d81c1c;
}

.panel {
    position: absolute;
    width: 100%;
    height: 100%;
    bottom: 0;
    left: 0;
    right: 0;
    background: linear-gradient(to top,
        rgba(0, 0, 0, 0.7) 0%,
        rgba(0, 0, 0, 0.2) 40%,
        rgba(0, 0, 0, 0) 100%
    );
    opacity: 0;
    transition: opacity 0.3s;
    align-content: end;
}

.panel:hover, .panel.active {
    opacity: 1;
}

.controller {
    padding: 20px 10px 5px 10px;
    display: flex;
    flex-direction: column;
    gap: 10px;
}

.timeline {
    position: relative;
    height: 5px;
    background: rgba(255, 255, 255, 0.3);
    cursor: pointer;
    border-radius: 3px;
    overflow: hidden;
    transition: height 0.15s ease-in-out;
}
.timeline:hover {
    height: 8px;
}

/* 로드된 세그먼트를 표시할 요소 추가 */
.buffer-progress {
    position: absolute;
    height: 100%;
    background: rgba(255, 255, 255, 0.4);
    border-radius: 3px;
    width: 0;
}

.progress {
    position: absolute;
    height: 100%;
    background: #ff0000;
    border-radius: 3px;
    width: 0;
    z-index: 1;  /* 버퍼 프로그레스 위에 표시 */
}



.control-box{
    display: flex;
    align-items: center;
    justify-content: space-between;
}
.left-control-box, .right-control-box {
    display: flex;
    align-items: center;
    gap: 10px;
}

.volume-container {
    display: flex;
    align-items: center;
}
.volume-container:hover .volume-slider {
    width: 60px;
}

.volume-slider {
    transition: width 0.2s ease-in-out;
    width: 0px;
    height: 5px;
    background: rgba(255, 255, 255, 0.3);
    border-radius: 3px;
    cursor: pointer;
}

.volume-progress {
    height: 100%;
    background: #fff;
    border-radius: 3px;
    width: 100%;
}

.time-display {
    color: white;
    font-size: 14px;
    text-align: center;
}

.quality-container {
    position: relative;
}

.quality-btn {
    background: none;
    border: none;
    color: white;
    cursor: pointer;
    padding: 5px 10px;
    font-size: 14px;
    width: initial !important;
}

.quality-options {
    display: block;
    position: absolute;
    bottom: 100%;
    right: 0;
    background: rgba(0, 0, 0, 0.9);
    border-radius: 4px;
    padding: 5px 0;
    margin-bottom: 5px;
    z-index: 2;
}

.quality-options button {
    display: block;
    width: 100%;
    padding: 5px 20px;
    background: none;
    border: none;
    color: white;
    cursor: pointer;
    text-align: left;
    white-space: nowrap;
}

.quality-options button:hover {
    background: rgba(255, 255, 255, 0.1);
}

.animate-icon {
    animation: iconFade 0.3s ease-out;
}

@keyframes iconFade {
    from {
        transform: scale(0.9);
        opacity: 0.5;
    }
    to {
        transform: scale(1);
        opacity: 1;
    }
}

.play-pause-event {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    opacity: 0;
    pointer-events: none;
    background: rgba(0, 0, 0, 0.4);
    width: 50px;
    height: 50px;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 100%;
}
.play-pause-event svg {
    fill: white;
}
.play-pause-event.active {
    animation: scaleAndFade 0.7s ease-out forwards;
}

@keyframes scaleAndFade {
    0% {
        transform: translate(-50%, -50%) scale(1);
        opacity: 1;
    }

    100% {
        transform: translate(-50%, -50%) scale(1.7);
        opacity: 0;
    }

}

.loading-spinner {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    width: 50px;
    height: 50px;
    display: flex;
    align-items: center;
    justify-content: center;
}

.loading-spinner svg {
    width: 30px;
    height: 30px;
    fill: white;
    animation: spin 1s linear infinite;
}

@keyframes spin {
    from {
        transform: rotate(0deg);
    }
    to {
        transform: rotate(360deg);
    }
}

 

 

JavaScript

const KEYS = {
    NEXT : 'ArrowRight',
    PREV : 'ArrowLeft',
    PLAY_PAUSE : 'Space',
    FULL_SCREEN : 'KeyF'
}
const DISABLED = 'disabled';

class Video {

    constructor(videoElement, playlistUrl, panelElement) {
        if (!this.#isSupported()) return;
        this.video = videoElement;
        this.playlistUrl = playlistUrl;
        this.loadingSpinner = document.querySelector('.loading-spinner');

        this.#setHls();
        this.controller = new Controller(this.hls, this.video, this.loadingSpinner, panelElement);
        this.#addLoadingEvent();
    }

    setPlayButton(thumbnailBoxElement, playButtonElement) {
        this.thumbnail = thumbnailBoxElement;
        this.playButton = playButtonElement;
    }

    #addLoadingEvent() {
        // 로딩 시작
        this.video.addEventListener('waiting', () => {
            this.loadingSpinner.classList.remove(DISABLED);
            this.controller.panel.classList.add('active');
        });

        // 로딩 완료
        this.video.addEventListener('canplay', () => {
            this.loadingSpinner.classList.add(DISABLED);
            this.controller.panel.classList.remove('active');
        });

    }
    #isSupported() {
        return Hls.isSupported();
    }
    #setHls() {
        this.hls = new Hls();
    }

    #initPlayButtonEvent() {
        this.thumbnail.remove();
        this.controller.panel.classList.remove(DISABLED);
        this.play();
    }

    initialEventListener() {
        this.controller.initialDependenciesEventListener();
    }

    async autoPlay() {
        this.hls.loadSource(this.playlistUrl);
        this.hls.attachMedia(this.video);

        if (await this.play()) {
            this.thumbnail.remove();
            this.controller.panel.classList.remove(DISABLED);
        } else {
            this.playButton.addEventListener('click', () => {
                this.#initPlayButtonEvent();
            }, {once : true})
        }
    }

    async play() {
        return await this.controller.playPause.play();
    }

    pause() {
        this.controller.playPause.pause();
    }

}

class Controller {

    constructor(hls, video, loadingSpinner, panelElement) {
        this.hls = hls;
        this.video = video;
        this.loadingSpinner = loadingSpinner;
        this.panel = panelElement;
    }

    setQuality(qualityBtnElement, qualityOptionsBox) {
        this.quality = new Quality(qualityBtnElement, qualityOptionsBox);

        // 화질 최초 선택
        this.hls.on(Hls.Events.MANIFEST_PARSED, (event, data) => {
            console.log('Available qualities:', data.levels);

            // 사용 가능한 화질 옵션 생성
            this.quality.createQualityOptions(data.levels);
            this.quality.addEventChangeQuality(this.hls, this.playPause, this.loadingSpinner);

            // 현재 화질 레벨 설정
            let currentLevel = this.hls.currentLevel;
            this.quality.updateQualityButton(currentLevel, data.levels);

            // 자동 화질 선택 모드 설정
            this.hls.currentLevel = -1;
        });

        // 화질 변경 이벤트 처리
        this.hls.on(Hls.Events.LEVEL_SWITCHED, (event, data) => {
            this.quality.updateQualityButton(data.level, data.levels);
        });

    }
    setVolume(volumeBtn, volumeSlider, volumeProgress) {
        this.volume = new Volume(this.video, volumeBtn, volumeSlider, volumeProgress);
    }
    setPlayPauseBtn(playPauseBtnElement, playPauseEventElement) {
        this.playPause = new PlayPause(this.video, this.panel, playPauseBtnElement, playPauseEventElement);
    }
    setFullScreen(fullscreenBtnElement) {
        this.fullScreen = new FullScreen(this.video, fullscreenBtnElement);
    }
    setTimeline(timelineElement, progressElement, timeDisplay) {
        this.timeline = new Timeline(this.video, timelineElement, progressElement, timeDisplay);
    }

    initialDependenciesEventListener() {
        this.timeline.setDraggingEvent(this.playPause);
    }

    setKeyShortCuts() {
        document.addEventListener('keydown', (e) => {
            // 입력 필드에 포커스가 있을 때는 단축키 비활성화
            if (document.activeElement.tagName === 'INPUT' ||
                document.activeElement.tagName === 'TEXTAREA') {
                return;
            }
            this.playPause.keyShortcuts(e,10, 10);
            this.fullScreen.keyShortcuts(e);
        })

    }
}
class PlayPause {

    constructor(video, panel, playPauseBtnElement, playPauseEventElement) {
        this.video = video;
        this.panel = panel;
        this.playPauseBtn = playPauseBtnElement;
        this.playPauseEvent = playPauseEventElement;

        this.#addEvent();
    }

    #addEvent() {
        this.playPauseBtn.addEventListener('click', () => {
            this.#playPauseToggle();
        });

        this.panel.addEventListener('click', (e) => {
            if (e.target !== this.panel) return;
            this.#playPauseToggle();
            this.#showEventAnimation();
        })

        // 비디오 종료 시 reload 버튼 변경
        this.video.addEventListener('ended', () => {
            this.playPauseBtn.innerHTML = this.getReloadSvg();
        });
    }

    /**
     * @param e
     * @param increaseAmount : number n초 후로 비디오 타임라인 이동
     * @param decreaseAmount : number n초 전으로 비디오 타임라인 이동
     */
    keyShortcuts(e, increaseAmount, decreaseAmount) {
        if (e.code === KEYS.NEXT) {
            // 오른쪽 화살표 (n초 앞으로)
            this.video.currentTime = Math.min(this.video.duration, this.video.currentTime + increaseAmount);
        } else if (e.code === KEYS.PREV) {
            // 왼쪽 화살표 (n초 뒤로)
            this.video.currentTime = Math.max(0, this.video.currentTime - decreaseAmount);
        } else if (e.code === KEYS.PLAY_PAUSE) {
            // 시작, 일시정지
            this.#playPauseToggle();
        }
    }

    #playPauseToggle() {
        this.isPaused() ? this.play() : this.pause();
    }

    #showEventAnimation() {
        // 이전 타이머가 있다면 정리
        if (this.activeTimer) {
            clearTimeout(this.activeTimer);
            this.playPauseEvent.classList.remove('active');
            // 강제 리플로우 발생
            void this.playPauseEvent.offsetWidth;
        }

        this.playPauseEvent.classList.add('active');

        // 애니메이션이 700ms 이기 때문에 700으로 설정
        this.activeTimer = setTimeout(() => {
            this.playPauseEvent.classList.remove('active');
            this.activeTimer = null;
        }, 700);
    }

    async play() {
        try {
            let isPlay = await this.video.play();
            this.playPauseBtn.innerHTML = this.getPauseSvg();
            this.playPauseEvent.innerHTML = this.getPauseSvg();
            return isPlay;
        } catch (e) {
            return false;
        }
    }

    pause() {
        this.video.pause();
        this.playPauseBtn.innerHTML = this.getPlaySvg();
        this.playPauseEvent.innerHTML = this.getPlaySvg();
    }

    isPaused() {
        return this.video.paused;
    }

    getPauseSvg() {
        return '<svg class="animate-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path d="M48 64C21.5 64 0 85.5 0 112L0 400c0 26.5 21.5 48 48 48l32 0c26.5 0 48-21.5 48-48l0-288c0-26.5-21.5-48-48-48L48 64zm192 0c-26.5 0-48 21.5-48 48l0 288c0 26.5 21.5 48 48 48l32 0c26.5 0 48-21.5 48-48l0-288c0-26.5-21.5-48-48-48l-32 0z"/></svg>';
    }
    getPlaySvg() {
        return '<svg class="animate-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path d="M73 39c-14.8-9.1-33.4-9.4-48.5-.9S0 62.6 0 80L0 432c0 17.4 9.4 33.4 24.5 41.9s33.7 8.1 48.5-.9L361 297c14.3-8.7 23-24.2 23-41s-8.7-32.2-23-41L73 39z"/></svg>';
    }
    getReloadSvg() {
        return '<svg class="animate-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M463.5 224H472c13.3 0 24-10.7 24-24V72c0-9.7-5.8-18.5-14.8-22.2s-19.3-1.7-26.2 5.2L413.4 96.6c-87.6-86.5-228.7-86.2-315.8 1c-87.5 87.5-87.5 229.3 0 316.8s229.3 87.5 316.8 0c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0c-62.5 62.5-163.8 62.5-226.3 0s-62.5-163.8 0-226.3c62.2-62.2 162.7-62.5 225.3-1L327 183c-6.9 6.9-8.9 17.2-5.2 26.2s12.5 14.8 22.2 14.8H463.5z"/></svg>';
    }

}
class Quality {

    constructor(qualityBtnElement, qualityOptionsBox) {
        this.qualityBtn = qualityBtnElement;
        this.qualityOptionsBox = qualityOptionsBox;

        this.#addEvent();
    }

    createQualityOptions(levels) {
        this.qualityOptionsBox.innerHTML = '';

        // AUTO 옵션 추가
        const autoButton = document.createElement('button');
        autoButton.setAttribute('data-quality', '-1');
        autoButton.textContent = 'AUTO';
        this.qualityOptionsBox.appendChild(autoButton);

        // 높은 화질부터 추가
        for (let i = levels.length - 1; i >= 0; i--) {
            const level = levels[i];
            const resolution = level.height + 'p';
            const button = document.createElement('button');
            button.setAttribute('data-quality', i.toString());
            button.textContent = resolution;
            this.qualityOptionsBox.appendChild(button);
        }
    }

    // 화질 버튼 텍스트 업데이트

    updateQualityButton(level, levels) {
        if (level === -1) {
            this.qualityBtn.textContent = 'AUTO';
        } else if (levels && level < levels.length) {
            this.qualityBtn.textContent = levels[level].height + 'p';
        }
        this.qualityOptionsBox.classList.add(DISABLED);
    }

    #addEvent() {
        // 선택 후 박스 감춤 이벤트
        this.qualityBtn.addEventListener('click', () => {
            this.qualityOptionsBox.classList.toggle(DISABLED);
        })
        document.addEventListener('click', (e) => {
            if (e.target !== this.qualityOptionsBox && e.target !== this.qualityBtn) {
                this.qualityOptionsBox.classList.add(DISABLED);
            }
        })
    }

    addEventChangeQuality(hls, playPause, loadingSpinner) {
        const qualityOptions = this.qualityOptionsBox.querySelectorAll('button');
        qualityOptions.forEach(button => {
            button.addEventListener('click', () => {
                const quality = parseInt(button.getAttribute('data-quality'));

                if (quality === hls.currentLevel) {
                    this.qualityOptionsBox.classList.add(DISABLED);
                    return;
                }

                loadingSpinner.classList.remove(DISABLED);

                if (quality === -1) {
                    // AUTO 모드 활성화
                    hls.currentLevel = -1;
                    hls.loadLevel = -1;
                    hls.config.startLevel = -1;  // 자동 품질 선택
                } else {
                    // 수동으로 특정 품질 선택
                    hls.currentLevel = quality;
                    hls.loadLevel = quality;
                }

                this.updateQualityButton(quality, hls.levels);

                const onFragLoaded = (event, data) => {
                    loadingSpinner.classList.add(DISABLED);
                    hls.off(Hls.Events.FRAG_LOADED, onFragLoaded);
                };

                hls.on(Hls.Events.FRAG_LOADED, onFragLoaded);
            });
        });
    }



}
class Volume {

    constructor(video, volumeBtn, volumeSlider, volumeProgress) {
        this.video = video;
        this.volumeBtn = volumeBtn;
        this.volumeSlider = volumeSlider;
        this.volumeProgress = volumeProgress;
        // 드래그를 통한 볼륨 조절 기능
        this.isDraggingVolume = false;

        this.addEvent();

    }

    #loadLocalVolumeData() {
        const savedVolume = localStorage.getItem('videoVolume');
        if (savedVolume !== null) {
            this.video.volume = parseFloat(savedVolume);
            this.#drawVolumeProgress(this.video.volume);
        }
        this.updateVolumeIcon(this.video.volume);
    }

    addEvent() {
        // Initialize volume display
        this.video.addEventListener('loadedmetadata', () => {
            this.#loadLocalVolumeData();
        });

        // 볼륨 음소거 토글
        this.volumeBtn.addEventListener('click', () => {
            this.video.muted = !this.video.muted;
            this.updateVolumeIcon(this.video.muted ? 0 : this.video.volume);
            this.#drawVolumeProgress(this.video.muted ? 0 : this.video.volume);
        });

        // 볼륨 조절
        this.volumeSlider.addEventListener('click', (e) => {
            const pos = this.#getPos(e);
            this.video.volume = pos;
            this.#drawVolumeProgress(pos);
            this.updateVolumeIcon(pos);
        });

        // 볼륨 슬라이더 드래그 이벤트
        this.volumeSlider.addEventListener('mousedown', (e) => {
            this.isDraggingVolume = true;
            this.handleVolumeChange(e);
        });

        document.addEventListener('mousemove', (e) => {
            if (this.isDraggingVolume) this.handleVolumeChange(e);
        });

        document.addEventListener('mouseup', () => {
            this.isDraggingVolume = false;
        });
    }

    // 볼륨 아이콘 업데이트
    updateVolumeIcon(volume) {
        const volumeLevel = Math.floor(volume * 100);
        if (volumeLevel === 0) {
            this.volumeBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M301.1 34.8C312.6 40 320 51.4 320 64l0 384c0 12.6-7.4 24-18.9 29.2s-25 3.1-34.4-5.3L131.8 352 64 352c-35.3 0-64-28.7-64-64l0-64c0-35.3 28.7-64 64-64l67.8 0L266.7 40.1c9.4-8.4 22.9-10.4 34.4-5.3zM425 167l55 55 55-55c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-55 55 55 55c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0l-55-55-55 55c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l55-55-55-55c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0z"/></svg>';
        } else if (volumeLevel <= 50) {
            this.volumeBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M301.1 34.8C312.6 40 320 51.4 320 64l0 384c0 12.6-7.4 24-18.9 29.2s-25 3.1-34.4-5.3L131.8 352 64 352c-35.3 0-64-28.7-64-64l0-64c0-35.3 28.7-64 64-64l67.8 0L266.7 40.1c9.4-8.4 22.9-10.4 34.4-5.3zM412.6 181.5C434.1 199.1 448 225.9 448 256s-13.9 56.9-35.4 74.5c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C393.1 284.4 400 271 400 256s-6.9-28.4-17.7-37.3c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5z"/></svg>';
        } else {
            this.volumeBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M333.1 34.8C312.6 40 320 51.4 320 64l0 384c0 12.6-7.4 24-18.9 29.2s-25 3.1-34.4-5.3L131.8 352 64 352c-35.3 0-64-28.7-64-64l0-64c0-35.3 28.7-64 64-64l67.8 0L266.7 40.1c9.4-8.4 22.9-10.4 34.4-5.3zm172 72.2c43.2 35.2 70.9 88.9 70.9 149s-27.7 113.8-70.9 149c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C507.3 341.3 528 301.1 528 256s-20.7-85.3-53.2-111.8c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5zm-60.5 74.5C466.1 199.1 480 225.9 480 256s-13.9 56.9-35.4 74.5c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C425.1 284.4 432 271 432 256s-6.9-28.4-17.7-37.3c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5z"/></svg>';
        }
    }

    handleVolumeChange(e) {
        let pos = this.#getPos(e);
        this.video.volume = pos;
        this.#drawVolumeProgress(pos);
        this.updateVolumeIcon(pos);

        // 볼륨 설정 저장
        localStorage.setItem('videoVolume', this.video.volume);
    }

    #drawVolumeProgress(volume) {
        this.volumeProgress.style.width = (volume * 100) + '%';
    }

    #getPos(e) {
        const rect = this.volumeSlider.getBoundingClientRect();
        let pos = (e.clientX - rect.left) / rect.width;
        return Math.max(0, Math.min(1, pos)); // Clamp between 0 and 1
    }


}
class Timeline {

    constructor(video, timelineElement, progressElement, timeDisplay) {
        this.video = video;
        this.timeline = timelineElement;
        this.progress = progressElement;
        this.bufferProgress = this.timeline.querySelector('.buffer-progress');
        this.timeDisplay = timeDisplay;
        this.isDraggingProgress = false;
        this.currentTimelineLocation = 0;
        this.#addEvent();
    }

    setDraggingEvent(playPause) {

        // 버퍼링 상태 업데이트
        this.video.addEventListener('progress', () => {
            this.#updateBufferProgress();
        });

        // 타임라인 드래그 이벤트
        this.timeline.addEventListener('mousedown', (e) => {
            this.isDraggingProgress = true;
            playPause.pause();
            this.currentTimelineLocation = this.#handleProgressChange(e);
        });

        document.addEventListener('mousemove', (e) => {
            if (this.isDraggingProgress) {
                this.currentTimelineLocation = this.#handleProgressChange(e);
                this.percentage(this.currentTimelineLocation, this.video.duration);
            }
        });

        document.addEventListener('mouseup', () => {
            if (this.isDraggingProgress) {
                this.isDraggingProgress = false;
                this.video.currentTime = this.currentTimelineLocation;
                playPause.play();
            }
        });
    }

    #addEvent() {
        // 타임라인 클릭
        this.timeline.addEventListener('click', (e) => {
            this.video.currentTime = this.#handleProgressChange(e);
        });

        // 타임라인 업데이트
        this.video.addEventListener('timeupdate', () => {
            this.percentage(this.video.currentTime,this.video.duration);
        });


    }

    #handleProgressChange(e) {
        const rect = this.timeline.getBoundingClientRect();
        const pos = (e.clientX - rect.left) / rect.width;
        return pos * this.video.duration;
    }

    #updateBufferProgress() {
        if (this.video.buffered.length > 0) {
            const bufferedEnd = this.video.buffered.end(this.video.buffered.length - 1);
            const duration = this.video.duration;
            this.bufferProgress.style.width = ((bufferedEnd / duration) * 100) + '%';
        }
    }

    percentage(current, total) {
        this.timeDisplay.textContent = `${this.#formatTime(current)} / ${this.#formatTime(total)}`;
        this.progress.style.width = (current / total) * 100 + '%';
    }

    // 시간 포맷팅
    #formatTime(seconds) {
        if (isNaN(seconds)) return '--:--';
        const minutes = Math.floor(seconds / 60);
        seconds = Math.floor(seconds % 60);
        return `${minutes}:${seconds.toString().padStart(2, '0')}`;
    }
}
class FullScreen {

    constructor(video, fullscreenBtn) {
        this.video = video;
        this.fullScreen = fullscreenBtn;
        this.#addEvent();
    }

    #addEvent() {
        this.fullScreen.addEventListener('click', () => this.#toggleFullScreen());
    }

    keyShortcuts(e) {
        if (e.code === KEYS.FULL_SCREEN) {
            this.#toggleFullScreen();
        }
    }

    #toggleFullScreen() {
        document.fullscreenElement ? this.#fullScreenExit() : this.#fullScreen();
    }
    #fullScreen() {
        this.video.parentElement.requestFullscreen();
    }
    #fullScreenExit() {
        document.exitFullscreen();
    }

}

window.addEventListener('load', () => {
    const videoElement = document.querySelector('#videoPlayer');
    const videoId = videoElement.getAttribute('aria-id');
    const playlistUrl = `/videos/${videoId}/master.m3u8`;

    const video = new Video(
        videoElement,
        playlistUrl,
        document.querySelector('.panel')
    );

    video.setPlayButton(
        document.querySelector('.thumbnail-box'),
        document.querySelector('#playButton')
    );
    video.controller.setQuality(
        document.querySelector('.quality-btn'),
        document.querySelector('.quality-options')
    )
    video.controller.setPlayPauseBtn(
        document.querySelector('.play-pause'),
        document.querySelector('.play-pause-event')
    );
    video.controller.setVolume(
        document.querySelector('.volume-btn'),
        document.querySelector('.volume-slider'),
        document.querySelector('.volume-progress')
    )
    video.controller.setFullScreen(
        document.querySelector('.fullscreen')
    );
    video.controller.setTimeline(
        document.querySelector('.timeline'),
        document.querySelector('.progress'),
        document.querySelector('.time-display')
    )
    video.controller.setKeyShortCuts();
    video.initialEventListener();
    video.autoPlay();
});

 

JS만 거의 600줄 정도 됩니다. 최대한 깔끔하게 만들려고 Class를 만들어 관리하도록 해보았습니다. HTML, CSS는 쉬웠는데 JS가 쉽지않더라구요. Class를 만들기 전에는 800줄 정도였습니다 ㄷㄷ 더 복잡했었기도하고..

 

 

Chrome 자동재생 정책

비디오 플레이어를 만들면서 하나 알아낸 사실은 크롬에는 비디오 자동재생 정책이란게 존재한다는 것을 알았습니다. 원하지 않는 스팸성 광고를 차단하기 위한 정책인데요. 동영상이 음소거상태이거나, 이전에 유저와 상호작용을 했다거나(재생버튼 클릭으로 동영상 재생), 이전에 미디어를 소비한 것을 바탕으로 점수를 합산해 높은 점수를 받은(신뢰할 수 있는) 사이트에서는 자동재생을 허용하도록 한다고합니다.

 

about://media-engagement 여기에 들어가보면 그 스코어를 확인할 수 있고, 유튜브 같은 경우 신뢰할 수 있는 사이트로 분류되어 음소거 되지않은 영상을 자동재생하는 것이 가능합니다.

 

 

 

Chrome의 자동재생 정책  |  Blog  |  Chrome for Developers

Chrome의 새로운 자동재생 정책을 통해 우수한 사용자 경험을 위한 권장사항을 알아보세요.

developer.chrome.com

자세한 내용은 여기에서 확인해보세요.

 

 

 

마치며

비디오 플레이어는 Hls만 연결해서 재생하는 것밖에 안하기 때문에 별로 중요하지 않아 자세한 설명은 하지 않고 마치겠습니다. 

 

 

 

 

[Spring Boot] 선착순 이벤트 쿠폰을 만들어보자 (1) - 동시성 이슈

들어가기 전에 우아한테크톡의 선착순 이벤트 서버 생존기를 보고 아이디어를 얻었고, 그 과정을 처음부터 부딪히면서 배우기 위해 바닥부터 만들어보면서 느낀점과 배운것들을 정리하기 위

tmd8633.tistory.com

 

[Spring Boot] 선착순 이벤트 쿠폰을 만들어보자 (2) - Redis

[Spring Boot] 선착순 이벤트 쿠폰을 만들어보자 (1) - 동시성 이슈들어가기 전에 우아한테크톡의 선착순 이벤트 서버 생존기를 보고 아이디어를 얻었고, 그 과정을 처음부터 부딪히면서 배우기 위

tmd8633.tistory.com

이전 글을 읽어보시기 바랍니다. 이전 글에서 이어집니다.

 

 

개요

이전 글에서 선착순 이벤트 쿠폰 발급에 대해서 공부했습니다. 이는 오늘 같이 공부해볼 내용을 하기 위해서 선행학습에 불과합니다. 파트별로 공부하기 위해서 구현해보았던 것이죠. 오늘은 이벤트 주문에 대해서 공부해볼겁니다.

 

큐넷

큐넷 시험신청이나 수강신청을 할 때 이런 대기열을 보셨을겁니다. 이걸 만들어볼겁니다. 이전 글에서 Redis를 활용하여 대기열을 만들었었는데 그걸 이용하면 금방 구현해볼 수 있을 것같습니다.

 

 

이전과 다른 점

이전 선착순 이벤트 쿠폰을 발행하는 로직은 줄을 세우고 순차적으로 원하는 양만큼만 발급해주면 끝났습니다. 그런데 이번에는 줄을 세우고 줄을 선 유저에게 자신이 몇번째인지 알려주고 이벤트 페이지에 접속하기전까지 갱신해주어야합니다. 이 점을 생각하면서 전체적인 구조를 그려보겠습니다.

 

 

구조

유저가 /event 에 접속 요청을 하면 Filter에서 인증된 토큰이 있는지 검사합니다. 없으면 Redis에서 토큰을 생성해 대기열에 넣고, /waiting 으로 redirect 해서 현재 대기열을 표시해줍니다.

/waiting 에서 /api/v1/polling 요청으로 현재 대기열이 몇명인지, 인증되었는지, 내 대기순번은 몇번인지를 1초마다 polling 해줍니다.

유저의 토큰이 인증되었으면 /event 로 보내줍니다.

 

이렇게 구성해보았습니다. 이제 구현해보겠습니다.

 

 

 

Page

@Controller
public class EventController {

    @GetMapping("/")
    public String mainPage() {
        return "main";
    }

    @GetMapping("/event")
    public String orderPage() {
        return "event";
    }

    @GetMapping("/waiting")
    public String waitingPage() {
        return "waiting";
    }

}

 

main.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>메인페이지임</title>
</head>
<body>
    <h1>메인페이지임</h1>
    <a href="/event">이벤트 참여</a>
</body>
</html>

메인페이지에서 이벤트를 참여할 수 있게 만들었습니다.

event.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>이벤트 페이지</title>
</head>
<body>
    <h1>이벤트 페이지임</h1>
</body>
</html>

이벤트페이지입니다. 인증된 토큰이 있어야지 접속할 수 있습니다.

 

waiting.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>대기중</title>
    <style>
        body {
            width: 100%;
            height: 100vh;
            display: flex;
            align-items: center;
            justify-content: center;
        }
        .container {
            width: 300px;
            height: 400px;
            background-color: antiquewhite;
            display: flex;
            align-items: center;
            justify-content: center;
        }
        h1,
        h3 {
            text-align: center;
        }
    </style>
    <script src="/js/waiting.js"></script>
</head>
<body>
<div class="container">
    <div id="card">
        <h3>대기번호</h3>
        <h1 id="rank">0</h1>
        <span>뒤에 <span id="waitingCount">0</span>명이 있습니다.</span>
    </div>
</div>
</body>
</html>

대기열페이지입니다. 내 대기번호와 그 뒤로 몇명의 대기자기 있는지 확인할 수 있습니다.

 

waiting.js

const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token');

if (token == null) {
    alert('토큰 없음');
    window.location.href = '/';
}

window.addEventListener('load', () => {

    const rank = document.querySelector('#rank');
    const remain = document.querySelector('#waitingCount');

    const poling = () => setInterval(() => fetchGet('/api/v1/poling', callback), 1000);
    poling();


    const callback = (json) => {
        let jRank = json.rank;
        let jTotal = json.total;
        let jAuthenticated = json.authenticated;

        if (jAuthenticated) {
            location.href = `/event?token=${token}`;
            return;
        }

        if (jRank == null) {
            alert('토큰 정보가 없습니다.');
            location.href = '/';
            return;
        }
        rank.innerHTML = jRank + '';
        remain.innerHTML = (Number(jTotal) - Number(jRank)) + '';
    }
});

function fetchGet(url, callback) {
    fetch(`${url}?token=${token}`)
        .then(response => response.json())
        .then(json => callback(json))
}

 

setInterval로 1000ms 마다 서버에서 대기번호, 전체대기열사이즈, 인증여부를 받아옵니다.

인증이 되었다면 인증된 토큰과 함께 /event 페이지로 이동합니다.

 

 

Dto

public record ResponseToken(
    boolean authenticated,	// 인증여부
    Long total, 		// 전체 대기열 수
    Long rank) {		// 대기번호

}

 

Redis

@Service
@RequiredArgsConstructor
public class TokenRedisService {

    private final TokenRedisRepository tokenRedisRepository;

    private static final String WAITING_KEY = "event:waiting";
    private static final String AUTHENTICATED_KEY = "event:authenticated";

    @PostConstruct
    void initDeleteAll() {
        tokenRedisRepository.delete(WAITING_KEY);
        tokenRedisRepository.delete(AUTHENTICATED_KEY);
    }

    public String register() {
        String token = UUID.randomUUID().toString();
        tokenRedisRepository.addIfAbsent(WAITING_KEY, token);
        return token;
    }

    public boolean isAuthenticated(String token) {
       return tokenRedisRepository.exists(AUTHENTICATED_KEY, token);
    }
    public boolean isWaiting(String token) {
        return tokenRedisRepository.exists(WAITING_KEY, token);
    }

    public ResponseToken getRank(String token) {
        Long total = tokenRedisRepository.getSize(WAITING_KEY);
        Long rank = tokenRedisRepository.getRank(WAITING_KEY, token);
        boolean authenticated = tokenRedisRepository.exists(AUTHENTICATED_KEY, token);
        return new ResponseToken(authenticated, total, rank);
    }

    public void authenticate(long poolSize) {
        for (long i = 0; i < poolSize; i++) {
            String token = tokenRedisRepository.popMinIfExists(WAITING_KEY);
            if (token == null) return;

            tokenRedisRepository.addIfAbsent(AUTHENTICATED_KEY, token);
        }
    }

}

 

@Repository
@RequiredArgsConstructor
public class TokenRedisRepository {

    private final StringRedisTemplate redisTemplate;

    public Long getSize(String key) {
        return redisTemplate.opsForZSet().size(key);
    }

    public void delete(String key) {
        redisTemplate.delete(key);
    }

    public String popMinIfExists(String key) {
        if (getSize(key) < 1) return null;
        var tuple = redisTemplate.opsForZSet().popMin(key);
        return (tuple == null) ? null : tuple.getValue();
    }

    public Boolean addIfAbsent(String key, String token) {
        return redisTemplate.opsForZSet().addIfAbsent(
            key,
            token,
            System.currentTimeMillis()
        );
    }

    public void removeToken(String key, String token) {
        redisTemplate.opsForZSet().remove(key, token);
    }

    public boolean exists(String key, String token) {
        return redisTemplate.opsForZSet().score(key, token) != null;
    }

    public Long getRank(String key, String token) {
        Long rank = redisTemplate.opsForZSet().rank(key, token);
        return rank != null ? rank + 1 : null;
    }

}

 

 

 

Filter

@Configuration
@RequiredArgsConstructor
public class FilterConfig implements WebMvcConfigurer {

    private final EventTokenFilter eventTokenFilter;

    @Bean
    FilterRegistrationBean<EventTokenFilter> filterRegistrationBean() {
        FilterRegistrationBean<EventTokenFilter> filter = new FilterRegistrationBean<>(eventTokenFilter);
        filter.setOrder(1);
        filter.addUrlPatterns("/event/*");
        return filter;
    }

}
@Component
@RequiredArgsConstructor
public class EventTokenFilter extends OncePerRequestFilter {

    private final TokenRedisService tokenRedisService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        String token = request.getParameter("token");

        if (token == null) {
            token = tokenRedisService.register();
            response.sendRedirect("/waiting?token=" + token);
            return;
        }

        boolean isAuthenticated = tokenRedisService.isAuthenticated(token);

        if (!isAuthenticated) {
            response.sendRedirect("/waiting?token=" + token);
            return;
        }

        filterChain.doFilter(request, response);
    }

}

 

/event 요청의 Filter입니다.

  1. token이 없으면 토큰을 생성해서 대기열에 넣고 /waiting 으로 redirect 합니다.
  2. token이 있지만 인증된 토큰이 아니라면 /waiting 으로 redirect 합니다.

 

 

Polling

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1")
public class EventRestController {

    private final TokenRedisService tokenRedisService;

    @GetMapping("/poling")
    public ResponseEntity<ResponseToken> rank(String token) {
        ResponseToken rToken = tokenRedisService.getRank(token);
        return ResponseEntity.ok(rToken);
    }

}

 

 

 

Schedule

@Configuration
@RequiredArgsConstructor
public class EventScheduler {

    private final TokenRedisService tokenRedisService;
    
    private static final long POOL_SIZE = 100;

    @Scheduled(fixedDelay = 1000)
    public void eventScheduler() {
        tokenRedisService.authenticate(POOL_SIZE);
    }
}

 

1초마다 대기열에서 100명의 유저를 인증해줍니다.

 

 

 

결과

JMeter로 대기열에 사람을 채우면서 대기열에 진입해보겠습니다.

 

 

/waiting
/waiting
/event 페이지

 

정상 동작합니다. 영상으로 남기고싶었는데 귀찮아서 사진으로 그냥 찍었습니다. 궁금하면 만들어서 직접 해보세요

 

 

 

 

추가

여기까지만 하려는데 조금 아쉽습니다.

 

첫번째로 위의 코드는 고정적인 유저수를 1초간격으로 허용해주는데 내가 원하는 사이즈만큼만 허용시키고싶은 생각이 들더라구요 전체 허용시킬 size가 1000이고, 1초마다 100명의 유저를 인증해준다고했을때 전체 size 1000이 다 차면 더이상 허용시키지 않고 인증된 유저가 페이지를 벗어나면 그 수만큼 인증시키고싶었습니다.

 

두번째는 대기열에서 유저가 빠져나가면 대기열 토큰을 지워 불필요한 데이터가 담기지 않게 하고싶었습니다.

 

이 두 작업을 더해보겠습니다.

 

먼저 대기열에서 토큰을 지워주는 /api/v1/leave와 인증된토큰을 지워주는 /api/v1/exit를 만들어보겠습니다.

 

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1")
public class EventRestController {

    private final TokenRedisService tokenRedisService;

    @GetMapping("/poling")
    public ResponseEntity<ResponseToken> rank(String token) {
        ResponseToken rToken = tokenRedisService.getRank(token);
        return ResponseEntity.ok(rToken);
    }

    @PostMapping("/exit")
    public ResponseEntity<String> exit(String token) {
        tokenRedisService.exit(token);
        return ResponseEntity.ok("exit");
    }

    @PostMapping("/leave")
    public ResponseEntity<String> leave(String token) {
        tokenRedisService.leave(token);
        return ResponseEntity.ok("leave");
    }
}

 

TokenRedisService

    public void leave(String token) {
        tokenRedisRepository.removeToken(WAITING_KEY, token);
    }

    public void exit(String token) {
        tokenRedisRepository.removeToken(AUTHENTICATED_KEY, token);
    }

 

 

1. 대기열 토큰 제거

window.addEventListener('beforeunload', () => {
    navigator.sendBeacon(`/api/v1/leave?token=${token}`);
})

waiting.js 에서 unload 되기전에 /api/v1/leave 에 토큰을 보내 대기열에서 제거해주도록 했습니다.

 

 

 

2. 인증토큰 제거

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>이벤트 페이지</title>
</head>
<body>
    <h1>이벤트 페이지임</h1>
    <button>완료</button>

    <script>
        const button = document.querySelector('button');

        const param = new URLSearchParams(window.location.search);
        const token = param.get('token');

        button.addEventListener('click', () => {

            fetch(`/api/v1/exit?token=${token}`, {method : 'POST'})
                .then(res => {
                    window.location.href = '/';
                })
        })

    </script>
</body>
</html>

이벤트 페이지에서 모든 작업이 완료되었다면 /api/v1/exit 요청으로 인증된 토큰을 제거해줍니다.

 

 

TokenRedisService

    public void authenticateWithStaticPool(long poolSize) {
        for (long i = 0; i < poolSize; i++) {
            String token = tokenRedisRepository.popMinIfExists(WAITING_KEY);
            if (token == null) return;

            tokenRedisRepository.addIfAbsent(AUTHENTICATED_KEY, token);
        }
    }

    public void authenticateWithMaxPool(long maxPoolSize, long poolSize) {
        Long authenticatedSize = tokenRedisRepository.getSize(AUTHENTICATED_KEY);
        long remainAuthenticatedSize = Math.min(maxPoolSize - authenticatedSize, poolSize);
        System.out.println("현재 인증된 토큰 수 : " + authenticatedSize + ", 인증 가능한 수 : " + remainAuthenticatedSize);
        authenticateWithStaticPool(remainAuthenticatedSize);
    }

 

기존의 authenticate 를 위와 같이 변경해줍니다. authenticateWithStaticPool 은 기존의 authenticate이고,

authenticateWithMaxPool 은 최대 인증토큰 개수까지만 인증시키는 메소드입니다.

 

@Configuration
@RequiredArgsConstructor
public class EventScheduler {

    private final TokenRedisService tokenRedisService;

    private static final long POOL_SIZE = 100;
    private static final long MAX_POOL_SIZE = 1000;

    @Scheduled(fixedDelay = 1000)
    public void eventScheduler() {
        tokenRedisService.authenticateWithMaxPool(MAX_POOL_SIZE, POOL_SIZE);
    }
}

이렇게 하고 실행해봅시다.

 

 

결과

100 개씩 인증하다가 최대 사이즈가 다 차면 인증토큰을 더이상 발행하지 않습니다.

이때 1001번 대기자는 계속 polling 하면서 대기하다가 앞에서 인증토큰이 exit되면

1001번 대기자 이벤트페이지 접속

들어오는 걸 확인할 수 있습니다.

1001번째 대기자 polling되었을때의 Schedule

 

아직 해결못한 내용

이렇게 했을 때 아직 해결못한것이 있습니다. 인증된 토큰을 어떻게 지우냐 입니다. 위의 코드는 인증된 토큰을 지우려면 이벤트페이지에서 어떤 작업을 모두 완료했을때 뿐입니다.

만약 중간에 페이지를 닫거나 새로운 페이지로 이동한다면 어떻게 토큰을 지워야할까요?

JS에서 beforeunload 로 토큰을 지우도록 하면 다음 문제는 /event 페이지에서 /event/** 로 이동할때 발생합니다. 페이지가 변경되면 인증된 토큰이 사라지거든요.

이 문제에 대해서는 해결하면 글써보도록하겠습니다.

 

 

[Spring Boot] 선착순 이벤트 쿠폰을 만들어보자 (1) - 동시성 이슈

들어가기 전에 우아한테크톡의 선착순 이벤트 서버 생존기를 보고 아이디어를 얻었고, 그 과정을 처음부터 부딪히면서 배우기 위해 바닥부터 만들어보면서 느낀점과 배운것들을 정리하기 위

tmd8633.tistory.com

 

이전 글에서 이어집니다.

 

 

 

개요

저번 글에서 동시성이슈를 해결했었습니다. 이번에는 순간적인 대량의 트래픽을 감당하기 위해 TPS를 높혀보겠습니다.

우아한테크톡에서도 보았듯이 Redis를 통해 이 문제를 해결해 보도록 하겠습니다.

 

 

 

 

구조

 

  1. 유저가 이벤트 쿠폰 발행을 요청하면 Redis에서 SortedSet으로 정렬해서 대기열에 넣습니다.
  2. Schedule로 1초간격으로 한정된 유저만큼 쿠폰 발급을 요청합니다.
  3. /api/v3/status 로 발급이 완료되었는지 확인할 수 있습니다.

 

이런 순서로 동작하도록 설계했습니다. 여기서 중요한 것은 많은 트래픽을 감당하기 위해서 대기열에 진입하는 로직에서 무거운 작업을 빼서 최소화 하기로했습니다. 

 

 

CouponConfig

@Configuration
@RequiredArgsConstructor
public class CouponConfig {

    private final JpaCouponRepository jpaCouponRepository;
    public static final int COUPON_COUNT = 200;

    @PostConstruct
    public void init() {
        Coupon saveCoupon = Coupon.builder()
            .name("한정수량 쿠폰")
            .amount(COUPON_COUNT)
            .build();
        jpaCouponRepository.save(saveCoupon);
    }
}

쿠폰 발급갯수를 public static으로 만들어놓았습니다.

 

 

CouponController

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v3")
public class CouponControllerV3 {

    private final CouponRedisService couponRedisService;

    @PostMapping("/coupon")
    public ResponseEntity<ResponseCouponStatus> coupon() {
        long userId = Math.abs(new Random().nextLong() % 2000) + 1;
        boolean register = couponRedisService.register(userId);

        return ResponseEntity.ok(new ResponseCouponStatus(register ? "요청" : "마감", userId));
    }

    @GetMapping("/status")
    public ResponseEntity<Boolean> status(Long userId) {
        boolean isIssued = couponRedisService.isIssued(userId);
        return ResponseEntity.ok(isIssued);
    }

}

/api/v3/coupon 은 쿠폰 발급 요청 API

/api/v3/status 는 쿠폰 발급이 완료되었는지를 확인하는 API 입니다.

boolean register는 대기열에 정상적으로 등록되었으면 true, 등록이 되지않았으면(이미 마감된경우) false를 반환합니다.

userId 를 Response에 담아서 보내는 이유는 테스트를 위함입니다. 랜덤으로 userId를 만들었기때문에 랜덤으로 만들어진 userId로 /api/v3/status 로 발급여부를 확인하기 위해 다시 한번 요청을 해야하기때문입니다.

 

CouponRedisService

@Service
@RequiredArgsConstructor
public class CouponRedisService {

    private final CouponRedisRepository couponRedisRepository;

    private static final String WAITING_QUEUE_KEY = "coupon:waiting";     // 대기열
    private static final String ISSUED_SET_KEY = "coupon:issued";   // 처리된 유저 목록
    private boolean isFull = false;

    @PostConstruct
    void init() {
        couponRedisRepository.delete(WAITING_QUEUE_KEY);
        couponRedisRepository.delete(ISSUED_SET_KEY);
    }

    public boolean register(Long userId) {
        if (isFull) return false;
        return addIfAbsent(WAITING_QUEUE_KEY, String.valueOf(userId));
    }

    public List<String> getWaitingUsers() {
        if (isFull) return null;
        return addIssued();
    }

    private Long getSize(String key) {
        return couponRedisRepository.getSize(key);
    }

    private List<String> addIssued() {
        List<String> temp = new ArrayList<>(COUPON_COUNT);

        String userId = popMinIfExists(WAITING_QUEUE_KEY);

        while (userId != null) {
            if (getSize(ISSUED_SET_KEY) >= COUPON_COUNT) {
                isFull = true;
                couponRedisRepository.delete(WAITING_QUEUE_KEY);
                return temp;
            }
            Boolean isAdded = addIfAbsent(ISSUED_SET_KEY, userId);

            if (Boolean.TRUE.equals(isAdded)) {
                temp.add(userId);
            }

            userId = popMinIfExists(WAITING_QUEUE_KEY);
        }
        return temp;
    }

    private String popMinIfExists(String key) {
        return couponRedisRepository.popMinIfExists(key);
    }

    private Boolean addIfAbsent(String key, String userId) {
        return couponRedisRepository.addIfAbsent(key, userId);
    }

    public boolean isIssued(long userId) {
        return couponRedisRepository.existsByUserId(ISSUED_SET_KEY, userId);
    }
}

 

코드를 분리한게 많아서 하나하나 설명하겠습니다.

public 메소드는 register, getWaitingUsers, isIssued 뿐입니다.

register는 Redis로 대기열에 추가하는 메소드,

getWaitingUsers는 Schedule에서 대기열의 유저를 받아오는 메소드로 DB에 이벤트쿠폰을 넣어주는 역할을 합니다.

isIssued는 유저가 발급되었는지 확인하는 메소드로 /api/v3/status 요청에 사용됩니다.

 

COUPON_COUNT 는 import static 된 데이터입니다. CouponConfig에 COUPON_COUNT를 말합니다.

getWaitinUers는 ISSUED_SET_KEY size를 확인하고 최대 남은 발급수량만큼 대기열에서 유저를 pop 해줍니다.

 

 

CouponRedisRepository

@Repository
@RequiredArgsConstructor
public class CouponRedisRepository {

    private final StringRedisTemplate redisTemplate;

    public Long getSize(String key) {
        return redisTemplate.opsForZSet().size(key);
    }

    public void delete(String key) {
        redisTemplate.delete(key);
    }

    public String popMinIfExists(String key) {
        if (getSize(key) < 1) return null;
        var tuple = redisTemplate.opsForZSet().popMin(key);
        return (tuple == null) ? null : tuple.getValue();
    }

    public Boolean addIfAbsent(String key, String userId) {
        return redisTemplate.opsForZSet().addIfAbsent(
            key,
            userId,
            System.currentTimeMillis()
        );
    }

    public boolean existsByUserId(String key, long userId) {
        return redisTemplate.opsForZSet().score(key, String.valueOf(userId)) != null;
    }
}

RedisTemplate를 관리하는 Repository입니다.

 

 

Scheduler

@EnableScheduling
@Configuration
@RequiredArgsConstructor
public class CouponScheduler {

    private final CouponRedisService couponRedisService;
    private final CouponServiceV3 couponServiceV3;

    @Scheduled(fixedDelay = 1000)
    public void processCouponRequests() {

        var waitingUsers = couponRedisService.getWaitingUsers();

        if (waitingUsers == null || waitingUsers.isEmpty()) return;

        couponServiceV3.issueEventCoupons(waitingUsers);
    }
}

대기중인 유저를 가져와 saveAll 하는 메소드입니다. 작업이 1초 이상이 걸릴 수 있기 때문에 fixedDelay를 사용했습니다.

 

CouponServiceV3

@Service
@RequiredArgsConstructor
public class CouponServiceV3 {

    private final JpaCouponRepository jpaCouponRepository;
    private final CouponRepository couponRepository;


    @Transactional(isolation = Isolation.REPEATABLE_READ)
    public boolean issueEventCoupons(List<String> waitingUsers) {
        Coupon coupon = jpaCouponRepository.findByIdWithLock(1L)
            .orElseThrow(() -> new CouponException("쿠폰을 찾을 수 없습니다."));
        if (!coupon.isAvailable()) {
            return false;
        }

        coupon.decreaseAmount(waitingUsers.size());

        List<EventCoupon> saveEventCoupons = waitingUsers
            .stream()
            .map(x -> EventCoupon.builder()
                .userId(Long.valueOf(x))
                .createdAt(LocalDateTime.now())
                .coupon(coupon)
                .build())
            .toList();

        couponRepository.saveEventCoupons(saveEventCoupons);

        return coupon.isAvailable();
    }
}

대기중인 유저들에게 EventCoupon saveAll 하는 역할을 합니다.

 

 

결과

400 Thread, 10초간 요청하도록 설정

환경은 이전과 동일합니다.

 

 

Samples는 2.5배, 평균응답시간(ms) 2.8배, TPS 2.5배 향상이 있었습니다.

 

응답스펙이 Redis가 30 bytes가 높아서 그렇지 스펙을 똑같이 맞춘다면 어마어마한 속도차이가 있습니다.

Samples는 4.4배, 평균응답시간(ms) 4.8배, TPS 4.2배 빨랐습니다.

 

 

결론

우연히 우아한테크톡에서 봤던 영상덕분에 이벤트 쿠폰을 발행하는 과정에서 생길 수 있는 문제 (동시성, 트래픽처리)에 대해서 배울 수 있었던 것같습니다. Lock 에 대해서도 공부가 되었고, Redis가 아직 익숙하지 않지만 더 공부해서 잘 사용하게 될때쯤 글을 적어보려고합니다.  이게 글로는 엄청 짧아보이는데 Redis를 사용해 어떻게 해야 조금 더 빨라 질 수 있을까에 대해서 고민하면서 코드를 지우고 적고 하면서 배운걸 적다보니 결론만 내린 느낌같습니다. 그런데 지금 위에 코드를 보면 변경하고싶은 사항들이 많아보입니다. 대기열에서 발급받은유저목록으로 보내는 과정에서 조금더 최적화 할 수 있을 것같거든요. 하지만 더이상 손대지 않을겁니다. 이번 실기나 이벤트 쿠폰 발급은 메인이 아니거든요. 다음 글 부터는 대기열을 이용해 rank를 반환해주고 다음 과정을 넘어가기 전에 buffer를 만들어볼까합니다.

큐넷

이런 기능처럼요. 아직 해보지 않아서 며칠 걸릴 수 있습니다. 

들어가기 전에

 

우아한테크톡의 선착순 이벤트 서버 생존기를 보고 아이디어를 얻었고, 그 과정을 처음부터 부딪히면서 배우기 위해 바닥부터 만들어보면서 느낀점과 배운것들을 정리하기 위해 쓰는 글입니다.

 

 

개요

초보 개발자로써 높은 트래픽을 경험하는 것은 거의 불가능합니다. 그래서 그런지 저도 동시성 불감증이 있나 별로 크게 생각하지 않았었는데, 우아한테크톡에서 다룬 영상을 보고 직접 만들어보고 싶은 생각이 들었습니다. 개발 과정은 처음부터 완성된 결과물을 만들어내는게 아니고 바닥부터 단계별로 적용해보면서 조금씩 나아지는 결과물을 만들어 보는 방향으로 해보았습니다. 그게 더 공부가 될 것 같거든요.

 

 

Entity

@Entity
public class Coupon {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "COUPON_ID")
    private Long id;

    private String name;
    private int amount; // 최대 수량

}
@Entity
public class EventCoupon {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    public Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "COUPON_ID")
    private Coupon coupon;

    @Column(unique = true, nullable = false)
    private Long userId;

    private LocalDateTime createdAt;

}

 

Entity는 테스트 환경에 맞게 간단하게 구성했습니다.

Coupon의 amount는 한정수량을 나타냅니다. 500 이면 500장만 발급할 수 있습니다.

EventCoupon의 Long userId 는 원래 User user 로 join 되어야 하지만 테스트 환경 상 수 많은 User 데이터를 넣는것이 번거롭기 때문에 User user의 id 값인 Long userId 로 넣었습니다. 저 자리에 실제로는 User user 가 들어가야 한다고 이해하면 됩니다.

이벤트 쿠폰은 중복수령할 수 없기 때문에 userId에 unique 속성을 주었습니다.

 

Config

@Configuration
@RequiredArgsConstructor
public class CouponConfig {

    private final JpaCouponRepository jpaCouponRepository;

    @PostConstruct
    public void init() {
        Coupon saveCoupon = Coupon.builder()
            .name("한정수량 쿠폰")
            .amount(200)
            .build();
        jpaCouponRepository.save(saveCoupon);
    }
}

쿠폰을 서버 띄울 때 하나 만들어줍니다. 수량은 200개로 고정하고 시작하겠습니다.

 

 

동시성 문제 해결하기

먼저 내가 작성한 로직이 정상적으로 동작해야 다음 파트로 넘어갈 수 있습니다. 제가 생각한 로직의 순서는 다음과 같습니다.

 

  1. Coupon에서 현재 amount를 확인 - amount가 0이면 예외
  2. EventCoupon에서 이전에 발급받은 이력 확인 - 발급이력이 존재하면 예외
  3. Coupon 의 amount 1 감소
  4. 이벤트 쿠폰 발급

간단하게 이렇게 구성했습니다.

 

synchronized

@Service
@RequiredArgsConstructor
public class CouponServiceV1 {

    private final JpaEventCouponRepository jpaEventCouponRepository;
    private final JpaCouponRepository jpaCouponRepository;
    private final CouponRepository couponRepository;

    @Transactional
    public synchronized void issueEventCoupon(long userId, LocalDateTime now) {

        Coupon coupon = jpaCouponRepository.findById(1L)
            .orElseThrow(() -> new CouponException("쿠폰을 찾을 수 없습니다."));
        if (!coupon.isAvailable()) {
            throw new CouponException("마감되었습니다.");
        }

        if (jpaEventCouponRepository.existsByUserId(userId)) {
            throw new DuplicateEventCouponException("중복해서 쿠폰을 받을 수 없습니다.");
        }

        coupon.decreaseAmount();
        couponRepository.issueEventCoupon(userId, now, coupon);
    }

}
@Repository
@RequiredArgsConstructor
public class CouponRepository {

    private final JpaEventCouponRepository jpaEventCouponRepository;

    public void issueEventCoupon(long userId, LocalDateTime now, Coupon coupon) {
        EventCoupon saveEventCoupon = EventCoupon.builder()
            .userId(userId)
            .coupon(coupon)
            .createdAt(now)
            .build();
        jpaEventCouponRepository.save(saveEventCoupon);
    }
    
}

그렇게 만들어진 CouponServiceV1과 CouponRepository입니다. Coupon Id는 1L로 그냥 고정하고 만들었습니다.

 

V1의 특징으로는 @Transactional 에 synchronized 키워드가 붙어있습니다.

'synchronized가 Thread-Safe 하니까 알아서 잘 해주겠지?' 하고 테스트코드를 작성해봅니다.

 

 

 

Test

@SpringBootTest
public class CouponServiceV1Test {

    @Autowired
    private CouponServiceV1 couponService;
    @Autowired
    private JpaCouponRepository jpaCouponRepository;
    @Autowired
    private JpaEventCouponRepository jpaEventCouponRepository;


    @Test
    @DisplayName("1000개의 동시 요청에도 200개의 이벤트쿠폰이 정상적으로 발급된다.")
    void issueEventCoupon() throws InterruptedException {
        // given
        final int threadPoolSize = 32;
        final int threadCount = 1000;

        final ExecutorService executorService = Executors.newFixedThreadPool(threadPoolSize);
        final CountDownLatch countDownLatch = new CountDownLatch(threadCount);

        // when
        for (int i = 0; i < threadCount; i++) {
            executorService.submit(() -> {
                try {
                    long userId = new Random().nextLong(100000) + 1;
                    couponService.issueEventCoupon(userId, LocalDateTime.now());
                } finally {
                    countDownLatch.countDown();
                }
            });
        }
        countDownLatch.await();
        executorService.shutdown();

        // then
        Optional<Coupon> findCoupon = jpaCouponRepository.findById(1L);
        assertThat(findCoupon).isPresent();
        assertThat(findCoupon.get().getAmount()).isEqualTo(0);

        List<EventCoupon> findEventCoupon = jpaEventCouponRepository.findAll();
        assertThat(findEventCoupon).hasSize(200);

    }

}

 

ExecutorService로 여러 Thread로 해당 메소드를 실행하는 코드를 작성했습니다. 근데 조금 보기가 불편해서 ThreadExecutor 클래스를 새로 만들었습니다.

 

public class ThreadExecutor {

    private final ExecutorService executorService;
    private CountDownLatch countDownLatch;

    private ThreadExecutor(int nThreads) {
        this.executorService = Executors.newFixedThreadPool(nThreads);
    }

    public static ThreadExecutor newThreadPool(int threadPoolSize) {
        return new ThreadExecutor(threadPoolSize);
    }

    public ThreadExecutor threadCount(int threadCount) {
        this.countDownLatch = new CountDownLatch(threadCount);
        return this;
    }

    public void run(Runnable task) throws InterruptedException {
        assert (countDownLatch != null) : "Require countDownLatch before run()";
        for (int i = 0; i < countDownLatch.getCount(); i++) {
            executorService.submit(() -> {
                try {
                    task.run();
                } finally {
                    countDownLatch.countDown();
                }
            });
        }

        countDownLatch.await();
        executorService.shutdown();
    }

}
    @Test
    @DisplayName("1000개의 동시 요청에도 200개의 이벤트쿠폰이 정상적으로 발급된다.")
    void issueEventCoupon2() throws InterruptedException {
        // given
        final int threadPoolSize = 32;
        final int threadCount = 1000;

        // when
        ThreadExecutor.newThreadPool(threadPoolSize)
            .threadCount(threadCount)
            .run(() -> {
                long userId = new Random().nextLong(100000) + 1;
                couponService.issueEventCoupon(userId, LocalDateTime.now());
            });

        // then
        Optional<Coupon> findCoupon = jpaCouponRepository.findById(1L);
        assertThat(findCoupon).isPresent();
        assertThat(findCoupon.get().getAmount()).isEqualTo(0);

        List<EventCoupon> findEventCoupon = jpaEventCouponRepository.findAll();
        assertThat(findEventCoupon).hasSize(200);

    }

앞으로도 몇 번 더 써야하니까 미리 분리했고 조금더 깔끔해 보이네요 이제 진짜 돌려보겠습니다.

 

결과

[pool-3-thread-2] o.h.engine.jdbc.spi.SqlExceptionHelper   : Deadlock found when trying to get lock; try restarting transaction

 

Deadlock이 감지되었습니다.

Deadlock 감지 기능이 ON 되어있어서 이렇게 뜨는겁니다.
Deadlock 감지 기능 ON(default) -> 이전 요청을 롤백해서 선행요청에 Blocking이 해제되어 작업수행
Deadlock 감지 기능 OFF -> 양쪽에서 Timeout 되어 롤백됩니다.

MySQL에서 상태변경
SET GLOBAL innodb_deadlock_detect = OFF; -- Deadlock 감지 비활성화
SET GLOBAL innodb_deadlock_detect = ON; -- Deadlock 감지 활성화 (기본값)

쿠폰은 200개로 한정했지만 총 380개가 발행되었습니다. 대참사입니다. 처음에는 synchronized 키워드를 사용했는데 동시요청이 왜 발생한건지 이해가 되지 않았습니다.

 

원인

결론부터 말하자면 synchronized 는 정상적으로 동작했으나 트랜잭션의 프록시 객체에 의해서 실행되는 것이 문제였습니다.

public class CouponServiceV1Proxy {

    private CouponServiceV1 couponServiceV1;

    public void issueEventCoupon(long userId, LocalDateTime now) {
        // 트랜잭션 시작
        
        couponServiceV1.issueEventCoupon(userId, now);
        
        // 트랜잭션 종료
    }

}

대충 만들어보자면 이런 구조인거죠.

  1. 프록시객체에서 트랜잭션을 시작한다
  2. synchronized 메소드는 트랜잭션 내부에 있다.
  3. 트랜잭션이 종료되고 최종 커밋된다.
  4. synchronized 메소드내에서 변경이 일어나고 메소드가 종료되어도 아직 프록시 객체의 트랜잭션은 종료되지 않았다.
  5. 따라서 의도되지 않은 동시성 이슈가 발생했다.

 

프록시 객체가 만들어지는건 뻔히 알고있었는데도 synchronized 키워드에 대해서는 인지하지 못한 실수입니다.

그렇다면 트랜잭션 밖에 synchronized 를 붙히면 되지 않을까?

 

    private synchronized void issueEventCouponSynchronized(Long userId, LocalDateTime now) {
        couponService.issueEventCoupon(userId, now);
    }

대충 이렇게 만들어보았다.

성공

제대로 테스트가 성공했습니다. 그럼 이제 해피엔딩일까? 저도 처음에는 그렇게 생각했습니다. 잘 생각해보면 synchronized 는 하나의 서버에서만 Thread-Safe 하다는걸 느끼실겁니다. 서버가 2개 이상일때는 무방비해진다는거죠. 따라서 DB 자체에서 Lock을 걸어 데이터의 일관성을 유지하도록 해야합니다. 

 

서버의 확장이 전혀 없을 만한 환경에서는 사용해도 무방할것같습니다. 하지만 저는 synchronized 는 사용하지 않도록 하겠습니다.

 

 

 

Lock

 

[DB] Lock 이해하기

Lock 이란?하나의 자원에 여러 요청이 들어올 때 데이터의 일관성과 무결성을 보장하기 위해 하나의 커넥션에게만 변경할 수 있는 권한을 주는 기능으로 동시성을 제어하기 위한 목적으로 사용

tmd8633.tistory.com

MySQL에서 사용하는 비관적 락, 낙관적 락 과 같은 내용은 따로 글을 다루고 여기에 추가하겠습니다. 아직 글을 안썼습니다.

 

위의 글을 참고하시면 도움이 될겁니다.

 

 

@Service
@RequiredArgsConstructor
public class CouponServiceV2 {

    private final JpaEventCouponRepository jpaEventCouponRepository;
    private final JpaCouponRepository jpaCouponRepository;
    private final CouponRepository couponRepository;

    @Transactional(isolation = Isolation.REPEATABLE_READ) // MySQL 기본 격리 수준, Oracle 은 READ_COMMITTED
    public void issueEventCoupon(long userId, LocalDateTime now) {

        Coupon coupon = jpaCouponRepository.findByIdWithLock(1L)
            .orElseThrow(() -> new CouponException("쿠폰을 찾을 수 없습니다."));
        if (!coupon.isAvailable()) {
            throw new CouponException("수량 마감");
        }

        if (jpaEventCouponRepository.existsByUserId(userId)) {
            throw new DuplicateEventCouponException("중복해서 쿠폰을 받을 수 없습니다.");
        }

        coupon.decreaseAmount();
        couponRepository.issueEventCoupon(userId, now, coupon);
    }

}

그렇게 만들어진 CouponServiceV2 입니다.

트랜잭션이 시작될 때 읽은 데이터를 트랜잭션이 종료될 때까지 다른 트랜잭션이 변경할 수 없는 격리수준 (REPEATABLE_READ)을 사용했습니다. 저는 MySQL을 사용하고있고, 기본격리수준이 REPEATABLE_READ 라 명시하지 않아도 되지만 공부하기 위해서 작성해놓았습니다.

 

public interface JpaCouponRepository extends JpaRepository<Coupon, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE) // 배타적 락 획득
    @Query("SELECT c FROM Coupon c WHERE c.id = :id")
    Optional<Coupon> findByIdWithLock(@Param("id") Long id);
}

@Lock이나 @Transactional isolation 에 대해서는 따로 글을 작성하겠습니다.

 

Coupon을 가져올 때, 배타적 락을 획득하도록 만들었습니다. UPDATE가 이루어지기 때문에 WRITE로 했습니다.

 

배타적 락을 획득함으로 다른 트랜잭션에서는 락을 반환하기전까지 Blocking 되도록 했습니다.

 

 

Test

@SpringBootTest
public class CouponServiceV2Test {

    @Autowired
    private CouponServiceV2 couponService;
    @Autowired
    private JpaCouponRepository jpaCouponRepository;
    @Autowired
    private JpaEventCouponRepository jpaEventCouponRepository;

    @Test
    @DisplayName("1000개의 동시 요청에도 200개의 이벤트쿠폰이 정상적으로 발급된다.")
    void issueEventCoupon2() throws InterruptedException {
        // given
        final int threadPoolSize = 32;
        final int threadCount = 1000;

        // when
        ThreadExecutor.newThreadPool(threadPoolSize)
            .threadCount(threadCount)
            .run(() -> {
                long userId = new Random().nextLong(2000) + 1;
                couponService.issueEventCoupon(userId, LocalDateTime.now());
            });

        // then
        Optional<Coupon> findCoupon = jpaCouponRepository.findById(1L);
        assertThat(findCoupon).isPresent();
        assertThat(findCoupon.get().getAmount()).isEqualTo(0);

        List<EventCoupon> findEventCoupon = jpaEventCouponRepository.findAll();
        assertThat(findEventCoupon).hasSize(200);

    }

}

달라진거 하나도 없습니다. CouponServiceV1 -> CouponServiceV2로만 변경되었습니다.

 

 

결과

성공

 

이번에는 JMeter로도 조금더 강하게 테스트 해보겠습니다.

 

400 Thread, 10초간 요청하도록 설정

 

10초간 21124번의 요청이 있었고, 에러 0% 입니다. 정상 작동합니다.

 

 

 

결론

위의 코드는 최대한 한 화면에 보이도록 작성한 코드입니다. 쓸만한 코드가 아닙니다. 그냥 내용만 봐주세요.

생각보다 오랫동안 고민하면서 작성했습니다. 특히 synchronized 부분에서 애먹었던것 같습니다. 'synchronized 는 thread-safe하다' 와 '트랜잭션은 프록시를 통해 실행된다.', '프록시객체를 사용하기 때문에 내무 메소드를 호출하면 안된다' 를 통합하는게 이상하게 너무 어려웠네요.. 그래서 Lock 파트는 내용 자체가 어렵지 않아서 금방 이해할 수 있었습니다. 이번 동시성 이슈를 다루면서 전에 생각하지 못했던 부분들 (락 획득 자원의 순서의 중요성, Deadlock 감지 기능 등)에 대해서 배울 수 있었던 시간이었습니다.

 

다음 글은 Redis를 활용해서 줄세우기를 만들어보도록 하겠습니다. 만들어보고 테스트해보고 글을 작성하는거라 좀 걸립니다.

개요

이번에는 OAuth2 Resource Server 를 이용해 JWT를 손쉽게 적용해보도록 하겠습니다.

 

 

JWT란?

들어가기에 앞서 JWT에 대해서 간단히 설명하자면 JWT(JSON Web Token)는 자원을 사용하기 전에 사용해도 되는 유저인지 판단할 수 있게 해주는 티켓이라고 생각하면됩니다. 그러니까 놀이공원에 들어갈 수 있게 해주는 입장권인겁니다. 놀이기구를 타기위해서는 놀이공원에 돈을 내야합니다. 그걸 증명하는게 입장권이죠. 놀이기구를 타기 전에 입장권을 검사함으로써 놀이기구(자원)을 사용하도록 직원이 허가 해줍니다. 여기서 중요시 봐야할 점은 표를 검사하는 직원은 어떤 경로를 통해 입장권을 구매했는지, 어떻게 입장권을 구했는지 알지도 못할 뿐더러 궁금해 하지도 않습니다. 그저 입장권이 있는지, 없는지만 판단하게되죠. JWT는 그런 역할을 하는 겁니다.

Web 환경에서는 Session과 Cookie를 활용해 입장권을 저장해놓을 수 있지만 REST API 로 확장하게되면 Session 과 같은 저장공간을 사용하지 못합니다. 따라서 HTTP Request Header부분에 유저의 정보를 보관해서 요청을 보내서 권한을 사용할 수 있는지 없는지 판단하게됩니다. 

 

JWT에 대한 상세한 글은 따로 작성하도록 하겠습니다.

 

 

 

OAuth2 Resource Server

먼저 OAuth2 Client와 OAuth2 Resource Server 의 차이점을 짚고 넘어가겠습니다.

oauth2-client는 보호된 자원을 사용하려고 접근 요청을 하는 애플리케이션입니다.

oauth2-resource-server 는 보호된 자원을 호스팅하는 역할을 합니다.

 

대략적인 순서는 다음과 같습니다.

 

  • Client가 Resource Server에 접근하기 위해 AccessToken 요청
  • 인증 후 AccessToken 발급받음
  • Client는 이 AccessToken을 사용해 Resource Server의 API 호출
  • Resource Server는 토큰을 검증하고 요청된 리소스 제공

그러니까 oauth2-resource-server에서 토큰을 검증하는 부분을 이번에 구현하는 것입니다.

 

HttpSecurity oauth2ResourceServer 메소드를 살펴보면

 

메소드 설명
bearerTokenResolver Bearer 토큰을 추출하는 방법을 커스텀할 수 있는 기능
accessDeniedHandler 권한이 없을 때 예외 핸들링
authenticationEntryPoint 잘못된 토큰일 때 예외 핸들링
jwt jwt 관련 설정
opaqueToken opaqueToken 관련 설정

 

 

이렇게 되어있습니다. 우리는 jwt를 사용할 것이기 때문에 opaqueToken 설정을 제외하고 나머지를 구현해주면됩니다.

 

 

 

 

JWT 인증 흐름

공식문서 https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html

 

  1. BearerTokenAuthenticationFilter 에서 추출된 토큰을 BearerTokenAuthenticationToken으로 만들어 AuthenticationManager로 전달합니다.
  2. AuthenticationProvider 의 구현체인 JwtAuthenticationProvider 에서 실제 인증절차를 거칩니다.
  3. 그 인증절차에는 비밀키를 이용해 JWT 토큰의 무결성(위변조 여부) 검사를 하는 JwtDecoder가 있고,
  4. JWT 토큰을 Authentication 객체로 변환해주는 JwtAuthenticationConverter가 있습니다.
  5. 인증이 성공하면 JwtAuthenticationToken을 반환하게되면서 인증과정이 종료됩니다.

이 과정에서 우리가 구현해야할 부분은

  • 비밀키와 함께 무결성검사를 하기위한 JwtDecoder
  • JWT 토큰의 payload(사용자정보)를 커스텀해 변경된 데이터를 저장하는 AuthenticationToken이 필요합니다. 따라서 JwtAuthenticationToken를 상속한 커스텀된 AuthenticationToken
  • 커스텀된 AuthenticationToken 을 convert 해줄 JwtAuthenticationConverter를 상속한 커스텀 converter

이렇게 3개가 필요합니다. 

 

 

구현하기 전에

@Component
@RequiredArgsConstructor
public class JwtTokenProvider {

    private final SecretKey secretKey;
    public final int JWT_ACCESS_TIME = 60 * 60;
    public final int JWT_REFRESH_TIME = 60 * 60 * 24;

    public String generateAccessToken(TokenId tokenId, long userId,
                                        Collection<? extends GrantedAuthority> authorities) {
        return createToken(tokenId, userId, authorities, JWT_ACCESS_TIME);
    }
    public String generateRefreshToken(TokenId tokenId, long userId,
                                      Collection<? extends GrantedAuthority> authorities) {
        return createToken(tokenId, userId, authorities, JWT_REFRESH_TIME);
    }

    // 토큰 생성
    private String createToken(TokenId tokenId,
                              long userId,
                              Collection<? extends GrantedAuthority> authorities,
                              int expirationPeriod) {
        Date now = new Date(System.currentTimeMillis());

        return Jwts.builder()
            .header()
            .keyId(UUID.randomUUID().toString())
            .add("typ", "JWT")
            .and()
            .issuer("TEST_JWT")
            .claim("iss", tokenId.getProvider())
            .claim("sub", tokenId.getProviderId())
            .claim("userId", userId)
            .claim("scope", authorities.stream()
                .map(GrantedAuthority::getAuthority)
                .toList())
            .issuedAt(now)
            .expiration(new Date(now.getTime() + expirationPeriod * 1000L))
            .signWith(secretKey)
            .compact();
    }

}

커스텀된 JWT 양식입니다. 참고로만 사용해주세요.

권한정보를 넣을것이기 때문에 scope에 넣어주었습니다.

 

구현

 

application.yaml

jwt:
  secret: "{secret-key}"

 

 

JwtDecoder

@Configuration
@RequiredArgsConstructor
public class JwtConfig {

    private final SecretKey secretKey;

    @Bean
    public JwtDecoder jwtDecoder() {
        return NimbusJwtDecoder.withSecretKey(secretKey)
            .macAlgorithm(MacAlgorithm.HS512)
            .build();
    }

}

JwtDecoder 에 비밀키와 알고리즘 HS512를 설정해주었습니다.

중요한 점은 SecretKey의 알고리즘과 JwtDecoder의 알고리즘이 서로 일치해야합니다.

 

HMAC-SHA 키를 생성할 때, 키의 길이에 따라 알고리즘이 자동 선택됩니다:

  • 256비트(32바이트) → HS256
  • 384비트(48바이트) → HS384
  • 512비트(64바이트) → HS512

 

 

CustomJwtToken

@Getter
public class CustomJwtToken extends JwtAuthenticationToken {

    private final Long userId;

    public CustomJwtToken(Jwt jwt, Collection<? extends GrantedAuthority> authorities) {
        super(jwt, authorities);
        this.userId = Long.valueOf(jwt.getClaimAsString("userId"));
    }

}

JwtAuthenticationToken을 상속에 만든 CustomJwtToken 입니다.

 

 

 

CustomJwtAuthentication

public class CustomJwtAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> {

    private final JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();

    @Override
    public final AbstractAuthenticationToken convert(Jwt jwt) { // 3
        AbstractAuthenticationToken token = jwtAuthenticationConverter.convert(jwt); // 4
        Collection<GrantedAuthority> authorities = token.getAuthorities();
        return new CustomJwtToken(jwt, authorities);
    }

}

 

JwtAuthenticationConverter의 convert 메소드는 final 이라 Override할 수 없어서 JwtAuthenticationConverter를 필드에서 사용하였습니다.

 

 

JwtAuthenticationConverter 를 조금더 커스텀 하고싶다면?

public class CustomJwtAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> {

    private final JwtAuthenticationConverter jwtAuthenticationConverter;

    public CustomJwtAuthenticationConverter() {
        this.jwtAuthenticationConverter = new JwtAuthenticationConverter();

        var grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
        // 권한정보가 들어가있는 claim key가 다른경우
        grantedAuthoritiesConverter.setAuthoritiesClaimName("roles");
        // ROLE_ 접두사가 필요한 경우
        grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");

        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
    }
    @Override
    public final AbstractAuthenticationToken convert(Jwt jwt) {
        AbstractAuthenticationToken token = jwtAuthenticationConverter.convert(jwt);
        Collection<GrantedAuthority> authorities = token.getAuthorities();
        return new CustomJwtToken(jwt, authorities);
    }

}

JwtAuthenticationConverter는 Authorities "scope", 접두사 "SCOPE_" 이 기본값입니다. 이를 수정하고싶은경우

위와 같이 설정하면됩니다. 저 같은 경우 JWT Token을 생성할때 GrantedAutority에서 authority를 꺼냈기 때문에 접두사가 붙은 상태로 생성되었습니다. 따라서 

 

public class CustomJwtAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> {

    private final JwtAuthenticationConverter jwtAuthenticationConverter;

    public CustomJwtAuthenticationConverter() {
        this.jwtAuthenticationConverter = new JwtAuthenticationConverter();

        var grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
        grantedAuthoritiesConverter.setAuthorityPrefix("");

        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
    }
    @Override
    public final AbstractAuthenticationToken convert(Jwt jwt) {
        AbstractAuthenticationToken token = jwtAuthenticationConverter.convert(jwt);
        Collection<GrantedAuthority> authorities = token.getAuthorities();
        return new CustomJwtToken(jwt, authorities);
    }

}

접두사를 제거해서 사용하였습니다.

 

 

권한정보를 담지않는다면 위 과정은 생략해도 됩니다.

 

 

SecurityConfig

이전에 개발해놓았던 서버는 Web 기반이었습니다. 따라서 Security 설정을 API와 WEB을 구분해서 만들겠습니다.

 

@Bean
@Order(1)
SecurityFilterChain apiSecurityFilterChain(HttpSecurity http, ObjectMapper objectMapper, JwtTokenProvider jwtTokenProvider) throws Exception {

    http.securityMatcher("/api/**")

        .csrf(AbstractHttpConfigurer::disable)
        .cors(cors -> cors
            .configurationSource(apiConfigurationSource())
        )
        .authorizeHttpRequests(request -> request
            .requestMatchers("/api/user/**").hasAnyRole(Role.USER.name(), Role.ADMIN.name())
            .requestMatchers("/api/admin/**").hasRole(Role.ADMIN.name())
            .requestMatchers(HttpMethod.POST, "/api/login").anonymous()
            .anyRequest().permitAll()
        )
        .sessionManagement(session -> session
           .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        )
        // oauth2-resource-server 설정 부분
        .oauth2ResourceServer(server -> server
            .jwt(jwt -> jwt
                .jwtAuthenticationConverter(new CustomJwtAuthenticationConverter())
            )
        );
    return http.build();
}

@Bean
@Order(2)
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http, RememberMeServices rememberMeServices) throws Exception {

    http.securityMatcher("/**")

        .authorizeHttpRequests(request -> request
            .requestMatchers("/user/**").hasAnyRole(Role.USER.name(), Role.ADMIN.name())
            .requestMatchers("/admin/**").hasRole(Role.ADMIN.name())
            .requestMatchers("/login", "/signup").anonymous()
            .anyRequest().permitAll()
        )

        /// ... 이전 설정과 동일

    return http.build();
}

// SecretKey Bean 등록
@Bean
SecretKey secretKey(@Value("${jwt.secret}") String secretKey) {
    return Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
}

 

 

 

결과

@RestController
@RequiredArgsConstructor
public class RestMainController {


    @GetMapping("/api/user/profile")
    public ResponseEntity<Long> userProfile() {
        CustomJwtToken customJwtToken = (CustomJwtToken) SecurityContextHolder.getContext().getAuthentication();
        return ResponseEntity.ok(customJwtToken.getUserId());
    }

}

 

이렇게 테스트할 Controller를 만들고, 만들어진 JWT로 시도해봅시다.

 

정상적인 토큰인 경우

 

만료된 토큰인 경우

 

 

조금 더 간단하게 써보자

CustomJwtToken customJwtToken = (CustomJwtToken) SecurityContextHolder.getContext().getAuthentication();

일단 이렇게 쓰는게 너무 마음에 안듭니다.

 

public class JwtUserContextHolder {

    public static CustomJwtToken getJwtToken() {
        return (CustomJwtToken) SecurityContextHolder.getContext().getAuthentication();
    }
}
    @GetMapping("/user/profile")
    public ResponseEntity<Long> userProfile() {
        CustomJwtToken customJwtToken = JwtUserContextHolder.getJwtToken();
        return ResponseEntity.ok(customJwtToken.getUserId());
    }

 

이렇게 바꿨습니다. 이것도 조금 마음에 안듭니다. 어노테이션을 활용해서 변수로 바로 가져오게 하고싶습니다.

 

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface JwtToken {
}

@Component
public class JwtTokenArgumentResolver implements HandlerMethodArgumentResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(JwtToken.class)
            && parameter.getParameterType().equals(CustomJwtToken.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        return JwtUserContextHolder.getJwtToken();
    }
}
@GetMapping("/user/profile")
public ResponseEntity<Long> userProfile(@JwtToken CustomJwtToken customJwtToken) {
    return ResponseEntity.ok(customJwtToken.getUserId());
}

 

엄청 깔끔해 졌네요.

 

 

 

 

결론

사실 이번 글에서 JWT에 관한걸 모두 다루려고 했습니다. 그런데 JWT 발급부분 (oauth2-client)과 JWT 인증부분(oauth2-resource-server) 의 역할이 구분되어있는데 하나의 글로 작성하면 혼란이 생길것같아서 어쩔 수 없이 나눠서 글을 작성했습니다.

위의 코드들은 이미 한 번 모두 구현하고 나서 파트별로 지우고 추가하면서 약간의 컴파일 오류가 생길 수는 있습니다. 그러니 따라치지 마시고 흐름만 이해하고 넘어가주시기 바랍니다. 공부하려고 글을 작성하는 거지 저도 배우는 입장이라 틀릴 수 있다는 부분 알아주셨으면 좋겠습니다.

개요

저번 글에서 SessionManagement 에 대해서 소개하는 글을 적었습니다. 공식문서에 있는 글을 최대한 줄이고 압축해서 글을 쓰려다보니 설명이 부족한 부분도 많았던 것 같습니다. 그래서 두 파트로 나누었고 이번 글에서는 SessionManagement를 어떤식으로 적용하는지 보여드리도록 하겠습니다.

 

 

 

1. 동시 로그인 차단하기

먼저 동시 세션을 제어해서 하나의 로그인한 허용해보도록 하겠습니다.

저는 최대 1개의 세션만 허용하고 새로운 로그인이 발생하면 이전 세션을 만료시키겠습니다. 그리고 세션이 만료가 되면 "/join?expired" 로 이동시키도록 제어해 보겠습니다.

 

 

UserDetails

세션을 1개로 제한한다는 것은 이전 세션과 비교해서 같은세션인지 확인하는 과정이 있어야 가능합니다. 이전 시간에 UserDetails를 구현한 PrincipalDetails 구현체가 있었습니다. 이 PrincipalDetails 의 equals와 hashCode를 override하겠습니다.

 

여기서 한가지 알아야할 것은 PrincipalDetails 객체 자체를 비교하는것은 중요하지 않습니다. 그 안에 있는 User를 비교하는 것이 더 중요합니다. User의 기본키는 교유하기 때문입니다.

 

먼저 User 객체의 equals와 hashCode를 override 하겠습니다.

 

@Getter @Setter
@EqualsAndHashCode(of = "username")
public class User {

    private String username;
    private String password;
    private String role;
    private String name;
}

저는 간단한 개발환경을 위해서 기본키값을 username으로 했지만 실제 DB를 사용하는 환경에서는 id값을 비교하면됩니다.

 

 

lombok을 사용하지 않을 경우

@Getter @Setter
public class User {

    private String username;
    private String password;
    private String role;
    private String name;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        User user = (User) o;
        return Objects.equals(username, user.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(username, username);
    }
}

 

JPA가 기본적으로 id 기반으로 equals/hashCode 제공합니다. 복합키를 사용하거나 또는 명확성을 위해서 객체에서 override해주는 것도 좋은 방법이라고 생각합니다.

 

 

public record PrincipalDetails(User user) implements UserDetails {

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority(user.getRole()));
    }

    @Override
    public String getPassword() {
        return user == null ? null : user.getPassword();
    }

    @Override
    public String getUsername() {
        return user == null ? null : user.getUsername();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

 

 

 

record의 특성을 이해하길 원하신다면 다음 글을 읽어보시길 추천드립니다.

 

 

[JAVA] Record와 Sealed

개요오늘은 Java 14부터 도입된 Record와 Java 15에서 preview로 시작된 Sealed Classes 에 대해서 알아보겠습니다.참고로 Sealed Classes 는 Java 17부터 정식적으로 확정된 것같습니다. JDK 17 Release Notes, Important C

tmd8633.tistory.com

 

 

 

SecurityConfig

        http.sessionManagement(session -> session
            .maximumSessions(1) // 사용자당 세션 최대 1개
            .maxSessionsPreventsLogin(true) // 새로운 로그인 차단
            .expiredUrl("/join?expired") // 세션 만료시 로그인으로 이동
        );

 

기존 세션을 만료시키기 전에 새로운 로그인을 차단하는 기능에 대해서 알아 보겠습니다. 이렇게 설정하고 동시 로그인을 하게되면

 

 

해당 url로 이동하게 됩니다. maxSessionPreventsLogin(true)는 

        http.formLogin(login -> login
                .loginPage("/join")
                .loginProcessingUrl("/join")
                .defaultSuccessUrl("/user")
                .failureUrl("/join?error")
            )

formLogin 에서 로그인 실패로 남게됩니다.

        http.formLogin(login -> login
                .loginPage("/join")
                .loginProcessingUrl("/join")
                .defaultSuccessUrl("/user")
                .failureHandler((request, response, exception) -> {
                    exception.printStackTrace();
                    response.sendRedirect("/join?error");
                })
            )

 

failureHandler에서 printStackTrace를 해보면

org.springframework.security.web.authentication.session.SessionAuthenticationException: Maximum sessions of 1 for this principal exceeded

이렇게 뜨는 것을 확인할 수 있습니다.  따라서 새로운 로그인을 차단하는 기능을 활성화 시킨다면 formLogin failureHandler에서 처리를 해야한다는 뜻입니다.

 

 

자 다시 돌아와서 maxSessionPreventsLogin(false) : 기존 세션 만료 로 돌려놓고 새로운 로그인을 시도해보겠습니다.

 

기존 로그인 했던 페이지에서 새로고침을 해보면 '/join?expired' 로 redirect 되었습니다. 세션 만료가 잘 되었군요. 이제 서버에는 세션은 최대 1개까지만 접속할 수 있게 되었습니다.

 

 

잠깐 SessionRegistry 는 왜 등록하지 않았나?

동시세션을 제어하기 위해서는 SessionRegistry를 등록해서 제어해야합니다. 그런데 위에 코드에는

.sessionRegistry(new SessionRegistryImpl())

이런 코드는 작성하지 않았습니다.

 

결론부터 말하자면 동시세션제어를 활성화할때 Bean에 SessionRegistry를 등록하지 않는다면 내부에서 new SessionRegistryImpl()을 생성해서 넣어줍니다.

 

 

.maximumSessions(1) 을 할때 내부 코드를 보면 ConcurrencyControlConfigurer 를 반환합니다.

    public SessionManagementConfigurer<H>.ConcurrencyControlConfigurer maximumSessions(int maximumSessions) {
        this.maximumSessions = maximumSessions;
        this.propertiesThatRequireImplicitAuthentication.add("maximumSessions = " + maximumSessions);
        return new ConcurrencyControlConfigurer();
    }

 

그 내부를 들어가보면 SessionManagementConfigurer 중첩클래스로 정의 되어있습니다.

 

그리고 getSessionRegistry 를 보면 sessionRegistry == null 이면 sessionRegistryImpl() 을 내부적으로 가지는것을 확인할 수 있습니다.

    // SessionManagementConfigurer 내부
    private SessionRegistry getSessionRegistry(H http) {
        if (this.sessionRegistry == null) {
            this.sessionRegistry = (SessionRegistry)this.getBeanOrNull(SessionRegistry.class);
        }

        if (this.sessionRegistry == null) {
            SessionRegistryImpl sessionRegistry = new SessionRegistryImpl();
            this.registerDelegateApplicationListener(http, sessionRegistry);
            this.sessionRegistry = sessionRegistry;
        }

        return this.sessionRegistry;
    }

 

사용처를 본다면

private ConcurrentSessionFilter createConcurrencyFilter(H http) {
    SessionInformationExpiredStrategy expireStrategy = this.getExpiredSessionStrategy();
    SessionRegistry sessionRegistry = this.getSessionRegistry(http);
    // ... 중략
    return concurrentSessionFilter;
}

private SessionAuthenticationStrategy getSessionAuthenticationStrategy(H http) {
        if (this.sessionAuthenticationStrategy != null) {
            return this.sessionAuthenticationStrategy;
        } else {
            // ... 중략
            if (this.isConcurrentSessionControlEnabled()) {
                SessionRegistry sessionRegistry = this.getSessionRegistry(http);
                // ... 중략
            } else {
                delegateStrategies.add(defaultSessionAuthenticationStrategy);
            }

            this.sessionAuthenticationStrategy = (SessionAuthenticationStrategy)this.postProcess(new CompositeSessionAuthenticationStrategy(delegateStrategies));
            return this.sessionAuthenticationStrategy;
        }
}

 

동시세션필터를 생성할때, SessionAuthenticationStrategy를 가져올때 사용되는것을 볼 수 있습니다.

 

 

2. 세션 생성 정책 설정

        http.sessionManagement(sessionManagement -> sessionManagement
            .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)     // 필요한 경우에만 생성
        );

이 값은 변경할 필요가 없을 것같습니다. 그렇기 때문에 생략하도록 하겠습니다.

다만, 이후에 JWT를 다룰때 SessionCreationPolicy.STATELESS(세션을 사용하지 않음)으로 변경할 예정입니다.

 

 

3. 세션 고정 보호 설정

        http.sessionManagement(session -> session
            .sessionFixation(SessionManagementConfigurer.SessionFixationConfigurer::changeSessionId)
            .maximumSessions(1) // 사용자당 세션 최대 1개
            .maxSessionsPreventsLogin(false) // 기존 세션 만료
            .expiredUrl("/join?expired") // 세션 만료시 로그인으로 이동
        );

세션 생성 정책과 마찬가지로 changeSessionId 가 기본값이기 때문에 생략하도록 하겠습니다.

 

 

 

4. 세션 타임아웃 설정

유효하지 않은 세션에 대한 설정인데요. 값이 변조되었거나 서버 재구동등 여러 요인으로 인해서 유효하지 않은 세션이 들어왔을때 처리를 담당합니다. 

 

        http.sessionManagement(session -> session
            .invalidSessionUrl("/join?invalid")
            .maximumSessions(1) // 사용자당 세션 최대 1개
            .maxSessionsPreventsLogin(false) // 기존 세션 만료
            .expiredUrl("/join?expired") // 세션 만료시 로그인으로 이동
        );

 

invalidSessionUrl을 "/join?invalid" 로 설정해줍니다.

 

.invalidSessionStrategy(new CustomInvalidSessionStrategy())

이렇게 추가적인 로직이 필요한경우 InvalidSessionStrategy 를 구현해서 넣을 수 있지만 쿠키제거, 세션무효화를 직접 작성해야하는 불편함이 있고 굳이 따로 객체를 만들어서 관리하는 수고로움 보다 JavaScript로 구현하는게 조금더 편한것 같더라구요.

// joinForm.html
<script>
    const urlParams = new URLSearchParams(window.location.search);

    // invalid 파라미터 체크
    if (urlParams.has('invalid')) {
        alert('세션이 유효하지 않습니다');
    }
    // expired 파라미터 체크
    else if (urlParams.has('expired')) {
        alert('세션이 만료되었습니다');
    }
</script>

무엇보다 사용자가 세션만료건 유효하지않건 알아봤자 중요하지도 않고 그게 무슨 뜻인지도 모를것이니까요. 물론 경우에 따라서는 Strategy를 구현해 사용하는 것도 방법입니다.

1. Session Management란?

Spring Security의 세션 관리는 사용자의 세션을 생성, 유지, 파괴하는 전반적인 프로세스를 관리합니다. 이는 보안과 사용자 경험 모두에 중요한 영향을 미칩니다. 또한 JWT이나 Session 관리를 하지않는 API를 사용하기전에 알아두어야 하기때문에 공부해보도록 하겠습니다.

 

 

 

2. Session Creation Policy (세션 생성 정책)

        http.sessionManagement(sessionManagement -> sessionManagement
            .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)     // 필요한 경우에만 생성(Default)
        );

 

정책 설명
SessionCreationPolicy.ALWAYS 항상 세션 생성
SessionCreationPolicy.IF_REQUIRED 필요한 경우에만 생성 (Default)
SessionCreationPolicy.NEVER 생성하지 않지만 존재하면 사용
SessionCreationPolicy.STATELESS 세션을 완전히 사용하지 않음 (JWT 등의 토큰 기반 인증에 적합)

 

 

3. Concurrent Session Control (동시 세션 제어)

@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
    return new HttpSessionEventPublisher();
}
        http.sessionManagement(sessionManagement -> sessionManagement
            .maximumSessions(1)                     // 사용자당 최대 세션 수
            .maxSessionsPreventsLogin(true)         // true: 새로운 로그인 차단, false: 기존 세션 만료(기본값)
            .expiredUrl("/session-expired")         // 세션 만료시 이동할 URL
        );

 

3-1. HttpSessionEventPublisher

HttpSessionEventPublisher는 세션 생명주기 이벤트를 Spring의 ApplicationContext에 발행하는 역할을 합니다.

@Component
public class SessionEventListener {

    private final Logger logger = LoggerFactory.getLogger(getClass());

    @EventListener
    public void handleSessionCreated(HttpSessionCreatedEvent event) {
        HttpSession session = event.getSession();
        logger.info("새 세션 생성: {}", session.getId());
    }

    @EventListener
    public void handleSessionDestroyed(HttpSessionDestroyedEvent event) {
        HttpSession session = event.getSession();
        logger.info("세션 파괴됨: {}", session.getId());
    }

}

 

세션 모니터링

@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
    return new HttpSessionEventPublisher();
}
@Bean
public SessionRegistry sessionRegistry() {
    return new SessionRegistryImpl();
}

        http.sessionManagement(session -> session
            .sessionRegistry(sessionRegistry())
        );
@Component
@Slf4j
@RequiredArgsConstructor
public class SessionEventListener {

    private final SessionRegistry sessionRegistry;

    @EventListener
    public void handleSessionDestroyed(HttpSessionDestroyedEvent event) {
        log.info("세션만료 : {}", event.getSession().getId());
        // 세션이 만료되면 해당 사용자의 세션 정보 정리
        SecurityContext securityContext = (SecurityContext) event.getSession()
            .getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY);
        if (securityContext != null) {
            sessionRegistry.removeSessionInformation(event.getSession().getId());
        }
    }

    @EventListener
    public void handleSessionCreated(HttpSessionCreatedEvent event) {
        log.info("세션생성 : {}", event.getSession().getId());
        HttpSession session = event.getSession();
        // 세션 생성 시 기본 타임아웃 설정
        session.setMaxInactiveInterval(60 * 30); // 30분
    }

}

 

3-2. 이전 세션 만료 전략

.maximumSessions(1)
.maxSessionsPreventsLogin(false)  // 기본값

 

3-3. 새 로그인 차단 전략

.maximumSessions(1)
.maxSessionsPreventsLogin(true)

 

 

4. Session Fixation Protection (세션 고정 보호)

 

4-1. 세션 고정 보호가 뭘까?

정확히는 세션 고정 공격 보호 (Session Fixation Attack Protection) 입니다. 세션 고정 공격은 악의적인 공격자가 사이트에 접속해서 세션을 생성한 다음 동일한 세션으로 다른 사용자가 접속하도록 유도할 수 있는 잠재적인 공격입니다. (예, 세션 식별자를 매개변수로 포함하는 링크를 전송) Spring Security는 사용자가 로그인할 때 새 세션을 생성하거나 세션 ID를 변경하여 이를 자동으로 보호하는데 이를 설정하는 것이 sessionFixation 설정입니다.

출처 : https://blog.pollra.com/odop-day-76

 

4-2. 세션 고정 보호 설정

        http.sessionManagement(sessionManagement -> sessionManagement
            .sessionFixation(fixation -> fixation
                .changeSessionId() // 세션 ID만 변경 (Servlet 3.1+ 컨테이너 기본값)
            )
        );

 

 

메소드 설명
newSession() 완전히 새로운 세션 생성
migrateSession() 이전 세션 속성을 새 세션으로 복사 (Servlet 3.0이하 Default)
changeSessionId() 세션 ID만 변경 (Servlet 3.1 이상 Default)
none() 보호 비활성화

 

 

 

5. Session Timeout (세션 타임아웃)

세션은 자체적으로 만료되며 Security Context가 제거되도록 해야할 것은 없습니다. 그러므로 세션이 만료된 시점을 감지하고 사용자가 특정 작업을 수행할 수 있도록 엔드포인트를 리디렉션할 수 있게만 해주면됩니다. 이는 invalidSessionUrl 에서 이루어집니다.

 

        http.sessionManagement(session -> session
            .invalidSessionUrl("/invalidSession") // 만료된 세선이 접근할 경우 "/invalidSession" 으로 이동
        );

 

 

        http.sessionManagement(session -> session
            .invalidSessionStrategy(new CustomInvalidSessionStrategy())
        );
        
public class CustomInvalidSessionStrategy implements InvalidSessionStrategy {

    @Override
    public void onInvalidSessionDetected(HttpServletRequest request, HttpServletResponse response) throws IOException {
        // Session 무효화
        HttpSession session = request.getSession(false);
        if (session != null) {
            session.invalidate();
        }

        // JSESSIONID 쿠키 삭제
        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if ("JSESSIONID".equals(cookie.getName())) {
                    cookie.setValue("");
                    cookie.setPath("/");
                    cookie.setMaxAge(0);
                    response.addCookie(cookie);
                    break;
                }
            }
        }
        response.setContentType("text/html;charset=UTF-8");
        PrintWriter out = response.getWriter();
        out.println("<script>alert('세션이 만료되었습니다.');</script>");
        out.flush();
    }

}

 

invalidSessionUrl과 invalidSessionStrategy는 동시에 사용할 수 없습니다. 둘다 정의 되어있다면 invalidSessionStrategy만 적용됩니다. 그냥 세션 만료시 이동을 원할경우 invalidSessionUrl을 사용하고, 복잡한 로직이 추가되어야 한다면 invalidSessionStrategy를 사용하시면 됩니다.

 

 

 

주의사항

로그아웃 시에 쿠키가 제대로 삭제되지 않아 문제가 발생할 수 있습니다.

        http.logout(logout -> logout
            // 모든 쿠키 제거
            .addLogoutHandler(new HeaderWriterLogoutHandler(new ClearSiteDataHeaderWriter(ClearSiteDataHeaderWriter.Directive.COOKIES)))
            // 특정 쿠키 제거
            .deleteCookies("JSESSIONID", "remember-me")
        );

이렇게 명시적으로 쿠키를 제거해줍시다.

ClearSiteData를 지원하지 않는 브라우저에서는 정상적으로 동작하지 않을 수 있습니다.

 

 

 

 

6. SessionManagementFilter

sessionManagement는 SessionManagementFilter에서 사용됩니다. 그 역할로는 

 

  • 세션 생성 전략 관리
  • 동시 세션 제어
  • 세션 고정 보호
  • 유효하지 않은 세션 처리

우리가 위에서 알아본 것과 같습니다.

 

6-1. SessionManagementFilter 구조

  • SecurityContextRepository의 내용을 현재 SecurityContextHolder의 내용과 비교 검사
  • 이를 통해 현재 요청 중에 사용자가 인증되었는지 확인
  • 주로 pre-authentication이나 remember-me와 같은 비대화형 인증 메커니즘에서 사용됨

처리 흐름

  • SecurityContextRepository에 Security Context가 있다면 아무 작업도 하지 않음
  • SecurityContextRepository에 context가 없고, SecurityContext가 Authentication 객체를 포함하고 있다면 (익명객체가 아닌)
    • 이전 필터에서 이미 인증되었다고 가정
    • 설정된 SessionAuthenticationStrategy를 실행

미인증 사용자 처리

  • 유효하지 않은 세션 ID가 요청되었는지 확인 (예: 타임아웃으로 인한 유효하지 않은 세션)
  • 설정된 InvalidSessionStrategy가 있다면 이를 실행

 

6-2. SecurityContextRepository

SecurityContextRepository는 SecurityContext를 저장하고 불러오는 역할을 하는 인터페이스입니다. 주로 HTTP 세션에 SecurityContext를 저장하고 검색하는 작업을 담당합니다. 기본객체는 HttpSessionSecurityContextRepository 입니다.

기본 구현

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.securityContext((securityContext) -> securityContext
            .securityContextRepository(new HttpSessionSecurityContextRepository())
        );
        return http.build();
    }
}​


DelegatingSecurityContextRepository 사용 시

@Bean
public SecurityContextRepository securityContextRepository() {
    return new DelegatingSecurityContextRepository(
        new HttpSessionSecurityContextRepository(),
        new RequestAttributeSecurityContextRepository()
    );
}​

 

 

 

7. 보안설정

server.servlet.session.cookie.secure=true
server.servlet.session.cookie.http-only=true

 

server.servlet.session.cookie.secure=true

  • HTTPS를 통해서만 쿠키가 전송되도록 설정
  • HTTP로 요청시 쿠키가 전송되지 않음
  • 중간자 공격(Man-in-the-Middle Attack) 방지
  • 쿠키 탈취 위험 감소

server.servlet.session.cookie.http-only=true

  • JavaScript를 통한 쿠키 접근을 차단
  • XSS(Cross-Site Scripting) 공격 방지
  • 클라이언트 측 스크립트에서 document.cookie로 접근 불가

 

7-1. 추가 설정

# 쿠키 만료 시간 설정
server.servlet.session.timeout=30m

# 쿠키 경로 설정
server.servlet.session.cookie.path=/

# 쿠키 도메인 설정
server.servlet.session.cookie.domain=example.com

# 쿠키 이름 변경
server.servlet.session.cookie.name=MYSESSIONID

# SameSite 설정
server.servlet.session.cookie.same-site=strict

 

 

 

글이 너무 길어져서 여기까지 작성하겠습니다.

SessionManagement 관련된 내용은 어마어마하게 많습니다. 다음글에서는 이 글을 토대로 어떻게 적용하는지에 대해서 알아보도록하겠습니다.

1. CORS란 무엇인가?

CORS(Cross-Origin Resource Sharing)는 웹 브라우저에서 외부 도메인 리소스를 안전하게 요청할 수 있도록 하는 표준 규약입니다. 프론트엔드와 백엔드가 분리하는데 있어 CORS에 대해서 반드시 짚고 넘어가야합니다. 그래서 온르은 CORS에 대해서 공부해보겠습니다.

 

 

 

2. CORS의 필요성

핵심은 외부로부터 리소스를 공유하는 것입니다. 요즘 웹 애플리케이션에 개발에서 백엔드와 프론트엔드를 구분하지 않고 개발하는 곳은 거의 없을 겁니다.

 

  • 프론트엔드와 백엔드의 분리
  • 마이크로서비스 아키텍처 도입
  • 외부 API 활용
  • SPA(Single Page Application) 개발 방식

이러한 상황에서 다른 출처(Origin)의 리소스를 안전하게 요청하고 사용할 수 있어야 했고, 이를 위한 표준이 바로 CORS입니다.

 

 

 

3. Same-Origin Policy

Same-Origin Policy는 웹 브라우저의 기본적인 보안 정책으로, 같은 출처에서만 리소스를 공유할 수 있도록 제한합니다.

출처(Origin)는 다음 세 가지 요소로 결정됩니다:

  • 프로토콜 (http, https)
  • 호스트 (domain)
  • 포트 번호
  • http://example.com/path1, https://example.com/path2 는 프로토콜이 다르므로 다른 출처로 간주됩니다.

 

 

4. CORS 동작 방식

CORS는 HTTP 헤더를 통해 동작합니다. 주요 헤더는 다음과 같습니다:

 

4-1. 요청 헤더

  • Origin: 요청을 보내는 출처
  • Access-Control-Request-Method: 실제 요청에서 사용할 HTTP 메서드
  • Access-Control-Request-Headers: 실제 요청에서 사용할 헤더

4-2. 응답 헤더

  • Access-Control-Allow-Origin: 허용된 출처
  • Access-Control-Allow-Methods: 허용된 HTTP 메서드
  • Access-Control-Allow-Headers: 허용된 헤더
  • Access-Control-Max-Age: 프리플라이트 요청 캐시 시간
  • Access-Control-Allow-Credentials: 인증 정보 포함 여부

 

5. CORS 요청의 종류

 

5-1. Simple Request

  • GET, HEAD, POST 중 하나의 메서드 사용
  • 허용된 헤더만 사용
  • Content-Type이 다음 중 하나:
    • application/x-www-form-urlencoded
    • multipart/form-data
    • text/plain

 

5-2. Preflight Request

Simple Request 조건을 만족하지 않는 요청의 경우, 브라우저는 실제 요청 전에 OPTIONS 메서드를 사용한 예비 요청을 보냅니다.

 

5-3. Credentialed Request

인증 정보(쿠키, HTTP 인증)를 포함한 요청입니다.

 

 

 

6. Spring Security CORS 설정

Spring Security CORS 설정에 대해서 알아보겠습니다.

 

 

UrlBasedCorsConfigurationSource apiConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();

        // 허용할 출처(Origin) 설정
        // https://api.example.com 에서 오는 요청만 허용
        configuration.setAllowedOrigins(List.of("https://api.example.com"));

        configuration.setAllowedOriginPatterns(List.of(
            "https://*.example.com",     // example.com의 모든 서브도메인 허용
            "https://*.example.*.com",   // 더 복잡한 패턴 매칭도 가능
            "http://localhost:[*]"       // 로컬호스트의 모든 포트 허용
        ));

        // 허용할 HTTP 메서드 설정
        // GET과 POST 메서드만 허용 (PUT, DELETE, PATCH 등은 차단됨)
        configuration.setAllowedMethods(List.of("GET","POST"));

        // 허용할 헤더 설정
        // 모두 허용
        configuration.setAllowedHeaders(List.of("*"));

        // 클라이언트에게 노출할 헤더
        configuration.setExposedHeaders(List.of("Authorization"));

        // allowCredentials를 true로 설정할 경우, allowedOrigins에 "*"를 사용할 수 없습니다
        configuration.setAllowCredentials(true);

        // CORS 프리플라이트 요청의 캐시 시간
        configuration.setMaxAge(3600L);

        // URL 패턴별로 CORS 설정을 적용할 수 있는 객체 생성
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();

        // 모든 경로("/**")에 대해 위에서 설정한 CORS 설정을 적용
        source.registerCorsConfiguration("/**", configuration);


        return source;
    }

 

    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {

        http.cors(cors -> cors
            .configurationSource(apiConfigurationSource())
        );
        
        return http.build();
    }

 

6-1. Preflight 란?

configuration.setMaxAge() 에 대해서 간단하게 알아보겠습니다.

  • 브라우저는 실제 요청 전에 OPTIONS 메서드를 사용하여 preflight 요청을 보냅니다
  • 이 요청으로 해당 출처가 안전한지, 어떤 메서드와 헤더가 허용되는지 확인합니다
  • 매 요청마다 preflight를 보내면 성능 저하가 발생할 수 있습니다

6-1-1. maxAge의 역할

// 예시: preflight 응답 헤더
Access-Control-Max-Age: 3600
Access-Control-Allow-Methods: GET, POST
Access-Control-Allow-Headers: Content-Type

 

  • 브라우저가 preflight 응답을 캐시하는 시간을 지정
  • 캐시 기간 동안은 동일한 요청에 대해 preflight를 다시 보내지 않음
  • 서버 부하 감소와 성능 향상에 도움

6-1-2. 브라우저별 최대 제한 시간

// 브라우저별 최대 캐시 시간이 다름
Chrome: 2시간 (7200초)
Firefox: 24시간 (86400초)
Safari: 7일

 

 

6-1-3. 장점

  • 서버 부하 감소
  • 네트워크 트래픽 감소
  • 응답 시간 개선

6-1-4. 단점

  • CORS 정책 변경 시 캐시된 정책이 즉시 적용되지 않을 수 있음
  • 브라우저마다 다른 최대 제한으로 일관성 있는 동작을 보장하기 어려움

 

 

6-2. allowCredentials와 allowedOrigins 설정

allowCredentials를 true로 설정하고 allowedOrigins에 "*"를 함께 사용할 수 없는 것은 중요한 보안상의 이유 때문입니다.

 

credentials에는 쿠키, HTTP 인증 토큰과 같은 민감한 인증 정보가 포함됩니다. 그런데 allowedOrigins에 모든 도메인("*")의 접근을 허용한다면 악의적인 웹사이트에서 사용자의 인증정보를 이용해 요청을 보낼 수 있게 됩니다.

 

 

[CS][Spring Security] CSRF란?

CSRF란?CSRF Cross-Site Request Forgery의 약자로 인증된 사용자의 권한을 악용하여 해당 사용자가 의도하지 않은 요청을 웹사이트에 전송하는 공격 기법입니다. 공격자는 사용자가 이미 인증된 상태를

tmd8633.tistory.com

이에 대해서는 CSRF에 대해서 읽어보시기 바랍니다.

 

따라서 브라우저는 이러한 보안 위험을 방지하기 위해 allowCredentials(true)와 allowedOrigins("*")의 조합을 명시적으로 금지하고 있습니다. 이는 웹 보안의 기본 원칙인 "최소 권한의 원칙"을 따르는 것이며, 실수로 인한 보안 취약점 발생을 방지합니다.

 

 

 

7. 자주 발생하는 CORS 에러와 해결 방법

7-1. No 'Access-Control-Allow-Origin' header is present

  • 원인: 서버에서 Access-Control-Allow-Origin 헤더를 설정하지 않음
  • 해결: 서버에서 적절한 CORS 설정 추가

 

7-2. Method not allowed

  • 원인: 허용되지 않은 HTTP 메서드 사용
  • 해결: allowedMethods에 필요한 메서드 추가

 

7-3. Credentials flag is true, but Access-Control-Allow-Credentials is false

  • 원인: 인증 정보를 포함한 요청에 대한 서버 설정 미비
  • 해결: allowCredentials(true) 설정 추가

 

8. 보안 관련 고려사항

8-1. Origin 설정

  • "*" 대신 구체적인 도메인 지정
  • 신뢰할 수 있는 출처만 허용

8-2. 인증 관련

  • allowCredentials(true) 사용 시 구체적인 출처 지정 필요
  • 보안에 민감한 API의 경우 더 엄격한 CORS 정책 적용

8-3. 헤더 설정

  • 필요한 헤더만 허용
  • exposedHeaders 설정 시 최소한의 헤더만 노출

8-4. 캐시 설정

  • maxAge 값을 적절히 설정하여 불필요한 프리플라이트 요청 감소

 

9. 결론

CORS는 현대 웹 개발에서 필수적인 보안 메커니즘입니다. 올바른 CORS 설정은 웹 애플리케이션의 보안과 기능성을 모두 만족시킬 수 있습니다. 각 프로젝트의 요구사항과 보안 정책에 맞게 적절한 CORS 설정을 적용하시기 바랍니다.

'일반 > CS' 카테고리의 다른 글

[CS][Spring Security] CSRF란?  (0) 2024.12.12
Maven Central Repository에 라이브러리 등록하기  (0) 2024.09.19
[CS] MVC 패턴  (0) 2024.04.17
HTTP GET과 POST 차이  (0) 2024.01.25
URI와 URL의 차이점 (Feat : URN)  (0) 2024.01.21

Anonymous 인증이란?

Spring Security의 Anonymous 인증은 인증되지 않은 사용자(로그인하지 않은 사용자)를 처리하는 메커니즘입니다. 인증되지 않은 요청에 대해 AnonymousAuthenticationToken을 생성하여 보안 컨텍스트에 저장합니다. 내용을 길지않으니 빠르게 알아보겠습니다.

 

 

 

사용법

anonymous 설정은 아래와 같습니다.

        http.anonymous(anonymous -> anonymous
            .principal("anonymousUser")      // 익명 사용자의 주체
            .authorities("ROLE_ANONYMOUS")    // 익명 사용자의 권한
        );
        // 비활성화
        http.anonymous(AbstractHttpConfigurer::disable);

 

 

 

사용처

로그인/회원가입은 로그인한 회원은 접근하지 못해야합니다.

        http.authorizeHttpRequests(request -> request
            .requestMatchers("/user/**").hasAnyRole("USER", "ADMIN")
            .requestMatchers("/admin/**").hasRole("ADMIN")
            .requestMatchers("/join", "/signup").anonymous()
            .anyRequest().permitAll()
        );

/join 과 /signup URL에는 로그인하지 않은 사용자만 접근하도록 설정했습니다.

 

 

결과

이제 로그인하고 /join 에 접속해보면

403 Forbidden 이 발생합니다.

 

 

Anonymous 인증의 동작 방식

 

Anonymous 인증 필터

  • AnonymousAuthenticationFilter가 인증되지 않은 요청을 처리
  • SecurityContextHolder에 인증 객체가 없을 때 AnonymousAuthenticationToken 생성
  • 필터 체인의 맨 마지막 부분에서 동작

 

인증 객체 구조

AnonymousAuthenticationToken {
    principal: "anonymousUser",
    authorities: ["ROLE_ANONYMOUS"],
    details: WebAuthenticationDetails,
    authenticated: true
}

 

 

 

주의사항

Anonymous 를 사용할 때에는 알고있어야 하는 것이 있습니다. AnonymousAuthenticationToken 이 SecurityContextHolder에 생성되었다는 것은 인증된 상태로 간주된다는 것입니다.

 

 

 

[Spring Security] 스프링 시큐리티 이해하기 (1)

Spring SecuritySpring Security는 인증, 인가 및 일반적인 공격에 대한 보호를 제공하는 프레임워크 입니다. 서비스를 개발할때 필수적으로 구현해야하는것이 로그인, 회원가입과 권한인데요. 이를 Sprin

tmd8633.tistory.com

이 특징에 대해서 3-1 SecurityContextHolder 부분을 읽어보시면 이해가 되실겁니다.

 

 

Authentication auth = SecurityContextHolder.getContext().getAuthentication();
System.out.println("isAuthenticated: " + auth.isAuthenticated());  // true 반환

이렇게 true가 반환되니 주의하시기 바랍니다.

 

실제 로그인한 사용자인지 파악하기위해서는

if (auth instanceof AnonymousAuthenticationToken) {
    // 익명 사용자
} else {
    // 실제 인증된 사용자
}

이렇게 사용하셔야합니다.

 

 

 

 

ExceptionHandling

http.exceptionHandling(handling -> handling
    .accessDeniedHandler()           // 권한 부족 시 처리
    .accessDeniedPage()             // 권한 부족 시 리다이렉트할 페이지
    .authenticationEntryPoint()     // 인증되지 않은 사용자 처리
    .defaultAuthenticationEntryPointFor()  // 특정 요청에 대한 인증 진입점 설정
);

 

 

accessDeniedHandler

.accessDeniedHandler((request, response, accessDeniedException) -> {
    // 인증된 사용자가 권한이 부족한 리소스에 접근할 때
    response.setContentType("text/html;charset=UTF-8");
    PrintWriter out = response.getWriter();
    out.println("<script>alert('권한이 없습니다.'); window.location.href='/';</script>");
    out.flush();
})

 

 

accessDeniedPage

.accessDeniedPage("/error/403")  // 간단히 특정 페이지로 리다이렉트

 

 

authenticationEntryPoint

.authenticationEntryPoint((request, response, authException) -> {
    // 인증되지 않은 사용자가 보호된 리소스에 접근할 때
    response.sendRedirect("/login");
})

 

 

defaultAuthenticationEntryPointFor

.defaultAuthenticationEntryPointFor(
    new LoginUrlAuthenticationEntryPoint("/api/login"),
    new AntPathRequestMatcher("/api/**")
)

 

 

전체 코드

        http.exceptionHandling(handling -> handling
            // 1. 권한 부족 처리 (403)
            .accessDeniedHandler((request, response, accessDeniedException) -> {
                response.setContentType("text/html;charset=UTF-8");
                PrintWriter out = response.getWriter();
                out.println("<script>alert('권한이 없습니다.'); history.back();</script>");
                out.flush();
            })
            
            // 2. 인증되지 않은 사용자 처리 (401)
            .authenticationEntryPoint((request, response, authException) -> {
                if (isAjaxRequest(request)) {
                    response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
                } else {
                    response.sendRedirect("/login");
                }
            })
            
            // 3. API 요청에 대한 특별한 처리
            .defaultAuthenticationEntryPointFor(
                new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
                new AntPathRequestMatcher("/api/**")
            )
        );
        
    // AJAX 요청 확인
    private boolean isAjaxRequest(HttpServletRequest request) {
        return "XMLHttpRequest".equals(request.getHeader("X-Requested-With"));
    }

 

코드만 봐서는 헷갈리는게 많습니다. 정리해보자면 다음과 같습니다.

 

주요 차이점

  1. accessDeniedHandler vs authenticationEntryPoint
    • accessDeniedHandler: 인증은 됐지만 권한이 없을 때 (403)
    • authenticationEntryPoint: 인증이 안된 경우 (401)
  2. accessDeniedPage vs accessDeniedHandler
    • accessDeniedPage: 단순 페이지 리다이렉트
    • accessDeniedHandler: 더 복잡한 로직 구현 가능
  3. defaultAuthenticationEntryPointFor
    • 특정 URL 패턴에 대해서만 다른 인증 진입점 설정 가능
    • API와 웹 페이지를 다르게 처리할 때 유용

 

 

오늘은 여기까지하고 마치겠습니다.

개요

Spring Security 에서 ThreadLocal에대해서 언급한적이 있어 글로 남겨봅니다.

 

ThreadLocal이란?

ThreadLocal은 Java에서 제공하는 클래스로, java.lang 패키지에 존재합니다.

각 스레드마다 독립적인 변수를 가질 수 있게 해주는 기능인데 쉽게 말해, ThreadLocal에 저장된 데이터는 해당 Thread만 접근할 수 있는 데이터 저장소라고 할 수 있습니다. 개인이 가지는 사물함이라고 생각하시면 쉽습니다.

 

 

 

동시성 문제

그럼 왜 ThreadLocal을 알아야 할까요? JAVA는 멀티쓰레딩 환경으로 한 메소드에 동시에 접근이 가능합니다. 그래서 항상 동시성문제에 신경써야하죠. 특히 Spring Container에서는 Bean으로 등록해 객체를 싱글톤으로 관리해 자원을 최소화 합니다. 하나의 객체를 여러명이 사용하면 문제가 생기기 마련입니다. 읽기 메소드는 멱등성을 보장받아 문제가 생기지않지만 수정, 생성, 삭제 등 데이터가 변경되었을 때 문제가 생기죠.

 

싱글톤패턴에 대해서 알고싶다면 아래 글을 읽어보세요!

 

 

자바(JAVA) - 싱글톤 패턴(Singleton Pattern)

싱글톤 패턴 싱글톤 패턴은 단 하나의 유일한 객체를 만들기 위한 코드 패턴입니다. 싱글톤 패턴을 따르는 클래스는 생성자가 여러번 호출되더라도 실제 생성되는 객체는 단 하나이고, 최초 생

tmd8633.tistory.com

 

 

자바(JAVA) - 싱글톤 패턴(Singleton Pattern)

싱글톤 패턴 싱글톤 패턴은 단 하나의 유일한 객체를 만들기 위한 코드 패턴입니다. 싱글톤 패턴을 따르는 클래스는 생성자가 여러번 호출되더라도 실제 생성되는 객체는 단 하나이고, 최초 생

tmd8633.tistory.com

 

public class BankAccount {
    private int balance = 0;
    
    // 동시성 문제가 발생하는 메서드
    public int transfer(int amount) {
        int currentBalance = balance;         // 현재 잔액 읽기
        balance = currentBalance + amount;    // 잔액 업데이트
        return balance;
    }
    
    public int getBalance() {
        return balance;
    }
}

 

가령 BankAccount 객체가 싱글톤으로 관리되고, 여러 사용자가 BankAccount를 사용한다고 했을때,

 

  1. 사용자A : bankAccount.transfer(1000)
  2. 사용자A 현재 잔액 읽음 : 현재 잔액 0
  3. 사용자B : bankAccount.transfer(1000)
  4. 사용자B 현재 잔액 읽음 : 현재 잔액 0
  5. 사용자A : 잔액 업데이트 - 결과 반환 1000
  6. 사용자B : 잔액 업데이트 - 결과 반환 1000

최종 잔액이 2000이 될 것으로 예상했지만 동시성 문제로 최종 잔액이 1000이 되었습니다.

 

 

 

ThreadLocal의 특징

 

  • 스레드 안전성: 각 스레드가 자신만의 독립된 변수를 가지므로, 동기화 없이도 스레드 안전성을 보장합니다.
  • 데이터 격리: 다른 스레드의 데이터에 접근할 수 없어 데이터 격리가 완벽하게 이루어집니다.
  • 성능: 동기화가 필요 없으므로, synchronized 키워드 사용 대비 성능상 이점이 있습니다.

 

 

ThreadLocal

public class ThreadLocal<T> {
    
    public T get() {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        return setInitialValue();
    }
    
    boolean isPresent() {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        return map != null && map.getEntry(this) != null;
    }
    
    public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            map.set(this, value);
        } else {
            createMap(t, value);
        }
    }
    
    public void remove() {
        ThreadLocalMap m = getMap(Thread.currentThread());
        if (m != null) {
            m.remove(this);
        }
    }

    ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
    }
    
    void createMap(Thread t, T firstValue) {
        t.threadLocals = new ThreadLocalMap(this, firstValue);
    }

    static class ThreadLocalMap {

        static class Entry extends WeakReference<java.lang.ThreadLocal<?>> {
            Object value;

            Entry(java.lang.ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
        }
    }
}

 

ThreadLocal은 ThreadLocalMap을 가지고있고 여기에서 key, value로 데이터를 보관합니다.

그리고 이때 get()메소드에서 Thread.currentThread()를 사용해 Thread를 꺼내고 그 ThreadLocalMap을 반환해서 가져오게됩니다.

 

 

이제 개념을 알았으니 어디에서 사용하고 있는지 간단하게 알아보겠습니다.

 

 

 

사용 사례

 

Spring Security

public class SecurityContextHolder {

    // ... 코드 생략

    private static void initializeStrategy() {
        if ("MODE_PRE_INITIALIZED".equals(strategyName)) {
            Assert.state(strategy != null, "When using MODE_PRE_INITIALIZED, setContextHolderStrategy must be called with the fully constructed strategy");
        } else {
            if (!StringUtils.hasText(strategyName)) {
                strategyName = "MODE_THREADLOCAL"; // ThreadLocal 전략이 Default
            }

            if (strategyName.equals("MODE_THREADLOCAL")) {
                strategy = new ThreadLocalSecurityContextHolderStrategy();
            } else if (strategyName.equals("MODE_INHERITABLETHREADLOCAL")) {
                strategy = new InheritableThreadLocalSecurityContextHolderStrategy();
            } else if (strategyName.equals("MODE_GLOBAL")) {
                strategy = new GlobalSecurityContextHolderStrategy();
            } else {
                try {
                    Class<?> clazz = Class.forName(strategyName);
                    Constructor<?> customStrategy = clazz.getConstructor();
                    strategy = (SecurityContextHolderStrategy)customStrategy.newInstance();
                } catch (Exception ex) {
                    ReflectionUtils.handleReflectionException(ex);
                }

            }
        }
    }
final class ThreadLocalSecurityContextHolderStrategy implements SecurityContextHolderStrategy {
    private static final ThreadLocal<Supplier<SecurityContext>> contextHolder = new ThreadLocal();
    
    // ... 코드 생략
}

 

 

 

 

Transaction

public class DataSourceTransactionManager extends AbstractPlatformTransactionManager implements ResourceTransactionManager, InitializingBean {

    // ... 코드 생략
    
    protected Object doGetTransaction() {
        DataSourceTransactionObject txObject = new DataSourceTransactionObject();
        txObject.setSavepointAllowed(this.isNestedTransactionAllowed());
        ConnectionHolder conHolder = (ConnectionHolder)TransactionSynchronizationManager.getResource(this.obtainDataSource());
        txObject.setConnectionHolder(conHolder, false);
        return txObject;
    }
    
    protected void doBegin(Object transaction, TransactionDefinition definition) {
    
        // ... 코드 생략
        if (txObject.isNewConnectionHolder()) {
            TransactionSynchronizationManager.bindResource(this.obtainDataSource(), txObject.getConnectionHolder());
        }    
        // ... 코드 생략
    }
}

public abstract class TransactionSynchronizationManager {
    private static final ThreadLocal<Map<Object, Object>> resources = new NamedThreadLocal("Transactional resources");
    private static final ThreadLocal<Set<TransactionSynchronization>> synchronizations = new NamedThreadLocal("Transaction synchronizations");
    private static final ThreadLocal<String> currentTransactionName = new NamedThreadLocal("Current transaction name");
    private static final ThreadLocal<Boolean> currentTransactionReadOnly = new NamedThreadLocal("Current transaction read-only status");
    private static final ThreadLocal<Integer> currentTransactionIsolationLevel = new NamedThreadLocal("Current transaction isolation level");
    private static final ThreadLocal<Boolean> actualTransactionActive = new NamedThreadLocal("Actual transaction active");
    
    // ... 코드 생략
}

 

 

 

Hibernate

public class ThreadLocalSessionContext extends AbstractCurrentSessionContext {
    private static final CoreMessageLogger LOG = (CoreMessageLogger)Logger.getMessageLogger(CoreMessageLogger.class, ThreadLocalSessionContext.class.getName());
    private static final Class<?>[] SESSION_PROXY_INTERFACES = new Class[]{Session.class, SessionImplementor.class, EventSource.class, LobCreationContext.class};
    private static final ThreadLocal<Map<SessionFactory, Session>> CONTEXT_TL = ThreadLocal.withInitial(HashMap::new);
    
    // ... 코드 생략
}

 

 

 

 

ThreadLocal 주의사항

 

메모리 누수

ThreadLocal 사용 후에는 반드시 remove()를 호출하여 메모리 누수를 방지해야 합니다. 특히 스레드 풀을 사용하는 환경에서는 더욱 중요합니다.

 

try {
    // ThreadLocal 사용
    userContext.set(new UserContext());
    // 비즈니스 로직
} finally {
    // 반드시 삭제
    userContext.remove();
}

가능하면 try-with-resources 패턴을 사용해 자원을 반납하면 안전하게 사용할 수 있습니다.

 

성능 고려사항

  1. ThreadLocal은 각 스레드마다 별도의 메모리를 사용합니다.
  2. 많은 수의 ThreadLocal 변수를 사용하면 메모리 사용량이 증가할 수 있습니다.
  3. get()과 set() 연산은 매우 빠르지만, 너무 빈번한 접근은 피하는 것이 좋습니다.

 

static final 선언

static final로 선언하는걸 권장합니다. 위에 사용 사례를 보시면 모두 static final로 선언되어있는걸 보실 수 있습니다.

 

  • static 사용 이유
    • ThreadLocal 객체 자체는 모든 스레드가 공유해도 됩니다
    • 각 스레드마다 값을 따로 저장하는 것은 ThreadLocal의 내부 구현이 처리함
    • 불필요한 인스턴스 생성을 방지할 수 있음
    • 전역적으로 접근이 필요한 경우가 많음 (예: Transaction, Security 등)
  • final 사용 이유
    • ThreadLocal 인스턴스 자체는 변경될 필요가 없음
    • 한 번 생성된 후에는 참조가 변경되면 안됨
    • 실수로 ThreadLocal 참조를 바꾸는 것을 방지
    • 불변성을 보장하여 스레드 안전성을 높임

 

 

 

끝!

+ Recent posts