문제

N×N크기의 땅이 있고, 땅은 1×1개의 칸으로 나누어져 있다. 각각의 땅에는 나라가 하나씩 존재하며, r행 c열에 있는 나라에는 A[r][c]명이 살고 있다. 인접한 나라 사이에는 국경선이 존재한다. 모든 나라는 1×1 크기이기 때문에, 모든 국경선은 정사각형 형태이다.

오늘부터 인구 이동이 시작되는 날이다.

인구 이동은 하루 동안 다음과 같이 진행되고, 더 이상 아래 방법에 의해 인구 이동이 없을 때까지 지속된다.

국경선을 공유하는 두 나라의 인구 차이가 L명 이상, R명 이하라면, 두 나라가 공유하는 국경선을 오늘 하루 동안 연다.
위의 조건에 의해 열어야하는 국경선이 모두 열렸다면, 인구 이동을 시작한다.
국경선이 열려있어 인접한 칸만을 이용해 이동할 수 있으면, 그 나라를 오늘 하루 동안은 연합이라고 한다.
연합을 이루고 있는 각 칸의 인구수는 (연합의 인구수) / (연합을 이루고 있는 칸의 개수)가 된다. 편의상 소수점은 버린다.
연합을 해체하고, 모든 국경선을 닫는다.
각 나라의 인구수가 주어졌을 때, 인구 이동이 며칠 동안 발생하는지 구하는 프로그램을 작성하시오.

시간 제한 : 2초
메모리 제한 : 512MB


연합을 이루기 위해 각 나라들에서 상하좌우로 인접한 나라들과의 인구 차이를 비교할 필요가 있습니다.

이때 특정 나라가 속한 연합을 찾기 위해서 그 나라에서 DFS를 통해 인구 차이L명 이상, R명 이하인 나라로 탐색하며 연합을 구했습니다.
연합을 구한 이후 속한 나라들에 연산을 적용하는 방법으로 한 연합에 대한 인구 이동을 끝냈고, 방문하지 않은 모든 나라에 동일한 연산을 적용하였습니다.

연합끼리는 영향을 주지 않기 때문에 원본 map 배열에 바로 값을 업데이트하였습니다. 

public static boolean move() {
    boolean visit[][] = new boolean[map.length][map[0].length];
    isMoved = false;

    // 모든 나라에 대해 수행
    for (int i = 0; i < map.length; i++) {
        for (int j = 0; j < map[0].length; j++) {
            if (!visit[i][j]) {
                dfs(i, j, visit);
            }
        }
    }
    return isMoved;
}

boolean 타입의 visit[][] 배열을 선언하여, 방문하지 않은 모든 나라에 대해 dfs를 시작합니다. 

public static boolean dfs(int i, int j, boolean[][] visit) {
    Stack<Country> stack = new Stack<>();
    List<Country> tmp = new ArrayList<>();
    int groupSum = 0;

    // 처음 방문하는 나라를 stack에 추가
    stack.add(new Country(i, j));
    visit[i][j] = true;

    while (!stack.isEmpty()) {
        Country now = stack.pop();
        tmp.add(now);

        groupSum += map[now.x][now.y];

        for (int m = 0; m < 4; m++) {
            int xx = now.x + mx[m];
            int yy = now.y + my[m];

            if (xx >= 0 && xx < map.length && yy >= 0 && yy < map[0].length && !visit[xx][yy]) {
                if (check(map[now.x][now.y], map[xx][yy])) {
                    visit[xx][yy] = true;
                    isMoved = true;
                    stack.add(new Country(xx, yy));
                }
            }
        }
    }

    if(tmp.size() > 0)
        groupSum /= tmp.size();

    for (Country c : tmp) map[c.x][c.y] = groupSum;

    return isMoved;
}

stack을 사용하여 dfs를 수행하는 부분입니다.
인구 이동이 가능하면서 방문하지 않은 나라를 다음 탐색 나라로 추가합니다. (

이후 인구 이동 발생 여부를 확인하기 위해 isMoved라는 값을 리턴합니다.

public static boolean check(int n1, int n2){

    if (n1 < n2) {
        int tmp = n1;
        n1 = n2;
        n2 = tmp;
    }

    if( ((n1-n2) >= L) && ((n1-n2) <= R) ) return true;

    return false;
}

인구 이동이 가능한지 확인하는 메서드입니다. 

기본적으로 n1이 더 크다고 가정하였기 때문에 n1이 더 작은 경우 swap을 통해 차를 계산합니다.

 

 

https://www.acmicpc.net/problem/16234

 

16234번: 인구 이동

N×N크기의 땅이 있고, 땅은 1×1개의 칸으로 나누어져 있다. 각각의 땅에는 나라가 하나씩 존재하며, r행 c열에 있는 나라에는 A[r][c]명이 살고 있다. 인접한 나라 사이에는 국경선이 존재한다. 모

www.acmicpc.net

'PS > 백준' 카테고리의 다른 글

[ BOJ 2776 ] 암기왕 ( JAVA )  (0) 2023.07.28
[ BOJ 1966 ] 프린터 큐 ( JAVA )  (0) 2023.07.26
[ BOJ 4179 ] 불! ( JAVA )  (0) 2023.05.26
[ BOJ 2075 ] N번째 큰 수 ( JAVA )  (1) 2023.05.16
[ BOJ 9205 ] 맥주 마시면서 걸어가기 ( JAVA )  (0) 2023.04.11

딥러닝 모델과 유저가 대결하는 보드게임 앱 프로젝트를 진행하는 과정에서 발생한 문제입니다. 저는 백엔드를 맡았고 게임의 전반적인 기능들과 게임을 진행하는 API를 구현하였습니다.

 

게임을 복기하는 기능을 구현하기 위해 다음과 같이 마지막 게임 인덱스를 유저 엔티티에 저장하려고 하였고, 게임이 종료되는 메서드가 호출되는 경우 게임과 연관되어있는 유저 엔티티의 최근 게임 인덱스를 수정하는 로직을 추가하였습니다.

@Entity
public class User{
    @Column(name = "last_game_idx")
    private Long lastGameIdx;
	
    public Long updateLastGameIdx(Long lastGameIdx) {
        this.lastGameIdx = lastGameIdx;
        return lastGameIdx;
    }
  }
@Service
public class GameService{	
	public ReturnRankingDto endGame(GameEndDto gameEndDto) {

        gameEndDto.turnListRight();

        Game game = gameRepository.findById(gameEndDto.getGameIdx()).get();
		
        // 승자를 저장함
        game.insertWinner(gameEndDto.getWinner());

        ... 
        
        // 최근 게임을 저장
        User user = game.getUserFk();
        user.updateLastGameIdx(gameEndDto.getGameIdx());
	}
}

발생한 문제점 : JPA의 변경감지가 작동하지 않음

위처럼 GameService 마지막 부분에 lastGameIdx를 수정하면 JPA의 변경감지( Dirty checking )를 통해 Game의 winner와 User 엔티티의 lastGameIdx가 변경될 것이라고 생각하였습니다. 하지만 실제로는 그렇지 않았고, 변경 감지가 발생하지 않았습니다. 

( 업데이트 쿼리 자체가 발생 x )

 

다음과 같이 코드를 user 수정 로직 이후 userRepository.save(user);를 호출하여 해결할 수 있었지만 어떤 이유에서 발생하는 문제인지 궁금했습니다.

Game game = gameRepository.findById(gameEndDto.getGameIdx()).get();
game.insertWinner(gameEndDto.getWinner());
gameRepository.save(game);


// 최근 게임을 저장
User user = game.getUserFk();
user.updateLastGameIdx(gameEndDto.getGameIdx());
userRepository.save(user);

왜 그런 것일까요??

이유를 찾기 전에 변경 감지(Dirty Checking)가 발생하는 조건을 알아보겠습니다.

  • 기본적으로 JPA에서는 엔티티 매니저(EntityManager)를 사용하여 영속성 컨텍스트(PersistenceContext)에 엔티티를 저장합니다. 이렇게 영속성 컨텍스트에 저장된 엔티티는 보통 트랜잭션을 커밋하는 순간 DB에 반영되고 이를 플러시(Flush)라고 합니다.  
  • JPA는 엔티티를 영속성 컨텍스트에 보관할 때 최초 상태를 복사하여 저장해두고 (SnapShot) 트랜잭션이 커밋되는 순간(Flush 시점)에 스냅샷엔티티비교하여 변경된 엔티티를 찾아 수정 쿼리를 생성하여 쓰기 지연 SQL 저장소에 추가합니다.    

저의 코드에서 어느 부분이 위 조건을 만족하지 못한 것인지 확인해보겠습니다. 

프로젝트에는 Spring data jpa를 사용하였고, Spring data jpa는 기본적으로 JpaRepository를 상속받으며 구현체로  SimpleJpaRepository를 사용합니다. 

@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {

   private static final String ID_MUST_NOT_BE_NULL = "The given id must not be null";

   private final JpaEntityInformation<T, ?> entityInformation;
   private final EntityManager em;
   private final PersistenceProvider provider;

   private @Nullable CrudMethodMetadata metadata;
   ...
}

위와 같이 SimpleJpaRepository는 내부에 EntityManager를 사용하고 있습니다. 

트랜잭션의 범위 또한 JPA만 사용하였을 때와 달리 Spring data jpa가 자동으로 지정하고 있었고 저는 이러한 부분을 잘 모른 상태로 사용하여 문제가 발생했던 것입니다.

 

1) JPA 만 사용한 코드 - 트랜잭션 시작, 커밋을 명시하여 명확하게 알 수 있음.

public void saveUser(){
	EntityTransaction transaction = em.getTransaction();
	transaction.begin(); // 트랜잭션 시작
	.
	em.persist(user)
	.
	transaction.commit() // 트랜잭션 커밋
}

2) Spring data jpa를 사용한 코드 - 트랜잭션이 어디까지 적용되는 건지 알 수 없음

public void saveUser(){
	userRepository.save(user);
}

SimpleJpaRepository 내부의 메서드를 살펴보면 다음과 같습니다.

@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {

   private static final String ID_MUST_NOT_BE_NULL = "The given id must not be null";

   private final JpaEntityInformation<T, ?> entityInformation;
   private final EntityManager em;
   private final PersistenceProvider provider;
@Transactional
@Override
public <S extends T> S save(S entity) {

   Assert.notNull(entity, "Entity must not be null");

   if (entityInformation.isNew(entity)) {
      em.persist(entity);
      return entity;
   } else {
      return em.merge(entity);
   }
}

즉, 클래스와 메서드 단에서 @Transactional을 사용하여 repository 내부 메서드 단위에만 트랜잭션 범위가 적용되는 것이었습니다. 제가 이후 유저를 수정한 부분은 이미 트랜잭션의 범위 바깥으로 변경 감지 조건에 부합하지 못했던 것이었습니다. 

( @Transactional에는 Propagation이라는 트랜잭션의 전파 레벨을 지정하는 속성이 있습니다. 기본 값은 Required로 기본적으로 부모 트랜잭션의 범위를 따르며 없는 경우 트랜잭션을 새로 생성하는 레벨입니다. )

Propagation propagation() default Propagation.REQUIRED;

 

트랜잭션의 범위를  따라서 @Transactional을 생성하여 트랜잭션의 범위를 Service 단의 메서드 범위로 확장하여 이를 해결할 수 있었습니다.

@Transactional
public ReturnRankingDto endGame(GameEndDto gameEndDto) {
		
    Game game = gameRepository.findById(gameEndDto.getGameIdx()).get();
	...
    // 최근 게임을 저장
    User user = game.getUserFk();
    user.updateLastGameIdx(gameEndDto.getGameIdx());
   }

다음과 같이 game에 save를 명시해주는 경우에도 save 메서드 내부의 flush()를 통해 변경 감지가 발생합니다. 하지만 가독성 부분에서 이해하기 어렵고, 굳이 save 메서드를 따로 호출해야하는 번거로움이 있습니다. 

Game game = gameRepository.findById(gameEndDto.getGameIdx()).get();

game.insertWinner(gameEndDto.getWinner());
int winner = gameEndDto.getWinner();
 
.
.
.

// 최근 게임을 저장
User user = game.getUserFk();
user.updateLastGameIdx(gameEndDto.getGameIdx());
gameRepository.save(game);

실제로 하이버네이트의 구현체인 SessionImpl에 체크포인트를 찍어보면 doFlush()가 실행되는 것을 확인할 수 있습니다.

public class SessionImpl
      extends AbstractSharedSessionContract
      implements Serializable, SharedSessionContractImplementor, JdbcSessionOwner, SessionImplementor, EventSource,
            TransactionCoordinatorBuilder.Options, WrapperOptions, LoadAccessContext {
   private static final EntityManagerMessageLogger log = HEMLogging.messageLogger( SessionImpl.class );

   // Defaults to null which means the properties are the default
   // as defined in FastSessionServices#defaultSessionProperties
   private Map<String, Object> properties;

   private transient ActionQueue actionQueue;
   private transient StatefulPersistenceContext persistenceContext;
   .
   .
   .
   	private void managedFlush() {
		if ( isClosed() && !waitingForAutoClose ) {
			log.trace( "Skipping auto-flush due to session closed" );
			return;
		}
		log.trace( "Automatically flushing session" );
		doFlush();
	}
    }

불!


문제

지훈이는 미로에서 일을 한다. 지훈이를 미로에서 탈출하도록 도와주자!

미로에서의 지훈이의 위치와 불이 붙은 위치를 감안해서 지훈이가 불에 타기전에 탈출할 수 있는지의 여부, 그리고 얼마나 빨리 탈출할 수 있는지를 결정해야한다.

지훈이와 불은 매 분마다 한칸씩 수평또는 수직으로(비스듬하게 이동하지 않는다) 이동한다.

불은 각 지점에서 네 방향으로 확산된다.

지훈이는 미로의 가장자리에 접한 공간에서 탈출할 수 있다.

지훈이와 불은 벽이 있는 공간은 통과하지 못한다.

입력

입력의 첫째 줄에는 공백으로 구분된 두 정수 R과 C가 주어진다. 단, 1 ≤ R, C ≤ 1000 이다. R은 미로 행의 개수, C는 열의 개수이다.

다음 입력으로 R줄동안 각각의 미로 행이 주어진다.

각각의 문자들은 다음을 뜻한다.

  • #: 벽
  • .: 지나갈 수 있는 공간
  • J: 지훈이의 미로에서의 초기위치 (지나갈 수 있는 공간)
  • F: 불이 난 공간

J는 입력에서 하나만 주어진다.

출력

지훈이가 불이 도달하기 전에 미로를 탈출 할 수 없는 경우 IMPOSSIBLE 을 출력한다.

지훈이가 미로를 탈출할 수 있는 경우에는 가장 빠른 탈출시간을 출력한다.

시간 제한 메모리 제한 제출 정답 맞힌 사람 정답 비율
1 초 256 MB 39000 8622 5840 20.875%

구현

- 이 문제는 지훈이와 불을 동시에 움직여야 하는 문제입니다. 

 

지훈이는 오직 '.'으로만 움직일 수 있습니다. ( 불이 있는 칸이나 벽으로 이동할 수 없음)

그러나 불의 경우는 벽을 제외하면 지훈이가 있는 곳으로도 번질 수 있기 때문에, 지훈이를 먼저 이동시킨 후 불을 이동시켰습니다!

 

지훈이가 이동할 때 만약 배열 밖으로 이동할 수 있다면 탈출 처리를 하였고, 밖으로 나가지 못하고 모든 탐색이 끝나면 탈출하지 못한 것으로 처리하였습니다.

 

( 처음에는 불의 시작지점도 1개만 있다고 가정하고 풀었는데 틀렸고, 불의 시작지점을 여러개로 가정한 후 AC를 받았습니다 ㅠ)     

 

제출 결과


소스코드

더보기
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.*;

/**
 * 불!
 */
public class BOJ4179 {

    static int mx[] = {0, -1, 0, 1};
    static int my[] = {-1, 0, 1, 0};

    static int r, c;
    static char map[][];
    static int visit[][];
    static int INF = 150000; // R,C의 최댓값이 1000이므로 1000 * 1000 보다 크게 넉넉잡아 설정했습니다

    static List<Info> fire = new ArrayList<>(); // 불 시작지점
    static Info J; // 지훈 시작지점
    
    public static void main(String[] args) throws IOException {
        BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(System.in));
        String[] s = bufferedReader.readLine().split(" ");

        r = Integer.parseInt(s[0]);
        c = Integer.parseInt(s[1]);

        map = new char[r][c];
        visit = new int[r][c];

        for (int i = 0; i < r; i++) {
            Arrays.fill(visit[i], INF);
            String input = bufferedReader.readLine();
            
            for (int j = 0; j < c; j++) {
                map[i][j] = input.charAt(j);

				// 불의 시작지점을 리스트에 추가
                if (map[i][j] == 'F') {
                    fire.add(new Info(i, j, true));
                }

                if (map[i][j] == 'J') {
                    J = new Info(i, j, false);
                    visit[i][j] = 0;
                }
            }
        }

        int result = escape();

        System.out.println((result==-1) ? "IMPOSSIBLE": result);
    }

    static int escape() {
        Queue<Info> queue = new LinkedList<>();
			
        // 지훈이부터 움직이도록 먼저 큐에 넣습니다.
        queue.add(J);
		
        for (Info info : fire) {
            queue.add(info);
        }

        while (!queue.isEmpty()) {
            Info cur = queue.poll();

            // 지훈이 이동
            if (!cur.isFire) {
				
                if(map[cur.x][cur.y] == 'F') continue;

                for (int i = 0; i < 4; i++) {
                    int xx = cur.x + mx[i];
                    int yy = cur.y + my[i];

                    // 탈출성공
                    if (xx < 0 || xx >= r || yy < 0 || yy >= c)
                    {
                        return visit[cur.x][cur.y] + 1;
                    }

                    // 이동 가능한 공간이면서 처음 가는 곳이면 다음 칸으로 이동
                    if (xx >= 0 && xx < r && yy >= 0 && yy < c && map[xx][yy] == '.' && visit[xx][yy] == INF)
                    {
                        visit[xx][yy] = visit[cur.x][cur.y] + 1;
                        map[xx][yy] = 'J';
                        queue.add(new Info(xx, yy, false));
                    }

                }
            }
            
            // 불 이동
            else {
                for (int i = 0; i < 4; i++) {
                    int xx = cur.x + mx[i];
                    int yy = cur.y + my[i];

                    // 얘는 벽이나 불 아니면 한칸씩 번집니다.
                    if (xx >= 0 && xx < r && yy >= 0 && yy < c && map[xx][yy] != '#' && map[xx][yy] != 'F')
                    {
                        map[xx][yy] = 'F';
                        queue.add(new Info(xx, yy, true));
                    }
                }
            }
        }

        return -1;
    }

    static class Info {

        int x, y;
        boolean isFire;

        public Info(int x, int y, boolean isFire) {
            this.x = x;
            this.y = y;
            this.isFire = isFire;
        }
    }

}

https://www.acmicpc.net/problem/4179

 

4179번: 불!

입력의 첫째 줄에는 공백으로 구분된 두 정수 R과 C가 주어진다. 단, 1 ≤ R, C ≤ 1000 이다. R은 미로 행의 개수, C는 열의 개수이다. 다음 입력으로 R줄동안 각각의 미로 행이 주어진다. 각각의 문자

www.acmicpc.net


 

 

학교에서 프로젝트를 진행하면서 웹 사이트를 구축하고 있습니다!

저는 백엔드를 담당하여 Spring Security + OAuth2.0 + JWT를 이용한 소셜로그인 기능을 구현하였는데요.

생각보다 소셜 로그인이 정말 복잡하고 어렵다는 것을 알게 되었습니다..ㅎ  

 

버전 정보

Spring Boot : 3.0.x

Spring Security : 6.x

 

Spring에서는 인증인가를 쉽게 구현할 수 있도록 Spring Security라는 하위 프로젝트를 제공하고 있습니다.

(근데 쉽지 않습니다.)

 스프링 시큐리티의 핵심은 Security Filter Chain으로 기본적으로 여러가지 보안 필터를 제공하며 요청에 대한 필터링을 수행합니다.이 필터 체인에 저희가 구현한 커스텀 필터를 추가하여 인증, 인과 과정을 Customizing 할 수 있습니다!

 

스프링 시큐리티는 기본적으로 Form 기반의 로그인 ( 아이디, 비밀번호가 넘어오면 그걸 기반으로 ) 을 수행하며 이때 Security Filter 중에서 UsernamePasswordAuthenticationFilter 필터가 작용하여 인증 프로세스를 수행합니다.

저희는 Form기반의 로그인이 아닌 JWT를 이용한 토큰 기반의 로그인을 사용할 것이기 떄문에 UsernamePasswordAuthenticationFilter 필터의 앞에 저희의 커스텀 JWT 인증 필터를 만들어서 인증 과정을 대신 수행하도록 하여 인증 성공UsernamePasswordAuthenticationFilter를 통과하도록 할 것 입니다.

 

OAuth2.0의 경우 Authorization Code Grant 방식을 사용하였으며, OAuth2.0 동작 방식까지 포스팅하면 내용이 너무 많아져서 검색한번 해보시는 것을 추천드립니다! 


* https://developers.kakao.com/에서  애플리케이션을 등록한 상태여야 합니다.

 

로그인 프로세스

  1. 최초 로그인 요청
    - 사용자가 저희 웹사이트에 로그인을 요청하는 경우입니다! ( 카카오 로그인 버튼을 누르는 경우)
  2. 소셜 로그인 요청
    -  클라이언트(프론트엔드 서버)는 Redirect URI, Client ID, Scope 등을 담아 Resource Server에 로그인 요청을 보냅니다.
  3. 소셜 로그인 페이지 제공
    - Redirect URI, Client ID 등을 검증하고 유효한 경우 사용자에게 로그인 페이지를 제공합니다.
  4. 소셜 로그인 ID / PW 제공 
    - 사용자는 로그인 페이지에 로그인을 합니다.
  5. Authorization Code 발급
    - 사용자가 로그인에 성공하면, Authorization code를 발급합니다.
  6. Redirect URI로 리다이렉트
    - 위에서 지정한 Redirect URI로 리다이렉트 합니다.
  7. 인가 코드 전달
    - 프론트엔드에서 백엔드(API 서버)로 Authorization code를 담아 Post 요청을 보냅니다.
    ( POST /oauth2/login/kakao/{Authorizationcode} )
  8. Access token 요청 ( 여기서부터 구현했습니다 )
    - 해당 요청을 컨트롤러에서 받고, OAuth2TokenService에서 인가 코드를 이용하여 Authorization ServerAccess token을 요청합니다.
  9. Access token 발급
    - 넘어온 요청 정보들 ( 인가코드, 아까 사용한 redirec uri 등 )을 검증하고 성공시 access token을 발급해줍니다.
  10. User Information 요청 
    - Access token을 발급받았으니 이 토큰으로 로그인한 유저가 동의한 Scope에 있는 소셜 정보들을 불러올 수 있습니다. 제가 구현한 경우에는 이름, 이메일, 식별자(OauthId) 정도만 가져왔습니다!
  11. User Information 응답
    - 요청한 정보를 담아 응답해줍니다. ( JSON )
  12. User 정보 전달 - UserRepository에 받아온 사용자 정보를 전달하여 처리합니다.
  13. User 저장 or 이미 있으면 Update
    - DB에 유저 정보가 없으면 저장하고 만약 이미 있으면 (변경사항이 있을 수도 있으므로 반영을 위해 )수정하는 쿼리를 날립니다! 
  14. JwtTokenProvider에게 받아온 유저 정보를 넘겨줍니다.
    - 유저 정보를 사용하여 JWT 토큰을 생성합니다  
  15. JWT 토큰 발급 ( ~ 17 ) 
    - JWT 토큰을 발급하여 응답합니다.

OAuth2TokenService

    public OauthToken getKakaoAccessToken(String authorizationCode)  {

        // Set URI
        URI uri = UriComponentsBuilder
                .fromUriString("https://kauth.kakao.com")
                .path("/oauth/token")
                .encode()
                .build()
                .toUri();

        RestTemplate restTemplate = new RestTemplate();

        // Set header
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        headers.add("Accept", "application/json");

        // Set parameter
        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("grant_type", "authorization_code");
        params.add("client_id", KAKAO_REST_API_KEY);
        params.add("redirect_uri", KAKAO_REDIRECT_URI);
        params.add("code", authorizationCode);

        // Set http entity
        RequestEntity<MultiValueMap<String, String>> requestEntity = RequestEntity.post(uri).headers(headers).body(params);
        ResponseEntity<String> responseEntity;

        try {
            // 토큰 받기
            responseEntity = restTemplate.exchange(requestEntity, String.class);
        } catch (Exception e) {
            log.error("[kakao] access token 발급 실패 ");
            throw (new RuntimeException("authorization code가 잘못되었습니다."));
        }

        // JSON String to OauthToken
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        OauthToken oauthToken;

        try {
            oauthToken = objectMapper.readValue(responseEntity.getBody(), OauthToken.class);
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }

        return oauthToken;
    }
    public OAuthAttributes loadKakao(String accessToken, String refreshToken) {
 	        RestTemplate restTemplate = new RestTemplate();
        // 유저 정보 불러오기
        ResponseEntity<String> responseEntity = null;
        try {
            responseEntity = restTemplate.exchange(requestEntity, String.class);
        } catch (RuntimeException e) {
           log.error("[kakao] loadKakao 유저 정보 불러오기 실패");
            throw (new RuntimeException());
        }
        // JSON String to Object
        ObjectMapper objectMapper = new ObjectMapper();
        Map<String, Object> attributes;
        try {
            attributes = objectMapper.readValue(responseEntity.getBody(), HashMap.class);
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }

        return OAuthAttributes.of(SocialType.KAKAO, "", attributes);
    }
  • access token을 이용하여 사용자 정보를 불러오는 부분입니다! 
  • Map<String , Object> 형태로 사용자 정보를 반환합니다.  

OAuthAttributes

public class OAuthAttributes {
	private Map<String, Object> attributes;
	private String oAuthId;     // OAuth2.0에서 사용하는 PK
	private String nickName;    // 닉네임 정보
	private String email;       // 이메일 주소
	private SocialType socialType;
    }
  • 각각의 Resource Server ( 카카오, 구글, 네이버 등) 넘어온 데이터로부터 뽑아낼 정보들입니다.
   private static OAuthAttributes ofKakao(String userNameAttributeName, Map<String, Object> attributes) {

        Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
        Map<String, Object> profile = (Map<String, Object>) kakaoAccount.get("profile");

        String nickname = (String) profile.get("nickname");
        String email = (String) kakaoAccount.get("email");
        
        // 리소스 서버별 사용자 식별하는 값입니다.
        String oAuthId = String.valueOf(attributes.get(userNameAttributeName));

        return OAuthAttributes.builder()
                .oAuthId(oAuthId)
                .email(email)
                .nickName(nickname)
                .attributes(attributes)
                .socialType(SocialType.KAKAO)
                .build();
    }
  • Resource Server마다 속성 이름이나 넘겨주는 데이터 구조 등이 달라서 통일화 하기 위한 클래스입니다!
    참고 자료 (스프링 부트와 AWS로 혼자 구현하는 웹 서비스 | 이동욱)

JwtTokenProvider

   public String createToken(String userOAuthId) {
        log.info("[createToken] userOAuthId = {}", userOAuthId);
        Claims claims = Jwts.claims().setSubject(userOAuthId);
        Date now = new Date();

        String token = Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + tokenValidMillisecond))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();

        return token;
    }
  • JwtTokenProvider는 JWT 토큰의 생성, 유효성확인, Authentication 생성 등 토큰과 관련된 모든 처리를 담당하는 모듈입니다.
  • OAuthId를 받아 그 정보를 토큰의 subject로 지정하고, 토큰을 발급하는 코드입니다. 
    public boolean validateToken(String token) {
        log.info("[validateToken] 토큰 유효성 확인");
        try {
            Claims body = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody();
            return !body.getExpiration().before(new Date());
        } catch (Exception e) {
            log.error("[validateToken] 유효하지 않은 토큰입니다.");
            return false;
        }

    }
  • 토큰이 넘어오면 유효기간을 체크하는 기능입니다.
    public Authentication getAuthentication(String token) {
        log.info("[getAuthentication] 토큰 기반 정보 조회 시작");
        String userName = getUserName(token);
        UserDetails userDetails = new CustomUserDetails(userName);
        return new UsernamePasswordAuthenticationToken(userDetails, "", null);
    }
  • 토큰에서 유저 정보를 추출하여 Authentication 객체를 생성합니다. 이 Authentication 객체는 인증 정보를 담고 있으며 Spring Security의 SecurityContextHolder라는 인증 저장소에 담기게 됩니다!

SecurityConfig

@Configuration
@EnableWebSecurity
@EnableWebMvc
public class SecurityConfig{

    private final JwtTokenProvider jwtTokenProvider;
    @Autowired
    public SecurityConfig(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }

    // HttpSecurity 설정
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .httpBasic().disable()  // UI 사용하는 것을 기본값으로 가진 시큐리티 설정 비활성화
                .formLogin().disable()

                .csrf().disable()       // CSRF 보안 비활성화

                .sessionManagement()    // 세션 관리 정책 설정
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)     // JWT로 인증, 인증에서 세션은 사용하지 않음

                .and()
                // swagger 설정
                .authorizeHttpRequests().requestMatchers("v3/api-docs/**", "/swagger-resources/**", "/swagger-ui*/**",
                        "/webjars/**", "/swagger/**").permitAll()

                .and()
                // 인증이 필요한 요청
                .authorizeHttpRequests().requestMatchers("/user/detail/**").authenticated()

                .anyRequest().permitAll()

                .and()
                // 접근 권한이 없는 경우 발생하는 예외 처리
                .exceptionHandling().accessDeniedHandler(new CustomAccessDeniedHandler())
                .and()
                // 인증 실패시 발생하는 예외 처리
                .exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint())

                .and()
                // 인증을 담당하는 UsernamePasswordAuthenticationFilter 앞에 커스텀 필터를 배치하여 커스텀 필터가 인증 과정을 수행하도록 하였습니다.
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

}
  • 스프링 시큐리티 필터에 대한 설정입니다!
  • 인증이 필요한 요청에 대해서는 authenticated를 적용해 인증된 접근인지 검증합니다.
  • accessDeniedHandler() : 접근 권한이 없는 리소스에 접근할시 해당 클래스에서 예외처리

JwtAuthenticationFilter 

public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private JwtTokenProvider jwtTokenProvider;

    public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String token = jwtTokenProvider.resolveToken(request);
        log.info("[JwtAuthenticationFilter] token 값 추출. token : {}", token);

        log.info("token 유효성 검증시작");
        if (token != null && jwtTokenProvider.validateToken(token)) {
            log.info("token 인증성공");
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

}
  • 직접 구현한 커스텀 필터입니다! OncePerRequestFilter 또는 GenericFilterBean를 상속받아 쉽게 구현이 가능합니다. OncePerRequestFilter는 이름 그대로 요청당 한번만 수행되는 필터입니다.
  • 토큰을 검증하고, 토큰이 있는 경우 Authentication 객체를 SecurityConyextHolder에 추가하여 인증을 수행합니다.
  • 커스텀 필터에 @Component가 없는 이유 : 해당 필터를 스프링 빈으로 등록하게 되면, 스프링 시큐리티의 범위에서 벗어나 스프링 자체에서 필터 역할을 하기 때문에 저희가 지정한 요청 제어 등이 제대로 수행되지 않습니다. 따라서 해당 클래스는 스프링 빈으로 등록하지 않습니다.

이번 프로젝트를 진행하며 처음으로 Spring Security, OAuth2.0, JWT 기능들을 사용해봤는데요.  정말 오래걸리고 힘들었지만 찾아보며 구현하는 재미가 있었던 것 같습니다! 읽어주셔서 감사합니다 

+ Recent posts