검증을 위한 Logback 로그 출력 테스트 방법

로그 출력을 검증하는 것은 테스트 코드 작성 시 중요한 부분입니다. 특히, 로그 레벨에 따른 조건적인 출력을 검증할 때는 정확성이 요구됩니다. 이 문서는 Logback 라이브러리를 사용하여 로그 출력을 효과적으로 검사하는 방법을 설명합니다.

Appender란?

Appender는 Logback의 핵심 구성 요소로 로그 이벤트를 기록하는 역할을 합니다. 공식 문서에 따르면:

Logback은 로깅 이벤트를 작성하는 작업을 'appender'라고 불리는 컴포넌트에 위임합니다. Appender는 ch.qos.logback.core.Appender 인터페이스를 구현해야 합니다. - Logback 공식 문서

기본적으로, 스프링 부트에는 콘솔에 로그 이벤트를 출력하는 콘솔 Appender가 포함되어 있습니다.

로그 검사를 위한 사용자 정의 Appender 구현

로그 출력을 검사하기 위해서는 사용자 정의 Appender와 RecordCheckable 인터페이스를 구현해야 합니다. Logback 라이브러리는 리스트 형태로 이벤트를 기록하는 ListAppender를 제공합니다. 아래는 ListAppender를 확장하고 RecordCheckable 인터페이스를 구현한 예시 코드입니다.

ListAppender

import java.util.ArrayList;  
import java.util.List;  
import ch.qos.logback.core.AppenderBase;  

public class ListAppender<E> extends AppenderBase<E> {  
    public List<E> list = new ArrayList<E>();  

    protected void append(E e) {  
        list.add(e);  
    }  
}

RecordCheckable 인터페이스와 RecordCheckAppender 구현

import ch.qos.logback.classic.Level;  
import ch.qos.logback.classic.Logger;  
import ch.qos.logback.classic.spi.ILoggingEvent;  
import ch.qos.logback.core.read.ListAppender;  

interface RecordCheckable {  
    boolean isRecord(String message, Level level);  
}  

public class RecordCheckAppender extends ListAppender<ILoggingEvent> implements RecordCheckable {  
    public static RecordCheckAppender addAppender(Logger logger) {  
        RecordCheckAppender newAppender = new RecordCheckAppender();  
        logger.addAppender(newAppender);  
        newAppender.start();  
        return newAppender;  
    }  

    @Override  
    public boolean isRecord(String message, Level level) {  
        return this.list.stream()  
            .filter(iLoggingEvent -> iLoggingEvent.getLevel().equals(level))  
            .anyMatch(iLoggingEvent -> iLoggingEvent.getMessage().contains(message));  
    }  
}

테스트 코드에서의 사용

테스트를 진행하기 전에 테스트 대상 클래스의 Logger에 RecordCheckAppender를 추가합니다.

@BeforeEach  
void beforeEach() {  
    this.recordCheckAppender =  
        RecordCheckAppender.addAppender((Logger) LoggerFactory.getLogger(Foo.class));  
}

이제 아래와 같이 로그 레벨과 메시지를 통해 로그의 출력 여부를 검증할 수 있습니다.

@Test  
public void testAlertUnspecifiedAuthError() {  
    Foo foo = new Foo();
    foo.log();
    assertTrue(recordCheckAppender.isRecord("foo log가 출력되었습니다.", Level.WARN));  
}

위의 방법을 통해 로그 출력의 정확성을 테스트 코드 내에서 쉽고 명확하게 검증할 수 있습니다.

[Spring] Request Body 를 직접 읽을 때 주의할 점

들어가며

Filter 를 이용해 로그에 Request Body 를 기록하는 기능을 추가하자
기존 API의 Request Body가 비어있는 오류가 발생했다. 원인과 해결방안에 대해서 알아보자

예시 HTTP 코드

다음과 같은 테스트 HTTP API 코드가 있다고 하자

@RestController
class TestController {
    companion object{
        val log:Logger=LoggerFactory.getLogger(this::class.java)
    }

    @PostMapping("/test/requestBody")
    fun test(@RequestBody message: Message): Message {
        return message
    }

    data class Message(
        val id: Long,
        val message: String
    )
}

이 경우 아래와 같은 테스트 코드를 실행하면 당연히 성공해야한다.

@SpringBootTest
@AutoConfigureMockMvc
class TestControllerTest {

    @Autowired
    private lateinit var mockMvc: MockMvc

    @Autowired
    private lateinit var objectMapper: ObjectMapper

    @Test
    fun `test endpoint should return correct message`() {
        val message = TestController.Message(1, "test")

        mockMvc.perform(
            post("/test/requestBody")
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(message)
            )
            .accept(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk)
            .andExpect(jsonPath("$.id", Matchers.`is`(message.id.toInt())))
            .andExpect(jsonPath("$.message", Matchers.`is`(message.message)))
    }

}

Request Body를 사용하는 필터 추가

Sentry (에러 모니터링 도구)에 Request Body를 기록하는 필터를 추가한다.
request 에서 request body를 읽어오는 모습을 볼 수 있다.

@Component
class RequestBodyLogger: OncePerRequestFilter() {

    val log: Logger=LoggerFactory.getLogger(this::class.java)
    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        Sentry.configureScope{scope ->
            scope.setExtra("requestBody",request.reader.lines().collect(Collectors.joining(System.lineSeparator())))
        }
        filterChain.doFilter(request,response)
    }
}

에러

필터를 추가하고 다시 테스트 코드를 실행하면 아래와 같은 에러를 마주친다.

java.lang.IllegalStateException: Cannot call getInputStream() after getReader() has already been called for the current request

이 에러는 @RequestBody 어노테이션을 처리하는 과정에서 request body의 getReader()가 이미 사용되었기 때문에 더 이상 request body를 읽어올 수 없다는 내용이다.

Servlet의 Request Body는 메모리에 저장하는 것이 아니라 BufferReader 또는 InputStream으로 처리된다. 즉, 한 번 소비가 되면
다른 곳에 저장하지 않는 이상 더 이상 데이터를 가져올 수가 없다.

따라서 필터에서 이미 소비가 되었기 때문에 컨트롤러에서 RequestBody를 해석하려고 하면 데이터를 읽어올 수 없기 때문에 에러가 발생한다.

해결방안

해결방안은 ContentCachingRequestWrapper를 사용하는 것이다.

ContentCachingRequestWrapper는 HttpServletRequest 에서 스트림이나 리더를 이용해 소비되는 데이터를 캐시에 저장해
byte array를 이용해 조회할 수 있도록 하는 래퍼 클래스이다.

이 클래스를 이용하면 필터에서 request body 데이터를 가져와도 이후에도 다시 데이터를 가져올 수 있다.

@Component
class RequestBodyLogger: OncePerRequestFilter() {

    val log: Logger=LoggerFactory.getLogger(this::class.java)
    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        //use cache wrapper for request
        val wrappedRequest = ContentCachingRequestWrapper(request)
        filterChain.doFilter(request,response)

        Sentry.configureScope{scope ->
            val buf=wrappedRequest.contentAsByteArray
            val length = min(buf.size, request.contentLength)
            scope.setExtra("requestBody", String(buf, 0, length, charset(request.characterEncoding)))
        }
    }
}

여기서 중요한 점은 contentAsByteArray를 가져오기 전에 filterChain.doFilter를 호출해야한다는 것이다.

contentAsByteArray 데이터는 request가 리더나 버퍼를 통해서 데이터가 읽어질 때 중간에 데이터를 가로채 저장함으로써 조회가 가능하다.
따라서, request body를 조회하기 전에 contentAsByteArray 데이터를 조회하면 빈 값이 있으므로 filterChain.doFilter를 먼저 호출해야한다.

    /**
     * Return the cached request content as a byte array.
     * <p>The returned array will never be larger than the content cache limit.
     * <p><strong>Note:</strong> The byte array returned from this method
     * reflects the amount of content that has been read at the time when it
     * is called. If the application does not read the content, this method
     * returns an empty array.
     * @see #ContentCachingRequestWrapper(HttpServletRequest, int)
     */
    public byte[] getContentAsByteArray() {
        return this.cachedContent.toByteArray();
    }

Companion Object vs Inner Object

들어가며

companon object를 단순히 java의 static을 대체한다고만 하는 경우가 많아 좀 더 자세히 알아보도록 하자.

companon object 에 대한 정의는 공식 문서에서 확인할 수 있다.
공식문서에서는 원리보다는 용례에 대해 중점적으로 설명하고 있는데, 여기에서는 companon object의 원리와 inner object와의 차이에 대해서 알아보고자 한다.

예시코드

class CompanionObject {
    companion object{
        const val LIMIT=100
        fun add(a: Int,b: Int): Int = a+b
    }
}

class InnerObject {
    object Companion{
        const val LIMIT=100
        fun add(a: Int,b: Int): Int = a+b
    }
}

분석

바이트 코드를 살펴보면 static inner 클래스가 생성되는 것은 동일하지만, 인스턴스 선언방식과 상수의 위치 차이를 알 수 있다.

// CompanionObject.java
public final class CompanionObject {
   public static final int LIMIT = 100;
   @NotNull
   public static final Companion Companion = new Companion((DefaultConstructorMarker)null);

   public static final class Companion {
      public final int add(int a, int b) {
         return a + b;
      }

      private Companion() {
      }

      // $FF: synthetic method
      public Companion(DefaultConstructorMarker $constructor_marker) {
         this();
      }
   }
}

// InnerObject.java
public final class InnerObject {

   public static final class Companion {
      public static final int LIMIT = 100;
      @NotNull
      public static final Companion INSTANCE;

      public final int add(int a, int b) {
         return a + b;
      }

      private Companion() {
      }

      static {
         Companion var0 = new Companion();
         INSTANCE = var0;
      }
   }
}

자바에서 코틀린 companion object 호출

바이트 코드를 보면 알 수 있듯, 함수는 Companion 클래스 내부에 선언되어 있으므로 직접 호출이 불가능하다.
호출을 하려면 Companion 클래스까지 정의를 해야하는데 이는 java static 메서드의 정의와 상이하다.

import com.example.kotlinspring.companionobject.CompanionObject;

public class CompanionJavaTest {
    public static void main(String[] args) {
        int result = CompanionObject.add(1, 2); // 에러 발생
        int result = CompanionObject.Companion.add(1,2);
    }
}

직접 호출을 하기 위해서는 @JVMStatic 어노테이션을 코틀린에서 정의하면 된다.

class CompanionObject {
    companion object : Factory<CompanionObject>{
        const val LIMIT=100
        @JvmStatic fun add(a: Int,b: Int): Int = a+b
        override fun create(): CompanionObject = CompanionObject()
    }
}

이렇게 하면 아래처럼 바이트 코드에 static 함수가 생기고 이 함수는 Companion 클래스의 함수를 다시 호출한다.

public final class CompanionObject {
    public static final int LIMIT = 100;
    @NotNull
    public static final Companion Companion = new Companion((DefaultConstructorMarker) null);

    @JvmStatic
    public static final int add(int a, int b) {
        return Companion.add(a, b);
    }
    //중략...
}

스프링에서 데이터에베이스에 연결해 테스트를 진행할 때는 로컬에 설치하거나 H2 인메모리 데이터베이스를 테스트 코드와 같이 실행시키는 등 다양한 방법이 있다. 이번에는 TestContainer를 이용해서 테스트 코드를 실행할 떄 MySQL 컨테이너를 실행시키고 연결해 통합테스트코드를 수행하는 방법에 대해서 알아보자. 완성된 프로젝트 코드는 링크에서 찾을 수 있다.

준비

도커 설치

MySQL 컨테이너를 실행시키기 위해선 당연히 컨테이너 실행 도구가 필요하다. 이 중 가장 대표적인 도커를 설치해보자. 링크를 통해 OS에 맞는 도커를 설치하도록 한다.

환경 변수 설정

To run Testcontainers-based tests, you need a Docker-API compatible container runtime, such as using Testcontainers Cloud or installing Docker locally. During development, Testcontainers is actively tested against recent versions of Docker on Linux, as well as against Docker Desktop on Mac and Windows. These Docker environments are automatically detected and used by Testcontainers without any additional configuration being necessary.

TestContainer가 설치된 컨테이너 실행도구를 찾기 위해서는 정보를 알려주어야 한다. TestContainer는 기본적으로 도커를 알아서 찾아 연결하려고 한다. 도커 설치 후 따로 설정을 하지 않는 이상 테스트 컨테이너를 위해 추가적으로 설정해야할 일은 없다. 만약 컨테이너 실행 도구를 도커로 사용하지 않거나 추가 설정이 필요하다면, 링크를 참고하자.

Gradle 종속성 추가

스프링 프로젝트에서 테스트 코드 실행시 도커를 활용하기 위해서는 TestContainer 모듈이 필요하다. 아래 종속성 코드를 참고해 필요한 모듈을 추가하도록 한다.

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web' // 예시 웹 서비스 용
    runtimeOnly 'com.mysql:mysql-connector-j'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.testcontainers:junit-jupiter' //테스트 컨테이너를 Junit 테스트 라이프사이클에 활용하기 위한 모듈
    testImplementation "org.testcontainers:mysql:1.19.1" //MySQL 컨테이너를 테스트에 활용하기 위한 모듈
    testImplementation 'io.rest-assured:rest-assured' //API 테스트를 위한 모듈
}

예시 웹 서비스 구현

테스트를 위해서 고객 정보를 저장하고 조회하는 서비스를 간단하게 구현해보자

학생 스키마 정의

resources/schema.sql에 MySQL 에 정의할 학생 테이블 스키마 DDL을 작성하자

CREATE TABLE students
(
    id    BIGINT AUTO_INCREMENT NOT NULL,
    name  VARCHAR(255)          NULL,
    age   INT                   NOT NULL,
    email VARCHAR(255)          NULL,
    CONSTRAINT pk_students PRIMARY KEY (id)
);

학생 엔티티 정의

학생의 이름과 이메일을 저장하는 엔티티를 아래와 같이 정의하자.

package com.example.testcontainermysql.domain;  

import jakarta.persistence.*;  

@Entity  
@Table(name = "students")  
public class Student {  

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

    private String name;  
    private int age;  
    private String email;  

    // 기본 생성자  
    public Student() {  
    }  
    // 모든 필드를 포함한 생성자  
    public Student(String name, int age, String email) {  
        this.name = name;  
        this.age = age;  
        this.email = email;  
    }  

    // getter와 setter 메소드  
    public Long getId() {  
        return id;  
    }  

    public void setId(Long id) {  
        this.id = id;  
    }  

    public String getName() {  
        return name;  
    }  

    public void setName(String name) {  
        this.name = name;  
    }  

    public int getAge() {  
        return age;  
    }  

    public void setAge(int age) {  
        this.age = age;  
    }  

    public String getEmail() {  
        return email;  
    }  

    public void setEmail(String email) {  
        this.email = email;  
    }  

    // toString 메소드  
    @Override  
    public String toString() {  
        return "Student{" +  
                "id=" + id +  
                ", name='" + name + '\'' +  
                ", age=" + age +  
                ", email='" + email + '\'' +  
                '}';  
    }  
}

학생 레포지토리 정의

학생 정보를 저장, 조회하는 레포지토리를 정의하자.

package com.example.testcontainermysql.domain;  

import org.springframework.data.jpa.repository.JpaRepository;  

public interface StudentRepository extends JpaRepository<Student, Long> {  
}

학생 컨트롤러 정의

학생 정보를 조회하는 컨트롤러를 정의하자.

package com.example.testcontainermysql.api;

import com.example.testcontainermysql.domain.Student;
import com.example.testcontainermysql.domain.StudentRepository;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
public class StudentController {
    private final StudentRepository studentRepository;

    public StudentController(StudentRepository studentRepository) {
        this.studentRepository = studentRepository;
    }

    @GetMapping("/student/all")
    List<Student> findAll(){
        return studentRepository.findAll();
    }
}

테스트

테스트 리소스 설정

test/resources/spring.yaml에 테스트시 사용할 스프링 설정을 정의하자. 아래와 같이 정의하면 이전에 정의한 학생 스키마를 MySQL에 자동으로 전달해 학생 테이블을 생성한다.

spring:  
  sql:  
    init:  
      mode: always

테스트 코드 작성

이제 MySQL 컨테이너를 실행시키고 예시 학생 데이터를 생성한 다음 API를 통해 학생 데이터를 조회하는 테스트 코드를 작성해보자. 테스트 코드의 역할을 주석을 참고해보자.

package com.example.testcontainermysql;  

import static io.restassured.RestAssured.given;  
import static org.hamcrest.Matchers.hasSize;  

import com.example.testcontainermysql.domain.Student;  
import com.example.testcontainermysql.domain.StudentRepository;  
import io.restassured.RestAssured;  
import io.restassured.http.ContentType;  
import java.util.List;  
import org.junit.jupiter.api.AfterAll;  
import org.junit.jupiter.api.BeforeAll;  
import org.junit.jupiter.api.BeforeEach;  
import org.junit.jupiter.api.Test;  
import org.springframework.beans.factory.annotation.Autowired;  
import org.springframework.boot.test.context.SpringBootTest;  
import org.springframework.boot.test.web.server.LocalServerPort;  
import org.springframework.test.context.DynamicPropertyRegistry;  
import org.springframework.test.context.DynamicPropertySource;  
import org.testcontainers.containers.MySQLContainer;  

/**  
 * 1. 통합 테스트를 위해 임의의 포트 번호를 할당한 웹 어플리케이션을 실행합니다.  
 */@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)  
class StudentControllerTest {  

    //현재 서버의 포트 번호  
    @LocalServerPort  
    private Integer port;  

    /**  
     * 2. 도커 이미지 명을 명시해 컨테이너를 정의합니다.  
     */    static MySQLContainer<?> mysql = new MySQLContainer<>(  
            "mysql:8.0.35"  
    );  

    /**  
     * 3. 테스트 전, 컨테이너를 실행합니다, 이미지가 없다면 풀링을 먼저 수행합니다.  
     */    @BeforeAll  
    static void beforeAll() {  
        mysql.start();  
    }  

    /**  
     * 4. 테스트 후, 컨테이너를 종료합니다.  
     */    @AfterAll  
    static void afterAll() {  
        mysql.stop();  
    }  

    /**  
     * 5. 통합 테스트에서 동적으로 데이터베이스 관련 프로퍼티를 정의합니다. 이를 통해, JPA가 어떤 데이터베이스 연결 정보를 알 수 있습니다.  
     * @param registry  
     */  
    @DynamicPropertySource  
    static void configureProperties(DynamicPropertyRegistry registry) {  
        registry.add("spring.datasource.url", mysql::getJdbcUrl);  
        registry.add("spring.datasource.username", mysql::getUsername);  
        registry.add("spring.datasource.password", mysql::getPassword);  
    }  

    @Autowired  
    StudentRepository studentRepository;  

    /**  
     * 6. 각 테스트 수행 전, 학생 테이블을 초기화합니다.  
     */    @BeforeEach  
    void setUp() {  
        RestAssured.baseURI = "http://localhost:" + port;  
        studentRepository.deleteAll();  
    }  

    /**  
     * 7. 예시 데이터를 입력 후, API 테스트를 수행합니다.  
     */    @Test  
    void shouldGetAllStudents() {  
        List<Student> customers = List.of(  
                new Student("foo", 11, "john@mail.com"),  
                new Student("bar", 12, "dennis@mail.com")  
        );  
        studentRepository.saveAll(customers);  

        given()  
                .contentType(ContentType.JSON)  
                .when()  
                .get("/student/all")  
                .then()  
                .statusCode(200)  
                .body(".", hasSize(2));  
    }  
}

테스트 코드 실행

이제 테스트 코드를 실행하면 아래와 같이 컨테이너가 실행되는 모습을 볼 수 있다.

컨테이너 재활용하기

만약 위와 같은 테스트 코드가 여러개라면 어떨까? 각 테스트 클래스가 실행될 때마다 컨테이너를 시작하고 종료하기를 반복할 것이다. 이는 테스트의 멱등성을 보장하지만, 똑같은 환경에서 읽기만 수행하는 테스트가 여러개 있을 경우에는 하나의 컨테이너만 사용해도 된다. 하나의 컨테이너만 사용하면 컨테이너를 시작하고 종료하는 시간을 줄일 수 있기 때문에 매우 효과적이다.

설정

컨테이너 재사용을 위해서는 .testcontainers.properties파일 설정이 필요하다. 이 파일은 MAC OS 기준으로 /Users/<사용자명>/.testcontainers.properties에 위치해 있다. 여기에 testcontainers.reuse.enable=true를 추가해주면 된다.

코드 수정

테스트를 위해 StudentControllerTest와 똑같은 클래스 StudentControllerTest2, StudentControllerTest3을 만들어보자.

새로 만든 코드에서 afterAll 메서드를 삭제하고 컨테이너를 생성하는 코드를 다음과 같이 수정하자.

static MySQLContainer<?> mysql = new MySQLContainer<>(  
        "mysql:8.0.35"  
).withReuse(true);

이렇게하고 테스트 코드를 수행하면 아래 로그처럼 컨테이너를 재활용하는 것을 확인할 수 있다!

2023-11-14T15:44:34.462+09:00  INFO 69374 --- [    Test worker] tc.mysql:8.0.35                          : Reusing container with ID: 81a0dea4b23a4239dfad1b4d2c587d118a98d2bcf463155479563ebfc6c2e608 and hash: 97a15af525be79af393ee3c604797b631872c409
2023-11-14T15:44:34.463+09:00  INFO 69374 --- [    Test worker] tc.mysql:8.0.35                          : Reusing existing container (81a0dea4b23a4239dfad1b4d2c587d118a98d2bcf463155479563ebfc6c2e608) and not creating a new one

Reference

  1. 시작 가이드, https://testcontainers.com/guides/testing-spring-boot-rest-api-using-testcontainers/
  2. General Container runtime requirements, https://java.testcontainers.org/supported_docker_environment/

서론 (Introduction)

Java에서 JDBC를 이용해 MySQL에 연결하는 방법을 알아보고, MySQL 드라이버의 연결과정에 대해서 알아보자

 

본문 (Body)

1. MySQL을 실행한다.
아래 Docker Compose를 이용하면 localhost:3306을 통해서 mysql에 접속이 가능하다. 물론 도커가 아니라 직접 설치해도 된다.

  
services:  
  
  db:  
    image: mysql:8.2.0  
    command: --default-authentication-plugin=mysql_native_password  
    restart: always  
    environment:  
      MYSQL_ROOT_PASSWORD: example  
      MYSQL_DATABASE: testDB  
    ports:  
      - 3306:3306


2. gradle 종속성에 mysql JDBC connector 라이브러리를 추가한다.

이렇게 하면 어플리케이션을 시작할 때, MySQL JDBC 라이브러리의 클래스가 등록된다.

dependencies {  
    implementation 'org.springframework.boot:spring-boot-starter'  
    runtimeOnly 'com.mysql:mysql-connector-j:8.2.0'  
    testImplementation 'org.springframework.boot:spring-boot-starter-test'  
}


2. 테스트 코드 작성

데이터베이스 연결 테스트를 위한 코드를 작성한다.

import org.junit.jupiter.api.Test;  
import org.springframework.boot.test.context.SpringBootTest;  
  
import java.sql.Connection;  
import java.sql.DriverManager;  
import java.sql.SQLException;  
  
@SpringBootTest  
public class ConnectionTest {  
    @Test  
    void test(){  
        Connection connection=null;  
        try {  
             connection = DriverManager.getConnection("jdbc:mysql://localhost:3306/testDB", "root", "example");  
            Class<? extends Connection> aClass = connection.getClass();  
            System.out.println("connection = " + connection);  
            System.out.println("aClass = " + aClass);  
        } catch (SQLException e) {  
            throw new RuntimeException(e);  
        }  
        finally {  
            if(connection!=null){  
                try {  
                    connection.close();  
                } catch (SQLException e) {  
                    throw new RuntimeException(e);  
                }  
            }  
        }  
    }  
}


3. 결과

실행 결과를 보면, 커넥션 인스턴스가 생성되었고 그 클래스는 com.mysql.cj.jdbc.ConnectionImpl 인 것을 볼 수 있다.

connection = com.mysql.cj.jdbc.ConnectionImpl@1f916219
aClass = class com.mysql.cj.jdbc.ConnectionImpl

4. 분석
DriverManager.getConnection는 다음과 같이 정의되어있다.

@CallerSensitive  
public static Connection getConnection(String url,  
    String user, String password) throws SQLException {  
    java.util.Properties info = new java.util.Properties();  
  
    if (user != null) {  
        info.put("user", user);  
    }  
    if (password != null) {  
        info.put("password", password);  
    }  
  
    return (getConnection(url, info, Reflection.getCallerClass()));  
}


아래 getConnection을 좀 더 들어가보면 registeredDrivers에 있는 드라이버들에 접속 시도를 하는 것을 볼 수 있다. 여기까지 보면 java.sql.connection.DriverManager는 자신한테 등록되어 있는 드라이버들에서 하나씩 조회 시도를 하고 성공한 경우를 반환하는 것을 볼 수 있다. 그렇다면 MySQL 드라이버는 언제 등록되는 걸까?

private static Connection getConnection(
//...
for (DriverInfo aDriver : registeredDrivers) {  
    // If the caller does not have permission to load the driver then  
    // skip it.    if (isDriverAllowed(aDriver.driver, callerCL)) {  
        try {  
            println("    trying " + aDriver.driver.getClass().getName());  
            Connection con = aDriver.driver.connect(url, info);  
            if (con != null) {  
                // Success!  
                println("getConnection returning " + aDriver.driver.getClass().getName());  
                return (con);  
            }  
        } catch (SQLException ex) {  
            if (reason == null) {  
                reason = ex;  
            }  
        }  
  
    } else {  
        println("    skipping: " + aDriver.driver.getClass().getName());  
    }  
  
}
//...
}


아래 com.mysql.cj.jdbc.Driver 파일을 보면 static 선언을 통해서 클래스 로드시 자기 자신을 등록하고 있는 모습을 볼 수 있다. 즉, gradle에 라이브러리를 추가하고 코드를 실행시킬 때 MySQL 드라이버가 java.sql.DriverManager에 등록되며, DriverManager.getConnection 호출 시 MySQL 드라이버를 가져와 연결을 시도할 수 있다.

```java
package com.mysql.cj.jdbc; 
  
import java.sql.SQLException;  
  
/**  
 * The Java SQL framework allows for multiple database drivers. Each driver should supply a class that implements the Driver interface. * * <p>  
 * The DriverManager will try to load as many drivers as it can find and then for any given connection request, it will ask each driver in turn to try to  
 * connect to the target URL. * * <p>  
 * It is strongly recommended that each Driver class should be small and standalone so that the Driver class can be loaded and queried without bringing in vast  
 * quantities of supporting code. * * <p>  
 * When a Driver class is loaded, it should create an instance of itself and register it with the DriverManager. This means that a user can load and register a  
 * driver by doing Class.forName("foo.bar.Driver"). */
 public class Driver extends NonRegisteringDriver implements java.sql.Driver {  
  
    // Register ourselves with the DriverManager.  
    static {  
        try {  
            java.sql.DriverManager.registerDriver(new Driver());  
        } catch (SQLException E) {  
            throw new RuntimeException("Can't register driver!");  
        }  
    }  
  
    /**  
     * Construct a new driver and register it with DriverManager     *     * @throws SQLException  
     *             if a database error occurs.     */    public Driver() throws SQLException {  
        // Required for Class.forName().newInstance().  
    }  
  
}

 

결론 (Conclusion)

지금까지 JDBC를 이용해 MySQ에 접속하는 과정에 대해서 알아보았다. 뜻밖의 수확으로, MySQL에서 드라이버를 등록할 때 static 방식으로 진행한다는 건데 이런 용법은 처음보아 다른 코드에 유용하게 활용할 수 있을 것 같다.

 

현재 Arc browser를 이용해 번역을 사용하려고 하면 영어로만 번역하려고 한다. 다음 절차를 통해 한국어로 번역하도록 수정 할 수 있다.

 

1. 언어 설정 페이지 이동

command + L 을 입력하고 arc://settings/lanugages 를 입력한다.

2. 한국어 추가

Add languages 버튼 클릭 후 다음과 같이 한국어를 추가한다.

3. 번역 언어 설정

아래와 같이 한국어를 번역어로 설정한다.

4. 확인

아래와 같이 한국어로 번역대상이 설정된 경우 성공이다.

서론 (Introduction)

QueryDSL은 ORM을 사용하다 보면 언젠가는 사용하게 될 기술 중 하나입니다. 이를 설정하는 법을 구글링해보면 표준적인 설정 방법이 없기 때문에 각기 다른 방법을 제시하고 그만큼 설정에 에러를 많이 겪는 경우가 보입니다. 여기에서는 자바 버전에 따라 가장 간단하게 QueryDSL을 설정하는 방법을 알아보고, 각 설정의 의미에 대해서 서술해보도록 하겠습니다.

본문 (Body)


간단한 QueryDSL 원리

설정에 대해 알아보기전, 간단하게 QueryDSL의 원리에 대해 알면 설정을 이해하기 쉽습니다. 만약, 빠르게 설정만 하고 싶다면 아래 (QueryDSL 설정)를 바로 참고하시기 바랍니다.

 

QueryDSL은 "쿼리를 안전하고 쉽게 하기 위한 도메인 특화언어(DSL, Domain Specific Language)"입니다. 이를 위해선, 기존 자바코드를 바탕으로 JPA, MongoDB 같은 도메인에 맞는 언어를 생성하는 과정이 필요합니다. QueryDSL은 다음 과정을 통해 도메인 언어를 생성합니다.

 

1. compileJava 태스크를 실행합니다.

2. @Entity와 같이 쿼리와 관련된 어노테이션과 클래스를 스캔합니다.

3. 스캔한 클래스를 활용해 도메인에 맞는 언어를 생성합니다. (ex. Student.class ->  QStudent.class)

 

이제 개발자는 QueryDSL이 생성한 언어를 이용해 자바언어를 토대로 쿼리를 작성할 수 있습니다.


QueryDSL 설정

QueryDSL은 다음 코드를 build.gradle 파일에 추가해 설정할 수 있습니다.

dependencies{
    //Java EE를 사용하는 경우, (javax 패키지를 참조할 수 있는 경우)
    implementation 'com.querydsl:querydsl-jpa:5.0.0'
    annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jpa'
    annotationProcessor 'javax.persistence:javax.persistence-api:2.2.0'

    
    //Jakarta EE를 사용하는 경우, (jakarta 패키지를 참조할 수 있는 경우)
    implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
    annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta'
    annotationProcessor 'jakarta.persistence:jakarta.persistence-api:3.1.0'
    
 }
 
 ...
 
 task deleteGenerated(type: Delete) {
    delete "src/main/generated"
}

clean.dependsOn deleteGenerated

 

각 설정의 이유에 대해서 설명해보겠습니다. 

 

먼저, 아래 의존성은 queryDSL에서 쿼리 기능을 구현하는데 필요한 클래스를 불러오기 위해 사용됩니다.

jakarta 의 경우 기존에 javax.persistence.* 로 선언된 패키지들이  jakarta.persistence.* 로 변경되었기 때문에, 올바른 참조를 위해 추가로 선언한 필요가 있습니다.

implementation 'com.querydsl:querydsl-jpa:5.0.0'

annotationProcessor는 Gradle에서 컴파일 타임에 어노테이션을 처리해 코드를 생성 또는 검증하는데 사용하기 위한 키워드입니다. APT는 Annotation Processing Tool의 약자입니다. 즉, querydsl에서 어노테이션 처리를 위한 기능을 가져오기 위해 사용됩니다.

이 또한, jakarta 의 경우 기존에 javax.persistence.* 로 선언된 패키지들이  jakarta.persistence.* 로 변경되었기 때문에, 올바른 참조를 위해 추가로 선언한 필요가 있습니다.

annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jpa'

@Entity와 같은 기본 어노테이션들은 자바에서 표준적으로 정의되어 있습니다. 따라서 각 환경에 따라 필요한 정의를 annotationProcessor를 사용해 정의할 필요가 있습니다.

 

annotationProcessor 'javax.persistence:javax.persistence-api:2.2.0'
annotationProcessor 'jakarta.persistence:jakarta.persistence-api:3.1.0'


QueryDSL 사용

위 설정이 올바르게 완료되면, compileJava 라는 태스크를 실행할 수 있습니다. ./gradlew compileJava 커맨드를 이용해 태스크를 실행하면, 위에서 설명한 DSL 생성과정이 진행됩니다.

2. @Entity와 같이 쿼리와 관련된 어노테이션과 클래스를 스캔합니다.
3. 스캔한 클래스를 활용해 도메인에 맞는 언어를 생성합니다. (ex. Student.class ->  QStudent.class)

그럼 이제 queryDSL 문법에 따라 설정이 가능합니다!

 

 

서론 (Introduction)

JPA를 사용하면 대표적인 구현체인 HIbernate를 사용하게 됩니다. HIbernate는 SQL 쿼리를 생성하주는 기능을 가지고 있는데, MySQL, Oracle 등 데이터베이스 마다 문법이 조금씩 다르기 때문에 Dialect(방언)을 설정해 주어야합니다. 이번 글에서는 Oracle 데이터베이스를 위한 Dialect 설정에 대해서 다루어보도록 하겠습니다.

 

본문 (Body)

a. org.hibernate.dialect

Hibernate에서는 데이터베이스 방언들을 org.hibernate.dialect 패키지에 정리하고 있습니다. 이 패키지의 내용은 hibernate 버전이 변경되면서 조금씩 바뀌어왔습니다. 여기에서는 이전 버전과 현재 버전의 dialect 적용에 대해서 알아보도록 하겠습니다.


B. 구버전의 Dialect

dialect 관련된 내용을 검색하면, 아래와 같이 데이터베이스 버전 별로 dialect를 설정하는 방법에 대한 질문이 많습니다. 답변을 보면 oracle 데이터베이스의 경우 버전 별로 Oracle10gDialect, Oracle12cDialect dialect를 설정하라고 하고 있습니다. 하지만 현재 hibernate 최근 버전(6.2.9-Final)에서는 이를 사용할 경우 에러가 발생합니다.


C. 최근 버전의 Dialect

최근 버전의 Dialect에서는 버전별로 각각의 Dialect를 사용하는 대신 통합된 Dialect를 사용하려는 경향을 보이고 있습니다. 따라서 이전에 사용된 버전별 Dialect는 Deprecated 처리되어 있는 것을 찾을 수 있습니다.

 따라서 OracleDialect를 사용하기 위해선 hibernate.dialect를 다음과 같이 설정해야합니다.

spring:
  jpa:
    properties:
      hibernate:
        dialect: org.hibernate.dialect.OracleDialect

결론 (Conclusion)

평소에 개발하듯이 모르는게 생기면 구글링을 했는데, 이번에는 예전 버전을 기준으로 작성된 답변이나 블로그 글이 많아 개발을 하는데 많이 해매게 되었습니다. 역시 가장 확실한 건 직접 코드를 확인하는 것인 것 같습니다. 다른 분들은 저처럼 잘못된 답변으로 해매지말고 이 글로 명쾌하게 hibernate.dialect 를 설정할 수 있기를 바랍니다.

 

참조(Reference)

1. https://docs.jboss.org/hibernate/orm/6.3/userguide/html_single/Hibernate_User_Guide.html#database-dialect

서론 (Introduction)

JPA를 사용한 스프링 프로젝트를 기존에 존재하던 오라클 데이터베이스에 접속하자 JPA - Value '0000-00-00' can not be represented as java.sql.Date 에러가 발생했습니다. 이 에러가 발생한 원인과 해결과정에 대해서 다루어보겠습니다.


원인 파악 (Diagnosis)

먼저 상황을 재현해보도록 하겠습니다. 아래와 같이 LocalDate 타입을 가진 필드를 선언한 엔티티를 이용해 데이터베이스에서 데이터를 가져오는 과정에서 에러가 발생했습니다. 날짜가 포함된 컬럼에 '0000-00-00' 데이터를 가진 튜플이 있었는데, 이 튜블을 데이터베이스에서 조회해 클래스로 변환하는 과정에서 에러가 발생했습니다.

@Entity(name = "period_entity")
public class PeriodEntity {
    @Id
    Long id;
    Date startDate;
    Date endDate;
}


해결 과정 (Solution)

1. zeroDateTimeBehavior=convertToNull

구글링으로 해결방법을 검색한 결과, 대표적인 해결방법은 zeroDateTimeBehavior=convertToNull 를 다음과 같이 JDBC URL에 추가하는 것이었습니다. 이렇게 하면 데이터가 0000-00-00 값일때 JDBC 가 null을 대신 반환하도록 합니다. 이 방법은 간단하고 명확하게 에러를 해결하지만, 단점도 명확합니다. 이 방법은 모든 데이터베이스에 적용되는 것도 아니며, 0000-00-00 이라는 값을 null이 아닌 최소 시간으로 취급하는 경우에는 사용할 수 없습니다. 

 

2. AttributeConvert

제가 선택한 방법은 직접 변환과정을 설정하는 것이었습니다. AttributeConvert는 데이터베이스에서 읽은 데이터를 엔티티 필드와 데이터베이스 값 사이를 변환하는데 사용되는 인터페이스입니다. 아래와 같이 0000-00-00 값 일때 최소 값으로 변경함으로써 에러를 해결할 수 있습니다. 이 방법은 zeroDateTimeBehavior=convertToNull 와 달리 모든 데이터베이스에 적용할 수 있으며, 원하는 값으로 변경하 할 수 있습니다. 다음은 0000-00-00 값을 최소 시간으로 변경하는 코드입니다.

@Converter(autoApply = true)
public class ZeroDateConverter implements AttributeConverter<LocalDate,String> {

    public static final String ZERO_DATE_STRING = "0000-00-00";

    @Override
    public String convertToDatabaseColumn(LocalDate attribute) {
        return attribute.format(DateTimeFormatter.ISO_DATE_TIME);
    }

    @Override
    public LocalDate convertToEntityAttribute(String dbData) {
        if(dbData==null || dbData.equals(ZERO_DATE_STRING)){
            return LocalDate.MIN;
        }
        return LocalDate.parse(dbData,DateTimeFormatter.ISO_DATE_TIME);
    }
}


결론 (Conclusion)

지금까지 JPA를 사용하는 과정에서 0000-00-00 값을 변환하는 에러를 확인하고 이를 해결하는 과정을 살펴보았습니다. 저는 회사에서 기존에 운영하던 개발 데이터베이스에 접근하는 과정에서 위와 같은 에러가 발생했습니다. 만약 혼자만 사용하는 데이터베이스라면 0000-00-00 값을 파싱가능한 다른 값으로 변경하는 것도 좋겠지만, 하나의 디비에 여러 서비스가 물려있는 경우에는 적용할 수 없기에 위와 같은 솔루션을 사용하게 되었습니다.

 

 

상황

Spring MessageSource를 사용해 messages.properties 에서 메시지를 읽는 과정에서 한글이 아래와 같이 깨지는 상황이 생겼다.

분석

문자를 인코딩하는 과정에서 발생한 에러라고 생각되어 message.properties 파일의 인코딩을 확인한 결과 ISO-8859-1 이었지만, MessageSource는 UTF-8으로 읽어 문자열 형식이 일치하지 않는 것을 확인했다.

해결


IntelliJ에서 파일 인코딩할 때, UTF-8을 사용하도록 설정하니 한글 깨짐이 해결되었다.

목차

  1. 소개
  2. $UnionWith의 지원 버전 확인
  3. 네이티브 쿼리를 이용한 Union 연산
    • 네이티브 쿼리 실행 방법
    • MongoCollection.aggregate를 사용한 네이티브 쿼리 실행
  4. 코드 예시
    • MongoClient 설정
    • MongoCollection 가져오기
    • 매치 쿼리 생성
    • 매치 쿼리를 List<Document>로 변환
    • $unionWith로 쿼리 감싸기
    • 집계 연산 쿼리 추가
    • 최종 쿼리 실행

1. 소개

MongoDB 4.4부터 SQL의 Union all과 유사한 연산인 $UnionWith를 지원합니다. 이 기능은 데이터베이스의 여러 컬렉션을 합치는 데 사용됩니다. 그러나 Spring Data MongoDB의 최신 버전인 spring-boot-start-data-mongodb:3.0.7은 spring-data-mongodb:4.0.6에 의존하고 있어 $UnionWith와 관련된 기능을 사용할 수 없습니다.

따라서 네이티브 쿼리를 사용해야 합니다. 네이티브 쿼리는 MongoDatabase 인터페이스의 runCommand 메서드나 MongoCollectionaggregate 메서드를 사용하여 실행할 수 있습니다. 이를 통해 네이티브 쿼리 요청을 MongoDB 쿼리로 전달할 수 있습니다.

이 글에서는 MongoCollectionaggregate 메서드를 사용하여 네이티브 쿼리를 실행하는 방법을 알아보겠습니다.

2. $UnionWith의 지원 버전 확인

$UnionWith 연산을 사용하기 전에 MongoDB 서버의 버전을 확인해야 합니다. $UnionWith는 MongoDB 4.4 이상에서 지원되므로 해당 버전 이상이어야 합니다.

3. 네이티브 쿼리를 이용한 Union 연산

3.1. 네이티브 쿼리 실행 방법

네이티브 쿼리를 실행하는 방법은 MongoDatabaserunCommand 메서드나 MongoCollectionaggregate 메서드를 사용하는 것입니다. 이 중 MongoCollection.aggregate를 사용하여 네이티브 쿼리를 실행해보겠습니다.

3.2. MongoCollection.aggregate를 사용한 네이티브 쿼리 실행

MongoTemplate에서 사용할 컬렉션을 지정하여 MongoCollection 인터페이스의 객체를 가져옵니다. 그런 다음, 네이티브 쿼리를 작성하고 List<Document> 형식으로 변환한 후, MongoCollection.aggregate(List<Document>) 메서드에 전달하여 쿼리를 실행합니다.

4. 코드 예시

아래는 Spring Data MongoDB에서 Union 연산을 수행하는 예시 코드입니다.

4.1. MongoClient 설정

먼저, MongoClient를 생성하여 Spring Data MongoDB가 접근해야 할 데이터베이스를 확인합니다.

@Configuration
public class MongoConfig {

    @Bean
    public MongoClient mongoClient() {
        ConnectionString connectionString = new ConnectionString("mongodb://localhost:27017/test");
        MongoClientSettings mongoClientSettings = MongoClientSettings.builder()
            .applyConnectionString(connectionString)
            .build();

        return MongoClients.create(mongoClientSettings);
    }
}

4.2. MongoCollection 가져오기

MongoTemplate에서 사용할 컬렉션을 지정하여 MongoCollection 인터페이스 객체를 가져옵니다.

mongoTemplate.getDb().getCollection(COLLECTION_NAME)

4.3. 매치 쿼리 생성

Union에 사용할 매치 쿼리를 생성하는 메서드를 작성합니다.

private List<String> generateMatchQuery(List<Target> targets) {
    return targets.stream().map(target -> {
        return "{\n"
            + "  $match :{\n"
            + "    where : \"" + target.getPageUrl() + "\",\n"
            + "    \"what.uniqueSelector\" : \"" + target.getHtmlElement() + "\",\n"
            + "    when : {\n"
            + "      $gte : ISODate(\"" + target.getApplyStart() + "\"),\n"
            + "      $lte : ISODate(\"" + target.getApplyEnd() + "\"),\n"
            + "    },\n"
            + "    \"what.eventType\" : \"" + target.getEventType() + "\"\n"
            + "  }\n"
            + "}";
    }).toList();
}

4.4. 매치 쿼리를 List<Document>로 변환

생성된 여러 개의 매치 쿼리를 바탕으로 List<Document>를 생성합니다. 첫 번째 쿼리는 그대로 추가하고, 나머지 쿼리는 $unionWith로 감싸서 리스트에 추가합니다.

private List<Document> convert(List<Target> targets){
    if(targets.size()==0){
        throw new AnalyzeServiceException();
    }
    List<String> matchQuery= generateMatchQuery(targets);
    List<Document> documents=new ArrayList<>();
    documents.add(Document.parse(matchQuery.get(0)));
    for(int idx=1;idx<targets.size();idx++){
        documents.add(Document.parse(wrapUnionCommand(matchQuery.get(idx))));
    }
    return documents;
}

private String wrapUnionCommand(String query){
    return "{ $unionWith : { coll : \""+COLLECTION_NAME+"\", pipeline: ["
        +query+"]}}";
}

4.5. 집계 연산 쿼리 추가

중복 데이터를 제거하기 위해 집계 연산 쿼리를 추가하는 기능을 구현합니다. $unionWith 쿼리는 중복 데이터를 제거하지

않으므로 집계 연산을 추가하여 중복을 제거해야 합니다.

public String query(List<Document> convertResult, String aggregationQueryJson) {
    List<Document> fullQuery= new ArrayList<>(convertResult.size()+2);
    convertResult.forEach(document -> fullQuery.add(document));
    fullQuery.add(Document.parse("{ $group: {_id: \"$$ROOT\"} }"));
    fullQuery.add(Document.parse(aggregationQueryJson));
    return convert(runAggregate(fullQuery));
}

이제 네이티브 String 쿼리를 List<Document> 형식으로 변환하는 과정을 마쳤습니다. 이를 MongoCollection.aggregate(List<Document>)에 전달하여 Union 연산을 수행할 수 있습니다.

목차

소개

이 글은 Gitlab에 러너를 등록하고 설정하는 방법에 대해 안내합니다. Gitlab의 CI/CD를 활용하기 위해서는 러너를 등록해야 하며, 이를 통해 자동화된 빌드, 테스트, 배포 등의 작업을 실행할 수 있습니다.

러너 등록하기

  1. Gitlab 러너 설치 문서를 참고하여 해당 환경에 맞게 러너를 설치합니다.
  2. Gitlab 레포지토리로 이동하여 “Settings” 메뉴에서 “CI/CD” 탭으로 이동합니다.
  3. “Runner” 항목에서 등록할 레포지토리 URL 및 등록 토큰을 확인합니다.
  4. 터미널에서 gitlab-runner register 명령을 실행합니다.
  5. URL과 토큰을 입력하고, executor는 원하는 설정으로 선택합니다. Docker를 사용하는 것을 추천합니다.
  6. 기본 이미지로 ubuntu:latest를 설정합니다.
  7. gitlab-runner verify 명령을 실행하여 러너가 정상적으로 등록되었는지 확인합니다.
  8. gitlab-runner start 명령을 실행하여 러너 서비스를 시작합니다.
  9. 다시 Gitlab 레포지토리의 “Settings” 메뉴에서 “CI/CD” 탭으로 이동합니다.
  10. 초록색 불이 나타나는지 확인합니다. 성공적으로 등록된 경우 아래 이미지와 같은 화면이 표시됩니다.
    Runner Configuration
  11. “Edit” 버튼을 클릭하여 러너를 편집합니다.
  12. “Run untagged jobs” 옵션을 선택합니다. 이 옵션은 태그가 없는 작업도 실행할 수 있도록 해줍니다.

러너 구성하기

  1. 레포지토리의 루트 디렉토리에서 .gitlab-ci.yml 파일을 생성합니다.
  2. 다음 코드를 입력하여 테스트 작업을 실행해보세요.
stages:
  - deploy

deploy:
  stage: deploy
  script:
    - echo 'hello runner'
  only:
    - main
  1. 위 코드는 간단한 테스트용 작업을 실행하는 것입니다. 다음과 같은 결과가 나타나면 성공입니다.
    Runner Test

문제 해결

만약 러너가 정상적으로 작동하지 않는다면, 다음 단계를 다시 확인해보세요:

  • Gitlab 러너 설치 문서를 참고하여 올바르게 설치되었는지 확인합니다.
  • 등록할 레포지토리 URL 및 토큰을 정확하게 입력했는지 확인합니다.
  • 편집한 러너 구성이 올바른지 다시 확인합니다.

이 글을 통해 Gitlab에 러너를 등록하고 설정하는 방법에 대해 알아보았습니다. Gitlab의 CI/CD를 효율적으로 활용하여 개발 작업을 자동화할 수 있습니다.

1. 서버 구조

  1. 사설 인터넷망에 3개의 클러스터용 노드가 있습니다.
  2. 사설 인터넷망에 1개의 프로젝트 배포용 노드가 있습니다.

2. 앤서블 설정하기

2.1 배포 서버 DNS 설정하기

배포 서버를 정의하기 위해 /etc/hosts 파일에 호스트를 설정해야 합니다. 호스트 설정은 각 운영 체제마다 다를 수 있습니다. 리눅스 기반 시스템에서는 다음과 같이 /etc/hosts 파일을 수정합니다:

//... 10.12.204.13 peter-zk01.foo.bar 10.12.204.13 peter-kafka01.foo.bar 10.12.204.14 peter-zk02.foo.bar 10.12.204.14 peter-kafka02.foo.bar 10.12.204.17 peter-zk03.foo.bar 10.12.204.17 peter-kafka03.foo.bar 

2.2 SSH 허용하기

앤서블을 사용하여 서버에 접속할 때마다 비밀번호를 입력하는 것은 번거로울 수 있습니다. 따라서 배포 서버에 대한 SSH 접속을 허용하는 것이 좋습니다.

2.2.1 SSH 키 생성

ssh-keygen 명령을 사용하여 SSH 키를 생성합니다. 생성된 키 데이터는 ~/.ssh/id_rsa.pub 파일에 저장됩니다. 다음 단계에서 이 키 데이터를 사용하게 됩니다.

  1. ssh-keygen 명령을 실행하여 키를 생성합니다.
  2. 생성된 데이터를 ~/.ssh/id_rsa.pub 파일로부터 복사합니다.
  3. SSH로 접속하고자 하는 서버에 접속합니다.
  4. vi ~/.ssh/authorized_keys 명령을 사용하여 인증 키 파일을 수정합니다.
  5. 새로운 라인에 이전에 복사한 ~/.ssh/id_rsa.pub 데이터를 붙여넣습니다.
  6. chmod 600 ~/.ssh/authorized_keys 명령을 실행하여 권한을 설정합니다.
  7. 이제 로컬에서 ssh 명령을 사용하여 비밀번호 없이 서버에 접속할 수 있습니다.

2.3 앤서블 스크립트 다운로드

다음 명령을 사용하여 앤서블 스크립트를 다운로드합니다:

git clone https://github.com/onlybooks/kafka2 

그리고 kafka2/chapter2/ansible_playbook 디렉토리로 이동합니다.

hosts 파일을 다음과 같이 수정합니다. 보통 사용자는 root를 사용하므로 ansible_user를 root로 설정합니다:

[zkhosts] peter-zk01.foo.bar peter-zk02.foo.bar peter-zk03.foo.bar  [kafkahosts] peter-kafka01.foo.bar peter-kafka02.foo.bar peter-kafka03.foo.bar  [kerberoshosts] peter-zk01.foo.bar  [all:vars] ansible_user=root 

2.4 주키퍼 클러스터 설치하기

다음 명령을 사용하여 주키퍼 클러스터를 설치합니다:

ansible-playbook -i hosts zookeeper.yml 

Kafka ZooKeeper

2.5 카프카 클러스터 설치하기

다음 명령을 사용하여 카프카 클러스터를 설치합니다:

ansible-playbook -i hosts kafka.yml 

Kafka Cluster Installation

카프카는 실행되지만 내부적으로 주키퍼와의 연결을 실패하여 일정 시간 후에 타임아웃으로 종료될 수 있습니다. 서버에서 systemctl status kafka-server 명령을 실행하여 아무런 에러가 없는지 확인하면 성공입니다.

2.6 카프카 테스트하기

카프카가 올바르게 작동하는지 확인하기 위해 토픽을 생성하고 샘플 데이터를 프로듀스하고 컨슈밍해보겠습니다.

2.6.1 토픽 생성하기

서버에 접속하여 다음 명령을 실행합니다:

/usr/local/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --create --topic test-topic --partitions 1 --replication-factor 3 

출력으로 다음과 같이 "Created topic test-topic."이 나오면 성공입니다.

2.6.2 메시지 전송하기

샘플 메시지를 프로듀스해보겠습니다. 다음 명령을 실행하세요:

/usr/local/kafka/bin/kafka-console-producer.sh --bootstrap-server localhost:9092 --topic test-topic 

> 기호가 나타나면 원하는 메시지를 입력하고 엔터 키를 눌러 메시지를 전송할 수 있습니다.

2.6.3 메시지 수신하기

2번 서버에서 메시지를 수신해보겠습니다. 다음 명령을 실행하세요:

/usr/local/kafka/bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic test-topic 

이제 이전에 실행한 프로듀서에서 테스트 메시지를 입력하면 출력으로 확인할 수 있습니다.

Kafka Consumer Output

 

Kafka Producer Output

2.7 스프링 프로젝트와 연결하기

스프링 프로젝트와 카프카 프로젝트를 연결해보겠습니다.

  1. 먼저 리액터 카프카 패키지를 설치합니다:
implementation 'io.projectreactor.kafka:reactor-kafka:1.3.18' 
  1. application.yml 파일에 다음과 같이 카프카 서버에 대한 정보를 설정합니다:
spring:   kafka:     bootstrap-servers: peter-kafka01.foo.bar:9092,peter-kafka02.foo.bar:9092,peter-kafka02.foo.bar:9092 
  1. 프로젝트를 실행하고 다음 로그가 출력되면 그룹에 성공적으로 조인했음을 의미합니다:
2023-07-12T17:59:46.090+09:00  INFO 3367 --- [y-trace-group-1] o.a.k.c.c.internals.ConsumerCoordinator  : [Consumer clientId=consumer-user-activity-trace-group-1, groupId=user-activity-trace-group] Successfully joined group with generation Generation{generationId=1, memberId='consumer-user-activity-trace-group-1-67094391-d771-4ec8-ba5e-d56cdf0e8083', protocol='range'} 

이제 앤서블을 사용하여 카프카 클러스터를 구축하고 테스트하는 방법에 대해 알게 되었습니다. 카프카를 사용하여 안정적이고 확장 가능한 메시지 시스템을 구축할 수 있습니다.

들어가며

리액티브 프로그래밍은 현대의 수요 증가와 불확실성에 대응하기 위한 새로운 소프트웨어 아키텍처와 프로그래밍 기법입니다. 이 글에서는 리액티브 프로그래밍의 필요성에 대해 다루고, 리액티브 시스템의 기본 원리, 적합한 비즈니스 사례, 적합한 프로그래밍 기술, 그리고 스프링이 리액티브로 전환되는 이유에 대해 알아보겠습니다.

책의 내용을 바탕으로 알아보는 과정에서 부족하거나 이해가 안되는 설명의 경우에는 직접 찾은 내용을 추가했습니다.

1. 왜 리액티브인가?

우리는 과거에는 톰캣과 같은 서버를 사용하여 웹 요청을 처리했습니다. 하지만 이러한 전통적인 방식은 한계가 있습니다. 예를 들어, 스레드 풀을 구성하고 초당 일정 수의 요청을 처리하는 등의 방식으로 서버를 구성합니다. 그러나 이러한 방식은 대량의 요청이 동시에 발생하는 경우에는 제대로 대응하기 어렵습니다.

예를 들어, 블랙 프라이데이와 같은 특정 이벤트 기간에는 사용자의 요청이 급증하게 됩니다. 이런 경우 스레드 부족으로 인해 요청이 누락될 수 있습니다. 또한, 서버의 부하 부담 능력이 한정되어 있어서 대량의 요청을 처리하기에는 한계가 있습니다.

톰캣은 thread per request 정책을 가지고 있습니다. 따라서 새로운 요청이 들어올 경우 새로운 스레드가 필요합니다. 자바에서는 스레드를 생성하는데 약 1MB의 메모리가 필요합니다. 동시에 수천, 수만개의 요청이 몰릴경우 메모리 부족으로인해 서버가 다운되거나 요청이 누락되어 서비스 장애가 발생할 수 있으므로 대량의 요청을 처리하기에는 한계가 있습니다.

그렇다면 이러한 문제에 대해 어떻게 대응해야 할까요? 수요와 응답에 미칠 수 있는 모든 영향을 고려해야합니다. 즉 어떠한 환경에서도 높은 응답성을 달성할 수 있어야 합니다. 높은 응답성을 달성하기 위해서는 "탄력성"과 "복원력"이 필요합니다.

1.1 탄력성

탄력성이란 사용자의 수요에 따라서 자원을 증가, 감소 시킴으로써 수요에 대응할 수 있는 능력을 뜻합니다. 이를 통해서 사용자의 수요가 많든 적든 일정한 레이턴시를 보장함으로써 사용자의 모든 수요를 충족시킬 수 있습니다. 이는 수요를 모니터링하고 적절하게 자원을 스케일 아웃 또는 스케일 업을 적용함으로써 달성할 수 있습니다.

1.2 복원력

복원력이란 만약 시스템에 장애가 발생했을 때에도 응답성을 유지할 수 있는 능력을 뜻합니다. 이는 두 가지를 통해 달성할 수 있습니다. 첫째로 시스템의 기능 요소를 격리해 장애가 전파되지 않도록해야합니다. 둘째로 복제 데이터베이스 처럼 장애가 발생한 기능 요소를 대체할 수 있는 예비 시스템이 존재해야합니다.

1.3 기존 아키텍쳐의 탄력성 한계

기존 아키텍쳐의 특징은 동기적인(순차적인) 처리 과정이 존재한다는 것입니다. 이는 아무리 인스턴스를 확장해도 처리가능한 한계가 존재한다는 것을 의미합니다. 이는 이론적 관점에서 암달의법칙과 건터의 보편적 확장성 모델로 설명할 수 있습니다.

암달의 법칙은 프로그래밍 요소에 동기적인 부분이 존재하는한 아무리 멀티코어로 병렬화를 진행해도 한계가 존재한다는 것을 의미합니다. 병렬코어를 추가하면 추가할수록 프로그램 처리 속도 증가율을 점점 더 낮아지게 되고 결국 한계에 봉착하게 됩니다.

"Universal scalability model"은 시스템이 대규모로 확장되는 능력을 지칭하는 개념입니다. 이 모델은 시스템이 증가하는 작업 부하나 사용자 수에 따라 선형적으로 확장할 수 있는지 여부를 나타냅니다. 일반적으로 시스템은 사용자나 작업 부하가 증가함에 따라 성능이 저하될 수 있습니다. 이를 해결하기 위해 시스템은 확장 가능한 아키텍처와 설계 원칙을 적용할 수 있습니다. Universal scalability model은 이러한 아키텍처와 원칙을 사용하여 시스템의 성능이 선형적으로 증가할 수 있는 모델을 말합니다.

2.메시지 기반 통신

리액티브 시스템에서는 메시지 기반 통신이 중요한 역할을 합니다. 전통적인 블로킹 방식의 단점을 극복하기 위해 비동기적인 메시지 기반 통신을 사용합니다. 이는 분산 시스템 간의 자원을 효율적으로 사용할 수 있도록 도와줍니다.

메시지 기반 통신은 컴퓨터 시스템 또는 네트워크 간에 데이터를 교환하기 위해 사용되는 통신 방식입니다. 메시지 기반 통신은 송신자가 메시지를 작성하고 수신자에게 보내는 방식으로 동작합니다. 메시지는 일련의 데이터로 구성되며, 송신자는 메시지를 생성하고 목적지 주소와 함께 수신자에게 전송합니다. 수신자는 메시지를 받아들이고 필요한 처리를 수행한 후 응답 메시지를 송신자에게 보낼 수도 있습니다. 메시지 기반 통신은 느슨한 결합(loose coupling)을 제공하므로, 송신자와 수신자 간의 시간적 또는 공간적 제약이 없습니다. 또한, 메시지 큐 등의 중간 매개체를 사용하여 메시지를 저장하고 전달하는 기능을 제공할 수 있습니다. 이는 시스템의 확장성과 유연성을 향상시키는 데 도움이 됩니다.

메시지 기반 통신은 메시지 브로커를 통해 이루어지며, 메시지 대기열을 모니터링하여 부하와 탄력성을 제어합니다. 이를 통해 응답성을 달성하기 위해서는 탄력성과 복원력이 필요하며, 메시지 기반 접근 방식을 채택하여 구성 요소를 독립적으로 격리시킴으로써 유지 보수 및 확장을 용이하게 합니다. 이러한 원칙은 리액티브 선언문의 핵심 개념으로써 사용됩니다.

리액티브 선언문은 높은 응답성을 달성하기 위해 탄력성, 복원력 그리고 메시지 기반 통신의 관계 및 필요성에 대해서 설명한 선언문입니다. 리액티브 아키텍쳐의 기초이기 때문에 직접 읽어보는 것이 좋습니다. https://www.reactivemanifesto.org/

3. 왜 리액티브 스프링인가?

리액티브 프로그래밍은 다양한 플랫폼과 언어에서 사용될 수 있습니다. 그중에서도 JVM 기반의 언어인 자바와 스칼라는 리액티브 프로그래밍을 위한 프레임워크와 라이브러리가 많이 개발되어 있습니다. 스프링의 경우, 리액티브 프로그래밍을 지원하기 위해 스프링 5 버전부터 리액티브 스프링 프레임워크를 도입하였습니다.

스프링 이외의 다른 프레임워크로는 vert.x와 Akka 등이 있습니다. vert.x는 노드를 대체하기 위한 강력한 프레임워크이지만, 스프링보다 연혁이 낮습니다. Akka는 스칼라를 주로 사용하는 프레임워크로서, 자바 개발자에게는 다소 익숙하지 않을 수 있습니다.

4. 서비스 레벨에서의 반응성

스프링을 사용하여 리액티브 시스템을 구현하는 것은 몇 가지 제한이 있습니다. 스프링 클라우드는 몇 가지 문제를 해결하고 분산 시스템의 구축을 단순화해주었습니다. 그러나 명령형 프로그래밍은 여전히 블로킹 작업과 스레드 낭비를 유발할 수 있습니다. 콜백을 사용하면 논블로킹을 구현할 수 있지만, 콜백 지옥과 멀티스레드의 복잡성이 코드에 드러납니다. Java 8의 CompletableFuture는 완전히 비동기적인 처리를 지원하지만, 스프링 4와는 호환되지 않는다는 한계가 있습니다. 스프링에서 제공하는 ListenalbeFuture는 자체적으로 멀티스레딩을 지원하지만, 스레드가 추가될수록 메모리 사용량이 증가하는 단점이 있습니다.

증상

Kubernetes 클러스터에서 Nginx Ingress Controller를 사용하여 서비스를 구성하였습니다. 그런데, 큰 파일을 업로드하려고 할 때마다 다음과 같은 에러가 발생합니다.

413 Request Entity Too Large

이 오류는 클라이언트가 보내는 요청이 서버가 처리할 수 있는 최대 크기를 초과했음을 나타냅니다. 기본적으로 Nginx는 1MB보다 큰 요청을 허용하지 않습니다.

해결 방법

이 문제를 해결하기 위해서는 Nginx Ingress Controller의 설정을 변경하여 최대 요청 크기를 늘려야 합니다. 이는 ingress 스펙을 작성한 yaml 파일에서 쉽게 설정할 수 있습니다.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: test-ingress
  annotations:
    nginx.ingress.kubernetes.io/proxy-body-size: 8m
...

위와 같이 nginx.ingress.kubernetes.io/proxy-body-size 를 설정하면 쉽게 최대 요청 크기를 수정할 수 있습니다.

참조

https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/annotations/#custom-max-body-size

+ Recent posts