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

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

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

흔히 블록킹 구간이라고 하는 네트워크 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

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

1. Overview

JPA 에서 연관 관계를 설정할 때 여러가지 옵션을 추가할 수 있습니다.

삭제에 관련된 옵션은 orphanRemovalcascade 가 있는데 둘이 어떤점이 다른지 알아봅니다.

 

2. orphanRemoval

JPA 2.0 부터 지원하며 부모 엔티티와 관계가 끊어진 자식 엔티티를 자동으로 삭제해줍니다.

@OneToMany@OneToOne 에서 지원하는 옵션입니다.

아마 @ManyToOne 는 보통 연관 관계의 주인인 엔티티가 사용해서 저 옵션이 없는 것 같네요.

 

@Entity
public class School {

    @OneToMany(mappedBy = "school", orphanRemoval = true)
    private List<Teacher> teachers = new ArrayList<>();
}

@Entity
public class Teacher {

    @ManyToOne
    @JoinColumn(name = "school_id")
    private School school;
}

위 도메인은 School(1) <-> Teacher(N) 인 다대일 양방향 관계를 나타냅니다.

원래는 연관 관계의 주인만 외래키의 저장, 조회, 삭제 권한을 갖기 때문에 School.teachers Collections 에서 데이터를 지워도 반영되지 않습니다.

그러나 orphanRemoval = true 옵션을 추가해주면 부모 엔티티의 컬렉션에서 자식 엔티티를 삭제할 때 참조가 끊어지면서 DB 에서도 삭제됩니다.

 

3. CascadeType.REMOVE

@Entity
public class School {

    @OneToMany(mappedBy = "school", cascade = CascadeType.REMOVE)
    private List<Teacher> teachers = new ArrayList<>();
}

@Entity
public class Teacher {

    @ManyToOne
    @JoinColumn(name = "school_id")
    private School school;
}

cascade 는 영속성 전이에 관한 옵션입니다.

설정된 엔티티가 저장/수정/삭제 될 때 연관된 엔티티들도 전부 동일한 액션을 해줍니다.

그래서 연관 관계의 주인이 아니더라도 해당 엔티티를 지우면 관련된 모든 엔티티들이 삭제됩니다.

 

4. Conclusion

  • 부모 엔티티가 삭제되면 자식 엔티티도 전부 삭제되는 것은 동일하지만 원인이 다름
    • orphanRemoval = true 옵션은 부모 엔티티가 사라지면서 자식 엔티티와의 참조(연결)가 끊어져서 삭제됨
    • CascadeType.REMOVE 옵션은 원래 엔티티가 삭제될 때 연관된 엔티티를 전부 삭제하는 옵션
  • orphanRemoval 옵션은 Collections 에서 자식 엔티티를 삭제하는 걸로 DB 에서도 삭제 가능하지만 cascade 는 불가능

 

Reference

1. Overview

JPA 의 Entity 는 그 자체로 테이블을 나타내기 때문에 어떻게 설계하는지가 중요합니다.

데이터는 테이블에 저장되어 있지만 그걸 사용하는 코드는 객체입니다.

객체와 테이블의 차이점을 알아야 제대로 된 엔티티 설계를 할 수 있습니다.

데이터베이스는 외래키를 이용해서 서로 다른 테이블끼리 상호 작용을 하는데, JPA 에서는 연관 관계라는 걸 이용합니다.

JPA 는 엔티티 사이의 연관 관계를 위해 @OneToOne, @OneToMany, @ManyToOne 라는 어노테이션을 제공합니다.


2. 연관 관계란?

JPA 에서 연관 관계를 매핑할 때 고려해야 할 점은 크게 두 가지가 있습니다.

  • 단뱡향, 양방향
  • 연관 관계의 주인

2.1. 단방향, 양방향

데이터베이스 관점에서는 Join 을 통해 여러 테이블을 한꺼번에 조회 가능하기 때문에 방향이라는 개념이 없습니다.

그러나 엔티티 (객체) 가 다른 연관된 엔티티를 조회하려면 필드값으로 참조해야 합니다.

예를 들어 Member (1) : Car (N) 관계의 테이블이 있다고 가정합니다.

Member 의 자동차 목록을 가져오기 위해선 List<Car> cars 필드값이 필요합니다.

이를 Member -> Car 단방향 참조라고 합니다.

반대로 Car 엔티티만 존재할 때, 이 자동차의 소유자를 알고 싶다면 Member member 필드값이 필요합니다.

이것 역시 Car -> Member 단방향 참조입니다.

이렇게 Member <-> Car 처럼 각 엔티티가 서로를 단방향으로 참조하고 있는 걸 양방향 참조 라고 합니다.

성능상 문제도 없는데 전부 양방향으로 참조 하면 되지 않을까? 하는 생각이 들 수도 있습니다.

하지만 양방향 참조를 한다는건 엔티티의 필드 갯수가 그만큼 늘어난다는 뜻입니다.

특히 사용자를 나타내는 User, Member, Account 등의 엔티티가 모든 엔티티에 대해 참조를 해버리면 필드 갯수가 어마어마하게 많은 복잡한 클래스가 될 겁니다.

그렇기 때문에 엔티티 설계를 할 때 참조가 꼭 필요하지 않은 상황이라면 단방향 참조만 하는 것을 추천합니다.


2.2. 연관 관계의 주인

두 엔티티가 양방향 관계일 때는 연관 관계의 주인를 정해야 합니다.

연관 관계의 주인이란 외래키를 관리하며 외래키 저장, 수정, 삭제의 권한을 갖는 실질적인 엔티티이고, 주인이 아닌 엔티티는 조회만 가능합니다.

주인이 아닌 엔티티에서 mappedBy 속성을 사용해서 주인 엔티티 필드에 붙이면 됩니다.

연관 관계의 주인은 mappedBy 속성을 사용하지 않습니다.

예를 들어 다대일에서 @ManyToOne 을 사용하는 다(N) 쪽은 항상 연관 관계의 주인이기 때문에 mappedBy 옵션을 지원하지 않습니다.

일반적으로 외래키가 있는 곳을 연관 관계의 주인로 많이 설정합니다. (다대일에서 다 쪽)


3. 일대일 (1:1)

요구사항

  • 도메인: 사람(Person), 회사(Compnay), 집(House)
  • 회사는 소유자가 존재한다 (Company -> Person)
  • 집은 주인이 존재한다 (House -> Person)
  • 사람은 집 정보를 갖고 있다 (Person -> House)

우선 사람이라는 공통 Domain Entity 가 존재합니다.

@Entity
public class Person {

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

    @Column(name = "name")
    private String name;

    @OneToOne(mappedBy = "person")
    private House house;
}

3.1. 일대일 (1:1) 단방향

@Entity
public class Company {

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

    @Column(name = "name")
    private String name;

    @OneToOne(cascade = CascadeType.PERSIST)
    @JoinColumn(name = "person_id")
    private Person person;
}
  • Company(1) -> Person(1)

두 테이블은 일대일 관계이기 때문에 어느 쪽에서 외래키를 관리할 지만 정하면 됩니다.

어느 쪽에서 참조하냐에 따라 외래키 위치가 정해지기 때문에 설계할 때 신중하게 정하는게 좋습니다.

예를 들어, 사람은 회사를 소유하지 않을 수도 있지만 주인 없는 회사는 일반적으로 없습니다.

객체 지향 관점에서 보면 사람이 회사를 소유하고 있기 때문에 PersonCompany 필드를 갖는게 자연스럽습니다.

하지만 그렇게 되면 person 테이블이 company_id 라는 외래키 컬럼을 갖게 되는데, 회사를 소유하지 않은 사람은 해당 컬럼값이 null 로 세팅됩니다.

null 값을 갖는 건 데이터베이스 관점에서는 바람직하지 않기 때문에 company 테이블에 person_id 컬럼을 만들면 null 데이터는 사라집니다.

이처럼 객체 지향 관점이냐 데이터베이스 관점이냐에 따라 외래키의 위치가 달라집니다.

또한 나중에 회사를 여러 사람이 공동 소유하거나, 한 사람이 소유할 수 있는 회사가 많아지면서 일대일 관계가 다대일 관계로 확장될 수 있습니다.

어느 쪽이 옳다고 정해진 것은 없으므로 여러 가지 상황과 확장성을 고려해서 테이블 및 엔티티를 설계하는게 좋습니다.


3.2. 일대일 (1:1) 양방향

@Entity
public class House {

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

    @Column(name = "address")
    private String address;

    @OneToOne(cascade = CascadeType.PERSIST)
    @JoinColumn(name = "person_id")
    private Person person;
}
  • House(1) <-> Person(1)

양쪽에 @OneToOne 으로 모두 참조값을 넣어주고 mappedBy 로 연관 관계의 주인을 설정해주면 됩니다.

두 엔티티가 서로를 참조할 수 있기 때문에 객체지향 관점에서는 걱정할 것이 없고 데이터베이스 관점으로 정하거나 추후 확장성을 고려해서 연관 관계의 주인을 설정해주는게 좋습니다.

여기서는 Person 객체에서 mappedBy 가 설정되어 있고 House 는 설정하지 않았기 때문에 House 가 연관 관계의 주인이 되어 외래키를 관리합니다.


4. 다대일 (N:1)

요구사항

  • 도메인: 학교(School), 학생(Student), 선생(Teacher)
  • 한 학교에 여러 학생이 다닐 수 있다 (Student -> School)
  • 한 학교에 여러 선생이 근무할 수 있다 (Teacher -> School)
  • 학교는 선생님들의 정보를 갖고 있다 (School -> Teacher)

우선 학교라는 공통 Domain Entity 가 존재합니다.

@Entity
public class School {

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

    @OneToMany(mappedBy = "school")
    private List<Teacher> teachers = new ArrayList<>();
}

4.1. 다대일 (N:1) 단방향

@Entity
public class Student {

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

    @Column(name = "name")
    private String name;

    @ManyToOne(cascade = CascadeType.PERSIST)
    @JoinColumn(name = "school_id")
    private School school;
}
  • Student(N) -> School(1)

다대일은 다(N) 쪽에 외래키가 있는 게 일반적입니다.

@ManyToOne 을 사용해서 School 필드값을 참조하면 되며, School 엔 별다른 설정을 하지 않아도 됩니다.

코드상으로는 학생은 학교의 정보가 있지만 학교 입장에서는 학생들의 정보를 직접적으로 알 수 없습니다.


4.2. 다대일 (N:1) 양방향

@Entity
public class Teacher {

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

    @Column(name = "name")
    private String name;

    @ManyToOne(cascade = CascadeType.PERSIST)
    @JoinColumn(name = "school_id")
    private School school;
}
  • Teacher(N) <-> School(1)

단방향과 동일하지만 School 도메인에 @OneToMany 적용이 필요합니다.

다대일이기 때문에 참조하는 필드는 List 로 설정합니다.

학교는 teachers 를 통해 선생들의 정보를 알 수 있습니다.


5. 다대일 양방향 관계 데이터 저장

다대일 양방향 관계에서 데이터를 저장할 때는 Teacher.setSchool() 메소드와 School.getTeachers().add() 메소드를 모두 호출해서 객체끼리 동기화를 해주는 것이 중요합니다.

특히 주의할 점은 연관 관계의 주인이 데이터를 세팅해야 외래키에 값이 제대로 들어간다는 사실입니다.


5.1. 연관 관계의 주인이 아닌 일(1) 쪽에서만 세팅

School school = new School();
Teacher teacher = new Teacher();

school.getTeachers().add(teacher);  // 주인이 아닌 엔티티가 세팅

schoolRepository.save(school)
teacherRepository.save(teacher);
  • 위 코드처럼 List 에만 데이터를 넣고 저장하면 외래키가 세팅되지 않습니다.
  • school, teacher 각각 데이터는 들어가지만 teacher.school_id 값이 null 이 됩니다.

5.2. 연관 관계의 주인인 다(N) 쪽에서만 세팅

School school = new School();
Teacher teacher = new Teacher();

teacher.setSchool(school);  // 주인인 엔티티가 세팅

schoolRepository.save(school)
teacherRepository.save(teacher);
  • 위 코드를 실행하면 외래키까지 데이터가 제대로 저장됩니다.
  • 연관 관계의 주인이 외래키의 저장, 수정, 삭제를 담당하기 때문입니다.
  • 다만, 순수한 객체끼리의 데이터 동기화를 위해 school.getTeachers().add(teacher) 도 호출하는 것이 좋습니다.
    • 만약 List 에 데이터를 넣지 않으면 school.getTeachers() 를 호출해도 리스트가 비어 있습니다.
    • 물론 다른 트랜잭션에서 호출하면 DB 를 조회해서 가져오긴 하지만 같은 트랜잭션 내에서 추가로 작업을 한다면 혼란이 생길 수 있습니다.

Reference

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

JPA Batch Size  (0) 2021.09.14
JPA Entity 삭제: orphanRemoval vs CascadeType.REMOVE  (0) 2021.08.28
Spring Boot 에서 Redis 사용하기  (4) 2021.08.11
Spring Boot Swagger 3.x 적용  (4) 2021.07.26
JPA 의 getById() vs findById()  (0) 2021.07.24

+ Recent posts