개발놀이터

온라인 쇼핑몰 ver.2 (1) : Redis를 이용해 Session 과 Caching 적용하기 본문

사이드 프로젝트/온라인 쇼핑몰 ver.2

온라인 쇼핑몰 ver.2 (1) : Redis를 이용해 Session 과 Caching 적용하기

마늘냄새폴폴 2023. 5. 15. 15:57

기존 프로젝트의 방식

  • "누가"에 대한 데이터가 굉장히 많이 필요합니다. 
  • "누가" 장바구니를 담았는지, "누가" 좋아요를 눌렀는지, "누가" 결제를 했는지, "누가" 로그인을 했는지 등등...
  • 총 42개의 API 중 66%에 해당하는 28개의 API에서 "누가"에 해당하는 데이터를 요청했습니다. 
  • 총 DB 연산 143개 중 약 30%에 해당하는 43개의 DB 연산이 "누가"에 해당하는 데이터를 요청합니다. 

 

기존 프로젝트의 문제점

  • 정적 데이터를 항상 DB에서 조회하기 때문에 조금만 트래픽이 몰리면 DB의 심각한 부하가 우려됩니다. 

 

ver.2에서 개선한 문제점

  • 정적 데이터이기 때문에 캐싱을 적용하면 좋겠다고 판단하였습니다. 
  • EHcache, Memcached, Redis 중 고민하였고 세션까지 분리할 수 있고 다양한 자료구조를 지원하는 Redis를 선택하게 되었습니다. 

 

 

단순 세션 사용의 취약점

악의적인 사용자들은 취약 웹 애플리케이션에 직접 로그인하여 세션 ID를 발급받고 XSS 같은 교차 스크립트 공격을 병행하여 이메일을 열람 시 공격자의 세션 ID를 사용하여 서비승네 접근하도록 하면 사용자들은 공격자와 동일한 세션 ID를 사용하게 됩니다. 

 

여기서 관리자 페이지나 기타 개인정보를 포함하고 있는 페이지를 사용할 경우 공격자는 자신의 페이지를 Refresh (F5) 하여  추가 정보를 획득하거나 사용자의 행세를 하게 됩니다. 

 

XSS 공격에 의해 세션 아이디가 갈취당할 수 있다는 얘기이고 만약 더 높은 권한을 가진 사용자의 인가를 마음대로 이용한다면 인가되지 않은 사용자가 마구 날 뛸 수 있어서 조심해야 합니다. 

 

XSS 공격을 방어하기 위해서는 방화벽을 이용할 수 없습니다. XSS를 막기 위해서 가장 중요한 것은 역시 입력값 검증입니다. 

 

client side에서 검증과 server side에서의 검증이 모두 이루어져야 해결할 수 있습니다. 

 

거기에 추가적으로 session을 httpOnly 에 담아둔다거나 session을 애플리케이션과 분리하는 작업을 거치면 더 안전하게 사용자를 보호할 수 있습니다. 

 

이번 포스팅에선 session을 애플리케이션과 분리하는 작업을 통해 보안적으로 개선한 경험을 먼저 적도록 하겠습니다. 

 

Session 과 Application을 분리해라

먼저 dependency를 추가합니다. 

 

implementation 'org.springframework.session:spring-session-data-redis'

그리고 아래와 같이 적었습니다.

@SpringBootApplication
@EnableRedisHttpSession
public class CapstonApplication {

	public static void main(String[] args) {
		SpringApplication.run(CapstonApplication.class, args);
	}

}

이렇게 시작이되는 IoC 컨테이너에 @EnableRedisHttpSession을 적어 세션을 애플리케이션과 분리했습니다. 

 

이렇게 설정하면 WAS가 나뉘어진 로드밸런싱 상태의 애플리케이션에서 발생하는 문제인 각기 다른 WAS에서 다른 JSESSION이 설정된다는 문제도 해결할 수 있습니다. 

 

거기다 session을 애플리케이션과 분리하여 XSS 공격에 간접적으로 도움이 됩니다. 

 

 

캐싱으로 데이터베이스 부하를 줄여라

제 프로젝트인 온라인 쇼핑몰에선 특성상 "누가" 에 해당하는 객체가 굉장히 많이 필요합니다. 

 

"누가" 장바구니에 물건을 담았는지, "누가" 어떤 상품에 좋아요를 눌렀는지, "누가" 결제를 했는지, "누가" 상품을 구매했는지 등등이 있습니다. 

 

지금 위의 예시만 적었지 사실 굉장히 많은 곳에서 "누가"에 해당하는 객체가 필요했습니다. 

 

 

개수를 세어본 결과 총 49개의 API에서 28개의 API가 이 "누가"에 해당하는 데이터를 요청했습니다. 49개의 API중 7개는 단순 redirect API였기 때문에 따지고보면 42개의 API중 28개인 즉, 66퍼센트의 API에서 "누가"에 해당하는 데이터를 필요로 했습니다. 

 

심지어 이 28개의 API에서 한번만 요청한 것도 아닙니다. 조회를 요청한 API에서 총 43번의 데이터베이스 쿼리 요청이 있었습니다. 이는 하나의 API에서 Member (일반 로그인), User (소셜 로그인) 둘 다 조회를 요구하는 API가 있었기 때문입니다. 

 

이는 만약 사용자가 조금만 늘어나도 DB부하를 견디지 못할 것이라고 판단했습니다. 

 

이러한 문제의 해결책으로 Redis를 이용한 캐싱을 선택했습니다. 

 

"누가"에 해당하는 Member와 User의 객체를 Redis 객체로 저장하고 API에서 해당 객체를 요청할 때마다 데이터베이스에 쿼리를 날리는 것이 아닌 캐싱된 데이터를 가져오게 만드는 것이 저의 과제였습니다. 

 

그리고 저는 아래와 같은 설계를 위해 노력했습니다. 

 

순서도

 

package com.hello.capston.repository.cache;

import com.hello.capston.entity.Member;
import com.hello.capston.entity.User;
import com.hello.capston.repository.MemberRepository;
import com.hello.capston.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Repository;

import java.util.concurrent.TimeUnit;

@Repository
@RequiredArgsConstructor
public class CacheRepository {

    private final RedisTemplate<String, Object> redisTemplate;
    private final UserRepository userRepository;

    public void addMember(Member member) {
        String key = KeyGenerator.memberKeyGenerate(member.getUsername());

        if (!isMemberWasFound(member.getUsername())) {
            redisTemplate.opsForValue().set(key, member);
            redisTemplate.expire(key, 60, TimeUnit.MINUTES);
        }
    }

    public void addUser(Long userId) {
        User findUser = userRepository.findById(userId).orElseThrow(
                () -> new RuntimeException("Not Found User")
        );
        String key = KeyGenerator.userKeyGenerate(findUser.getEmail());
        if (!isUserWasFound(findUser.getEmail())) {
            redisTemplate.opsForValue().set(key, findUser);
            redisTemplate.expire(key, 60, TimeUnit.MINUTES);
        }
    }

    public Member findMemberAtCache(String loginId) {
        String key = KeyGenerator.memberKeyGenerate(loginId);
        return (Member) redisTemplate.opsForValue().get(key);
    }

    public User findUserAtCache(String email) {
        String key = KeyGenerator.userKeyGenerate(email);
        return (User) redisTemplate.opsForValue().get(key);
    }

    private boolean isMemberWasFound(String loginId) {
        Member findMember = findMemberAtCache(loginId);
        if (findMember != null) {
            return true;
        }
        return false;
    }

    private boolean isUserWasFound(String email) {
        User findUser = findUserAtCache(email);
        if (findUser != null) {
            return true;
        }
        return false;
    }
}

 

이렇게 CacheRepository를 만들어서 캐시를 관리하고 

 

String logindId = (String) session.getAttribute("loginId");
String userEmail = (String) session.getAttribute("userEmail");

Member findMember = MemberService.findMemberWithLoginId(loginId);
User findUser = UserService.findUserWithUserEmail(userEmail);

위와같은 코드에서 아래와 같은 코드로 바꿨습니다. 

String loginId = (String) session.getAttribute("loginId");
String userEmail = (String) session.getAttribute("userEmail");

Member findMember = cacheRepository.findMemberAtCache(loginId);
User findUser = cacheRepository.findUserAtCache(userEmail);

 

이렇게 Member와 User가 필요한 API에서 캐싱된 객체를 반환하도록 하였습니다. 

 

이러한 해결책으로 데이터베이스 쿼리를 날리는 총 143개의 요청에서 43번의 쿼리를 캐싱으로 처리할 수 있었습니다. 이는 DB부하를 30퍼센트정도 줄일 수 있는 정도의 성능 개선이었습니다. 

 

 

부수적으로 단순히 Member, User를 조회하기 위해 사용되었던 MemberService, UserService 두개의 의존관계에서 CacheRepository 하나만 의존관계를 맺음으로써 낮은 결합도를 유지하도록 설계하였습니다. 



느낀점

Redis를 이용해 세션을 애플리케이션과 분리하고 캐싱을 통해 DB의 부하를 줄여주는 경험은 저에게 있어서 첫번째 성능개선 경험이었습니다. 

 

실제로 성능이 30퍼센트가 개선된 것은 분명 아닐 것입니다. 다만 DB의 부하를 30퍼센트 줄였다는 것은 그에 못지않은 성능 개선을 이루어냈다고 봐도 무방할 것입니다. 

 

또한, 처음 프로젝트를 만들 때 이런 것을 전혀 고려하지 않았다는 점에서 여러가지를 느끼게 되었습니다. 

 

첫번째로 많은 개발자분들께서 말씀하시는 레거시 코드에서 "왜 이렇게 만들었지?" 라는 의문에 "그 당시에는 그것이 최선이었다" 라고 말씀하신 것이 어떤 느낌인지 알게 되었습니다. 

 

두번째로 제 프로젝트는 현존하는 많은 애플리케이션에 미치지는 못하겠지만 어느정도 규모가 있는 프로젝트에서 문제점을 발견하고 개선한다는 것은 의미가 있는 작업이었습니다. 

 

이번 ver.2 프로젝트를 통해 문제는 한번에 보이지 않는다는 것을 깨달았습니다. 두번 세번 여러번을 보고 고민해야 문제점을 개선할 수 있다는 것을 알게된 의미있는 프로젝트였습니다.