SY 개발일지
article thumbnail

스프링과 OAuth 를 이용한 프로젝트를 진행중입니다. 

구매자는 예약을 할 수 있는데, 이러한 예약 내역에 대해 카카오 캘린더로 일정을 추가해주려고 합니다.

이 로직을 작성해주기 위해 다음 카카오 톡캘린더 docs를 참고하여 로직을 작성해주었습니다.

https://developers.kakao.com/docs/latest/ko/talkcalendar/rest-api

 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com

 

전체적인 로직은 다음과 같습니다.

 

보기에는 어려울 수 있으니 하나씩 천천히 해보도록 하겠습니다.

 

 

액세스 토큰 저장 (스킵 가능)

카카오 API의 캘린더 기능을 사용하기 위해서는 로그인 유저의 액세스 토큰이 필요합니다. 하지만, 저는 OAuth2를 이용하여 로그인 시 사용자의 데이터만을 가져왔고 이러한 작업들은 Spring 의 OAuth2가 자동으로 해주었기 때문에 액세스토큰을 따로 DB에 추가하는 작업을 해주었습니다. 만일 이전 로직에 액세스 토큰을 저장하였거나 가져오는 로직이 있다면 해당 작업은 패스하셔도 됩니다.

 

먼저, 카카오 액세스 토큰을 저장하는 로직을 작성해주도록 하겠습니다.

Customer entity에 accessToken을 저장하는 컬럼을 생성해줍니다. 저는 구매자의 경우 소셜로그인만 지원하기 때문에 테이블을 따로 생성해주지 않고, 컬럼만 생성해주었습니다.

@Entity
public class Customer extends User{
	
    ...

    @Column(nullable = false)
    private String accessToken;

}

 

CustomerOAuth2UserService에서 accessToken을 가져올 수 있습니다.

CustomerOAuth2UserService의 loadUser는 OAuth2UserRequest라는 클래스를 인자로 갖습니다. 여기서, OAuth2UserRequest는 accessToken을 필드로 가지고 있습니다.

public class OAuth2UserRequest {

	private final ClientRegistration clientRegistration;

	private final OAuth2AccessToken accessToken;

	private final Map<String, Object> additionalParameters;
	
	/**
	 * Returns the {@link OAuth2AccessToken access token}.
	 * @return the {@link OAuth2AccessToken}
	 */
	public OAuth2AccessToken getAccessToken() {
		return this.accessToken;
	}

}

 

따라서, userRequest의 accessToken을 가져온 후, 그 value값을 가져오면 됩니다.

저는, 그 value값을 DB에 직접 저장해주었습니다

@Service
@Slf4j
@RequiredArgsConstructor
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

    private final UserRepository userRepository;
    private final CustomerRepository customerRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
       
       ...
       
        // OAuth2UserRequest에서 accessToken을 가져옴
        OAuth2AccessToken accessToken = userRequest.getAccessToken();

        // 저장
        Customer customer = save(attributes, accessToken.getTokenValue());


        // 여기서 리턴해주는 값을 successHandler에서 authentication 객체에서 확인할 수 있음.
        return new DefaultOAuth2User(
                Collections.singleton(new SimpleGrantedAuthority(customer.getType()))
                , toMap(customer)
                , attributes.getNameAttributeKey());
    }

	...
    
    private Customer save(OAuthAttributes attributes, String accessToken) {
        Customer customer = customerRepository.findByUidAndLoginMethod(attributes.getUid(), attributes.getLoginMethod())
                // 우리 프로젝트에서는 유저의 닉네임/사진에 대한 실시간 정보가 필요 없기 때문에 update는 하지 않는다.
                .orElse(attributes.toEntity());
        customer.updateAccessToken(accessToken);
        return customerRepository.save(customer);
    }


}

 

 

서브캘린더 추가 요청

캘린더 API 를 이용하기 위해선 우선, 사용자에게 승인을 받아야 합니다.

만약 동의를 받지 않았다면 카카오에서 자동적으로 동의를 받도록 해줍니다.

 

https://developers.kakao.com/docs/latest/ko/talkcalendar/rest-api#common-parameter-color

 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com

 

이 때 권한이 없으면 다음과 같은 에러가 발생합니다.

org.springframework.web.client.HttpClientErrorException$Forbidden: 403 Forbidden: "{"msg":"insufficient scopes.","code":-402,"api_type":"CALENDAR_CREATE_CALENDAR","required_scopes":["talk_calendar"],"allowed_scopes":["account_email","profile_image","profile_nickname"]}"
더보기

에러 전체 보기

2024-06-03T01:33:04.076+09:00 ERROR 23740 --- [nio-8080-exec-2] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springframework.web.client.HttpClientErrorException$Forbidden: 403 Forbidden: "{"msg":"insufficient scopes.","code":-402,"api_type":"CALENDAR_CREATE_CALENDAR","required_scopes":["talk_calendar"],"allowed_scopes":["account_email","profile_image","profile_nickname"]}"] with root cause

org.springframework.web.client.HttpClientErrorException$Forbidden: 403 Forbidden: "{"msg":"insufficient scopes.","code":-402,"api_type":"CALENDAR_CREATE_CALENDAR","required_scopes":["talk_calendar"],"allowed_scopes":["account_email","profile_image","profile_nickname"]}"
	at org.springframework.web.client.HttpClientErrorException.create(HttpClientErrorException.java:109) ~[spring-web-6.1.6.jar:6.1.6]
	at org.springframework.web.client.DefaultResponseErrorHandler.handleError(DefaultResponseErrorHandler.java:183) ~[spring-web-6.1.6.jar:6.1.6]
	at org.springframework.web.client.DefaultResponseErrorHandler.handleError(DefaultResponseErrorHandler.java:137) ~[spring-web-6.1.6.jar:6.1.6]
	at org.springframework.web.client.ResponseErrorHandler.handleError(ResponseErrorHandler.java:63) ~[spring-web-6.1.6.jar:6.1.6]
	at org.springframework.web.client.RestTemplate.handleResponse(RestTemplate.java:942) ~[spring-web-6.1.6.jar:6.1.6]
	at org.springframework.web.client.RestTemplate.doExecute(RestTemplate.java:891) ~[spring-web-6.1.6.jar:6.1.6]
	at org.springframework.web.client.RestTemplate.execute(RestTemplate.java:790) ~[spring-web-6.1.6.jar:6.1.6]
	at org.springframework.web.client.RestTemplate.postForEntity(RestTemplate.java:538) ~[spring-web-6.1.6.jar:6.1.6]
	at com.restgram.domain.user.service.CalendarServiceImpl.createCalender(CalendarServiceImpl.java:62) ~[main/:na]
	at com.restgram.domain.user.service.CalendarServiceImpl.customerCalendarAgree(CalendarServiceImpl.java:47) ~[main/:na]
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na]
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) ~[na:na]
	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na]
	at java.base/java.lang.reflect.Method.invoke(Method.java:568) ~[na:na]
	at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:354) ~[spring-aop-6.1.6.jar:6.1.6]
	at org.springframework.aop.framework.ReflectiveMethodInvocation.invokeJoinpoint(ReflectiveMethodInvocation.java:196) ~[spring-aop-6.1.6.jar:6.1.6]
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163) ~[spring-aop-6.1.6.jar:6.1.6]
	at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) ~[spring-aop-6.1.6.jar:6.1.6]
	at org.springframework.transaction.interceptor.TransactionInterceptor$1.proceedWithInvocation(TransactionInterceptor.java:123) ~[spring-tx-6.1.6.jar:6.1.6]
	at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:392) ~[spring-tx-6.1.6.jar:6.1.6]
	at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119) ~[spring-tx-6.1.6.jar:6.1.6]
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) ~[spring-aop-6.1.6.jar:6.1.6]
	at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) ~[spring-aop-6.1.6.jar:6.1.6]
	at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:720) ~[spring-aop-6.1.6.jar:6.1.6]
	at com.restgram.domain.user.service.CalendarServiceImpl$$SpringCGLIB$$0.customerCalendarAgree(<generated>) ~[main/:na]
	at com.restgram.domain.user.controller.CalendarController.customerCalendarAgree(CalendarController.java:26) ~[main/:na]
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na]
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) ~[na:na]
	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na]
	at java.base/java.lang.reflect.Method.invoke(Method.java:568) ~[na:na]
	at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:255) ~[spring-web-6.1.6.jar:6.1.6]
	at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:188) ~[spring-web-6.1.6.jar:6.1.6]
	at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:118) ~[spring-webmvc-6.1.6.jar:6.1.6]
	at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:926) ~[spring-webmvc-6.1.6.jar:6.1.6]
	at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:831) ~[spring-webmvc-6.1.6.jar:6.1.6]
	at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87) ~[spring-webmvc-6.1.6.jar:6.1.6]
	at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1089) ~[spring-webmvc-6.1.6.jar:6.1.6]
	at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:979) ~[spring-webmvc-6.1.6.jar:6.1.6]
	at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1014) ~[spring-webmvc-6.1.6.jar:6.1.6]
	at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:888) ~[spring-webmvc-6.1.6.jar:6.1.6]
	at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:658) ~[tomcat-embed-core-10.1.20.jar:6.0]
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:206) ~[tomcat-embed-core-10.1.20.jar:10.1.20]
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) ~[tomcat-embed-core-10.1.20.jar:10.1.20]
	at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:51) ~[tomcat-embed-websocket-10.1.20.jar:10.1.20]
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) ~[tomcat-embed-core-10.1.20.jar:10.1.20]
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) ~[tomcat-embed-core-10.1.20.jar:10.1.20]
	at org.springframework.web.filter.CorsFilter.doFilterInternal(CorsFilter.java:91) ~[spring-web-6.1.6.jar:6.1.6]
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-6.1.6.jar:6.1.6]
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) ~[tomcat-embed-core-10.1.20.jar:10.1.20]
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) ~[tomcat-embed-core-10.1.20.jar:10.1.20]
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:110) ~[spring-web-6.1.6.jar:6.1.6]
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) ~[tomcat-embed-core-10.1.20.jar:10.1.20]
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) ~[tomcat-embed-core-10.1.20.jar:10.1.20]
	at org.springframework.web.filter.CompositeFilter$VirtualFilterChain.doFilter(CompositeFilter.java:108) ~[spring-web-6.1.6.jar:6.1.6]
	at org.springframework.security.web.FilterChainProxy.lambda$doFilterInternal$3(FilterChainProxy.java:231) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:365) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.security.web.access.intercept.AuthorizationFilter.doFilter(AuthorizationFilter.java:100) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:374) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.security.web.access.ExceptionTranslationFilter.doFilter(ExceptionTranslationFilter.java:126) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.security.web.access.ExceptionTranslationFilter.doFilter(ExceptionTranslationFilter.java:120) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:374) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.security.web.session.SessionManagementFilter.doFilter(SessionManagementFilter.java:131) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.security.web.session.SessionManagementFilter.doFilter(SessionManagementFilter.java:85) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:374) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.security.web.authentication.AnonymousAuthenticationFilter.doFilter(AnonymousAuthenticationFilter.java:100) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:374) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter.doFilter(SecurityContextHolderAwareRequestFilter.java:179) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:374) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.security.web.savedrequest.RequestCacheAwareFilter.doFilter(RequestCacheAwareFilter.java:63) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:374) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter.doFilterInternal(DefaultLogoutPageGeneratingFilter.java:58) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-6.1.6.jar:6.1.6]
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:374) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter.doFilter(DefaultLoginPageGeneratingFilter.java:189) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter.doFilter(DefaultLoginPageGeneratingFilter.java:175) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:374) ~[spring-security-web-6.2.4.jar:6.2.4]
	at com.restgram.global.jwt.filter.JwtFilter.doFilterInternal(JwtFilter.java:52) ~[main/:na]
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-6.1.6.jar:6.1.6]
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:374) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter.doFilter(AbstractAuthenticationProcessingFilter.java:227) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter.doFilter(AbstractAuthenticationProcessingFilter.java:221) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:374) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter.doFilterInternal(OAuth2AuthorizationRequestRedirectFilter.java:181) ~[spring-security-oauth2-client-6.2.4.jar:6.2.4]
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-6.1.6.jar:6.1.6]
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:374) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.security.web.authentication.logout.LogoutFilter.doFilter(LogoutFilter.java:107) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.security.web.authentication.logout.LogoutFilter.doFilter(LogoutFilter.java:93) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:374) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.web.filter.CorsFilter.doFilterInternal(CorsFilter.java:91) ~[spring-web-6.1.6.jar:6.1.6]
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-6.1.6.jar:6.1.6]
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:374) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.security.web.header.HeaderWriterFilter.doHeadersAfter(HeaderWriterFilter.java:90) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.security.web.header.HeaderWriterFilter.doFilterInternal(HeaderWriterFilter.java:75) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-6.1.6.jar:6.1.6]
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:374) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.security.web.context.SecurityContextHolderFilter.doFilter(SecurityContextHolderFilter.java:82) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.security.web.context.SecurityContextHolderFilter.doFilter(SecurityContextHolderFilter.java:69) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:374) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter.doFilterInternal(WebAsyncManagerIntegrationFilter.java:62) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-6.1.6.jar:6.1.6]
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:374) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.security.web.session.DisableEncodeUrlFilter.doFilterInternal(DisableEncodeUrlFilter.java:42) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-6.1.6.jar:6.1.6]
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:374) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.security.web.FilterChainProxy.doFilterInternal(FilterChainProxy.java:233) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.security.web.FilterChainProxy.doFilter(FilterChainProxy.java:191) ~[spring-security-web-6.2.4.jar:6.2.4]
	at org.springframework.web.filter.CompositeFilter$VirtualFilterChain.doFilter(CompositeFilter.java:113) ~[spring-web-6.1.6.jar:6.1.6]
	at org.springframework.web.servlet.handler.HandlerMappingIntrospector.lambda$createCacheFilter$3(HandlerMappingIntrospector.java:195) ~[spring-webmvc-6.1.6.jar:6.1.6]
	at org.springframework.web.filter.CompositeFilter$VirtualFilterChain.doFilter(CompositeFilter.java:113) ~[spring-web-6.1.6.jar:6.1.6]
	at org.springframework.web.filter.CompositeFilter.doFilter(CompositeFilter.java:74) ~[spring-web-6.1.6.jar:6.1.6]
	at org.springframework.security.config.annotation.web.configuration.WebMvcSecurityConfiguration$CompositeFilterChainProxy.doFilter(WebMvcSecurityConfiguration.java:230) ~[spring-security-config-6.2.4.jar:6.2.4]
	at org.springframework.web.filter.DelegatingFilterProxy.invokeDelegate(DelegatingFilterProxy.java:352) ~[spring-web-6.1.6.jar:6.1.6]
	at org.springframework.web.filter.DelegatingFilterProxy.doFilter(DelegatingFilterProxy.java:268) ~[spring-web-6.1.6.jar:6.1.6]
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) ~[tomcat-embed-core-10.1.20.jar:10.1.20]
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) ~[tomcat-embed-core-10.1.20.jar:10.1.20]
	at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100) ~[spring-web-6.1.6.jar:6.1.6]
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-6.1.6.jar:6.1.6]
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) ~[tomcat-embed-core-10.1.20.jar:10.1.20]
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) ~[tomcat-embed-core-10.1.20.jar:10.1.20]
	at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93) ~[spring-web-6.1.6.jar:6.1.6]
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-6.1.6.jar:6.1.6]
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) ~[tomcat-embed-core-10.1.20.jar:10.1.20]
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) ~[tomcat-embed-core-10.1.20.jar:10.1.20]
	at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201) ~[spring-web-6.1.6.jar:6.1.6]
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-6.1.6.jar:6.1.6]
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) ~[tomcat-embed-core-10.1.20.jar:10.1.20]
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) ~[tomcat-embed-core-10.1.20.jar:10.1.20]
	at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:167) ~[tomcat-embed-core-10.1.20.jar:10.1.20]
	at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:90) ~[tomcat-embed-core-10.1.20.jar:10.1.20]
	at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:482) ~[tomcat-embed-core-10.1.20.jar:10.1.20]
	at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:115) ~[tomcat-embed-core-10.1.20.jar:10.1.20]
	at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:93) ~[tomcat-embed-core-10.1.20.jar:10.1.20]
	at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:74) ~[tomcat-embed-core-10.1.20.jar:10.1.20]
	at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:344) ~[tomcat-embed-core-10.1.20.jar:10.1.20]
	at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:391) ~[tomcat-embed-core-10.1.20.jar:10.1.20]
	at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63) ~[tomcat-embed-core-10.1.20.jar:10.1.20]
	at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:896) ~[tomcat-embed-core-10.1.20.jar:10.1.20]
	at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1736) ~[tomcat-embed-core-10.1.20.jar:10.1.20]
	at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52) ~[tomcat-embed-core-10.1.20.jar:10.1.20]
	at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1191) ~[tomcat-embed-core-10.1.20.jar:10.1.20]
	at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659) ~[tomcat-embed-core-10.1.20.jar:10.1.20]
	at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63) ~[tomcat-embed-core-10.1.20.jar:10.1.20]
	at java.base/java.lang.Thread.run(Thread.java:833) ~[na:na]

 

저는 이러한 경우에 프론트에게 응답을 보내, 권한을 얻은 후 재요청하도록 하였습니다.

이러한 권한을 얻는 프로세스는 단순히 로그인이 되어 있는 상태에서 재요청보내게 되면 자동으로 권한을 얻을 수 있는 페이지로 넘어가게 됩니다.

 

일정 생성

이번에는 일정을 생성해보도록 하겠습니다. 일단 일정 생성의 경우 다음 문서를 참고하였습니다.

https://developers.kakao.com/docs/latest/ko/talkcalendar/rest-api#common-event-create

 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com

 

저는 사용자가 예약을 하고, 캘린더에 동의가 되어 있다면, 일정에 추가해주었습니다.

    @Override
    @Transactional
    public void addReservation(Long userId, AddReservationRequest request) {
        
        ...
        
        // 만약 캘린더 동의가 되어 있다면 일정에 추가하기
        if (customer.isCalendarAgree()) {
            calendarEventService.createCalendarEvent(reservation);
        }

    }

 

다음은 일정생성 코드 전문입니다.

public class CalendarEventServiceImpl implements CalendarEventService{

    private final CalendarRepository calendarRepository;
    private final CalendarEventRepository calendarEventRepository;
    private final RestTemplate restTemplate;
    private final ObjectMapper objectMapper;  // entity -> json

    @Override
    @Transactional
    @Async("kakaoAsyncExecutor")
    public void createCalendarEvent(Reservation reservation) {
        String calendarId = calendarRepository.findByCustomer(reservation.getCustomer())
                .orElseThrow(() -> new RestApiException(CalendarErrorCode.CALENDAR_NOT_FOUND))
                .getCalendarId();

        // 일정 생성하기
        String eventId = requestCreateCalenderEvent(reservation, calendarId);
        log.info("사용자 일정 생성완료 : " + eventId);
        calendarEventRepository.save(CalendarEvent.builder()
                        .reservation(reservation)
                        .eventId(eventId)
                        .build());
    }


    // 카카오 API를 이용한 서브 캘린더 생성
    private String requestCreateCalenderEvent(Reservation reservation, String calendarId) {
        String url = "https://kapi.kakao.com/v2/api/calendar/create/event";

        HttpHeaders headers = new HttpHeaders();
        headers.set("Authorization", "Bearer " + reservation.getCustomer().getAccessToken());
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

        EventCreate eventCreate = EventCreate.of(reservation);

        String eventJson;
        try {
            eventJson = objectMapper.writeValueAsString(eventCreate);
        } catch (JsonProcessingException e) {
            throw new RuntimeException("Failed to convert EventCreate to JSON", e);
        }

        MultiValueMap<String, String> map= new LinkedMultiValueMap<>();
        map.add("event", eventJson);
        map.add("calendar_id", calendarId);

        HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(map, headers);

        ResponseEntity<CalendarEventResponse> response = restTemplate.postForEntity(
                url,
                requestEntity,
                CalendarEventResponse.class
        );

        if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
            String eventId = response.getBody().getEvent_id();
            return eventId;
        } else {
            throw new RuntimeException("Calendar creation failed: " + response.getStatusCode());
        }
    }
}

 

 

저의 경우에는 서드파티로 요청을 보내는 추가 작업의 경우에는 모두 비동기처리를 해주었습니다.

가장 중요한 요청을 보내는 코드만 자세히 살펴보도록 하겠습니다.

 

헤더의 경우, 이전에 저장한 유저의 액세스 토큰으로 인증을 해야 합니다.

또한 해당 요청의 타입을 APPLICATION_FORM_URLENCODED로 두어야 합니다.

HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + reservation.getCustomer().getAccessToken());
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

 

 

일정의 경우, 필요한 부분들이 있어 따로 DTO를 생성해주었습니다.

일정의 시간 부분에서 end_at는 필수부분이지만, 저희 프로젝트에서는 필요부분이 아니라 공통적으로 1시간 이후로 설정해주었습니다. 만일 여기서 end_at이 없다면, 요청 데이터를 해석할 수 없다는 에러가 발생하게 됩니다.

public class EventCreate {
    private String title;
    private EventTime time;
    private Integer[] reminders;
    private String color;

    static class EventTime {
        @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'")
        private LocalDateTime start_at;

        @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'")
        private LocalDateTime end_at;
        protected EventTime(LocalDateTime start_at, LocalDateTime end_at) {
            this.start_at = start_at;
            this.end_at = end_at;
        }

    }

    public static EventCreate of(Reservation reservation) {
        return EventCreate.builder()
                .title("[예약] "+reservation.getStore().getStoreName()+"("+reservation.getHeadCount()+"인)")
                .time(new EventTime(reservation.getDatetime(), reservation.getDatetime().plusHours(1)))
                .reminders(new Integer[]{120})
                .color("LAVENDER")
                .build();

    }
}

 

요청을 보낼 때에는 APPLICATION_FORM_URLENCODED 방식의 경우 Map을 인코딩하지 못하기 때문에, MultiValueMap 의 형태로 넣어줍니다. 

 

데이터를 설정해준 후, restTemplate을 통해 요청을 받고 응답을 보냅니다.

응답 데이터의 경우 body에 event_id라는 키값에 일정 id 가 삽입되어 보내지기 때문에, 저는 이 데이터를 db에 저장해주었습니다.

 

일정 삭제

일정 삭제의 경우 다음 문서를 참고해주었습니다.

https://developers.kakao.com/docs/latest/ko/talkcalendar/rest-api#common-event-delete

 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com

 

만일, 사용자가 예약을 취소한 경우, 해당 일정도 캘린더에서 삭제해주어야 합니다. 

우선 다음의 경우에 일정을 삭제해주도록 하겠습니다.

 

 

    @Override
    @Transactional
    public void cancelReservation(Long userId, DeleteReservationRequest request) {
        ...

        // 카카오 일정 삭제
        if (calendarEventRepository.existsByReservation(reservation)) {
            calendarEventService.deleteCalendarEvent(reservation);
        }
    }

 

다음은 일정 삭제 코드 전문입니다.

public class CalendarEventServiceImpl implements CalendarEventService{

    private final CalendarRepository calendarRepository;
    private final CalendarEventRepository calendarEventRepository;
    private final RestTemplate restTemplate;
    private final ObjectMapper objectMapper;  // entity -> json

    @Override
    @Transactional
    @Async("kakaoAsyncExecutor")
    public void deleteCalendarEvent(Reservation reservation) {
        CalendarEvent calendarEvent = calendarEventRepository
                .findByReservation(reservation)
                .orElseThrow(() -> new RestApiException(CalendarErrorCode.CALENDAR_EVENT_NOT_FOUND));

        // 일정 삭제하기
        requestDeleteCalenderEvent(reservation.getCustomer(), calendarEvent.getEventId());
        log.info("사용자 카카오 일정 삭제완료 : " + calendarEvent.getEventId());
        calendarEventRepository.delete(calendarEvent);
    }

    // 카카오 API를 이용한 서브 캘린더 삭제
    private boolean requestDeleteCalenderEvent(Customer customer, String eventId) {
        String url = "https://kapi.kakao.com/v2/api/calendar/delete/event?event_id="+eventId;

        HttpHeaders headers = new HttpHeaders();
        headers.set("Authorization", "Bearer " + customer.getAccessToken());
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

        HttpEntity<String> requestEntity = new HttpEntity<>(headers);

        ResponseEntity<CalendarEventResponse> response = restTemplate.exchange(
                url,
                HttpMethod.DELETE,
                requestEntity,
                CalendarEventResponse.class
        );

        if (response.getStatusCode().is2xxSuccessful()) {
            return true;
        } else {
            throw new RuntimeException("Calendar deletion failed: " + response.getStatusCode());
        }
    }
}

 

 

저의 경우에는 query string에 event_id를 넣어주었습니다. 그리고 나머지 로직은 일정 생성과 동일합니다.

 

 

restTemplate의 HttpMethod.DELETE의 경우 의존성을 주입할 때 Factory를 생성하여 바디를 갖게 하면 데이터를 보낼 수 있다고 들었습니다. 그래서 그 방식으로 여러번 테스트 해보고, api문서도 보며 현재 restTemplate내 HttpMethod.DELETE는 요청 바디를 가질 수 있는 걸로 확인하였습니다. 하지만 그렇게 하였을 경우에 계속 body: null 이라는 에러가 발생하여 쿼리 스트링으로 날리게 되었습니다.

 

[ 해당 트러블슈팅 참고 문서 ]

https://stackoverflow.com/questions/36899641/resttemplate-delete-with-body

 


 

다음은 카카오 일정 관련 깃허브 링크입니다. 제가 작성한 코드의 전문이 들어있으니 작성하실 때 참고하시면 좋을 것 같습니다.

https://github.com/soyeonnnb/restagram-api/tree/master/src/main/java/com/restgram/domain/calendar

 

restagram-api/src/main/java/com/restgram/domain/calendar at master · soyeonnnb/restagram-api

Contribute to soyeonnnb/restagram-api development by creating an account on GitHub.

github.com

 

 

 

profile

SY 개발일지

@SY 키키

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!