Overview

베이스 코드로 Spring Boot Cache 적용에 있던 코드들을 재활용할 예정이라 앞의 글을 먼저 읽어보는걸 추천합니다.

단순하게 Redis Cache 설정만 알고 싶다면 상관 없습니다.

코드를 직접 실행해보려면 로컬에 Redis 를 설치해야 합니다.

만약 별도의 Redis 서버를 운영 중이라면 해당 서버를 사용해도 됩니다.


1. Dependency 추가

implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-cache'

spring-boot-starter-cache 에 이어 spring-boot-starter-data-redis 설정을 추가합니다.

Spring Data Redis 설정을 추가하면 자동으로 기본 캐시가 ConcurrentMapCache 에서 RedisCache 로 설정됩니다.


2. Redis Configuration

spring:
  data:
    redis:
      host: 127.0.0.1
      port: 6379

@Configuration
public class RedisConfig {

    @Value("${spring.data.redis.host}")
    private String host;

    @Value("${spring.data.redis.port}")
    private int port;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(
                new RedisStandaloneConfiguration(host, port)
        );
    }
}

Redis 연결을 위한 기본 설정을 추가합니다.


3. Redis Cache Configuration

@EnableCaching
@Configuration
public class CacheConfig {

    /**
     * Spring Boot 가 기본적으로 RedisCacheManager 를 자동 설정해줘서 RedisCacheConfiguration 없어도 사용 가능
     * Bean 을 새로 선언하면 직접 설정한 RedisCacheConfiguration 이 적용됨
     */
    @Bean
    public RedisCacheConfiguration redisCacheConfiguration() {
        return RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofSeconds(60))
                .disableCachingNullValues()
                .serializeKeysWith(
                        RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())
                )
                .serializeValuesWith(
                        RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())
                );
    }

    /**
     * 여러 Redis Cache 에 관한 설정을 하고 싶다면 RedisCacheManagerBuilderCustomizer 를 사용할 수 있음
     */
    @Bean
    public RedisCacheManagerBuilderCustomizer redisCacheManagerBuilderCustomizer() {
        return (builder) -> builder
                .withCacheConfiguration("cache1",
                        RedisCacheConfiguration.defaultCacheConfig()
                                .computePrefixWith(cacheName -> "prefix::" + cacheName + "::")
                                .entryTtl(Duration.ofSeconds(120))
                                .disableCachingNullValues()
                                .serializeKeysWith(
                                        RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())
                                )
                                .serializeValuesWith(
                                        RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())
                                ))
                .withCacheConfiguration("cache2",
                        RedisCacheConfiguration.defaultCacheConfig()
                                .entryTtl(Duration.ofHours(2))
                                .disableCachingNullValues());
    }
}

Redis Cache 설정을 추가합니다.

CacheManager 를 사용했던 ConcurrentMapCache 와는 다르게 Redis 는 간단하게 Redis Cache 설정을 적용할 수 있습니다.

우선 Spring Data Redis 를 사용한다면 Spring Boot 가 RedisCacheManager 를 자동으로 설정해줍니다.

하지만 Redis 는 직렬화/역직렬화 때문에 별도의 캐시 설정이 필요하고 이 때 사용하는게 RedisCacheConfiguration 입니다.

RedisCacheConfiguration 설정은 Redis 기본 설정을 오버라이드 한다고 생각하면 됩니다.

  • computePrefixWith: Cache Key prefix 설정
  • entryTtl: 캐시 만료 시간
  • disableCachingNullValues: 캐싱할 때 null 값을 허용하지 않음 (#result == null 과 함께 사용해야 함)
  • serializeKeysWith: Key 를 직렬화할 때 사용하는 규칙. 보통은 String 형태로 저장
  • serializeValuesWith: Value 를 직렬화할 때 사용하는 규칙. Jackson2 를 많이 사용함

만약 캐시이름 별로 여러 세팅을 하고 싶다면 RedisCacheManagerBuilderCustomizer 를 선언해서 사용할 수 있습니다.

위 코드에서는 cache1, cache2 두 가지 캐시를 설정했으며 만약 다른 이름의 캐시를 사용하려 한다면 기본 설정인 RedisCacheConfiguration 를 따라갑니다.

GenericJackson2JsonRedisSerializer 를 사용할 때 주의할 점은 여러개의 데이터를 한번에 저장할 때 List 를 사용하지 말고 별도의 일급 컬렉션을 선언해서 사용해야 합니다.

자세한 이슈는 Spring Boot 에서 Redis Cache 사용 시 List 역직렬화 에러 (GenericJackson2JsonRedisSerializer) 글에 정리해두었습니다.


4. Redis 데이터 확인

API 호출 후 Redis CLI 에서 직접 저장된 데이터를 확인해봅니다.


4.1. cache1 데이터 확인

cache1 은 설정한 대로 prefix 가 붙은 key 값이 사용됩니다.


4.2. members 데이터 확인

별도의 이름을 설정하지 않은 members 캐시는 그냥 일반 키값으로 저장됩니다.


Conclusion

Redis Cache 는 일반적으로 가장 많이 사용되는 글로벌 캐시입니다.

직접 RedisTemplate 을 호출해서 구현할 수도 있지만 Spring Boot 에서 제공하는 설정을 알아두면 나중에 유용하게 사용할 일이 있을 겁니다.


Reference

Overview

Spring Boot 에서 Cache 를 적용하는 방법에 대해 알아봅니다.

원래 Cache 를 사용할 때 Redis 같은 별도의 글로벌 저장소를 활용하는게 일반적이지만 이번에는 간단하게 기본 캐시인 ConcurrentMapCache 를 사용합니다.


1. Dependency 추가

implementation 'org.springframework.boot:spring-boot-starter-cache'

사실 spring-boot-starter-cache 를 추가하지 않아도 캐시 기능을 사용할 수 있습니다.

spring-boot-starter-web 같은 스타터 모듈에 자동으로 포함되어 있는 spring-context 라는 모듈 덕분인데요.

Spring Boot 3.0.5 가이드를 보면 spring-boot-starter-cache 모듈을 추가해야 spring-context-support 모듈을 가져와서 캐시 관련된 여러 기능을 제공하기 때문에 캐시 관련 의존성을 추가해준다고 합니다.


2. Configuration

@EnableCaching
@Configuration
public class CachingConfig {

    @Bean
    public CacheManager cacheManager() {
        ConcurrentMapCacheManager cacheManager = new ConcurrentMapCacheManager();
        cacheManager.setAllowNullValues(false);
        cacheManager.setCacheNames(List.of("members"));
        return cacheManager;
    }
}

다음은 Cache 관련 설정을 추가합니다.

@EnableCachingSpringBootApplication 에 추가해도 되지만 어차피 캐시 설정을 위해 Config 클래스를 추가할 거라면 여기에 추가해도 됩니다.


2.1. Cache Customizer

@Component
public class SimpleCacheCustomizer implements CacheManagerCustomizer<ConcurrentMapCacheManager> {

    @Override
    public void customize(ConcurrentMapCacheManager cacheManager) {
        cacheManager.setAllowNullValues(false);
    }
}

Cache Customizer 를 따로 추가하는 방법도 있습니다.


3. Cache 어노테이션

설정을 마쳤으니 Cache 관련 어노테이션을 사용하면 손쉽게 캐시 기능을 사용할 수 있습니다.

캐시 어노테이션들은 기본적으로 AOP 로 동작하기 때문에 내부 호출 같은 이슈를 주의해야 합니다.

  • @Cacheable
    • 데이터를 캐시에 저장
    • 메서드를 호출할 때 캐시의 이름 (value) 과 키 (key) 를 확인하여 이미 저장된 데이터가 있으면 해당 데이터를 리턴
    • 만약 데이터가 없다면 메서드를 수행 후 결과값을 저장
  • @CachePut
    • @Cacheable 과 비슷하게 데이터를 캐시에 저장
    • 차이점은 @Cacheable 은 캐시에 데이터가 이미 존재하면 메서드를 수행하지 않지만 @CachePut 은 항상 메서드를 수행
    • 그래서 주로 캐시 데이터를 갱신할 때 많이 사용
  • @CacheEvict
    • 캐시에 있는 데이터를 삭제
  • @CacheConfig
    • 메서드가 아닌 클래스에 붙여서 공통된 캐시 기능을 모을 수 있음
    • 예를 들면 cacheNames, cacheManager 등등
  • @Caching
    • Cacheable, CachePut, CacheEvict 를 여러 개 사용할 때 묶어주는 기능

일반적으로 @Cacheable 을 사용해서 캐싱하고 데이터를 갱신할 때 @CachePut, @CacheEvict 중 하나를 선택해서 갱신합니다.

@CachePut 을 사용하면 @Cacheable 데이터 조회 시 캐시에 새로운 데이터가 존재하기 때문에 DB 조회를 하지 않아도 된다는 장점이 있습니다.


4. Domain 정의

이제 캐시를 적용하기 전에 간단하게 필요한 클래스들을 정의해봅니다.


4.1. Member

@Getter
@Setter
@ToString
@NoArgsConstructor
public class Member {

    private Long id;
    private String name;
    private Integer age;

    public Member(String name, Integer age) {
        this.name = name;
        this.age = age;
    }
}

캐싱 대상인 Member 클래스입니다.

Lombok 을 사용했고 복잡한 데이터 없이 간단하게 만들었습니다.


4.2. Members (List)

@Getter
@ToString
@NoArgsConstructor
public class Members {
    private List<Member> members = new ArrayList<>();

    public Members(List<Member> members) {
        this.members = members;
    }
}

마찬가지로 캐시 대상인 Members 입니다.

List<Member> 를 담은 일급 컬렉션이고 특별한 건 없습니다.


5. MemberRepository

@Slf4j
@Repository
@CacheConfig(cacheNames = "members")
public class MemberRepository {

    private final Map<Long, Member> store = new LinkedHashMap<>();

    @Cacheable(key = "'all'")
    public Members findAll() {
        List<Member> members = store.values().stream().toList();
        log.info("Repository findAll {}", members);
        return new Members(members);
    }

    @Cacheable(key = "#memberId", unless = "#result == null")
    public Member findById(Long memberId) {
        Member member = store.get(memberId);
        log.info("Repository find {}", member);
        return member;
    }

    @CachePut(key = "#member.id")
    @CacheEvict(key = "'all'")
    public Member save(Member member) {
        Long newId = calculateId();
        member.setId(newId);

        log.info("Repository save {}", member);

        store.put(member.getId(), member);
        return member;
    }

    private Long calculateId() {
        if (store.isEmpty()) {
            return 1L;
        }

        int lastIndex = store.size() - 1;
        return (Long) store.keySet().toArray()[lastIndex] + 1;
    }

    @CachePut(key = "#member.id")
    @CacheEvict(key = "'all'")
    public Member update(Member member) {
        log.info("Repository update {}", member);
        store.put(member.getId(), member);
        return member;
    }

    @Caching(evict = {
            @CacheEvict(key = "'all'"),
            @CacheEvict(key = "#member.id")
    })
    public void delete(Member member) {
        log.info("Repository delete {}", member);
        store.remove(member.getId());
    }
}

캐시 어노테이션을 적용한 MemberRepository 코드입니다.

데이터는 실제 DB 대신 간단하게 LinkedHashMap 을 사용했습니다.

우선 전체 코드를 보고 하나씩 살펴봅니다.


5.1. @CacheConfig

@CacheConfig 를 클래스에 붙여서 members 라는 공통 캐시 이름을 설정합니다.


5.2. 복수 조회 (findAll)

@Cacheable(key = "'all'")
public Members findAll() {
    List<Member> members = store.values().stream().toList();
    log.info("Repository findAll {}", members);
    return new Members(members);
}

전체 데이터를 조회하는 메서드입니다.

key 를 all 로 설정했기 때문에 members::all 이라는 key 값에 Members 데이터가 저장됩니다.

이후에 한번 더 조회를 하면 members::all 을 확인하고 데이터가 있다면 그 값을 그대로 리턴합니다.


5.3. 단건 조회 (findById)

@Cacheable(key = "#memberId", unless = "#result == null")
public Member findById(Long memberId) {
    Member member = store.get(memberId);
    log.info("Repository find {}", member);
    return member;
}

Member 데이터를 저장하는 단건 조회 메서드입니다.

memberId 를 키값으로 설정하며 unless = "#result == null" 조건을 추가하여 DB 에 없는 데이터인 경우 캐싱하지 않도록 했습니다.

만약 이 조건을 추가하지 않으면 null 값도 캐싱 대상이 됩니다.

우리는 캐시 설정에서 cacheManager.setAllowNullValues(false); 를 추가했기 때문에 null 값을 캐싱하려고 하면 에러가 발생하니 꼭 위 조건을 함께 추가해줘야 합니다.


5.4. 생성 및 변경 (save & update)

@CachePut(key = "#member.id")
@CacheEvict(key = "'all'")
public Member save(Member member) {
    Long newId = calculateId();
    member.setId(newId);

    log.info("Repository save {}", member);

    store.put(member.getId(), member);
    return member;
}


@CachePut(key = "#member.id")
@CacheEvict(key = "'all'")
public Member update(Member member) {
    log.info("Repository update {}", member);
    store.put(member.getId(), member);
    return member;
}

새로운 데이터를 저장합니다.

여기에는 두가지 어노테이션이 붙어 있는데 @CachePut 은 새로운 데이터를 저장하면 해당 데이터를 바로 캐싱하기 위해 추가했습니다.

여기서 캐싱하지 않아도 조회할 때 캐싱되기 때문에 반드시 필요한 설정은 아닙니다.

@CacheEvict 는 전체 조회 데이터를 삭제합니다.

2개의 데이터가 캐싱되어 있고 새로운 데이터가 추가되었는데 캐시를 갱신하거나 비워주지 않으면 만료될 때까지 이전 데이터를 보고 있기 때문에 한번 삭제해줘야 합니다.

단건 조회라면 @CachePut 을 사용해서 갱신할 수 있지만 복수 조회라면 갱신하는 일이 더 귀찮기 때문에 그냥 캐시를 비워주고 findAll 을 호출할 때 새로 캐싱합니다.

@CachePut 을 사용할 때 한가지 주의할 점이라면 반환값을 캐싱하기 때문에 void update 처럼 리턴값을 제대로 지정하지 않는 경우 제대로 동작하지 않을 수 있습니다.


5.5. 삭제 (delete)

@Caching(evict = {
        @CacheEvict(key = "'all'"),
        @CacheEvict(key = "#member.id")
})
public void delete(Member member) {
    log.info("Repository delete {}", member);
    store.remove(member.getId());
}

Member 데이터를 삭제합니다.

삭제는 Member 데이터와 Members 데이터의 캐싱을 모두 없애줘야 하기 때문에 @CacheEvict 을 두개 사용했습니다.

메서드에는 중복된 어노테이션을 두개 붙일 수 없기 때문에 @Caching 을 사용해서 묶어주면 동일한 어노테이션 두개를 전부 적용할 수 있습니다.


6. MemberController

@Slf4j
@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberRepository memberRepository;

    @GetMapping("/members")
    public Members findAll() {
        Members members = memberRepository.findAll();
        log.info("Controller findAll {}", members);
        return members;
    }

    @GetMapping("/members/{memberId}")
    public Member findById(@PathVariable Long memberId) {
        Member member = memberRepository.findById(memberId);
        log.info("Controller find {}", member);
        return member;
    }

    @PostMapping("/members")
    public Member save(@RequestBody MemberDto memberDto) {
        Member member = new Member(memberDto.getName(), memberDto.getAge());
        Member savedMember = memberRepository.save(member);
        log.info("Controller save {}", savedMember);
        return savedMember;
    }

    @PutMapping("/members/{memberId}")
    public Member update(@PathVariable Long memberId, @RequestBody MemberDto memberDto) {
        Member member = new Member(memberDto.getName(), memberDto.getAge());
        member.setId(memberId);
        return memberRepository.update(member);
    }

    @DeleteMapping("/members/{memberId}")
    public void delete(@PathVariable Long memberId) {
        Member member = memberRepository.findById(memberId);
        log.info("Controller delete {}", member);
        memberRepository.delete(member);
    }
}

간단한 CRUD REST API 를 만들었습니다.


7. API Test

로컬에서 실제 테스트를 진행해봅니다.


7.1. 빈 List 조회

우선 아무것도 추가하지 않은 상태로 데이터를 조회해봅니다.

처음에는 Repository 로그까지 남지만 똑같은 요청을 반복하면 캐싱된 데이터를 가져오므로 Controller 까지만 로그가 남습니다.


7.2. 새로운 데이터 추가

새로운 데이터를 추가합니다.

데이터 추가나 변경은 @CachePut 을 사용하기 때문에 매번 Repository 로그를 남깁니다.


7.3. Members 새로운 데이터 조회

다시 findAll 을 호출합니다.

새로운 데이터를 추가할 때마다 members::all 은 evict 되어 다시 Repository 조회까지 수행합니다.

하지만 한번 조회한 이후에는 여전히 Controller 로그까지만 남깁니다.


7.4. Member 단건 조회

단건 조회를 해도 @CachePut 으로 members::2 가 이미 캐싱되어 있기 때문에 Repository 로그는 남지 않습니다.


Conclusion

Cache 는 서버 개발을 하는데 굉장히 중요한 기능입니다.

대부분의 성능 개선을 캐시 추가로 할 수 있으며 설정도 다양하고 캐시 전략도 다양합니다.

Spring Boot 에서는 Cache 를 사용하기 쉽게 AOP 로 제공하고 있으니 사용법을 알아두면 필요할 때 유용하게 사용할 수 있습니다.


Reference

1. Overview

1편에서는 OAuth 2.0 에 대한 간단한 개념을 알아보고 네이버, 카카오 앱 등록까지 완료했습니다.

2편에서는 직접 코드를 구현하면서 최종적으로 API 만들어봅니다.

샘플 코드를 볼 때 다음 내용들을 참고해주세요.

  • Spring Security 를 사용하지 않음
  • ID, PW 인증하는 기본 로그인 코드는 작성하지 않음
  • 회원을 의미하는 Member 테이블에 저장되는 데이터는 각자 설계하기 나름이고 여기서는 기본적인 데이터만 사용
  • OAuth 2.0 은 Client (웹, 앱) 개발자와의 협업이 필수지만 여기서는 Backend 코드만 작성
    • 클라이언트 없이 테스트 하는 방법 소개

2. Server 의 역할

서버에서 구현해야 하는 로직은 크게 세가지 입니다.

  1. 카카오/네이버와 같은 OAuth 플랫폼에 인증 후 프로필 정보 가져오기
  2. email 정보로 사용자 확인 (없으면 새로 가입처리)
  3. Access Token 생성 후 내려주기

3. 개발 환경

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.0.3'
    id 'io.spring.dependency-management' version '1.1.0'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    runtimeOnly 'com.h2database:h2'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'

    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
    implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}

tasks.named('test') {
    useJUnitPlatform()
}

프로젝트 환경은 다음과 같습니다.

  • Spring Boot 3.0.3
  • Java 17
  • Spring Web
  • Spring Data JPA
  • H2 Database
  • Lombok
  • JWT 관련 라이브러리

4. 사전 세팅

4.1. application.yml

jwt:
  secret-key: Z29nby10bS1zZXJ2ZXItZGxyamVvYW9yb3JodG9kZ290c3Atam9vbmdhbmduaW0teWVvbHNpbWhpaGFsZ2VveW8K

oauth:
  kakao:
    client-id: 160cd4f66fc928d2b279d78999d6d018
    url:
      auth: https://kauth.kakao.com
      api: https://kapi.kakao.com
  naver:
    secret: W_2DmcLfYU
    client-id: Y2i4SlApP7A1KZsUoott
    url:
      auth: https://nid.naver.com
      api: https://openapi.naver.com

JWT 토큰 생성을 위한 Secret Key 와 OAuth 요청을 위한 여러가지 정보를 넣어둡니다.

Secret 값 같은 경우는 외부에 노출되지 않게 Vault 같은 보안 저장소에 넣을 수도 있습니다.

각자 본인이 등록한 Client ID 를 사용해야 합니다. (저는 게시글 작성 후 앱 삭제 예정)


4.2. Configuration

@Configuration
public class ClientConfig {

    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
}

RestTemplate 을 사용하기 위해 Spring Bean 컴포넌트로 등록하는 설정을 추가합니다.


5. Member 도메인 정의

public enum OAuthProvider {
    KAKAO, NAVER
}

회원의 로그인 타입을 저장하는 Enum 클래스입니다.

"OAuth 인증 제공자" 라는 의미에서 OAuthProvider 라는 네이밍을 사용했습니다.


@Getter
@Entity
@NoArgsConstructor
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String email;

    private String nickname;

    private OAuthProvider oAuthProvider;

    @Builder
    public Member(String email, String nickname, OAuthProvider oAuthProvider) {
        this.email = email;
        this.nickname = nickname;
        this.oAuthProvider = oAuthProvider;
    }
}

회원 정보를 담는 Member 엔티티입니다.

Email, Nickname 과 같은 프로필 정보나 인증 타입을 갖고 있습니다.

프로젝트 성격에 따라 회원 (Member) 도메인과 인증 (Authentication) 도메인을 분리하는 경우도 있으니 이 부분은 설계하기에 따라 바뀔 수 있습니다.


6. 외부 API 요청

외부 API 요청을 위한 Client 클래스를 만들어봅니다.

API 요청을 위해 RestTemplate 을 사용했지만, 선호도에 따라 다른 걸 사용해도 됩니다.

  • OAuthApiClient: 카카오나 네이버 API 요청 후 응답값을 리턴해주는 (인터페이스)
  • OAuthLoginParams: 카카오, 네이버 요청에 필요한 데이터를 갖고 있는 파라미터 (인터페이스)
  • KakaoTokens, NaverTokens: 인증 API 응답
  • OAuthInfoResponse: 회원 정보 API 응답 (인터페이스)
  • RequestOAuthInfoService: 외부 API 요청의 중복되는 로직을 공통화한 클래스

인터페이스를 많이 사용했는데 다음과 같은 장점이 있습니다.

  • 카카오, 네이버 외에 새로운 OAuth 로그인 수단이 추가되어도 쉽게 추가할 수 있음
  • "외부 Access Token 요청 -> 프로필 정보 요청 -> 이메일, 닉네임 가져오기" 라는 공통된 로직을 하나로 묶을 수 있음

6.1. OAuthLoginParams

public interface OAuthLoginParams {
    OAuthProvider oAuthProvider();
    MultiValueMap<String, String> makeBody();
}

OAuth 요청을 위한 파라미터 값들을 갖고 있는 인터페이스입니다.

이 인터페이스의 구현체는 Controller 의 @RequestBody 로도 사용하기 때문에 getXXX 라는 네이밍을 사용하지 않아야 합니다.


@Getter
@NoArgsConstructor
public class KakaoLoginParams implements OAuthLoginParams {
    private String authorizationCode;

    @Override
    public OAuthProvider oAuthProvider() {
        return OAuthProvider.KAKAO;
    }

    @Override
    public MultiValueMap<String, String> makeBody() {
        MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
        body.add("code", authorizationCode);
        return body;
    }
}

카카오 API 요청에 필요한 authorizationCode 를 갖고 있는 클래스입니다.


@Getter
@NoArgsConstructor
public class NaverLoginParams implements OAuthLoginParams {
    private String authorizationCode;
    private String state;

    @Override
    public OAuthProvider oAuthProvider() {
        return OAuthProvider.NAVER;
    }

    @Override
    public MultiValueMap<String, String> makeBody() {
        MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
        body.add("code", authorizationCode);
        body.add("state", state);
        return body;
    }
}

네이버는 authorizationCodestate 값을 필요로 합니다.


6.2. KakaoTokens, NaverTokens

Authorization Code 를 기반으로 타플랫폼 Access Token 을 받아오기 위한 Response Model 입니다.

여러 가지 값을 받아오지만 여기서 사용할 부분은 Access Token 뿐입니다.


@Getter
@NoArgsConstructor
public class KakaoTokens {

    @JsonProperty("access_token")
    private String accessToken;

    @JsonProperty("token_type")
    private String tokenType;

    @JsonProperty("refresh_token")
    private String refreshToken;

    @JsonProperty("expires_in")
    private String expiresIn;

    @JsonProperty("refresh_token_expires_in")
    private String refreshTokenExpiresIn;

    @JsonProperty("scope")
    private String scope;
}

https://kauth.kakao.com/oauth/token 요청 결과값입니다.

Kakao Developers - 카카오 로그인 토큰 받기 의 응답값 부분을 참고했습니다.


@Getter
@NoArgsConstructor
public class NaverTokens {

    @JsonProperty("access_token")
    private String accessToken;

    @JsonProperty("refresh_token")
    private String refreshToken;

    @JsonProperty("token_type")
    private String tokenType;

    @JsonProperty("expires_in")
    private String expiresIn;
}

https://nid.naver.com/oauth2.0/token 요청 결과값입니다.

Naver Developers - 로그인 API 명세의 접근 토큰 발급 요청 응답값을 참고했습니다.


6.3. OAuthInfoResponse

public interface OAuthInfoResponse {
    String getEmail();
    String getNickname();
    OAuthProvider getOAuthProvider();
}

Access Token 으로 요청한 외부 API 프로필 응답값을 우리 서비스의 Model 로 변환시키기 위한 인터페이스입니다.

카카오나 네이버의 email, nickname 정보를 필요로 하기 때문에 Getter 메서드를 추가했습니다.


@Getter
@JsonIgnoreProperties(ignoreUnknown = true)
public class KakaoInfoResponse implements OAuthInfoResponse {

    @JsonProperty("kakao_account")
    private KakaoAccount kakaoAccount;

    @Getter
    @JsonIgnoreProperties(ignoreUnknown = true)
    static class KakaoAccount {
        private KakaoProfile profile;
        private String email;
    }

    @Getter
    @JsonIgnoreProperties(ignoreUnknown = true)
    static class KakaoProfile {
        private String nickname;
    }

    @Override
    public String getEmail() {
        return kakaoAccount.email;
    }

    @Override
    public String getNickname() {
        return kakaoAccount.profile.nickname;
    }

    @Override
    public OAuthProvider getOAuthProvider() {
        return OAuthProvider.KAKAO;
    }
}

https://kapi.kakao.com/v2/user/me 요청 결과값입니다.

Kakao Developers - 사용자 정보 가져오기 를 참고해서 만든 응답값입니다.

원래 더 많은 응답값이 오지만 필요한 데이터만 추려내기 위해 @JsonIgnoreProperties(ignoreUnknown = true) 를 사용했습니다.


@Getter
@JsonIgnoreProperties(ignoreUnknown = true)
public class NaverInfoResponse implements OAuthInfoResponse {

    @JsonProperty("response")
    private Response response;

    @Getter
    @JsonIgnoreProperties(ignoreUnknown = true)
    static class Response {
        private String email;
        private String nickname;
    }

    @Override
    public String getEmail() {
        return response.email;
    }

    @Override
    public String getNickname() {
        return response.nickname;
    }

    @Override
    public OAuthProvider getOAuthProvider() {
        return OAuthProvider.NAVER;
    }
}

https://openapi.naver.com/v1/nid/me 요청 결과값입니다.

Naver Devlopers - 네이버 회원 프로필 조회 API 명세 를 참고해서 만든 응답값입니다.

마찬가지로 @JsonIgnoreProperties(ignoreUnknown = true) 를 사용해서 필요 없는 값들은 제외하고 원하는 값만 받도록 했습니다.


6.4. OAuthApiClient

public interface OAuthApiClient {
    OAuthProvider oAuthProvider();
    String requestAccessToken(OAuthLoginParams params);
    OAuthInfoResponse requestOauthInfo(String accessToken);
}

OAuth 요청 을 위한 Client 클래스입니다.

  • oAuthProvider(): Client 의 타입 반환
  • requestAccessToken: Authorization Code 를 기반으로 인증 API 를 요청해서 Access Token 을 획득
  • requestOauthInfo: Access Token 을 기반으로 Email, Nickname 이 포함된 프로필 정보를 획득

@Component
@RequiredArgsConstructor
public class KakaoApiClient implements OAuthApiClient {

    private static final String GRANT_TYPE = "authorization_code";

    @Value("${oauth.kakao.url.auth}")
    private String authUrl;

    @Value("${oauth.kakao.url.api}")
    private String apiUrl;

    @Value("${oauth.kakao.client-id}")
    private String clientId;

    private final RestTemplate restTemplate;

    @Override
    public OAuthProvider oAuthProvider() {
        return OAuthProvider.KAKAO;
    }

    @Override
    public String requestAccessToken(OAuthLoginParams params) {
        String url = authUrl + "/oauth/token";

        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

        MultiValueMap<String, String> body = params.makeBody();
        body.add("grant_type", GRANT_TYPE);
        body.add("client_id", clientId);

        HttpEntity<?> request = new HttpEntity<>(body, httpHeaders);

        KakaoTokens response = restTemplate.postForObject(url, request, KakaoTokens.class);

        assert response != null;
        return response.getAccessToken();
    }

    @Override
    public OAuthInfoResponse requestOauthInfo(String accessToken) {
        String url = apiUrl + "/v2/user/me";

        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        httpHeaders.set("Authorization", "Bearer " + accessToken);

        MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
        body.add("property_keys", "[\"kakao_account.email\", \"kakao_account.profile\"]");

        HttpEntity<?> request = new HttpEntity<>(body, httpHeaders);

        return restTemplate.postForObject(url, request, KakaoInfoResponse.class);
    }
}

Kakao Develpers 의 카카오 로그인 토큰 받기사용자 정보 가져오기 를 참고했습니다.

RestTemplate 을 활용해서 외부 요청 후 미리 정의해둔 KakaoTokens, KakaoInfoResponse 로 응답값을 받습니다.


@Component
@RequiredArgsConstructor
public class NaverApiClient implements OAuthApiClient {

    private static final String GRANT_TYPE = "authorization_code";

    @Value("${oauth.naver.url.auth}")
    private String authUrl;

    @Value("${oauth.naver.url.api}")
    private String apiUrl;

    @Value("${oauth.naver.client-id}")
    private String clientId;

    @Value("${oauth.naver.secret}")
    private String clientSecret;

    private final RestTemplate restTemplate;

    @Override
    public OAuthProvider oAuthProvider() {
        return OAuthProvider.NAVER;
    }

    @Override
    public String requestAccessToken(OAuthLoginParams params) {
        String url = authUrl + "/oauth2.0/token";

        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

        MultiValueMap<String, String> body = params.makeBody();
        body.add("grant_type", GRANT_TYPE);
        body.add("client_id", clientId);
        body.add("client_secret", clientSecret);

        HttpEntity<?> request = new HttpEntity<>(body, httpHeaders);

        NaverTokens response = restTemplate.postForObject(url, request, NaverTokens.class);

        assert response != null;
        return response.getAccessToken();
    }

    @Override
    public OAuthInfoResponse requestOauthInfo(String accessToken) {
        String url = apiUrl + "/v1/nid/me";

        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        httpHeaders.set("Authorization", "Bearer " + accessToken);

        MultiValueMap<String, String> body = new LinkedMultiValueMap<>();

        HttpEntity<?> request = new HttpEntity<>(body, httpHeaders);

        return restTemplate.postForObject(url, request, NaverInfoResponse.class);
    }
}

Naver Developers 의 로그인 API 명세회원 프로필 조회 API 명세 를 참고했습니다.

RestTemplate 을 활용해서 외부 요청 후 미리 정의해둔 NaverTokens, NaverInfoResponse 로 응답값을 받습니다.


6.5. RequestOAuthInfoService

@Component
public class RequestOAuthInfoService {
    private final Map<OAuthProvider, OAuthApiClient> clients;

    public RequestOAuthInfoService(List<OAuthApiClient> clients) {
        this.clients = clients.stream().collect(
                Collectors.toUnmodifiableMap(OAuthApiClient::oAuthProvider, Function.identity())
        );
    }

    public OAuthInfoResponse request(OAuthLoginParams params) {
        OAuthApiClient client = clients.get(params.oAuthProvider());
        String accessToken = client.requestAccessToken(params);
        return client.requestOauthInfo(accessToken);
    }
}

지금까지 만든 OAuthApiClient 를 사용하는 Service 클래스입니다.

KakaoApiClient, NaverApiClient 를 직접 주입받아서 사용하면 중복되는 코드가 많아지지만 List<OAuthApiClient> 를 주입 받아서 Map 으로 만들어두면 간단하게 사용할 수 있습니다.

참고로 List<인터페이스> 를 주입받으면 해당 인터페이스의 구현체들이 전부 List 에 담겨옵니다.


7. JWT(Access Token) 생성

네이버, 카카오 인증이 완료되면 클라이언트에게 Access Token 을 내려주어야 합니다.

여기서 Access Token 은 내 서비스의 인증 토큰이지, 네이버나 카카오의 토큰이 아닙니다.

OAuth 플랫폼들의 Access Token 을 클라이언트에게 내려주면 플랫폼 별로 만료 기간 관리도 번거롭고 혹여나 탈취라도 당하면 안되기 때문에 반드시 직접 토큰을 만들어서 내려줍니다.

JWT 관련 부분은 이 글의 핵심 주제는 아니기 때문에 자세한 설명은 생략합니다.


7.1. JwtTokenProvider

@Component
public class JwtTokenProvider {

    private final Key key;

    public JwtTokenProvider(@Value("${jwt.secret-key}") String secretKey) {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    public String generate(String subject, Date expiredAt) {
        return Jwts.builder()
                .setSubject(subject)
                .setExpiration(expiredAt)
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();
    }

    public String extractSubject(String accessToken) {
        Claims claims = parseClaims(accessToken);
        return claims.getSubject();
    }

    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(accessToken)
                    .getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }
}

JWT 토큰을 만들어주는 유틸 클래스입니다.


7.2. AuthTokens

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class AuthTokens {
    private String accessToken;
    private String refreshToken;
    private String grantType;
    private Long expiresIn;

    public static AuthTokens of(String accessToken, String refreshToken, String grantType, Long expiresIn) {
        return new AuthTokens(accessToken, refreshToken, grantType, expiresIn);
    }
}

사용자에게 내려주는 서비스의 인증 토큰 값입니다.


7.3. AuthTokensGenerator

@Component
@RequiredArgsConstructor
public class AuthTokensGenerator {
    private static final String BEARER_TYPE = "Bearer";
    private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30;            // 30분
    private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7;  // 7일

    private final JwtTokenProvider jwtTokenProvider;

    public AuthTokens generate(Long memberId) {
        long now = (new Date()).getTime();
        Date accessTokenExpiredAt = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
        Date refreshTokenExpiredAt = new Date(now + REFRESH_TOKEN_EXPIRE_TIME);

        String subject = memberId.toString();
        String accessToken = jwtTokenProvider.generate(subject, accessTokenExpiredAt);
        String refreshToken = jwtTokenProvider.generate(subject, refreshTokenExpiredAt);

        return AuthTokens.of(accessToken, refreshToken, BEARER_TYPE, ACCESS_TOKEN_EXPIRE_TIME / 1000L);
    }

    public Long extractMemberId(String accessToken) {
        return Long.valueOf(jwtTokenProvider.extractSubject(accessToken));
    }
}

AuthTokens 을 발급해주는 클래스입니다.

  • generate: memberId (사용자 식별값) 을 받아 Access Token 을 생성
  • extractMemberId: Access Token 에서 memberId (사용자 식별값) 추출

8. Controller, Service

이제 지금까지 만든 코드를 갖고 최종 비즈니스 로직을 만들어봅니다.


8.1. OAuthLoginService

@Service
@RequiredArgsConstructor
public class OAuthLoginService {
    private final MemberRepository memberRepository;
    private final AuthTokensGenerator authTokensGenerator;
    private final RequestOAuthInfoService requestOAuthInfoService;

    public AuthTokens login(OAuthLoginParams params) {
        OAuthInfoResponse oAuthInfoResponse = requestOAuthInfoService.request(params);
        Long memberId = findOrCreateMember(oAuthInfoResponse);
        return authTokensGenerator.generate(memberId);
    }

    private Long findOrCreateMember(OAuthInfoResponse oAuthInfoResponse) {
        return memberRepository.findByEmail(oAuthInfoResponse.getEmail())
                .map(Member::getId)
                .orElseGet(() -> newMember(oAuthInfoResponse));
    }

    private Long newMember(OAuthInfoResponse oAuthInfoResponse) {
        Member member = Member.builder()
                .email(oAuthInfoResponse.getEmail())
                .nickname(oAuthInfoResponse.getNickname())
                .oAuthProvider(oAuthInfoResponse.getOAuthProvider())
                .build();

        return memberRepository.save(member).getId();
    }
}

처음 설명했던 로직을 그대로 작성했습니다.

  1. 카카오/네이버와 같은 OAuth 플랫폼에 인증 후 프로필 정보 가져오기
  2. email 정보로 사용자 확인 (없으면 새로 가입처리)
  3. Access Token 생성 후 내려주기

취향에 따라 findOrCreateMember 부분을 별도 MemberService 로 분리해도 상관없습니다.


코드를 보면 알 수 있듯이 네이버, 카카오에 특화된 로직이 아닌 공통된 로직이며 인터페이스만을 사용했습니다.

대신 login 메서드 호출 시 KakaoLoginParams, NaverLoginParams 둘 중에 뭐가 들어오냐에 따라 API 요청하는 곳이 달라집니다.

만약 새로운 Google, Facebook 로그인이 추가된다고 하더라도 이 코드는 수정할 필요가 없기 때문에 안전하게 추가 가능합니다.


8.2. AuthController

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/auth")
public class AuthController {
    private final OAuthLoginService oAuthLoginService;

    @PostMapping("/kakao")
    public ResponseEntity<AuthTokens> loginKakao(@RequestBody KakaoLoginParams params) {
        return ResponseEntity.ok(oAuthLoginService.login(params));
    }

    @PostMapping("/naver")
    public ResponseEntity<AuthTokens> loginNaver(@RequestBody NaverLoginParams params) {
        return ResponseEntity.ok(oAuthLoginService.login(params));
    }
}

사용자에게 요청을 받는 Controller 부분입니다.

딱히 특별한 부분은 없고 파라미터로 구현체를 받아서 직접 login 을 호출하는 차이밖에 없습니다.


9. Class Diagram

위에서 작성한 코드를 간단하게 표현하면 이렇게 나옵니다.


10. API 요청 테스트

클라이언트 없이 서버에서만 테스트를 진행해봅시다.


10.1. OAuth 로그인

네이버, 카카오 로그인 페이지를 직접 만들어서 접속 후 로그인 합니다.

1편에서도 한번 다뤘기 때문에 자세한 설명은 생략합니다.

로그인 후에 Redirect URI 로 전달된 Authorization Code 를 확인합니다.

  • 카카오: https://kauth.kakao.com/oauth/authorize?client_id=160cd4f66fc928d2b279d78999d6d018&redirect_uri=http://localhost:8080/kakao/callback&response_type=code
  • 네이버: https://nid.naver.com/oauth2.0/authorize?response_type=code&client_id=Y2i4SlApP7A1KZsUoott&state=hLiDdL2uhPtsftcU&redirect_uri=http://localhost:8080/naver/callback

10.2. API 호출하기

Postman, Curl, IntelliJ IDE 등 자신만의 API 호출 방법을 사용해서 직접 API 를 호출합니다.

저는 Talend API Tester 라는 크롬 확장프로그램을 사용했습니다.

응답값으로 Access Token 을 받아온다면 성공입니다.


10.2.1. Kakao 로그인 호출

카카오는 authorizationCode 파라미터만 추가해서 호출합니다.


10.2.2. Naver 로그인 호출

네이버는 authorizationCode 뿐만 아니라 state 값도 함께 받아서 전달합니다.


10.3. Member 정보 호출

간단한 Member API 를 만들어서 원하는 데이터가 잘 들어갔는지 확인합니다.


10.4. Access Token 검증

Access Token 검증은 두 가지 방법이 있습니다.

  1. API 만들어서 호출
  2. Test Code 에서 코드를 만들어 AuthTokensGenerator.extractMemberId 메서드를 직접 실행

둘 중에 편한 방법으로 확인하시면 됩니다.

위에서 획득한 Access Token 으로 정확한 memberId 를 얻을 수 있다면 성공입니다.


Conclusion

지금까지 Spring Boot 에서 OAuth 2.0 을 활용한 인증 기능을 개발했습니다.

OAuth 2.0 에 대해 잘 모를 때는 어렵고 막막하단 느낌이 들었는데 실제로 구현하고 나니 간단하다고 느꼈습니다.


Reference

1. Overview

서비스를 개발하다 보면 ID, Password 를 사용하는 기본 로그인 외에 SNS 를 활용한 소셜 로그인을 제공하는 경우도 있습니다.

구글 로그인이 대표적이며 국내에서는 네이버, 카카오 로그인을 많이 사용합니다.

이번 시리즈에서는 Spring Boot 환경에서 네이버, 카카오 로그인을 지원하는 기능을 만들어봅니다.

우선 1편에서는 간단한 OAuth 2.0 에 대한 설명과 앱 등록을 알아보고 2편에서 실제 구현 코드를 다룰 예정입니다.


2. OAuth 2.0 이란?

OAuth 2.0 을 간단하게 설명하면 어떤 서비스를 만들 때 사용자 개인정보와 인증에 대한 책임을 지지 않고 신뢰할 만한 타사 플랫폼에 위임하는 겁니다.

개인정보 관련 데이터를 직접 보관하는 것은 리스크가 큽니다.

보안적으로 문제되지 않도록 안전하게 관리해야 하고 ID/PW 에 관련된 지속적인 해킹 공격 등 여러 가지 신경써야 할 부분이 많습니다.

하지만 OAuth 2.0 을 사용해 신뢰할 수 있는 플랫폼 (구글, 페이스북, 네이버, 카카오 등) 에 개인정보, 인증 기능을 맡기면 서비스는 인증 기능에 대한 부담을 줄일 수 있습니다.


2.1. OAuth 2.0 Sequence Diagram

OAuth 2.0 은 일반적으로 위와 같은 플로우를 많이 사용합니다.

그래서 클라이언트와 서버측 모두 OAuth 2.0 플로우에 대해 숙지하고 있어야 합니다.


3. 카카오 앱 등록

카카오 로그인을 사용하기 위해선 우선 카카오에 내 서비스를 등록해야 합니다.


3.1. 앱 등록

https://developers.kakao.com/console/app 에 접속해서 앱을 추가합니다.


3.2. 플랫폼 등록

여기서는 테스트용으로 localhost 만 사용하기 때문에 따로 등록은 안했지만 도메인을 등록해야 사용 가능한 API 들도 존재합니다.

확인 후 필요한 경우 자신의 서비스 도메인을 등록해줍니다.


3.3. 로그인 API 활성화

로그인 API 를 활성화 하고 Redirect URI 를 등록합니다.

Redirect URI 를 등록하지 않으면 인증에 필요한 code 를 받을 수 없으므로 필수로 등록해야 합니다.

Redirect URI 로 code 를 받는 부분은 클라이언트의 영역이므로 같이 협업하는 웹, 앱 개발자가 있다면 등록을 부탁하면 됩니다.

여기서는 서버 개발만 해서 테스트할 예정이므로 http://localhost:8080/kakao/callback 으로 등록했습니다.


3.4. 동의항목 활성화

이제 동의항목으로 이동해서 필요한 데이터의 동의 항목을 활성화 합니다.

여기서는 테스트를 위해 닉네임, 이메일을 활성화 했습니다.

이메일 필수 동의를 받으려면 앱 검수가 필요하기 때문에 임시로 "선택 동의" 상태로 만듭니다.


3.5. 인가 코드 받기 테스트

이제 앱 화면에서 REST API 키 (Client ID) 값을 확인합니다.

client_id, redirect_uri, response_type 파라미터를 사용해서 카카오 로그인 페이지 URL 을 만듭니다.

다른 여러가지 파라미터 값들은 Kakao Developers - 인가 코드 받기 를 참고합니다.

https://kauth.kakao.com/oauth/authorize
?client_id=160cd4f66fc928d2b279d78999d6d018
&redirect_uri=http://localhost:8080/kakao/callback
&response_type=code

위 URL 에 접속하면 카카오 로그인 화면이 보입니다.

원래는 클라이언트에서 "카카오로 로그인하기" 버튼을 추가하고 사용자가 눌렀을 때 연결되어야 하지만 우리는 UI 가 없으므로 직접 URL 에 접속합니다.


전체 동의 후 계속하기를 누르면 우리가 등록한 Redirct URI 로 페이지가 이동합니다.

그 페이지 URL 의 파라미터를 확인하면 code 를 찾을 수 있습니다.

http://localhost:8080/kakao/callback
?code=IY6uM7PIvuWa5D1LYXYrnfvMcd1-0U36AwkwrHm_aUwud8-neFISILn6KzXuJesy0p4GOwopyV4AAAGGt3E46Q

이제 클라이언트에서 얻은 Authorization Code 로 우리가 만든 백엔드 API 를 호출해서 로그인을 진행할수 있습니다.


4. 네이버 앱 등록

카카오 앱 등록과 비슷하지만 네이버는 설정해야 하는 게 조금 더 적습니다.


4.1. 앱 등록

카카오 앱 등록과 마찬가지로 애플리케이션 이름, 사용할 API 를 선택 후 동의 항목은 이메일과 별명을 설정합니다.

서비스 URL 에는 본인이 만들고 있는 서비스의 URL 을 넣습니다.

Callback URL 은 네이버 로그인 후 이동할 URL 이며 Authorization Code 을 파라미터로 받는 URL 입니다.


4.2. 코드 받기 테스트

앱을 등록하고 나면 이렇게 정보를 얻을 수 있습니다.

네이버는 카카오와 달리 Client Secret 값을 추가로 제공하는데, 나중에 인가 코드를 요청할 때 필요합니다.

client_id, redirect_uri, response_type, state 파라미터를 사용해서 네이버 로그인 페이지 URL 을 만듭니다.

파라미터 값에 대한 상세한 설명은 Naver Developers - 네이버 로그인 요청 변수 를 참고하시면 됩니다.

https://nid.naver.com/oauth2.0/authorize
?response_type=code
&client_id=Y2i4SlApP7A1KZsUoott
&state=hLiDdL2uhPtsftcU
&redirect_uri=http://localhost:8080/naver/callback

로그인을 완료하면 우리가 등록해둔 Callback URL 로 이동합니다.

URL 의 파라미터를 확인하면 code, state 값을 볼 수 있습니다.

http://localhost:8080/naver/callback
?code=Sl6x32RuDGpdIXCjmV
&state=hLiDdL2uhPtsftcU

5. Conclusion

다음 포스팅에서는 Spring Boot 코드를 직접 짜보겠습니다.

Overview

Ruby 언어에 관한 정보를 정리합니다.

나중에 더 추가될 수도 있습니다.


1. Variables

number = 1
puts number # => 1

large_number = 2

변수 이름이 길어질 때는 snake_case 를 사용합니다.


2. Data Types

Ruby 에는 다음과 같은 타입들이 있습니다.

기본적으로 최상위 타입은 전부 Object 입니다.

  • Numbers
  • Strings (texts)
  • True, False, and Nil
  • Symbols
  • Arrays
  • Hashes

2.1. Numbers

a = 1   # Integer
b = 1.2 # Float

c = 1_000_000 # == 1,000,000

숫자 타입입니다.

Integer, Float, BigDecimal 등등이 존재합니다.


2.2. Strings

"hi" + "hi"         # => "hihi"
"hi" * 3            # => "hihihi"

"hello".upcase      # => "HELLO"
"hello".capitalize  # => "Hello"
"hello".length      # => 5
"hello".reverse     # => "olleh"

문자열입니다.

큰 따옴표가 아니라 작은 따옴표로 묶어서 사용할 수도 있습니다.


2.3. Symbols

Symbol 은 문자열과 비슷하지만 조금 다릅니다.

앞에 콜론이 붙어있으면 Symbol 입니다. (:something)

Symbol 은 보통 텍스트를 데이터 가 아닌 코드 로 사용할 때 많이 사용합니다.

예를 들면 Hash Key 같은 경우 Key 로 사용한다는 것에 의미가 있죠.

그리고 String 과 비교했을 때 가장 두드러지는 부분은 같은 Symbol 은 동일한 객체를 참조한다는 점입니다.

String 은 매번 다른 객체를 생성하지만 Symbol 은 동일한 객체를 재사용합니다.

"string".object_id # => 2409957680
"string".object_id # => 2682952200
"string".object_id # => 2409974840

:symbol.object_id # => 881948
:symbol.object_id # => 881948
:symbol.object_id # => 881948

2.4. Array

a = [1, 2, 3]

a[0]        # 1
a << 4      # a == [1, 2, 3, 4]
a[6] = 7    # a == [1, 2, 3, 4, nil, nil, 7]
a.first     # 1
a.last      # 7
a.length    # 7
a.compact   # [1, 2, 3, 4, 7]

a.compact 처럼 새로운 배열을 뽑을 때 현재 배열 자체를 바꾸고 싶다면 마지막에 ! 를 붙여주면 됩니다. (a.compact!)


2.5. Hash

dictionary = { "one" => "eins", "two" => "zwei", "three" => "drei" }
dictionary["one"]               # "eins"
dictionary["zero"] = "null"     # insert

Key, Value 로 지정할 수 있는 타입에는 제한이 없습니다.

위 예시에서는 둘다 String 이었지만 Key 로 Integer 를 사용해도 되고 Value 에 배열이 들어가도 됩니다.

가장 많이 사용되는 Key 타입은 Symbol 입니다.

Symbol Key Hash 는 좀더 간편하게 표현할 수 있게 지원해줍니다.

아래 두 문법은 동일한 기능을 갖습니다.

a = { one: "eins", two: "zwei", three: "drei" }
a = { :one => "eins", :two => "zwei", :three => "drei" }

a[:one] # => "eins"

2. String interpolation

str = "Ruby"
puts "Hello, #{str}"
puts "1 + 2 = #{1 + 2}"

#{} 로 감싸면 다른 변수를 넣을 수 있습니다.


3. Method

def add(a, b)
  a + b
end

add(1, 2) # => 3
add 1, 2  # => 3

Ruby 의 메서드는 return 이 없어도 맨 마지막 값을 자동으로 리턴합니다.

메서드를 호출할 때는 괄호로 감싸지 않아도 호출 가능합니다.


3.1. 괄호 생략

def print
  puts "Hello, World!"
end

print() # => Hello, World!
print # => Hello, World!

메서드를 정의할 때 파라미터가 필요 없다면 생략할 수 있습니다.

호출 역시 마찬가지로 생략 가능합니다.


3.2. Default parameter

def print(greeting = "Hello", target = "World")
  puts "#{greeting} #{target}"
end

print               # Hello World
print("Hi")         # Hi World
print("Hi", "Ruby") # Hi Ruby

파라미터 값이 없을 때의 기본값을 넣어줄 수 있습니다.


4. Class

class Person
end

클래스는 위와 같이 정의할 수 있습니다.

person = Person.new 처럼 객체를 생성할 수 있습니다.


4.1. Ruby Class 의 인스턴스, 클래스란?

Ruby 에서는 인스턴스 변수, 인스턴스 메서드, 클래스 변수, 클래스 메서드 등 인스턴스와 클래스라는 단어가 많이 나옵니다.

Ruby 에서 말하는 인스턴스, 클래스는 다음과 같습니다.

  • 인스턴스
    • 객체를 생성 (new) 해야 사용할 수 있는 변수, 메서드
    • 인스턴스 변수는 객체끼리 독립된 값을 가짐
  • 클래스
    • 객체 생성 없이 클래스 자체로 생성할 수 있는 변수, 메서드
    • 클래스 변수는 같은 클래스로 생성된 여러 객체가 공유함

4.2. 인스턴스 변수

class Person
  def print_name
    puts "My Name is #{@name}"
  end
end

p1 = Person.new
p1.print_name   # My Name is
p1.name = "woody"
p1.print_name   # My Name is woody

인스턴스 변수는 클래스 내에서 @ 가 붙어있는 변수를 의미합니다.

일회성으로 사용되고 끝나는 변수와 달리 생성된 인스턴스 내에서 계속 사용할 수 있습니다.


4.3. 클래스 변수

class Person
  @@name = "default"

  def name
    @@name
  end

  def name=(name)
    @@name = name
  end
end

p1 = Person.new
p2 = Person.new
p1.name     # default
p2.name     # default

p1.name = "woody"
p1.name     # woody
p2.name     # woody

클래스 변수는 모든 객체가 공유하는 변수입니다.

위 코드에서 볼수있듯이 p1 의 변수를 바꾸었지만 p2 의 변수도 똑같이 바뀐 것을 볼 수 있습니다.

이것이 인스턴스 변수와의 차이점입니다.


4.4. 상수

class Person
  AGE = 24
end

Person::AGE # 24

상수는 변하지 않는 변수입니다.

객체를 직접 생성하지 않아도 바로 사용할 수 있습니다.


4.5. 인스턴스 메서드, 클래스 메서드

class Sample
  def print
    puts "Instance Method"
  end

  def self.print
    puts "Class Method"
  end
end

Sample.new.print # Instance Method
Sample.print     # Class Method

인스턴스 메서드는 객체를 생성한 뒤에 사용하는 메서드, 클래스 메서드는 그대로 사용하는 메서드입니다.

인스턴스 메서드는 def 처럼 평범하게 정의하면 되지만 클래스 메서드는 def self 를 사용해서 정의해야 합니다.


4.6. 생성자

class Person
  def initialize(name)
    @name = name
  end
end

Person.new("woody") # => #<Person:0x000000012cebfeb0 @name="woody">
Person.new          # ArgumentError (wrong number of arguments (given 0, expected 1))

Ruby 에서는 initialize 메서드로 생성자를 선언할 수 있습니다.

생성자를 선언하지 않으면 파라미터가 없는 기본 생성자를 사용할 수 있습니다.

인스턴스 변수를 초기화할 때는 일반적으로 생성자를 사용합니다.


4.7. Accessor (Getter, Setter)

class Person
  attr_accessor :name
  attr_reader :age
  attr_writer :address
end

p = Person.new

# accessor (getter, setter)
p.name # => nil
p.name = "adsf"
p.name # => "asdf"

# reader (getter)
p.age # => nil
p.age # NoMethodError (undefined method `age=' for #<Person:0x000000012cb9ce90 @name="asdf">)

# writer (setter)
p.address # NoMethodError (undefined method `address' for #<Person:0x000000012cb9ce90 @name="asdf">)
p.address = "Seoul"

attr_accessor, attr_reader, attr_writer 를 사용해서 Getter, Setter 를 생성해줄 수 있습니다.

반대로 private 을 사용해서 Getter, Setter 를 숨길 수도 있습니다.

Accessor 로 지정한 변수는 인스턴스 변수로 사용 가능합니다.


4.8. 상속

class Fruit
  attr_accessor :name

  def print
    puts "This is Fruit"
  end

  def super_method
    puts "This is super class method"
  end
end

class Apple < Fruit
  def print
    puts "This is Apple"
  end
end

< 키워드를 사용해서 상속을 표현할 수 있습니다.

상속받은 클래스에서는 상위 클래스의 메서드나 변수를 사용할 수 있으며 오버라이드도 가능합니다.


4.9. 클래스 확장

클래스 확장이란 기존에 정의된 클래스에 새로운 메서드나 변수를 추가하는 것을 의미합니다.

새로운 기능을 추가하는 방법은 총 세가지지만 기존에 정의된 메서드를 중복 정의하는 경우 새롭게 덮어 씌운다는 공통점이 있습니다.

우선 Game 이라는 기본적인 클래스를 정의합니다.

class Game
  def initialize
    @name = "lol"
  end
end

1. << 키워드 사용

# 인스턴스
game = Game.new

class << game
  attr_accessor :age

  def print_one
    puts "print_one: #{@name}"
  end
end

# 클래스
class << Game
  def print_class
    puts "print_class"
  end
end

<< 키워드를 사용하면 동적으로 새로운 변수, 메서드를 추가할 수 있습니다.


2. 확장 함수로 정의

# 인스턴스
def game.print_two
  puts "print_two: #{@name}"
end

# 클래스
def Game.print_class
  puts "print_class"
end

Kotlin 의 확장 함수와 비슷한 문법입니다.


3. 클래스 분할 정의

class Game
  def print_three
    puts "print_three: #{@name}"
  end

  def self.print_class
    puts "print_class"
  end
end

Ruby 는 클래스를 여러 번 정의할 수 있습니다.

분할 정의된 클래스의 내용은 기존 클래스에 그대로 추가됩니다.


5. Module

모듈 (Module) 이란 여러 가지 기능을 모은 것을 의미합니다.

모듈은 크게 두 가지 목적으로 사용합니다.

  • Namespace 구분
    • 중복된 이름의 클래스를 사용할 때 충돌이 발생하지 않도록 합니다
    • V1, V2 처럼 버전 구분을 할 때 유용
  • 중복된 기능 모으기
    • 여러 곳에서 사용하는 중복된 기능을 분리해서 각각의 모듈에 담을 수 있습니다

5.1. Definition

module Sample
end

모듈은 위와 같이 정의할 수 있습니다.


5.2. Namespace

module V1
  class API
    def self.call
      puts "Call API v1"
    end
  end
end

module V2
  class API
    def self.call
      puts "Call API v2"
    end
  end
end

V1::API.call # Call API v1
V2::API.call # Call API v2

원래 동일한 이름의 클래스와 메서드를 선언하면 분할 정의로 취급하여 기존 메서드는 사라졌습니다.

하지만 모듈을 사용해 분리했기 때문에 두 클래스는 같은 이름과 같은 메서드를 같지만 다른 클래스, 메서드처럼 동작합니다.


5.3. Module Function

module Person
  ADDRESS = "Seoul"

  def age
    @age
  end

  def age=(age)
    @age = age
  end

  def company
    "company"
  end

  module_function :age, :age=
end

Person::ADDRESS # => "Seoul"
Person.age      # => nil
Person.age = 24
Person.age      # => 24
Person.company  # NoMethodError (undefined method `company' for Person:Module)

모듈에서 변수나 메서드를 정의할 수 있습니다.

모듈은 객체처럼 생성이 불가능하기 때문에 사실상 모든 변수와 메서드는 클래스 변수, 메서드라고 볼 수 있습니다.

정의된 메서드를 모듈에서 직접 사용하기 위해선 module_function 키워드를 사용해서 지정해줘야 합니다.


5.4. Mixin

Module 을 Class 에서 참조하면 마치 클래스의 메서드인것처럼 사용할 수 있습니다.

이걸 mix-in 이라고 부릅니다.

모듈을 mixin 하는 방법에는 include, prepend, extend 총 세가지가 있습니다.


5.4.1. Include

module IncludeModule
  def hello
    puts "Hello, Include"
  end
end

class Person
  include IncludeModule

  def hello
    puts "Hello, Person"
  end
end

person = Person.new
person.hello    # Hello, Person

include 키워드를 사용해서 모듈을 참조하면 모듈의 메서드를 인스턴스 메서드로 사용할 수 있습니다.

만약 동일한 이름의 메서드가 모듈과 클래스 양쪽에 정의되어 있다면 클래스의 메서드를 우선시합니다.


5.4.2. Prepend

module PrependModule
  def hello
    puts "Hello, Prepend"
  end
end

class Person
  prepend PrependModule

  def hello
    puts "Hello, Person"
  end
end

person = Person.new
person.hello    # Hello, Prepend

prependinclude 와 마찬가지로 모듈의 메서드를 인스턴스 메서드로 사용할 수 있습니다.

하지만 include 와 다르게 동일한 이름의 메서드가 정의되어 있을 경우 모듈의 메서드를 우선시합니다.


5.4.3. Extend

module ExtendModule
  def hello
    puts "Hello, Extend"
  end
end

class Person
  extend ExtendModule
end

Person.hello    # Hello, Extend

extend 는 다른 mixin 과 다르게 클래스 메서드로 사용 가능합니다.

중복 정의된 메서드는 include 와 마찬가지로 클래스에 있는 걸 우선시합니다.


5.4.4. Ancestors

만약 여러 개의 모듈을 한번에 include 하면 어떻게 될까?

include, prepend 가 여러개 섞여 있으면 어떻게 될까?

Ruby 에서는 어떤 오브젝트가 젤 우선시 되는지 ancestors 메서드로 확인할 수 있습니다.


module Include1
end

module Include2
end

module Prepend1
end

module Prepend2
end

class Human
end

class Person < Human
  include Include1
  include Include2
  prepend Prepend1
  prepend Prepend2
end

Person 클래스에서는 여러 모듈을 동시에 mixin 하고 있습니다.

prepend -> Person -> include 순서까지는 쉽게 짐작할 수 있지만 같은 키워드를 사용한 모듈들은 어떤게 우선시 되는지 알 수 없습니다.

게다가 상위 클래스에도 중복된 메서드가 정의되어 있다면 더욱 더 헷갈립니다.

이를 확인할 수 있는게 ancestors 메서드입니다.


Person.ancestors
=> [Prepend2, Prepend1, Person, Include2, Include1, Human, ActiveSupport::ToJsonWithActiveSupportEncoder, Object, PP::ObjectMixin, ActiveSupport::Dependencies::Loadable, ActiveSupport::Tryable, JSON::Ext::Generator::GeneratorMethods::Object, Kernel, BasicObject]

Person.ancestors 를 사용하면 해당 클래스가 참조하고 있는 모든 오브젝트가 나옵니다.

그리고 중복 정의된 메서드가 있으면 앞 순서에 있는 오브젝트의 메서드가 우선시됩니다.

그래서 Person 클래스에서 중복된 메서드가 있으면 Prepend2 모듈의 메서드가 사용 됩니다.

모든 클래스는 오브젝트라서 BasicObject 가 최상단에 있는 걸 알 수 있습니다.


Reference

'Framework > RubyOnRails' 카테고리의 다른 글

RubyOnRails 세션  (0) 2021.11.06
Ruby 의 as_json 과 to_json 의 차이  (0) 2021.06.06
RSpec Test Frameworks  (0) 2020.07.14
RubyOnRails - nil? empty? blank? present? 차이점  (0) 2020.07.14
Ruby Regular Expression (정규 표현식)  (0) 2020.07.14

순전히 개인적인 경험과 주관적인 의견으로 작성된 글입니다.

잘못된 정보가 있으면 지적 부탁드립니다.

Overview

웹플럭스는 흔히 비동기/논블로킹 이벤트루프 모델이라고 말합니다.

적은 수의 쓰레드로 많은 요청을 처리할 수 있는 걸 장점으로 내세우고 있는데, 항상 의문이 있었습니다.

흔히 블록킹 구간이라고 하는 네트워크 I/O 또는 데이터베이스 I/O 작업은 결국 어디선가 또는 누군가가 대기했다가 결과를 받아서 처리해야 합니다.

기존 MVC 모델에서 비동기 처리를 하면 별도의 쓰레드를 사용해서 처리했었기 때문에 큰 의문이 없었지만 쓰레드 하나로는 어떻게 처리하는지 궁금했습니다.

만약 웹플럭스에서도 별도 쓰레드풀을 만들어 처리한다면 요청량 증가에 따라 점점 백그라운드 쓰레드가 많아질거고 적은 수의 쓰레드로 컨텍스트 스위칭 비용 최소화 라는 장점이 무색해질 것 같았습니다.

이것저것 찾아본 결과 I/O 요청은 커널단으로 넘기고 JVM 은 I/O 요청에 필요한 데이터 복사만 해주기 때문에 블록킹 구간이 없다는 정보를 얻었습니다.

그렇다면 실제로 쓰레드가 처리해야 하는 연산이 오래 걸리는 경우에는 어떻게 될까? 라는 의문도 함께 들었습니다.

궁굼증을 해결하기 위해 코드를 짜서 테스트를 진행해보기로 했습니다.

전체 코드는 Github 에서 확인할 수 있습니다.

  • 테스트 환경
    • Kotlin / WebFlux 기반의 Spring Boot
    • 쓰레드는 단 하나만 사용
  • 테스트 내용
    • WebClient 로 응답이 오래 걸리는 외부 API 요청 시 쓰레드가 블락되는가?
    • 쓰레드가 처리해야 하는 무거운 연산 요청이 동시에 들어오면 비동기/논블로킹으로 처리 가능한가?
  • 주의사항
    • API 테스트를 할 때 동일 브라우저에서 여러 탭을 열어서 같은 요청을 보내면 순서대로 처리하기 때문에 시크릿 브라우저를 열어서 테스트 필요

1. 요청이 오래 걸리는 외부 API 를 요청하면 어떻게 될까?

Spring WebFlux 에서 외부 API 를 호출할 때는 WebClient 를 사용합니다.

WebClient 는 기존 MVC 모델에서 사용하던 RestTemplate 클래스와는 다르게 비동기로 API 를 호출하고 응답받을 수 있는 기능을 지원합니다.

WebClient 는 Spring WebFlux 에서 사용하는 이벤트 루프 워커 쓰레드를 공유합니다.

그래서 만약 외부 API 요청 시에 쓰레드가 Block 된다면 굉장한 문제가 생깁니다.

웹플럭스는 Core * 2 개의 쓰레드를 사용하기 때문에 많은 쓰레드를 사용하는 MVC 모델에 비해 쓰레드 블락의 영향이 큽니다.


1.1. 외부 API 서버 만들기

@SpringBootApplication
class ServerMvcApplication

fun main(args: Array<String>) {
    System.setProperty("server.port", "8181")
    runApplication<ServerMvcApplication>(*args)
}

@RestController
class BlockController {
    val log: Logger = LoggerFactory.getLogger(BlockController::class.java)

    @GetMapping("/block/{id}")
    fun block(@PathVariable id: Long): ResponseEntity<String> {
        log.info("request $id start")
        Thread.sleep(5000)
        log.info("request $id end")
        return ResponseEntity.ok().body("response $id")
    }
}

API 요청을 받아 5초 뒤에 응답해주는 서버입니다.

일반적인 상황을 위해 외부 서버는 Spring Boot WebMVC 로 만들었습니다.

로컬에서 동시에 띄우기 위해 포트를 8181 로 변경하였고 /block/{id} API 를 요청하면 쓰레드를 5초동안 슬립시킨 후에 응답합니다.

MVC 모델은 요청마다 쓰레드를 하나씩 할당해서 처리하기 때문에 여러 요청이 들어와도 5초씩만 지연됩니다.


크롬 브라우저와 시크릿 브라우저에서 요청하면 별도 쓰레드에서 각각 요청을 처리하는 걸 볼 수 있습니다.


1.2. WebFlux 서버 만들기

위에서 만든 MVC 를 호출하는 웹플럭스 서버를 만들어봅니다.


1.2.1. Server Code

@SpringBootApplication
class ServerWebfluxApplication

fun main(args: Array<String>) {
    // 쓰레드 1개만 사용
    System.setProperty("reactor.netty.ioWorkerCount", "1")
    runApplication<ServerWebfluxApplication>(*args)
}

@Configuration
class RouterConfig {
    val log: Logger = LoggerFactory.getLogger(RouterConfig::class.java)

    @Bean
    fun route(handler: RouterHandler) = router {
        "v1".nest {
            GET("/call/{id}", handler::call)

            before { request ->
                log.info("Before Filter ${request.pathVariable("id")}")
                log.info("$request")
                request
            }

            after { request, response ->
                log.info("After Filter ${request.pathVariable("id")}")
                response
            }
        }
    }
}

@Controller
class RouterHandler {
    val log: Logger = LoggerFactory.getLogger(RouterHandler::class.java)
    val webClient = WebClient.create("http://localhost:8181")

    fun call(request: ServerRequest): Mono<ServerResponse> {
        val id = request.pathVariable("id")

        return webClient.get()
            .uri("/block/$id")
            .retrieve()
            .bodyToMono(String::class.java)
            .flatMap {
                ServerResponse.ok().json().body(
                    Mono.just("[request $id] response $it")
                )
            }
    }
}

/v1/call/{id} 요청을 받으면 http://localhost:8181/block/{id} 호출한 결과값을 응답하는 API 입니다.

쓰레드 블록 여부를 판단해야 하기 때문에 워커 쓰레드 갯수를 1 개로 세팅합니다.


1.2.2. Thread Count

실제로 쓰레드가 한 개만 뜬 것을 확인할 수 있습니다.

요청은 모두 하나의 쓰레드로만 들어오며 쓰레드가 블락되는 경우 API 응답이 지연될 겁니다.


1.2.3. Log

쓰레드 하나로만 처리하는데도 Block 되지 않고 각각 5초만에 응답을 리턴합니다.


1.3. WebClient 대신 RestTemplate 을 사용하면?

@Controller
class RouterHandler {

    fun rest(request: ServerRequest): Mono<ServerResponse> {
        val id = request.pathVariable("id")
        val restTemplate = RestTemplate()
        val response = restTemplate.getForObject("http://localhost:8181/block/$id", String::class.java)

        return ServerResponse.ok().json().body(
            Mono.just(response!!)
        )
    }
}

테스트 하는 김에 RestTemplate 으로도 테스트 해봤습니다.

/v1/rest/{id} 를 호출하면 쓰레드 1개를 블록시키기 때문에 요청이 순차적으로 처리됩니다.


일반 브라우저와 시크릿 브라우저에서 동시에 호출해도 순서대로 처리되는 걸 볼 수 있습니다.


2. 무거운 연산을 수행하는 경우에는 어떻게 될까?

API 요청은 논블로킹으로 처리하는데 무거운 연산을 쓰레드가 직접 수행하는 경우에는 어떻게 되는지 확인해봤습니다.


2.1. Server Code

@Controller
class RouterHandler {
    val log: Logger = LoggerFactory.getLogger(RouterHandler::class.java)

    fun heavy(request: ServerRequest): Mono<ServerResponse> {
        val id = request.pathVariable("id")

        (0..1_000_000_000).forEach {
            if (it % 100_000_000 == 0) {
                log.info("Request [$id] for: $it")
            }
        }

        return ServerResponse.ok().json().body(
            Mono.just("heavy response $id")
        )
    }
}

for 문을 많이 돌면서 오래 걸리는 API 를 만들었습니다.


2.2. Log

위와 마찬가지로 쓰레드는 하나만 사용했습니다.

로그를 보면 알 수 있듯이 기존 요청을 처리하는 동안 대기했다가 순서대로 요청을 처리하는 것을 알 수 있습니다.


2.3. Response Time

id=1 인 요청은 5초만에 응답했지만 id=2 인 경우에는 앞의 연산 때문에 지연되어 9초나 걸린 것을 확인할 수 있습니다.


Conclusion

직접 테스트를 해보니 인터넷에서 알아본 것처럼 NIO 쓰레드를 사용하면 I/O 요청 시 쓰레드가 대기하지 않고 다른 일을 처리할 수 있었습니다.

하지만 실제로 쓰레드가 일을 해야하는 무거운 연산을 수행하는 경우에는 응답이 지연되는 결과를 얻었습니다.

흔히 웹플럭스를 사용하기 좋은 환경으로 무거운 연산이 적고 I/O 위주의 로직이 존재하는 환경을 이야기합니다.

DB 를 연동할 때도 R2DBC 나 NoSQL 처럼 Reactive 모델을 지원하지 않는 경우 블로킹 구간이 발생해서 사용할 수 없다 라고도 말합니다.

그동안은 막연하게 생각해오기만 했는데 실제로 테스트 해서 눈으로 확인해보니 이유를 알 수 있었습니다.

1. Overview

Rails Session 은 우리가 알고있는 세션과 크게 다르지 않습니다.

사용자에 대한 일부 데이터를 저장하기 위해 사용합니다.

쿠키를 사용할 수도 있지만 외부에 노출되는 쿠키와 달리 서버에만 저장해야 하는 중요한 데이터들은 세션에 저장하면 편리합니다.


2. 사용법

Rails Session 은 Hash 처럼 사용하면 됩니다.

다른 Controller 에서 저장한 데이터를 꺼내 쓸 수 있습니다.


# app/controllers/index_controllers.rb
def create
    # ...
    session[:current_user_id] = @user.id
    # ...
end

예를 들어 IndexController 에서 저장한 세션값을..


# app/controllers/users_controllers.rb
def index
    # ...
    current_user = User.find_by_id(session[:current_user_id])
    # ...
end

UserController 에서 꺼내서 사용할 수 있습니다.


3. Session Stores

세션 저장소의 종류는 쿠키, 데이터베이스, Memcached, Redis 등등 다양합니다.

쿠키 세션 저장소를 제외한 모든 세션 저장소는 동일한 프로세스로 동작합니다.



3.1. Session 값 저장

  1. session[:current_user_id] = 1 을 호출했는데 기존에 사용하던 세션(Session ID) 이 없는 경우
  2. Rails 는 09497d46978bf6f32265fefb5cc52264 와 같은 임의의 Session ID 를 사용하여 sessions 테이블에 새로운 record 를 저장
  3. 해당 record 의 data 속성에 {current_user_id: 1} (Base64-encoded) 값도 함께 저장
  4. 생성한 Session ID (09497d46978bf6f32265fefb5cc52264) 는 Set-Cookie 를 사용하여 브라우저에게 전달

3.2. Session 값 가져오기

  1. 브라우저가 서버에 요청할 때 Cookie: 헤더를 사용해서 동일한 쿠키값을 전달
    • (1번 예시) Cookie: _my_app_session=09497d46978bf6f32265fefb5cc52264; path=/; HttpOnly
  2. 코드에서 session[:current_user_id] 을 호출
  3. 쿠키에 있는 Session ID 값으로 sessions 테이블에 있는 record 를 가져옴
  4. record 에 있는 data 속성에서 current_user_id 값을 가져옴

Reference

1. Overview

BatchSize 는 JPA 의 성능 개선을 위한 옵션 중 하나입니다.

여러 개의 프록시 객체를 조회할 때 WHERE 절이 같은 여러 개의 SELECT 쿼리들을 하나의 IN 쿼리로 만들어줍니다.

간단한 테스트와 함께 사용법을 알아봅니다.


2. Domain 정의

@Entity
public class Parent {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "parent_id")
    private Long id;

    private String name;

    @OneToMany(mappedBy = "parent")
    private List<Child> children = new ArrayList<>();
}


@Entity
public class Child {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "child_id")
    private Long id;

    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "parent_id")
    private Parent parent;
}

간단한 Parent(1) <-> Child(N) 관계의 도메인을 작성했습니다.

편의상 Getter/Setter 는 생략합니다.


3. BatchSize 정의

@Target({TYPE, METHOD, FIELD})
@Retention(RUNTIME)
public @interface BatchSize {
    int size();
}

@BatchSize 클래스 파일을 보면 위과 같이 나와있습니다.

Type, Method, Field 에 사용할 수 있으며 size 를 설정해야 합니다. (Method 에 설정하는 건 자주 사용하지 않아서 이 포스트에선 제외합니다)

size 는 간단히 말해서 IN 절에 들어갈 요소의 최대 갯수를 의미합니다.

만약 IN 절에 size 보다 더 많은 요소가 들어가야 한다면 여러 개의 IN 쿼리로 나누어 날립니다.


3.1. Type (Class) 에 정의

@BatchSize(size = 100)
@Entity
public class Parent {
    ...
}

Entity 클래스 위에 붙일 수 있습니다.

만약 다른 엔티티에서 여러 개의 Parent 객체를 프록시로 호출한다면 배치사이즈가 적용되어 IN 쿼리로 조회할 겁니다.


3.2. Field 에 정의

@Entity
public class Parent {

    @BatchSize(size = 100)
    @OneToMany(mappedBy = "parent")
    private List<Child> children = new ArrayList<>();
}

@OneToMany 를 사용하는 Collections 에 붙이면 여러 Parent 객체가 getChildren() 호출할 때 하나의 쿼리로 가져옵니다.


3.3. application.yml 에 정의

spring:
    properties:
      hibernate:
        default_batch_fetch_size: 100

application.yml 에 추가하면 프로젝트 전역으로 배치 사이즈를 적용할 수 있습니다.


4. 호출 테스트

List<Parent> parents = parentRepository.findAll();

// 실제로 사용해야 쿼리가 나가기 때문에 size() 까지 호출해줌
parents.get(0).getChildren().size();
parents.get(1).getChildren().size();

Parent, Child 데이터가 이미 존재한다고 가정하고 테스트 코드를 작성했습니다.

parents 에서 for 문으로 간단하게 작성해도 되지만 명시적으로 두번 호출해봅니다.


4.1. Before

SELECT * FROM parent

SELECT * FROM child WHERE child.parent_id = 1
SELECT * FROM child WHERE child.parent_id = 2

배치 사이즈를 적용하지 않으면 child 테이블을 조회하기 위해 두 개의 쿼리가 날아갑니다.

만약 parents 의 갯수가 더 많다면 갯수만큼 쿼리가 날아갈겁니다.


4.2. After

SELECT * FROM parent

SELECT * FROM child WHERE child.parent IN (1, 2)

배치 사이즈를 추가하면 여러 쿼리를 하나의 IN 쿼리로 만들어줍니다.

IN 절에 들어가는 요소의 갯수는 설정 가능합니다.

만약 조건 갯수보다 설정한 배치사이즈 크기가 더 작다면 IN 쿼리가 추가로 날아갑니다.

예를 들어 size 를 100 으로 설정했기 때문에 데이터가 250 개라면 1 ~ 100, 101 ~ 200, 201 ~ 250 이렇게 세 번에 나누어서 IN 쿼리를 날립니다.

(사실 완전히 똑같은 사이즈로 분배해서 날리지는 않고 내부적으로 최적화한 사이즈로 나누어서 날립니다)


5. Conclusion

BatchSize 옵션을 사용하면 비슷한 조회 쿼리 데이터들을 한번에 가져올 수 있어 성능적으로 효과를 볼 수 있습니다.

+ Recent posts