구현하기

구현 순서는 아래와 같다.

  1. restdocs-api-spec을 이용한 OAS 파일을 생성하는 빌드 환경 구축
  2. Swagger-UI standalone, Static Routing 세팅
  3. 생성된 OAS 파일을 Swagger 디렉터리로 복사(copyOasToSwagger)
  4. SampleController 및 MockMvc REST Docs Test 코드 작성
  5. 결과 확인

사용 기술

  • Spring Boot 3.3.0 / gradle-kotlin
  • Java 17
  • restdocs-api-spec

1. restdocs-api-spec을 이용한 OAS 파일을 생성하는 빌드 환경 구축

1-1. 의존성 추가

아래처럼 의존성을 추가해준다.

// build.gradle.kts
plugins {
    ... 생략
    id("com.epages.restdocs-api-spec") version "0.15.3"
}

repositories {
    mavenCentral()
}

dependencies {
    ... 생략
    // Rest Docs
    testImplementation("org.springframework.restdocs:spring-restdocs-mockmvc")
    testImplementation("org.springframework.restdocs:spring-restdocs-asciidoctor")
    testImplementation("com.epages:restdocs-api-spec-mockmvc:0.17.1")
    ... 생략
}

openapi3 {
    this.setServer("https://localhost:8080") // list로 넣을 수 있어 각종 환경의 URL을 넣을 수 있음!
    title = "My API"
    description = "My API description"
    version = "0.1.0"
    format = "yaml" // or json
}

1-2. task 확인

의존성을 추가하고 gradle의 tasks를 보면 아래 사진처럼 openapi3 과 같은 task가 추가돼있다.

 

여기서 openapi3 task를 실행해보면 아래 사진처럼 build/api-spec 경로에 openapi3.yaml 파일이 생성된다.

 

openapi3.yaml 파일을 보면 아래와 같다.


2. Swagger-UI standalone, 리소스 핸들러 설정

2-1. Swagger-ui 정적 파일 설치

위 사진은 Swagger-ui 정적 파일 설치 사이트 하단의 설명이다.

위 페이지에서 latest release 를 다운 받는 링크로 이동하여 압축 파일을 받아준다.

2-2. 스프링부트의 정적 파일 경로에 추가

압축을 해제하면 아래 사진처럼 나오는데, 여기서 /dist 의 내용물만 필요하다.

 

/dist 내용물을 아래 사진처럼 스프링부트의 /resources/static/swagger-ui 로 옮겨준다.

2-3. 파일명 변경 및 불필요한 파일 제거

index.html 은 swagger-ui.html 로 파일명을 변경하였다.

또한 불필요한 파일들은 삭제해주었다.

  • oauth2-redirect.html
  • swagger-ui.js
  • swagger-ui-es-bundle-core.js
  • swagger-ui-es-bundle.js

2-4. swagger-initializer.js 파일의 url 수정

swagger-initializer.js의 코드를 보면 아래 사진과 같다.

1-2 에서는 openapi3 task 를 돌리면 OAS 파일이 생성됐지만, 최종적으로는

Spring Rest Docs와 연동하여 빌드 시 테스트 코드를 돌릴 때 OAS 파일을 만들게 될것이다.

swagger-initializer.js에서 OAS 파일을 읽어 API 시각화를 하기 때문에 이를 우리가 만들 OAS 파일을 참조하도록 수정한다.

여기서 url 부분을 수정하여 우리가 만들 OAS 파일을 참조하도록 한다.

Swagger-initializer.js

2-5. 리소스 핸들러 설정

브라우저에서 스프링부트 애플리케이션 서버의 /static/swagger-ui 경로에 있는 정적 파일에 접근하기 위해서는

리소스 핸들러 설정을 바꿔줘야 한다.

(사실 이 부분은 2-4 에서 url 을 /static/swagger-ui/openapi3.yaml 으로 했을때는 필요했는데

본인은 url을 /swagger-ui/openapi3.yaml로 설정해서 굳이 필요 없을것 같기는 하다.)

@Configuration
public class AppConfig implements WebMvcConfigurer {

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/static/**").addResourceLocations("classpath:/static/");
    }
}

3. 생성된 OAS 파일을 Swagger 디렉터리로 복사(copyOasToSwagger)

1-2에서 openapi3 task를 실행하면 build/api-spec 경로에 openapi3.yaml 파일이 생성되는걸 확인했다.

이걸 2에서 생성한 /static/swagger-ui 경로에 넣고 swagger-ui.html로 읽을 것이다.

build.gradle.kts에 아래 코드를 추가하여 이를 위한 task를 추가한다.

// build.gradle.kts

tasks.withType<Test> {
    useJUnitPlatform()
    // 기존에 있는 test 쪽에 이 코드를 추가
    finalizedBy("copyOasToSwagger")  // test 후 copyOasToSwagger task 진행
}

val buildDir = layout.buildDirectory.get().asFile
tasks.register<Copy>("copyOasToSwagger") {

    dependsOn("openapi3") // openapi3 Task가 먼저 실행되도록 설정
    delete("src/main/resources/static/swagger-ui/openapi3.yaml") // 기존 OAS 파일 삭제
    from("$buildDir/api-spec/openapi3.yaml") // 복제할 OAS 파일 지정
    into("src/main/resources/static/swagger-ui/") // 타겟 디렉터리로 파일 복제
}

tasks {
    bootJar {
        // openapi3.yaml 파일을 /static/swagger-ui 경로로 복사에 성공한 후 bootJar 진행
        dependsOn("copyOasToSwagger")
        from("build/api-spec") {
            into ("BOOT-INF/classes/static/swagger-ui")
        }
    }
}

 

위 코드를 추가하고 빌드나 테스트를 돌려서 로그를 살펴보면 아래처럼 task 를 진행함을 확인할 수 있다.

test -> openapi3 -> copyOasToSwagger -> bootJar -> build 의 순서로 task 를 진행한다.


4. Sample Controller 코드 및 Rest Docs 테스트 작성

간단하게 테스트용으로 컨트롤러를 작성하였다.

TestController

@RestController
@RequestMapping("/hi")
public class TestController {

    @GetMapping("/hi1")
    public String test() {
        return "hi1";
    }

    @GetMapping("/hi2")
    public String test2() {
        return "hi2";
    }
}

TestControllerTest

Spring Rest Docs는 MockMvc 와 결합해서 동작한다.

컨트롤러 테스트 코드를 작성할 때 아래 두 가지를 목적에 맞게 사용해야 한다.

  • Rest Docs 사용- MockMvcRestDocumentation.document 
  •   Swagger 사용 - MockMvcRestDocumentationWrapper.document

본인은 Rest Docs는 사용하지 않고 Swagger 용도로만 사용하였다.

어떤 메서드를 사용하는지 참고하기 쉽도록 패키지까지 올려두었다.

package com.example.dream;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.restdocs.RestDocumentationExtension;
import org.springframework.test.web.servlet.MockMvc;

import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document;
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureRestDocs
@ExtendWith(RestDocumentationExtension.class)
class TestControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @DisplayName("")
    @Test
    void test() throws Exception {
        mockMvc.perform(get("/hi/hi1")
                .accept(MediaType.APPLICATION_JSON)
            )
            .andDo(print())
            .andExpect(status().isOk())
            .andDo(document("나는짱1"));
    }

    @DisplayName("")
    @Test
    void test2() throws Exception {
        mockMvc.perform(get("/hi/hi2")
                .accept(MediaType.APPLICATION_JSON)
            )
            .andDo(print())
            .andExpect(status().isOk())
            .andDo(document("나는짱2"));
    }
}

 


5. 결과 확인

아래 사진처럼 Production 코드를 침투하지 않고도 Swagger 의 API Test가 가능하다.

또한 Spring Rest Docs 의 테스트 코드를 통한 유지 보수성 또한 확보 되었다.

 

위 결과는 빌드하고 java -jar 명령어로 실행해본 결과이다.

배포 환경에서는 서버 URL 설정을 변경해줘야할 수 있다.

그때는 build.gradle.kts 의 openapi3 쪽의 서버 URL을 변경해주자.

혹은 주석 달려있는것 처럼 List 형태로 넣어서 여러개의 URL을 사용해도 될듯 하다.

yml 파일로 동적으로 이용할 수 있는 방법도 있을듯 하니 추후 사용할 때 가볍게 정리할것 같다.

 

참고자료

 

OpenAPI Specification을 이용한 더욱 효과적인 API 문서화 | 카카오페이 기술 블로그

사실상의 표준으로 발돋움 중인 OpenAPI Specification을 이용한 API 문서화 방법(Swagger와 Spring REST Docs의 장점을 합치는 방법)을 공유드립니다.

tech.kakaopay.com

 

 

내가 만든 API를 널리 알리기 - Spring REST Docs 가이드편

'추석맞이 선물하기 재개발'에 차출되어 API 문서화를 위해 도입한 Spring REST Docs 를 소개합니다.

helloworld.kurly.com

 

 

[리팩토링] Swagger UI + Spring RestDocs 적용기

Swagger UI 와 Spring RestDocs 의 장 단점의 비교Swagger UI 의 장점직관적인 UI스웨거는 API 에 대한 요청과 응답 등을 시각적으로 표현하여 사용자가 쉽게 이해할 수 있습니다실시간 테스트API 엔드포인트

oth3410.tistory.com

 

API 문서화

개발자에게는 API를 만드는것 뿐 아니라 이에 대한 문서화 또한 필요하다.

수동으로 Notion이나 스프레드 시트에 정리할 수도 있겠지만 이는 일일이 최신화를 해줘야해서 너무 불편하다.

이런 불편함을 해소하기 위해 API 문서화를 자동으로 해주는 툴이 나왔고, 유명한건 아래 두가지 정도가 있다.

  • Spring Rest Docs
  • Swagger

한번 이 두가지를 비교해보자.


Spring Rest Docs  vs  Swagger

Spring Rest Docs

Spring Rest Docs는 컨트롤러 테스트를 통해 Integration 테스트를 진행하면서 최신화하는 형식이다.

테스트 코드를 통한 방식이기 때문에 Production 코드에 영향을 미치지 않아서 깔끔하게 분리가 가능하다.

또한 테스트를 강제하기 때문에 유지보수에 도움을 준다.

하지만 Swagger처럼 API Test 기능을 지원하지 않고 가시성이 조금 부족하다는 단점이 있다.

아래는 Rest Docs의 예시 이미지이다.

Swagger

Swagger는 컨트롤러나 DTO 등에 애노테이션을 다는걸로 API 문서를 만들어준다.

이전 글에서 Swagger를 써서 API 문서화를 해보았다.

Swagger는 API Test 기능을 지원하고 문서의 가시성이 좋다.

아래는 Swagger의 예시 이미지이다.

 

하지만 Swagger 문서화를 위한 코드가 Production 코드에 너무 깊게 침투한다.

그래서 아래처럼 컨트롤러 코드가 너무 지저분해진다.


Spring Rest Docs + Swagger 병용

Swagger를 쓰면서 늘 불편했던 컨트롤러 코드가 지저분해지는 단점을 커버할 수 있단 말에 굉장히 설렜다.

이 방법에 대한 간단한 설명은 아래와 같다.

OpenApi Specification(OAS) 기반 API 문서화

Swagger 팀이 SmartBear Software에 합류하면서 Swagger Spec.이 OpenApi Spec.으로 명칭이 바뀌었고,

오늘날에는 RESTful API 스펙에 대한 사실상의 표준으로서 활용되고 있다고 한다.

Swagger-ui는 이 OAS를 해석하여 API 스펙을 시각화해준다.

또한 Postman, Paw 같은 API Client들도 OAS를 지원하고 있어 OAS를 사용하면 활용도가 높을것으로 예상된다.

OAS를 생성하는 restdocs-api-spec 오픈소스 

그렇다면 Spring REST Docs와 Swagger의 장점을 어떻게 합칠 수 있을까? 

독일 기업 epages에서 Spring REST Docs를 연동하여 OAS 파일을 만들어주는 오픈소스(restdocs-api-spec)를 제공하고 있다.

위에서 Swagger-ui는 OAS를 해석해서 API 스펙을 시각화 해준다고 했다.

따라서 이 오픈소스를 이용해서 OAS 파일을 생성하고 Swagger-ui로 띄우면 된다!

 

 

최근에 한 팀 프로젝트에서는 Swagger를 사용하였는데, Production 코드에 침투하는게 마음에 들지는 않았지만 Swagger의 장점 때문에 유지했었다. (사실 팀원이 테스트 코드를 잘 작성하지 않아서 RestDocs 를 쓰기는 힘들기도 했다.)

하지만 이처럼 Spring Rest Docs와 Swagger를 혼용하여 장점만 취하는 방법을 접하였고 다음 글에서 시도해보려 한다.

API 인증 필요 여부 개별 관리

이전 글에서 API 인증 필요 여부를 표기하려면 @SecurityRequirement 를 이용했었다.

하지만 이 애노테이션을 클래스에 붙이면 해당  클래스 아래의 모든 엔드포인트에 자물쇠가 표시된다.

만약 그 중 1~2개 정도는 인증이 필요 없어서 자물쇠 표시를 하지 않으려면 어떡 해야할까?

애노테이션을 메서드 단위로 붙여서 인증이 필요 없는 메서드만 안붙이기에는 너무 번거롭다.

이런 상황을 위해 API 인증이 불필요한 메서드에만 애노테이션을 붙여서 자물쇠를 제거하는 방법을 정리해두려 한다.


구현하기

구현 순서는 아래와 같다.

  1. 커스텀 애노테이션 추가
  2. SwaggerConfig 추가 설정
  3. 컨트롤러에 커스텀 애노테이션 추가
  4. 결과 확인

1. 커스텀 애노테이션 추가

SecurityNotRequired 라는 이름의 애노테이션을 추가해준다.

이름은 원하는대로 설정해도 상관 없다.

SecurityNotRequired

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface SecurityNotRequired {
}

2. SwaggerConfig 추가 설정 

기존 설정에서 아래처럼 OperationCustomizer 를 빈등록해준다.

여기서 애노테이션을 등록해줄 때는 본인이 만든 커스텀 애노테이션으로 등록해주면 된다.

SwaggerConfig

... 기존 설정

@Bean
public OperationCustomizer customize() {
    return (Operation operation, HandlerMethod handlerMethod) -> {
        SecurityNotRequired annotation = handlerMethod.getMethodAnnotation(SecurityNotRequired.class);
        // SecurityNotRequire 어노테이션있을시 스웨거 시큐리티 설정 삭제
        if (annotation != null) {
            operation.security(Collections.emptyList());
        }
        return operation;
    };
}

3. 컨트롤러에 커스텀 애노테이션 추가

클래스에 @SecurityRequirement 애노테이션을 추가하고

인증이 필요 없는 엔드포인트에만 @SecurityNotRequired 애노테이션을 추가하였다.

4. 결과 확인

스웨거 UI를 보면 아래처럼 애노테이션을 추가해준 부분에는 자물쇠 표시가 사라져있음을 확인할 수 있다.

배포 환경에서 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. 멤버 등록

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

HTTPS에서 스웨거

보통 프로젝트를 진행하면서 서비스를 배포하고 도메인을 붙이고 HTTPS를 적용할 것이다.

하지만 스웨거의 기본 서버 URL은 http://localhost:8080 이므로

HTTPS를 적용할 때 스웨거에서 설정을 추가해주지 않으면 이 글처럼 CORS 에러를 만나는 수가 있다.

따라서 환경에 따라 스웨거 서버 설정을 변경해주어야 한다.


1.  SwaggerConfig 설정 추가

SwaggerConfig

아래처럼 서버 설정을 추가하고 환경변수로 분리하여 환경에 맞게 관리한다.

@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()); // 여기서 서버 URL 설정 가능 -> 환경 변수로 관리
        server.description("백엔드 도메인");
        return new OpenAPI()
            .addServersItem(server)
            .info(new Info().title("PAWLAND API")
                .description("PAWLAND API SWAGGER UI입니다."));
    }
}

2. 결과 확인

아래처럼 스웨거 서버 URL을 yml으로 관리하여 프로필에 따라 환경 변수를 다르게 할 수 있다.

1. 로컬에서 테스트 시 서버 URL - application-local.yml

2. 배포 시 서버 URL - application-prod.yml

 

+ Recent posts