(장준영) 팀 프로젝트 (1일차) - 알약 상세정보 검색 서비스 [백엔드]

2024. 5. 9. 00:27프로젝트 일지

학교 팀 프로젝트로 백엔드 직군을 맡게 되었다, 백엔드는 나를 포함한 2명에서 작업을 하기로했고 내가

백엔드 리더를 맡게돼었다

 

프로젝트 배경

서버 프레임워크 : 스프링부트

데이터베이스 : MySQL

 

프론트 직군들은 리엑트 프레임워크를 사용한다, 그러다보니 자연스럽게 CSR을 위한 서버를 만들게됐다

데드라인은 5월 16일 까지다, 오늘이 5월 8일 이니 9일 남았다..씨ㅣ발

데드라인 땅겨졌다 5월 14일 까지다, 7일 남았다 씨이ㅣ빨

 

스프링부트를 아직 배워본적이 없어서 속성으로 1주일 정도 공부하고 프로젝트에 투입됐다

노드를 조금 만져봐서 빨리 배울 수 있었다

 

문제가 하나 있는데, 프로젝트를 같이하는 회사가 모피어스 라는 하이브리드 앱 프레임워크를 이용해 하이브리드 앱을 만드는 과제를 내 주었다, 그래서 웹뷰를 사용해서 하이브리드앱 을 만드는것이 목표기때문에, 업체쪽에서 어떤 이슈로인해 프론트쪽에서 로컬스토리지를 이용할 수 없다고 했다, 쿠키도 이용할 수 있을지 확실하게는 모르겠다, 가능하긴 한데 해줘야할게 많아서, JWT 에세스토큰을 헤더에 담아서 보내주기로 결정했다

 

설계한 RDB 도식

디비 설계는 내가 하지않았고 팀장이 해놓았다, 디비 설계를 내가 하고싶었지만, 이미 기획서에 설계를 해서 제출을해놨기때문에 위 태이블을 기반으로 백엔드를 구축해야하는 상황이였다


서비스가 전반적으로 로그인 이후 이용가능하기때문에, 우선 회원가입 기능부터 만들어야한다

회원테이블의 이름은 페이션트다 (솔직히 이름이 좀 ㅈ같다 하지만 참고 그냥 한다)

회원가입 기능 구현

회원가입을 테스트하기위해

프론트쪽 코드를 작성해준다

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>테스트를 위해 만든 페이지임</title>
</head>
<body>
    <h1>회원가입 테스트</h1>
    <input class="register__name--email" name="email">
    <input class="register__name--password" name="password">
    <button class="test__register">회원가입 요청하기</button>

    <script src="https://code.jquery.com/jquery-3.7.1.min.js" integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script>
    <script>
        document.getElementsByClassName('test__register')[0].addEventListener('click',function () {
            let register_name_email = document.getElementsByClassName('register__name--email')[0].value
            let register_name_password = document.getElementsByClassName('register__name--password')[0].value
            let data_for_register = { 'email' : register_name_email, 'password' : register_name_password}
            $.ajax({
                type: "POST",
                url: "/register",
                data: JSON.stringify(data_for_register),
                contentType: "application/json; charset=utf-8",
                dataType: "json",
                success: function(data){
                    console.log(data);
                }
            });

        })
    </script>
</body>
</html>

서버에서 프론트로 회원가입을 위해 이메일과 페스워드 데이터를 JSON으로 담아 ajax POST 요청을 보내는 코드다

 

프론트와, 컨트롤러에서는 email 로 아이디값을 받지만

뺵엔드에서는 username 로 아이디값을 처리할거다

 

그래서 엔티티는 아래와같이 작성해주었다

package com.example.testLogin.Patient;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;

import java.sql.Timestamp;

@Getter
@Setter
@Entity
public class Patient {

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

    @Column(nullable = false)
    private int age;

    @Column(nullable = false)
    private boolean gender;

    @Column(nullable = false)
    private int weight;

    @Column(nullable = false)
    private int height;

    @Column(unique = true, nullable = false)
    private String username;

    @Column(nullable = false)
    private String password;

    @Column(unique = true)
    private String nickname;

    @Column(nullable = false)
    private boolean isMailVaild;

    @Column(nullable = false)
    private boolean isFirst;

    private Timestamp created;

    private Timestamp updated;


}

컨트롤러에서 유저가 응답과함께 전송한 이메일과 페스워드 데이터를 데이터베이스에 저장하는 비즈니스로직을 작성하기위해 레포지토리를 만들고 서비스에서 비즈니스로직을 아래와같이 작성해주었다

public void register(String username, String password){
        Patient patient = new Patient();
        patient.setAge(0);
        patient.setGender(true);
        patient.setWeight(87);
        patient.setHeight(178);
        patient.setUsername(username);
        patient.setPassword(password);
        patient.setMailValid(false);
        patient.setFirst(true);
        paitentRepository.save(patient);
    }

컨트롤러에서는 아래와같이 처리해주었다, 원래는 엔티티마다 널값이 입력 되도록 만들어뒀는데

널값으로 인해 발생할 에러들이 염려돼서 회원가입을 처리할때 기본값을 셋팅해주기로했다, 닉네임은 유니크한 값이라
처리할때 기본값을 셋팅할 수 없어 널값이 입력되도록 처리했다 

@PostMapping("/register")
    @ResponseBody
    public String register(@RequestBody HashMap<String, Object> map) {
        patientService.register(map.get("email").toString(), map.get("password").toString());
        return "회원가입 성공";
    }

테스트를 해보자

인서트가 잘 돼었다


로그인 기능 구현

로그인은 JWT 로그인 방식을 요구했다

세션 방식으로 구현을 해본적이 있지만 JWT는 처음이다

 

우선 JWT 구현을 위해

JWT 의 이론과, 스프링시큐리티에 대해 자세히 알아보아야한다

 

JWT 구현에 대해서는 개발자마다 구현방식이 다르고 정형화된 구현방법이 없다고해서, 직접 구현해보기로했다

인증, 인가 에 대해서 구현을 해야한다

 

이제 생각을 해보자

 

세션방식으로 로그인을 할때 스프링시큐리티가 작동하는 방식의 내부를 변형해 JWT를 구현할생각이다

 

이걸 위해서 스프링시큐리티가 작동하는 방식에 대해 이것저것 찾아보며 개지랄발광을 했다

구글에 뭔가 제대로된 스프링시큐리티 작동 방식에 대한 정보를 찾기 어려워서, 혼자 이리저리 뒤진걸

뤼튼으로 계속 검증하면서 디테일하게 파고들었다

존나파고파고파고 파고들어서 정리한 스프링시큐리티 내부적으로 인증, 인가 동작 방식은 아래와 같다

솔직히 내가 정리한정보 이거 찾아도 안 나오는데 시발 이 글만 비공개처리해버릴까? 누구한테 알려주기 아깝다

이걸 응용만하면 스프링 시큐리티로 웬만한건 다 할 수 있을거다

 

최종 정리

인증 :

폼으로 로그인을 요청 하고, 폼으로 로그인 요청을 할것이라고 시큐리티콘피그에서 설정해준 경우

스프링시큐리티의 시큐리티콘피그 안에있는 시큐리티필터체인 메소드에서 

수많은 필터들이 있고, 그 필터중 usernamepassword어센시케이션filter 가 

usernamepassword어센시케이션token 을 만들고 그 토큰 안에 유저가 폼태그로 보낸 username, password 값을 추출해서 담아주고, usernamepassword어센시케이션token을 어센시케이션Manager에게 보내고

어센시케이션Manager가 어센시케이션프로바이더를 통해

UserDetailService안에있는 loadByUsername() 메소드를 호출해

데이터베이스에있는 유저정보가 담긴 userDetail 객체를 받아와서

토큰에 있는 사용자가 입력한 username, password와

메소드를 호출해 디비에서 받아온 유저정보 속 username, password 값을 비교해주고

일치한다면 어센시케이션 객체에 loadByUsername()을 호출해 얻은 userDetail 객체를 담아주고

userDetail객체, 다시말해 로그인인증에 성공한 유저의 데이터를 가지고있는

어센시케이션 객체를 추후 인가과정에서 사용하기위해서 AccessDecisionManager 에 전달해주고,

어센시케이션 객체를 저장하기위해서 시큐리티콘텍스트에 넣어주고

사용자의 요청 처리 과정에서 어센시케이션 객체를 사용하기위해서 시큐리티콘텍스트홀더에 시큐리티콘텍스트를 넣는다

시큐리티콘텍스트에 넣어주고 시큐리티콘텍스를 시큐리티콘텍스트홀더에 담아준다

시큐리티콘텍스트홀더에 시큐리티콘텍스트를 담아주면, 스레드 안전성 보장, 인증 과정 추정 의 추가적인 장점도 있다

인가 :

유저가 특정 URL로 요청을 보내면 그 요청을 FilterSecurityInterceptor 라는 필터가 가로채고

FilterSecurityInterceptor 필터가 사용자가 요청한 URL의 접근권한을 전달받을때 까지 기다린다

이후 스프링시큐리티가 AccessDecisionManager 구현체를 생성하고 시큐리티콘텍스트에서 어센시케이션 객체를 가져와

AccessDecisionManager 구현체 객체에 전달한다, AccessDecisionManager 구현체 객체는 전달받은 어센시케이션 객체를 AccessDecisionVote 구현체 객체에게 전달하여

사용자가 요청을 보낸 URL의 접근 권한을 사용자가 가지고있는지 투표를 요청하게된다, 이후

AccessDecisionManager 구현체 객체는 투표 결과를 종합해서 사용자가 요청한 URL의 접근권한을 결정하고

FilterSecurityInterceptor 필터에게 결정된 사용자가 요청한 URL의 접근권한을 전달한다,

그러면 FilterSecurityInterceptor 필터는 접근권한이 있는 사용자라면 가로챈 요청을 다시 돌려주어 

사용자가 요청한 URL경로의 api를 실행시켜준다

AccessDecisionManager 는

AccessDecisionVote 에게 vote() 메소드를 통한 투표를 통해 접근 권한을

가지게할지 ( vote()가 리턴하게할 값 = ACCESS_GRANTED: 접근 허용)

말지 ( vote()가 리턴하게할 값 = ACCESS_DENIED: 접근 거부)

기권할지 ( vote()가 리턴하게할 값 = ACCESS_ABSTAIN: 투표 기권)

투표하도록 한다, 어떤 정보를 기반으로 투표할지는 AccessDecisionVote의 구현체에 따라 다르다

스프링시큐리티에서 제공하는 AccessDecisionVoter의 기본 구현체

RoleVoter: 사용자의 역할(ROLE_)을 기반으로 접근 허용 여부를 판단
AuthenticatedVoter : 사용자의 인증 상태(익명, 인증됨 등)를 기반으로 접근 허용 여부를 판단
WebExpressionVoter: SpEL 표현식을 사용하여 접근 허용 여부를 판단

에게 vote() 메소드를 통해 투표를 하도록 시킨다 

AccessDecisionManager 는 투표를 종합하여 투표결과를 결정하는데

투표결과를 결정하는 방식은 AccessDecisionManage의 구현체에따라 다르다

스프링시큐리티에서 제공하는 AccessDecisionManager의 기본 구현체

AffirmativeBased : 하나라도 ACCESS_GRANTED이면 최종 결과는 ACCESS_GRANTED
ConsensusBased: 긍정 투표가 부정 투표보다 많으면 ACCESS_GRANTED
UnanimousBased: 모든 투표가 ACCESS_GRANTED이면 ACCESS_GRANTED

에게 투표결과를 결정하도록 시키고 투표결과를 FilterSecurityInterceptor 에게 전달한다

FilterSecurityInterceptor에서 투표결과에 따라 로직을 처리해주는데, 투표를 통해 접근을 허용하는것으로

결정돼었다면, 사용자가 요청한 리소스경로에 해당하는 api가 실행된다

 

뤼튼한테 최종검증을 받아보았다

 

이거 정리만 존나오래했는데 나름 뿌듯? 하다

근데 시발 이제 시작이다, 이걸 응용해서 JWT 로그인을 구현해야한다

 

그걸 위해서 JWT 에 대해 자세히 알아봐야하겠다 여기에 참 잘 자세히 정리돼있다 함 읽어보면 조타

https://inpa.tistory.com/entry/WEB-%F0%9F%93%9A-JWTjson-web-token-%EB%9E%80-%F0%9F%92%AF-%EC%A0%95%EB%A6%AC#token_%EC%9D%B8%EC%A6%9D

 

🌐 JWT 토큰 인증 이란? (쿠키 vs 세션 vs 토큰)

Cookie / Session / Token 인증 방식 종류 보통 서버가 클라이언트 인증을 확인하는 방식은 대표적으로 쿠키, 세션, 토큰 3가지 방식이 있다. JWT를 배우기 앞서 우선 쿠키와 세션의 통신 방식을 복습해

inpa.tistory.com

JWT 토큰 발급은 그냥 하면 되는거고 

2가지가 궁금한데

 

토큰을 서버에서 어떻게 관리하나? > 서버메모리 안에서 관리하면 된다, Map 자료형 안에 사용자ID를 Key 값으로 가지고 JWT를 Value값으로 가지도록 저장해서 관리하면 검색 속도도 배열보다 빠르고, 사용자ID를 Key로 하니 중복될 일도 없어서 관리가 좋다 

 

어떻게 클라이언트에서 에세스토큰이 만료된걸 확인하고, 리프레시토큰을 서버로 보내나? >

클라이언트에서 주기적으로 JWT의 페이로드 부분에 적힌 토큰 만료기간을 확인해주고, 토큰이 만료됐으면

리프레시토큰을 서버로 전달하는거다, 리프레시토큰을 서버에서 받으면 새로 리프레시토큰과 에세스토큰을 발급하고

클라이언트로 에세스토큰과 리프레시토큰을 전달해주면 된다, 토큰 만료기간을 확인하는 방법은 JWT 를 디코딩하고

페이로드에있는 exp 필드를 이용하면 되는데 exp 필드에는 토큰만료기간이 타임스탬프로 기록돼있어서

현재 유닉스시간과 토큰만료기간을 비교해 현재 유닉스시간과 토큰만료기간을 비교해서 토큰만료기간보다 현재유닉스시간이 더 크면 토큰이 만료된거다, 클라이언트 쪽에서 토큰이 만료됐는지 검증하고 만료됐음 리프레시토큰 보내면 되고

서버에서는 리프레시토큰 받으면 에세스토큰 리프레시토큰 재발급해서 서버 메모리에 저장해주면 되는거고

에세스토큰이든 리프레시토큰이든 일단 서버에서 받으면 사용자가보낸 JWT 디코딩해서 페이로드에있는 exp 검사 해주고

불통이면 에러보내면 되고, 통과되면 사용자가 보낸 에세스or리프레시토큰이 서버메모리의 에세스or리프레시토큰이랑 일치하는지 검사하고 일치 안하면 에러보내면 되고, 일치하면 api 실행해주면 된다, 

 

이제 내일 JWT를 어떻게 구현할지 생각하고 구현 시작할거다