사전 조건

  1. AWS Elasticache 설정
    • Redis Cluster을 설정합니다. (기본적으로 3개의 node 설정)
  2. Redis-cli 접속 가이드
    • 간단한 명령어로 테스트 가능(get,set)
  3. redis-cli -h 호스트 -p 포트
  4. Java의 Redis 클라이언트
    • Redis 클라이언트로는 크게 Jedis, Lettuce가 존재합니다.
    • Lettuce : Netty 기반의 클라이언트로, 비동기로 요청 처리하여 고성능을 자랑함.
    • Jedis : 동기적으로 요청을 처리, 이전부터 자바의 표준 Redis Client)
    • Jedis보다는 Lettuce를 사용하는 것을 권장(https://jojoldu.tistory.com/418)
      • 스프링부트 2.0부터는 Lettuce가 기본 클라이언트가 되어서 Jedis를 사용하려면 제거해야만 함
    • Lettuce 공식문서

Lettuce Config

@Bean
fun redisConnectionFactory(): RedisConnectionFactory =
    LettuceConnectionFactory(RedisStandaloneConfiguration("server", 6379))

@Bean
fun redisTemplate(redisConnectionFactory: RedisConnectionFactory): RedisTemplate<String, String> {
    val template = RedisTemplate<String, String>()
    template.setConnectionFactory(redisConnectionFactory)

    return template
}
  • server 위치에 레디스 서버 주소를 기입하여 RedisConnectionFactory를 만듭니다.
    • 추후 RedisTemplate에서 ConnectionFactory를 지정할 때 사용해 주어야 합니다.
  • AWS Elasticache를 사용하는 경우 “server” 위치에 reader가 아닌 기본 엔드포인트를 기재합니다.
    • read 목적인 경우 reader로 사용해도 되기는 하지만 write도 같이 사용하기 위해서는 기본 엔드포인트를 사용합니다

 

Simple Lettuce RW

@Repository
class StudentRedisRepository(
    private val redisTemplate: RedisTemplate<String, String>,
    private val objectMapper: ObjectMapper,
) {

    fun save(student: Student) {
        println("deserialize: ${objectMapper.writeValueAsString(student)}")

        redisTemplate.opsForValue().set(student.id, objectMapper.writeValueAsString(student))
    }

    fun getStudent(id: String): Student {
        val studentJson = redisTemplate.opsForValue().get(id)
            ?: throw IllegalArgumentException("존재하지 않는 학생 정보입니다.")

        // Serialize 하여 데이터 로딩
        return objectMapper.readValue<Student>(studentJson)
    }
}
  • 데이터를 저장할 때는 opsForValue()의 set, 불러올 때는 get 메소드 사용
    • opsForValue뿐 아니라 opsForList, opsForSet 등 다양한 연산 제공
  • Redis의 K-V 쌍에 Key에는 student의 id, Value에는 Student 객체를 저장
    • Object 타입이 아닌 String 타입이므로 객체를 Serialize하여 저장
    • {id: ?, name: ?, grade: ?}같은 형식으로 저장됩니다.
  • write시는 serialize, read시는 deserialize (직접 ObjectMapper을 사용합니다)
    • opsForValue().get() 메소드는 nullable하므로 따로 처리(존재하지 않는 경우에는 null 리턴)

 

조금 더 간편하게 사용해 보자(With. Kotlin)


Redis의 get, set에만 신경쓰고 싶은 경우(serialize, deserialize를 꼭 서비스 코드 상에서 해주어야 하나?)

  • redisTemplate 생성 시 defaultSerializer을 지정해 줍니다.
  • Kotlin의 경우, objectMapper.registerKotlinModule()로 코틀린 모듈을 등록시켜 줍니다.

Jackson-Kotlin

  • Kotlin Data Class에 대한 편의성을 제공해 줍니다.
  • 기본 생성자가 따로 필요하지 않습니다.
  • 데이터 클래스 생성자를 사용합니다, JSON 속성명은 kotlin의 런타임 타입 정보에 의해 추론될 수 있습니다.
  • https://www.baeldung.com/kotlin/jackson-kotlin
@Bean
fun objectMapper(): ObjectMapper = ObjectMapper()
    .registerKotlinModule()

@Bean
fun redisTemplate(objectMapper: ObjectMapper, redisConnectionFactory: RedisConnectionFactory): RedisTemplate<String, Student> {
    val serializer = Jackson2JsonRedisSerializer(objectMapper, Student::class.java)
    val template = RedisTemplate<String, Student>()
    template.setConnectionFactory(redisConnectionFactory)
    template.setDefaultSerializer(serializer)

    return template
}
@Repository
class StudentRedisRepository(
    private val redisTemplate: RedisTemplate<String, Student>,
) {

    fun save(student: Student) {
        redisTemplate.opsForValue().set(student.id, student)
    }

    fun getStudent(id: String): Student {
        val student = redisTemplate.opsForValue().get(id)
            ?: throw IllegalArgumentException("존재하지 않는 학생 정보입니다.")

        return student
    }
}

이전 코드와 비교해 보았을 때, objectMapper을 직접 사용해주지 않아도 deserialize/serialize 되어 간편하게 사용할 수 있습니다.

환경 설정

  • build.gradle 파일에 QueryDSL 관련 설정을 해줍니다.
  • 이후 compileQueryDsl을 실행시켜 주면 Q class가 생성됩니다.
    • generated/querydsl에 Q class가 생성됩니다.
// queryDSL Configurtion, 맨 윗줄에 나와야 한다.
buildscript {
    dependencies {
        classpath("gradle.plugin.com.ewerk.gradle.plugins:querydsl-plugin:1.0.10")
    }
}

...

apply plugin: "com.ewerk.gradle.plugins.querydsl"

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

dependencies {
		
		...
    //querydsl 추가
    implementation 'com.querydsl:querydsl-jpa'
    //querydsl 추가
    implementation 'com.querydsl:querydsl-apt'
}

//querydsl 추가
//def querydslDir = 'src/main/generated'
// Q Class가 등록될 위치이다.
def querydslDir = "$buildDir/generated/querydsl"

querydsl {
    library = "com.querydsl:querydsl-apt"
    jpa = true // Jpa 사용 유무를 명시한다.
    querydslSourcesDir = querydslDir
}

sourceSets {
    main {
        java {
            srcDirs = ['src/main/java', querydslDir]
        }
    }
}

compileQuerydsl{
    options.annotationProcessorPath = configurations.querydsl
}

configurations {
    querydsl.extendsFrom compileClasspath
}

 

간단한 QueryDSL 사용법

  • 우선, JPAQueryFactory를 만들어 주어야 합니다.
    • 이 때, EntityManager가 필요합니다.
    • JPAQueryFactory의 경우는 따로 Configuration에서 Bean으로 등록하여 사용하겠습니다.
@Configuration
public class QueryDslConfig {

    @PersistenceContext
    EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }
}

 

Autowired vs PersistenceContext

위 예제에서 보면 EntityManager를 주입받아 줄 때, Autowired가 아닌 다른 어노테이션으로 주입을 받는 것을 볼 수 있습니다. 다른 사람들의 코드를 보니 PersistenceContext로 주입을 받기는 하는데.. 개인적으로 생각해봤을 때는 Autowired와 다를게 없다고 생각해서 차이점을 좀 찾아보았습니다.

  • Autowired는 스프링 빈을 주입받는데 사용하는 어노테이션입니다.
  • PersistenceContext는 JPA 스펙에서 영속성 컨텍스트를 주입하는 표준 어노테이션입니다.
  • 테스트해본 결과, 둘 다 정상적으로 동작합니다. 아무래도 QueryDsl은 JPA와 연관이 높아 보이니 이번 예제에서는 PersistenceContext로 주입받겠습니다.

 

데이터 가져오기 예시

  • jpaQueryFactory를 의존성 주입받아 줍니다.
  • 예시로는 이름으로 Professor를 조회해 옵니다.
    • 이 때, 일반 클래스가 아닌 사전에 만들어 두었던 Q 클래스를 사용하여 조회하거나 조건을 명시합니다.

 

참고 사항

JPAQuery vs JPAQueryFactory

  • 개인적인 생각으로는.. 취향차인거 같습니다. 성능은 차이가 없다고 합니다.
  • 실제로 JPAQueryFactory 내부를 들어가도.. JPAQuery를 사용하고 있습니다. 직접 만드는것과 Factory를 거쳐서 만드는 것의 차이입니다.

JPAQueryFactory의 메서드

 

실제로 두 방법을 모두 사용해서 작성해 보겠습니다.

@BeforeEach
void setUp() {
    professorRepository.save(new Professor(null, "덕배"));
}

@Test
@DisplayName("한명의 professor를 조회한다.")
void queryDslNormalSelect() {
    QProfessor professor = QProfessor.professor;
    JPAQueryFactory queryFactory = new JPAQueryFactory(entityManager);

    final Professor targetProfessor = queryFactory.selectFrom(professor)
            .fetchOne();

    System.out.println(targetProfessor.getName());
    assertThat(targetProfessor.getName()).isEqualTo("덕배");
}

@Test
@DisplayName("vs JPAQuery")
void JPAQueryNormalSelect() {
    JPAQuery<Professor> jpaQuery = new JPAQuery<>(entityManager);
    QProfessor professor = QProfessor.professor;

    final Professor professor1 = jpaQuery.select(professor)
            .from(professor)
            .fetchOne();

    assertThat(professor1.getName()).isEqualTo("덕배");
}

두 방법 모두 정상적으로 동작하는걸 볼 수 있습니다. 단, QueryDSL을 사용하는 레포지토리가 따로 추가되는 문제점이 생겼습니다. 하나의 레포지토리로 QueryDSL로 작성한 기능 + JpaRepository의 기능을 모두 제공할 방법은 없을까요?

 

레포지토리가 여러개 생기는 문제점 해결

  1. 하나는 QueryDSL 전용 레포지토리, 하나는 Spring Data JPA 전용 레포지토리 총 두개의 레포지토리를 받아서 사용해야만 할까요?
  2. 이러한 문제를 해결하기 위해 Spring Data JPA에서는 커스텀 저장소를 구현할 수 있습니다.

내용을 요약하자면 다음과 같습니다.

  • 사용자 정의 레포지토리 인터페이스를 생성합니다.
public interface QueryDslProfessorRepository {

    Professor findByName(String name);
}
  • 해당 인터페이스를 구현한 구현 클래스를 만듭니다. 단, 클래스 이름은 인터페이스명 + impl로 설정해 두어야 합니다. Postfix 변경을 원한다면 @EnableJpaRepositories 어노테이션에서 설정할 수 있습니다.
@Repository
public class QueryDslProfessorRepositoryImpl implements QueryDslProfessorRepository {

    private final JPAQueryFactory jpaQueryFactory;

    public QueryDslProfessorRepositoryImpl(final JPAQueryFactory jpaQueryFactory) {
        this.jpaQueryFactory = jpaQueryFactory;
    }

    @Override
    public Professor findByName(final String name) {
        final Professor professor = jpaQueryFactory.selectFrom(QProfessor.professor)
                .where(QProfessor.professor.name.eq(name))
                .fetchOne();

        return professor;
    }

		...

}
  • 기존 레포지토리에서 사용자 정의 레포지토리 인터페이스를 상속받도록 처리합니다.

추가 인터페이스 상속

  • 하나의 인터페이스로 QueryDSL 기능과 JpaRepository의 기능을 모두 사용할 수 있습니다.
@DisplayName("QueryDsl로 만들어진 쿼리를 테스트한다.")
@Test
void queryDslQueryTest() {
    final Professor professorA = professorRepository.save(new Professor(null, "professorA"));
    final Lecture lecture = lectureService.makeLecture(new Lecture(null, professorA, "lectureA"));

    entityManager.flush();
    entityManager.clear();

    final Professor professor = professorRepository.findByName("professorA");

    assertThat(professor.getName()).isEqualTo("professorA");
}

 

출처

개요

JPA를 처음 배울 때, JpaRepository라는 인터페이스를 상속받기만 해주면 기본적으로 제공하는 save나 findById같은 메소드들이 신기했던 경험이 있습니다. findById같은 경우는 기본적으로 제공한다고 치고.. 그럼 Query 어노테이션이 붙은 메소드들은 과연 어떻게 처리될까요?

지금부터는 정확한 내용이 아닐수도 있습니다 😅 중간중간 생략된 메소드들이 있을 수 있습니다. 또한 AOP 관련된 로직들은 모두 뛰어넘겠습니다.

 

실험 메소드

테스트 대상 메소드

해당 메소드를 실행시켰을 때, 어떠한 과정을 거쳐서 실행되는지 한번 쭉 살펴보겠습니다.

 

CrudMethodMetadataPostProcessor

  • 기존에 저장되어 있던 구현 목록에 존재하는 메소드를 호출하는지 확인하고, 존재하지 않는다면 따로 처리합니다.
  • implementations 내부에는 아래와 같은 메소드들이 저장되어 있습니다.

  • 현재 예제에서는, 사전에 등록된 메소드들이 아닌 저희가 직접 Query로 정의하였으므로 조건식에서 걸러지게 됩니다.

 

TransactionInterceptor

  • 트랜잭션을 처리해주는 인터셉터입니다.
  • createTransactionIfNecessary 메서드에서 트랜잭션이 요구된다면 생성시켜 줍니다.
  • completeTransactionAfterThrowing에서는 설정에 따라 예외가 발생한 경우 트랜잭션을 롤백하거나 커밋시켜 줍니다.
  • cleanupTransactionInfo는 ThreadLocal의 정보를 리셋시켜 줍니다. 실제 동작은 oldTransaction으로 변경시켜 주는데, 현재 트랜잭션은 종료시키고 이전 트랜잭션으로 바꿔주는것 같다고 느껴졌습니다. 참고로 ThreadLocal을 통해 트랜잭션이 관리됩니다.
  • commitTransactionAfterReturning 메소드에서 트랜잭션이 종료된 이후 커밋을 담당합니다.
  • 참고로 가운데에 있는 Vavr 관련 처리는 Vavr은 조금 다르게 처리해줘야 해서 따로 처리해준 것 같습니다.

트랜잭션을 걸어주고(실제로는 따로 트랜잭션을 걸지 않았으므로 안걸림) invocation.proceedWithInvocation() 메서드를 타고 넘어가겠습니다.

 

DefaultMethodinvokingMethodInterceptor

  • 메소드가 Default Method인지 체크합니다.
    • 클래스가 인터페이스이면서, 구현 코드가 있는 Static이 아닌 메소드를 말합니다.
  • 이번 예제는 Default Method는 아니므로.. 내부 코드로 넘어갑시다.

 

QueryExecutorMethodInterceptor

어..뭐야 언제..

  • 내부적으로 이미 쿼리 데이터와 파라메터를 객체로 만들어서(SimpleJpaQuery) 가지고 있습니다.
    • 쿼리 뿐 아니라 바인딩된 파라메터까지 가지고 있습니다.
    • 이러한 정보들을 바탕으로 다음 로직을 수행합니다.

  • 리턴 타입에 해당하는 QueryExecutionConverters.ExecutionAdapter을 불러옵니다. 따로 등록해둔 Adapter가 없으므로 null을 가져오게 됩니다.
  • 다음으로 doInvoke() 메서드로 실제 메서드를 실행시켜 줍니다.

  • hasQueryFor() 메서드로 실행시킬 수 있는 쿼리인지를 확인합니다.
  • method와 query를 가지고 invoker를 불러오고, 이를 invocationMetadataCache에 넣은 다음 호출합니다.

 

RepositoryMethodInvoker

찾았다!!%!@%!@

  • 여정의 마무리입니다. invoke 메서드를 호출하여 실제 결괏값을 가져옵니다. 드디어 어디서 호출되는지 진짜로 볼수 있겠군요ㅠㅠㅠㅠㅠㅠㅠㅠ

 

AbstractJpaQuery

  • 가져온 파라메터를 기반으로 JpaParametersParameterAccessor를 얻어옵니다.
  • Accessor에는 다음과 같은 정보들이 들어있습니다.
    • 파라메터의 정보, 대입되는 값 등등

  • Accessor 정보와 AbstractJpaQuery를 넘겨줘서 진짜진짜 메소드를 실행해 봅시다.
  • AbstractJpaQuery에는 실제 실행해야 하는 쿼리가 들어있습니다.

 

JpaQueryExecution

  • 진짜진짜 찐막입니다.
  • doExecute() 메서드로 가져온 JPQL 쿼리를 실행시킵니다.
  • 아래 블록에서는 형변환이 가능한 경우, 결과값을 형변환하여 반환시켜 줍니다.
  • 값을 한개만 반환하므로, SingleEntityExecution의 doExecute 메서드를 호출합니다.

  • JPQL 쿼리를 실행시켜서 결괏값을 얻어옵니다. 이 아래는 쿼리를 실행하는 부분이므로 다루지 않겠습니다.

간단하게만 파보자면 AbstractProducedQuery의 메서드를 호출합니다.(getSingleResult) 이 때, 궁금한 부분중 하나가 JPQL을 실제 SQL문으로 언제 바꿔주냐였습니다. 해당 처리는 HQLQueryPlan에서 처리합니다.

 

compile() 메서드를 실행시키면, QueryTranslatorImpl의 doCompile() 메서드로 넘어갑니다. 해당 메서드에서 파싱을 진행하는데, 최종적으로 파싱이 되는 부분은 다음과 같습니다.

해당 메서드를 실행하면 JPQL이 아닌 실제로 실행하는 SQL문 형태로 변환됩니다.

 

참고

https://stackoverflow.com/questions/38509882/how-are-spring-data-repositories-actually-implemented/38511337#38511337

 

문제 상황

프로젝트를 진행하던 중, 모임을 참여하는 기능에서 동시에 참여하는 경우 제한 인원수에 비해 더 많은 인원이 참가하게 되는 문제점이 발생하였습니다. Transactional을 걸어줬으니까 당연히 동시성 문제는 생기지 않았겠지.. 하는 안일함이 불러온 참사였습니다.

실제로 여러명의 크루들과 함께 테스트를 해 보니, 여러명의 인원이 동시에 모임을 참여하는 경우 동시성 문제가 생기는 것을 알 수 있었습니다. 왜 이런 문제가 생기게 된것일까요? 모임을 참가하는 로직을 가져와 보겠습니다.

 

 

대충 봤을땐 문제가 없어보이지만, 사실 네모친 칸이 문제입니다. 두 사용자가 동시에 참여를 한다고 가정했을 때, 최대 인원수가 5명이고, 현재 인원수가 4명인 경우 두 사용자 시점에서는 모두 4명으로 처리되어 둘 다 참여가 되기 때문입니다.

그렇다면, 이러한 문제는 어떻게 해결할 수 있을까요? 자바에서는 아주 적절한 기능을 제공합니다. 바로 synchronized 키워드입니다. 한번에 한 사용자만이 참여를 하고, 나머지 사용자들은 대기하도록 메소드에 Synchronized 키워드를 붙여주면 됩니다.

 

 

이제 해결됬으니 끝났을까요? 아닙니다. 저희 서비스는 아쉽게도(?) 로드밸런싱을 도입하게 되었습니다. 하나의 서버에서는 한명만이 보장되겠지만, 여러대의 서버에서 한명이 보장되지는 않았습니다. 따라서 저희는 Synchronized보다 락의 필요성을 느끼게 되었습니다. 그렇다면 락이란 무엇일까요?

 

데이터베이스 락

락이란 데이터베이스의 일관성을 보장하기 위한 방법입니다. 락의 종류에는 여러 락이 있는데, 이번에는 낙관적 락비관적 락에 대해서 학습해 보겠습니다.

 

낙관적 락이란?

  • 트랜잭션 대부분은 충돌이 발생하지 않는다고 낙관적으로 가정하는 방법입니다.
  • 애플리케이션 단에서 Version 관리 기능을 사용합니다.
  • 버전 정보를 비교하여 가져온 버전 정보와 변경할 때의 버전 정보가 다른 경우 예외가 발생합니다.

예시 쿼리는 다음과 같습니다.

 

update board
set title=?,version = 버전 + 1
where id = ? and version = 버전;

변경할 때의 버전이 초기에 읽어들인 버전이 아니라면 update 쿼리의 실행 결과 행의 수가 0개일 것이므로, 예외가 발생하는 방식입니다. 그렇다면 실제로 어떻게 쿼리가 날라가는지 한번 테스트해 보겠습니다. 

 

우선 도메인은 다음과 같습니다. 버전 관리를 위한 필드를 하나 추가해 주어야 합니다.

public class Raccoon {

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

    @Version
    private Long version;

    private String name;
}

 

이후 Repository에서 낙관적 락을 걸어줍니다. Version 필드가 존재하는 경우, OPTIMISTIC을 생략한다면 데이터가 변경되었을 때만 Version 필드가 업데이트 됩니다.

public interface RaccoonRepository extends JpaRepository<Raccoon, Long> {

    @Lock(LockModeType.OPTIMISTIC)
    Optional<Raccoon> findById(Long id);
}
@Service
@AllArgsConstructor
@Transactional
public class RaccoonService {

    private final RaccoonRepository repository;

    public Raccoon save() {
        final Raccoon raccoon = repository.save(new Raccoon(null, 1L, "cute"));
        
        return raccoon;
    }

    public Raccoon findById(Long id) {
        final Raccoon raccoon = repository.findById(id).get();

        return raccoon;
    }
}

 

위 코드에 대한 테스트코드를 작성해 보았습니다.

    @DisplayName("낙관적 락으로 인해 어떠한 쿼리가 나가는지 테스트한다.")
    @Test
    void optimisticTest() {
        final Raccoon raccoon = raccoonService.save();
        entityManager.flush();
        entityManager.clear();
        
        final Raccoon foundRaccoon = raccoonService.findById(raccoon.getId());
        foundRaccoon.setName("kim");
        entityManager.flush();
        entityManager.clear();
    }

 

날라가는 쿼리는 다음과 같습니다.

데이터가 변경됨과 동시에 Version이 1에서 하나 올라간 2로 저장됨을 볼 수 있습니다. 이 때 만일 update 된 row가 하나도 없는 경우에는 예외가 발생합니다.

 

장/단점

  1. 아무래도 어플리케이션 단에서 락을 걸기 때문에 비관적 락에 비해 성능이 더 좋습니다.
    • 엔티티를 수정하지 않고 조회만 해오는 경우에는 LockModeType의 OPTIMISTIC 옵션을 걸지 않으면 버전을 체크하지 않습니다.
  2. 어플리케이션 단에서 락을 걸기 때문에 예외가 발생하는 경우 개발자가 직접 후처리를 해주어야 합니다.
  3. 도메인에 버전 관리를 위한 필드가 추가됩니다.
  4. 엔티티를 수정하는 경우, 매번 update 쿼리가 나가는 문제점이 있습니다.

 

비관적 락이란?

  • 트랜잭션의 충돌이 발생한다고 가정하고 락을 걸고 보는 방법입니다.
  • select for update 구문을 사용합니다. 
  • 데이터베이스 트랜잭션 락 메커니즘에 의존하는 방법입니다.

그렇다면 한번 비관적 락도 어떻게 쿼리가 날라가는지 테스트해 보겠습니다. Version 필드는 제거해도 되지만, 혼동될 수도 있으므로 제거하지 않고 Repository에서의 Lock 수준만 변경하겠습니다.

public interface RaccoonRepository extends JpaRepository<Raccoon, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    Optional<Raccoon> findById(Long id);
}

 

그렇다면, 테스트 결과는 어떠한 쿼리가 날라가게 될까요?

 

원하던 대로 select for update 구문으로 조회 쿼리가 나가는 모습을 볼 수 있습니다. select for update 쿼리로 조회한 데이터는 트랜잭션이 끝나기 전까지 다른 트랜잭션에서 수정하거나 select for update 쿼리 또는 for share로 조회할 수 없습니다.

 

장/단점

  • 데이터를 수정하는 즉시 트랜잭션 충돌을 감지할 수 있습니다.
  • 데이터베이스 단에서 락을 걸기 때문에 동시성 문제를 해결하기 위한 Version 필드가 따로 필요하지 않으며, 개발자는 로직 자체에 더 집중할 수 있습니다.
  • 데이터베이스 단에서 락을 걸기 때문에 성능이 비교적 좋지 않습니다.

 

프로젝트에서는?

프로젝트에서는 비관적 락을 적용했는데, 이유는 동시에 접근하는 충돌이 많이 일어날 것이라고 예상했기 때문입니다. 만일 낙관적 락을 사용한다면 예외처리를 하는데 많은 시간이 들 것 같았습니다. 따라서 비관적 락을 사용하여 동시성 문제를 해결하였습니다.

 

참고

  • 자바 ORM 표준 JPA 프로그래밍

'프레임워크 > Spring' 카테고리의 다른 글

QueryDSL 핥아만 보기  (0) 2022.11.21
Jpa의 @Query 탐험기  (0) 2022.11.19
N+1 문제와 해결 방법  (0) 2022.11.06
스프링 이벤트를 통한 양방향 의존성 풀기  (0) 2022.11.04
Where절의 Subquery 성능 문제  (0) 2022.10.30

JPA에서의 N+1 문제란?

프로젝트를 진행하던 중 N+1 문제를 만난 적이 있었습니다. 그당시는 짧은 검색을 통해 join fetch를 하면 해결된다로 넘어갔었는데, 이번 기회에 여러 해결 방법과 함께 알아보면 좋을 것 같았습니다. N+1 문제는 일반적으로 지연 로딩으로 인해 발생합니다. 연관된 데이터를 가져오기 위해 N개의 추가 쿼리문이 실행되는 문제입니다. 말로만 설명하기엔 너무 어려운거 같군요. 예시를 들어보겠습니다.

 

 

배경

데이터베이스는 H2 데이터베이스를 사용했으며, 쿼리 조회와 DB 설정은 다음 사이트를 참고했습니다.

https://www.baeldung.com/spring-boot-h2-database

 

Spring Boot With H2 Database | Baeldung

Learn how to configure and how to use the H2 database with Spring Boot.

www.baeldung.com

https://lannstark.tistory.com/14

 

Spring Boot SQL 보기 옵션 총 정리

Spring Boot에서 query DSL을 사용하건 spring boot JPA를 사용하건, 쿼리를 튜닝할 때 SQL을 봐야할 때가 있다. 그럴 때 사용할 수 있는 몇 가지 옵션을 조사해 보았다. 환경 : Spring boot 2.* + hibernate 5.3.10 이

lannstark.tistory.com

 

 

예시로는 라쿤과 라쿤 그룹이 있습니다. 각각의 라쿤 집단에는 라쿤이 있습니다. 각각의 라쿤 그룹에 라쿤이 몇마리가 있는지 확인해 보겠습니다. 예제에서는 count() 쿼리가 아닌, 리스트를 가져와서 size() 메서드로 마리수를 측정한다고 가정하겠습니다. 전체 라쿤 그룹과 그룹에 속해있는 라쿤들을 모두 조회하는 쿼리의 구조는 다음과 같을 것입니다.

SELECT {라쿤 그룹의 속성들}, {라쿤의 속성들}
FROM raccoon_group g inner join raccoon r on g.id = r.group_id;

 

위 쿼리의 실행 결과를 엔티티에 맞추어서 넣어주면 한번만 쿼리를 날리면 될 것 같습니다. 하지만 Lazy Loading 으로 연관관계를 설정해준다면 생각한 대로 동작하지 않습니다. 다음은 예제 코드와 실행 결과입니다.

@Test
void list() {
    final List<RaccoonGroup> raccoonGroups = groupRepository.findAll();

    for (final RaccoonGroup raccoonGroup : raccoonGroups) {
        System.out.println(raccoonGroup.getName() + ":" + raccoonGroup.getRaccoons().size() + "마리");
    }

    entityManager.flush();
    entityManager.clear();
}

쿼리가 총 3개, 맨 위에서부터 전체 그룹 조회, A 그룹 조회, B 그룹 조회 쿼리가 날라가는 것을 볼 수 있습니다. 그렇다면 왜 이렇게 쿼리가 날라갈까요? 그 이유는 연관관계에 걸어준 Lazy Loading 때문입니다.

 

 

Lazy Loading

연관관계 설정된 객체를 프록시 객체로 가지고 있는 방식입니다. 객체를 실제로 사용하는 시점에서야 데이터베이스에서 해당 객체를 조회합니다. 앞서 raccoonGroup 내부에 있는 raccoon들은 실제 그룹을 조회하는 시점(getRaccoons)이 되서야 데이터베이스에서 조회가 된 것이었습니다. 실제로 일반적인 Collection이 아닌 프록시 객체인 PersistentBag으로 변환되어 저장되는 것을 볼 수 있습니다.

프록시 객체인 PersistentBag으로 저장된 모습

 

실제로 PersistentBag의 get() 메서드를 들어가 보면 컬렉션에서 데이터를 조회하기 전에 read() 메서드로 데이터를 조회해 오는 것을 볼 수 있습니다. 참고로 bag은 저희가 Entity의 필드를 선언할 때 써준 List라고 보시면 됩니다.

그렇다면 왜 데이터 조회 시 프록시 객체를 사용할까요? 이유는 필요하지 않은 많은 데이터를 가져올 수 있기 때문입니다. 예시로 저희는 라쿤이 몇마리인지에만 관심이 있지 각각의 라쿤이 무슨 먹이를 좋아하는지는 관심이 없습니다. 이러한 상황에서 먹이 데이터까지 불러오면 불필요한 데이터까지 DB에서 불러오기 때문에 메모리 낭비가 있을 수 있습니다.

 

https://www.baeldung.com/hibernate-lazy-eager-loading

 

Eager/Lazy Loading In Hibernate | Baeldung

Quick and practical introduction to different data loading approaches - lazy and eager - in Hibernate.

www.baeldung.com

앞서서 N+1 문제가 무엇인지와 왜 발생하는지에 대해서 살펴보았습니다. 이제 N+1을 해결하는 방법에 대해서 알아보겠습니다.

 

Join Fetch

이전에는 전체 객체를 조회하고, 객체와 연관관계로 묶여있는 다른 객체를 하나씩 조회하기 때문에 N+1 문제가 발생했었습니다.

Join Fetch 방법은 JPQL을 사용하여 명시적으로 Join으로 연관된 데이터까지 모두 영속화시키는 방법입니다. 추후 연관관계로 묶여 있는 엔티티를 사용할 때, 이미 영속화되어 있으므로 추가 쿼리가 발생하지 않습니다.

참고로 OneToMany 관계이므로 조인으로 인해 그룹이 중복되어 조회될 수도 있으므로 distinct를 사용해서 중복되지 않도록 처리하였습니다.

public interface GroupRepository extends JpaRepository<RaccoonGroup, Long> {

    @Query("select distinct g from RaccoonGroup g join fetch g.raccoons")
    List<RaccoonGroup> findAllByFetch();
}

 

Join Fetch 방법의 단점으로는 페이지네이션과 함께 사용할 경우 limit이 정상적으로 걸리지 않아 원하는 결과가 나오지 않을 수 있으며 쿼리 결과를 모두 메모리에 적재한 다음 페이지네이션을 하기 때문에 성능 상 치명적으로 좋지 않다는 문제점이 있습니다.

https://tecoble.techcourse.co.kr/post/2020-10-21-jpa-fetch-join-paging/

 

BatchSize 설정으로 해결하기

N+1 문제가 조회해온 전체 엔티티의 각각의 연관관계를 하나씩 조회해오기 때문에 생기는 문제이므로, select의 in절을 이용하여 한번에 여러 연관관계 엔티티를 조회해오는 방식입니다. 이번 예제에서는 한번에 두개의 라쿤 그룹에 속해있는 라쿤들을 조회해오도록 작성해 보겠습니다.

@OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.PERSIST, orphanRemoval = true)
@BatchSize(size = 2)
private List<Raccoon> raccoons;

 

 

Entity Graph

attributePaths에 가져올 엔티티를 직접적으로 명시해 줍니다. 연관관계가 있는 필드명을 작성해 주면, 해당 엔티티를 left outer join으로 가져옵니다.

@EntityGraph(attributePaths = {"raccoons"})
@Query("select g from RaccoonGroup g")
List<RaccoonGroup> findAllByEntityGraph();

 

결론

지금까지 N+1 문제를 해결하기 위한 세가지 방법을 살펴보았습니다. 어떤 방법이 가장 좋다라고 정의하기는 어렵고 상황에 맞추어서 사용하면 될 것 같습니다. 각 방법에 대해 간단하게 정리하겠습니다.

FetchJoin

  • 많이 사용되는 방법, 한번에 연관관계에 있는 데이터를 모두 불러온다.
  • Pagination과 함께 사용되는 경우 전체 데이터를 메모리에 불러와서 페이지네이션을 한다는 큰 문제점이 있음

EntityGraph

  • 명시해둔 AttributePath에 해당하는 연관관계 데이터를 불러올 수 있다.
  • 사용방법을 더 자세히 알아보기 위해서는 NamedEntityGraph를 찾아본다.
  • left outer join을 사용하기 때문에 null값이 존재할 수 있다.
  • AttributePath를 잘못 입력하는 경우 런타임에서 예외가 발생한다.

BatchSize

  • in절을 사용하여 데이터를 BatchSize만큼 한번에 가져올 수 있다.
  • 적절한 BatchSize를 찾아야 한다.

EventListener

Publisher

  • 이벤트를 Listen하고 있는 모든 객체에게 게시합니다.
  • 게시하는 객체에서는 ApplicationEventPublisher을 따로 주입받고 ApplicationEventPublisher의 publishEvent() API를 사용합니다.

Listener

  • Listener은 빈이어야 합니다.
  • 빈의 public 메소드에 @EventListener 어노테이션을 붙여서 사용합니다.
  • 파라메터 타입에 맞는 이벤트가 발행한다면 해당 메소드가 실행됩니다.

 

기본적으로 EventListener은 동기화 되어 있습니다. 예시로 트랜잭션 내에서 Event를 발행하면, Event를 수신하기를 기다리며 수신 측에서 예외가 터진다면 Transaction이 롤백됩니다.

커밋이 된 이후에 이벤트를 처리하도록 하려면 TransactionalEventListener을 사용하면 됩니다.

 

 

그럼 언제 이벤트를 사용할까요?

다음과 같은 경우를 생각해 봅시다.

단방향 의존성

 

게임 플랫폼이 게임에 의존하고 있는 형태입니다. 스팀 비슷하게 생각하면 될 거 같습니다. 사용자는 게임 플랫폼을 통해 게임을 접속하게 됩니다. 그렇다면 한가지 케이스에 대해 이야기해보겠습니다. 게임을 점검해야 하는 기능이 도입된다면 어떨까요? 게임 측에서 본인이 점검중이라고 게임 플랫폼에게 알려주어야 할 것입니다.

private GameRepository gameRepository;
private GamePlatformService gamePlatformService;

void maintenance(Long gameId) {
    final Game game = gameRepository.findById(gameId);
    gamePlatformService.unableGame(gameId);
    
    ...
}

양방향 의존성

 

위와 같이 작성하는 순간 의존성이 양방향으로 바뀌게 됩니다. 게임에서도 결국 게임 플랫폼을 알고 있어야 하기 때문입니다. 그렇다면 결국 양방향이 됩니다. 양방향은 썩 좋지 않은 구조입니다. A가 변경되면 B도 변경되고, 반대로 B가 변경되도 A를 변경시켜 주어야 하기 때문입니다. 또한 양방향이 좋지 않은 구조인 이유는 자칫 잘못하면 메소드를 순환 호출할 위험이 생기고 양쪽 상태를 맞추어 주어야 하는 작업이 필요합니다.

 

그렇다면, 이러한 양방향을 어떻게 해결해 줄 수 있을까요? 여러 방법이 있겠지만 위에서 설명한 이벤트를 사용한다면 해결 가능합니다. 게임이 점검 상태라고 이벤트를 publish 하면 게임 플랫폼에서 이를 받아서 상태를 바꾸도록 바꿔서 구현하면 될 것 같습니다.

 

Game

private ApplicationContext applicationContext;

void maintenance(Long gameId) {
    final Game game = gameRepository.findById(gameId);
    applicationContext.publishEvent(new GameMaintenanceEvent(this, game.getId()));
}

GamePlatform

@EventListener
public void unableGame(final GameMaintenanceEvent event) {
    Long gameId = event.getGameId();
    
    ...
}

이로써 Game이 GamePlatform을 더이상 의존하지 않아도 되어 단방향으로 되었습니다.

 

이벤트의 단점은?

  1. 동기화되는 이벤트의 경우는 오류처리가 가능할 수 있지만, 비동기 이벤트의 경우 오류 처리가 어렵습니다.
  2. 명확한 실행 순서가 존재하지 않습니다.

 

참고 문헌

https://www.baeldung.com/spring-events

https://blog.pragmatists.com/spring-events-and-transactions-be-cautious-bdb64cb49a95

https://www.techtarget.com/searchapparchitecture/tip/Event-driven-architecture-pros-and-cons-Is-EDA-worth-it

문제 상황

문제가 되는 쿼리는 다음과 같습니다.

앗..커서 뭐야

참여한 모임을 조회할 때, 100만건 기준으로 데이터를 조회할 때 1.7초정도의 시간이 걸렸습니다. 가장 직관적이라고 생각해서 이런 구조로 쿼리를 날렸었는데, 생각보다 성능이 훨씬 좋지 않았습니다. 이유가 무엇일까요?

주최한 모임에 대해서는(group0_.host_id=?) 인덱스가 걸려있어서 빠르게 조회할 수 있지만, 참여한 모임을 조회하는 경우가 문제였습니다.

현재 참여한 모임을 조회하기 위해 서브쿼리로 participant 테이블에서 인원들을 가져와서, 그 인원들에 포함되어 있는지를 조회하는 방식이었습니다. 여기서 문제가 되는 점은 서브쿼리의 조건절에 외부 테이블 컬럼을 참조하는 부분이 문제였습니다.

 

따라서, 쿼리를 두개로 나누는 것이 좋을 것 같습니다.

  1. 주최한 모임 조회
  2. 내가 참여한 모임 조회. 내가 참여한 모임을 조회하는 경우는, 먼저 participant 테이블에서 member_id로 group_id를 조회해온 다음, in절의 내부에 쿼리의 결괏값을 넣어주는 방식으로 작성하였습니다.
  3. Union을 사용하는 것이 가장 직관적이라고 생각했지만.. 아쉽게도 Union을 JPA에서는 지원하지 않았습니다.

어떻게 처리했을까?

빨간 네모 부분 in절 내부의 서브쿼리를 사전에 실행시켜서 결괏값을 미리 얻어온 다음 대입해주는 방식으로 로직을 교체해 보겠습니다. 현 시점에서 문제가 되는 코드는 다음과 같습니다.

@Query("SELECT g FROM Group g "
            + "WHERE g.participants.host = :member "
            + "OR ( :member IN (SELECT p.member.id FROM Participant p WHERE p.group = g) )")
List<Group> findParticipatedGroups(@Param("member") Member member);

위 로직을 다음과 같이 변경해서 테스트해 보겠습니다.

@Query("select distinct p.group.id from Participant p where p.member.id = :memberId")
List<Long> findGroupIdWhichParticipated(@Param("memberId") Long memberId);

@Query("SELECT g FROM Group g "
        + "WHERE g.participants.host = :member "
        + "OR g.id IN :participatedGroupIds")
List<Group> findParticipatedGroups(@Param("member") Member member, 
                                   @Param("participatedGroupIds") List<Long> participatedGroupIds);

단, 사용하는 서비스단에 추가 로직이 필요합니다.

public List<Group> findParticipatedGroups(Member member) {
    List<Long> participatedGroupIds = groupSearchRepository.findGroupIdWhichParticipated(member.getId());
    return groupSearchRepository.findParticipatedGroups(member, participatedGroupIds);
}

결과는 어떻게 되었을까요? 다음과 같이 두개의 쿼리가 따로따로 나가는 모습을 볼 수 있었습니다. 본인이 참여한 그룹들의 ID를 미리 구해두고, 이후 본인이 주최한 모임과 참여한 모임을 조회하는 모습을 볼 수 있었습니다.

그렇다면.. 이제 대망의 성능 측정 시간입니다. 이렇게 조회를 하게 된다면 도합 0.03초정도의 조회 시간이 걸리는 것을 알 수 있습니다. 매번 불필요한 서브쿼리 조회가 날라가는걸 없애고 한번만 조회해서 다 가져온 다음에 in절로 묶어오니 훨씬 빠르게 개선되었다는 점을 알 수 있습니다.

 

빨라진 성능 bb

 

발단

본격적으로 성능 개선을 하기 위해 어느정도의 성능인지 테스트해야할 일이 생겼습니다. 테스트를 위해 더미데이터가 필요하였습니다. 대략 필요한 데이터의 수는 회원 100만건, 모임 100만건, 각 모임에 평균 5명의 참여자를 모이기 위해 참여 500만건 정도의 데이터가 필요하였습니다.

처음에는.. 데이터를 생성하려고 할 때 그냥 JPA를 이용하여 데이터를 저장하려고 하였습니다. 그냥저냥 무난할 줄 알았는데 100만건의 데이터를 넣기에는 너무나도 오랜 시간이 걸리더라구요. 끝이 보이지 않길래 이건 가망이 없다 싶어서 다른 방식을 찾아보다가, 코치와 크루에게 도움을 받아 JdbcTemplate의 BatchUpdate 방식을 사용하게 되었습니다.

 

BatchUpdate랑 일반 Update의 차이점

BatchUpdate를 사용하여 쿼리를 날려보니 생각보다 훨씬 훨씬 훨씬 더 더 더 많이 차이가 났습니다. 끝이 보이지 않는 100만건의 데이터 추가가 몇분안에 끝났습니다.

그렇다면 BatchUpdate는 어떻게 쿼리가 나가길래 빨리 끝나는 걸까요? 한번 직접 조회 로직을 비교해 보았습니다. 전체 테스트 코드는 다음과 같습니다.

@SpringBootTest
public class batchTest {

    private static final String INSERT_SQL = "insert into momo_member(user_id, password, name, deleted) values (?, ?, ?, ?)";

    @Autowired
    JdbcTemplate jdbcTemplate;

    @Autowired
    EntityManager entityManager;

    @Test
    void batchUpdate() {

        List<MemberDto> memberDtos = List.of(
                new MemberDto("momo1", "momo1234", "momokingppp"),
                new MemberDto("momo2", "momo1234", "momokingpppp")
        );

        System.out.println("데이터 출력 시작");
        jdbcTemplate.batchUpdate(INSERT_SQL, new BatchPreparedStatementSetter() {
            @Override
            public void setValues(PreparedStatement ps, int i) throws SQLException {
                MemberDto memberDto = memberDtos.get(i);

                ps.setString(1, memberDto.userName);
                ps.setString(2, memberDto.password);
                ps.setString(3, memberDto.name);
                ps.setBoolean(4, false);
            }

            @Override
            public int getBatchSize() {
                return memberDtos.size();
            }
        });
    }

    class MemberDto {
        String userName;
        String password;
        String name;

        public MemberDto(String userName, String password, String name) {
            this.userName = userName;
            this.password = password;
            this.name = name;
        }
    }
}

 

로그가 잘 찍히는 것 같지 않아, MySQL WorkBench로 실제 데이터 추가 쿼리가 어떻게 추가되었나 살펴보겠습니다. 참고로, DB 로그를 테이블로 찍어보는 방법은 다음과 같습니다.

set global log_output = 'TABLE';
set global general_log = 'ON';

select * from mysql.general_log; // 로그 보기
truncate table mysql.general_log;

SET GLOBAL general_log = 'OFF'; // 이후 종료

 

실제 실행 결과를 캡쳐해 보았는데.. 너무 어두우므로.. 옮겨 써보겠습니다.

실행 화면.. 설정이 이상한지 폰트가 아주 어둡다.. 옮겨봐야 할 수준

insert into momo_member(user_id, password, name, deleted) 
	values ('momo1', 'momo1234', 'momokingppp', 0),('momo2', 'momo1234', 'momokingpppp', 0)

여러개의 추가 쿼리가 하나의 insert문으로 묶여서 가는 모습을 볼 수 있습니다. 여러개의 insert문보다 하나의 insert문으로 여러개의 데이터를 묶어서 보내는 것이 더 속도가 빠를까요? 결론적으로는, 하나의 insert문이 훨씬 빠르다고 합니다. 하나의 insert문이 실행되기까지는 연결, 서버로 쿼리 전송, 구문 분석, 행 삽입, 인덱스 삽입, 연결 종료 등의 절차가 모두 포함되기 때문입니다.

 

https://dev.mysql.com/doc/refman/8.0/en/insert-optimization.html

 

MySQL :: MySQL 8.0 Reference Manual :: 8.2.5.1 Optimizing INSERT Statements

8.2.5.1 Optimizing INSERT Statements To optimize insert speed, combine many small operations into a single large operation. Ideally, you make a single connection, send the data for many new rows at once, and delay all index updates and consistency checkin

dev.mysql.com

 

더..더 빠르게

전체 데이터를 넣는데까지 대략 20분정도의 시간이 걸렸습니다. 20분 정도면 충분히 만족 가능하지만.. 최근에 쓰레드를 배워서 멀티 쓰레드를 적용한다면 조금 더 시간이 빨라질 것 같습니다. 따라서 전략을 세웠습니다.

  1. 총 5개의 쓰레드를 동작시킵니다.
  2. 5개의 쓰레드가 개별적으로 유저를 저장합니다. 한 쓰레드당 200000명(memberBlockSize)의 유저를 저장합니다
    1. 1번 쓰레드가 1번~200000번 유저
    2. 2번 쓰레드가 200001번 유저 ~ 400000번 유저
    3. 3번 쓰레드가 400001번 유저 ~ 600000번 유저
    4. 4번 쓰레드가 600001번 유저 ~ 800000번 유저
    5. 5번 쓰레드가 800001번 유저 ~ 1000000번 유저
  3. 각 쓰레드가 저장할 땐 1000개의 데이터(BATCH_SIZE)씩 저장합니다.

위와 같은 구조로 한다면 이전보다는 더 빨라질 것 같았습니다. Member에 해당하는 구현 코드는 다음과 같습니다.

private void saveMembers() {
    final int memberBlockSize = TOTAL_MEMBER_SIZE / THREAD_COUNT; // 한 쓰레드가 맡아서 저장해야하는 유저 수

    for (int threadNumber = 0; threadNumber < THREAD_COUNT; threadNumber++) {
        saveMemberByThread(memberBlockSize, threadNumber);
    }
}

private void saveMemberByThread(int memberBlockSize, int threadNumber) {
    executorService.execute(() -> {
        log.info("Member 저장 시작-" + threadNumber);
        for (int i = 0; i < memberBlockSize / BATCH_SIZE; i++) {
            saveMemberBlock(memberBlockSize, threadNumber, i);
        }
        log.info("Member 저장 종료-" + threadNumber);
    });
}

private void saveMemberBlock(int memberBlockSize, int threadNumber, int i) {
    int currentMemberBlockStartId = memberBlockSize * threadNumber;
    List<MemberDto> generatedMember = makeMember(
            currentMemberBlockStartId + BATCH_SIZE * i + 1,
            currentMemberBlockStartId + BATCH_SIZE * (i + 1)
    );

    jdbcTemplate.batchUpdate(MEMBER_INSERT_SQL, new BatchPreparedStatementSetter() {
        @Override
        public void setValues(PreparedStatement ps, int i) throws SQLException {
            MemberDto member = generatedMember.get(i);
            ps.setLong(1, member.getId());
            ps.setBoolean(2, false);
            ps.setString(3, member.getUserId());
            ps.setString(4, member.getUserName());
            ps.setString(5, member.getPassword());
        }

        @Override
        public int getBatchSize() {
            return generatedMember.size();
        }
    });
}

private List<MemberDto> makeMember(int startId, int endId) {
    List<MemberDto> members = new ArrayList<>();
    for (long i = startId; i <= endId; i++) {
        members.add(new MemberDto(i));
    }
    return members;
}

 

전체적으로 구현한 결과, 최종적으로 모든 데이터를 저장하기까지 대략 10분정도의 시간이 걸렸습니다. 초기 단계는 저장이 완료될 기미조차 보이지 않았는데, 최종적으로 10분정도면 괄목할 만한 성과가 아닐까 싶습니다.

저장 시작 시간
전체 쓰레드 종료 시간

 

MOMO의 이미지 서버 구축기

https://github.com/2022-momo/momoimage

 

GitHub - 2022-momo/momoimage: 모모팀 이미지 서버

모모팀 이미지 서버. Contribute to 2022-momo/momoimage development by creating an account on GitHub.

github.com

 

어떤 일이 있었길래?

모두모여라 프로젝트 진행 중, 기존에는 모임 카테고리별 고정 이미지를 제공하였습니다. 그렇다 보니 모임 관련해서 모임명이 다르더라도 모든 썸네일이 같아 사용자로 하여금 직관적이지 않을 것 같다는 느낌을 받았습니다.

또한 무엇보다 모임을 대표할만한 썸네일이 아니라는 점이 발목을 잡았습니다. 이에 썸네일 이미지를 따로 유저가 저장하게 하는 방식을 도입하자는 팀 회의 끝에 기능을 도입하게 되었습니다.

 

업로드는 어떻게 해야 할까?

조사를 하다 보니 두가지 방법이 있었습니다. 하나는 S3 버킷을 이용하여 파일을 관리하는 방법이었고, 다른 하나는 직접 스프링을 띄워서 파일을 업로드시키는 방법이었습니다.

S3 버킷을 사용하려다가 우테코 보안 정책에 의해 가로막히는 부분이 많았습니다. 따라서 어쩔수 없이 직접 스프링을 띄워서 파일을 업로드시키자는 결론이 나왔습니다. 따로 서버를 둔 이유는 다음과 같습니다.

 

  1. 이미지 서버에 직접 접근해서 업로드하는것 보다는 기존 서버를 거쳐서 회원 유무를 체크하고, 이미지 서버로 이미지를 전송하는 방식이 더 좋아 보입니다.
  2. EC2의 용량이 적다 보니, 기존 서버에서 이미지 관리까지 담당한다면 이미지가 많이 쌓이게 되면 서버가 터지지 않을까 고민이 많았습니다.

 

따라서, (1)의 방법을 사용하고, MultipartFile을 스프링에서 파라메터로 받을 수 있다고 하여 MultipartFile을 사용하였습니다. 전체적인 구조는 다음과 같습니다.

 

구현하기 위해 참고한 Multipart 관련 자료는 다음과 같습니다.

https://www.baeldung.com/sprint-boot-multipart-requests

 

Multipart Request Handling in Spring | Baeldung

Learn how to send multipart HTTP requests using Spring Boot.

www.baeldung.com

 

업로드 된 이미지를 사용자에게 어떻게 전달해야 할까?

업로드가 다 해결되고 나니, 이미지를 제공할 때 어떻게 해야 될지 생각을 하다 두가지 방법을 생각해 보았습니다.

  1. 클라이언트가 백엔드 서버로 요청을 하면, 백엔드 서버가 이미지 서버로부터 이미지 파일을 가져오도록 구현합니다.
  2. 클라이언트가 직접 이미지 서버로 접근하여 이미지를 가져오도록 구현합니다.

(1)의 방법은, 백엔드 서버가 이미지 서빙을 하기엔 부하가 너무 클것이라고 생각이 되기도 하고, 굳이 이미지를 서버 하나를 거쳐서 전달해야 하나 하는 생각이 있었습니다.

따라서, 이미지 전달은 스프링을 거쳐서 하는것이 아닌 Nginx를 사용하여 정적 파일을 제공하자는 결론이 나왔습니다. 따라서, 이미지 서버에 Nginx를 따로 하나 더 두어 최종적으로 Nginx와 스프링을 동시에 사용하였습니다.

번외로, 고민한 사항

이미지 서버로 파일이 들어오는데, 이를 이미지인지 어떻게 체크할 수 있을까요? 다음과 같은 요소들을 체크하였습니다.

 

첫번째는 ContentType 체크입니다.

  • 들어온 파일에 대해서 ContentType를 체크합니다.(image/jpeg인지, image/png인지)
private static final List<String> IMAGE_CONTENT_TYPES =
            List.of(IMAGE_JPEG_VALUE, IMAGE_PNG_VALUE);

public void validateContentType(MultipartFile file) {
    String contentType = file.getContentType();

    if (contentType == null || isContentTypeNotImage(contentType)) {
        throw new ImageException(String.format("올바른 컨텐츠 타입이 아닙니다. [%s]", contentType));
    }
}

private boolean isContentTypeNotImage(String contentType) {
    return IMAGE_CONTENT_TYPES.stream()
            .noneMatch(contentType::equals);
}

 

두번째는 정말 이미지 파일이 맞는가에 대한 검증입니다.

  • Content-Type만 지정하기에는 데이터가 바뀔 수 있다는 점에서 추가 검증을 진행합니다.
  • ImageIO.read 메서드는 읽어들인 데이터가 이미지가 아닌 경우 null을 반환합니다.
public void validateFileIsImage(MultipartFile multipartFile) {
    try {
        ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(multipartFile.getBytes());
        BufferedImage read = ImageIO.read(byteArrayInputStream);
        if (read == null) {
            throw new ImageException("올바른 이미지 파일이 아닙니다!!");
        }
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

 

세번째는 MultiPartFile의 용량 제한입니다.

  • 기본적으로 스프링에서는 1MB로 용량을 제한하고 있는데, 이를 늘려야 하는지, 줄여야 하는지 생각해 본 결과, 1MB정도면 적당하다고 판단되어 조절하진 않았습니다.

네번째는 중복 파일 명 체크입니다.

  • 이미지 저장 시에, 중복된 파일명이 나타날 수도 있을거라 생각해 파일명은 임의로 UUID 메서드를 사용하여 파일명을 구성하도록 구현하였습니다.
String extension = extractExtension(multipartFile.getOriginalFilename());
String changedFileName = UUID.randomUUID().toString() + "." + extension;

 

  1.  

IOC? DI?

  • 스프링에서의 IoC(제어의 역전), DI(의존성 주입)은 사실상 같은 말입니다.
  • IoC란, 메소드나 객체의 호출 작업을 개발자가 하는것이 아닌 외부에서 처리하는 것을 말합니다. 즉, IoC 컨테이너에서 DI를 하게 됩니다.
  • 스프링의 IoC 컨테이너로는 ApplicationContext를 사용합니다.

 

ApplicationContext

  • 애플리케이션에서 IoC를 적용해서 관리할 모든 오브젝트에 대한 생성과 관계설정을 담당합니다.

 

… 어떻게?

빈을 생성해서 관리하는것 까진 알겠는데 빈에 달려있는 의존성은 어떻게 주입하는 건지 문득 궁금해졌습니다. A → B로 의존되어 있다면 빈 생성이 어떻게 이루어질까요? 다음과 같은 테스트 코드를 작성해 보겠습니다.

@Controller
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

}
@Service
@RequiredArgsConstructor
public class UserService {

    private final UserDao userDao;

    @PostConstruct
    public void construct() {
        System.out.println("UserService 생성 완료");
    }
}

아주 간단한 주입받는 테스트 코드입니다. 객체가 생성된 직후의 상황을 테스트하기 위해 @PostConstruct 어노테이션을 사용하였습니다.

저는 DI를 미션을 통해 리플렉션을 사용하여 직접 구현해 보았는데도 불구하고 아직도 어떻게 DI가 일어나는지 와닿지 않았습니다. 그렇다면 실제 주입 과정이 어떻게 일어나는지 디버그를 찍어보며 확인해 보았습니다. 참고로 생성자 주입의 경우입니다.

  1. AbstractBeanFactory의 getBean()을 통해 빈을 생성하려고 합니다. 이 때, 주입을 처리해야 하므로 다음 단계로 이동합니다.
  2. 싱글톤 빈이므로 싱글톤 빈 관련 로직이 수행되며 ConstructorResolver.autowireConstructor() 메서드를 사용하여 주입을 위해 사용할 생성자를 가져옵니다.
  3. DefaultListableBeanFactory.doResolveDependency() 메서드에서 descriptor을 통해 빈 이름을 가져오고, 가져온 빈 이름으로 빈 객체를 조회합니다.
    • descriptor : 주입되어야 하는 의존성에 대한 명세
    • descriptor.resolveCandidate() 메서드를 사용하여 주입받아야 하는 빈 이름을 넣어 다시한번 getBean() 메서드를 호출합니다. 빈이 만들어지지 않았다면 위의 내용이 반복됩니다.

생략된 내용이 많긴 하지만.. 디버깅을 찍어보면서 알게 된 내용은 여기까지입니다. 결론적으로 A → B → C 순으로 의존하고 있다면, 빈 C부터 만들어지고 마지막으로 빈 A가 만들어지는 것을 알 수 있었습니다.

+ Recent posts