// 에드센스

오늘 깃을 만지다 실수로 졸업작품 코드를 몽땅 날려버릴뻔 했다. 

reset을 통해 복구를 했긴 했다만 참으로 아찔한 상황이었다.

고로 깃을 한번 체계적으로 짚고 넘어가볼까 한다. 

이 포스팅을 시작으로 여러 상황을 재현해보며 연습해보자.


1. 리포지토리 생성과 git init

CLI로 연습하는게 좋을 것 같아서 IDE의 git 플러그인을 사용하지 않고 깃배시를 설치했다.

최초 설치 후 계정설정이 필요한데 나의 경우는 먼 옛날에 해두었기에 생략한다.

 

echo "# git-test-repo" >> README.md
git init
git add README.md
git commit -m "first commit"
git branch -M main
git remote add origin [리포지토리 주소]
git push -u origin main

깃허브에서 리포지토리를 처음 생성하면 제시해주는 스크립트를 통해 Readme.md 파일을 생성하여 최초로 커밋 시켜준다.

 

 

 

 

 

이제 이 리포지토리에서 여러가지 깃 기능들을 시험해보자!

(simbean과 함께)

'Git' 카테고리의 다른 글

[Git] Branch 이름 변경하기  (0) 2022.04.26
[Git] git stash  (0) 2021.11.24
[Git] Branch와 Merge  (0) 2021.08.14
[Git] BFG Repo-Cleaner를 사용한 민감한 히스토리 삭제  (0) 2021.08.14

지난 포스팅에서 JWT가 무엇인지 알아보았다. 그럼 실제로 구현해보자.

https://llshl.tistory.com/26

 

[Web] JWT란?

1. JWT란? Json Web Token의 줄임말이다. 두 개체에서 JSON객체를 사용하여 가볍고 자가수용적인 방식으로 정보를 안정성 있게 전달해 주는 인증 방식이다. 2. 세션과의 차이점 세션 보통 로그인을 구현

llshl.tistory.com


1. JWT 테스트 시나리오

  1. 이메일과 비밀번호를 받아 회원 등록
  2. 로그인하면 JWT 토큰 반환
  3. JWT토큰을 Client 헤더에 등록하고, 요청 시 Spring Security에서 제한을 걸어놓은 리소스의 접근을 확인

 

전체 코드는 깃헙에 업로드 해놓았다.

참고로 이 코드는 

여기

블로그에서 참고했다. 설명이 아주 잘 돼 있으니 참고하면 좋다.

 

 

 

주요 코드만 살펴보자.

 

 

 


2. JWT를 사용해 로그인 기능을 구현해보자

 

회원가입 컨트롤러

    // 회원가입
    @PostMapping("/join")
    public Long join(@RequestBody Map<String, String> user) {
        return userRepository.save(User.builder()
                .email(user.get("email"))
                .password(passwordEncoder.encode(user.get("password")))
                .roles(Collections.singletonList("ROLE_ADMIN")) // 최초 가입시 USER 로 설정
                .build()).getId();
    }

그냥 별거없다. 이메일과 비밀번호를 입력받아서 Jpa의 save를 해준다. 참고로 데이터베이스는 H2를 썼다.

이렇게 포스트맨의 바디에 회원가입 정보를 담아서 요청을 보냈다.

 

 

 

 

로그인 컨트롤러

    // 로그인
    @PostMapping("/login")
    public String login(@RequestBody Map<String, String> user) {
        User member = userRepository.findByEmail(user.get("email"))
                .orElseThrow(() -> new IllegalArgumentException("가입되지 않은 E-MAIL 입니다."));
        if (!passwordEncoder.matches(user.get("password"), member.getPassword())) {
            throw new IllegalArgumentException("잘못된 비밀번호입니다.");
        }
        return jwtTokenProvider.createToken(member.getUsername(), member.getRoles());
    }

로그인 컨트롤러에서는 입력받은 이메일과 비밀번호가 데이터베이스에 있는 정보와 일치하는지 확인하고

일치한다면 JWT 토큰을 만들어서 반환해준다.

토큰에 사용자의 이메일(getUsername)과 권한 정보를 넣어줄 것이기에 파라미터로 넘겨준다.

회원가입한 정보 그대로 로그인 요청을 보낸다. 그러면 응답으로 JWT토큰이 돌아왔다!

 

 

 

 

JWT 토큰 생성

@RequiredArgsConstructor
@Component
public class JwtTokenProvider {
    private String secretKey = "llshlllshlllshlllshl";

    // 토큰 유효시간 30분
    private long tokenValidTime = 30 * 60 * 1000L;
    private final UserDetailsService userDetailsService;

    // 객체 초기화, secretKey를 Base64로 인코딩한다.
    @PostConstruct
    protected void init() {
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
    }

    // JWT 토큰 생성
    public String createToken(String userPk, List<String> roles) {
        Claims claims = Jwts.claims().setSubject(userPk); // JWT payload 에 저장되는 정보단위
        claims.put("roles", roles); // 정보는 key / value 쌍으로 저장된다.
        Date now = new Date();
        return Jwts.builder()
                .setClaims(claims) // 정보 저장
                .setIssuedAt(now) // 토큰 발행 시간 정보
                .setExpiration(new Date(now.getTime() + tokenValidTime)) // set Expire Time
                .signWith(SignatureAlgorithm.HS256, secretKey)  // 사용할 암호화 알고리즘과
                // signature 에 들어갈 secret값 세팅
                .compact();
    }

    // JWT 토큰에서 인증 정보 조회
    public Authentication getAuthentication(String token) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserPk(token));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    // 토큰에서 회원 정보 추출
    public String getUserPk(String token) {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
    }

    // Request의 Header에서 token 값을 가져옵니다. "X-AUTH-TOKEN" : "TOKEN값'
    public String resolveToken(HttpServletRequest request) {
        return request.getHeader("X-AUTH-TOKEN");
    }

    // 토큰의 유효성 + 만료일자 확인
    public boolean validateToken(String jwtToken) {
        try {
            Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
            return !claims.getBody().getExpiration().before(new Date());
        } catch (Exception e) {
            return false;
        }
    }
}

 

 

이 클래스의 createToken 메소드를 통해 토큰을 생성한다.

이렇게 생성된 JWT 토큰은 이후 사용자의 요청마다 헤더에 담아져 함께 전달된다.

 

 

이 토큰을 디코딩해보면

우리가 토큰에 넣어주었던 사용자의 정보가 잘 나오는것을 확인 할 수 있다.

이건 https://jwt.io/ 여기서 할 수 있다.

 

 

 

권한이 있어야 접근 가능하도록

토큰이 권한을 가져다 주는지 확인하기 위해 Spring Security의 WebSecurityConfig 클래스를 설정해준다.

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable();
        http.headers().frameOptions().disable();
        http.authorizeRequests()
                .antMatchers("/h2-console/**").permitAll(); // 누구나 h2-console 접속 허용
        http
                .httpBasic().disable() // rest api 만을 고려하여 기본 설정은 해제하겠습니다.
                .csrf().disable() // csrf 보안 토큰 disable처리.
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 토큰 기반 인증이므로 세션 역시 사용하지 않습니다.
                .and()
                .authorizeRequests() // 요청에 대한 사용권한 체크
                .antMatchers("/admin/**").hasRole("ADMIN")
                .antMatchers("/user/**").hasRole("USER")
                .anyRequest().permitAll() // 그외 나머지 요청은 누구나 접근 가능
                .and()
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
                        UsernamePasswordAuthenticationFilter.class)
                .authorizeRequests(); // 권한요청 처리 설정 메서드

        // JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 전에 넣는다
    }

 

configure 메소드에서 user와 admin으로 시작하는 요청에는

각각 USER와 ADMIN의 권한이 있어야만 접근 가능하도록 제한을 걸어두었다.

 

 

 

권한은 어떻게 확인?

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        // 헤더에서 JWT 를 받아옵니다.
        String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);
        // 유효한 토큰인지 확인합니다.
        if (token != null && jwtTokenProvider.validateToken(token)) {
            // 토큰이 유효하면 토큰으로부터 유저 정보를 받아옵니다.
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            // SecurityContext 에 Authentication 객체를 저장합니다.
            SecurityContextHolder.getContext().setAuthentication(authentication);
            System.out.println("토큰 유효하다");
        }
        chain.doFilter(request, response);
    }
}

이 필터가 요청을 가로채어 JWT 토큰이 유효한지 판단한다. 토큰 검증 후 유효하다면 다시 요청을 진행시킨다.

 

 

 

확인해보자

    @PostMapping("/user/test")
    public Map userResponseTest() {
        Map<String, String> result = new HashMap<>();
        result.put("result","user ok");
        return result;
    }

    @PostMapping("/admin/test")
    public Map adminResponseTest() {
        Map<String, String> result = new HashMap<>();
        result.put("result","admin ok");
        return result;
    }

접근 제한을 걸어둔 이 두개의 리소스에 요청을 보내어 결과가 잘 받아지는지 확인해보자.

회원가입시 기본적으로 얻는 권한은 USER다.

 

 

 

 

Headers에 우리가 설정해둔 키인 X-AUTH-TOKEN에 대응되게 value로 JWT 토큰을 넣어서 요청을 보내주면 올바른 응답이 돌아옴을 확인할 수 있다. 즉 인증이 되었다는 것.

 

 

 

 

 

당연하게도 토큰을 빼고 요청을 보내면 응답을 받을 수 없다. 

 

 

 

 

또한 USER권한으로는 ADMIN에 접근이 안됨을 확인할 수 있다.

 

 

 

 

참고:

H2 데이터베이스를 사용할 일이 많음에도 불구하고 초기 설정할때마다 삐걱대는게 지겨워져서 한번 정리한다.


1. H2 데이터베이스란?

H2 데이터베이스는 설치가 필요 없고 용량이 매우 가벼우며 웹용 콘솔을 제공하여 개발용 로컬 DB로 사용하기 좋은 데이터베이스이다. H2의 특징을 정리하면 다음과 같다.

  • 스프링 부트가 지원하는 인메모리 관계형 데이터베이스이다.
  • 인메모리로 띄우면 애플리케이션을 재시작할 때마다 초기화된다.
  • 별도의 설치가 필요 없다.
  • 로컬 환경, 테스트 환경에서 많이 사용된다.

http://h2database.com에 들어가서 다운받으면 된다.

 

 


2. Spring boot에서 H2 사용하기

 

build.gradle 설정

runtimeOnly 'com.h2database:h2'

 

 

application.yml 설정

spring:
  h2:
    console:
      enabled: true
      path: /jwt

  datasource:
    driver-class-name: org.h2.Driver
    url: jdbc:h2:file:~/Desktop/jwt-h2/jwt;AUTO_SERVER=TRUE
    username: sa
    password:

h2.console.enable : true 는 웹 콘솔을 사용하겠다는 의미

h2.console.path : /jwt는 콘솔의 경로를 의미(나는 jwt 관련 프로젝트를 진행하고 있기에 이름을 이렇게 했다)

 

 

이렇게 하면 h2 웹 콘솔에 접속할 때 

http://localhost:8080/jwt

주소로 들어가면 된다. 

 

 

datasource.url은 로컬에서 어디에 h2 데이터베이스의 정보를 저장할 것이냐는 건데, 나의 경우

~/Desktop/jwt-h2/jwt;AUTO_SERVER=TRUE

로 설정하면 바탕화면에 jwt-h2라는 폴더가 생기고 그 안에 jwt라는 이름으로 저장된다.

 

 

 

이제 웹 콘솔창에서 url 전체를 입력하고 connect를 누르면 사용 가능하다.

jdbc:h2:file:~/Desktop/jwt-h2/jwt;AUTO_SERVER=TRUE

 

 

 


3. 스프링 시큐리티를 사용한다면

스프링 시큐리티에 의해서 h2 콘솔 접근이 차단될 수 있다. 나의 경우는

h2 console localhost에서 연결을 거부했습니다.

라고 뜨며 다음과 같은 화면이 나왔다.

 

 

 

 

이럴땐 스프링 시큐리티의 WebSecurityConfig 클래스에서 h2-console에 대한 접근을 허용해줘야 한다.

@Configuration
@EnableWebSecurity
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {

	// ... 
	
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable();
        http.headers().frameOptions().disable();
        http.authorizeRequests()
                .antMatchers("/h2-console/**").permitAll(); // 누구나 h2-console 접속 허용
    
    //...
}

configure 메소드에 위와 같이 h2 콘솔에 대한 접근을 허용해 주면 된다. 그럼 문제 해결

 

 

 

 

 

참고:


1. JWT란?

Json Web Token의 줄임말이다. 

두 개체에서 JSON객체를 사용하여 가볍고 자가수용적인 방식으로 정보를 안정성 있게 전달해 주는 인증 방식이다.

 

 


2. 세션과의 차이점

세션

보통 로그인을 구현할때 세션로그인을 많이 사용했다. 세션로그인이란, 

이런 흐름을 가지고 진행된다.

  1. 클라이언트에서 서버로 로그인 요청을 보낸다.
  2. 서버는 로그인 정보 확인 후 세션아이디를 응답한다. 이 세션아이디는 서버에서도 가지고있는다.
  3. 이후 클라이언트의 요청에는 2번에서 응답받은 세션아이디를 쿠키에 담아서 함께 요청한다.
  4. 서버는 함께 요청된 쿠키 속의 세션아이디를 확인하여 로그인된 사용자인지 확인한다.

 

이러한 세션 로그인 방식은 단점이 있다.

 

단점.

  1. 만약 여러대의 서버가 운영된다면, 서버측에서 저장하고있는 모든 세션을 공유해야한다.
  2. 사용자가 많아질수록 서버측에서 모든 사용자의 세션들을 저장하기 부담스럽다.

 

 

그러면 JWT는 이 문제를 어떻게 해결할까?

JWT

JWT방식은 다음과 같은 흐름을 가진다.

  1. 클라이언트에서 서버로 로그인 요청을 보낸다.
  2. 서버는 로그인 정보 확인 후 토큰을 응답한다. 이 토큰은 서버에서 보관하지 않는다.
  3. 이후 클라이언트의 2번에서 응답받은 토큰을 요청에 함께 보낸다.
  4. 서버는 토큰이 유효한지 검증하고 유효하면 로그인된 사용자라고 생각한다.

 

세션방식과 다른 가장 큰 포인트는, 로그인 후 무언가를 응답으로 보내주긴하는데 JWT는 서버측에서 그걸 기억하지 않는다는 것이다. 

세션방식의 문제점 중 하나가 기억해야할 세션 아이디가 너무 많다는 것이었는데 이 문제점을 해결하는 부분이다.

서버는 자신이 발행한 토큰을 기억하지 않고, 나중에 토큰을 받으면 그 토큰이 유효한지 검증만 하면 되는 것이다.

또한 여러 디바이스나 도메인에서도 토큰에 대한 인증만 하면 되니 여러 서버가 운영될 때에도 문제 없다.

 

 

JWT가 마냥 좋은것만은 아니다.

세션은 시간에 따라 바뀌는 값을 갖는 stateful한 값이므로 어떠한 장점이 있느냐, 세션 값을 가지고있는 대상들을 제어할 수 있다. 예를 들어서 한 기기에서만 로그인이 가능하도록 구현하려 한다고 가정해보자.

1번 기기에 로그인이 돼있는데 2번 기기에서 로그인을 하면, 1번 기기의 세션을 종료하면 된다.

하지만 JWT는 사용자의 상태를 모르기 때문에 (stateless) 이것이 불가능하다. 이미 줘버린 토큰을 다시 회수할 수도 없고, 그 토큰의 발급 내용이나 정보를 서버가 추적하고 있지도 않기 때문이다. 반면 세션은 서버측의 세션저장소에 있기에 가능하다.

 

 

여담으로 지금 말한 단일 기기 로그인을 JWT로 해결하기 위한 기법도 존재한다. 

  • 최초 토큰 발행시 refresh 토큰과 access 토큰, 총 2개의 토큰을 발행한다.
  • refresh토큰은 만료기한(수명)이 꽤 길고 access토큰은 매우 짧다. 
  • refresh토큰의 상응값을 데이터베이스에도 저장한다.
  • 사용자가 요청할때 access토큰을 사용하는데 access토큰의 수명이 끝나면 refresh토큰을 사용해서 요청을 보낸다.
  • 서버는 서버측의 refresh 토큰 상응값과 비교해보고 맞다면 새로운 access토큰을 발행해 준다.
  • 즉, refresh토큰만 안전하게 관리된다면 중간에 access토큰이 탈취당해도 어자피 수명이 짧기 때문에 보안상 위협을 줄일 수 있고, 로그인 유지도 가능하다. 

 

 


3. JWT의 구성

JWT토큰은 3개의 부분으로 구성된다.

실제로는

 

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

 

이렇게 생겼다. 각 부분을 디코딩해보면 JSON형태로 나온다.

 

 

헤더 (header)

헤더는 두가지 정보를 갖는다.

typ: 토큰의 타입을 지정. 여기는 JWT가 고정으로 들어간다. 여기가 JWT여야지만 JWT기 때문에

alg: 어떤 해싱 알고리즘을 사용할지 지정한다. 해싱 알고리즘으로는 보통 HMAC SHA256 혹은 RSA 가 사용되며, 이 알고리즘은, 토큰을 검증 할 때 사용되는 signature 부분에서 사용된다.

 

{ 
  "typ": "JWT", 
  "alg": "HS256" 
}

 

 

내용 (payload)

이 부분에는 토큰에 담을 정보가 들어있다. 이 정보의 한 조각을 "클레임(Claim)"이라고 부르고 key : value의 한 쌍으로 이루어져 있다. 클레임의 종류는 3가지로 분류된다.

 

등록된(registered) 클레임

등록된 클레임은 서비스에 필요한 정보가 아니라 토큰에 대한 정보를 담기위해 이름이 이미 정해진 클레임들이다. 등록된 클레임은 Optional하다.

  • iss : 토큰 발급자 (issuer)
  • sub : 토큰 제목 (subject)
  • aud : 토큰 대상자 (audience)
  • exp : 토큰의 만료시간 (expiraton), 시간은 NumericDate 형식으로 되어있어야 하며 (예: 1480849147370) 언제나 현재 시간보다 이후로 설정돼야한다.
  • nbf : Not Before 를 의미하며, 토큰의 활성 날짜와 비슷한 개념. 여기에도 NumericDate 형식으로 날짜를 지정하며, 이 날짜가 지나기 전까지는 토큰이 처리되지 않는다.
  • iat : 토큰이 발급된 시간 (issued at), 이 값을 사용하여 토큰의 age 가 얼마나 되었는지 판단 할 수 있다.
  • jti : JWT의 고유 식별자로서, 주로 중복적인 처리를 방지하기 위하여 사용된다. 일회용 토큰에 사용하면 유용.

 

공개(public) 클레임

공개 클레임들은 충돌이 방지된 (collision-resistant) 이름을 가지고 있어야 한다. 충돌을 방지하기 위해서는, 클레임 이름을 URI 형식으로 짓는다.

 

{
    "https://velopert.com/jwt_claims/is_admin": true
}

 

비공개(private) 클레임

등록된 클레임도아니고, 공개된 클레임들도 아니다. 양 측간에 (보통 클라이언트 <->서버) 협의하에 사용되는 클레임 이름들이다. 공개 클레임과는 달리 이름이 중복되어 충돌이 될 수 있으니 사용할때에 유의.

 

{
    "username": "velopert"
}

 

Payload의 예시

{
    "iss": "llshl.com",
    "exp": "1485270000000",
    "https://llshl.com/jwt_claims/is_admin": true,
    "userId": "11028373727102",
    "username": "llshl"
}

 

 

서명 (signature)

서명은 헤더의 인코딩값과, 정보의 인코딩값을 합친후 주어진 비밀키로 해쉬를 하여 생성한다.

서버에서 요청에서 토큰을 받으면 헤더와 페이로드의 값을 서버의 비밀키와 함께 돌려서 계산된 결과값이 서명값과 일치하는지 확인한다.

 

 


한 줄 요약

"세션은 서버에서 세션아이디 보관,

JWT는 보관 없이 인증만"

 

 

 

 

참고:

'Web' 카테고리의 다른 글

[Web] REST API  (0) 2021.07.03
[Web] 세션과 쿠키  (0) 2021.07.02

1편에서 이어지는 글이다. 카카오 디벨로퍼스에서의 설정은 끝났으니 코드로 남은 구현을 마무리하자.


1. REST API 키 복사하기

여기서 두번째 저것을 사용해야한다. 복사하자.

 

 

 

2. Callback Url을 받을 Controller (인증 코드 요청)

@GetMapping("/user/kakao/callback")
public String kakaoLogin(String code) {
  // code는 카카오 서버로부터 받은 인가 코드
  log.info("kakaoLogin");
  memberService.kakaoLogin(code);
  return "redirect:/";
}

이 컨트롤러에서 다음 그림의 첫번째 한쌍의 절차를 수행한 것이다.

 

 

 

3. 토큰 요청

service의 kakaoLogin 메소드

public void kakaoLogin(String authorizedCode) {
        log.info("kakaoLogin Service 호출");
        // 카카오 OAuth2 를 통해 카카오 사용자 정보 조회
        KakaoUserInfo userInfo = kakaoOAuth2.getUserInfo(authorizedCode);
        System.out.println("userInfo.getNickname() = " + userInfo.getNickname());

        //카카오에서 받아온 사용자의 정보
        Long kakaoId = userInfo.getId();
        String nickname = userInfo.getNickname();
        String email = userInfo.getEmail();

        //카카오 로그인을 통해 이미 회원가입한 회원인지 확인하기 위해 카카오ID를 통해 검색
        MemberVo kakaoMember = memberMapper.findByKakaoId(kakaoId)
                .orElse(null);

        //중복된 사용자가 없다면(처음으로 카카오 로그인을 하는 경우 카카오에서 받은 정보를 통한 회원가입 진행)
        if(kakaoMember == null) {
            MemberVo sameEmailMember = memberMapper.findByEmail(email).orElse(null);
            if(sameEmailMember != null){
                //카카오로그인은 처음인데 이미 그냥 회원가입은 돼있는경우
                kakaoMember = sameEmailMember;
                kakaoMember.setKakao_id(kakaoId);    //이미 저장돼있는 회원 정보에 카카오 ID만 추가해서 다시 저장한다.
                memberMapper.updateMember(kakaoMember);     //기존 회원에 카카오 아이디만 추가해준다
            }
            else{
                String userName = nickname;
                String password = kakaoId + ADMIN_TOKEN;
                String encodedPassword = passwordEncoder.encode(password);
                kakaoMember = MemberVo.builder()
                        .kakao_id(kakaoId)
                        .login_id(email)    //카카오 로그인의 경우에는 login id가 없기에 이메일을 넣어줌
                        .nickname(nickname)
                        .name(userName)
                        .email(email)
                        .login_password(encodedPassword)
                        .build();
                memberMapper.save(kakaoMember); //회원가입

                int idByLoginId = memberMapper.findIdByLoginId(email);
                kakaoMember.setMember_id(idByLoginId);
            }
        }

        // 로그인 처리
        // 스프링 시큐리티 통해 인증된 사용자로 등록
        MemberDetailsImpl memberDetails = new MemberDetailsImpl(kakaoMember);
        Authentication authentication = new UsernamePasswordAuthenticationToken(memberDetails, null, memberDetails.getAuthorities());
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }

 

 

 

kakaoOAuth2 메소드

@Component
public class KakaoOAuth2 {

    public KakaoUserInfo getUserInfo(String authorizedCode) {
        System.out.println("getUserInfo 호출");
        // 인가코드 -> 액세스 토큰
        String accessToken = getAccessToken(authorizedCode);
        // 액세스 토큰 -> 카카오 사용자 정보
        KakaoUserInfo userInfo = getUserInfoByToken(accessToken);

        return userInfo;
    }

    private String getAccessToken(String authorizedCode) {
        System.out.println("getAccessToken 호출");
        // HttpHeader 오브젝트 생성
        HttpHeaders headers = new HttpHeaders();
        headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");

        // HttpBody 오브젝트 생성
        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("grant_type", "authorization_code");
        params.add("client_id", "여기에 REST API 키");   
        params.add("redirect_uri", "http://localhost:8080/user/kakao/callback");
        params.add("code", authorizedCode);

        // HttpHeader와 HttpBody를 하나의 오브젝트에 담기
        RestTemplate rt = new RestTemplate();
        HttpEntity<MultiValueMap<String, String>> kakaoTokenRequest =
                new HttpEntity<>(params, headers);

        // Http 요청하기, Post방식으로, 그리고 response 변수의 응답 받음
        ResponseEntity<String> response = rt.exchange(
                "https://kauth.kakao.com/oauth/token",
                HttpMethod.POST,
                kakaoTokenRequest,
                String.class
        );

        // JSON -> 액세스 토큰 파싱
        String tokenJson = response.getBody();
        JSONObject rjson = new JSONObject(tokenJson);
        String accessToken = rjson.getString("access_token");   //우리가 필요한건 accessToken

        return accessToken;
    }

    //토큰을 통해 사용자 정보 가져오기
    private KakaoUserInfo getUserInfoByToken(String accessToken) {
        // HttpHeader 오브젝트 생성
        HttpHeaders headers = new HttpHeaders();
        headers.add("Authorization", "Bearer " + accessToken);
        headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");

        // HttpHeader와 HttpBody를 하나의 오브젝트에 담기
        RestTemplate rt = new RestTemplate();
        HttpEntity<MultiValueMap<String, String>> kakaoProfileRequest = new HttpEntity<>(headers);

        // Http 요청하기 - Post방식으로 - 그리고 response 변수의 응답 받음.
        ResponseEntity<String> response = rt.exchange(
                "https://kapi.kakao.com/v2/user/me",
                HttpMethod.POST,
                kakaoProfileRequest,
                String.class
        );

        JSONObject body = new JSONObject(response.getBody());
        Long id = body.getLong("id");
        String email = body.getJSONObject("kakao_account").getString("email");
        String nickname = body.getJSONObject("properties").getString("nickname");

        //가져온 사용자 정보를 객체로 만들어서 반환
        return new KakaoUserInfo(id, email, nickname);
    }
}

 

 

 

kakaoLogin 메소드의 마지막 부분인

SecurityContextHolder.getContext().setAuthentication(authentication);

을 통해서 로그인 처리가 된다.

 

*SecurityContextHolder란 Spring Security의 인메모리 세션저장소

 

 

 

4. 로그인 페이지

<button id="login-kakao-btn" onclick="location.href='https://kauth.kakao.com/oauth/authorize?client_id=[여기에RESTAPI키]&redirect_uri=http://localhost:8080/user/kakao/callback&response_type=code'">
        카카오로 로그인하기
</button>

 

 

 

5. 현재 사용자의 정보 가져오기

지금까지 OAuth2를 사용하지 않고 로그인을 구현할 때는 

session.setAttribute("loginMemberId",loginDto.getUserID());

이런식으로 세션에 직접 무언가를 set 해줬는데 스프링 시큐리티를 사용할때는

 

 

MemberDetailsImpl memberDetails = new MemberDetailsImpl(kakaoMember);
Authentication authentication = new UsernamePasswordAuthenticationToken(memberDetails, null, memberDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);

이렇게  MemberDetails로 꺼내 사용자의 session 생성하고 이를 SecurityContextHolder에 저장한다.

이것을 가져올때는

 

 

@GetMapping("/")
public String home(Model model, @AuthenticationPrincipal MemberDetailsImpl memberDetails){
    model.addAttribute("username", memberDetails.getUsername());
    System.out.println("member_id: "+memberDetails.getMemberVo().getMember_id());
    return "index";
}

@AuthenticationPrincipal 어노테이션을 통해 SecurityContextHolder에 저장된 현재 사용자 정보를 가져올 수 있다. 위 코드의 출력 결과는 사용자의 member_id가 된다. 이것을 사용하여 여러 쿼리문의 where에 사용할 수 있겠지? 

 

 

사실 스프링시큐리티와 OAuth2에 대한 정보가 많이 부족하기에 혹시라도 지나가시던 고수님께서 보시고 틀린점이나 추가할 점을 발견하신다면 한소리 부탁드립니다..

 

 

 

 

참고:

지난 네이버 아이디로 로그인하기에 이어서 다음 소셜 로그인으로 카카오를 선택했다.

선택했다기보단 지금 모 코딩 교육 프로그램에서 한번 배웠기에 간단하게라도 복기하는 느낌으로 정리하는거다.

 


1. 소셜 로그인?

우리가 사용하는 웹 사이트들마다 전부 회원가입을 하는것은 사용자에게 너무 부담이 된다. 귀찮으니까. 또한 대부분 지키지 않겠지만 웹 사이트마다 다른 아이디와 비밀번호를 사용해야 보안적으로도 좋기에 각각 어떤 아이디였는지 외우기도 힘들다. 웹사이트를 운영하는 측면에서도 개인정보를 지켜야하는 부담이 있다.

따라서 OAuth를 사용한 소셜로그인이 등장했다.

 

 

OAuth란,

인터넷 사용자들이 비밀번호를 제공하지 않고 다른 웹사이트 상의 자신들의 정보에 대해 웹사이트나 애플리케이션 접근 권한을 부여할 수 있는 공통적인 수단으로서 사용되는, 접근 위임을 위한 개방형 표준이다. 사용자가 애플리케이션에게 모든 권한을 넘기지 않고 사용자 대신 서비스를 이용할 수 있게 해주는 HTTP 기반의 보안 프로토콜이다.

대표적으로 구글, 페이스북, 네이버, 카카오에서 서비스를 제공해주고 있다.

 

 

카카오 로그인의 큰 흐름은 다음과 같다.

 

 

 

 


2. 카카오 로그인 설정하기

1. 카카오 디벨로퍼스에 프로젝트 등록하기

https://developers.kakao.com/console/app

 

카카오계정 로그인

여기를 눌러 링크를 확인하세요.

accounts.kakao.com

 

애플리케이션 추가하기를 눌러서 적으라는것을 다 적는다! 등록된 애플리케이션 ID는 정확히 어디에 사용되는 것인지 모르기에 일단은 모자이크 처리를 하여서 캡쳐했다.

 

 

 

2. 사이트 도메인 입력

좌측 메뉴에서 플랫폼에 들어간 후 Web 플랫폼을 등록해준다.

 

 

 

3. Callback Url 설정하기

등록하러 가기를 눌러서 카카오 로그인이 성공하면 redirect 될 url을 설정해준다.

나는 이렇게 설정했고 controller에서 저 url을 처리해 줄 예정이다.

 

 

 

4. 동의항목 설정

카카오로부터 받을 사용자의 정보를 선택해야한다.

닉네임과 이메일정도만 받아보자.

 

 

다음부턴 코드로 구현해야한다. 다음 포스팅에 이어 작성하겠다.

 

 

 

 

참고:

 

이 카테고리는 인프런 김영한님의 JPA 강의를 보고 정리하는 공간입니다.


연관관계 매핑 시 고려할 것 3가지

  • 다중성
  • 단/양방향
  • 연관관계의 주인

 

 

다중성

N:1 , 1:N , 1:1 , N:N의 4가지 다중성이 있다.

보통은 다대일과 일대다를 많이 사용하고 다대다는 실무에서 거의 사용하지 않는다고 한다.

 

단방향/양방향

테이블에서는 외래 키 하나로 조인을 통해서 테이블간 양방향 참조가 가능하기 때문에 사실상 방향성이 없다. 하지만 객체에서는 참조용 필드를 통해 단방향으로만 접근할 수 있다. 단방향으로 서로 참조하고있으면 이를 양방향이라고 부른다. 

 

연관관계의 주인

테이블에서는 외래 키 하나로 두 테이블의 연관관계를 맺는다. 즉, 테이블의 연관관계를 관리하는 포인트는 외래 키 하나이다. 하지만 객체의 양방향 관계는 A->B , B->A 처럼 2개의 참조가 필요하다. JPA는 두 객체 연관관계 중 하나를 정해서 데이터베이스의 연관관계를 관리하는데 이를 연관관계의 주인이라고 한다. 

외래 키를 가진 쪽이 연관관계의 주인이고 외래키는 다(N)쪽에 존재한다.

 

 

 


다대일(N:1)

다대일 단방향
다대일 양방향

가장 많이 사용하는 관계이다. 외래 키는 항상 다(N)쪽에 있다. 따라서 객체 양방향 관계에서 연관관계의 주인은 항상 다(N)쪽이다. 여기서는 Member의 team이 연관관계의 주인이다.

또한 양방향 연관관계에서는 항상 서로를 참조하고 있어야한다. 이를 위해서는 이전 포스팅의 순수객체 상태 관련 메소드를 사용하면 좋다.

 

 

 


일대다(1:N)

일대다 단방향

일대다 단방향은 일(1)이 연관관계의 주인이다.

객체와 테이블의 차이 때문에 반대편 테이블의 외래 키를 관리하는 특이한 구조이다. 꼭 @JoinColunm을 사용해야한다. 사용하지 않으면 중간에 매핑테이블이 자동으로 생성돼버린다.

 

엔티티가 관리하는 외래 키가 다른 테이블에 있기에 추가로 update SQL이 실행된다. 일대다 단방향보다는 다대일 양방향을 이용하자.

 

 

일대다 양방향

이런 매핑은 공식적으로 존재하지 않는다.

@JoinColumn(insertable=false, updatable=false) 읽기 전용 필드를 사용해서 양방향 처럼 사용하는 방법이다.

 

즉, 그냥 다대일 양방향을 사용하는 것이 좋다.

 

 

 


일대일 (1:1)

일대일 단방향

주 테이블이나 대상 테이블 중 어떤곳에 외래 키를 두어도 된다.

외래 키에 데이터베이스 유니크 제약조건 추가돼야 한다.

 

 

주 테이블에 외래 키

  • 주 객체가 대상 객체를 참조하는 형태로, 객체지향 개발자들이 선호한다.
  • 장점은 주 테이블만 확인해도 대상 테이블과의 연관관계를 알 수 있다.
  • 단점은 값이 없으면 외래 키에 null 허용

 

대상 테이블에 외래 키

  • 전통적인 데이터베이스 개발자들은 대상 테이블에 외래 키를 두는 것을 선호한다.
  • 장점은 테이블 관계를 일대일에서 일대다로 변경할 때 테이블 구조를 그대로 유지할 수 있다.
  • 단점은 프록시 기능의 한계로 지연로딩으로 설정해도 항상 즉시로딩이 된다.

 

 

 

일대일 양방향

양방향 매핑이므로 연관관계의 주인을 정해야한다. 이 경우 MEMBER 테이블이 외래 키를 가지고 있으므로 Member 엔티티의 locker가 연관관계의 주인이다.

따라서 반대 매핑인 Locker 엔티티의 member는 mappedBy를 통해 연관관계의 주인이 아니라고 명시해야 한다.

 

 

 

 


다대다 (N:N)

김영한 강사님이 쓰지 말라신다. 쓰지 말자.

관계형 데이터베이스는 정규화된 테이블 2개로 다대다 관계를 표현할 수 없다. 연결 테이블을 추가해서 일대다, 다대일 관계로 풀어내야한다.

 

예를 들어 회원들(N)은 상품들(N)을 주문하고, 상품들(N)도 회원들(N)에게 주문된다고 하면, 이 둘은 다대다 관계다.

고로 회원 테이블과 상품 테이블만으로 이 관계를 표현할 수 없고 아래처럼 중간 테이블을 통해 다대일, 일대다 관계로 풀어내야 한다. (@ManyToMany => @ManyToOne , @OneToMany)

 

 

 

참고:

더보기

'JPA' 카테고리의 다른 글

[JPA] 연관관계 매핑  (0) 2021.07.21
[JPA] 엔티티 매핑  (0) 2021.07.19
[JPA] 영속성 관리  (0) 2021.07.17
[JPA] JPA란?  (0) 2021.07.15

이 카테고리는 인프런 김영한님의 JPA 강의를 보고 정리하는 공간입니다.


연관관계가 필요한 이유

"객체지향 설계의 목표는 자율적인 객체들의 협력 공동체를 만드는 것이다"

 

예를 들어보자.

  • 회원과 팀이 있다.
  • 회원은 하나의 팀에만 소속될 수 있다.
  • 회원과 팀은 N:1 관계다.
@Entity
public class Member {
  @Id @GeneratedValue
  private Long id;
  
  @Column(name = "USERNAME")
  private String name;
  
  @Column(name = "TEAM_ID")
  private Long teamId;
}

@Entity
public class Team {
  @Id @GeneratedValue
  private Long id;
  
  private String name;
}
//팀 저장
Team team = new Team();
team.setName("TeamA");
em.persist(team);

//회원 저장
Member member = new Member();
member.setName("member1");
member.setTeamId(team.getId());
em.persist(member);

라는 상황이 있을때, 특정 회원이 속한 팀을 찾으려면 어떻게 해야할까?

 

 

일반적인 경우는 다음과 같은 방법을 사용한다.

Member findMember = em.find(Member.class, member.getId());

Long findTeamId = findMember.getId();
Team findTeam = em.find(Team.class, findTeamId);

먼저 특정 회원을 조회하고, 그 회원의 팀 ID를 getter로 가져와서

팀 ID로 다시 조회한다. 

 

벌써 두번이나 쿼리가 발생했다. 이런것을 보고 객체지향스럽지 못하다고 한다.

즉, 객체를 테이블에 맞춰서 데이터 중심으로 모델링하면, 협력 관계를 만들 수 없다.

  • 테이블은 외래키를 사용한 조인을 통해 연관된 테이블을 찾는다.
  • 객체는 참조를 통해서 연관된 객체를 찾는다.

이것이 객체와 테이블이 가지는 가장 큰 차이점.

 

 

 


단방향 연관관계

객체지향 모델링

위에서 작성한 Member 엔티티를 조금 수정해보자.

@Entity
public class Member {

  @Id @GeneratedValue
  private Long id;

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

  // @Column(name = "TEAM_ID")
  // private Long teamId;

  //Team에 대한 참조를 넣음
  @ManyToOne
  @JoinColumn(name = "TEAM_ID")
  private Team team;
  
}

 

 

Team을 참조하는 Long형 teamId 대신 Team에 대한 참조값 team을 넣어서 매핑을 한다면 어떻게 될까?

이렇게 모델링을 한다면 위에서 예시로 들었던 특정 멤버의 팀을 조회하는 코드가 한결 간결해지고 쿼리도 줄어든다.

 

 

 

//조회
Member findMember = em.find(Member.class, member.getId());

//참조를 사용해서 연관관계 조회
Team findTeam = findMember.getTeam();

해당 회원의 팀 ID를 조회하고 그것으로 다시 팀을 조회하는것이 아닌,

한번에 해당 회원의 팀을 조회할 수 있다. team 참조가 있기 때문에.

 

 

 

하지만 Team에서는 어떤 Member가 있는지 조회할 수 없다. 단방향 연관관계이기 때문이다.

  • 객체는 team이라는 참조를 통해 단방향으로 연관관계를 가진다. Member에서는 team을 통해 팀에 대한 정보를 알 수 있지만 Team에서는 Member를 알 수 있는 방법이 없기에 단방향 연관관계다.
  • 테이블에서는 TEAM_ID라는 외래키를 통해 연관관계를 가진다. 이는 Team에서도 Member를 조회할 수 있기에 양방향 관계라고 할 수 있다.

 

즉, 정리하자면

  • 객체는 참조를 통해 연관관계 가진다.
  • 객체의 연관관계는 단방향이다.
  • 테이블은 외래키를 통해 연관관계 가진다.
  • 테이블의 연관관계는 양방향이다.

 

 

 


양방향 연관관계

 

팀 1개에 N명의 멤버가 있을 수 있으므로 Team 엔티티에 리스트형으로 member의 참조값을 넣었다.

@Entity
public class Team {
  @Id @GeneratedValue
  private Long id;
  
  private String name;
  
  @OneToMany(mappedBy = "team")
  List<Member> members = new ArrayList<Member>();

 

 

이를 통해 객체에서도 Team -> Member로의 조회가 가능하다.

//조회
Team findTeam = em.find(Team.class, team.getId());
int memberSize = findTeam.getMembers().size(); //역방향 조회

 

 

 


연관관계의 주인

  • 양방향 연관관계에서는 주인이 필요하다.
  • 테이블의 연관관계는 외래키 하나로 양쪽을 다 조회할 수 있지만,
  • 객체에서는 양쪽에서 서로를 참조하는 값이 있어야 서로간의 조회가 가능하다. 즉 까보면 단방향 연관관계 두개로 인해 양방향처럼 보이는 것이다.
class A {
	B b;
}
class B {
	A a;
}

이런 윈리지만 우리는 이를 양방향 연관관계라고 부른다.

 

 

  • 엔티티를 양방향으로 연관관계를 가지게 하기 위해 참조가 2개 필요한데,
  • 데이터베이스에서는 외래키는 1개 필요하다.
  • 이 차이로인해 JPA에서는 객체 연관관계 중 하나를 주인으로 삼고 이 외래키를 보관하도록 한다.
  • 연관관계의 주인만이 데이터베이스의 연관관계와 매핑되고 외래키를 관리할 수 있다. 주인이 아닌쪽은 읽기만 간으하다.

 

 

 


누구를 주인으로?

외래키가 있는 곳을 주인으로.

외래키는 N이 있는 곳.

(이 예시에서는 Member쪽이다)

 

 

양방향 매핑 규칙

  • 연관관계의 주인만이 외래키를 관리(등록, 수정)
  • 주인이 아닌쪽은 읽기만 가능
  • 주인은 mappedBy 속성 사용X
  • 주인이 아닌쪽에서 mappedBy 속성을 통해 주인 지정

 

 

주의할 점

연관관계의 주인에 값을 입력하지 않음

Team team = new Team();
team.setName("TeamA");
em.persist(team);

Member member = new Member();
member.setName("member1");

//역방향(주인이 아닌 방향)만 연관관계 설정
team.getMembers().add(member);
em.persist(member);

 

 

순수한 객체 관계를 고려하면 연관관계 주인과 반대편 양쪽 다 값을 입력 해야한다.

Team team = new Team();
team.setName("TeamA");
em.persist(team);

Member member = new Member();
member.setName("member1");
team.getMembers().add(member);  //반대편인 여기랑

//연관관계의 주인에 값 설정
member.setTeam(team);  //주인인 여기 양쪽 모두 값 넣어야함
em.persist(member);

DB에 올라가지 않고 1차 캐시에서 엔티티를 다시 바로 가져오는 경우를 순수한 객체상태라고 하는데 이때 연관관계의 주인쪽에만 값을 입력해주면 다른 한쪽엔 반영이 안된다. 1차 캐시를 없애고 조회하는 순간부터 반대편에서도 참조가 가능한 문제가 생기기에 영속성 컨텍스트로 올릴때 부터 양쪽에 값을 넣어주자.

 

 

이때 한가지 팁이 있다면,

연관관계 주인(Member)쪽 엔티티에서 setter 메소드를

public void setTeam(Team team){
    this.team = team;
    team.getMembers.add(this);
}

이처럼 수정하여 주인쪽은 자신의 인스턴스를 한번에 넣어주도록 수정하면 된다.

이때 setter 메소드는 관례적인 setter와 다르므로 메소드 이름을 changeTeam과 같이 이례적으로 바꾸면 더 좋다.

 

 

 

 

 

참고:

더보기

'JPA' 카테고리의 다른 글

[JPA] 다양한 연관관계  (2) 2021.07.24
[JPA] 엔티티 매핑  (0) 2021.07.19
[JPA] 영속성 관리  (0) 2021.07.17
[JPA] JPA란?  (0) 2021.07.15

+ Recent posts