들어가기 전에

사이드 프로젝트로 진행했던 프로젝트에서 사용자 권한이 USER, COMPANY, INSTRUCTOR, ADMIN으로 나누어져 있었고 각 권한마다 접근이 가능한 API가 달라 권한 체크 로직이 필요한 상황이었다. 

 

프로젝트 아키텍처는 MSA 구조였으며, Spring Cloud Gateway에서 JWT 토큰으로부터 사용자 PK와 권한 정보를 추출하여 각 마이크로서비스로 라우팅하고 있었다. 따라서, 해당 권한 정보를 기반으로 API 접근을 제한할 수 있을 것이라고 판단했다.

 

Spring MVC 레벨에서 동작하며 컨트롤러 진입 전후에 특정 로직을 처리하는 Interceptor와 비즈니스 로직과 서브로직을 분리하여 횡단 관심사를 처리하는 AOP 중 고민했지만 특정 URI 경로에 대해 권한을 검사하는 Interceptor와 달리 커스텀 어노테이션으로 쉽게 권한을 체크할 API를 지정할 수 있는 AOP를 사용하기로 했다.

 

구현하기

RoleAuthorization

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RoleAuthorization {
    String[] roles();
}

AOP 포인트 컷으로 사용할 커스텀 어노테이션이다. 해당 어노테이션이 붙은 메소드에서는 권한을 체크해야 한다.

  • @Retention : 런타임 시까지 해당 어노테이션이 유지된다는 것을 의미, 어플리케이션이 실행 중에도 이 어노테이션 정보를 참조할 수 있다.
  • @Target : 해당 어노테이션이 메소드에만 적용될 수 있음을 의미한다.
  • String[] roles(): 어노테이션을 선언할 때 접근가능한 권한을 선언하기 위한 변수

RoleAuthorizationAop

@Aspect
@Component
public class RoleAuthorizationAop {
    @Pointcut("@annotation(com.example.companyservice.common.security.RoleAuthorization)")
    public void roleAuthorizationPointcut() {}

    @Around("roleAuthorizationPointcut()")
    public Object roleAuthorizationAdvice(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        RoleAuthorization roleAuthorization = signature.getMethod().getAnnotation(RoleAuthorization.class);
        String[] requiredRoles = roleAuthorization.roles();

        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder
                .currentRequestAttributes()).getRequest();
        String userRole = Utils.parseRole(request);

        if (Arrays.asList(requiredRoles).contains(userRole)) {
            return joinPoint.proceed();
        } else {
            throw new ApiException(ExceptionEnum.ACCESS_NOW_ALLOW_EXCEPTION);
        }
    }
}

RoleAuthorization 어노테이션이 선언되어 있는 메소드가 호출됐을 때 실행되는 권한체크 로직이다.

어노테이션을 포인트 컷 지시자로 지정하기 위해 roleAuthorizationPointcut 메소드를 생성하고 @Pointcut 어노테이션에 RoleAuthorization 어노테이션 인터페이스의 패키지 경로를 작성해주었다.

MethodSignature signature = (MethodSignature) joinPoint.getSignature();
RoleAuthorization roleAuthorization = signature.getMethod().getAnnotation(RoleAuthorization.class);
String[] requiredRoles = roleAuthorization.roles();

ProceedingJoinPoint의 Signature로부터 어노테이션 정보를 추출하고 어노테이션에 선언된 권한 정보를 조회한다. 그런 다음 Spring Cloud Gateway로부터 전달받은 권한과 비교하여 포함하면 정상적으로 메소드를 호출하고 포함하지 않으면 권한 에러를 발생한다.

 

어노테이션 포인트 컷 지시자는 아래와 같이 어노테이션 인터페이스를 인자로 받음으로써 더 간단하게 지정할 수 있다.

@Aspect
@Component
public class RoleAuthorizationAop {

    @Around("@annotation(roleAuthorization)")
    public Object roleAuthorizationAdvice(ProceedingJoinPoint joinPoint, RoleAuthorization roleAuthorization) throws Throwable {
        String[] requiredRoles = roleAuthorization.roles();

        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder
                .currentRequestAttributes()).getRequest();
        String userRole = Utils.parseRole(request);

        if (Arrays.asList(requiredRoles).contains(userRole)) {
            return joinPoint.proceed();
        } else {
            throw new ApiException(ExceptionEnum.ACCESS_NOW_ALLOW_EXCEPTION);
        }
    }
}

이제 권한 체크를 위한 AOP 설정이 모두 끝났다.

특정 API에 대해 접근을 제한하고 싶으면 아래와 같이 메소드에 @RoleAuthorization 어노테이션을 선언하고 허용 권한을 명시해주면 된다.

@GetMapping("/members")
@RoleAuthorization(roles = {"USER"})
@Operation(summary = "마이페이지 - 내 정보 수정 화면에 필요한 정보 조회", description = "내 정보 수정을 위한 내 정보 조회\n[피그마 링크](https://www.figma.com/file/nYEBH6aqCI37ZX0X6w7Ena?embed_host=notion&kind=file&mode=design&node-id=9914-16871&t=T0LzXHd8One1Acu9-0&type=design&viewer=1)")
@ApiResponse(responseCode = "200", description = "성공", content = @Content(schema = @Schema(implementation = MemberDetailResponseDto.class)))
public ResponseEntity<BaseResponseDto<MemberDetailResponseDto>> getMemberDetail(HttpServletRequest request) {
    long memberId = Utils.parseAuthorizedId(request);

    return ResponseEntity.status(HttpStatus.OK)
            .body(new BaseResponseDto<>(memberService.getMemberDetail(memberId)));
}

이번 프로젝트에서 AOP와 커스텀 어노테이션을 활용해 권한 체크 로직을 구현함으로써 코드의 중복을 줄이고 보다 모듈화된 방식으로 권한 관리를 처리할 수 있었습니다.

들어가기 전에

프로젝트를 진행하면 할 수록 쌓여만 가는 DTO 클래스를 보면서 관리의 필요성을 느끼게 되었다. 같은 도메인을 조회하더라도 API 마다 요구하는 데이터 필드가 다르기 때문에 불필요한 데이터 전송을 최소화하기 위해 API 마다 DTO 클래스를 생성하다 보니 유지보수하기 힘든 지경에까지 이르게 되었다. DTO 클래스 관리를 위한 리팩토링이 필요하다는 결론에 다달았고 상속 구조와 Static Inner 클래스를 사용하여 DTO 클래스를 관리했던 경험을 기록하고자 한다.

상속 구조를 이용한 DTO 관리

API 마다 데이터 구조가 다르다하더라도 공통적으로 조회하는 필드는 존재한다. 이 때 공통 필드를 모아놓은 DTO 클래스를 생성하고 새로운 API를 개발할 때 공통 DTO를 상속받아 새 DTO 클래스를 생성하면 같은 필드를 DTO 마다 작성할 필요가 없기 때문에 DTO 클래스를 관리하기가 쉬워진다.

FolderReseponse

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class FolderResponse {

    private Long id;

    private String name;

    private int count;
}

Folder 엔티티에 대한 ResponseDto 클래스이다.

PK, 이름, Folder에 포함된 데이터 수를 전송하며 Folder 데이터를 조회할 때 공통적으로 조회하는 필드이다.

 

만약 폴더에 포함된 데이터 정보까지 같이 조회해야하는 API를 개발해야한다고 가정해보자.

데이터 정보를 포함한 DTO 클래스를 아래와 같이 작성했다.

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class FolderDetailResponse {

    private Long id;

    private String name;

    private int count;

    private List<ArchiveResponse> archiveResponses;
}

id, name, count 필드가 FolderResponse 클래스와 중복되는데 위와 같은 경우 문제점은 만약 Folder 데이터를 조회하는 API를 여러 사람이 개발할 때 필드명이 제각각일 수 있다는 점이다. 또한 응답 데이터 구조에 변동이 생겼을 때 모든 DTO의 응답 구조를 수정해야한다는 점이다. 이를 방지하기 위해 상속 구조를 사용하여 아래와 같이 수정했다.

FolderResponse

@Getter
@NoArgsConstructor
@AllArgsConstructor
@SuperBuilder
public class FolderResponse {

    private Long id;

    private String name;

    private int count;

    public static FolderResponse fromEntity(Folder folder) {
        return fromEntity(folder, builder());
    }

    public static <T extends FolderResponse> T fromEntity(Folder folder, FolderResponse.FolderResponseBuilder<T, ?> builder) {
        return builder
                .id(folder.getId())
                .name(folder.getName())
                .count(folder.getFolderArchives().size())
                .build();
    }
}

FolderDetailResponse

@Getter
@NoArgsConstructor
@AllArgsConstructor
@SuperBuilder
public class FolderDetailResponse extends FolderResponse {

    private List<ArchiveResponse> archiveResponses;

    public static FolderDetailResponse fromEntity(Folder folder) {
        return fromEntity(
                folder,
                builder().archiveResponses(folder.getFolderArchives().stream().map(fa -> ArchiveResponse.fromEntity(fa.getArchive())).toList()));
    }
}

부모 클래스의 필드를 빌더 패턴으로 생성하기 위해 @SuperBuilder 어노테이션을 사용했다.

또한 FolderResponse 클래스를 상속 받은 자식 클래스에서 부모 클래스 필드를 포함한 응답 데이터를 생성하기 위해 FolderResponse 클레스에 제네릭 빌더 메서드를 구현했다.

 

위와 같은 방법으로 상속 구조를 이용해 DTO를 관리하면 공통 필드가 많을수록 중복된 코드를 줄일 수 있고, 유지보수성을 크게 향상시킬 수 있다. 또한, 새로운 API 개발 시 공통 필드를 재사용함으로써 일관된 응답 구조를 유지할 수 있고, 구조 변경이 있을 때도 부모 DTO만 수정하면 되기 때문에 수정 범위가 최소화된다.

Static Inner Class

상속 구조를 사용하더라도 API 마다 Dto 클래스를 생성해야 하는 것은 변함없다. FolderDetailResponse 클래스의 ArchiveResponse Dto 클래스의 경우 다른 API에서도 단독으로 사용되기 때문에 상관없지만 해당 API에서만 사용하는 Response 클래스의 경우 전부 따로 생성하면 관리해야 할 클래스 파일이 늘어나게 된다.

 

하지만 아래와 같이 FolderResponse 클래스 내부에서 Inner 클래스를 사용하면 별도의 Dto 클래스 파일을 생성할 필요없이 FolderResponse 클래스 안에서 전부 관리할 수 있다.

FolderResponse

@Getter
@NoArgsConstructor
@AllArgsConstructor
@SuperBuilder
public class FolderResponse {

    private Long id;

    private String name;

    private int count;

    private OwnerResponse ownerResponse;

    public static FolderResponse fromEntity(Folder folder, OwnerResponse ownerResponse) {
        return fromEntity(folder, ownerResponse, builder());
    }

    public static <T extends FolderResponse> T fromEntity(Folder folder, OwnerResponse ownerResponse, FolderResponse.FolderResponseBuilder<T, ?> builder) {
        return builder
                .id(folder.getId())
                .name(folder.getName())
                .count(folder.getFolderArchives().size())
                .ownerResponse(ownerResponse)
                .build();
    }

    @Builder
    public static class OwnerResponse {
    
        private long userId;

        private String name;

        public OwnerResponse from(User user) {
            return builder()
                    .userId(user.getId())
                    .name(user.getName())
                    .build();
        }
    }
}

폴더의 소유자 정보를 갖고 있는 OwnerResponse 클래스를 FolderResponse 클래스에서 static inner 클래스로 생성하였다.

이제 OwnerResponse 클래스에 대한 정보를 보고싶다면 클래스를 이동할 필요없이 FolderResponse에서 확인할 수 있으며 만약 OwnerResponse 클래스를 다른 API에서도 공용으로 사용하게 된다면 따로 클래스 파일로 분리하여 공용 DTO 패키지에서 관리하면 훨씬 수월할 것이다.

 

물론 가장 중요한 점은 체계적인 패키지 구조로 Dto 클래스를 구분하고 명확한 클래스 명명 규칙으로 한눈에 Dto 클래스를 식별할 수 있어야 한다는 점이다.

들어가기 전에

추상 팩토리 패턴이란??

추상 팩토리 패턴(Abstract Factory Pattern)이란, 서로 관련된 객체들의 집합을 생성할 수 있는 인터페이스를 제공하면서 구체적인 클래스는 지정하지 않는 생성 패턴 중 하나이다. 즉, 클라이언트는 구체적인 클래스에 의존하지 않고, 인터페이스를 통해 객체를 생성하고 사용하게 된다. 이 패턴을 사용하면 객체 생성 로직을 클라이언트 코드에서 분리하고, 다양한 구체적인 클래스들을 교체할 수 있는 유연성을 제공할 수 있다.

 

소셜 로그인을 구현할 때 Provider 마다 CLIENT_ID, REDIRECT_URI, CLIENT_SECRET이 다르고 사용자 정보도 다르게 넘어온다. if문으로 분기처리하여 구현할 수 있지만 추상 팩토리 패턴을 사용하면 서비스 코드에서 매번 분기처리할 필요없이 Provider 별로 제공된 팩토리 객체의 메소드를 호출하여 관련 객체를 생성함으로써 유지보수가 쉽고 깔끔한 코드를 작성할 수 있게 된다.

 

구현하기

추상 팩토리 패턴은 객체 생성 코드를 인터페이스로 추상화하고 상황별로 구현 클래스를 작성한 후 타입을 추상화한 인터페이스로 선언하여 사용한다.

 

OAuthRequestBodyFactory

package com.plcok.common.oauth;

import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;

import java.util.Map;

public interface OAuthRequestBodyFactory {

    MultiValueMap<String, String> createRequestBody(String token);

    default MultiValueMap<String, String> createDefaultRequestBody(
            String clientId,
            String redirectUri,
            String createSecret,
            String code
    ) {
        MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
        body.add("grant_type", "authorization_code");
        body.add("client_id", clientId);
        body.add("redirect_uri", redirectUri);
        body.add("client_secret", createSecret);
        body.add("code", code);
        return body;
    }

    String getRequestUrl();

    String getUserInfoRequestUrl();

    OAuth2Attributes createOauthAttribute(Map<String, Object> map);
}

 

객체 생성 코드를 추상화한 인터페이스이다.

  • createRequestBody : AccessToken을 받아오기 위해 작성해야 할 RequestBody 생성 메소드
  • createDefaultRequestBody : CLIENT_ID, REDIRECT_URI, CLIENT_SECRET, Code를 파라미터로 받아 RequestBody를 생성. 템플릿은 동일하기 때문에 디폴트 메소드로 선언, createRequestBody를 호출했을 때 구현부에서 호출한다.
  • getRequestUrl : AccessToken을 받아오기 위해 요청해야 할 URL
  • getUserInfoRequestUrl : AccessToken으로 사용자 정보를 받아오기 위해 요청해야 할 URL
  • createOauthAttribute : 조회한 사용자 정보를 담아둘 객체를 생성하는 메소드

 

OAuthRequestBodyFactory

package com.plcok.common.oauth;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.MultiValueMap;

import java.util.Map;

@Component
public class KakaoRequestBodyFactory implements OAuthRequestBodyFactory {

    @Value("${spring.security.oauth2.client.registration.kakao.client-id}")
    private String CLIENT_ID;

    @Value("${spring.security.oauth2.client.registration.kakao.redirect-uri}")
    private String REDIRECT_URI;

    @Value("${spring.security.oauth2.client.registration.kakao.client-secret}")
    private String CLIENT_SECRET;

    @Override
    public MultiValueMap<String, String> createRequestBody(String code) {
        return createDefaultRequestBody(CLIENT_ID, REDIRECT_URI, CLIENT_SECRET, code);
    }

    @Override
    public String getRequestUrl() {
        return "https://kauth.kakao.com/oauth/token";
    }

    @Override
    public String getUserInfoRequestUrl() {
        return "https://kapi.kakao.com/v2/user/me";
    }

    @Override
    public OAuth2Attributes createOauthAttribute(Map<String, Object> map) {
        return OAuth2Attributes.ofKakao(map);
    }
}

 

Provider가 카카오일 때 OAuthRequestBodyFactory 인터페이스에서 추상화한 메소드를 구현한 클래스이다. createRequestBody 메소드에서는 인터페이스에서 선언한 디폴트 메소드를 호출하고 있으며 createOauthAttribute 메소드에서는 OAuth2Attributes 클래스에서 선언한 카카오 사용자 정보를 생성하는 정적 팩토리 메소드를 호출하고 있다.

 

application.yml 파일에서 선언한 CLIENT_ID, REDIRECT_URI, CLIENT_SECRET 정보를 가져오기 위해 @Component 어노테이션을 선언하고 컴포넌트 스캔의 대상이 되도록 하여 빈 객체로 등록했다.

 

Provider가 구글인 경우 팩토리 객체도 구현하자

 

GoogleRequestBodyFactory

package com.plcok.common.oauth;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.MultiValueMap;

import java.util.Map;

@Component
public class GoogleRequestBodyFactory implements OAuthRequestBodyFactory {

    @Value("${spring.security.oauth2.client.registration.google.client-id}")
    private String CLIENT_ID;

    @Value("${spring.security.oauth2.client.registration.google.redirect-uri}")
    private String REDIRECT_URI;

    @Value("${spring.security.oauth2.client.registration.google.client-secret}")
    private String CLIENT_SECRET;

    @Override
    public MultiValueMap<String, String> createRequestBody(String code) {
        return createDefaultRequestBody(CLIENT_ID, REDIRECT_URI, CLIENT_SECRET, code);
    }

    @Override
    public String getRequestUrl() {
        return "https://www.googleapis.com/oauth2/v4/token";
    }

    @Override
    public String getUserInfoRequestUrl() {
        return "https://www.googleapis.com/oauth2/v2/userinfo";
    }

    @Override
    public OAuth2Attributes createOauthAttribute(Map<String, Object> map) {
        return OAuth2Attributes.ofGoogle(map);
    }
}

 

OAuth2Attributes

package com.plcok.common.oauth;

import com.plcok.user.entity.enumerated.ProviderType;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.Map;

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class OAuth2Attributes {

    private String email;

    private ProviderType providerType;

    private String providerUserId;

    public static OAuth2Attributes ofKakao(Map<String, Object> attributes) {
        Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
        String email = String.valueOf(kakaoAccount.get("email"));
        String providerUserId = String.valueOf(attributes.get("id"));

        return builder()
                .email(email)
                .providerType(ProviderType.KAKAO)
                .providerUserId(providerUserId)
                .build();
    }

    public static OAuth2Attributes ofGoogle(Map<String, Object> attributes) {
        String email = String.valueOf(attributes.get("email"));
        String providerUserId = String.valueOf(attributes.get("id"));

        return builder()
                .email(email)
                .providerType(ProviderType.GOOGLE)
                .providerUserId(providerUserId)
                .build();
    }
}

 

처음에 언급했듯이 인증 서버로부터 넘어오는 사용자 정보도 Provider 별로 다르다. 인증 서버로부터 넘어온 사용자 정보를 담아둘 객체이다. Provider 별로 정적 팩토리 메서드를 선언했다.

 

AuthUserController

package com.plcok.user.controller;

import com.plcok.user.dto.response.SocialLoginResponse;
import com.plcok.user.service.AuthUserService;
import com.plcok.common.oauth.GoogleRequestBodyFactory;
import com.plcok.common.oauth.KakaoRequestBodyFactory;
import com.fasterxml.jackson.core.JsonProcessingException;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/auth")
@Tag(name = "인증 관련 API")
public class AuthUserController {

    private final AuthUserService authUserService;
    private final GoogleRequestBodyFactory googleRequestBodyFactory;
    private final KakaoRequestBodyFactory kakaoRequestBodyFactory;

    @GetMapping("/user/kakao/callback")
    public ResponseEntity<SocialLoginResponse> kakaoLogin(@RequestParam("code") String code) throws JsonProcessingException {
        String accessToken = authUserService.getToken(code, kakaoRequestBodyFactory);
        return ResponseEntity.status(HttpStatus.OK)
                .body(authUserService.login(accessToken, kakaoRequestBodyFactory));
    }

    @GetMapping("/user/google/callback")
    public ResponseEntity<SocialLoginResponse> googleLogin(@RequestParam("code") String code) throws JsonProcessingException {
        String accessToken = authUserService.getToken(code, googleRequestBodyFactory);
        return ResponseEntity.status(HttpStatus.OK)
                .body(authUserService.login(accessToken, googleRequestBodyFactory));
    }
}

 

소셜 로그인 요청을 받는 RestController이다. Provider 별로 API를 생성하고 구현 팩토리를 파라미터로 넘겼다.

 

getToken

@Override
@Transactional(readOnly = true)
public String getToken(String code, OAuthRequestBodyFactory factory) throws JsonProcessingException {
    // HTTP Header 생성
    HttpHeaders headers = new HttpHeaders();
    headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");

    // HTTP Body 생성
    MultiValueMap<String, String> body = factory.createRequestBody(code);

    // HTTP 요청 보내기
    HttpEntity<MultiValueMap<String, String>> tokenRequest = new HttpEntity<>(body, headers);
    RestTemplate rt = new RestTemplate();
    ResponseEntity<String> response = rt.exchange(
            factory.getRequestUrl(),
            HttpMethod.POST,
            tokenRequest,
            String.class
    );

    // HTTP 응답 (JSON) -> 액세스 토큰 파싱
    String responseBody = response.getBody();
    ObjectMapper objectMapper = new ObjectMapper();
    JsonNode jsonNode = objectMapper.readTree(responseBody);
    return jsonNode.get("access_token").asText();
}

 

인증 서버로부터 AccessToken을 받아오는 getToken 메소드이다. HTTP Body를 생성하는 코드를 보면 factory.createRequestBody 메소드를 호출하고 있는데 OAuthRequestBodyFactory 인터페이스를 파라미터로 받기 때문에 호출 클래스에서 어떤 구현 팩토리를 넘기느냐에 따라 다른 RequestBody를 생성한다. 아래는 전체 서비스 코드이다.

 

AuthUserService

package com.plcok.user.service;

import com.plcok.common.oauth.OAuth2Attributes;
import com.plcok.common.oauth.OAuthRequestBodyFactory;
import com.plcok.user.entity.UserProvider;
import com.plcok.common.security.JwtUtil;
import com.plcok.user.entity.User;
import com.plcok.user.dto.response.SocialLoginResponse;
import com.plcok.user.repository.UserProviderRepository;
import com.plcok.user.repository.UserRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;

import java.util.Map;

@Service
@RequiredArgsConstructor
@Slf4j
public class AuthUserServiceImpl implements AuthUserService {

    private final JwtUtil jwtUtil;

    private final UserRepository userRepository;

    private final UserProviderRepository userProviderRepository;

    @Override
    @Transactional
    public SocialLoginResponse login(String token, OAuthRequestBodyFactory factory) {
        Map<String, Object> map = getUserAttributes(factory, token);
        OAuth2Attributes attributes = factory.createOauthAttribute(map);

        User user = findOrCreateUser(attributes);
        return SocialLoginResponse
                .from(jwtUtil.createToken(user.getId(), user.getRole(), user.getCreatedAt()));
    }

    private User findOrCreateUser(OAuth2Attributes attributes) {
        return userProviderRepository.findByProviderTypeAndProviderUserId(attributes.getProviderType(), attributes.getProviderUserId())
                .map(UserProvider::getUser)
                .orElseGet(() -> createUser(attributes));
    }

    private User createUser(OAuth2Attributes attributes) {
        User user = new User();
        userRepository.save(user);
        UserProvider userProvider = UserProvider.of(attributes.getProviderType(), user, attributes.getProviderUserId());
        userProviderRepository.save(userProvider);
        return user;
    }

    private Map<String, Object> getUserAttributes(OAuthRequestBodyFactory factory, String token) {
        HttpHeaders headers = new HttpHeaders();
        headers.setBearerAuth(token);
        HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(headers);
        RestTemplate restTemplate = new RestTemplate();
        ResponseEntity<String> response = restTemplate
                .exchange(factory.getUserInfoRequestUrl(), HttpMethod.GET, request, String.class);
        return parseResponseBody(response.getBody());
    }

    private Map<String, Object> parseResponseBody(String responseBody) {
        try {
            return new ObjectMapper().readValue(responseBody, new TypeReference<Map<String, Object>>() {});
        } catch (Exception e) {
            throw new RuntimeException("Failed to parse user attributes", e);
        }
    }

    @Override
    @Transactional(readOnly = true)
    public String getToken(String code, OAuthRequestBodyFactory factory) throws JsonProcessingException {
        // HTTP Header 생성
        HttpHeaders headers = new HttpHeaders();
        headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");

        // HTTP Body 생성
        MultiValueMap<String, String> body = factory.createRequestBody(code);

        // HTTP 요청 보내기
        HttpEntity<MultiValueMap<String, String>> tokenRequest = new HttpEntity<>(body, headers);
        RestTemplate rt = new RestTemplate();
        ResponseEntity<String> response = rt.exchange(
                factory.getRequestUrl(),
                HttpMethod.POST,
                tokenRequest,
                String.class
        );

        // HTTP 응답 (JSON) -> 액세스 토큰 파싱
        String responseBody = response.getBody();
        ObjectMapper objectMapper = new ObjectMapper();
        JsonNode jsonNode = objectMapper.readTree(responseBody);
        return jsonNode.get("access_token").asText();
    }
}

 

추상 팩토리 패턴을 사용한 이 구조는 Provider 별로 다른 요청 처리를 깔끔하게 분리해준다. 새로운 Provider가 추가될 때도 기존 코드를 크게 수정하지 않고 팩토리 객체만 구현하면 되므로, 유지보수가 용이하고 코드의 복잡도를 줄일 수 있다.

만들면서 배우는 클린 아키텍처라는 책을 읽고 정리한 내용입니다.

헥사고날 아키텍처란?

헥사고날 아키텍처알리스테어 콕번(Alistair Cockburn)이 만든 용어로 모듈 간 종속성을 외부에서 내부로만 향하게 하여 의존성을 역전시켜 객체 의존도를 낮추는데 목적을 둔 클린 아키텍처의 원칙들을 조금 더 구체적으로 정립한 개념이다.

아래 그림이 헥사고날 아키텍처가 어떤 구조로 이루어져 있는지 보여주는 좋은 예시이다.

 

 

헥사고날 아키텍처는 어플리케이션 계층이 각 어댑터와 상호작용하기 위해 특정 포트를 제공하기 때문에 포트와 어댑터(ports-and-adapters) 아키텍처라고도 불린다.

 

 

위와 같은 패키지 구조를 가지고 있으며 어댑터, 어플리케이션, 도메인으로 나눈 뒤 각 패키지간에 통신할 때는 어플리케이션 계층에서 제공하는 인터페이스 기반인 입출력 포트를 통해서 통신이 이루어진다.

 

각 패키지에 대해 조금 더 설명하자면

domain 패키지는 해당 패키지가 속한 Account 모델과 관련된 도메인 모델이 포함되어있다.

application 패키지는 도메인 모델을 둘러싼 서비스 계층(비즈니스 로직)을 포함하고 있으며 웹 계층은 인커밍 포트 인터페이스(SendMoneyUseCase)를 통해 application 계층을 호출하고 application 계층은 아웃고잉 포트 인터페이스(LoadAccountPort, UpdateAccountStatePort)를 통해 영속성 계층을 호출한다.

adapter 패키지는 application 계층의 인커밍 포트를 호출하는 웹 어댑터와 아웃고잉 포트에 대한 구현을 제공하는 아웃고잉 어댑터를 포함하고 있다.

 

만들고 보니 굉장히 복잡해 보인다. 이렇게 패키지 구조를 구성함으로써 얻는 이득이 뭘까?? 오히려 패키지를 구조화하는데 더 많은 비용을 소모하게 되는건 아닐까??

 

책을 읽으면서 가장 공감이 됐던 부분은 새로운 코드를 작성할 때 이 코드가 어느 패키지에 속해야 하는지 항상 염두해 두어야 하고 이러한 과정이 클린 아키텍처를 구현하는 데 많은 도움이 된다는 점이다.

 

이 책을 읽기 전까지는 모든 프로젝트를 계층형 아키텍처로 개발을 해왔다.

계층형 아키텍처는 단순하다. 웹에서 클라이언트의 요청을 받아 도메인이나 비즈니스 계층에서 서비스 로직을 수행하고 영속성 계층에서 도메인 엔티티의 현재 상태를 조회하거나 변경하기 위한 작업을 수행한다. 패키지 구조도 Controller, Service, Repository로 나눈 뒤 단순한 구조로 개발을 해왔다. 아마 웹 애플리케이션 개발 공부를 하는 사람이라면 모두 위와 같은 계층형 구조로 개발을 해봤을텐데 계층형 구조는 다음과 같은 단점이 존재한다.

1. 데이터베이스 주도 설계를 유도한다.

그동안 만들어 왔던 애플리케이션을 되돌아 보면 항상 DB 설계를 먼저 하고 엔티티를 정의한 후 비즈니스 로직을 구현해왔다. 이는 비즈니스 관점에서는 맞지 않으며 비즈니스 관점에서 봤을때 가장 중요한 비즈니스 로직을 먼저 구현한 후 영속성 계층과 웹 계층을 만들어야 한다. 

2. 지름길을 택하기 쉬워진다.

계층형 아키텍처에서는 같은 계층에 있는 컴포넌트나 아래에 있는 계층에만 접근이 가능하다. 만약 상위 계층에 있는 컴포넌트에 접근하고 싶다면 해당 컴포넌트를 아래로 내리면 가능하다. 하지만 이와 같은 일이 반복된다면 하위 계층의 규모는 점점 복잡하고 비대해지며 계층형 구조에서는 이런일이 자주 발생한다.

3. 테스트하기 어려워진다.

계층형 아키텍처에서 발생하는 또 다른 문제는 중간계층을 건너 뛰는 것이다. 만약 영속성 계층에서 단 하나의 엔티티의 필드만 조작하면 되는 경우 도메인 계층을 건너뛰어 바로 영속성 계층에 접근하는 행위를 하게 된다.

 

이 경우에는 웹 계층에서 도메인 로직을 구현하게 되고 이와 같은 일이 반복되면 책임이 섞이고 핵심 도메인 로직이 퍼져 나가게 된다. 뿐만 아니라 테스트 코드를 작성할 때 의존하는 객체가 많아져 단위 테스트의 복잡도가 올라가게 되고 테스트 코드를 작성하는 시간보다 mock 객체를 만드는 시간이 더 들어가게 된다.

4. 동시 작업이 어려워진다.

앞서 언급했듯이 계층형 아키텍처는 영속성 계층 -> 도메인 계층 -> 웹 계층 순으로 개발이 이루어 진다. 그렇기 때문에 개발자들은 계층 단위가 아닌 기능단위로 작업을 수행하는데 서로 다른 기능을 작업하더라도 계층형 구조에서는 컴포넌트간에 로직이 복잡하게 얽혀있어 같은 서비스를 동시에 작업하는 상황이 발생한다. 이렇게 되면 충돌이 발생할 수 있기 때문에 동시 작업이 어려워진다.

 

위와 같은 단점들을 헥사고날 아키텍처를 사용함으로써 한번 더 고민하게 되고 유지보수하기 쉬운 클린한 아키텍처를 만듬으로써 해결할 수 있게 된다.

원티드에서 제공하는 프리온보딩 챌린지 과정을 수강하면서 데이터베이스 구조를 주 데이터베이스(Master DB)서브 데이터베이스(Slave DB)로 나눠 쓰기 연산(Insert, Update, Delete)과 읽기 연산(Find)을 분기 처리하는 방법을 알게 되어 정리한 글입니다.

서론

대부분의 애플리케이션은 쓰기 연산보다 읽기 연산의 비중이 훨씬 높다. 따라서 더 나은 성능을 위해 데이터베이스 상태를 변경하는 생성, 수정, 삭제는 주 데이터베이스(Master DB)에서 처리하고 읽기 연산은 서브 데이터베이스(Slave DB)에서 처리한다.

 

 

연산 종류에 따라 DB를 구분하는 방법은 @Transactional 어노테이션을 사용한다. @Transactional의 readOnly 속성이 true면 읽기 연산이므로 Slave DB에서 처리하고 false면 Master DB에서 처리한다.

  1. @Transactional(readOnly = true) : 읽기 연산, Slave DB에서 처리
  2. @Transactional(readOnly = false) : 쓰기 연산, Master DB에서 처리

DB  Replication

 

[Spring-boot] Master - Slave 구조에 따른 Read, Write 분기

서론 데이터베이스를 이용한다면 대부분 쓰기보다 읽기 의 행위가 더 많습니다. DB의 부하를 줄이기 위해 다음과 같이 Master - Slave 구조를 많이 사용하는데요. 이러한 구조를 가지고 있을 때 Transe

k3068.tistory.com

코드를 작성하기 전에 먼저 Master DB와 Slave DB를 동기화 시켜주는 작업이 필요하다. (동기화 방법은 위 블로그를 참조했습니다.)

 

docker-compose.yml

version: "3"
services:
  db-master:
    build:
      context: ./
      dockerfile: master/Dockerfile
    restart: always
    environment:
      MYSQL_DATABASE: "db"
      MYSQL_USER: "user"
      MYSQL_PASSWORD: "password"
      MYSQL_ROOT_PASSWORD: "password"
    ports:
      - "3307:3306"
    # Where our data will be persisted
    container_name: "master_db"
    volumes:
      - my-db-master:/var/lib/mysql
      - my-db-master:/var/lib/mysql-files
    networks:
      - net-mysql

  db-slave:
    build:
      context: ./
      dockerfile: slave/Dockerfile
    restart: always
    environment:
      MYSQL_DATABASE: "db"
      MYSQL_USER: "user"
      MYSQL_PASSWORD: "password"
      MYSQL_ROOT_PASSWORD: "password"
    ports:
      - "3308:3306"
    # Where our data will be persisted
    container_name: "slave_db"
    volumes:
      - my-db-slave:/var/lib/mysql
      - my-db-slave:/var/lib/mysql-files
    networks:
      - net-mysql

# Names our volume
volumes:
  my-db-master:
  my-db-slave:

networks:
  net-mysql:
    driver: bridge

master/Dockerfile

FROM mysql:8.0.32
ADD ./master/my.cnf /etc/mysql/my.cnf

master/my.cnf

[mysqld]
log_bin = mysql-bin
server_id = 10
default_authentication_plugin=mysql_native_password

slave/Dockerfile

FROM mysql:8.0.32
ADD ./slave/my.cnf /etc/mysql/my.cnf

slave/my.cnf

[mysqld]
log_bin = mysql-bin
server_id = 11
relay_log = /var/lib/mysql/mysql-relay-bin
log_slave_updates = 1
read_only = 1
default_authentication_plugin=mysql_native_password

 

위와 같이 DB Replication에 필요한 설정 파일을 작성하고 Docker-compose.yml 파일이 있는 폴더 경로로 접근해 아래 순서대로 명령어를 수행한다.

 

docker-compose up -d

 

docker inspect {MASTER DB CONTAINER ID}

 

여기서 IPAddress 값 저장!!

 

docker exec -it {SLAVE DB CONTAINER ID} mysql -u root -p

 

위 명령어로 SLAVE DB CONTAINER에 접속 후 아래 명령어로 SLAVE DB를 세팅해 준다.

이때 'MASTER DB IP Address' 값은 위에서 저장한 IPAddress 값을 적용해 준다.

 

stop slave;

CHANGE MASTER TO 
MASTER_HOST='MASTER DB IP Address', 
MASTER_USER='root', 
MASTER_PASSWORD='password', 
MASTER_LOG_FILE='mysql-bin.000001', 
MASTER_LOG_POS=0, 
GET_MASTER_PUBLIC_KEY=1;

start slave;

 

SLAVE DB STATUS 확인

 

show slave status\G;

 

상태를 확인했을 때 Slave_IO_Running과 Slave_SQL_Running 속성이 Yes로 되있다면 DB 동기화 성공!!

만약 둘중 하나가 NO라면 Last_Errno를 확인 후 적절한 조취를 취하면 된다.

 

application.yml

spring:
  datasource:
    master:
      hikari:
        #mysql setting
        driver-class-name: com.mysql.cj.jdbc.Driver
        jdbc-url: jdbc:mysql://localhost:3307/db?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul
        username: root
        password: password
    slave:
      hikari:
        #mysql setting
        driver-class-name: com.mysql.cj.jdbc.Driver
        jdbc-url: jdbc:mysql://localhost:3308/db?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul
        username: root
        password: password

 

Master DB와 Slave DB를 나누는 설정은 SpringBoot의 application.yml 파일에서 쉽게 할 수 있다.

설정은 Docker-compose.yml 파일에서 생성한대로 Master DB는 3307 port로 Slave DB는 3308 port로 연결했다.

 

하나의 데이터소스를 사용하는 경우 스프링에서 자동으로 데이터소스를 생성하지만 위와 같이 2개 이상의 데이터소스를 사용하면 추가 코드를 작성하여 데이터소스를 직접 작성해야 한다.

AbstractRoutingDataSource.class

public class RoutingDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        return (TransactionSynchronizationManager.isCurrentTransactionReadOnly()) ? "slave" : "master";
    }
}

 

AbstractRoutingDataSource.class를 상속받아 determineCurrentLookupKey() 메서드를 재정의 했다.

현재 트랜잭션의 readOnly 속성true면 "slave", false면 "master" 문자열을 리턴

DataSourceConfig.class

@Configuration
@EnableAutoConfiguration(exclude = {DataSourceAutoConfiguration.class})
@EnableTransactionManagement
public class DataSourceConfig {

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.master.hikari")
    public DataSource masterDataSource() {
        return DataSourceBuilder.create()
                .type(HikariDataSource.class)
                .build();
    }

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.slave.hikari")
    public DataSource slaveDataSource() {
        return DataSourceBuilder.create()
                .type(HikariDataSource.class)
                .build();
    }

    @Bean
    @DependsOn({"masterDataSource", "slaveDataSource"})
    public DataSource routingDataSource(
            @Qualifier("masterDataSource") DataSource masterDataSource,
            @Qualifier("slaveDataSource") DataSource slaveDataSource) {

        RoutingDataSource routingDataSource = new RoutingDataSource();

        Map<Object, Object> datasourceMap = new HashMap<>();
        datasourceMap.put("master", masterDataSource);
        datasourceMap.put("slave", slaveDataSource);

        routingDataSource.setTargetDataSources(datasourceMap);
        routingDataSource.setDefaultTargetDataSource(masterDataSource);

        return routingDataSource;
    }

    @Bean
    @Primary
    @DependsOn("routingDataSource")
    public LazyConnectionDataSourceProxy dataSource(@Qualifier("routingDataSource") DataSource routingDataSource){
        return new LazyConnectionDataSourceProxy(routingDataSource);
    }
}

 

DataSourceConfig.class 파일을 작성하여 Master DB와 Slave DB DataSource를 직접 빈으로 등록해 주었다.

  • @EnableAutoConfiguration(exclude = {DataSourceAutoConfiguration.class}) : JPA에서 default로 설정하는 DataSource Config 파일을 제외
  • @EnableTransactionManagement : TransactionManager에 data source를 넣는 config를 호출
  • masterDataSource() : application.yml 파일에서 설정한 spring.datasource.master.hikari 프로퍼티를 사용하여 MASTER DB에 대한 데이터 소스를 설정
  • slaveDataSource() : application.yml 파일에서 설정한 spring.datasource.slave.hikari 프로퍼티를 사용하여 SLAVE DB에 대한 데이터 소스를 설정
  • routingDataSource() : masterDataSource와 slaveDataSource 빈을 인자로 받아 라우팅 데이터 소스를 생성
  • dataSource() : 트랜잭션 동기화 시점에 커넥션을 얻기 위한 설정, 만약 LazyConnectionDataSourceProxy 설정을 하지 않으면 트랜잭션 진입 전에 DataSource가 정해지고 커넥션을 연결하기 때문에 readOnly 속성으로 DataSource를 선택하는 것이 불가능하다.

테스트

왼쪽은 MASTER DB의 sql 로그를 실시간으로 출력한 이미지이고 오른쪽은 SLAVE DB의 sql 로그이다.

 

save 로직을 수행했을 때 MASTER DB 로그에서 insert query가 찍힌 것을 확인할 수 있었다.

 

반대로 select 로직을 수행했을 때는 SLAVE DB에 select query가 찍혀 readOnly 값에 따라 DataSource가 다르게 적용된 것을 확인할 수 있었다.

 

이전글에서 다뤘던 템플릿 메서드 패턴은 핵심기능과 부가기능을 분리해주기는 하지만 상속을 사용하기 때문에 부모, 자식 클래스 간에 강한 연관 관계가 생기거나, 핵심기능 클래스를 계속 생성해주어야 하는 등 여러가지 단점이 있었다. 전략 패턴은 상속이 아닌 위임을 사용하여 이러한 단점을 해결하면서 핵심기능과 부가기능을 분리한다.

 

위임이란 특정 클래스가 다른 클래스의 객체를 멤버로 갖고 있는 형태로 스프링의 의존성 주입 방식이 위임을 사용한 예시이다.

전략 패턴은 아래와 같이 변하지 않는 부분을 Context 클래스에 두고 변하는 부분을 Strategy 인터페이스로 만들어 해당 인터페이스를 구현하여 주입해주는 패턴이다. 이렇게 하면 Context 클래스가 인터페이스에만 의존하기 때문에 구현체를 변경하거나 새로 만들어도 Context 클래스에 영향을 미치지 않는다.

 

문제 코드

@Slf4j
public class TemplateMethodTest {

    @Test
    void templateMethodV0 () {
        logic1();
        logic2();
    }

    private void logic1() {
        long startTime = System.currentTimeMillis();

        // 비즈니스 로직 실행
        log.info("비즈니스 로직1 실행");
        // 비즈니스 로직 종료
        
        long endTime = System.currentTimeMillis();
        long resultTime = endTime - startTime;
        log.info("resultTime = {}", resultTime);
    }

    private void logic2() {
        long startTime = System.currentTimeMillis();

        // 비즈니스 로직 실행
        log.info("비즈니스 로직2 실행");
        // 비즈니스 로직 종료
        
        long endTime = System.currentTimeMillis();
        long resultTime = endTime - startTime;
        log.info("resultTime = {}", resultTime);
    }
}

 

위 코드에서 logic1()과 logic2()는 아래와 같이 중복되는 코드가 존재한다. 해당 코드는 부가 기능으로 두 logic에서 동일한 기능을 수행한다. 이러한 설계는 만약 부가 기능을 사용하는 logic이 100개가 존재할 때 이 부가 기능을 수정하려면 100개의 logic을 모두 수정해주어야 하기 때문에 좋은 방법이 아니다.

 

만약 공통된 코드를 추출하여 하나의 클래스로 관리할 수 있다면 부가 기능을 수정할 때 해당 클래스만 수정해주면 되기 때문에 유지보수하기 쉬워진다. 전략 패턴을 적용하여 문제를 해결해보자.

 

long startTime = System.currentTimeMillis();

...

long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("resultTime = {}", resultTime);

전략 패턴 적용

Strategy

public interface Strategy {
    void call();
}

 

먼저 Context 클래스에 주입할 인터페이스를 생성한다. call() 메서드가 변화하는 코드인 핵심 로직이다.

StrategyLogic

@Slf4j
public class StrategyLogic1 implements Strategy {

    @Override
    public void call() {
        log.info("비즈니스 로직1 실행");
    }
}

 

Strategy 인터페이스를 구혆나 핵심 로직이다.

Context

@Slf4j
public class ContextV1 {

    private Strategy strategy;

    public ContextV1(Strategy strategy) {
        this.strategy = strategy;
    }

    public void execute() {
        long startTime = System.currentTimeMillis();

        // 비즈니스 로직 실행
        strategy.call();
        // 비즈니스 로직 종료

        long endTime = System.currentTimeMillis();
        long resultTime = endTime - startTime;
        log.info("resultTime = {}", resultTime);
    }
}

 

중복되는 코드를 따로 추출한 Context 클래스이다. 다형성을 사용하여 Strategy 인터페이스를 필드에 선언한 뒤 주입받는다. 주입할 때는 인터페이스를 구현한 클래스를 주입해주며 Context 로직 중간에 strategy.call() 메서드를 호출하여 주입받은 strategy 로직을 실행해준다.

테스트코드

StrategyV1

@Test
void strategyV1() {
    StrategyLogic1 strategyLogic1 = new StrategyLogic1();
    ContextV1 context1 = new ContextV1(strategyLogic1);
    context1.execute();

    StrategyLogic2 strategyLogic2 = new StrategyLogic2();
    ContextV1 context2 = new ContextV1(strategyLogic2);
    context2.execute();
}

 

Strategy 인터페이스를 구현한 StrategyLogic1() 객체 인스턴스를 생성한 뒤 Context 클래스에 주입했다.

 

StrategyV2

@Test
void strategyV2() {
    Strategy strategyLogic1 = new Strategy() {
        @Override
        public void call() {
            log.info("비즈니스 로직1 실행");
        }
    };

    ContextV1 context1 = new ContextV1(strategyLogic1);
    context1.execute();

    Strategy strategyLogic2 = new Strategy() {
        @Override
        public void call() {
            log.info("비즈니스 로직2 실행");
        }
    };

    ContextV1 context2 = new ContextV1(strategyLogic2);
    context2.execute();
}

 

Strategy 인터페이스를 따로 클래스를 만들어 구현하지 않고 Strategy 인스턴스를 생성할 때 익명 내부 클래스를 사용하여 주입했다.

 

StrategyV3

@Test
void strategyV3() {
    ContextV1 context1 = new ContextV1(new Strategy() {
        @Override
        public void call() {
            log.info("비즈니스 로직1 실행");
        }
    });
    context1.execute();

    ContextV1 context2 = new ContextV1(new Strategy() {
        @Override
        public void call() {
            log.info("비즈니스 로직2 실행");
        }
    });
    context2.execute();
}

 

StrategyV2 처럼 Strategy 인터페이스를 따로 클래스를 만들어 구현하지 않았다. 또한 익명 내부 클래스를 따로 Strategy 변수에 담아두지 않고 Context 인스턴스를 생성할 때 바로 주입했다. 

 

StrategyV4

@Test
void strategyV4() {
    ContextV1 context1 = new ContextV1(() -> log.info("비즈니스 로직1 실행"));
    context1.execute();

    ContextV1 context2 = new ContextV1(() -> log.info("비즈니스 로직2 실행"));
    context2.execute();
}

 

Strategy 인터페이스의 메서드가 하나만 있는 경우 위와 같이 Lambda 함수를 사용하여 주입할 수 있다.

 

위 테스트 방법들은 모두 전략 패턴을 사용하였으며 공통적으로 Context 클래스에 중복되는 로직을 작성하고 Strategy에 비즈니스 로직을 작성하여 주입해주었다. 이러한 방식을 선 조립, 후 실행 방식이라고 하며 애플리케이션 실행 시점에 의존관계 주입을 통해 필요한 의존관계를 모두 맺어두고 난 다음에 실제 요청을 처리한다. 

 

하지만 이러한 방식은 조립한 이후에는 Strategy를 변경하기가 까다롭다는 단점이 있다. setter를 사용하여 변경할 수 있지만 setter를 사용하면 Context 클래스를 싱글톤으로 사용할 때 동시성 이슈가 발생할 수 있으며 내가 아닌 다른 누군가에 의해 Strategy 전략이 수정될 위험이 있다. 

 

이를 해결하기 위해 Context 클래스 필드에 Strategy 전략을 주입하는 방법이 아닌 execute() 파라미터로 method 넘겨주는 방식이 있다.

ContextV2

@Slf4j
public class ContextV2 {

    public void execute(Strategy strategy) {
        long startTime = System.currentTimeMillis();

        // 비즈니스 로직 실행
        strategy.call();
        // 비즈니스 로직 종료

        long endTime = System.currentTimeMillis();
        long resultTime = endTime - startTime;
        log.info("resultTime = {}", resultTime);
    }
}

 

ContextV1 클래스와 달리 Strategy 전략을 execute() 메서드의 파라미터로 넘겨준뒤 strategy.call()을 호출하여 핵심로직을 실행시켰다. 실행할 때는 아래와 같이 execute()를 호출할 때 Strategy를 넘겨주면 된다.

 

@Slf4j
public class ContextV2Test {

    /**
     * 전략 패턴 적용
     */
    @Test
    void strategyV1() {
        ContextV2 context = new ContextV2();
        context.execute(new StrategyLogic1());
        context.execute(new StrategyLogic2());
    }

    /**
     * 전략 패턴 익명 내부 클래스
     */
    @Test
    void strategyV2() {
        ContextV2 context = new ContextV2();
        context.execute(new Strategy() {
            @Override
            public void call() {
                log.info("비즈니스 로직1 실행");
            }
        });
        context.execute(new Strategy() {
            @Override
            public void call() {
                log.info("비즈니스 로직2 실행");
            }
        });
    }

    /**
     * 전략 패턴 익명 내부 클래스2, 람다
     */
    @Test
    void strategyV3() {
        ContextV2 context = new ContextV2();
        context.execute(() -> log.info("비즈니스 로직1 실행"));
        context.execute(() -> log.info("비즈니스 로직2 실행"));
    }
}

 

이러한 패턴을 템플릿 콜백 패턴이라고 하며 템플릿 콜백 패턴은 전략 패턴의 일종이다.

 

ContextV1 클래스와 ContextV2 클래스는 아래와 같은 장단점이 있다.

 

ContextV1

  • Context 를 실행하는 시점에는 이미 조립이 끝났기 때문에 전략을 신경쓰지 않고 단순히 실행만 하면 된다.
  • 조립이 끝난 후에는 전략을 수정하기 까다롭다.

ContextV2

  • 실행할 때마다 유연하게 전략을 수정할 수 있다.
  • 실행할 때마다 유연하게 전략을 정해주어야 하기 때문에 불편하다.

강의에서는 ContextV1 방법 보다 ContextV2 방법이 더 적합하다고 소개하고 있다. 그 이유는 현재 우리가 해결하고자 하는 문제가 핵심기능과 부가기능을 단일 책임 원칙을 지키며 분리하는 것이 목적인데 두 가지 방법 다 해당 문제를 해결할 수는 있지만 분리한 핵심 기능을 더 유연하게 사용할 수 있는 ContextV2가 더 목적에 부합하기 때문이다. 다만 두 가지 방법 모두 장단점이 있으니 상황에 맞게 사용하는 연습이 필요할 것 같다.

이번 글에서는 김영한님의 스프링 핵심 원리 - 고급편에서 배운 내용을 바탕으로 스프링에서 자주 사용하는 디자인패턴에 대해 정리하려고 합니다.

 

디자인 패턴이란 프로그램을 설계할 때 발생했던 문제점들을 객체 간의 상호 관계 등을 이용하여 해결할 수 있도록 하나의 규약 형태로 만들어 놓은 것이다. 이 디자인 패턴을 사용하여 문제점을 해결하고 좋은 설계를 통해 유지보수하기 쉬운 코드를 작성할 수 있다.

 

강의에서는 좋은 설계란 변하는 것과 변하지 않는 것을 분리하는 것이라고 설명하고 있었다. , 핵심 기능과 부가 기능을 따로 분리하여 모듈화 함으로써 하나의 클래스는 하나의 책임만 갖고 해당 클래스를 변경하는 이유는 오직 하나뿐이어야 한다 단일 책임 원칙을 지키는 것을 강조하고 있었다. 이번 글에서 다룰 템플릿 메서드 패턴은 이러한 원칙을 지키기 쉽게 해주는 디자인 패턴이다.

템플릿 메서드 패턴

템플릿 메서드 패턴은 작업에서 알고리즘의 골격을 정의하고 상속을 사용하여 일부 단계를 하위 클래스로 연기하는 패턴으로 핵심 기능이 포함된 추상클래스를 생성하고 해당 추상클래스에서 핵심로직을 추상메서드로 선언한 뒤 이를 상속받아 재정의함으로써 핵심기능과 부가기능을 분리하는 디자인 패턴이다.

 

문제 코드

@Slf4j
public class TemplateMethodTest {

    @Test
    void templateMethodV0 () {
        logic1();
        logic2();
    }

    private void logic1() {
        long startTime = System.currentTimeMillis();

        // 비즈니스 로직 실행
        log.info("비즈니스 로직1 실행");
        // 비즈니스 로직 종료
        
        long endTime = System.currentTimeMillis();
        long resultTime = endTime - startTime;
        log.info("resultTime = {}", resultTime);
    }

    private void logic2() {
        long startTime = System.currentTimeMillis();

        // 비즈니스 로직 실행
        log.info("비즈니스 로직2 실행");
        // 비즈니스 로직 종료
        
        long endTime = System.currentTimeMillis();
        long resultTime = endTime - startTime;
        log.info("resultTime = {}", resultTime);
    }
}

 

위 코드에서 logic1()과 logic2()는 아래와 같이 중복되는 코드가 존재한다. 해당 코드는 부가 기능으로 두 logic에서 동일한 기능을 수행한다. 이러한 설계는 만약 부가 기능을 사용하는 logic이 100개가 존재할 때 이 부가 기능을 수정하려면 100개의 logic을 모두 수정해주어야 하기 때문에 좋은 방법이 아니다. 만약 공통된 코드를 추출하여 하나의 클래스로 관리할 수 있다면 부가 기능을 수정할 때 해당 클래스만 수정해주면 되기 때문에 유지보수하기 쉬워진다.

 

long startTime = System.currentTimeMillis();

...

long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("resultTime = {}", resultTime);

템플릿 메서드 패턴 적용

AbstractTemplate

@Slf4j
public abstract class AbstractTemplate {

    public void execute() {
        long startTime = System.currentTimeMillis();

        // 비즈니스 로직 실행
        call();
        // 비즈니스 로직 종료
        
        long endTime = System.currentTimeMillis();
        long resultTime = endTime - startTime;
        log.info("resultTime = {}", resultTime);
    }

    protected abstract void call();
}

공통 로직을 추출한 추상클래스이다. 비즈니스 로직 부분을 call()이라는 추상메서드로 선언하였다.

 

SubClassLogic

@Slf4j
public class SubClassLogic1 extends AbstractTemplate {

    @Override
    protected void call() {
        log.info("비즈니스 로직1 실행");
    }
}

추상클래스(AbstractTemplate)을 상속받아 call() 메서드를 재정의 하였다.

 

테스트코드

@Slf4j
public class TemplateMethodTest {

    /**
     * 템플릿 메서드 패턴 적용
     */
    @Test
    void templateMethodV1() {
        AbstractTemplate template1 = new SubClassLogic1();
        template1.execute();

        AbstractTemplate template2 = new SubClassLogic2();
        template2.execute();
    }
}

AbstractTemplate 추상클래스를 상속받는 SubClassLogic1(), SubClassLogic2() 객체 인스턴스를 생성하고 execute() 메서드를 실행하면 아래와 같이 템플릿 메서드 패턴을 적용하기 전과 동일한 결과를 확인할 수 있다.

 

 

하지만 이 방법은 비즈니스 로직을 클래스로 계속 만들어야 하는 단점이 있다. 이는 익명 내부 클래스를 사용하여 해결할 수 있다.

익명 내부 클래스란 객체 인스턴스를 생성할 때 상속 받는 자식 클래스를 정의할 수 있는 문법으로 클래스 이름을 따로 선언하지 않아도 된다.

 

@Slf4j
public class TemplateMethodTest {

    /**
     * 익명 내부 클래스
     */
    @Test
    void templateMethodV2() {
        AbstractTemplate template1 = new AbstractTemplate() {
            @Override
            public void call() {
                log.info("비즈니스 로직1 실행");
            }
        };
        template1.execute();

        AbstractTemplate template2 = new AbstractTemplate() {
            @Override
            public void call() {
                log.info("비즈니스 로직2 실행");
            }
        };
        template2.execute();
    }
}

위와 같이 익명 내부 클래스를 사용하면 AbstractTemplate 객체를 생성할 때 call() 메서드를 재정의할 수 있어 클래스 파일을 생성해주지 않아도 된다.

 

이렇게 함으로써 이제 부가 기능을 수정할때는 AbstractTemplate 추상클래스의 execute() 메서드만 수정해주면 된다.

 

하지만 템플릿 디자인 패턴은 상속을 사용하기 때문에 상속이 가지고 있는 단점들을 그대로 가지고 있다. 특히 부모 클래스와 자식 클래스가 컴파일 시점에 강하게 결합되는 문제가 있다. 자식 클래스는 부모 클래스의 기능을 사용하지 않고 부모 클래스에서 비즈니스 로직인 추상 메서드만 재정의 해주면 되는데 상속을 받아 부모 클래스에 강하기 의존하고 있다. 이러한 의존 관계는 부모 클래스를 수정했을 때 자식 클래스에 영향을 미칠 수 있다. 이를 해결하기 위한 방법으로 비슷한 기능을 수행하면서 상속의 단점을 제거할 수 있는 방법으로 전략 패턴을 사용하는 방법이 있다.

트랜잭션 전파

트랜잭션이란 데이터베이스의 상태를 바꾸는 작업의 단위로 @Transactional을 메서드나 클래스에 선언하여 작업의 범위를 지정한다. 트랜잭션은 설정값에 따라 하나의 트랜잭션이 실행중일 때 다른 트랜잭션을 실행하면 기존의 실행중이던 트랜잭션에 종속되기도 하고 아예 새로운 트랜잭션이 실행되기도 하는데 이를 트랜잭션 전파(Transaction Propagation)라고 한다. 이번 글에서는 트랜잭션 전파 옵션의 종류와 의미, 그리고 적용예시에 대해서 정리해 보려고 한다.

트랜잭션 전파 옵션

REQUIRED

트랜잭션 전파의 기본 설정이다. 실무에서 가장 많이 사용되는 옵션으로 기존 트랜잭션이 없으면 새로 생성하고 있으면 참여한다. 기존 트랜잭션이나 신규 트랜잭션 중 하나라도 롤백이 발생하면 전부 롤백된다.

 

REQUIRES_NEW

항상 새로운 트랜잭션을 생성한다. REQUIRES_NEW 속성을 부여하면 기존에 실행중이던 트랜잭션이 있어도 새로 생성된 트랜잭션과 서로 영향을 미치지 않는다.

 

SUPPORT

기존 트랜잭션이 없으면 없는대로 진행하고 있으면 기존 트랜잭션에 참여한다.

 

NOT_SUPPORT

기존 트랜잭션이 있든 없든 트랜잭션 없이 진행한다.

 

MANDATORY

트랜잭션이 반드시 있어야 한다. 기존 트랜잭션이 없으면 IllegalTransactionStateException 예외가 발생하고 있으면 기존 트랜잭션에 참여한다.

  • 기존 트랜잭션 있음 : 기존 트랜잭션에 참여
  • 기존 트랜잭션 없음 : IllegalTransactionStateException 예외 발생

NEVER

MANDATORY 속성과 반대되는 개념으로 기존 트랜잭션이 없으면 없는대로 진행하고 있으면 IllegalTransactionStateException 예외가 발생한다.

  • 기존 트랜잭션 있음 : IllegalTransactionStateException 예외 발생
  • 기존 트랜잭션 없음 : 트랜잭션 없이 진행

NESTED

기존 트랜잭션이 없으면 새로운 트랜잭션을 생성하고 있으면 중첩 트랜잭션을 생성한다.

중첩 트랜잭션은 외부 트랜잭션에는 영향을 받기만하고 주지는 않는다. 즉 외부 트랜잭션에서 롤백이 발생하면 중첩 트랜잭션도 롤백되지만 중첩 트랜잭션에서 롤백이 발생하면 외부 트랜잭션은 롤백되지 않는다.

 

NESTED 속성은 JDBC의 savepoint 기능을 사용하므로 사용하기 전에 데이터베이스 드라이버에서 해당 기능을 지원하는지 확인이 필요하다. 참고로 JPA에서는 사용이 불가능하다.

REQUIRES_NEW 적용예시

REQUIRED 옵션은 외부 트랜잭션이 롤백되거나 내부 트랜잭션이 롤백되면 모든 연산이 롤백되므로 트랜잭션 원자성을 지켜 데이터 정합성을 보장해주기 때문에 실무에서 가장 많이 사용하는 옵션이다. 하지만 특정상황에 REQUIRES_NEW 옵션을 사용해야 하는 경우가 있는데 아래와 같은 경우이다.

MemberService

@Slf4j
@Service
@RequiredArgsConstructor
public class MemberService {

    private final MemberRepository memberRepository;

    private final LogRepository logRepository;

    @Transactional
    public void join(String username) {
        Member member = new Member(username);
        Log logMessage = new Log(username);

        log.info("== memberRepository 호출 시작 ==");
        memberRepository.save(member);
        log.info("== memberRepository 호출 종료 ==");

        log.info("== logRepository 호출 시작 ==");
        try {
            logRepository.save(logMessage);
        } catch (RuntimeException e) {
            log.info("log 저장에 실패했습니다. logMessage = {}", logMessage.getMessage());
            log.info("정상 흐름 반환");
        }
        log.info("== logRepository 호출 종료 ==");
    }
}

MemberRepository

@Slf4j
@Repository
@RequiredArgsConstructor
public class MemberRepository {

    private final EntityManager em;

    @Transactional
    public void save(Member member) {
        log.info("member 저장");
        em.persist(member);
    }

    public Optional<Member> find(String username) {
        return em.createQuery("select m from Member m where m.username = :username", Member.class)
                .setParameter("username", username)
                .getResultList().stream().findAny();
    }
}

LogRepository

@Slf4j
@Repository
@RequiredArgsConstructor
public class LogRepository {

    private final EntityManager em;

    @Transactional
    public void save(Log logMessage) {
        log.info("로그 저장");
        em.persist(logMessage);

        if (logMessage.getMessage().contains("로그예외")) {
            log.info("log 저장 시 예외 발생");
            throw new RuntimeException("에외 발생");
        }
    }

    public Optional<Log> find(String message) {
        return em.createQuery("select l from Log l where l.message = :message", Log.class)
                .setParameter("message", message)
                .getResultList().stream().findAny();
    }
}

 

회원을 등록하는 로직으로 회원등록과 함께 로그도 DB에 저장되도록 구현했다. MemberService, MemberRepository, LogRepository 각각 트랜잭션이 적용되어 있고 세 트랜잭션 모두 디폴트 옵션인 REQUIRED 속성으로 설정되어 있다. REQUIRED 속성으로 설정되어 있으므로 MemerService에서 시작한 트랜잭션에 MemberReopository와 LogRepository의 트랜잭션이 참여하고 있다.

 

로그 데이터를 저장하는 것은 비즈니스 로직에 영향을 미치지 않기 때문에 LogRepository에서 예외가 발생해도 회원은 등록되도록 MemberService에서 try ~ catch문으로 예외를 처리하고 정상로직으로 반환하도록 처리해주었다. 이제 LogRepository에서 예외가 발생해도 회원은 등록될 것으로 예상했지만 아래 코드로 테스트 해보니 예상과 다르게 전부 롤백되어 회원데이터도 사라지는 것을 확인할 수 있었다.

 

/**
 * MemberService    @Transactional:ON
 * MemberRepository @Transactional:ON
 * LogRepository    @Transactional:ON Exception
 */
@Test
void outerTxOn_fail() {
    // given
    String username = "로그예외_outerTxOn_fail";

    // when
    org.assertj.core.api.Assertions.assertThatThrownBy(() -> memberService.join(username))
            .isInstanceOf(RuntimeException.class);

    // then: 모든 데이터가 롤백된다.
    assertTrue(memberRepository.find(username).isEmpty());
    assertTrue(logRepository.find(username).isEmpty());
}

 

 

REQUIRED 속성을 사용하는 경우 내부 트랜잭션에서 예외가 발생하면 트랜잭션 동기화 매니저에 rollbackOnly=true가 표시되고 외부트랜잭션을 커밋할 때 rollbackOnly=true가 표시되어 있으면 UnexpectedRollbackException 에외가 발생하면서 종속된 트랜잭션을 전부 롤백시켜버린다.

 

이와 같은 상황을 REQUIRES_NEW 속성을 사용하여 해결할 수 있다.

REQUIRES_NEW 속성은 아래와 같이 @Transactional 어노테이션에 propagation = Propagation.REQUIRES_NEW를 사용하여 적용할 수 있다.

 

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void save(Log logMessage) {
    log.info("로그 저장");
    em.persist(logMessage);

    if (logMessage.getMessage().contains("로그예외")) {
        log.info("log 저장 시 예외 발생");
        throw new RuntimeException("에외 발생");
    }
}

 

REQUIRES_NEW 속성은 항상 새로운 트랜잭션을 생성하기 때문에 외부 트랜잭션과는 독립되어 있다. 그렇기 때문에 Log 데이터를 저장하는데 실패해도 외부 트랜잭션에 영향을 미치지 않으며 내부 트랜잭션이 롤백되어도 회원정보는 DB에 잘 저장되는 것을 확인할 수 있다.

 

 

/**
 * MemberService    @Transactional:ON
 * MemberRepository @Transactional:ON
 * LogRepository    @Transactional:ON(REQUIRES_NEW) Exception
 */
@Test
void recoverException_success() {
    // given
    String username = "로그예외_recoverException_success";

    // when
    memberService.join(username);

    // then: member 저장, log 롤백
    assertTrue(memberRepository.find(username).isPresent());
    assertTrue(logRepository.find(username).isEmpty());
}

 

하지만 REQUIRES_NEW 옵션은 데이터베이스 커넥션을 2개 사용하기 때문에 성능이 중요한 곳에서는 적합하지 않다. 대신 아래와 같이 구조를 변경하는 방법을 사용할 수 있다. 여러 해결방법이 있으니 각각의 장단점을 파악하고 적재적소에 알맞는 해결법을 사용해야 겠다.

 

+ Recent posts