1. 프로젝트 소개

이번 프로젝트는 단순한 게임 구현을 넘어, 실시간성보안에 초점을 맞춘 **'UNO Multiplayer Game'**입니다. 2~4인이 실시간으로 방을 만들어 카드 게임을 즐길 수 있으며, Spring BootReact라는 현대적인 기술 스택을 활용해 안정적인 게임 환경을 구축했습니다.

2. 사전 요구사항 (Prerequisites)

이 프로젝트를 실행하기 위해 필요한 개발 환경입니다.

  • Backend: Java 21 이상
  • Frontend: Node.js 18 이상

3. 주요 핵심 기능

  • 🌐 WebSocket(STOMP) 기반 실시간 동기화
    • Pub/Sub 모델을 활용해 게임 내 모든 액션(카드 내기, 드로우, 턴 변경)을 실시간으로 모든 플레이어에게 전송합니다.
  • 🛡️ 서버 사이드 룰 엔진 (UnoRuleEngine)
    • 클라이언트가 아닌 서버에서 모든 게임 로직을 관리합니다. 특히 손패 정보를 서버에서만 보관하여 클라이언트 변조를 통한 치트를 원천 차단했습니다.
  • 🃏 UNO 오리지널 룰 완벽 구현
    • 숫자 카드는 물론 SKIP, REVERSE, +2, WILD, +4 등 특수 카드를 모두 구현했습니다.
    • 2인 플레이 시 REVERSE가 SKIP으로 작동하는 세부 룰까지 적용되어 있습니다.
  • 🏠 로비 및 방 시스템
    • 6자리 초대 코드를 통해 친구와 같은 방에 입장하여 게임을 시작할 수 있습니다.

4. 기술적 의사결정 및 아키텍처

  • Java 21 Record 활용: 카드(Card) 정보를 불변 값 객체인 record로 선언하여 데이터 무결성을 보장했습니다.
  • In-Memory Store: 게임 데이터의 휘발성을 고려하여 ConcurrentHashMap 기반의 메모리 저장소를 구현, Redis 없이도 멀티스레드 환경에서 안전하게 데이터를 관리합니다.
  • Zustand 상태 관리: 프론트엔드에서는 가벼운 Zustand를 사용해 WebSocket으로 넘어오는 실시간 게임 상태를 효율적으로 처리합니다.

5. 프로젝트 구조

  • Backend: UnoRuleEngine(도메인 로직), GameService(흐름 제어), STOMP Controller 등으로 분리되어 유지보수가 용이합니다.
  • Frontend: Lobby, WaitingRoom, GameBoard로 이어지는 직관적인 페이지 구조와 useWebSocket 커스텀 훅을 통한 통신 관리가 특징입니다.

6. 마치며

단순한 토이 프로젝트를 넘어 WebSocket 통신과 서버 측 유효성 검사 로직을 깊게 고민해 볼 수 있었던 프로젝트였습니다. 실시간 멀티플레이어 게임 개발의 메커니즘을 이해하고 싶은 분들에게 좋은 참고 사례가 될 것입니다.

📌 GitHub Repository: https://github.com/wjsskagur/uno-game

 

반응형

1. 프로젝트 개요: 왜 만들었는가?

MMORPG 유저들에게 '일일/주간 숙제(퀘스트)' 관리는 필수적이지만 번거로운 일입니다. 단순히 체크리스트를 제공하는 것을 넘어, 공식 홈페이지의 데이터와 AI 요약 기술을 결합하여 유저의 편의성을 극대화하는 것을 목표로 프로젝트를 시작했습니다.

2. 전체 아키텍처 및 기술 스택

정적 파일(React)과 API 서버(Spring Boot)를 분리하여 확장성과 보안을 동시에 챙겼습니다.

  • Backend: Java 17, Spring Boot 3.3, Spring Data JPA
  • Security: Spring Security (JWT + OAuth2)
  • Database: MariaDB 10.11 (RDS)
  • Infrastructure: AWS (EC2, S3, CloudFront, SSM, CodeDeploy)
  • External API: Claude API (LLM), Jsoup (Crawling)

3. 핵심 기술 포인트 (Deep Dive)

① Jsoup을 활용한 신뢰성 있는 캐릭터 인증

단순히 아이디만 입력하는 방식은 타인의 캐릭터를 사칭할 위험이 있습니다. 이를 해결하기 위해 공식 홈페이지의 특정 게시글에 댓글을 작성하게 하고, 서버에서 Jsoup으로 이를 크롤링하여 소유권을 검증하는 로직을 구현했습니다. 외부 웹 데이터의 정적 분석을 통해 보안성을 높인 시도였습니다.

② Claude API 기반 공지사항 자동 요약 시스템

게임 공지사항은 길고 가독성이 떨어지는 경우가 많습니다. 3시간마다 크롤링한 데이터를 **Claude API (Haiku 모델)**에 전달하여 핵심 내용만 3줄로 자동 요약해 제공합니다. 최신 LLM 기술을 서비스 로직에 자연스럽게 녹여낸 기능입니다.

③ 스케줄링을 활용한 데이터 무결성 관리

게임의 정기 점검 시간(매주 수요일 오전 5시)에 맞춰 퀘스트 진행률이 초기화되어야 합니다. Spring의 @Scheduled 기능을 활용하여 서버 부하를 최소화하면서도 정확한 시점에 데이터 초기화가 이루어지도록 설계했습니다.


4. 인프라 및 운영 효율화

  • CI/CD 파이프라인: GitHub Actions와 AWS CodeDeploy를 연동하여 코드 수정부터 배포까지의 과정을 자동화했습니다. 이를 통해 개발 생산성을 크게 향상시켰습니다.
  • 보안 관리: DB 접속 정보나 API 키와 같은 민감한 설정값은 application.yml에 직접 노출하지 않고, AWS SSM Parameter Store를 통해 주입받아 보안 사고를 방지했습니다.
  • 비용 및 성능 최적화: EC2 t4g(Graviton2) 인스턴스를 활용하여 성능 대비 비용 효율을 극대화했습니다.

5. 기술적 도전과 해결

프로젝트 진행 중 CORS 문제OAuth2 인증 흐름에서 발생한 토큰 전달 이슈가 가장 큰 고민이었습니다.

  • 해결: React와 Spring Boot 사이의 보안 필터를 조정하고, CloudFront를 통해 도메인을 통합하거나 적절한 Proxy 설정을 통해 문제를 해결했습니다.
  • 또한, 백엔드 개발자로서 도메인 중심의 설계를 지향하며 각 서비스 계층이 명확한 책임(Responsibility)을 갖도록 리팩토링하는 과정에 공을 들였습니다.

6. 마무리하며

이번 프로젝트는 단순히 기능을 구현하는 것을 넘어, 사용자가 정말 필요로 하는 기능이 무엇인지 고민하고 이를 기술적으로 구현해 보는 귀중한 경험이었습니다. 특히 외부 API와의 연동 및 AWS를 활용한 안정적인 배포 경험은 실제 운영 가능한 서비스를 구축하는 데 큰 자산이 되었습니다.

더 자세한 코드는 저의 GitHub 레포지토리에서 확인하실 수 있습니다.

📌 GitHub Link: https://github.com/wjsskagur/AION2_QuestCheck

반응형

1. 프로젝트 소개

아이온2를 즐기는 유저들이라면 매일, 매주 반복되는 퀘스트 관리에 번거로움을 느껴본 적이 있을 것입니다. **'AION2 Quest Checker'**는 캐릭터별로 퀘스트 완료 현황을 한눈에 관리하고, 파티 모집 및 서버 랭킹 확인까지 지원하는 종합 웹 서비스입니다.

단순히 체크만 하는 도구를 넘어, AI를 활용한 공지 요약과 공식 홈페이지 크롤링을 통한 캐릭터 인증 등 유저 편의성을 극대화하는 데 초점을 맞추어 개발했습니다.

2. 주요 핵심 기능

이 프로젝트에서 공을 들인 주요 기능들을 소개합니다.

  • 🛡️ 퀘스트 체커 & 자동 초기화
    • 캐릭터별로 일일/주간 퀘스트 진행률을 시각적으로 표시합니다.
    • 퀘스트별로 초기화 시간을 개별 설정할 수 있으며, 기본적으로 매주 수요일 오전 5시에 자동 초기화되도록 구현했습니다.
  • 🤖 Claude API 기반 공지 자동 요약
    • 아이온2 공식 공지사항을 3시간마다 수집합니다.
    • 수집된 방대한 양의 공지를 Claude API(Haiku 모델)를 통해 핵심 3줄로 요약하여 유저들에게 제공합니다.
  • 🔗 Jsoup 기반 캐릭터 소유권 인증
    • 보안과 신뢰를 위해 공식 홈페이지의 게시글 댓글을 크롤링하여 실제 캐릭터 소유주인지 검증하는 프로세스를 도입했습니다.
  • 📢 파티 모집 게시판
    • 1차·2차 카테고리 구조를 통해 효율적인 파티 매칭이 가능하며, 관리자가 유연하게 카테고리를 관리할 수 있습니다.
  • 📊 서버 랭킹 서비스
    • 등록된 캐릭터들의 데이터를 기반으로 서버별 전투력 및 레벨 랭킹을 실시간으로 제공합니다.

3. 기술 스택 및 아키텍처

성능과 확장성을 고려하여 최신 기술 스택을 활용했습니다.

  • Backend: Java 17, Spring Boot 3.3, Spring Data JPA
  • Security: Spring Security (JWT Stateless 인증), OAuth2 (카카오, 네이버, 구글)
  • Frontend: React 18, Vite, React Router v6
  • Database: MariaDB 10.11
  • Infrastructure: AWS (EC2 t4g, RDS, S3, CloudFront)
  • CI/CD: GitHub Actions, AWS CodeDeploy

4. 개발 포인트: 인프라와 보안

운영 환경에서의 안정성을 위해 AWS ap-northeast-2(서울) 리전에 배포를 진행했습니다.

  • 보안 관리: DB 비밀번호, API 키 등 민감한 정보는 AWS SSM Parameter Store를 통해 안전하게 관리했습니다.
  • 효율적인 배포: GitHub Actions와 CodeDeploy를 연동하여 코드 수정 시 자동으로 EC2에 배포되는 CI/CD 파이프라인을 구축했습니다.
  • 정적 파일 서빙: React 빌드 파일은 S3와 CloudFront를 통해 전 세계 어디서든 빠르게 접속할 수 있도록 구성했습니다.

5. 마치며

이번 프로젝트를 통해 실제 게임 유저들이 겪는 불편함을 기술적으로 해결해 보는 즐거운 경험을 할 수 있었습니다. 특히 외부 API(Claude)와 크롤링 기술을 적절히 조합하여 서비스의 완성도를 높인 점이 기억에 남습니다.

더 자세한 코드와 설정 방법은 아래 GitHub 레포지토리에서 확인하실 수 있습니다.

📌 GitHub Repository: https://github.com/wjsskagur/AION2_QuestCheck

 

반응형

안녕하세요

 

자바 실무에서 엑셀 다운로드를 할때, 매번 apache poi를 사용한 엑셀 다운로드가 불편하여

 

SheetJS를 사용하기 시작했었는데, 무료버전은 스타일이 적용되지않아

 

스타일이 적용한 fork 된 버전을 찾아 사용 하여 후기 남깁니다

 

파일은 GitHub에 올려두었습니다.

 

https://github.com/wjsskagur/js_excel

 

GitHub - wjsskagur/js_excel: SheetJS를 fork한 xlsx-js-style 사용하여 엑셀 스타일 적용 및 다운로드 업로드

SheetJS를 fork한 xlsx-js-style 사용하여 엑셀 스타일 적용 및 다운로드 업로드 - GitHub - wjsskagur/js_excel: SheetJS를 fork한 xlsx-js-style 사용하여 엑셀 스타일 적용 및 다운로드 업로드

github.com

순수 html파일이 궁금하신분들은 only_html.html 파일 참고하시면 될것같습니다

(이외 파일은 Spring Boot 기반 프로젝트)

 

html 파일 우선 첨부합니다

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<section>
    <div>
        <button type="button" id="btn_excel">엑셀 다운로드</button>
    </div>
    <div>
        <input type="file" id="upload_file" accept=".xls, .xlsx" >
        <label for="upload_file" ></label>
        <!--            <button type="button" id="btn_save">저장</button>-->
    </div>
</section>
</body>
<script lang="javascript" src="/js/xlsx.js"></script>
<script src="/js/excel_js.js"></script>
<script>
    const list = [
        {"name":"홍길동","phone":"010-1234-5678"},
        {"name":"홍길순","phone":"012-1234-1255"},
        {"name":"홍길둔","phone":"010-5678-1234"}
    ];

    document.getElementById("btn_excel").addEventListener("click", () => {
        const header = ["이름","핸드폰"];

        // const response = fetch("/test/", {
        //     method: "POST",
        //     headers: {
        //         "Content-Type": "application/json",
        //     },
        //     body: JSON.stringify(list),
        // })
        //
        // const result = response.json();
        // download_excel(result, header, "시트 이름을 입력하세요", "테스트파일.xlsx")

        download_excel(list, header, "시트 이름을 입력하세요", "테스트파일.xlsx")
    })

    document.getElementById('upload_file').addEventListener('change', async () => {
        const target = document.getElementById('upload_file').files[0];
        await read_excel(target);
    });

</script>
</html>

 

업로드도 같이 샘플링 하기위해 파일을 간단하게 읽을 수 있는 버튼을 추가해두었습니다.

 

이어서 excel_js.js 파일입니다 ( xlsx.js 파일은 https://gitbrent.github.io/xlsx-js-style/ 에 있습니다. )

 

function download_excel(data, headers, sheetName, filename) {
    const wb = XLSX.utils.book_new(); // make Workbook of Excel
    const ws = XLSX.utils.json_to_sheet(data); // make Worksheet of Excel
    const wsCols = []; // for column width

    // ====================== set headers ===========================
    XLSX.utils.sheet_add_aoa(ws, [headers], {origin: "A1"});

    // ====================== set column width ======================
    data.map((item) => {
        Object.keys(item).map((key, index) => {
            let maxWidth;
            if (typeof item[key] === "number") {
                maxWidth = 10;
            } else if (wsCols[index] && item[key]) {
                maxWidth = wsCols[index].width < item[key].length ? (item[key].length+5) : wsCols[index].width;
            } else {
                maxWidth = item[key] !== null ? (item[key].length+5) : 10;
            }
            wsCols[index] = {width: maxWidth}
        })
    })

    ws['!cols'] = wsCols;
    // ==============================================================



    // ====================== set column style ======================
    for (i in ws) {
        if (typeof(ws[i]) != "object") continue;
        let cell = XLSX.utils.decode_cell(i);

        ws[i].s = { // styling for all cells
            font: {
                name: "arial"
            },
            alignment: {
                vertical: "center",
                horizontal: "center",
                wrapText: '1', // any truthy value here
            },
        };

        // if (cell.c === 0) { // first column
        //     ws[i].s.numFmt = "DD/MM/YYYY HH:MM"; // for dates
        //     ws[i].z = "DD/MM/YYYY HH:MM";
        // } else {
        //     ws[i].s.numFmt = "00.00"; // other numbers
        // }

        if (cell.r === 0 ) { // first row
            ws[i].s.fill = { // background color
                patternType: "solid",
                fgColor: { rgb: "b2b2b2" },
                bgColor: { rgb: "b2b2b2" }
            };
        }
    }
    // ==============================================================

    XLSX.utils.book_append_sheet(wb, ws, sheetName);
    XLSX.writeFile(wb, filename);
}

function read_excel(file) {
    const reader = new FileReader();
    reader.onload = function(e) {
        const data = e.target.result;
        const workbook = XLSX.read(data, {type: 'binary'});
        const sheetName = workbook.SheetNames[0];
        const worksheet = workbook.Sheets[sheetName];
        const json = XLSX.utils.sheet_to_json(worksheet, {raw: true});
        console.log(json);
    }
    reader.readAsBinaryString(file);
}

// Java LocalDate to Javascript Date
function convert_date(date, type) {
    let tmpDate;

    try {
        tmpDate = new Date(Date.parse(date));
    } catch (e) {
        return null;
    }
    if (type === "date") {
        return tmpDate.getFullYear() + "-" + ((tmpDate.getMonth() + 1) < 10 ? ("0" + (tmpDate.getMonth() + 1)) : (tmpDate.getMonth() + 1) ) + "-" + tmpDate.getDate();
    } else if(type === "datetime") {
        return tmpDate.getFullYear() + "-" + ((tmpDate.getMonth() + 1) < 10 ? ("0" + (tmpDate.getMonth() + 1)) : (tmpDate.getMonth() + 1) ) + "-" + tmpDate.getDate() + " " + tmpDate.getHours() + ":" + tmpDate.getMinutes() + ":" + tmpDate.getSeconds();
    }
}

 

html 파일에서 넘겨받은 데이터의 각 행의 값마다의 길이를 구하여 cell의 길이를 입력하고,

첫번째 행에 임의 스타일을 적용 해두었습니다.

 

질문이나 피드백은 댓글로 남겨주시면 확인후 답변하겠습니다.

반응형

 

안녕하세요

오랜만에 글을 작성하네요

구글 Firebase Admin sdk 를 사용한 FCM 발송에 대해 간단하게 다루려고합니다.

 

개발 환경 : java 11 , Spring Boot 2.7.x

 

소스 내부에 주석으로 참고 라고 남겨둔 부분은 참고하시면 아마 도움이 되실 것 같습니다

소스 파일 우선 첨부합니다

 

package fcm.firebase;

import com.google.auth.oauth2.GoogleCredentials;
import com.google.firebase.FirebaseApp;
import com.google.firebase.FirebaseOptions;
import com.google.firebase.messaging.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.InputStreamResource;
import org.springframework.core.io.Resource;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;

import static org.springframework.util.StringUtils.hasText;

/*
    @Author : 전남혁 ( all_step@naver.com )
    @Description : FCM 푸시 알림
    @Created : 2023.05.24
    @Version : 1.0.0
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
public class Fcm {

    /* ========================================= GOOGLE FCM =================================================
        사용 라이브러리 : implementation 'com.google.firebase:firebase-admin:9.1.1' // Firebase Admin SDK
        * 1. firebase console 접속
        * 2. 프로젝트 생성
        * 3. 프로젝트 설정 -> 서비스 계정 -> 새 비공개 키 생성 -> json 파일 다운로드 ( fcm.key.location 에 경로 저장 )
        * 4. 프로젝트 설정 -> 클라우드 메시징 -> 서버 키 복사 ( 현재 미사용 )


        * 참고 : MulticastMessage 는 한번에 최대 500명에게 전송 가능


        * 참고 : 다중 전송의 BatchResponse 는 아래와 같이 사용
        int getFailureCount()               : 실패한 메세지 수
        List<SendResponse> getResponses()   : 전송 결과
        int getSuccessCount()               : 성공한 메세지 수


        * 참고 : 링크 관련
        앱은 링크를 별첨 할수 없음 (https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?hl=ko#Message.FIELDS.data)
        네이티브 앱에선 deep link 관련 컨트롤이 가능.. 하이브리드 앱에선 불가
        ( 네이티브 앱 추가 참고 : https://firebase.google.com/docs/dynamic-links?hl=ko )
        Chrome 에서 발송되는 web push 알림은 링크를 별첨 할수 있음
        Message.builder()
                    .setNotification(Notification.builder()
                            .setTitle(title)
                            .setBody(body)
                            .setImage(imageUrl)
                            .build())
                    .setToken(targetToken)
                    .setWebpushConfig(WebpushConfig.builder().putData("link","https://www.naver.com").build())
            .build()


        * 참고 : 이미지 관련
        장치에 다운로드되어 알림에 표시될 이미지의 URL을 포함합니다.
        JPEG, PNG, BMP는 모든 플랫폼에서 완벽하게 지원됩니다.
        애니메이션 GIF 및 비디오는 iOS에서만 작동합니다.
        WebP 및 HEIF는 플랫폼 및 플랫폼 버전에 따라 다양한 수준의 지원을 제공합니다.
        Android는 1MB 이미지 크기 제한이 있습니다.
        Firebase 저장소에서 이미지 호스팅에 대한 할당량 사용 및 영향/비용: https://firebase.google.com/pricing
     */
    @Value("${fcm.key.location}")
    private String fcmKeyLocation;
    private final String fireBaseScope = "https://www.googleapis.com/auth/cloud-platform";





    // ============================================ FIREBASE INIT =============================================
    /* FCM 초기화 ( 필수 )
        application.properties 에서 fcm.key.location 의 경로에 있는 json 파일을 읽어서 초기화
        json 파일이 없으면 초기화 하지 않음
        이미 초기화 되었을 경우 하지 않음
    */
    public void init() {
        Path path = Paths.get(fcmKeyLocation);
        if (!Files.exists(path)) {
            log.info("NOT FOUND FCM KEY FILE");
            return;
        }
        Resource resource;
        InputStream stringInputStream;
        try {
            resource = new InputStreamResource(Files.newInputStream(path));
            stringInputStream = new ByteArrayInputStream(resource.getInputStream().readAllBytes());
        } catch (IOException e) {
            throw new RuntimeException("FCM KEY FILE IOException ",e);
        }

        if(resource == null || stringInputStream == null) {
            log.info("NOT FOUND FCM KEY FILE");
            throw new RuntimeException("FCM KEY FILE NOT FOUND");
        }

        try {
            FirebaseOptions options = FirebaseOptions.builder()
                    .setCredentials(GoogleCredentials.fromStream(stringInputStream)
                            .createScoped(List.of(fireBaseScope))
                    )
                    .build();
            log.info("Firebase initializeApp start : " + FirebaseApp.getApps().isEmpty());
            if (FirebaseApp.getApps().isEmpty()) {
                FirebaseApp.initializeApp(options);
                log.info("Firebase application has been initialized");
            }
        } catch (Exception e) {
            throw new RuntimeException("Firebase initializeApp error", e);
        }
    }
    // ================================================================================================





    // ================================== getAccessToken (현재 사용 X) ==================================
    public String getAccessToken() {
        GoogleCredentials googleCredentials;
        try {
            googleCredentials = GoogleCredentials
                    .fromStream(new ClassPathResource(fcmKeyLocation).getInputStream())
                    .createScoped(List.of(fireBaseScope));
        } catch (IOException e) {
            throw new RuntimeException("FCM KEY FILE IOException ",e);
        }

        if (googleCredentials == null) {
            throw new RuntimeException("FCM KEY FILE NOT FOUND");
        }

        return googleCredentials.getAccessToken().getTokenValue();
    }
    // =================================================================================================





    // =================================== FCM 단일건 메세지 객체 생성 ========================================
    /* 단일건 메세지 객체 생성
        @param targetToken : FCM 토큰
        @param title : 제목
        @param body : 내용
        @param imageUrl : 이미지 URL
        @return : Firebase Cloud Messaging 메세지 객체
    */
    public Message setSingleMessage(String title, String body, String imageUrl, String targetToken) {
        Message message;

        if (hasText(imageUrl)) {
            message = Message.builder()
                    .setNotification(Notification.builder()
                            .setTitle(title)
                            .setBody(body)
                            .setImage(imageUrl)
                            .build())
                    .setToken(targetToken)
                    .build();
        } else {
            message = Message.builder()
                    .setNotification(Notification.builder()
                            .setTitle(title)
                            .setBody(body)
                            .build())
                    .setToken(targetToken)
                    .build();
        }

        return message;
    }
    // ===================================================================================================





    // ================================= FCM 단일건 다중 전송 메세지 객체 생성 =====================================
    /* 한번에 여러명에게 FCM 메세지 전송을 위한 객체 생성
        @param targetToken : FCM 토큰 리스트
        @param title : 제목
        @param body : 내용
        @param imageUrl : 이미지 URL
        @return : 다중 FCM 메세지 객체
    */
    public MulticastMessage setMulticastMessage(String title, String body, String imageUrl, List<String> registrationTokens) {
        MulticastMessage multicastMessage;

        if (hasText(imageUrl)) {
            multicastMessage =  MulticastMessage.builder()
                    .setNotification(Notification.builder()
                            .setTitle(title)
                            .setBody(body)
                            .setImage(imageUrl)
                            .build())
                    .addAllTokens(registrationTokens)
                    .build();
        } else {
            multicastMessage =  MulticastMessage.builder()
                    .setNotification(Notification.builder()
                            .setTitle(title)
                            .setBody(body)
                            .build())
                    .addAllTokens(registrationTokens)
                    .build();
        }

        return multicastMessage;
    }
    // ===================================================================================================





    // ====================================== FCM 단일건 메세지 전송 ==========================================
    /* FCM 토큰으로 메시지 보내기
        @param Message : FCM 메세지 객체
        @return : FCM 응답
    */
    public String sendSingleMessage(Message message) {
        String response;
        try {
            response = FirebaseMessaging.getInstance().send(message);
        } catch (Exception e) {
            throw new RuntimeException("Firebase sendSingleMessage error", e);
        }
        return response;
    }
    // ===================================================================================================




    // ====================================== FCM 여러종류의 메세지 전송 ==========================================
    /* 메세지 여러번 보내기
        @param List<Message> : FCM 메세지 객체
        @return : FCM 응답 ( 객체 타입 )
    */
    public BatchResponse sendListMessage(List<Message> messageList) {
        BatchResponse response;
        try {
            response = FirebaseMessaging.getInstance().sendAll(messageList);
        } catch (Exception e) {
            throw new RuntimeException("Firebase sendListMessage error", e);
        }
        return response;
    }
    // ===================================================================================================




    // ====================================== FCM 단일 메세지 다중 전송 ==========================================
    /* 여러명에게 한번에 메세지 보내기
        @param MulticastMessage : FCM 다중 메세지 객체
        @return : FCM 응답 ( 객체 타입 )
    */
    public BatchResponse sendMultiMessage(MulticastMessage multicastMessage) {
        BatchResponse response;
        try {
            response = FirebaseMessaging.getInstance().sendMulticast(multicastMessage);
        } catch (Exception e) {
            throw new RuntimeException("Firebase sendMultiMessage error", e);
        }
        return response;
    }
    // ===================================================================================================
}

 

 

한명에게만 전송할땐 Message 타입을, 여러명에게 전송할떈 MulticastMessage 타입을 사용하여

메세지객체를 List로 담아 발송하면 여러번의 Http 전송이 일어나지만

MulticastMessage로 여러명에 발송하면, 한번의 Http 전송으로 대량 발송이 가능한점이 좋아 같이 쓰게 되었습니다

Fcm 전송시 List<Message> 타입으로 전송할땐 다른메세지를 한번에 만들어 전송할때 사용하기 좋아 보였습니다

 

예시 컨트롤러도 아래 첨부합니다

 

package fcm.push.controller;

import fcm.common.JSONResponse;
import fcm.member.dto.MemberCondition;
import fcm.member.dto.MemberListDto;
import fcm.member.service.MemberService;
import fcm.push.dto.AdminPushDetailDto;
import fcm.push.dto.AdminPushListDto;
import fcm.push.dto.PushCondition;
import fcm.push.dto.PushWriteDto;
import fcm.push.service.PushService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;

import java.util.ArrayList;
import java.util.List;

import static org.springframework.util.StringUtils.hasText;

@Slf4j
@Controller
@RequiredArgsConstructor
public class PushController {
    private final PushService pushService;
    private final MemberService memberService;

    @GetMapping("/admin/push/list")
    public String pushList(@ModelAttribute("condition") PushCondition condition,
                           Model model) {
        if(condition.getPage() < 1)
            condition.setPage(1);

        if(condition.getRowPerPage() == null)
            condition.setRowPerPage(20);

        Pageable pageable = PageRequest.of(condition.getPage() - 1, condition.getRowPerPage());
        Page<AdminPushListDto> pushList = pushService.getAdminPushPage(condition, pageable);
        model.addAttribute("pushList", pushList);
        return "admin/member/push_list";
    }

    @GetMapping("/admin/push/write")
    public String pushWrite(Model model) {
        return "admin/member/push_write";
    }


    @GetMapping("/admin/push/member/list")
    public String pushMemberList(@ModelAttribute("condition") MemberCondition condition,
                                 Model model) {

        List<MemberListDto> memberList = new ArrayList<>();
        if(hasText(condition.getName())) {
           memberList = memberService.getMemberPopupList(condition);
        }

        model.addAttribute("memberList", memberList);
        return "admin/popup/member_search";
    }

    @PostMapping("/admin/push/save")
    @ResponseBody
    public JSONResponse<?> pushSave(@RequestBody PushWriteDto pushWriteDto) {

        try {
            pushService.savePush(pushWriteDto);
        } catch (Exception e) {
            log.error("푸시 발송에 실패하였습니다.", e);
            return new JSONResponse<>(500, "푸시 발송에 실패하였습니다.", null);
        }

        return new JSONResponse<>(200, "푸시 발송에 성공하였습니다.", null);
    }

    @GetMapping("/admin/push/detail/{id}")
    public String pushDetail(@PathVariable("id") Long id,
                             Model model) {
        AdminPushDetailDto push = pushService.getAdminPushDetail(id);
        model.addAttribute("dto", push);
        return "admin/member/push_view";
    }

    @PostMapping("/admin/push/update")
    @ResponseBody
    public JSONResponse<?> pushUpdate(@RequestBody PushWriteDto pushWriteDto) {

        try {
            pushService.updatePush(pushWriteDto);
        } catch (Exception e) {
            log.error("푸시 발송에 실패하였습니다.", e);
            return new JSONResponse<>(500, "푸시 발송에 실패하였습니다.", null);
        }

        return new JSONResponse<>(200, "푸시 발송에 성공하였습니다.", null);
    }
}

 

 

사용시 참고하실 부분은 application.properties에 json 파일 경로 ( Fcm.java 참고 주석 ) 를 명확히 써주셔야 작동합니다.

 

 

질문이나 피드백 댓글로 남겨주시면 확인하고 답변 남기겠습니다.

 

 

 

 

https://github.com/wjsskagur/fcm_only

 

GitHub - wjsskagur/fcm_only: Google Firebase Admin SDK를 사용한 Spring Boot 백엔드에서 앱 푸쉬 알림 발송

Google Firebase Admin SDK를 사용한 Spring Boot 백엔드에서 앱 푸쉬 알림 발송 - GitHub - wjsskagur/fcm_only: Google Firebase Admin SDK를 사용한 Spring Boot 백엔드에서 앱 푸쉬 알림 발송

github.com

 

반응형

 

이번 스프링 시큐리티는 Spring Data JPA를 통한 로그인 입니다

Spring Boot 3.0.2 버전 , Spring Security 6 사용하여 기존 5.7 이하에서 사용하던,

Deprecated 된 부분들(websecurityconfigureradapter , authorizeRequests)를 바꾸어 작성 해보았습니다.

( 급하신 분들은 MemberSecurityConfig.java 파일만 참고하시면 될 것 같습니다 )

 

 

깃허브 레포지토리에서 해당 소스나 패키지 구조 참고 가능합니다

( ps. 2023.02.21 14:03 로그인 기억하기 추가 깃허브 참조 )

 

https://github.com/wjsskagur/spring_security1

 

GitHub - wjsskagur/spring_security1

Contribute to wjsskagur/spring_security1 development by creating an account on GitHub.

github.com

 

* Spring Boot 3 버전부터 java 17 아래 버전으론 컴파일이 되지 않습니다 *

데이터베이스는 h2 기반으로 작성하였습니다.

 

Gradle 입니다

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.0.2'
    id 'io.spring.dependency-management' version '1.1.0'
}

group = 'security1'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'
    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
}

tasks.named('test') {
    useJUnitPlatform()
}

 

 

 

 

application.properties 입니다

# h2 DataBase 접속정보 ( h2 DataBase 설치 필요 )
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.url=jdbc:h2:~/Documents/h2/jun
spring.datasource.username=jun
spring.datasource.password=qwer1234

# jpa 기본 설정
# 영속성 컨텍스트 유지
spring.jpa.open-in-view=true
# sql 보기
spring.jpa.show-sql=true
# 쿼리 가독성
spring.jpa.properties.hibernate.format_sql=true
# 테이블 자동생성 끄기
spring.jpa.hibernate.ddl-auto=none

 

 

 

 

 

이후 전체적인 프로젝트 구조 및 파일 입니다.

아래 파일들은 코드와 같이 작업 할 파일들입니다.

 

 

자세한 구조는 GitHub 참조 바랍니다

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

html 3개의 소스입니다

먼저 로그인 페이지 templates/login/login.html

로그인 기억하기 사용을 위한 remember-me 을 사용하였습니다.

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <title>로그인 페이지</title>
</head>
<body>
    <h1>로그인 페이지</h1>
    <form action="/member/login/login" method="post">
        <div>
            <label for="username">아이디</label>
            <input type="text" name="username" id="username">
        </div>
        <div>
            <label for="password">비밀번호</label>
            <input type="password" name="password" id="password">
        </div>
        <div>
            <input type="checkbox" name="remember-me" id="rememberMe">
            <label for="rememberMe">자동 로그인</label>
        </div>
        <p style="display: none" class="error_txt" id="error_text"
           th:if="${#strings.length(session.loginErrorMessage)>0}"
           th:text="${session.loginErrorMessage}">로그인하시려면 아이디와 비밀번호를 입력해주세요</p>
        <button type="submit">로그인</button>
    </form>
</body>
<script>
    const error = document.getElementById('error_text');
    if(error !== undefined) alert(error.innerText);
</script>
</html>

 

로그인 완료시 이동할 페이지 templates/main/main.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>로그인성공</title>
</head>
<body>
    <h1>로그인 성공</h1>
    <p>로그인 성공</p>
    <a href="/member/text">회원 정보 확인하기</a>
    <a href="/member/login/logout">로그아웃</a>
</body>
</html>

 

로그인 된 회원정보를 출력할 페이지

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>로그인한 회원 정보</title>
</head>
<body>
    <p th:text="${'회원 로그인 아이디 : ' + member.loginId}"></p>
    <p th:text="${'회원 이름 : ' + member.name}"></p>
    <p th:text="${'회원 이메일 : ' + member.email}"></p>
</body>
</html>

 

해당 프로젝트에 사용할 DDL 입니다

DROP TABLE IF EXISTS MEMBER_TB;

CREATE TABLE MEMBER_TB
(
    MEMBER_ID       INT AUTO_INCREMENT NOT NULL,
    MEMBER_LOGIN_ID VARCHAR(20) NOT NULL,
    MEMBER_ROLE     VARCHAR(20) DEFAULT 'ROLE_MEMBER' NOT NULL,
    MEMBER_NAME     VARCHAR(20) NOT NULL,
    MEMBER_PASSWORD VARCHAR(400) NOT NULL,
    MEMBER_EMAIL    VARCHAR(100) NOT NULL,
    IS_USED         CHAR(1) DEFAULT 'Y' NOT NULL,
    IS_DEL          CHAR(1) DEFAULT 'N' NOT NULL,
    ISRT_DATE       DATETIME DEFAULT NOW() NOT NULL,
    UPDT_DATE       DATETIME ,
    CONSTRAINT MEMBER_PK PRIMARY KEY (MEMBER_ID)
);

-- 비밀번호 1111
INSERT INTO MEMBER_TB (MEMBER_LOGIN_ID, MEMBER_NAME, MEMBER_PASSWORD, MEMBER_EMAIL)
VALUES ('member1', '전남혁', '$2a$12$umem9giXuB0lDzAQ1ofzmeVqwHHFX76sbMObVEWpcIOPc6O.47NGa', 'all_step@naver.com');

-- 비밀번호 1234
INSERT INTO MEMBER_TB (MEMBER_LOGIN_ID, MEMBER_NAME, MEMBER_PASSWORD, MEMBER_EMAIL)
VALUES ('member2', '김철수', '$2a$12$R0ZgpAnBKh8CX0sATNRY8OyXPfke6GsXOxOA18gWyJ7RrnzOGnDOu', '');

 

이어서 엔티티 입니다

member/entity/Member

@Entity // 엔티티 생성시
@Getter
@Setter
@Builder // 추후 Builder 사용시
@NoArgsConstructor
@AllArgsConstructor
@ToString
@Table(name = "MEMBER_TB")
public class Member {

    @Id // 엔티티 내부에서 아이디임을 선언
    @GeneratedValue(strategy = GenerationType.IDENTITY) // 시퀀스 전략 선언
    @Column(name = "MEMBER_ID") // 아이디에 해당하는 컬럼명 선언
    private Long id;

    @Column(name = "MEMBER_LOGIN_ID")
    private String loginId;

    @Column(name = "MEMBER_ROLE")
    private String role;

    @Column(name = "MEMBER_NAME")
    private String name;

    @Column(name = "MEMBER_PASSWORD")
    private String password;

    @Column(name = "MEMBER_EMAIL")
    private String email;

    @Column(name = "IS_USED")
    private String isUsed;

    @Column(name = "IS_DEL")
    private String isDel;

    @Column(name = "ISRT_DATE")
    private LocalDateTime isrtDate;

    @Column(name = "UPDT_DATE")
    private LocalDateTime updtDate;
}

 

이어서 레포지토리 입니다

 member/repository/MemberRepository

@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
    Member findByLoginId(String username);
}

 

Spring Security에서 사용자를 담아둘 클래스입니다

securty/auth/MemberPrincipalDetails

// Spring Security 에 있는 UserDetails 를 구현한 클래스,
// 이 클래스를 통해 Spring Security 에서 사용자의 정보를 담아둠
public class MemberPrincipalDetails implements UserDetails {


    // member 패키지에 선언해놓은 member 엔티티를 사용하기 위해 선언
    private final Member member;

    public MemberPrincipalDetails(Member member) {
        this.member = member;
    }

    // 생성자
    public Member getMember() {
        return member;
    }

    // member 계정의 권한을 담아두기위해
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<GrantedAuthority> authorities = new ArrayList<>();
        authorities.add(new SimpleGrantedAuthority(member.getRole()));
        return authorities;
    }

    // member 계정의 비밀번호를 담아두기 위해
    @Override
    public String getPassword() {
        return member.getPassword();
    }

    // member 계정의 아이디를 담아두기 위해
    @Override
    public String getUsername() {
        return member.getLoginId();
    }

    // 계정이 만료되지 않았는지를 담아두기 위해 (true: 만료안됨)
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    // 계정이 잠겨있지 않았는지를 담아두기 위해 (true: 잠기지 않음)
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    // 계정의 비밀번호가 만료되지 않았는지를 담아두기 위해 (true: 만료안됨)
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    // 계정이 활성화되어있는지를 담아두기 위해 (true: 활성화됨)
    @Override
    public boolean isEnabled() {
        return true;
    }
}

 

 

스프링 시큐리티가 로그인 요청을 가로챌때 동작하는 서비스 입니다

security/auth/MemberPrincipalDetailService

// UserDetailsService 를 구현한 클래스
// 스프링 시큐리티가 로그인 요청을 가로챌 때, username, password 변수 2개를 가로채는데
// password 부분 처리는 알아서 함
@Service
public class MemberPrincipalDetailService implements UserDetailsService {

    // JPARepository 를 상속받은 인터페이스를 자동으로 DI (의존성 주입)
    @Autowired
    private MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 넘겨받은 id 로 DB 에서 회원 정보를 찾음
        Member member = memberRepository.findByLoginId(username);
        System.out.println("username : " + username);
        System.out.println("member : " + member);
        // 없을경우 에러 발생
        if(member == null)
            throw new UsernameNotFoundException(username + "을 찾을 수 없습니다.");

        if(!"Y".equals(member.getIsUsed()))
            throw new UsernameNotFoundException("사용할 수 없는 계정입니다.");

        if(!"N".equals(member.getIsDel()))
            throw new UsernameNotFoundException("삭제된 계정입니다.");

        // MemberPrincipalDetails 에 Member 객체를 넘겨줌
        return new MemberPrincipalDetails(member);
    }
}

 

 

 

로그인  정보를 가로챈 후 인증버로를 반환 하기위한 클래스입니다

security/provider/MemberAuthenticationProvider

@Component
// AuthenticationProvider 를 구현한 클래스
public class MemberAuthenticatorProvider implements AuthenticationProvider {

    @Autowired
    private MemberPrincipalDetailService memberPrincipalDetailService;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        String username = authentication.getName(); // 사용자가 입력한 id
        String password = (String) authentication.getCredentials(); // 사용자가 입력한 password

        // 생성해둔 MemberPrincipalDetailService 에서 loadUserByUsername 메소드를 호출하여 사용자 정보를 가져온다.
        MemberPrincipalDetails memberPrincipalDetails = (MemberPrincipalDetails) memberPrincipalDetailService.loadUserByUsername(username);

        // ====================================== 비밀번호 비교 ======================================
        // 사용자가 입력한 password 와 DB 에 저장된 password 를 비교한다.

        // db 에 저장된 password
        String dbPassword = memberPrincipalDetails.getPassword();
        // 암호화 방식 (BCryptPasswordEncoder) 를 사용하여 비밀번호를 비교한다.
        PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

        if(!passwordEncoder.matches(password, dbPassword)) {
            System.out.println("[사용자] 비밀번호가 일치하지 않습니다.");
            throw new BadCredentialsException("[사용자] 아이디 또는 비밀번호가 일치하지 않습니다.");
        }
        // ========================================================================================

        // 사용자가 입력한 id 와 password 가 일치하면 인증이 성공한 것이다.
        // 인증이 성공하면 MemberPrincipalDetails 객체를 반환한다.
        Member member = memberPrincipalDetails.getMember();
        if (member == null || "N".equals(member.getIsUsed())) {
            System.out.println("[사용자] 사용할 수 없는 계정입니다.");
            throw new BadCredentialsException("[사용자] 사용할 수 없는 계정입니다.");
        }

        // 인증이 성공하면 UsernamePasswordAuthenticationToken 객체를 반환한다.
        // 해당 객체는 Authentication 객체로 추후 인증이 끝나고 SecurityContextHolder.getContext() 에 저장된다.
        return new UsernamePasswordAuthenticationToken(memberPrincipalDetails, null, memberPrincipalDetails.getAuthorities());
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return authentication.equals(UsernamePasswordAuthenticationToken.class);
    }
}

 

 

 

로그인 실패시 에러 메세지를 띄우기 위한 커스텀 핸들러 입니다

security/config/MemberAuthFailureHandler

public class MemberAuthFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws ServletException, IOException {
        // 실패 메세지를 담기 위한 세션 선언
        HttpSession session = request.getSession();
        // 세션에 실패 메세지 담기
        session.setAttribute("loginErrorMessage", exception.getMessage());
        // 로그인 실패시 이동할 페이지
        setDefaultFailureUrl("/member/login/loginForm?error=true&t=h");
        // 로그인 실패시 이동할 페이지로 이동
        super.onAuthenticationFailure(request, response, exception);
    }
}

 

 

 

로그인 성공시 작동하는 커스텀 핸들러 입니다 ( 해당 프로젝트에선 사용하지 않습니다 )

security/config/MemberAuthSuccessHandler

public class MemberAuthSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        // 로그인 성공시 이동할 페이지
        setDefaultTargetUrl("/member/main");
        // 로그인 성공시 이동할 페이지로 이동
        super.onAuthenticationSuccess(request, response, authentication);
    }

}

 

 

위에 작성한 파일들이 사용되는 파일입니다

security/config/MemberSecurityConfig

로그인 기억하기 사용을 위한 rememberMe 도 같이 사용하였습니

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class MemberSecurityConfig {
    /*
    * 중요
    * Spring Security 5.7.0 버전부터
    * WebSecurityConfigurerAdapter가 deprecated 되기 때문에
    * 이와 같은 방법으로 구현
    * */


    // 생성해둔 MemberAuthenticatorProvider를 주입받는다.
    // 해당 클래스로 MemberPrincipalDetailsService 내부 로직을 수행하며
    // 인증 처리도 같이 진행된다
    @Autowired
    MemberAuthenticatorProvider memberAuthenticatorProvider;

    // 로그인 기억하기 사용을 위해 MemberAuthenticatorProvider 내부
    // MemberPrincipalDetailsService 선언
    @Autowired
    MemberPrincipalDetailService memberPrincipalDetailService;


    // in memory 방식으로 인증 처리를 진행 하기 위해 기존엔 Override 하여 구현했지만
    // Spring Security 5.7.0 버전부터는 AuthenticationManagerBuilder를 직접 생성하여
    // AuthenticationManager를 생성해야 한다.
    @Autowired
    public void configure (AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(memberAuthenticatorProvider);
    }

    // 5.7.0 부터 Override 하지 않고
    // SecurityFilterChain을 직접 생성하여 구현
    // 그 외 authorizeRequests 가 deprecated 되었기 때문에
    // authorizeHttpRequests 로 변경
    @Bean
    public SecurityFilterChain memberSecurityFilterChain (HttpSecurity http) throws Exception {
        http.csrf().disable();

        http.authorizeHttpRequests(authorize -> {
            try {
                authorize
                        .requestMatchers("/css/**", "/images/**", "/js/**", "/member/login/**", "/member/attachment/**", "/member/files/**")
                        .permitAll() // 해당 경로는 인증 없이 접근 가능
                        .requestMatchers("/member/**") // 해당 경로는 인증이 필요
                        .hasRole("MEMBER") // ROLE 이 MEMBER 가 포함된 경우에만 인증 가능
                    .and()
                        .formLogin()
                        .loginPage("/member/login/loginForm") // 로그인 페이지 설정
                        .loginProcessingUrl("/member/login/login") // 로그인 처리 URL 설정
                        .defaultSuccessUrl("/member/main") // 로그인 성공 후 이동할 페이지
//                        .successHandler(new MemberAuthSuccessHandler()) // 로그인 성공 후 처리할 핸들러
                        .failureHandler(new MemberAuthFailureHandler()) // 로그인 실패 후 처리할 핸들러
                        .permitAll()
                    .and()
                        .logout()
                        .logoutUrl("/member/login/logout") // 로그아웃 처리 URL 설정
                        .logoutSuccessUrl("/member/login/loginForm?logout=1") // 로그아웃 성공 후 이동할 페이지
                        .deleteCookies("JSESSIONID"); // 로그아웃 후 쿠키 삭제
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        });

        http.rememberMe()
                .key("namhyeok") // 인증 토큰 생성시 사용할 키
                .tokenValiditySeconds(60 * 60 * 24 * 7) // 인증 토큰 유효 시간 (초)
                .userDetailsService(memberPrincipalDetailService) // 인증 토큰 생성시 사용할 UserDetailsService
                .rememberMeParameter("remember-me"); // 로그인 페이지에서 사용할 파라미터 이름

        return http.build();
    }
}

 

실행 결과입니다.

테스트 아이디 : member1 , 비밀번호 : 1111

자동로그인 체크후 로그인 시도

 

로그인 성공화면

 

자동 로그인 체크 후 remember-me 라는 쿠키가 생성, JSESSIONID를 지워도 remember-me 가 있어 다시 생성됨

 

로그인 성공 후 담겨있는 데이터를 model에 담은뒤 thymeleaf로 출력

 

다음 Spring Security 글은 DB에 저장되어있는 권한을 동적으로 부여하는 법에 대해 작성하겠습니다.

두서 없는글 읽어주셔서 감사합니다.

 

 

 

GitHub - wjsskagur/spring_security1

Contribute to wjsskagur/spring_security1 development by creating an account on GitHub.

github.com

 

반응형

+ Recent posts