25주차에 한것

이번 주도 기능 구현으로 바빴다.

프리사인 URL로 이미지를 저장할 수 있도록 하였고, 소셜 로그인을 구현하였다.

또한 자잘한 CORS 문제를 해결하였고, STOMP로 1:1 실시간 채팅을 구현하였다.

그리고 그동안 미뤄두었던 리팩토링을 진행하였다.

 

이번 주에 한 일

  • 프리사인 URL로 이미지 저장 기능 구현
  • 소셜 로그인 구현
  • STOMP로 1:1 실시간 채팅 구현
  • CORS 문제 해결
  • 도메인 리팩토링

결과

일반 회원가입과 소셜 로그인을 통한 회원가입의 두가지 방식을 병용해서 이메일, 닉네임에서 애먹었다.

이메일도 중복될 수 없고, 닉네임도 중복될 수 없다는 정책이 있었다.

어쩔 수 없이 소셜 로그인으로 회원가입 시 닉네임은 랜덤 번호가 붙은 임시 닉네임을 부여하는 방식으로 진행했고,

이메일도 소셜 로그인으로 회원가입 시 이메일 뒤에 소셜로그인 Provider를 표기하는 식으로 처리하였다.

더 좋은 방식이 있다면 수정할것 같지만 지금은 이게 최선이었다.

 

STOMP로 1:1 실시간 채팅을 구현하였는데 사실 채팅 기능 구현하는건 이전에 해봤던거라 그리 오래 걸리지 않았다.

하지만 나와 연관된 채팅방을 불러오는 조회 쿼리를 만드는게 더 힘들었던것 같다.

당장은 JPA의 기능 없이 한방 쿼리를 작성해서 만들었는데, 이러다보니 연관관계를 굳이 맺을 필요가 있을까 싶었다.

이 부분은 좀 더 공부해봐야겠다.

각각의 채팅방의 마지막 메시지도 조회해야 하는데 한방 쿼리로 어떻게 하고 싶었으나 아직 못했다.

 

이번 주에 아쉬웠던 부분은 아래와 같다.

  • 소셜 로그인/일반 회원가입 병행 시 더 좋은 방법이 없었을까?
  • 각 채팅방의 마지막 메시지 조회 기능을 구현하지 못 함

배포 환경에서 Nginx 사용 시 추가 설정

이전 글에서 STOMP를 이용한 실시간 채팅을 구현했다.

로컬에서 테스트 할 때는 문제 없겠지만, 배포 환경에 따라 웹소켓을 이용하기 위해서는 추가 설정을 해야할 수 있다.

만약 이 글의 서비스에서 적용된 Nginx 관련 설정이 궁금하다면 이 글을 참고한다.

 

현재 우리 애플리케이션에서는 Nginx를 리버스 프록시로 사용하여 SSL을 적용하고 있다.

즉, Nginx를 사용하여 특정 도메인의 특정 포트로의 접근을 EC2 퍼블릭 IP로 포워딩 시켜주고 있다.

HTTP 요청일때는 별 문제 없지만 웹소켓은 HTTP와는 다른 프로토콜이므로 포워딩 시켜줄 때 추가 설정을 해줘야 한다.

101 Switching Protocol 

웹소켓을 사용할 때 요청 헤더를 보면 아래와 같이 101 Switching Protocol 요청을 보내고 있다.

요청 URL은 SSL을 적용했으므로 HTTPS 처럼 보안이 적용된 wss://www.midcon.store/ws 이다.

여기서 요청 헤더를 보면 Connection, Upgrade 헤더가 담겨 있는걸 확인할 수 있다.

이 요청을 통해 HTTP 요청에서 웹소켓 프로토콜(WS)로 전환 을 요청하는 것이다.

 

따라서 EC2의 퍼블릭 IP 주소로 포워딩 해줄 때도 Connection, Upgrade 헤더를 설정 해줘야 한다.

이전 글에서 WebSocketConfig를 설정할 때 웹소켓 요청 엔드포인트를 /ws로 해두었다.

그러므로 /ws 경로로 들어오는 요청에 Connection, Upgrade 헤더를 설정해주면 될 것이다.

Nginx 추가 설정

Nginx를 설정 해주는 방식은 여러 가지가 있겠지만 본인은 sites-available/default 파일을 직접 수정했다.

추가 설정할 부분은 아래와 같다.

location /ws {
                proxy_pass http://127.0.0.1:8080;
                proxy_http_version 1.1;
                proxy_set_header Upgrade $http_upgrade;
                proxy_set_header Connection "upgrade";
}

 

설정 후 443번 포트 관련 설정은 아래 사진과 같다.

Nginx 설정 시 location 순서를 고려해야할 수도 있지만 여기서는 "/ws"와 "/" 설정의 순서는 상관없을듯 하다.

아래에 HTTP 요청을 리다이렉트 하는 부분은 어차피 certbot이 자동으로 설정해주는 부분이니 생략했다.

/etc/nginx/sites-available/default

 

Nginx 설정을 해주고 난 뒤에는 늘 nginx 를 다시 실행해줘야한다.

아래 명령어로 Nginx를 다시 실행하면 설정값이 적용 돼서 웹소켓을 사용할 수 있을것이다.

sudo service nginx restart

 

참고자료

 

nginx 에서 WebSocket Proxy 설정하기

Nginx를 이용하여 websocket 연결 요청을 websocket 전용 url로 proxy 할 필요가 생겨서 기록을 남깁니다. 환경 nginx 1.18ver cent os 7 본론 저 같은 경우 9000번 포트로 들어오는 신호를 http://서버EndPoint/socket/tes

shinwusub.tistory.com

 

 

소셜 로그인 서비스 레이어 구현

이전 글에서 컨트롤러 호출 방법과 컨트롤러까지 구현했다.

컨트롤러에서 Provider 이름과 인증 코드를 받아오기까지는 했으니 이제 아래 그림에서 토큰 발급 부터 진행한다.


1. 필요한 DTO 구현

1-1. 토큰 발급 과정에 필요한 DTO 구현

OauthTokenResponse

프론트에서 받은 인증 코드와 Provider 이름으로 Provider에 토큰 발급을 요청하고, 발급 받은 토큰으로 유저 정보를 요청한다.

토큰 발급 시 토큰 정보를 받을 DTO가 아래 OAuthTokenResponse 이다.

@Getter
public class OAuthTokenResponse {

    @JsonProperty("access_token")
    private String accessToken;

    @JsonProperty("token_type")
    private String tokenType;

    @Builder
    public OAuthTokenResponse(String accessToken, String tokenType) {
        this.accessToken = accessToken;
        this.tokenType = tokenType;
    }
}

1-2. 유저 정보 요청에 필요한 DTO 구현

OAuthAttributes

위에서 발급 받은 토큰으로 Provider에 요청하여 제공 받은 유저 정보를 담는 DTO가 OAuthAttributes 이다.

Provider가 제공하는 유저 정보에 temp_nickname 이라는 값을 추가해서 사용하였다.

Provider마다 제공하는 정보 형식이 달라서 팩토리 메서드로 구현했다.

만약 깃허브나 페이스북 로그인이 추가 된다면 OAuthAttributes 클래스와 서비스 레이어의 검증 로직 정도만 수정하면 될것이다.

@Getter
public class OAuthAttributes {

    private String nickname;
    private String email;
    private String profileImage;
    private String provider;

    @Builder
    public OAuthAttributes(String nickname, String email, String profileImage, String provider) {
        this.nickname = nickname;
        this.email = email;
        this.profileImage = profileImage;
        this.provider = provider;
    }

    public static OAuthAttributes of(String providerName, Map<String, Object> attributes) throws IllegalArgumentException {
        switch (providerName) {
            case "카카오":
                return ofKakao(providerName, attributes);
            case "구글":
                return ofGoogle(providerName, attributes);
            case "네이버":
                return ofNaver(providerName, attributes);
            default:
                throw new IllegalArgumentException("허용되지 않은 접근입니다.");
        }
    }

    public User toUser() {
        return User.builder()
            .nickname(nickname)
            .email(email)
            .password("oauth2")
            .profileImage(profileImage)
            .type(LoginType.fromString(provider))
            .build();
    }

    private static OAuthAttributes ofKakao(String providerName, Map<String, Object> attributes) {
        Map<String, Object> account = (Map<String, Object>) attributes.get("kakao_account");
        Map<String, Object> profile = (Map<String, Object>) account.get("profile");
        return OAuthAttributes.builder()
            .nickname((String) attributes.get("temp_nickname"))
            .email(account.get("email") + "/" + providerName)
            .profileImage((String) profile.get("profile_image_url"))
            .provider(providerName)
            .build();
    }

    private static OAuthAttributes ofGoogle(String providerName, Map<String, Object> attributes) {
        return OAuthAttributes.builder()
            .nickname((String) attributes.get("temp_nickname"))
            .email(attributes.get("email") + "/" + providerName)
            .profileImage((String) attributes.get("picture"))
            .provider(providerName)
            .build();
    }

    private static OAuthAttributes ofNaver(String providerName, Map<String, Object> attributes) {
        Map<String, Object> response = (Map<String, Object>) attributes.get("response");
        return OAuthAttributes.builder()
            .nickname((String) attributes.get("temp_nickname"))
            .email(response.get("email") + "/" + providerName)
            .profileImage((String) response.get("profile_image"))
            .provider(providerName)
            .build();
    }
}

2. 서비스 레이어 구현

과정은 아래와 같다.

  1. 우선 우리 서비스에서 제공하는 소셜 로그인 종류를 검증한다. (네이버, 카카오, 구글)
  2. oauth2-client에서 제공하는 ClientRegistrationRepository에서 ClientRegistration 객체를 조회한다.
    ClientRegistration 객체는 기본 설정 + yml 파일에서 설정한 값을 토대로 oauth2 과정에 필요한 정보를 제공한다.
  3. 인증 코드와 ClientRegistration 객체로 Provider에 액세스 토큰을 요청한다.
  4. 발급 받은 액세스 토큰으로 Provider에 유저 정보를 요청한다.
  5. 제공 받은 유저 정보로 회원가입 / 로그인을 진행한다.

AuthService

AuthService의 전문은 아래와 같다.

4번 까지는 소셜 로그인 공통 로직이다.

5번부터는 각자 서비스에 맞는 형태로 구현하면 된다.

본인은 4번에서 제공받은 유저 정보인 OAuthAttributes 에 임시 닉네임 값을 추가하여 사용하였다.

이후 소셜 로그인 시 변경이 있으면 수정하고, 가입되지 않은 유저면 회원가입을 진행하는 로직을 작성했다.

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AuthService {

    private static final String TEMP_NICKNAME_PREFIX = "임시닉네임";
    private final ClientRegistrationRepository clientRegistrationRepository;
    private final UserRepository userRepository;

    @Transactional
    public User oauth2Login(String code, String provider) {
        validateProvider(provider);
        ClientRegistration registration = clientRegistrationRepository.findByRegistrationId(provider);
        OAuthTokenResponse tokenResponse = requestAccessToken(code, registration);
        User uerProfile = getUerProfile(tokenResponse.getAccessToken(), registration);
        return updateOrSave(uerProfile);
    }

    private void validateProvider(String provider) {
        List<String> validProvider = List.of("kakao", "naver", "google");
        if (!validProvider.contains(provider)) {
            throw new IllegalArgumentException("허용되지 않은 접근입니다.");
        }
    }

    private OAuthTokenResponse requestAccessToken(String code, ClientRegistration registration) {
        return WebClient.create()
            .post()
            .uri(registration.getProviderDetails().getTokenUri())
            .headers(header -> {
                header.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
                header.setAcceptCharset(Collections.singletonList(StandardCharsets.UTF_8));
            })
            .bodyValue(createTokenRequest(code, registration))
            .retrieve()
            .bodyToMono(OAuthTokenResponse.class)
            .block();
    }

    private MultiValueMap<String, String> createTokenRequest(String code, ClientRegistration registration) {
        MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
        formData.add("code", code);
        formData.add("grant_type", "authorization_code");
        formData.add("redirect_uri", registration.getRedirectUri());
        formData.add("client_secret", registration.getClientSecret());
        formData.add("client_id", registration.getClientId());
        return formData;
    }

    private User getUerProfile(String accessToken, ClientRegistration registration) {
        Map<String, Object> userAttributes = requestUserAttributes(registration, accessToken);
        userAttributes.put("temp_nickname", createNonDuplicateNickname());
        return OAuthAttributes.of(registration.getClientName(), userAttributes).toUser();
    }

    private Map<String, Object> requestUserAttributes(ClientRegistration registration, String accessToken) {
        return WebClient.create()
            .get()
            .uri(registration.getProviderDetails().getUserInfoEndpoint().getUri())
            .headers(header -> header.setBearerAuth(accessToken))
            .retrieve()
            .bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
            .block();
    }

    private String createNonDuplicateNickname() {
        String tempNickname = createRandomNickname();
        while (isDuplicateNickname(tempNickname)) {
            tempNickname = createRandomNickname();
        }
        return tempNickname;
    }

    private String createRandomNickname() {
        Random random = new Random();
        int randomNumber = random.nextInt(999999) + 1;
        return TEMP_NICKNAME_PREFIX + String.format("%06d", randomNumber);
    }

    private boolean isDuplicateNickname(String nickname) {
        return userRepository.findByNickname(nickname).isPresent();
    }

    private User updateOrSave(User uerProfile) {
        User oauth2User = userRepository.findByEmail(uerProfile.getEmail())
            .map(user -> user.updateOauth2Profile(uerProfile))
            .orElse(uerProfile);
        return userRepository.save(oauth2User);
    }
}

3. 결과 확인

이전 글에서 네이버 로그인 링크가 아래와 같았다.

 

https://nid.naver.com/oauth2.0/authorize?client_id=9NjBfBE6YM2diWzMy19n&redirect_uri=http://localhost:8080/api/auth/oauth2/naver&response_type=code

 

또한 컨트롤러 구현 쪽에서 소셜 로그인 성공 시 프론트 URL로 리다이렉트 시키도록 해두었다.

앱 등록을 안해서 등록한 멤버만 위 링크를 사용 가능하겠지만 링크를 눌러 소셜 로그인을 하면

백엔드 서버에 인증 코드가 가고, 백엔드 서버에서 Oauth2 과정을 거쳐 회원 정보를 얻어온 다음 프론트 페이지로 리다이렉트 한다.

우선은 프론트도 로컬에서 작업할거라 localhost:3000으로 리다이렉트 시켰다.

결과는 아래와 같이 소셜로그인 이후 쿠키로 JWT를 발급함을 알 수 있다.

소셜 로그인 구현

이전 글에서 소셜 로그인 구현을 위한 사전 준비를 하였다.

이제 스프링 부트에서 oauth2-client와 webflux로 소셜 로그인을 구현해볼 것이다.

소셜 로그인 화면은 Provider에서 제공하는거라 딱히 프론트쪽 구현 없이도 테스트 할 수 있다.

 

아래 그림은 카카오 디벨로퍼스에서 제공하는 카카오 로그인 흐름도이다.

구현 방법에 따라 다르겠지만 우선 본인이 구현한 방법에서는 백엔드에서 생각할 건 아래 두가지이다.

  • 리다이렉트 URL로 컨트롤러 호출 시 Provider 이름, 인증 코드 확인
  • 서비스 레이어에서 토큰 발급 후 이 토큰으로 유저 정보 받아오기 

이번 글에서는 컨트롤러를 호출하고 Provider 이름과 인증 코드를 확인하는 부분까지 진행한다.


사용 기술

  • Spring Boot 3.2.4 / gradle-kotlin
  • Java 17
  • webflux, oauth2-client

 

1. 프로젝트 사전 작업

1-1. 의존성 추가

아래 두 라이브러리의 의존성을 추가해준다.

webflux는 서버에서 HTTP 요청을 하기 위해서 사용하고, oauth2-client는 소셜 로그인을 구현하기 위해 사용한다.

// build.gradle.kts
// 소셜 로그인
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
implementation ("org.springframework.boot:spring-boot-starter-webflux")

1-2. application.yml 설정

oauth2-client를 사용하기 위해서는 아래처럼 yml 파일을 설정해줘야한다.

아래에서 # 으로 표시한 클라이언트 ID, 시크릿 키, 리다이렉트 URL 외에는 그대로 써도 무방하다.

변경한다면 client-name는 원하는대로 바꿔도 될듯하고, scope도 각자 설정한 부분만큼 설정하면 될 것 같다.

참고로 구글은 provider depth에 없는 이유는 기본적으로 설정이 되어 있어서다.

클라이언트 ID, 시크릿 키를 확인하는 위치는 이전 글을 참고하자.

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: # 발급 받은 Client ID
            client-secret: # 발급 받은 시크릿 키
            redirect-uri: http://localhost:8080/api/auth/oauth2/google # 설정한 리다이렉트 URL
            scope:
              - email
              - profile
            client-name: 구글

          naver:
            client-id: # 발급 받은 Client ID
            client-secret: # 발급 받은 시크릿 키
            client-authentication-method: client_secret_post
            authorization-grant-type: authorization_code
            redirect-uri: # 설정한 리다이렉트 URL
            scope:
              - name
              - email
              - profile_image
            client-name: 네이버

          kakao:
            client-id: # 발급 받은 Client ID
            client-secret: # 발급 받은 시크릿 키
            client-authentication-method: client_secret_post
            authorization-grant-type: authorization_code
            scope:
              - profile_nickname
              - profile_image
              - account_email
            redirect-uri: # 설정한 리다이렉트 URL
            client-name: 카카오

        provider:
          naver:
            authorization-uri: https://nid.naver.com/oauth2.0/authorize
            token-uri: https://nid.naver.com/oauth2.0/token
            user-info-uri: https://openapi.naver.com/v1/nid/me
            user-info-authentication-method: header
            user-name-attribute: response # Naver 응답 값 resultCode, message, response 중 response 지정

          kakao:
            authorization-uri: https://kauth.kakao.com/oauth/authorize
            token-uri: https://kauth.kakao.com/oauth/token
            user-info-uri: https://kapi.kakao.com/v2/user/me
            user-info-authentication-method: header
            user-name-attribute: id # Kakao 응답 값 id, connected_at, properties, kakao_account 중 id 지정

2. 컨트롤러 호출 부분

컨트롤러를 호출하는 부분에 대해 설명해보려 한다.

Provider가 기본적으로 제공하는 화면이 있기 때문에 사용자를 그 화면을 호출하는 링크로 이동시키면 된다.

기본적인 형태는 아래와 같다.

{Provider가 제공하는 로그인 화면 주소}
?client_id={클라이언트 ID}
&redirect_uri={리다이렉트 URL}
&response_type=code

 

아래는 본인이 로컬에서 테스트할 때 사용하는 소셜 로그인 페이지 이동 링크이다.

2-1. 네이버 로그인 주소

기본적인 형태를 따른다.

https://nid.naver.com/oauth2.0/authorize?client_id=9NjBfBE6YM2diWzMy19n&redirect_uri=http://localhost:8080/api/auth/oauth2/naver&response_type=code

2-2. 카카오 로그인 주소

기본적인 형태를 따른다.

https://kauth.kakao.com/oauth/authorize?client_id=b88ca4129b6dcc848a022469f3d6ae1c&redirect_uri=http://localhost:8080/api/auth/oauth2/kakao&response_type=code

2-1. 구글 로그인 주소

구글 로그인 시 scope 값에 따라 제공하는 정보가 다르므로 스코프를 추가하고 나머지는 기본적인 형태를 따른다.

https://accounts.google.com/o/oauth2/v2/auth?client_id=241400103028-mj55qqtl4gv96o35fdavqm6fngb68ske.apps.googleusercontent.com&redirect_uri=http://localhost:8080/api/auth/oauth2/google&response_type=code&scope=email profile


3. 컨트롤러 구현

리다이렉트 URL로 컨트롤러를 호출하므로 GET 요청을 받는다.

또한 하나의 컨트롤러로 여러개의 Provider를 받기 위해 아래처럼 PathVariable로 다형적으로 받도록 설정했다.

또한 인증 코드는 Provider에서 리다이렉트 시 code라는 키 값의 쿼리 스트링으로 주므로 아래처럼 받았다.

소셜 로그인을 진행하고 성공 시 JWT를 쿠키에 담아 반환한다.

그리고 요청을 받는게 백엔드쪽 주소이므로 프론트 URL로 리다이렉트 시켜줬다.

AuthController

@Operation(summary = "소셜 로그인", description = "소셜 로그인 성공 시 쿠키를 반환합니다.")
@ApiResponse(responseCode = "200", description = "로그인에 성공",
    headers = {
        @Header(name = "Set-Cookie", description = "인증 쿠키")
    })
@ApiResponse(responseCode = "400", description = "잘못된 아이디 혹은 비밀번호")
@GetMapping("/oauth2/{provider}")
public ResponseEntity<ApiMessageResponse> oauth2Login(@PathVariable String provider, @RequestParam String code) {
    String jwtCookie = authFacade.oauth2Login(code, provider);
    return ResponseEntity
        .status(HttpStatus.FOUND)
        .header(HttpHeaders.SET_COOKIE, jwtCookie)
        .header(HttpHeaders.LOCATION, appConfig.getFrontDeployUrl())
        .body(new ApiMessageResponse("소셜 로그인에 성공했습니다."));
}

 

소셜 로그인

팀 프로젝트를 진행하면서 일반 회원가입과 소셜 로그인 두 가지 방식을 병행하기로 했다.

사실 이번 프로젝트의 일반 회원가입은 이메일 인증도 해야해서 번거로운 감이 있어서 소셜 로그인이 편할것 같기는 하다.

소셜 로그인이 구현되면 수작업으로 테스트하기도 편할것 같아서 마음에 든다.

 

이번 프로젝트에서는 일단 oauth2-client 를 이용해서 구현했다.

스프링 시큐리티에서도 oauth2 로그인을 지원해주니까 이걸 이용할 수 있을거 같긴 한데 그건 다음에 시도 해봐야겠다.

구현 방법에 따라 흐름은 다를 수 있겠지만 본인이 구현한 대략적인 소셜 로그인 흐름은 아래와 같다.

리다이렉트 URL도 구현에 따라 다르게 설정해도 무방하다.

그림이랑 설명의 번호는 안맞으니까 적당히 보자.

  1. 프론트에서 네이버, 카카오, 구글에서 제공하는 소셜 로그인 페이지로 이동시킨다.
    소셜 로그인 페이지로 이동 시킬 때 URL에 client Id 리다이렉트 URL(Callback URL) 등이 포함된다.
    여기서 리다이렉트 URL은 아래와 같은 형태이다.
    {백엔드 도메인}/백엔드 소셜로그인 API 엔드포인트/{Provider 이름}
    ex) https://midcon.store/api/auth/oauth2/kakao
  2. 사용자가 정보 입력, 개인정보 제공 동의 등의 소셜 로그인 과정을 진행 후 제출한다.
  3. 1번에서 URL에 포함된 리다이렉트 URL을 백엔드 주소로 등록해서 리다이렉트 시킨다.
    리다이렉트 됐을 때 쿼리스트링 형태로 소셜 로그인 Provider에서 인증 코드를 전달 받는다.
    ex) https://localhost:8080/api/auth/oauth2/kakao?code={인증 코드}
  4. 백엔드에서 리다이렉트 된 주소로 GET 요청을 받는다.
  5. 3의 예시처럼 PathVariable과 QueryParam 형태로 Provider 이름과 인증 코드를 받아 Oauth2 인증 과정을 진행한다.
  6. Oauth2 과정으로 Provider에서 받은 유저 정보로 서비스 정책에 맞게 회원가입 시키거나 accessToken을 발급한다.

본인이 사용한 플로우는 대략 위와 같다.

최종 결과물은 아래와 같이 회원가입 혹은 로그인 시킨 후 쿠키JWT를 발급하고 프론트 페이지로 리다이렉트 한다.


사용 기술

  • Spring Boot 3.2.4 / gradle-kotlin
  • Java 17
  • webflux, oauth2-client

1. 사전 작업 - 네이버

소셜 로그인을 하려면 각 소셜 로그인 provider에 해당하는 앱 등록을 해둬야한다.

네이버 디벨로퍼스에서 앱 등록을 한다.

앱 등록하는건 간단하니까 각자 해보면 되고 필요한 부분만 체크했다.

서비스 URL은 본인이 사용할 도메인으로 설정한다.

로컬에서 할거면 localhost로 해주면 된다.

1-1. 클라이언트 ID, 시크릿 키

1-2. Callback URL(Redirect URL)

본인은 로컬에서 테스트할 때와 배포 URL 둘 다 해뒀다.

만약 로컬에서만 할거라면 빨간 색 부분만 추가해도 된다.

본인의 소셜 로그인 엔드포인트가 /api/auth/oauth2/{providerName} 이라서 아래처럼 했다.

엔드포인트를 다른거로 할거라면 마음대로 설정하면 된다.

 

참고로 백엔드에서 설정 할 리다이렉트 URL과 프론트에서 리다이렉트 시켜줄 리다이렉트 URL이 안맞으면 에러가 발생한다.

1-3. 멤버 등록

본인만 테스트할거라면 상관 없지만, 팀 프로젝트라서 팀원들도 소셜 로그인 할 수 있게 하려면 멤버 등록을 해줘야한다.

만약 앱 등록을 해서 통과 된다면 멤버 등록 없이도 할 수 있다.

본인은 아직 하지 않았지만 앱 등록하는게 그렇게 어렵지 않다고 하니 해보는것도 좋을것 같다.


2. 사전 작업 - 카카오

카카오 디벨로퍼스에 들어가서 앱 등록을 한다.

2-1. 클라이언트 ID

카카오는 클라이언트 ID와 시크릿 키가 따로 있다.

 

시크릿 키는 보안 탭에서 확인할 수 있다.

2-2. Callback URL(Redirect URL)

리다이렉트 URL은 네이버와 마지막 PathVariable만 다르게 설정해준다.

마찬가지로 로컬에서 할거면 빨간 영역만 해도 무방하다.

2-3. 멤버 등록

마찬가지로 앱 등록을 안하면 따로 멤버 등록을 해야한다.


3. 사전 작업 - 구글

구글 클라우드 콘솔에서 앱 등록을 한다.

3-1. 클라이언트 ID, 시크릿 키, Callback URL(Redirect URL)

API 및 서비스 탭으로 들어가서 사용자 인증 정보를 확인한다.

리다이렉트 URL은 네이버, 카카오와 마지막 PathVariable만 다르게 설정해준다.

마찬가지로 로컬에서 할거면 빨간 영역만 해도 무방하다.

3-2. 멤버 등록

마찬가지로 앱 인증 전에는 멤버를 수동으로 등록해줘야한다.

문제 발생

프론트엔드 공부를 하면서 스프링 서버로 CORS 관려해서 이것저것 연습해봐서

이제 CORS에 관해서는 어느정도 잘 알고 있다고 생각했고 나름 자부하고 있었다.

솔직히 아래 설정만 하면 웬만한 CORS 설정은 다 끝나는줄 알았다.

또한 팀 프로젝트 기간 중 그동안 API를 만들어두고 프론트 측에서 아무 말도 없길래 문제 없다고 생각했다.

하지만 갑자기 CORS 문제가 생기는 이슈가 발생했다.

WebMvcConfigurer

@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {

    private final AppConfig appConfig;

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
            .allowedOrigins(appConfig.getFrontTestUrl(), appConfig.getFrontDeployUrl(), appConfig.getBackUrl())
            .allowCredentials(true);
    }
}

문제 해결

사실 결론만 보자면 별거는 없었다.

기본적으로 allowedMethods는 GET, POST 요청만 허용하고 있기 때문이었다.

프론트에서도 기존 만들어둔것 중 GET, POST 요청만 이용했었기에 별 다른 이슈가 없었던 것이다.

그래서 PUT 요청을 하면서부터 문제가 생기기 시작했다.

연습할 때도 PUT 요청까지 연습해보지 않고 GET, POST로만 연습했기 때문에 처음 겪어봤던 것이다.

따라서 아래처럼 PUT, DELETE 요청도 허용하게끔 설정하면 문제 없이 해결되었다.

WebMvcConfigurer

@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {

    private final AppConfig appConfig;

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
            .allowedOrigins(appConfig.getFrontTestUrl(), appConfig.getFrontDeployUrl(), appConfig.getBackUrl())
            .allowedMethods("GET", "POST", "PUT", "DELETE")
            .allowCredentials(true);
    }
}

24주차에 한것

이번 주에는 nginx와 certbot을 이용하여 HTTPS를 적용해보았다.

또한 HTTPS 적용 시 스웨거 관련 CORS 오류를 해결하였다.

 

이번 주에 한 일

  • 유저 정보 조회/수정 기능 구현
  • 게시글 작성 기능 구현
  • 배포 서버에 HTTPS 적용
  • HTTPS 적용 시 스웨거에서 발생하는 CORS 오류 해결

결과

저번 팀 프로젝트에서는 Route53로 인증서를 발급하고 AWS ELB로 HTTPS로 리다이렉트 시켰었는데

nginx와 certbot으로 다른 AWS 서비스 없이 EC2만으로 HTTPS를 적용할 수 있었다.

그동안 nginx로 인증서를 발급받고 적용할 수 있다고 듣기만 했었는데 괜히 쫄아서 못했었다.

이번 기회에 nginx로 HTTPS를 적용해보면서 nginx에 대해 어느정도 감을 잡을 수 있어서 좋았다.

저번에 프리티어 끝나고 ELB를 제거해두지 않아서 요금이 꽤 나왔었는데 무료로 할 수 있다는 점도 좋았다.

 

또한 프론트와 협업하면서 API 명세서로 스웨거를 사용하고 있었는데, HTTPS 적용 전에는 잘 되던게

HTTPS를 적용하면서 스웨거에서 갑자기 CORS 오류가 발생하여 이를 수정하였다.

스웨거를 이번에 처음 써보는거라 스웨거 관련 설정에서 어려움을 겪었던것 같다.

 

이번 주에 아쉬웠던 부분은 아래와 같다.

  • 이번 주는 프로젝트에 집중을 잘 하지 못했음.
  • 이미지 저장 기능까지 끝내고 싶었으나 하지 못함.

문제 발생

Nginx, certbot을 이용하여 HTTPS 적용을 마쳤다.

그리고 기분 좋게 스웨거로 잘 되는지 확인해보려 했는데 갑자기 아래처럼 CORS 에러가 발생했다.

문제 해결

분명 HTTPS 적용 전에는 잘 됐는데 무엇이 문제인지 생각해보았다.

바뀐건 HTTPS, Nginx 설정 뿐이어서 이 두가지를 위주로 검색을 해보고 답을 찾았다.

결론은 스웨거 서버 URL 설정 기본 값이 http://localhost:8080 이라서 발생한 문제였다.

HTTPS 적용 전에는 프로토콜이 같으므로 문제없었지만 HTTPS 적용 후 프로토콜이 달라져서 CORS 에러가 발생한듯 하다.

그래서 SwaggerConfg를 아래처럼 수정하여 문제를 해결했다.

SwaggerConfig

AppConfg로 yml 파일로 백엔드 도메인을 환경변수로 분리하였다.

yml 파일은 이 글에서와 동일하다.

이러면 로컬에선 서버 URL이 localhost:8080이고 배포 시에는 배포 도메인으로 바뀌므로 CORS 문제를 해결할 수 있다.

@Configuration
@RequiredArgsConstructor
@SecurityScheme(
    name = "jwt-cookie",
    type = SecuritySchemeType.APIKEY,
    in = SecuritySchemeIn.COOKIE
)
public class SwaggerConfig {

    private final AppConfig appConfig;

    @Bean
    public OpenAPI serverApiConfig() {
        Server server = new Server();
        server.setUrl(appConfig.getBackUrl());
        server.description("백엔드 도메인");
        return new OpenAPI()
            .addServersItem(server)
            .info(new Info().title("PAWLAND API")
                .description("PAWLAND API SWAGGER UI입니다."));
    }
}

+ Recent posts