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

상황

Redis Cache 를 사용해서 List<?> 를 저장하려고 했습니다.

직렬화해서 데이터 저장까지는 잘 되었는데 다시 역직렬화 하려고 하니 에러가 발생하며 실패했습니다.


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

캐싱한 데이터는 위와 같습니다.

List<Member> 를 응답으로 내려주고 members 라는 캐시의 all 이라는 키값으로 저장됩니다.

Redis 설정으로 Value 는 GenericJackson2JsonRedisSerializer 를 사용하여 직렬화했습니다.


Redis 를 확인해보면 제대로 저장된 것을 확인할 수 있습니다.


에러 로그

com.fasterxml.jackson.databind.exc.MismatchedInputException: Unexpected token (START_OBJECT), expected VALUE_STRING: need JSON String, Number of Boolean that contains type id (for subtype of java.lang.Object)
 at [Source: (byte[])"[{"@class":"com.example.springbootcache.model.Member","id":1,"name":"ChulSoo","age":50}]"; line: 1, column: 2]

원인

List<?> 를 그대로 저장해서 그렇습니다.

사실 정확한 원인은 저도 모릅니다.

그래도 확신은 없지만 나름대로 추측을 해보겠습니다.

GenericJackson2JsonRedisSerializer 는 직렬화 할 때 @class 라는 Key 값에 클래스의 패키지 정보까지 전부 저장됩니다.

그런데 List 를 통째로 저장하면 위 사진과 같이 { "@class": "..." } 이 아니라 [{ "@class": "..."}] 로 저장되어 찾지 못해서 발생하는 이슈 같습니다.


해결

List 를 감싸는 Wrapper 클래스를 만들어 주면 해결됩니다.


Members 클래스 정의

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

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

캐싱 대상 변경

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

Redis 저장 확인

Overview

Docker 공식 홈페이지에 있는 Ubuntu 설치를 보고 따라하면 됩니다


1. Docker 설치

$ sudo apt-get update
$ sudo apt-get install \
    ca-certificates \
    curl \
    gnupg

2. Docker 공식 GPG 키 추가

$ sudo mkdir -m 0755 -p /etc/apt/keyrings
$ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg

3. Docker Repository 설치

$ echo \
  "deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
  "$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

4. Docker 설치

$ sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

5. Docker 실행 테스트

$ sudo docker run hello-world

# 실행된 도커 컨테이너 확인
$ sudo docker ps

# 이미지 확인
$ sudo docker images

테스트 용으로 hello-world 라는 이미지를 실행합니다.

docker image 를 따로 받지 않아도 없으면 자동으로 pull 을 먼저 땡깁니다.

Problem


숫자만 존재하는 오름차순 배열이 주어집니다.

모든 값들은 2개씩 존재하고 단 하나의 값만 1개 존재합니다.

1개만 존재하는 값을 찾는 문제입니다.



Solution

그냥 Map 을 사용해도 되는 문제지만 추가 조건으로 O(log n) time and O(1) space 의 복잡도를 요구합니다.

이 조건을 만족하기 위해선 이분탐색이 필요합니다.

하지만 이 문제는 일반적인 이분탐색과 다르게 찾아야 하는 값이 따로 주어지지 않습니다.

그럼 범위를 절반으로 나누었을 때 왼쪽과 오른쪽 중 찾으려는 값이 있는 곳을 알 수 있을까요?

힌트는 "모든 숫자는 반드시 두개씩 존재한다" 입니다.

반드시 두개씩 연달아 존재하기 때문에 인덱스 위치를 파악해서 숫자를 비교하면 단 하나만 있는 값의 위치를 알 수 있습니다.

대신 현재 index 가 홀수인지 짝수인지에 따라 비교해야 하는 대상이 바뀌기 때문에 그 부분만 체크해주면 됩니다.

image



Java Code

class Solution {
    public int singleNonDuplicate(int[] nums) {
        int left = 0;
        int right = nums.length - 1;
        int mid = 0;

        while (left < right) {
            mid = (left + right) / 2;

            if (mid % 2 == 0 && nums[mid] == nums[mid + 1]) {
                // mid 위치가 짝수면 오른쪽 값이랑 비교하고 같으면 single 은 오른쪽에 있음
                left = mid + 2;
            } else if (mid % 2 == 1 && nums[mid] == nums[mid - 1]) {
                // mid 위치가 홀수면 왼쪽 값이랑 확인하고 같으면 single 은 오른쪽에 있음
                left = mid + 1;
            } else {
                // 위에 전부 해당 안되면 single 은 왼쪽에 있음
                right = mid;
            }

        }

        return nums[left];
    }
}

Problem


n 개의 rating 순서가 주어질 때, 만들어질 수 있는 팀의 갯수를 구하는 문제입니다.

팀을 만드려면 3 개의 rating 이 오름차순 또는 내림차순으로 존재해야 합니다.



Solution

팀의 인원은 반드시 3 명이라는 점에 주목할 수 있습니다.

3 명이라면 어떤 값을 가운데 기준으로 잡았을 때, 왼쪽에 있는 값은 더 작고 오른족에 있는 값은 더 커야 오름차순이 됩니다.

또는 반대가 되면 내림차순이 됩니다.

아래와 같은 순서로 코드를 작성하면 해결할 수 있습니다.

  1. ratings 를 전체 순회하면서 가운데 기준값을 잡는다.
  2. 기준값 기준으로 왼쪽에서 더 작은 값 갯수, 오른쪽에서 더 큰 값 갯수를 구해 곱한다 (오름차순인 팀 경우의 수)
  3. 기준값 기준으로 왼쪽에서 더 큰 값 갯수, 오른쪽에서 더 작은 값 갯수를 구해 곱한다 (내림차순인 팀 경우의 수)
  4. 두 값을 더하면 만들어질 수 있는 모든 팀의 수가 나온다.

image

예를 들어 그림으로 표현하면 위와 같습니다.

6 을 기준으로 오름차순은 168, 368, 568 이 존재하고 내림차순은 962, 964, 762, 764 이 존재합니다.

오름차순으로 봤을 때 왼쪽에는 1, 3, 5 세개가 있고 오른쪽에는 8 하나가 존재하기 때문에 오름차순 팀이 만들어질 수 있는 경우의 수는 1 * 3 입니다.

내림차순을 보면 왼쪽에 9, 7 이 존재하고 오른쪽에 4, 2 가 존재하기 때문에 내림차순 팀이 만들어질 수 있는 경우의 수는 2 * 2 입니다.

그러므로 가운데 숫자가 6 일때 만들 수 있는 팀은 7 개가 됩니다.

이런식으로 앞에서부터 모든 숫자에 하나씩 가운데 숫자를 대입하면서 끝까지 돌면 만들 수 있는 모든 팀의 갯수를 구할 수 있습니다.



Java Code

class Solution {
    public int numTeams(int[] rating) {
        int teamCount = 0;

        for (int mid = 0; mid < rating.length; mid++) {
            int leftSmaller = 0;
            int leftLager = 0;
            int rightSmaller = 0;
            int rightLager = 0;

            for (int left = 0; left < mid; left++) {
                if (rating[left] < rating[mid]) {
                    leftSmaller++;
                } else {
                    leftLager++;
                }
            }

            for (int right = mid + 1; right < rating.length; right++) {
                if (rating[right] < rating[mid]) {
                    rightSmaller++;
                } else {
                    rightLager++;
                }
            }

            teamCount += (leftSmaller * rightLager) + (leftLager * rightSmaller);
        }

        return teamCount;
    }
}

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 코드를 직접 짜보겠습니다.

+ Recent posts