[내일배움캠프 본캠프] 9일 차 TIL 소셜 로그인(OAuth), 단위 테스트, AOP, 예외 처리

2026. 2. 18. 23:11·내배캠

9일 차 TIL

스프링 심화과정을 수강 완료했다.
소셜 로그인부터 단위/통합 테스트, AOP, 예외 처리까지 배워봤다.
처음 다루는 내용이 많아서 크게 크게 흐름을 이해하는 식으로 공부했다.

 

내일부터 목,금,토,일에 걸쳐서 MSA를 우선으로 수강하면 될 것 같다.

소셜 로그인(카카오)

소셜 로그인은 사용자에게 모든 웹사이트에서 회원가입을 하고
ID와 비밀번호를 기억하는 번거로움을 줄이고자 등장한 것이다.

또한 서버측면에서도 회원관리비용이 줄어든다.

OAuth란?

소셜 로그인 구현을 위한 접근 위임을 위한 개방형 표준으로써,
사용자가 애플리케이션(서버)에게 모든 권한을 넘기지 않고
사용자 대신 서비스를 이용할 수 있게 해주는 HTTP 기반 보안 프로토콜이다.

 

그중 카카오의 소셜 로그인 기능을 이용했다.


네이버와 마찬가지로 앱 등록, 키 발급은 동일하다.
근데 여기서는 사용자가 카카오 서버로 요청을 보내고
카카오 서버에서 처리 후 반환할 Redirect URL을 설정해야 했다.


즉, Redirect URL이 우리가 개발할 서버에서 컨트롤러 엔드포인트가 된다.
인가 코드인 code값을 보내준다.
ex) http://localhost:8080/api/user/kakao/callback?code=~~~

Client가 서버로 요청을 보내는 예시)

location.href='https://kauth.kakao.com/oauth/authorize?client_id={REST_API_KEY}&redirect_uri=http://localhost:8080/api/user/kakao/callback&response_type=code'

카카오로 로그인하기 버튼 클릭 시 위 경로로 GET 요청을 보내는 모습이다.

response_type은 카카오에서 code로 고정하도록 설정했다.

client_id는 발급받은 REST API 키 값
redirect_uri는 설정한 리다이렉트 URL

 

서버에서 카카오로 로그인 기능 구현

이제 컨트롤러에서 설정한 리다이렉트 URL로 인가 토큰을 받는다.

@GetMapping("/user/kakao/callback")
    public String kakaoLogin(@RequestParam String code, HttpServletResponse response) throws JsonProcessingException {
    // 버튼 클릭 -> 컨트롤러 -> 서비스 -> kakaoLogin(code) 메서드 호출 ( 카카오 서버로 요청 )
    // 카카오에서 처리 후 다시 컨트롤러로 리다이렉트 -> 반환 된 인증 코드(JWT토큰)를 token 변수에 저장
    String token = kakaoService.kakaoLogin(code);
    
    // 변수 token에 저장된 JWT 토큰을 브라우저 쿠키에 저장
    Cookie cookie = new Cookie(JwtUtil.AUTHORIZATION_HEADER, token.substring(7)); // Barear 제거
    cookie.setPath("/");
    response.addCookie(cookie);
	
    return "redirect:/";
}


그러면, token에는 kakaoLogin(code)의 결과로 JWT 토큰이 저장될 것이다.
이 토큰을 브라우저의 쿠키로 저장하면 된다.

public String kakaoLogin(String code) throws JsonProcessingException {
    // 1. "인가 코드"로 "액세스 토큰" 요청
    String accessToken = getToken(code);

    // 2. 토큰으로 카카오 API 호출 : "액세스 토큰"으로 "카카오 사용자 정보" 가져오기
    KakaoUserInfoDto kakaoUserInfo = getKakaoUserInfo(accessToken);

    return null; // JWT 토큰생성 메서드로 변경할 예정
}


이제 return문에서 null 대신 JWT 토큰 생성 메서드가 들어갈 것이다.
그래서 처음 로그인하는 유저면,
회원 가입을 진행해 주고,
회원 정보와 회원권한을 파라미터로 넘겨줘서 JWT를 생성하도록 구현하면 된다.


즉 API 흐름은 다음과 같다.

// defalut 경로
https://kauth.kakao.com/

1. 프론트에서
/oauth/authorize 에 쿼리 스트링으로 client_id와 redirect_uri로 요청 => code
2. 컨트롤러에서 서비스 메서드를 실행
서비스레이어에서 getToken(code) 호출
1번에서 얻은 code를 body에 담아 "/oauth/token"로 요청 => 키 "access_token"의 값
3. 서비스레이어에서 getKakaoUserInfo(accessToken) 호출
2번에서 얻은 accessToken으로 "/v2/user/me"로 요청 헤더에 담아 사용자 정보를 받아온다.
4. 3번에서 얻은 사용자 정보로 JWT를 컨트롤러로 반환
5. Client로 JWT를 전달

3번까지 진행한 후 4~5번의 코드

public String kakaoLogin(String code) throws JsonProcessingException {
    // 1. "인가 코드"로 "액세스 토큰" 요청
    String accessToken = getToken(code);

    // 2. 토큰으로 카카오 API 호출 : "액세스 토큰"으로 "카카오 사용자 정보" 가져오기
    KakaoUserInfoDto kakaoUserInfo = getKakaoUserInfo(accessToken);

    // 3. 필요시에 회원가입
    User kakaoUser = registerKakaoUserIfNeeded(kakaoUserInfo);

    // 4. JWT 토큰 반환
    String createToken = jwtUtil.createToken(kakaoUser.getUsername(),kakaoUser.getRole());
    return createToken;
}

 

// 3. 필요시에 회원가입

소셜 로그인 시, 2가지 방법으로 회원가입을 진행할 수 있다.
1. 기존의 회원(User) 테이블 사용 + 카카오 정보 추가

2. 소셜 로그인용(kakao) 테이블 새로 생성

 

여기선 1번으로 진행했다.

 

기존 Form을 통해서 회원가입한 User과
새로 카카오 로그인한 유저의 email을 비교해서

이미 있는 email이면? kakao Id만 User 테이블에 추가하고
없는 email이면? 새로 회원가입을 진행하면 된다.

 

JPA의 save메서드는 이미 있는 엔티티면 merge
없는 엔티티면 persist를 진행하기에

if (kakaoUser == null) {
    // 카카오 사용자 email 동일한 email 가진 회원이 있는지 확인
    String kakaoEmail = kakaoUserInfo.getEmail();
    User sameEmailUser = userRepository.findByEmail(kakaoEmail).orElse(null);
    if (sameEmailUser != null) {
        // 기존 회원정보에 카카오 Id 추가
    } else {
        // 신규 회원가입
        // password: random UUID
    }
    userRepository.save(kakaoUser);
}
return kakaoUser;

조건문 진행 후, save로 persist or merge를 진행한다.

 

이때 신규 회원가입 진행 시, 기존의 이메일과 같기에
password가 같다면?
Form 로그인으로 로그인이 되는 문제가 발생할 수 있다.
=> 소셜 로그인으로 회원가입 시,  password를 UUID 설정해 사용자가 모르게 비밀번호를 모르게 함.

 

// 4. JWT 토큰 반환

회원가입된 유저의 정보로 JWT를 발행해 준다.

단위 테스트

기본적으로 Srping에서 제공하는 JUnit5로 단위테스트를 진행할 수 있다.

Assertion 라이브러리를 통해 예측값과 실제값을 비교할 수 있다.

 

Given-When-Then란?

Given - 미리 값 설정
When - 실행
Then - 예상

 

Mockito란?

테스트에서 진행할 Mock 객체를 쉽게 가져올 수 있는 라이브러리다.

 

@Mock은 가짜 객체를 가져와 준다.
이렇게 생성된 객체는 실제 DB작업을 하지는 않는다.

 

- @Mock으로 생성된 가짜 객체 ProductRepository

@Mock
ProductRepository productRepository;

 

실제 DB를 조회하진 못하다 보니 DB의 값들은 직접 만들어서 사용해야 한다.

ProductRequestDto requestProductDto = new ProductRequestDto(
                "Apple <b>맥북</b> <b>프로</b> 16형 2021년 <b>M1</b> Max 10코어 실버 (MK1H3KH/A) ",
                "https://shopping-phinf.pstatic.net/main_2941337/29413376619.20220705152340.jpg",
                "https://search.shopping.naver.com/gate.nhn?id=29413376619",
                3515000
        );

즉, 실제 테스트할 영역 빼고는 만들어 줘야 한다. ( Given 부분 )

통합 테스트

단위 테스트는 하나의 모듈에서 진행하지만 통합 테스트는 모듈 간의 테스트가 가능하다.
기본적으로 단위테스트에서 Spring은 동작되지 않지만
@SpringBootTest를 사용하면 가능하다.

통합 테스트에서는 이 애노테이션을 사용해서 모듈 간 테스트를 진행한다.
따라서 JPA의 insert, update 등의 메서드를 테스트해볼 수 있다.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) // 서버의 PORT 를 랜덤으로 설정합니다.
@TestInstance(TestInstance.Lifecycle.PER_CLASS) // 테스트 인스턴스의 생성 단위를 클래스로 변경합니다.
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class ProductServiceIntegrationTest {
    @Autowired
    ProductService productService;
    @Autowired
    UserRepository userRepository;

    User user;
    ProductResponseDto createdProduct = null;
    int updatedMyPrice = -1;

    @Test
    @Order(1)
    @DisplayName("신규 관심상품 등록")
    void test1() {
        // given
        String title = "Apple <b>에어팟</b> 2세대 유선충전 모델 (MV7N2KH/A)";
        String imageUrl = "https://shopping-phinf.pstatic.net/main_1862208/18622086330.20200831140839.jpg";
        String linkUrl = "https://search.shopping.naver.com/gate.nhn?id=18622086330";
        int lPrice = 173900;
        ProductRequestDto requestDto = new ProductRequestDto(
                title,
                imageUrl,
                linkUrl,
                lPrice
        );
        user = userRepository.findById(1L).orElse(null);

        // when
        ProductResponseDto product = productService.createProduct(requestDto, user);

        // then
        assertNotNull(product.getId());
        assertEquals(title, product.getTitle());
        assertEquals(imageUrl, product.getImage());
        assertEquals(linkUrl, product.getLink());
        assertEquals(lPrice, product.getLprice());
        assertEquals(0, product.getMyprice());
        createdProduct = product;
    }
}

 

컨트롤러를 테스트할 때는?

@WebMvcTest(
        controllers = {UserController.class, ProductController.class},
        excludeFilters = {
                @ComponentScan.Filter(
                        type = FilterType.ASSIGNABLE_TYPE,
                        classes = WebSecurityConfig.class
                )
        }
)
class UserProductMvcTest {
	// ...
}

컨트롤러 정보를 @WebMvcTest로 받아올 수 있다.
Security의 Filter도 연결해서 사용할 수 있다.

메서드 실행 시간 측정하기

- 현재 시간의 정보 가져오는 메서드

System.currentTimeMillis();

시작 시간 = 컨트롤러에 진입했을 때 현재 시간

종료 시간 = 메서드 실행 후 현재 시간
소요 시간 = 종료시간 - 시작시간


이렇게 코드가 실행되는데 얼마나 걸렸는지 측정할 수 있다.

Spring AOP

핵심 기능과 부가 기능을 분리하자.

어드바이스는 부가 기능의 실행 위치를 핵심기능 기준으로 수행 전, 후, 전후, 오류발생 시, 반환 시 등 언제 수행할지 정할 수 있다.
포인트컷은 부가 기능을 어디에서 수행할지를 정하는 것이다.

 

어드바이스 종류
- @Around : 핵심기능 수행 전과 후

- @Before : 핵심기능 호출 전 ( Validation 수행 등 )

- @After : 핵심기능 수행 성공/실패와 상관없이 동작 ( try-catch의 finally()처럼 동작 )

- @AfterReturning : 핵심기능 호출 성공 시 ( 메서드의 return 값 사용 가능 )

- @AfterThrowing : 핵심기능 호출 실패 시, 예외가 발생한 경우에 동작 ( 예외 발생 시, 추가 작업 )

 

포인트컷

- 어드바이스의 파라미터로 Expression을 넣는다.

포인트컷 Expression 형식

execution(modifiers-pattern? return-type-pattern declaring-type-pattern? 
method-name-pattern(param-pattern) throws-pattern?)

즉,

접근 제어자 : public , private
반환 타입 : void, String, List <Stirng> , *
클래스명 : com.~~~~~~.controller.*
-> .*은 패키지의 모든 클래스
-> ..*은 패키지의 하위 패키지에 모든 클래스
함수명 : add* ( add로 시작하는 모든 함수에 적용 )

(파라미터 패턴) : (), (*), (..)
=> 인수 없음, 1개, 0~n개


의 순서이다. 

 

- 적용 예시

@Around("execution(public * com.sparta.myselectshop.controller..*(..))")
public Object execute(ProceedingJoinPoint joinPoint) throws Throwable { ... }

접근 제어자 = public

반환 타입 = * ( 상관없음 )
클래스명 = com.sparta.myselectshop.controller..* ( ..*은 패키지의 하위 패키지의의 모든 클래스에 적용 )

(파라미터 패턴) = (..)       ( 인수 0 ~ n개, 타입 상관 x )

 

이때 포인트컷을 여러 개를 결합하고 싶다면?

메서드를 만들고 위에 @Pointcut을 붙인다. ( 변수에 값 저장하듯, 적용 위치를 메서드에 저장한다는 느낌? )

핵심 기능을 하는 메서드 위에 어드바이저 애노테이션을 붙이고,
파라미터로 @Pointcut을 붙인 메서드명을 적는다.

 

- @Pointcut 결합 예시

@Component
@Aspect
public class Aspect {
	@Pointcut("execution(* com.sparta.myselectshop.controller.*.*(..))")
	private void forAllController() {}

	@Pointcut("execution(String com.sparta.myselectshop.controller.*.*())")
	private void forAllViewController() {}

	@Around("forAllContorller() && !forAllViewController()")
	public void saveRestApiLog() {
		...
	}

	@Around("forAllContorller()")
	public void saveAllApiLog() {
		...
	}	
}

&&,! 등 을 통해 @Around("forAllContorller() &&! forAllViewController()")는
반환타입 상관없이 해당 컨트롤러의 모든 메서드 && !(반환타입이 String인 메서드)
로 해석된다.


즉, RestApi를 호출할 때만 사용할 수도 있다.


1. 메서드를 생성
2. 클래스명을 


- AOP의 동작 원리

 

DispatcherServlet과 컨트롤러 사이에서 동작한다.
컨트롤러에게 joinPoint.proceed()로 AOP 프록시에서 요청을 전달하고 받아올 수 있다.

 

@Around가 아닌 @Before나 @After를 사용하면 joinPoint.proceed() 없이 바로 실행된다.

API 예외 처리

응답 헤더에는 상태코드를 함께 보낸다.
2xx -> 성공
3xx -> Redirect
4xx -> 클라이언트 에러, 잘못된 요청
5xx -> 서버 에러

 

org.springframework.http.HttpStatus는 Spring에서 제공하는 상태코드들을 Enum으로 제공한다.

ResponseEntity에 body에 넣을 내용과 HttpStatus.*을 파라미터로 전달할 수 있다.

 

컨트롤러의 메서드 안에서,

try{
	// ... 성공
    return new ResponseEntity<>(HttpStatus.OK);
}catch(IllegalArgumentException ex){
	// ... 실패
    RestApiException restApiException = new RestApiException(ex.getMessage(), HttpStatus.BAD_REQUEST);
    return new ResponseEntity<>(
    restApiException,
    HttpStatus.BAD_REQEUST);
	)
}

RestApiException 객체를 만들어서, 에러 메시지와 상태코드를 저장한다.

 

RespnoseEntity에는
Body 부분에 위 RestApiException 객체
Status 부분에 HttpStatus.BAD_REQEUST

하지만 메서드마다 이런 예외처리를 일일이 하기 번거롭다. 따라서 이를 지원하는 애노테이션을 활용한다.

 

@ExceptionHandler는 컨트롤러에서 예외 발생 시, 해당 예외를 가져와서 처리해 준다.

AOP를 이용한 방식이므로, try catch가 필요 없다.

@ExceptionHandler({IllegalArgumentException.class})
    public ResponseEntity<RestApiException> handleException(IllegalArgumentException ex) {\
    RestApiException restApiException = new RestApiException(ex.getMessage(), HttpStatus.BAD_REQUEST.value());
        return new ResponseEntity<>(
                // HTTP body
                restApiException,
                // HTTP status code
                HttpStatus.BAD_REQUEST
        );
}

IllegalArgumentException이 발생하면, 원래 메서드는 그 즉시 종료되고 위 메서드가 실행되어 클라이언트에게 반환된다.

Global 예외 처리

특정 컨트롤러 내에서 발생하는 예외를 감지해서 처리하는 것이 아닌,
모든 컨트롤러, 즉 전역에서 발생하는 예외를 감지해서 처리하는 기능을 만들어보자.

@ExceptionHandler로 특정 예외를 감지해서 처리한다면,
이 @ExceptionHandler들을 모아서 Global로 처리하는 개념이다.

@ControllerAdvice를 사용할 수 있다.
Rest, 즉 View가 아닌 값을 반환할 거라면
@RestControllerAdvice를 사용할 수 있다.

 

- @RestControllerAdvice 적용 GlobalExceptionHandler 클래스

package com.sparta.myselectshop.exception;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler({IllegalArgumentException.class})
    public ResponseEntity<RestApiException> handleException(IllegalArgumentException ex) {
        RestApiException restApiException = new RestApiException(ex.getMessage(), HttpStatus.BAD_REQUEST.value());
        return new ResponseEntity<>(
                // HTTP body
                restApiException,
                // HTTP status code
                HttpStatus.BAD_REQUEST
        );
    }
}

@RestControllerAdvice는 클래스 레벨에 설정할 수 있다.

Error 메시지 처리

properties에 작성한 키:값 형태의 메시지를 org.springframework.context.MessageSource로 받아올 수 있다.

 

- message.properties 예시

below.min.my.price=최저 희망가는 최소 {0}원 이상으로 설정해 주세요.
not.found.product=해당 상품이 존재하지 않습니다.

 

- MessageSource 사용 예시

messageSource.getMessage(
        "below.min.my.price",
        new Integer[]{MIN_MY_PRICE},
        "Wrong Price",
        Locale.getDefault()
)

 

또한, Exception을 상속받으면 커스텀 예외 클래스를 만들 수 있다.

이 역시도 GlobalExceptionHandler에서 사용가능하다.

 

- 커스텀 예외 클래스인 ProductNotFoundException 클래스 사용

@ExceptionHandler({ProductNotFoundException.class})
public ResponseEntity<RestApiException> notFoundProductExceptionHandler(ProductNotFoundException ex) {
    RestApiException restApiException = new RestApiException(ex.getMessage(), HttpStatus.NOT_FOUND.value());
    return new ResponseEntity<>(
            // HTTP body
            restApiException,
            // HTTP status code
            HttpStatus.NOT_FOUND
    );
}

 

'내배캠' 카테고리의 다른 글

[내일배움캠프 본캠프] 11일 차 TIL MSA, Git flow  (0) 2026.02.20
[내일배움캠프 본캠프] 10일 차 TIL Spring AI  (0) 2026.02.19
[내일배움캠프 본캠프] 8일 차 TIL 페이징처리, 다대다, AWS  (0) 2026.02.17
[내일배움캠프 본캠프] 7일 차 TIL 지연로딩, cascade, scheduler, 헤더 JWT  (0) 2026.02.16
[내일배움캠프 본캠프]6일 차 TIL RestTemplate, Entity 연관 관계  (0) 2026.02.16
'내배캠' 카테고리의 다른 글
  • [내일배움캠프 본캠프] 11일 차 TIL MSA, Git flow
  • [내일배움캠프 본캠프] 10일 차 TIL Spring AI
  • [내일배움캠프 본캠프] 8일 차 TIL 페이징처리, 다대다, AWS
  • [내일배움캠프 본캠프] 7일 차 TIL 지연로딩, cascade, scheduler, 헤더 JWT
MvA
MvA
백엔드 개발자 김재현입니다. 주로 공부하면서 느낀점을 기록합니다.
  • MvA
    Man vs Ai
    MvA
  • 전체
    오늘
    어제
    • 분류 전체보기 (94)
      • Java (6)
      • Python (8)
        • 딥러닝 (1)
        • 머신러닝 (7)
      • JavaScript (2)
      • 내배캠 (60)
      • 개인 프로젝트 (11)
      • 책 후기 (5)
      • 기타 (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    내일배움캠프
    딥러닝
    아키텍처
    머신러닝
    배포
    Riot API
    TiL
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.4
MvA
[내일배움캠프 본캠프] 9일 차 TIL 소셜 로그인(OAuth), 단위 테스트, AOP, 예외 처리
상단으로

티스토리툴바