<![CDATA[new-pow's space]]>http://localhost:2369/http://localhost:2369/favicon.pngnew-pow's spacehttp://localhost:2369/Ghost 5.62Thu, 07 Dec 2023 07:19:25 GMT60<![CDATA[S3 업로드 비동기 처리✊ : 반환이 있는 @Async를 사용할 때 주의할 것들....]]>이 글은 Secondhand 프로젝트를 하며 트러블 슈팅하고 학습한 내용을 정리한 글입니다.

시작하며

Secondhand에 글을 작성할 때는 최대 10장의 이

]]>
http://localhost:2369/s3-eobrodeu-bidonggi-ceori-banhwani-issneun-asyncreul-sayonghal-ddae-juyihal-geosdeul/6548df72ba758b43a5c0397fMon, 06 Nov 2023 12:48:28 GMT이 글은 Secondhand 프로젝트를 하며 트러블 슈팅하고 학습한 내용을 정리한 글입니다.

시작하며

Secondhand에 글을 작성할 때는 최대 10장의 이미지를 업로드할 수 있습니다.

이 기능을 구현하고 나서 가장 마음에 걸렸던 부분이 있는데요, 바로 이 10장의 이미지가 하나의 스레드에서 동기적으로 처리된다는 것입니다. 꽤 고화질인 이미지를 10장 한번에 처리를 하면 3초정도 소요되기도 합니다 👀....

계속 마음에 걸렸던 코드라 이참에 리팩토링을 해보기로 했습니다.

개선 해보자

기존 로직 및 문제점 분석

원래 코드와 시퀀스 다이어그램을 먼저 공유하자면 다음과 같습니다.

    @Override
    public List<ItemDetailImage> uploadItemDetailImages(List<MultipartFile> request) throws ImageHostException {
        checkFilesSize(request);

        List<ItemDetailImage> images = new ArrayList<>();

        for (MultipartFile multipartFile : request) {
            ImageInfo imageInfo = uploadItemDetailImage(multipartFile);
            images.add(ItemDetailImage.create(imageInfo.getImageUrl()));
        }

        return images;
    }
    
    @Override
    public ImageInfo uploadItemDetailImage(MultipartFile file) throws ImageHostException {
        String imageUrl = "";

        try {
            imageUrl = upload(file, Directory.ITEM_DETAIL);
        } catch (IOException e) {
            throw new ImageHostException("물품 사진 업로드에 실패하였습니다.");
        }

        return ImageInfo.create(imageUrl);
    }
    
    public String upload(MultipartFile file, Directory directory) throws IOException, TooLargeImageException, NotValidImageTypeException {
        checkFileSize(file);
        checkFileType(file);

        String newFileKey = generateKey(file.getOriginalFilename(), directory.getPrefix());
        amazonS3.putObject(new PutObjectRequest(properties.getBucket(), newFileKey, file.getInputStream(), getMetadata(file)));
        return amazonS3.getUrl(properties.getBucket(), newFileKey).toString();
    }

스레드 하나에서 최대 10개의 이미지를 개미처럼 천천히 하나씩 처리하고 있습니다. 🐜🐜🐜🐜🐜🐜🐜🐜🐜🐜...

누가 이렇게 비효율적으로 짰나? 바로 접니다 허허허허....

그리고 이것을 모두 다 처리한 뒤에 섬네일을 처리하므로 S3 업로드로 트리거 발생하는 섬네일용 람다는 더 느리게 작업될 수 밖에 없었습니다.

테스트로 로컬에서 스레드 로그도 찍어보았는데요. 역시나 한개의 스레드로 직렬 처리하고 있었습니다. 깔끔한 이 로직..

DEBUG 9717 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread upload work start: 232, image: 7cdd2eb2-6aa9-42cd-88a9-eb9dbc8e9811-IMG_7857.jpg
DEBUG 9717 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread work end: 232, image: 7cdd2eb2-6aa9-42cd-88a9-eb9dbc8e9811-IMG_7857.jpg
DEBUG 9717 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread upload work start: 232, image: 7cdd2eb2-6aa9-42cd-88a9-eb9dbc8e9811-IMG_7857.jpg
DEBUG 9717 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread work end: 232, image: 7cdd2eb2-6aa9-42cd-88a9-eb9dbc8e9811-IMG_7857.jpg
DEBUG 9717 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread upload work start: 232, image: 8ec282b9-a2c6-46ab-8fe1-ed6499fd6f37-image0.jpeg
DEBUG 9717 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread work end: 232, image: 8ec282b9-a2c6-46ab-8fe1-ed6499fd6f37-image0.jpeg
DEBUG 9717 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread upload work start: 232, image: 68f27e6f-a951-441e-99fc-03cfe37d89c2-2023-05-11-bunny.jpg
DEBUG 9717 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread work end: 232, image: 68f27e6f-a951-441e-99fc-03cfe37d89c2-2023-05-11-bunny.jpg
DEBUG 9717 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread upload work start: 232, image: 115064144.jpeg
DEBUG 9717 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread work end: 232, image: 115064144.jpeg
DEBUG 9717 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread upload work start: 232, image: a9b9f3e6-2fa0-4737-9d42-25dc31a82a2c-image0.jpeg
DEBUG 9717 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread work end: 232, image: a9b9f3e6-2fa0-4737-9d42-25dc31a82a2c-image0.jpeg
DEBUG 9717 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread upload work start: 232, image: maxim-abramov-s7vgZ1Prn2M-unsplash.jpg
DEBUG 9717 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread work end: 232, image: maxim-abramov-s7vgZ1Prn2M-unsplash.jpg

마법의 어노테이션 @Async을 붙이면 어떻게 될까?

흔히들 동기 작업을 하기 위해서는 어노테이션 @Async를 붙여주곤 합니다. 그럼 해결 될까요?

위 코드에서 `upload()`에 @Async 를 붙이고 AsyncConfig 를 만들어줍니다. 그리고 기대하며 다시 테스트를 해보았지만...

여전히 동일한 스레드에서 동기로 실행됩니다. 심지어 스레드 그룹을 지정해주었음에도 해당 그룹에서 스레드를 가져오지도 않고 있습니다.

2023-11-05 22:18:39.177 DEBUG 10387 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread upload work start: main 232, image: 7cdd2eb2-6aa9-42cd-88a9-eb9dbc8e9811-IMG_7857.jpg
2023-11-05 22:18:41.300 DEBUG 10387 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread work end: 232, image: 7cdd2eb2-6aa9-42cd-88a9-eb9dbc8e9811-IMG_7857.jpg
2023-11-05 22:18:41.364 DEBUG 10387 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread upload work start: main 232, image: 7cdd2eb2-6aa9-42cd-88a9-eb9dbc8e9811-IMG_7857.jpg
2023-11-05 22:18:41.443 DEBUG 10387 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread work end: 232, image: 7cdd2eb2-6aa9-42cd-88a9-eb9dbc8e9811-IMG_7857.jpg
2023-11-05 22:18:41.448 DEBUG 10387 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread upload work start: main 232, image: 8ec282b9-a2c6-46ab-8fe1-ed6499fd6f37-image0.jpeg
2023-11-05 22:18:42.105 DEBUG 10387 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread work end: 232, image: 8ec282b9-a2c6-46ab-8fe1-ed6499fd6f37-image0.jpeg
2023-11-05 22:18:42.113 DEBUG 10387 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread upload work start: main 232, image: 68f27e6f-a951-441e-99fc-03cfe37d89c2-2023-05-11-bunny.jpg
2023-11-05 22:18:42.169 DEBUG 10387 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread work end: 232, image: 68f27e6f-a951-441e-99fc-03cfe37d89c2-2023-05-11-bunny.jpg
2023-11-05 22:18:42.170 DEBUG 10387 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread upload work start: main 232, image: 115064144.jpeg
2023-11-05 22:18:42.270 DEBUG 10387 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread work end: 232, image: 115064144.jpeg
2023-11-05 22:18:42.273 DEBUG 10387 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread upload work start: main 232, image: a9b9f3e6-2fa0-4737-9d42-25dc31a82a2c-image0.jpeg
2023-11-05 22:18:42.360 DEBUG 10387 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread work end: 232, image: a9b9f3e6-2fa0-4737-9d42-25dc31a82a2c-image0.jpeg
2023-11-05 22:18:42.365 DEBUG 10387 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread upload work start: main 232, image: maxim-abramov-s7vgZ1Prn2M-unsplash.jpg
2023-11-05 22:18:42.569 DEBUG 10387 --- [nio-8080-exec-2] c.t.s.a.image.service.ImageHostService   : Thread work end: 232, image: maxim-abramov-s7vgZ1Prn2M-unsplash.jpg

(참고용으로 수정한 코드를 추가합니다)

@EnableAsync
@Configuration
public class AsyncConfig {

    @Bean(name = "imageUploadExecutor")
    public Executor imageUploadExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setThreadGroupName("imageUploadExecutor");
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(20);
        executor.setQueueCapacity(50);
        executor.initialize();
        return executor;
    }
}
    @Async("imageUploadExecutor")
    public String upload(MultipartFile file, Directory directory) throws IOException, TooLargeImageException, NotValidImageTypeException {
        checkFileSize(file);
        checkFileType(file);

        String newFileKey = generateKey(file.getOriginalFilename(), directory.getPrefix());
        amazonS3.putObject(new PutObjectRequest(properties.getBucket(), newFileKey, file.getInputStream(), getMetadata(file)));
        return amazonS3.getUrl(properties.getBucket(), newFileKey).toString();
    }

⭐️ @Async 를 사용할 때 주의할 점

문제점은 제가 그동안 Async 에 대해 잘못 사용하고 있었다는 것입니다.

@Async는 어떻게 동작할까요?

이 어노테이션은은 Spring의 큰 특징인 AOP 의 기능으로 구현됩니다.

메서드에 @Async 어노테이션을 붙이면 proxyTargetClass 를 기반으로 해당 객체의 프록시가 만들어집니다. 그 후 Spring은 context에 연결된 스레드 풀을 찾아 해당 메서드의 로직을 별도 스레드로 실행하려고 합니다. 만약 명명된 빈을 찾을 수 없으면 기본 SimpleAsyncTaskExecutor를 사용합니다. (👉 참고 링크)

Proxy란 Target을 감싸 Target의 요청을 대신 받아주는 Wrapping Object인데요. 호출자에서 타겟을 호출하게되면 타겟이 아닌 프록시가 호출되어, 타겟 메소드 실행전 선처리→타겟 메소드→후처리를 실행합니다. (👉 참고 링크)

이러한 동작 방식 때문에 구현시 주의할 점이 있는데요. 주의사항을 지키지 않으면 어노테이션이 무시되고 동기 방식으로 동작할 수 있습니다.

@EnableAsync 어노테이션

애플리케이션의 메인 설정 클래스나 configuration 파일에서 해당 어노테이션을 사용해주어야 합니다. 이 어노테이션으로 스프링의 @Async 어노테이션과 EJB 3.1 javax.ejb.Asynchronous 를 감지합니다. (👉 참고 링크)

Bean으로 관리되고 있어야 합니다.

@ComponentScan 어노테이션에 의해 스캔되거나 @Configuration 클래스 내부에 빈으로 정의되어야 합니다. Spring 에서 프록시를 생성하기 위해서는 Spring IoC 컨테이너에 의해 관리되는 Bean이어야 하기 때문입니다.

Private 메서드에는 동작하지 않습니다.

런타임에 프록시를 생성할 수 없으므로 동작하지 않습니다.

호출하는 메서드와 비동기 메서드가 같은 클래스에 있으면(즉, Self-invocation 이면) 안됩니다

The default is AdviceMode.PROXY. Please note that proxy mode allows for interception of calls through the proxy only. Local calls within the same class cannot get intercepted that way; an Async annotation on such a method within a local call will be ignored since Spring's interceptor does not even kick in for such a runtime scenario. For a more advanced mode of interception, consider switching this to AdviceMode.ASPECTJ.
출처

프록시가 생성되더라도 같은 클래스 안에서 호출하기 때문에 프록시가 소용이 없게됩니다. 당연히 프록시의 선처리, 후처리 기능이 당연히 동작하지 않습니다.

이렇게 메서드를 호출하는 경우 스프링의 인터셉터가 작동하지 않기 때문입니다. 프록시를 우회하고 메서드를 직접 호출하게 되어 별도 Thread에서 동작하지 않아 Async 어노테이션은 무시됩니다.

(이미치 출처)

학습한 내용을 적용해보았습니다.

알고보니 @Async 어노테이션이 붙은 메서드를 내부 함수에서 호출해주었기 때문입니다.

클래스를 분리해주고, AsyncConfig도 다시 점검하여 수정해주었습니다.

아래는 따로 구현한 `ImageUploader` 클래스입니다.

@Service
@RequiredArgsConstructor
public class ImageUploader {

    private final AwsProperties properties;
    private final AmazonS3 amazonS3;

    @Async("imageUploadExecutor")
    public String upload(MultipartFile file, Directory directory) throws IOException {
        log.debug("Thread upload work start: {}, image: {}", Thread.currentThread().getId(), file.getOriginalFilename());

        String newFileKey = generateKey(file.getOriginalFilename(), directory.getPrefix());
        amazonS3.putObject(new PutObjectRequest(properties.getBucket(), newFileKey, file.getInputStream(), getMetadata(file)));
        log.debug("Thread work end: {}, image: {}", Thread.currentThread().getId(), file.getOriginalFilename());
        return amazonS3.getUrl(properties.getBucket(), newFileKey).toString();
    }

    private String generateKey(String originFileKey, String prefix) {
        return String.format("%s%s-%s", prefix, UUID.randomUUID(), originFileKey);
    }

    private ObjectMetadata getMetadata(MultipartFile file) {
        ObjectMetadata objectMetadata = new ObjectMetadata();
        objectMetadata.setContentLength(file.getSize());
        objectMetadata.setContentType(file.getContentType());
        return objectMetadata;
    }
}

이제 실행해보니 의도대로 스레드로 잘 쪼개져서 문제를 처리하고 있는데요. 속도도 매우 빨라졌고요.

2023-11-05 22:39:33.688 DEBUG 11379 --- [ploadExecutor-3] c.t.s.api.image.service.ImageUploader    : Thread upload work start: imageUploadExecutor 249, image: 8ec282b9-a2c6-46ab-8fe1-ed6499fd6f37-image0.jpeg
2023-11-05 22:39:33.688 DEBUG 11379 --- [ploadExecutor-2] c.t.s.api.image.service.ImageUploader    : Thread upload work start: imageUploadExecutor 248, image: 7cdd2eb2-6aa9-42cd-88a9-eb9dbc8e9811-IMG_7857.jpg
2023-11-05 22:39:33.689 DEBUG 11379 --- [ploadExecutor-4] c.t.s.api.image.service.ImageUploader    : Thread upload work start: imageUploadExecutor 250, image: 68f27e6f-a951-441e-99fc-03cfe37d89c2-2023-05-11-bunny.jpg
2023-11-05 22:39:33.688 DEBUG 11379 --- [ploadExecutor-1] c.t.s.api.image.service.ImageUploader    : Thread upload work start: imageUploadExecutor 247, image: 7cdd2eb2-6aa9-42cd-88a9-eb9dbc8e9811-IMG_7857.jpg
2023-11-05 22:39:33.688 DEBUG 11379 --- [ploadExecutor-7] c.t.s.api.image.service.ImageUploader    : Thread upload work start: imageUploadExecutor 253, image: maxim-abramov-s7vgZ1Prn2M-unsplash.jpg
2023-11-05 22:39:33.688 DEBUG 11379 --- [ploadExecutor-6] c.t.s.api.image.service.ImageUploader    : Thread upload work start: imageUploadExecutor 252, image: a9b9f3e6-2fa0-4737-9d42-25dc31a82a2c-image0.jpeg
2023-11-05 22:39:33.688 DEBUG 11379 --- [ploadExecutor-5] c.t.s.api.image.service.ImageUploader    : Thread upload work start: imageUploadExecutor 251, image: 115064144.jpeg
Hibernate: 
    insert 
    into
        item_contents
        (contents, detail_image_url, is_deleted) 
    values
        (?, ?, ?)
Hibernate: 
    insert 
    into
        item_counts
        (chat_counts, hits, is_deleted, like_counts) 
    values
        (?, ?, ?, ?)
Hibernate: 
    insert 
    into
        item
        (created_at, updated_at, category, item_contents_id, item_counts_id, is_deleted, price, region_id, seller_id, status, thumbnail_url, title) 
    values
        (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2023-11-05 22:39:34.986 DEBUG 11379 --- [ploadExecutor-1] c.t.s.api.image.service.ImageUploader    : Thread work end: 247, image: 7cdd2eb2-6aa9-42cd-88a9-eb9dbc8e9811-IMG_7857.jpg
2023-11-05 22:39:34.986 DEBUG 11379 --- [ploadExecutor-5] c.t.s.api.image.service.ImageUploader    : Thread work end: 251, image: 115064144.jpeg
2023-11-05 22:39:34.986 DEBUG 11379 --- [ploadExecutor-6] c.t.s.api.image.service.ImageUploader    : Thread work end: 252, image: a9b9f3e6-2fa0-4737-9d42-25dc31a82a2c-image0.jpeg
2023-11-05 22:39:34.986 DEBUG 11379 --- [ploadExecutor-4] c.t.s.api.image.service.ImageUploader    : Thread work end: 250, image: 68f27e6f-a951-441e-99fc-03cfe37d89c2-2023-05-11-bunny.jpg
2023-11-05 22:39:34.986 DEBUG 11379 --- [ploadExecutor-2] c.t.s.api.image.service.ImageUploader    : Thread work end: 248, image: 7cdd2eb2-6aa9-42cd-88a9-eb9dbc8e9811-IMG_7857.jpg
2023-11-05 22:39:35.006 DEBUG 11379 --- [ploadExecutor-7] c.t.s.api.image.service.ImageUploader    : Thread work end: 253, image: maxim-abramov-s7vgZ1Prn2M-unsplash.jpg
2023-11-05 22:39:35.117 DEBUG 11379 --- [ploadExecutor-3] c.t.s.api.image.service.ImageUploader    : Thread work end: 249, image: 8ec282b9-a2c6-46ab-8fe1-ed6499fd6f37-image0.jpeg

[##Image|kage@MgNIp/btszI2qqUHB/hmicWcJMK3Izw1aCDOLcjK/img.png|CDM|1.3|{"originWidth":1290,"originHeight":510,"style":"alignCenter","width":500,"height":198}##]

근데 쎄함이 느껴집니다...😨..

쿼리가 왜 중간에 나오는거야? 그리고 왜 이렇게 빠른거야?? I/O작업인데 200ms 대가 가능한거야? 정말로...?

새로운 문제의 등장: 반환값이 있는 비동기 메서드 처리

역시나.. 쿼리가 중간에 나오는게 이상해서 DB를 확인해보니 역시나 null 파티가 났더라고요. null null 한 이미지 url들...

왜? 비동기로 처리하다보니 `upload()` 의 반환값인 url을 기다리지 않고 후다닥 다음 로직을 처리해버린 탓입니다.

(부족하지만 이해를 돕기위한 시퀀스 다이어그램..)

이럴 때는 비동기 처리의 반환값을 기다렸다가 처리할 수 있도록 해주어야 하는데요.

기억 저편에 있던 동기-비동기, 블락킹-넌블락킹을 꺼내와 키워드를 찾기 시작했습니다. 해당 개념은 지금 여기서 다루지 않으므로 이 링크를 참고해 주세요.

반환값이 있는 비동기 메서드

void 반환 유형을 사용하는 메서드의 경우, 간단하게 메서드를 비동기적으로 실행하도록 할 수 있습니다. 하지만 반환값이 있는 경우가 문제였는데요. 일반적으로 반환하면 저의 경험처럼 스택 메모리에서 pop되며 공중으로 흩날리게 되겠죠... 🥲

반환 유형의 경우 아래와같은 선택지가 있습니다.

Future

Future 를 사용하여 비동기 프로세스의 결과를 받아 호출했던 스레드에서 사용할 수 있습니다.

`get()` 을 사용하면 결과값이 반환될 때까지 블로킹하고 기다립니다. 주의할 점은 get() 메서드의 파라미터로 time out 시간을 정해주지 않으면 무한정 대기하게 된다는 것입니다.

public class FutureExample {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        ExecutorService executorService = Executors.newFixedThreadPool(1);
        Future<Integer> future = executorService.submit(() -> {
            Thread.sleep(1000);
            return 42;
        });

        Integer result = future.get();
        System.out.println("Result: " + result);

        executorService.shutdown();
    }
}

Future 내부적으로 스레드 세이프하게 구현되어 있어서 따로 syncronized를 작성해주지 않아도 됩니다.

CompletableFuture

Java8부터 도입되었습니다. Future 인터페이스와 함께 CompletionStage 인터페이스를 구현하였습니다.

단순히 완료를 기다리는 Future와는 달리, 콜백을 추가하거나 작업을 조합하여 사용하는 비동기 파이프라인을 만들거나 여러 CompletableFuture 들을 모아 모두 완료되면 다음 작업을 하거나 예외를 발생시키는 완료를 할 때, 사용할 수 있습니다.

  • 단순한 Future로 사용하는 방법 get())
  • 비동기 계산 결과 처리 (thenApply(), thenAccept(), thenRun())
  • 작업을 결합해서 사용하기 (thenCompose())
  • 여러 Future 병렬로 실행 (allOf())
  • 오류에 대한 처리 (completeExceptionally(), CompleteOnTimeout())

자세한 메서드는 여기를 참고했습니다. 👉 참고 링크

각 메서드에 대한 예시는 여기를 참고했습니다. 👉 참고 링크

ListenableFuture

호출 메서드에서 성공, 실패시 콜백할 함수를 `addCallback()`으로 추가하여 사용합니다.

public class ListenableFutureExample {
    public static void main(String[] args) {
        SettableListenableFuture<Integer> future = new SettableListenableFuture<>();
        future.addCallback(new ListenableFutureCallback<Integer>() {
            @Override
            public void onSuccess(Integer result) {
                System.out.println("Result: " + result);
            }

            @Override
            public void onFailure(Throwable ex) {
                System.err.println("Error: " + ex.getMessage());
            }
        });

        // 비동기 작업 완료 후 결과 설정
        new Thread(() -> {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            future.set(42);
        }).start();

        // 비동기 작업이 완료될 때까지 대기하지 않고 계속 진행
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

Spring framework 6.0부터 deprecate 되었습니다. (👉 참고 링크)

deprecate된 정확한 원인은 찾지 못했는데요. 관련해서 알고계시다면 댓글 부탁드립니다.

반환값을 받아 사용할 수 있도록 수정

위 선택지 중 반환값을 CompletableFurure로 정했습니다. Future보다 예외처리나 반복 작업을 모두 모아서 처리할 수 있기 때문입니다.

비동기처리할 메서드의 반환값을 CompletableFurure로 바꾸어 주고 호출 메서드에서 이를 처리할 수 있도록 수정해주었습니다.

    @Async("imageUploadExecutor")
    public CompletableFuture<String> upload(MultipartFile file, Directory directory) throws IOException, TooLargeImageException, NotValidImageTypeException {
        log.debug("Thread upload work start: {}, image: {}", Thread.currentThread().getThreadGroup().getName()+ " " + Thread.currentThread().getId(), file.getOriginalFilename());
        CompletableFuture<String> future = new CompletableFuture<>();

        String newFileKey = generateKey(file.getOriginalFilename(), directory.getPrefix());
        amazonS3.putObject(new PutObjectRequest(properties.getBucket(), newFileKey, file.getInputStream(), getMetadata(file)));
        log.debug("Thread work end: {}, image: {}", Thread.currentThread().getId(), file.getOriginalFilename());
        future.complete(amazonS3.getUrl(properties.getBucket(), newFileKey).toString());
        return future;
    }
    @Override
    public List<ItemDetailImage> uploadItemDetailImages(List<MultipartFile> request) throws ImageHostException {
        checkFilesCount(request);

        List<ItemDetailImage> images = new ArrayList<>();
        List<CompletableFuture<String>> uploadFutures = new ArrayList<>(); // 결과 future

        for (MultipartFile multipartFile : request) {
            CompletableFuture<String> upload = null;
            try {
                upload = imageUploader.upload(multipartFile, Directory.ITEM_DETAIL);
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
            uploadFutures.add(upload);
        }
		
        uploadFutures.forEach(upload -> images.add(ItemDetailImage.create(
                upload.exceptionally(e -> {
                            log.error("이미지 업로드에 실패하였습니다.", e);
                            throw new CompletionException(e);
                        })
                        .join()))); // 완료된 반환값

        return images;
    }

[##Image|kage@ctS9oj/btszSHSvQAj/4K8OmXfOu4D2gGKKStQGm1/img.png|CDM|1.3|{"originWidth":2388,"originHeight":1668,"style":"alignCenter"}##]

그리고 실행 결과는 의도했던 대로 비동기로 진행되고 있음을 확인할 수 있었습니다.

2023-11-05 23:27:52.294 DEBUG 13264 --- [ploadExecutor-6] c.t.s.api.image.service.ImageUploader    : Thread upload work start: imageUploadExecutor 282, image: 7cdd2eb2-6aa9-42cd-88a9-eb9dbc8e9811-IMG_7857.jpg
2023-11-05 23:27:52.341 DEBUG 13264 --- [ploadExecutor-6] c.t.s.api.image.service.ImageUploader    : Thread work end: 282, image: 7cdd2eb2-6aa9-42cd-88a9-eb9dbc8e9811-IMG_7857.jpg
2023-11-05 23:27:52.342 DEBUG 13264 --- [ploadExecutor-5] c.t.s.api.image.service.ImageUploader    : Thread upload work start: imageUploadExecutor 281, image: 7cdd2eb2-6aa9-42cd-88a9-eb9dbc8e9811-IMG_7857.jpg
2023-11-05 23:27:52.342 DEBUG 13264 --- [loadExecutor-10] c.t.s.api.image.service.ImageUploader    : Thread upload work start: imageUploadExecutor 286, image: 8ec282b9-a2c6-46ab-8fe1-ed6499fd6f37-image0.jpeg
2023-11-05 23:27:52.342 DEBUG 13264 --- [ploadExecutor-3] c.t.s.api.image.service.ImageUploader    : Thread upload work start: imageUploadExecutor 279, image: 68f27e6f-a951-441e-99fc-03cfe37d89c2-2023-05-11-bunny.jpg
2023-11-05 23:27:52.342 DEBUG 13264 --- [ploadExecutor-8] c.t.s.api.image.service.ImageUploader    : Thread upload work start: imageUploadExecutor 284, image: 115064144.jpeg
2023-11-05 23:27:52.342 DEBUG 13264 --- [ploadExecutor-4] c.t.s.api.image.service.ImageUploader    : Thread upload work start: imageUploadExecutor 280, image: a9b9f3e6-2fa0-4737-9d42-25dc31a82a2c-image0.jpeg
2023-11-05 23:27:52.344 DEBUG 13264 --- [ploadExecutor-2] c.t.s.api.image.service.ImageUploader    : Thread upload work start: imageUploadExecutor 278, image: maxim-abramov-s7vgZ1Prn2M-unsplash.jpg
2023-11-05 23:27:52.414 DEBUG 13264 --- [ploadExecutor-4] c.t.s.api.image.service.ImageUploader    : Thread work end: 280, image: a9b9f3e6-2fa0-4737-9d42-25dc31a82a2c-image0.jpeg
2023-11-05 23:27:52.419 DEBUG 13264 --- [ploadExecutor-8] c.t.s.api.image.service.ImageUploader    : Thread work end: 284, image: 115064144.jpeg
2023-11-05 23:27:52.440 DEBUG 13264 --- [ploadExecutor-3] c.t.s.api.image.service.ImageUploader    : Thread work end: 279, image: 68f27e6f-a951-441e-99fc-03cfe37d89c2-2023-05-11-bunny.jpg
2023-11-05 23:27:52.451 DEBUG 13264 --- [ploadExecutor-5] c.t.s.api.image.service.ImageUploader    : Thread work end: 281, image: 7cdd2eb2-6aa9-42cd-88a9-eb9dbc8e9811-IMG_7857.jpg
2023-11-05 23:27:52.515 DEBUG 13264 --- [ploadExecutor-2] c.t.s.api.image.service.ImageUploader    : Thread work end: 278, image: maxim-abramov-s7vgZ1Prn2M-unsplash.jpg
2023-11-05 23:27:52.815 DEBUG 13264 --- [loadExecutor-10] c.t.s.api.image.service.ImageUploader    : Thread work end: 286, image: 8ec282b9-a2c6-46ab-8fe1-ed6499fd6f37-image0.jpeg
Hibernate: 
    insert 
    into
        item_contents
        (contents, detail_image_url, is_deleted) 
    values
        (?, ?, ?)
Hibernate: 
    insert 
    into
        item_counts
        (chat_counts, hits, is_deleted, like_counts) 
    values
        (?, ?, ?, ?)
Hibernate: 
    insert 
    into
        item
        (created_at, updated_at, category, item_contents_id, item_counts_id, is_deleted, price, region_id, seller_id, status, thumbnail_url, title) 
    values
        (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)

이 블로그에서 언급한 코드는 Secondhand 레포지토리에서 확인 가능합니다.

글에 대해 잘못된 부분이 있다면 댓글로 알려주시면 감사하겠습니다 😀


참고링크

]]>
<![CDATA[🔐 비밀번호 암호화해보자 (Feat: Spring)]]>해당 글에서 구현 언급한 코드는 practice-member-api 에서 확인 가능합니다.

시작하며

초반 연습용 프로젝트에서 회원가입을 구현하며 그냥 일

]]>
http://localhost:2369/bimilbeonho-amhohwahaeboja-feat-spring/653e691f38d514638a3772a0Sun, 29 Oct 2023 14:22:04 GMT해당 글에서 구현 언급한 코드는 practice-member-api 에서 확인 가능합니다.

시작하며

초반 연습용 프로젝트에서 회원가입을 구현하며 그냥 일반 텍스트로 회원 비밀번호를 관리해왔습니다.

하지만 보안상 비밀번호를 이렇게 평문으로 저장하는 일은 반드시 지양되어야 합니다.

마침 회원가입과 Jwt 토큰을 사용하는 인증/인가는 매우 자주 쓰는 로직이라 나만의 라이브러리를 만들고 싶었는데요. 원티드 프리온보딩 백엔드 사전 미션이 딱 기본 회원가입 + jwt 토큰 방식의 인증인가 + 게시판 REST API라서 이참에 이 비밀번호 암호화를 구현해보기로 했습니다💪

Spring FrameworkSpring Security 라는 하위 프레임워크에서 이러한 보안 인증인가 를 편리하게 제공해주고 있지만 해당 암호화 기술 대한 이해를 높이기 위해 Spring Security 없이 구현하였습니다. 그리고 이 암호화 하나만을 위해서 의존성을 주입하고, 잘 모르는 프레임 워크를 사용하고 싶지 않기도 했고요... 🥲

🤓 먼저 공부합시다, 비밀번호 암호화

암호화 Encryption 와 해싱 Hashing 의 차이점

보안 분야에서는 암호화와 해싱의 개념이 다르다는 것을 먼저 짚고 넘어가야겠습니다.

암호화는 데이터가 일반 텍스트로 전달되고 읽을 수 없는 암호화로 바꿀 수 있고, 이를 다시 해독하여 일반 텍스트로 읽을 수 있는 양방향 기능입니다. 암호화는 회원의 예민할 수 있는 정보를 저장합니다. (이름, 주소, 계좌번호 등) 데이터 노출을 최소화 시키기 위한 방법입니다.

반면 해싱은 단방향입니다. 한번 암호화하면 다시 이를 평문화 시킬 수 없습니다. 대신 같은 문자열을 같은 해시 함수를 거치면 결과값은 늘 같다는 것을 이용하여 값이 일치하는지 확인할 수 있습니다. 해싱은 비밀번호 등 평문화 할 일이 없는 중요한 정보를 저장하는 데 주로 사용합니다. 암호화의 목적과 다른 점은 원래 데이터와 비교해 데이터 변조가 없었는지 무결함을 증명하는 것이 목표 라는 것입니다.

그래서 우리는 비밀번호를 되찾을 수 없습니다.

서버에 있는 비밀번호는 암호화된 결과값(이하 다이제스트, 해시라는 용어로 표현하겠습니다.)일 뿐이지 원래 형태는 영영 되찾을 수 없는거죠. 그래서 비밀번호를 리셋해주거나 임시 비밀번호를 만드는 방법으로 비즈니스 로직을 해결합니다.

((( 만약 비밀번호를 되찾아주는 사이트가 있다면 당장 탈퇴하도록 하세요)))

요즘은 못 따라간다는 그때 그 시절 갬성 드라마 | 스브스노리터
비밀번호는 돌아오지 않는 거야 🪃

비밀번호를 해싱한다니! 해시테이블을 공부해본 분들이라면 다들 알겠지만 해시충돌(Hash Collision)이라는 것이 있습니다. 해시 테이블의 출력값이 유한할 경우 분명 다른 값을 넣었는데 같은 출력값이 나오는 경우이죠.

(이거 위키에서 다들 보셨죠?)

그래서 비밀번호 암호화 해싱을 하면 충돌이 나지 않을까 잠깐 걱정했지만 그럴 확률은 매우매우 적은 것 같습니다. 👉 참고링크 : Potential collision with hash password

여하튼 암호화와 해싱을 가볍게 정리하자면 다음의 표와 같습니다.

암호화 해싱
복호화 가능성 가능 불가능
(결과값의) 길이 가변적 고정 길이
주요 사용 알고리즘 AES, RC4, DES, RSA, ECDSA… SHA-1, SHA-2, MD5, CRC32…

그럼 해싱이 완벽한 비밀번호 암호화 방법일까?

물론 모든 암호는 뚫릴 수 있겠지만, 해싱이 가지는 문제점이 두 가지 있습니다. 👉 참고링크 D2

첫번째는 느리다는 것입니다.

해싱은 해싱 알고리즘마다 속도가 다른데요. 일반적으로 강력할 수록 더 느립니다. 예를 들어 자주 쓰였던 SHA-1 알고리즘과 최근 많이 사용되는 PBKDF2를 비교해본다면 후자 쪽이 브루트 포스 공격에 더 강력하지만 속도는 매우 느립니다.

의도적으로 해싱 횟수를 추측할 수 없게 하기 위해서 느리게 하기도 합니다.

하지만! 보안을 위해서 몇 초의 기다림은 사용자에게 그닥 문제가 되지 않을 것 같습니다.

두번째는 더 심각한 문제인 인식 가능성입니다.

그러니까 자주 쓰는 비밀번호의 경우에는 그냥 다이제스트(해시 함수를 거쳤을 때 결과 값)를 미리 표로 정리해둘 수 있습니다.

동일한 메시지에 대해서 늘 동일한 다이제스트를 갖기 때문에, 해커가 다이제스트 결과를 가능한 많이 확보한 다음 이를 탈취한 데이터 베이스 데이터와 비교하여 원본 메시지를 찾아낼 수 있게 되는거죠. 어떤 알고리즘을 사용했는지 안다면요.

이러한 다이제스트를 모아서 레인보우 테이블이라고 하는데요. 하나의 패스워드에서 시작해 변이된 형태의 여러 패스워드를 생성하여 그 패스워드의 해시를 고리처럼 연결해 일정 수의 패스워드와 해시로 이루어진 테이블입니다. 👉 참고 링크

이미지 출처 : https://www.thesecurityblogger.com/understanding-rainbow-tables/

이를 이용하면 브루트 포스 공격이 더 쉬워지겠죠.

아래는 SHA-1을 사용했을 때 레인보우 테이블의 예시입니다.

86f7e437faa5a7fce15d1ddcb9eaeaea377667b8 a
e9d71f5ee7c92d6dc9e92ffdad17b8bd49418f98 b
84a516841ba77a5b4648de2cd0dfcb30ea46dbb4 c
...
948291f2d6da8e32b007d5270a0a5d094a455a02 ZZZZZX
151bfc7ba4995bfa22c723ebe7921b6ddc6961bc ZZZZZY
18f30f1ba4c62e2b460e693306b39a0de27d747c ZZZZZZ

5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8 password
e38ad214943daad1d64c102faec29de4afe9da3d password1
b7a875fc1ea228b9061041b7cec4bd3c52ab3ce3 letmein
5cec175b165e3d5e62c9e13ce848ef6feac81bff qwerty123

Salt, Pepper, Key Stretch 😵

위에서 언급한 인식 가능성에 대한 단점을 보완하기 위해 솔트Salt, 페퍼pepper, 스트레치Key Stretch 방법을 사용하는데요. 이것에 대해서도 간단히 다루어 보겠습니다.

이 세가지가 적영된 해시를 간단하게 수식으로 표현하자면 이렇게 될 것 같습니다.

pepper = {came from somewhere}
salt = {generated random}
hash = H(H(H(H(H(H(H(H(H(H(H(H(H(H(H(...H(password + salt + pepper) + salt + pepper) + salt + pepper) ... )

Salt

솔트 Salt는 단방향 해싱시 다이제스트를 생성할 때 추가되는 바이트 단위의 임의 문자열입니다. 보통 128비트 이상으로 랜덤 문자열을 생성하여 사용하고 이 생성되는 문자열은 회원마다 각각 다르게 적용한다면 더욱 강력하게 작용합니다.

솔트의 목적은 해커가 데이터베이스를 탈취하기 전에 레인보우 테이블과 같은 예측가능한 테이블을 생성하지 못하는 데 있습니다. 그래서 솔트는 데이터베이스에 저장하고 이를 얻을 수 있는 유일한 방법을 데이터베이스로 합니다.

Pepper

페퍼 Pepper는 모든 비밀번호에 일정하게 적용되지만 ✌️데이터베이스에는 저장하지 않는✌️ 또다른 소금입니다.

모두에게 동일하게 적용되기 때문에 이 방법이 유효하지 않다는 의견도 있습니다만, 데이터베이스외에 다른 설정 파일을 통해서 넣어주는 페퍼로 비밀번호에 대한 안전성을 더 높일 수 있을 것입니다.

Key Stretch

salt와 pepper와 본래 문자열을 여러번 해시 함수를 거쳐 다이제스트를 중접하여 생성하게 합니다. 이렇게 생성된 다이제스트는 브루트 포스 공격시 시간이 더 걸리도록 할 수 있습니다.

이미지 출처 : https://d2.naver.com/helloworld/318732

그리고 더 어렵게!

다이제스트를 생성할 때 솔트와 패스워드 외에도 또 다른 값을 추가하여 다이제스트를 추측하기 더 어렵게 강력하게 만들 수 있습니다.

대표적인 예로 KDF(Key Derivation Function)가 있는데요. 비밀번호나 솔트 등에서 키를 파생시켜 암호화 알고리즘에 사용합니다. 👉 참고링크

hash = KDF(password, salt, workFactor)

가장 널리 사용되는 두 가지 KDF는 PBKDF2 와 bcrypt 입니다. PBKDF2는 키가 있는 HMAC (블록 암호를 사용할 수 있음) 의 반복을 수행하여 작동하며 bcrypt는 Blowfish 블록 암호에서 많은 수의 암호문 블록을 계산하고 결합하여 작동합니다.

Spring 에서 구현해보자

테이블 구조

비밀번호를 DB에 직접적으로 저장하지 않고, 해시와 솔트를 저장하여 비밀번호 검증을 하는 형태로 기획해보았는데요. 특히 회원가입과 로그인 외에는 회원 도메인과 별도로 움직일 수 있다고 생각되어 별도 테이블로 ERD를 짜보았습니다.

  • member_password 비밀번호를 저장하는 테이블입니다.
    • member 와 식별관계로 설계해도 괜찮았겠지만 member_auth 와 통일감있게 설계하고 싶어 비식별 관계로 설계하였습니다.
    • member 와는 1:1 관계입니다. (ERD가 조금 잘못되었습니다 🥲)

Entity 구현

ORM은 Spring Data JPA를 사용했습니다. 특이사항은 없고 테이블 레코드와 매핑이 될 수 있도록 했습니다.

@Entity
@Table(name = "member")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@SecondaryTable(name = "member_password", pkJoinColumns = @PrimaryKeyJoinColumn(name = "member_id"))
public class Member extends CreatedEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Email
    private String email;
    @Embedded
    private Password password = new Password();
    private String username;
}
@Getter
@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Password {

    @Column(table = "member_password")
    private String hash;
    @Column(columnDefinition = "BINARY(16)", table = "member_password")
    private byte[] salt;

    public static Password of (String hash, byte[] salt) {
        return new Password(hash, salt);
    }
}

PasswordEncoder

인터페이스로 먼저 역할을 규정해준 뒤, 알고리즘마다 하위 구현 클래스를 구현할 수 있도록 하였습니다.

단방향 암호화시 많이 쓰이는 PBKDF2 를 이용하는 인코더를 구현했습니다.

알고리즘 이름인 "PBKDF2WithHmacSHA1", 반복 회수와 키 길이는 외부 변수로 주입받았습니다. 인스턴스 생성자의 파라미터에 따라 변수를 정했고 페퍼는 따로 사용하지 않았습니다.

@Component
public class Pbkdf2Encoder implements PasswordEncoder {
    private final String encodeAlgorithm; // 알고리즘 이름
    private final int iterations; // 반복 횟수
    private final int keyLength; // 키 길이

    public Pbkdf2Encoder(
            @Value("${password.encode.algorithm}") String encodeAlgorithm,
            @Value("${password.encode.iterations}") int iterations,
            @Value("${password.encode.keyLength}") int keyLength) {
        this.encodeAlgorithm = encodeAlgorithm;
        this.iterations = iterations;
        this.keyLength = keyLength;
    }

    @Override
    public Password encrypt(String password) {
        byte[] saltBytes = generateSalt();
        return encrypt(password, saltBytes);
    }

    public Password encrypt(String password, byte[] saltBytes) {
        byte[] hashBytes;
        KeySpec spec = new PBEKeySpec(password.toCharArray(), saltBytes, iterations, keyLength);
        try {
            SecretKeyFactory factory = SecretKeyFactory.getInstance(encodeAlgorithm);
            hashBytes = factory.generateSecret(spec).getEncoded();
        } catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
            throw new RuntimeException("Password encryption failed.", e);
        }
        String hash = new String(Base64.getEncoder().encode(hashBytes));
        return Password.of(hash, saltBytes);
    }
		
		// 랜덤 salt를 반환합니다.
    private byte[] getSalt() {
        SecureRandom random = new SecureRandom();
        byte[] salt = new byte[16];
        random.nextBytes(salt);
        return salt;
    }

}
  • 테스트 코드
    @Test
    @DisplayName("비밀번호를 암호화 할 수 있다.")
    void testPasswordEncoder() throws Exception{
        //given
        String password = "password1234";
        
        //when
        Password encrypt = passwordEncoder.encrypt(password);
        log.info(encrypt.toString());
    }

    @Test
    @DisplayName("같은 비밀번호를 같은 salt로 암호화하면 해시가 같아야 한다.")
    void testPassword() throws Exception{
        //given
        String inputPassword1 = "password1234";
        String inputPassword2 = "password1234";

        //when
        Password password1 = passwordEncoder.encrypt(inputPassword1);
        byte[] salt = password1.getSalt();
        Password password2 = passwordEncoder.encrypt(inputPassword2, salt);

        assertThat(password1.getHash()).isEqualTo(password2.getHash());
        assertThat(password1).isEqualTo(password2);
    }​

PasswordValidator

암호를 체크하는 역할의 컴포넌트를 따로 구현하였습니다. 역할이 작기 때문에 PasswordEncoder 에 같이 체크 메서드를 구현해도 무방할 것 같습니다.

@Component
@RequiredArgsConstructor
public class PasswordValidator {

    private final PasswordEncoder passwordEncoder;

    public boolean check(Member member, String inputPassword) {
        return passwordEncoder.encrypt(inputPassword, member.getSalt())
                .equals(member.getPassword());
    }
}

  • 테스트 코드
    @BeforeEach
    void init() {
        Password password = passwordEncoder.encrypt("myPassword");

        testMember = Member.builder()
                .id(1L)
                .email("test@gmail.com")
                .username("회원")
                .password(password)
                .build();
    }

    @Test
    @DisplayName("회원 비밀번호가 맞는지 확인할 수 있다.")
    void isCorrectPassword() {
        byte[] salt = testMember.getSalt();
        Password otherPassword = passwordEncoder.encrypt("myPassword", salt);

        assertThat(testMember.isCorrectPassword(otherPassword)).isTrue();
    }

    @Test
    @DisplayName("회원 비밀번호가 틀린지 확인할 수 있다.")
    void isIncorrectPassword() {
        byte[] salt = testMember.getSalt();
        Password otherPassword = passwordEncoder.encrypt("wrongPassword", salt);

        assertThat(testMember.isCorrectPassword(otherPassword)).isFalse();
    }

마무리하며

구현 하면서 Spring Security는 어떻게 해시와 솔트를 저장하고 있는지 궁금했는데요.

자주 쓰이는 BCryptPasswordEncoder의 경우, 내부적으로 임의 솔트를 생성하고 해시 내에 저장하고 있다고 합니다. 👉 참고링크

$2a$10$ZLhnHxdpHETcxmtEStgpI./Ri1mksgJ9iDP36FmfMdYyVg9g0b2dq

이상 여기까지 비밀번호 암호화에 대해서 학습하고 구현한 내용이었습니다. 혹시 사실과 다른 부분이 있다면 댓글로 알려주시면 감사하겠습니다 😀


Refs.

]]>
<![CDATA[안 읽은 채팅 구현기 💥]]>이 글은 당근마켓을 모티브로 한 프로젝트 Secondhand 구현시 이슈 사항을 정리한 글입니다.

들어가며

Secondhand 프로젝트를 진행할 때 가

]]>
http://localhost:2369/an-ilgeun-caeting-guhyeongi/652cf177a2607c164223b2efMon, 16 Oct 2023 12:51:45 GMT이 글은 당근마켓을 모티브로 한 프로젝트 Secondhand 구현시 이슈 사항을 정리한 글입니다.

들어가며

Secondhand 프로젝트를 진행할 때 가장 마지막에 구현했던 기능인 ‘아직 안 읽은 채팅’에 대해 백엔드 개발자 2명이 한 고민과 구현 과정을 정리해보았습니다.

채팅 시스템은 처음 이 프로젝트를 시작할 당시에 BE는 물론 FE, iOS에게도 굉장히 큰 과제였습니다. 유저간 실시간 채팅을 어떻게 구현할 것이며, 어떻게 DB에 저장할 것인가도 어려운 미션이었는데요. (이것에 대해서는 추후에 따로 글을 적어보기로…) 산넘어 산… “안 읽은 채팅을 어떻게 구현할 것인가”는 다른 의미로 어려웠던 미션이었습니다.

전자는 “새로운 기술을 어떻게 도입할 것인가”라는 고민에 가까웠다면 후자는 채팅 도메인에 대한 해석과 구현 방법에 시간을 많이 할애한 것 같습니다. 복잡한 문제를 어떻게 쪼갤지에 대해서는 다양한 방법이 있습니다. 그리고 그 중에서 하나를 매번 선택해야 하는 것이죠.

발단 : 요구사항 파악

이미지 출처 : https://zdnet.co.kr/view/?no=20210528110514 정확하지는 않지만, 기획안은 코드스쿼드 저작권을 확인하지 못해 첨부는 못하고… 비슷한 웹뷰로 첨부합니다.

채팅 리스트에 보면 채팅방으로 접근할 수 있는 리스트 외에도 해당 회원이 마지막으로 보낸 메시지와 아직 내가 확인하지 않은 ‘안 읽은 메시지 개수’가 카운트됨을 확인할 수 있습니다.

그리고 정확하지는 않지만 대다수의 채팅 메신저가 그렇듯이 마지막 메시지의 발송 시간에 따라 정렬되고 있습니다.

이 리스트에서 원하는 채팅방을 클릭하면 채팅 내역을 확인할 수 있는데요. 이 채팅 내역을 확인하면 안 읽은 메시지는 0이 되어야 합니다.

안 읽은 채팅에 대한 고민

어떻게 동작해야 하는가?

요구사항 분석시에 언급한 동작방식을 정리하면 다음과 같습니다.

  • 사용자가 상대방에게 메시지를 보냈는데 상대방이 바로 읽지 않으면 ‘안 읽은 메시지’가 됩니다. 카운트가 1 늘어나야 합니다.
  • 사용자가 채팅방 리스트를 볼 때 안읽은 메시지가 각 채팅방별로 숫자로 표현되어야 합니다.
  • 사용자가 채팅방에 들어가면 안 읽은 메시지는 0으로 초기화 되어야 합니다.

어떻게 구현해야 하는가?

이쯤 되어서 안 읽은 채팅을 어떻게 구현할 것인가를 선택해야 되었는데요.

안 읽은 채팅을 팀원과 함께 상의하다가 다양한 방식으로 구현할 수 있고 그것에 대한 장단점을 분석하였습니다.

💡
방법 1. 사용자의 마지막 접속 시간과 채팅방 메시지 발송 시각을 비교합니다.

채팅방으로 채팅 메시지를 조회하여 마지막 접속시간 이후에 보내진 메시지를 카운트하여 보내는 방식입니다.

- 장점 : 데이터 정규화로 불필요한 데이터를 저장하지 않아도 되고, 데이터 정합성이 높습니다.
- 단점 : 매 채팅방 리스트를 조회할 때 사용자 채팅방의 모든 로그를 조회해야 합니다. 채팅 메시지가 많다면 시간이 꽤 오래 걸릴 수 있습니다.
💡
방법 2. 안 읽은 채팅을 저장하는 별도의 컬럼 혹은 테이블로 저장합니다.

별도의 컬럼 혹은 테이블을 DB에 저장하두고 매번 메시지를 수신할 때마다 갱신시켜줍니다. 유저가 접속하면 0으로 초기화합니다.

- 장점 : 매번 채팅방 로그를 모두 조회하여 비교해야한다는 부담감을 줄일 수 있습니다. 조회에 이점을 갖습니다.
- 단점 : 메시지 수신시 매번 UPDATE 가 일어납니다. 같은 트랜잭션에 있을 경우 UPDATE 로직의 실패가 동일 트랜잭션에 영향을 줄 수 있습니다. 데이터 정합성이 지켜지지 않을 수 있습니다.
💡
방법 3. 사용자의 마지막 읽은 채팅 메시지 PK를 저장합니다.

방법1과 방법2의 절충안이었습니다. 그러나 두 방법의 단점을 동시에 가지게 된다는 것이 제일 큰 단점이라는 분석으로 바로 열외시켰습니다.

이 방법들 중 최종적으로 선택한 방법은 두 번째, 안 읽은 채팅 개수를 저장하는 별도의 테이블을 만드는 것이었습니다.

그 이유는 채팅을 보내는 횟수보다 조회 횟수가 월등히 많다는 것입니다.

이 데이터는 채팅방 리스트를 조회할 때마다 일어나야 하고 비효율적으로 구현하는 경우 연쇄 쿼리가 N개에 채팅방에 대해 1개씩 일어날 수 있습니다.

그리고 이 안 읽은 채팅 매번 채팅방 입장할 때마다 초기화되고 약간 오류가 있어도 치명적인 로직이 아니라는 판단이었습니다.

고민했던 것과 구현 방식

어떻게 저장할까?

Secondhand는 현재 RDB로 MySQL을 사용하고 있습니다. 그러나 이 채팅 Metainfo는 MongoDB에 채팅 메시지와 함께 저장했습니다.

그 이유로는 구현해보지 못한 도메인이라 개발 단계에서는 저장 데이터가 매번 바뀔텐데 MySQL에 저장하면해 중간에 컬럼이 바뀔 때마다 스키마도 매번 수정해주어야 하기 때문에 개발 시간이 오래 소요될 것 같다는 것이 가장 컸습니다.

MongoDB는 Document 로 저장되기 때문에 중간에 데이터 저장 형식이 바뀌더라도 부담없이 바로 적용시킬 수 있습니다.

  • 저장한 결과입니다.
{
  "_id": "6c5879d3-455a-472a-9be6-eef45dcbf51b",
  "participants": {
    "info": {
      "member1": {
        "memberId": "member1",
        "isConnected": true, # 현재 접속 여부
        "messageStock": 0 # 안읽은 메시지 개수
      },
      "newpow": {
        "memberId": "newpow",
        "isConnected": false,
        "messageStock": 1
      }
    }
  },
  "lastMessage": "test message15", # 마지막 메시지
  "updateAt": {
    "$date": "2023-09-02T06:57:40.595Z" # 마지막 메시지 갱신 날짜. 이걸로 어플리케이션에서 정렬하여 API를 반환합니다.
  },
  "_class": "com.team5.secondhand.chat.chatroom.domain.Chatroom"
}

채팅 구현 환경 점검

현재 프로젝트 내에서 실시간 채팅은 STOMP와 Redis pub/sub 을 혼합하여 구현하였습니다. STOMP가 클라이언트의 구독과 메시지 발송을 받고 Redis를 통해 pub하면 이를 핸들링하여 클라이언트에게 메시지가 발송되는 형태인데요.

STOMP 에는 이 메시지를 받고 처리하기 전에 핸들링을 할 수 있는 ChannelInterceptor 를 제공합니다. 이를 이용하여 채팅방 구독 여부를 확인할 수가 있어 각 메시지 상태에 따라 채팅 metainfo를 갱신해주기로 했습니다.

그리고 매번 사용자가 메시지를 보낼 때마다 안읽은 메시지도 더해지도록 구현하기 위해 SpringEvent 를 이용하였습니다.

    @Async
    @EventListener
    public void chatBubbleArrivedEventHandler(ChatBubbleArrivedEvent event) throws NotChatroomMemberException {
        ChatBubble chatBubble = event.getChatBubble();
        Chatroom chatroom = getChatroom(chatBubble.getRoomId());
        chatroom.updateLastMessage(chatBubble);
        Chatroom saveChatroom = metaInfoRepository.save(chatroom);
    }

구현 코드

채팅 metainfo 도메인 구현

이제 채팅 metainfo를 저장하기 위한 도메인을 구현해보았습니다. 이번에는 테이블 구조를 먼저 짜기보다는 로직에 필요한 도메인을 구성하고 이를 MongoDB에 저장하다가 후에 MySQL 에 저장하도록 수정하기로 했습니다.

public class ChatroomMetaInfo implements Serializable {
    private String chatroomId; // chatroom 테이블 pk 참조, 식별관계
    private Participants participants = new Participants(new ConcurrentHashMap<>()); // 참가자 정보 저장
    private String lastMessage; // 채팅 마지막 메시지
    private Instant updateAt;   // 마지막 업데이트 날짜

    public boolean updateLastMessage (ChatBubble chatBubble) throws NotChatroomMemberException {
        this.lastMessage = chatBubble.getMessage();
        this.updateAt = Instant.now();
        return participants.getMessage(chatBubble.getReceiver());
    }

		// 사용자가 들어오고 나갈때마다 사용자 정보를 업데이트 합니다.
    public boolean enter(String memberId) {
        return participants.enter(memberId);
    }

    public boolean exit(String memberId) {
        return participants.exit(memberId);
    }

		// 생성자 getter 등 생략
}

사용자 정보는 Map 형태로 만들었고, 이를 관리하기 위한 일급컬렉션으로 구현하였습니다. 사용자 닉네임이나 PK로 바로 조회할 수 있기 때문에 Map 형태로 구현한 것입니다. 안읽은 메세지나 접속 여부를 참가자별로 저장해두어 채팅방에 사람이 더 들어가는 비즈니스 요구가 있을 때 확장 가능하도록 구성하였습니다.

public class Participants implements Serializable {

    private Map<String, ParticipantInfo> info = new ConcurrentHashMap<>();

    public boolean getMessage(Long receiver) {
        ParticipantInfo member;
        if((member=info.get(receiver))==null) {
            return false;
        }
        member.plusBubble();
        return true;
    }

    public boolean enter(String memberId) {
        ParticipantInfo participantInfo;
        if ((participantInfo = info.get(memberId))==null) {
            return false;
        }

        participantInfo.connect();
        info.put(memberId, participantInfo);
        return true;
    }

    public boolean exit(String memberId) {
        ParticipantInfo participantInfo;
        if ((participantInfo = info.get(memberId))==null) {
            return false;
        }

        participantInfo.disconnect();
        info.put(memberId, participantInfo);
        return true;
    }

		// 생성자 getter 생략
}
public class ParticipantInfo implements Serializable {
    private String memberId;
    private Instant lastDisconnectedAt;
    private Boolean isConnected;
    private Integer messageStock; // 안읽은 메시지 개수

    public void plusBubble() {
        this.messageStock ++;
    }

    public void connect() {
        this.isConnected = true;
        this.messageStock = 0;
    }

    public void disconnect() {
        this.isConnected = false;
        this.messageStock = 0;
        this.lastDisconnectedAt = Instant.now();
    }

		// 생성자 getter 생략
}

Message pre handler 구현

사용자의 메시지 header command를 읽고 서비스를 호출합니다.

command 종류에 대해선 다음 링크를 참조 👉 https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/messaging/simp/stomp/StompHeaderAccessor.html

public class StompMessageProcessor implements ChannelInterceptor {
    private final JwtService jwtService;
    private final SessionService sessionService;
    private final ChatroomMetaInfoService chatroomMetaInfoService;

    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor headerAccessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
        handleMessage(headerAccessor);
        return message;
    }

    private void handleMessage(StompHeaderAccessor headerAccessor) {
        if (headerAccessor == null || headerAccessor.getCommand() == null) {
            throw new MessageDeliveryException(ErrorType.BAD_REQUEST.getMessage());
        }

        switch (headerAccessor.getCommand()) {
            case CONNECT:
                String memberId = getMemberIdByToken(headerAccessor.getFirstNativeHeader("Authorization"));
                sessionService.saveSession(headerAccessor.getSessionId(), memberId); // 세션에 사용자 접속 여부를 저장합니다.
                break;
            case SUBSCRIBE: // 채팅방 구독시
                enterToChatRoom(headerAccessor);
                break;
            case UNSUBSCRIBE: // 채팅방 구독 해지시
                exitToChatRoom(headerAccessor);
                break;
            case DISCONNECT:
                sessionService.deleteSession(headerAccessor.getSessionId());
                break;
						// 그 외는 커맨드는 무시
        }
    }

    private String getMemberIdByToken(String authorization) {
        if (authorization == null) {
            throw new MessageDeliveryException(ErrorType.UNAUTHORIZED.getMessage());
        }

        return jwtService.getMemberId(authorization).orElseThrow(() -> new MessageDeliveryException(ErrorType.UNAUTHORIZED.getMessage()));
    }

    private void enterToChatRoom(StompHeaderAccessor headerAccessor) {
        String memberId = sessionService.getMemberIdBySessionId(headerAccessor.getSessionId());
        String roomId = extractRoomId(headerAccessor.getDestination());
        chatroomMetaInfoService.enterToChatRoom(roomId, memberId); // 서비스 안에서 
    }

    private void exitToChatRoom(StompHeaderAccessor headerAccessor) {
        String memberId = sessionService.getMemberIdBySessionId(headerAccessor.getSessionId());
        String roomId = extractRoomId(headerAccessor.getDestination());
        chatroomMetaInfoService.exitToChatRoom(roomId, memberId);
    }

    private String extractRoomId(String destination) {
        if (destination == null) {
            throw new MessageDeliveryException(ErrorType.BAD_REQUEST.getMessage());
        }
        return destination.replace("/sub/", "");
    }
}

이벤트 리스너 구현

@Service
@RequiredArgsConstructor
public class ChatroomMetaEventListener {

    private final ApplicationEventPublisher eventPublisher;
    private final ChatroomMetaRepository metaInfoRepository;

		// 채팅방 생성시 metainfo 도 함께 생성되도록 합니다.
    @Async
    @EventListener
    public void chatroomCreatedEventHandler(ChatroomCreatedEvent event) { 
        Chatroom chatroom = Chatroom.init(event.getInfo());
        metaInfoRepository.save(chatroom);
    }

		// 채팅메시지 도착시 안읽은 메시지와 마지막 메시지를 업데이트 합니다.
    @Async
    @EventListener
    public void chatBubbleArrivedEventHandler(ChatBubbleArrivedEvent event) throws NotChatroomMemberException {
        ChatBubble chatBubble = event.getChatBubble();
        Chatroom chatroom = getChatroom(chatBubble.getRoomId());
        chatroom.updateLastMessage(chatBubble);
    }

    public Chatroom getChatroom(String chatroomId) {
        return metaInfoRepository.findById(chatroomId).orElseThrow();
    }

}

한계 및 개선할 것

동시성 문제

채팅이라는 도메인의 특성상 빠르게 입력과 조회가 이루어져야 합니다. DB에 바로 접근하는 현재의 로직상 동시성 이슈로 lost update가 우려됩니다.

이를 위해 채팅 metadata를 캐싱하고 주기적으로 DB에 업데이트 하는 로직으로 변경하는 것이 서비스 안정성에 좋을 것 같습니다.

@Async 와 @EventListener 사용으로 데이터 정합성 문제

별도 트랜잭션이기 때문에 비동기 이벤트 로직 진행중 예외가 발생하였을 때 기존 데이터와 일치하지 않는 데이터 정합성 오류가 있을 수 있습니다. 현재 구현단계에서는 매번 초기화되는 데이터이기 때문에 치명적이지 않다고 판단하였지만, 장기적으로 서비스한다고 하였을 때 이에 대한 예외처리와 롤백을 처리해줘야 합니다.

ChannelInterceptor 의 역할에 대한 문제

구현하다보니 ChannelInterceptor 외에 다른 방법이 없나 고민이 들었습니다. 일부 로직의 구현인데 모든 웹소켓 통신을 pre handle하고 있는 점이 마음에 걸립니다. 특히 현재는 채팅방 구독과 구독 해지로 채팅방 입퇴장을 감지하고 있지만, 만약 토픽이 다양해진다면 구독과 해지 로직이 반드시 채팅방에만 쓰이지 않수도 있기 때문입니다. 일단 기능 구현은 했지만 고민이 많이 남습니다.


Refs.

]]>
<![CDATA[PostgreSQL에서 Full text search 사용하고 싶다면 : pg_bigm 모듈 사용하기]]>연관글
MySQL 검색 기능 개선하기 : Full-text search

들어가며

최근 영상에 데이터 라벨링을 하기 위한 REST API와 WAS를 구축하고 있습니다. 이전에

]]>
http://localhost:2369/postgresqleseo-full-text-search-sayonghago-sipeoyo-pg_bigm-modyul-sayonghagi/651abb0d45a954b700026197Mon, 02 Oct 2023 14:34:58 GMT연관글
MySQL 검색 기능 개선하기 : Full-text search

들어가며

최근 영상에 데이터 라벨링을 하기 위한 REST API와 WAS를 구축하고 있습니다. 이전에 여러번 진행했던 토이 프로젝트들과는 다르게 서비스를 구축하다보니 기술 스택 설정부터 모호한 서비스 요구사항 등등.. 이런저런 새로운 도전들을 하게되었습니다.

가장 새로운 도전 중 하나는 그동안 제 2의 고향과 같았던 🥹 MySQL을 떠나 다른 RDB를 사용하게 된 것인데요. 같은 RDB라 크게 달라질 것이 없다고 생각했지만.. 역시 세상에는 거저 지나가는 것들이 없었습니다.

💡
여담으로 제대로 PostgreSQL의 장점을 사용해보지 못한 상태에서, MySQL과 PostgreSQL의 가장 큰 차이점으로 느끼는 것은 auto_increment로 PK 생성할 때의 기본 전략입니다.
MySQL은 LAST_INSERT_ID(); 로 마지막에 삽입된 레코드 PK를 가져오지만 PostgreSQL 은 sequence 변수가 존재하여 이를 증가시키며 PK를 정합니다.

- 관련해서 겪은 에러 : Spring data JPA: Hibernate가 Sequence를 못찾아요
- 참고글 : Access last inserted row in MySQL?

이번에는 검색 요구사항에 맞닥뜨리게 되었습니다. 회원 이름으로 회원에게 지급할 금액과 작성한 내역을 검색하는 것이었습니다. 회원이 천명 단위가 되지 않을 소규모 애플리케이션으로 예상되기 때문에 별도의 검색엔진을 구현하는 것보다 간단하게 DB에서 처리해주려고 하였습니다. 간단하게 검색 기능을 구현하는 가장 간단한 방법으로 PostgreSQL에서 Full text Search를 구현해보았습니다.

PostgreSQL의 모듈 pg_bigm

PostgreSQL은 Full text Search를 지원하는 pg_bigm 모듈이 있습니다. 이 모듈을 사용하면 사용자가 더 빠른 검색을 할 수 있도록 bigram(2gram) 색인을 생성할 수 있습니다.

pg_trgm과 pg_bigm

pg_bigm은 pg_trgm을 기반으로 개발된 모듈입니다. 알파벳이 아닌 언어에 대해 지원하고, 구문 일치 범위가 3gram에서 2gram으로 좁아져서 더 작은 단어도 빠르게 검색할 수 있습니다.

다음은 공식문서에서 참고한 비교표입니다.

기능 및 특징pg_trgmpg_bigm
구문 일치 방법3그램2그램
사용 가능한 인덱스GIN, GiST진만 해당
사용 가능한 텍스트 검색 연산자LIKE(~~), LIKE(~~*), ~, ~*LIKE only
알파벳이 아닌 언어
(한국어, 일본어 등)
지원되지 않음(*1)지원됨
1~2자 키워드느림(index full scan)빠름
유사성 검색지원됨지원됨(버전 1.1 이상)
최대 인덱스 열 크기238,609,291바이트(~228MB)107,374,180바이트(~102MB)

Amazon RDS for PostgreSQL에서 구현

install / Uninstall

모듈 활성화를 위해 다음의 쿼리를 실행합니다.

-- 모듈 활성화
CREATE EXTENSION pg_bigm;

-- 모듈 삭제를 원할 때
DROP EXTENSION pg_bigm CASCADE;

모듈활성화 내역을 확인할 수 있습니다.

SELECT * FROM pg_extension


  Name   | Version | Schema |              Description
---------+---------+--------+---------------------------------------
 pg_bigm | 1.1     | public | text index searching based on bigrams

Index 생성

1개의 컬럼만 사용할 때의 쿼리입니다.

CREATE INDEX 인덱스명 ON pg_tools USING gin (컬럼명 gin_bigm_ops);
CREATE INDEX pg_tools_idx ON pg_tools USING gin (description gin_bigm_ops);

만약 여러 개의 컬럼에 대해 인덱스를 생성하고 싶다면 다음과 같이 쿼리를 실행하면 됩니다.

CREATE INDEX 인덱스명 ON pg_tools USING gin (컬럼1명 gin_bigm_ops, 컬럼2명 gin_bigm_ops) WITH (FASTUPDATE = off);
CREATE INDEX pg_tools_multi_idx ON pg_tools USING gin (tool gin_bigm_ops, description gin_bigm_ops) WITH (FASTUPDATE = off);

-- 생성되는 인덱스를 미리 확인할 수 있는 쿼리
SELECT show_bigm('안녕하세요');

-- 생성되는 인덱스
{녕하,세요,안녕,요 ,하세, 안}

그리고 원래 쿼리대로 검색했을 때 조건(검색어가 2글자 이상)을 만족하면 쿼리 실행에 인덱스를 사용하는 것을 확인할 수 있습니다.

-- index가 없을 때
+--------------------------------------------------------+
|QUERY PLAN                                              |
+--------------------------------------------------------+
|Seq Scan on member  (cost=0.00..10.12 rows=1 width=4152)|
|  Filter: ((name)::text ~~ '%이름%'::text)                |
+--------------------------------------------------------+

그런데 예상치못한 상황이 발생했습니다. 테이블 row가 너무 적어서인지 full scan을 하고 있습니다. 이럴땐 full scan이 더 빠르기 때문일까요? full scan시에는 DB에 random access를 하지 않으니까요? (가설)

  • 주의할 점
    • 너무 테이블 row가 적어서인지 index가 있어도 사용하지 않습니다.

CREATE INDEX full_index_video ON public.label_video USING gin (title gin_bigm_ops);
EXPLAIN ANALYZE SELECT * FROM public.label_video WHERE label_video.title LIKE '%LH%';


+-------------------------------------------------------------------------------------------------------+
|QUERY PLAN                                                                                             |
+-------------------------------------------------------------------------------------------------------+
|Seq Scan on label_video  (cost=0.00..4.19 rows=95 width=114) (actual time=0.010..0.033 rows=95 loops=1)|
|  Filter: ((title)::text ~~ '%LH%'::text)                                                              |
|Planning Time: 0.073 ms                                                                                |
|Execution Time: 0.055 ms                                                                               |
+-------------------------------------------------------------------------------------------------------+

데이터 100건일 때, 인덱스를 사용하지 않습니다.

성능 개선 확인

데이터를 1만건 랜덤으로 넣고 실행하니 다음과 같이 index가 반영되었음을 알 수 있습니다.

  • index생성하지 않았을 때
+--------------------------------------------------------------------------------------------------+
|QUERY PLAN                                                                                        |
+--------------------------------------------------------------------------------------------------+
|Seq Scan on member  (cost=0.00..369.04 rows=1 width=156) (actual time=0.008..1.195 rows=1 loops=1)|
|  Filter: ((name)::text ~~ '%이름%'::text)                                                          |
|  Rows Removed by Filter: 10002                                                                   |
|Planning Time: 0.184 ms                                                                           |
|Execution Time: 1.211 ms                                                                          |
+--------------------------------------------------------------------------------------------------+
  • index를 생성한 후
+---------------------------------------------------------------------------------------------------------------------------+
|QUERY PLAN                                                                                                                 |
+---------------------------------------------------------------------------------------------------------------------------+
|Bitmap Heap Scan on member  (cost=12.01..16.02 rows=1 width=156) (actual time=0.013..0.014 rows=1 loops=1)                 |
|  Recheck Cond: ((name)::text ~~ '%이름%'::text)                                                                             |
|  Heap Blocks: exact=1                                                                                                     |
|  ->  Bitmap Index Scan on full_index_work_log  (cost=0.00..12.01 rows=1 width=0) (actual time=0.009..0.010 rows=1 loops=1)|
|        Index Cond: ((name)::text ~~ '%이름%'::text)                                                                         |
|Planning Time: 0.164 ms                                                                                                    |
|Execution Time: 0.039 ms                                                                                                   |
+---------------------------------------------------------------------------------------------------------------------------+

성능 개선을 확인한 결과 실행시간 1.211ms 에서 0.039ms로 약 95% 개선된 것을 확인할 수 있었습니다.


Refs.

]]>
<![CDATA[API 성능개선 : Redis Cache를 적용하여 Read API 기능을 개선해보자]]>이 글은 당근마켓을 모티브로 한 프로젝트 Secondhand 구현시 이슈 사항을 정리한 글입니다.

사실 처음 시작은 조회수 구현을 어떻

]]>
http://localhost:2369/api-seongneunggaeseon-redis-cachereul-jeogyonghayeo-seongneungeul-gaeseonhaeboja/650811277e046f38de8c2771Mon, 18 Sep 2023 09:05:30 GMT이 글은 당근마켓을 모티브로 한 프로젝트 Secondhand 구현시 이슈 사항을 정리한 글입니다.

사실 처음 시작은 조회수 구현을 어떻게 효율적으로 할 수 있을까에서 출발하였습니다. [조회수 관련해서 참고한 블로그 글] 세션별로 중복되지 않게 일정 시간동안 1회씩 카운팅하고 싶은 욕심이었습니다.

그런데 학습을 하다보니 레디스 Cache를 읽기 API에서도 사용해보고 싶더라고요🔥 API 성능을 개선해본 뒤 차근차근 조회수도 개선해보도록 하겠습니다.

Cache에 대하여

캐시 Cache는 자주 사용하는 연산에 대하여 속도가 빠른 임시 공간에 저장해두어 애플리케이션 연산 속도를 높일 수 있습니다.

Cache는 나중에 요청을 위해 결과를 미리 저장해두었다가 빠르게 서비스 해주는 것을 의미합니다.
- 우아한레디스 (강대명)

애플리케이션이 Database(Disk)에 접속하여 데이터를 읽어오는 것보다는 자주 읽는 데이터를 애플리케이션이 빠르게 접근할 수 있는 메모리에 올려두고 빠르게 가져와 사용하는 것이지요. 창고에 있는 데이터를 책상에 올려두는 것과 같습니다.

비단 읽는 것뿐만 아니라 쓰기작업 혹은 연산 작업에도 적용됩니다. 다이나믹 프로그래밍을 생각해보세요. 중간 연산 결과를 계속 캐싱하여 다음 연산을 실행한다면 매번 처음부터 연산을 하는 것보다 훨씬 빠를 겁니다.

Lightbox

이 글에서는 Redis를 이용해서 cache를 구현합니다. 하지만 캐시 구현에 가장 손쉬운 방법은 ConcurrentHashMap 과 같은 자료구조를 이용해 로컬 인메모리로 구현하는 것일 수 있습니다.

Cache 와 Buffer 뭐가 다르지?

대부분 두 용어의 차이점을 정확하게 알지 못하기 때문에 종종 이 용어를 횬용해서 쓰기도 해서 간단하게 짚고 넘어가겠습니다. (제 얘기)

사실 엄밀히 말하자면 다른 의미를 갖고 있습니다.

공통점

캐시와 버퍼는 둘 다 데이터를 임시로 저장하는 데 사용됩니다.

차이점

Buffer는 전통적으로 빠른 속도의 장치와 느린 속도의 장치 사이에서 데이터를 일시적으로 보관하는 데 사용합니다. 빠른 속도의 장치에서 느린 속도의 장치로 데이터를 보낼 때 데이터의 유실, 손실 상황을 막기 위함입니다. 느린 속도의 장치가 이 버퍼 데이터를 받을 때는 한번에 받도록 하여 이 속도 차이를 완화시킵니다. 그러다보니 데이터는 버퍼에서 한 번만 쓰거나 읽을 수 있습니다.

예를 들어 컴퓨터에서 입력장치인 키보드의 데이터를 CPU가 받는다고 할 때, 키보드 버퍼에 데이터를 잠시 저장하거나 네트워킹에서 다른 장치로 데이터가 이동할 때 잠시 저장해두는 용도로 사용할 수 있습니다.

Cache 의 경우, 성능 향상에 목적이 있습니다. 캐시는 자주 사용하는 정보, 데이터(값비싼 연산결과나 자주 참조되는 데이터)를 메모리에 올려두고 사용할 수 있는 저장소입니다.

디스크 I/O보다 캐시 메모리에 올라간 데이터를 읽는 것이 훨씬 빠르기 때문입니다.

버퍼와 달리 데이터가 한번 읽고 쓰는 것으로 끝나지 않고, 일정 시간동안 계속 있으면서 반복적인 작업에 사용됩니다.

마치 책상 위에 자주 쓰는 필기구와 노트를 가져다 놓는 것처럼요. 서랍에 있으면 매번 꺼내쓰는 데 번거롭겠죠?

pen near black lined paper and eyeglasses
Photo by Jess Bailey / Unsplash

언제 사용해야 좋을까요?

캐시는 애플리케이션 전반에서 사용할 수 있습니다.

만약 캐시가 없는 애플리케이션 서버가 동일한 API 요청을 N번 받으면 매번 DB 조회 후에 모든 로직을 거쳐 반환하는 과정을 겪어야 겠지요.

대표적인 사용 예시

캐시의 장점은 데이터베이스에 직접 조회하는 것보다 빠른 것뿐만 아니라 데이터베이스의 부하를 줄일 수 있다는 점도 있습니다. 같은 API로직을 10번 요청받았을 때, 최초 1회만 DB에 접근하​면 되니까요.

캐시에 유리한 데이터

캐시에 들어갈 데이터는 자주 사용되지만 변경은 자주 일어나지 않는 것이 유리합니다.

예를 들어 채팅로그와 같은 데이터는 자주 바뀌기 때문에 사실 캐싱하기에는 별로 좋지 않은 데이터이지요. 물론 어떻게 구현하느냐, 어떤 로직이 필요하느냐에 따라 선택의 결과는 다를 수 있습니다.

아무것도 모를 때 채팅을 캐싱해보겠다며 팀 메이트와 그려본 플로우 차트 👀

다만 조회는 잦으면서 변함이 별로 없는 캐시라면 TTL을 길게해서 메모리에 올려두면 더 좋겠죠?

캐시를 사용하기 전에 선택할 것들

이처럼 캐시를 이용하여 DB 커넥션을 줄이고 서비스 로직 실행도 생략할 수가 있어서 DB와 애플리케이션 성능에 크게 도움이 되는데요. 캐시를 도입하기 전에 선택해야 하는 문제들이 몇가지 있습니다.

Local cache와 Global Cache

캐시를 WAS(Web Application Server) 에 저장하는 방식인 Local cache와 별도 캐시 서버를 구축하는 Global Cache 방식 두가지가 있는데요. 👉 Local cache 참고링크

이번 프로젝트의 경우에는 Scale out 가능성이 있다는 가정을 하고 있으며, 이미 Redis pub/sub으로 채팅 서비스를 구현한 상태이기 때문에 자연스럽게 Redis cache를 이용하여 Global Cache 를 사용하기로 하였습니다.

Local cache의 경우, WAS 인스턴스 메모리에 데이터를 저장하므로 속도가 매우 빠르고 별도 인프라를 구축할 필요가 없어서 선택하는 경우도 있습니다.

캐시 읽기/쓰기 전략과 TTL

캐시 읽기 전략과 쓰기 전략에는 여러가지 종류가 있었는데요. 데이터베이스와 캐시에서 어떻게 데이터를 가져올지에 대한 전략입니다.

캐싱할 API의 성격과 환경에 따라 선택해야 했습니다. 해당 전략에 대해서는 이 링크를 참고하였습니다.

우선적으로 캐싱 할 API로는 다음의 세가지를 염두에 두고 있었는데요.

  1. 상품판매 목록 조회
    1. 첫 페이지이므로 조회 빈도가 높은 편
    2. 여러 데이터를 조회해야하므로 쿼리가 복잡한 편
    3. 다만 데이터의 수정이 잦은 편이기 때문에 TTL을 짧게 해두는 것이 좋을 것 같음
  2. 지역 목록 조회
    1. 키워드 검색 결과 (key를 키워드를 붙여서 적용)
    2. 데이터가 변함이 없는 편
    3. TTL을 조금 길게 해두어도 좋을 것 같음

읽기 API에 적용해보자

우선적으로 캐싱 할 API로는 다음을 염두에 두고 있었는데요. 이유는 아래와 같습니다.

1. 상품판매 목록 조회

    • 첫 페이지이므로 조회 빈도가 높은 편
    • 여러 데이터를 조회해야하므로 쿼리가 복잡한 편
    • 다만 데이터의 수정이 잦은 편이기 때문에 TTL을 짧게 해두는 것이 좋을 것 같음

2. 지역 목록 조회

    • 키워드 검색 결과 (key를 키워드를 붙여서 적용)
    • 데이터가 변함이 없는 편
    • TTL을 조금 길게 해두어도 좋을 것 같음

3. 채팅 로그와 채팅방 메타 데이터

    • 속도가 중요한 도메인
    • 채팅 로그의 경우, 데이터가 계속 생성되어 쌓이는 특징을 가지고 있으므로 수정, 삭제를 신경쓰지 않아도됨
    • 쓰기 버퍼의 역할도 할 수 있을 것 같음.

막상 적용하려고 자료를 찾아보니 Spring Framework는 캐시 추상화가 되어있기 때문에 어노테이션을 기반으로 손쉽게 구현할 수 있었습니다. 이에 대한 내용은 다음 글을 통해 작성해보도록 하겠습니다.


Refs.

]]>
<![CDATA[API 성능개선 : 페이지네이션 cursor 방식으로 개선해보자]]>이 글은 당근마켓을 모티브로 한 프로젝트 Secondhand 구현시 이슈 사항을 정리한 글입니다.

API 기본 기능을 모두 구현하고 본격적

]]>
http://localhost:2369/api-seongneunggaeseon-200mangeonyi-deiteo-peiji/650416927e046f38de8c26b3Sun, 17 Sep 2023 02:16:04 GMT이 글은 당근마켓을 모티브로 한 프로젝트 Secondhand 구현시 이슈 사항을 정리한 글입니다.

API 기본 기능을 모두 구현하고 본격적으로 쿼리 튜닝과 성능 개선을 하기 위해 쿼리를 분석하고 개선해보도록 하겠습니다.

  • M1 16GB 메모리 환경에서 테스트되었습니다.

200만건의 데이터를 넣어보자

Dummy data를 넣는 다양한 방식이 있습니다. 하지만 저는 item 테이블을 vertical partitioning 한 상태여서 테이블 3개에 동시에 데이터를 넣어야 했기 때문에 프로시저를 통해 랜덤한 데이터를 넣어주고자 했습니다.

더미 데이터를 넣는 가장 빠른 방법은 csv로 데이터를 만든 후 dump insert하는 것이 가장 빠르겠지만, 랜덤하게 변수를 설정하는 부분이 다른 문서 편집기를 사용하는 것보다 MySQL 프로시저를 이용하는 편이 더 편했기 때문에 아래와 같이 프로시저를 만들고 실행시켰습니다. 200만건이 들어가는데에는 15분정도가 소요되었습니다. (이는 실행 환경마다 약간씩 다를 것 같습니다.)

DELIMITER $$

CREATE PROCEDURE InsertDummyData()
BEGIN
    DECLARE i INT DEFAULT 1; -- 1부터 삽입

    WHILE i <= 1000000 DO

        SET @title = CONCAT('중고거래 물품 #', i);
        SET @price = FLOOR(RAND() * 1000000) + 1;
        SET @category = FLOOR(RAND() * 12) + 1;
        SET @region_id = 1168065000; -- 지역값은 고정하였습니다.
    
        -- item_contents 테이블에 삽입
        INSERT INTO item_contents (contents, detail_image_url)
        VALUES ('랜덤한 내용 #', "https://placeimg.com/200/100/any");
    
        SET @item_contents_id = LAST_INSERT_ID();
    
        -- item_counts 테이블에 삽입
        INSERT INTO item_counts (hits, like_counts, chat_counts)
        VALUES (FLOOR(RAND() * 1000), FLOOR(RAND() * 1000), FLOOR(RAND() * 1000));
    
        SET @item_counts_id = LAST_INSERT_ID();
    
        -- item 테이블에 데이터 삽입
        INSERT INTO item (title, price, status, category, thumbnail_url, created_at, updated_at, seller_id, item_counts_id, region_id, item_contents_id, is_deleted)
        VALUES (@title, @price, 'ON_SALE', @category, "[{'url':'https://placeimg.com/200/100/any'}]", NOW(), NULL, 1, @item_counts_id, @region_id, @item_contents_id, 0);
    
        SET i = i + 1;
    
        -- 10000건마다 COMMIT 수행
        IF i % 10000 = 0 THEN COMMIT;
        END IF;

  END WHILE;
  COMMIT;

END $$
DELIMITER ;

-- 정의한 프로시저를 호출합니다.
call InsertDummyData();

성능 개선을 위해 기존 API 분석

가장 많이 호출되는 메서드인 item 목록 조회 쿼리를 Postman을 통해 실행시켜 보았습니다. 가장 초반에 구현하고 요구사항에 맞추기위해 조금씩 수정했던터라 효율면에서 가장 우려되는 API였습니다.

  • HTTP 요청사항
GET /items?regionId=1168065000&page=0 HTTP/1.1
Host: localhost:8080
  • 실행되는 쿼리
// 지역 유효성 검증을 위해 지역 id로 검색합니다.
Hibernate: select region0_.id as id1_7_0_, region0_.city as city2_7_0_, region0_.county as county3_7_0_, region0_.district as district4_7_0_ from region region0_ where region0_.id=?

// item을 조회합니다.
Hibernate: select item0_.id as id1_3_, item0_.created_at as created_2_3_, item0_.updated_at as updated_3_3_, item0_.category as category4_3_, item0_.item_contents_id as item_co10_3_, item0_.item_counts_id as item_co11_3_, item0_.is_deleted as is_delet5_3_, item0_.price as price6_3_, item0_.region_id as region_12_3_, item0_.seller_id as seller_13_3_, item0_.status as status7_3_, item0_.thumbnail_url as thumbnai8_3_, item0_.title as title9_3_ from item item0_ where item0_.region_id=? and item0_.is_deleted=? order by item0_.id desc limit ?, ?

// item의 조회수를 조회합니다. (batch size를 늘려주어 n+1을 1+1로 개선한 상태)
Hibernate: select itemcounts0_.id as id1_5_0_, itemcounts0_.chat_counts as chat_cou2_5_0_, itemcounts0_.hits as hits3_5_0_, itemcounts0_.is_deleted as is_delet4_5_0_, itemcounts0_.like_counts as like_cou5_5_0_ from item_counts itemcounts0_ where itemcounts0_.id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)

// 로그인한 멤버 조회합니다.
Hibernate: select member0_.id as id1_6_0_, member0_.member_id as member_i2_6_0_, member0_.oauth as oauth3_6_0_, member0_.profile_img_url as profile_4_6_0_ from member member0_ where member0_.id=?

// item의 조회수를 분할하여 조회합니다.
Hibernate: select itemcounts0_.id as id1_5_0_, itemcounts0_.chat_counts as chat_cou2_5_0_, itemcounts0_.hits as hits3_5_0_, itemcounts0_.is_deleted as is_delet4_5_0_, itemcounts0_.like_counts as like_cou5_5_0_ from item_counts itemcounts0_ where itemcounts0_.id=?

// 지역을 다시 조회합니다. (item 에 대한 1+1 쿼리로 나가는 듯)
Hibernate: select region0_.id as id1_7_0_, region0_.city as city2_7_0_, region0_.county as county3_7_0_, region0_.district as district4_7_0_ from region region0_ where region0_.id=?

실행 시간 확인

200만건의 데이터를 넣고서 가장 놀라웠던 부분이 실행 시간이 엄청나게 늘었다는 점입니다. offset 방식을 사용한 페이지네이션의 한계를 보여줍니다.

  • 실제 작성한 java 코드입니다.
@Override
public Slice<Item> findAllByBasedRegion(Long categoryId, Long sellerId, List<Status> sales, Long regionId, Pageable pageable) {
    int pageSize = pageable.getPageSize()+1;

    List<Item> fetch = jpaQueryFactory.selectFrom(item)
            .where(
                    eqRegion(regionId),
                    eqCategory(categoryId),
                    inSales(sales),
                    eqSeller(sellerId),
                    item.isDeleted.eq(false)
            )
            .offset(pageable.getOffset())
            .limit(pageSize)
            .orderBy(item.id.desc())
            .fetch();
    return new SliceImpl<>(getContents(fetch, pageSize-1), pageable, hasNext(fetch, pageSize-1));
}
  • 첫 페이지의 경우 별 문제가 없었습니다.
http://localhost:8080/items?region=1168065000&page=0
  • 거의 끝 페이지를 요청을 했을 때 실행 시간이 10배로 늘어납니다.
http://localhost:8080/items?region=1168065000&page=42012

💪 성능 개선을 하자

쿼리 정리

중복으로 나가는 쿼리를 삭제하기 위해 서비스 로직을 확인하였습니다. 지역 쿼리가 추가로 나가는 부분을 개선하였습니다.

  • item count 쿼리가 추가로 발생하는 부분은 hibernate가 쿼리를 캐싱한 결과입니다. [참고한 링크] 쿼리에 대한 캐싱 설정을 수정하면 다른 API 효율에 영향을 미칠 수 있을 것 같아 권장한 설정값대로 두었습니다.

인덱스 추가

-- 기존 인덱스
fk_item_item_contents1_idx
fk_item_item_count_idx
fk_item_member_idx
fk_item_region1_idx
create index fk_item_category_index
    on item (category);

검색 조건 중 category 에 대한 index가 없어서 추가해주었습니다. 그러나 쿼리에서는 무조건 지역 정보가 기본으로 하고 검색 조건이 필터링에 따라 추가되는 형태라 이 인덱스를 절대 타지 않았습니다. 그래서 필터 조건이 되는 region 조건을 추가하여 인덱스를 탈 수 있는 조건이 되도록 했습니다.

  • 아래는 가장 기본적인 검색 쿼리로 했을 때의 실행계획입니다.
+--+-----------+------+----------+----+-------------------+-------------------+-------+-----+----+--------+--------------------------------+
|id|select_type|table |partitions|type|possible_keys      |key                |key_len|ref  |rows|filtered|Extra                           |
+--+-----------+------+----------+----+-------------------+-------------------+-------+-----+----+--------+--------------------------------+
|1 |SIMPLE     |item0_|NULL      |ref |fk_item_region1_idx|fk_item_region1_idx|8      |const|1   |50      |Using where; Backward index scan|
+--+-----------+------+----------+----+-------------------+-------------------+-------+-----+----+--------+--------------------------------+
-- 기본 검색일 때
create index fk_item_region_is_deleted on item (region_id, is_deleted);

+--+-----------+------+----------+----+-------------------------+-------------------------+-------+-----------+----+--------+-------------------+
|id|select_type|table |partitions|type|possible_keys            |key                      |key_len|ref        |rows|filtered|Extra              |
+--+-----------+------+----------+----+-------------------------+-------------------------+-------+-----------+----+--------+-------------------+
|1 |SIMPLE     |item0_|NULL      |ref |fk_item_region_is_deleted|fk_item_region_is_deleted|10     |const,const|1   |100     |Backward index scan|
+--+-----------+------+----------+----+-------------------------+-------------------------+-------+-----------+----+--------+-------------------+

No-offset 방식으로 개선

Offset을 사용한 페이지 방식의 문제점

이전의 쿼리가 offset 방식으로 구현된 것을 Cursor 방식으로 바꾸기로 하였습니다.

오프셋 방식은 페이지 번호만 알면 되므로 간편하고 처리가 매우 쉽습니다. 임의의 페이지에 접근하려고 할 때는 해당 페이지의 offset인 페이지 번호만 알면됩니다.

-- 대표적인 offset 방식 페이지네이션의 쿼리 예시

SELECT *
FROM sales
ORDER BY sale_date DESC
LIMIT 10 OFFSET 10

하지만 이 방식을 뜯어보면 치명적인 단점을 발견할 수가 있습니다.

뒷 페이지로 갈 수록 성능 저하

오프셋 방식은 첫번째 레코드부터 마지막 레코드까지 번호를 매기고 원하는 페이지의 내용에서 필요없는 방식을 삭제하는 방법이기 때문에 첫 페이지는 별 문제가 없지만 점점 뒤의 페이지 일수록 빠른 성능 저하가 일어납니다. 스캔할 인덱스 범위가 더 커지기 때문입니다.

위의 예시 쿼리로 보았을 때 페이지 4의 레코드를 불러오기 위해서는 SALE_DATE 로 정렬된 레코드를 순서대로 읽어 4페이지 분량까지 스캔한 후, 3페이지의 분량을 삭제하는 방식인 것입니다.

데이터 추가시 중복 데이터 노출

또 다른 문제점은 만약 페이지 요청과 다음 페이지 요청 사이에 누군가 글을 삭제하거나 작성하면 페이지 내용이 밀려 중복된 데이터가 노출될 수 있다는 것입니다. 스크롤을 이용한 UI를 사용하는 환경에서는 조회 데이터가 누적되어 보이므로 치명적인 단점입니다. 새로고침 하거나 다른 페이지를 조회했다 다시 오지 않는 이상 데이터가 정정될 수도 없습니다. (같은 데이터가 여러번 보이면 아무리 봐도.. 200 OK가 무색하게 사용자에게는 렉이나 오류처럼 보일것 같지요... 👀)

Cursor 방식

그래서 대안이 offset을 사용하지 않는 방식입니다. 이전 페이지의 마지막 값을 사용하여 하한을 지정하는 방식입니다.

SELECT *
  FROM sales
 WHERE sale_date < ?
 ORDER BY sale_date DESC
 FETCH FIRST 10 ROWS ONLY

이 방법에서는 정렬 순서와 인덱싱이 매우 중요합니다. 인덱싱을 잘 활용하여 데이터 스캔하는 범위를 줄일수록 성능 개선에 효과적이기 때문입니다.

CREATE INDEX sl_dtid ON sales (sale_date, sale_id) -- 정렬조건에 맞는 인덱스를 생성해줍니다.

SELECT *
  FROM sales
 WHERE (sale_date, sale_id) < (?, ?)
 ORDER BY sale_date DESC, sale_id DESC
 FETCH FIRST 10 ROWS ONLY

초반에는 큰 차이가 나지 않지만 뒷 페이지 접근을 시도하면 차이점을 확연히 느낄 수가 있었습니다.

👇 이 내용을 이해하는 데는 해당 링크가 큰 도움이 되었습니다.

OFFSET is bad for skipping previous rows
OFFSET doesn’t deliver stable results and makes the query slow. Key-set pagination does neither.

아래는 cursor 방식으로 개선한 Java 코드입니다.

public Slice<Item> findAllByIdAndRegion(Long last, Long categoryId, Long sellerId, List<Status> sales, Long regionId, Pageable pageable) {
    int pageSize = pageable.getPageSize()+1;

    List<Item> fetch = jpaQueryFactory.selectFrom(item)
            .where(
                    eqLast(last),
                    eqRegion(regionId),
                    eqCategory(categoryId),
                    inSales(sales),
                    eqSeller(sellerId),
                    item.isDeleted.eq(false)
            )
            .limit(pageSize)
            .orderBy(item.id.desc())
            .fetch();
    return new SliceImpl<>(getContents(fetch, pageSize-1), pageable, hasNext(fetch, pageSize-1));
}

실행 쿼리

// region 검증을 위한 조회
Hibernate: select region0_.id as id1_7_0_, region0_.city as city2_7_0_, region0_.county as county3_7_0_, region0_.district as district4_7_0_ from region region0_ where region0_.id=?
// item 조회
Hibernate: select item0_.id as id1_3_, item0_.created_at as created_2_3_, item0_.updated_at as updated_3_3_, item0_.category as category4_3_, item0_.item_contents_id as item_co10_3_, item0_.item_counts_id as item_co11_3_, item0_.is_deleted as is_delet5_3_, item0_.price as price6_3_, item0_.region_id as region_12_3_, item0_.seller_id as seller_13_3_, item0_.status as status7_3_, item0_.thumbnail_url as thumbnai8_3_, item0_.title as title9_3_ from item item0_ where item0_.id<? and item0_.region_id=? and item0_.is_deleted=? order by item0_.id desc limit ?
// item count 조회
Hibernate: select itemcounts0_.id as id1_5_0_, itemcounts0_.chat_counts as chat_cou2_5_0_, itemcounts0_.hits as hits3_5_0_, itemcounts0_.is_deleted as is_delet4_5_0_, itemcounts0_.like_counts as like_cou5_5_0_ from item_counts itemcounts0_ where itemcounts0_.id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
// login member 조회
Hibernate: select member0_.id as id1_6_0_, member0_.member_id as member_i2_6_0_, member0_.oauth as oauth3_6_0_, member0_.profile_img_url as profile_4_6_0_ from member member0_ where member0_.id=?
Hibernate: select itemcounts0_.id as id1_5_0_, itemcounts0_.chat_counts as chat_cou2_5_0_, itemcounts0_.hits as hits3_5_0_, itemcounts0_.is_deleted as is_delet4_5_0_, itemcounts0_.like_counts as like_cou5_5_0_ from item_counts itemcounts0_ where itemcounts0_.id=?

개선 결과

  • 첫 페이지에 대한 요청
    • 개선 이전과 비슷한 결과가 나왔습니다.
http://localhost:8080/v2/items?regionId=1168065000
  • 거의 끝 페이지 (version 1 테스트시 같은 데이터 내용) 요청
    • 실행 시간 138 ms로 약 1000% 성능 개선이 되었음을 알 수 있습니다.
http://localhost:8080/v2/items?regionId=1168065000&last=1050

개선의 한계점

API 요청 값을 바꾸지 않고 Offset을 바꿀 수 있는지?

이번 API 개선을 하면서 불가피하게 version 2의 API를 만들어야 했습니다. API 요청하는 서비스가 계속 지속되고 있는 상태에서 요청 파라미터의 개편이 있었기 때문에 기존 API를 유지한 채 진행한 것입니다. [API 버저닝 참고링크]

이를 유지하며 개선하다보니 요청 파라미터를 유치한채 cursor 방식으로 개편하는 방법에 대해 고민해보았는데요. 카운트 쿼리를 추가로 날리게 되고, 게시물의 중복 표시 문제가 해결되지 않는 것 같아 v2의 API를 구현하였습니다.

한번에 1000페이지를 요청하고 싶을 때는 어떻게 하지?

이번에는 무한 스크롤 형태의 페이징 형식이었지만 번호로 라벨링 되는 페이지 형식의 UI일 때는 언급했듯이 count 쿼리를 추가하여 한번에 성능 저하없이 이동할 수 있지 않을까 싶습니다.

대다수의 대형 서비스에서는 애초에 조회 가능한 데이터 수를 한정해 두는 것 같습니다. [참고한 링크: MySQL LIMIT 최적화(feat. 구글이 검색결과를 최대 1,000건만 제공하는 이유)]


Refs.

OFFSET is bad for skipping previous rows
OFFSET doesn’t deliver stable results and makes the query slow. Key-set pagination does neither.
3-1. 페이징 성능 개선하기 - 검색 버튼 사용시 페이지 건수 고정하기
모든 코드는 Github에 있습니다. 앞서 포스팅에서 실질 페이징 쿼리 성능을 올리는 방법들을 소개 드렸는데요. 1. 페이징 성능 개선하기 - No Offset 사용하기 2. 페이징 성능 개선하기 - 커버링 인덱스 사용하기 페이징 기능을 구현하는데 있어, 페이징 쿼리 자체를 개선하는 것도 방법이지만 그 외 다른 기능을 개선하는 방법도 함께할 수 있습니다. 여기서 말하는 그 외 기능은 바로 count 쿼리입니다. 일반적인 페이징 기능에 있어 데이터 조회와 함께 매번 함께 수행되는 것이 바로 count 쿼리인데요. 해당 조건으로 조회되…
]]>
<![CDATA[Spring Event를 사용하여 도메인 의존성 낮추기]]>이 글은 당근마켓을 모티브로 한 프로젝트 Secondhand 구현시 이슈 사항을 정리한 글입니다.

발단


Secondhand 에는 실시간 채팅 어플리케이션이

]]>
http://localhost:2369/spring-eventreul-sayonghayeo-domein-yijonseong-najcugi/64e42a58749ee079fb78d2a2Tue, 22 Aug 2023 03:24:19 GMT이 글은 당근마켓을 모티브로 한 프로젝트 Secondhand 구현시 이슈 사항을 정리한 글입니다.

발단


Secondhand 에는 실시간 채팅 어플리케이션이 있습니다. 웹소켓 통신을 기반으로 하며 STOMP와 Redis pub/sub 으로 구현하였는데요. 이 채팅에 대한 요구사항 중에 다른 도메인의 서비스를 참조하는 경우가 많아져 Facade가 비대해지고 각 도메인의 서비스 로직이 복잡하게 구성이 되며 의존성이 높아지는 우려가 생겼습니다.

@Component
@RequiredArgsConstructor
public class ChatroomFacade {
    private final ChatroomService chatRoomService;
    private final ItemService itemService;
    private final MemberService memberService;
    private final ChatroomCacheService chatroomCacheService;

		// 기타 등등 코드들
}
이미지 출처 : WIKI Facade

파사드 패턴으로 저수준 모듈인 서비스를 참조하고 있었는데, 이러다간 모든 도메인의 서비스를 다 참조하게 생겼습니다 👀

그 외에도 몇가지 문제가 대두되는데요. 첫번째로는 하나의 트랜잭션으로 이 전체 로직이 다같이 묶이다보니 뭐 하나가 취소되더라도 롤백을 시켜야 하게됩니다. 예를들어 채팅알람을 보내는데 실패한다면 채팅 발송이 실패해야할까요? 다른 방식으로 이를 처리할 수도 있습니다. 두번째는 이 어플리케이션의 동작 성능에 다른 어플리케이션이 영향을 받습니다. 중간에 어떤 작업이 지연된다면 뒤에 명시된 작업들도 모두 지연될 수 있습니다.

구현 모듈 구조

구현 배경이 되는 현재의 패키지 구조입니다. 크게 apichat으로 나뉘어 있는데, 채팅에 대한 도메인과 로직이 복잡해지며 따로 분리한 결과입니다.

api
⎿ chatroom // 채팅방. 아이템과 구매자(member) 정보를 관리합니다.
⎿ item // 중고 상품.
⎿ member // 회원.
⎿ oauth // 회원 인증인가.
⎿ region // 동네 지역.
⎿ wishlist // 관심 상품.

chat // 실시간 채팅에 대한 도메인들을 모아두었습니다.
⎿ bubble // 채팅 메시지 도메인.
⎿ metainfo // 채팅방 metainfo 도메인. 채팅방 인원, 안읽은 채팅수, 마지막 메시지를 관리합니다.
⎿ notification // 현재 접속중인 member와 실시간 채팅 알람.

global // 전역에서 사용하는 model, exception, config 등을 정의하고 있습니다.

이렇게 패키지 구조가 되어있을 때 도메인간 의존도가 복잡해질만한 로직과 수도코드는 다음과 같습니다.

  • 사용자가 채팅 메시지를 발송한다.
    • 채팅 알람을 상대방이 받을 수 있다.
    • 채팅 메시지가 저장되어야 한다.
    • 채팅방 MetaInfo를 ‘안 읽은 메시지’ 개수와 ‘채팅방에 마지막으로 발송된 메시지’가 갱신되어야 한다.

// messagePublishFacade에서

@Transactional
  public void publish(String topic, ChatBubble message) {
    message.ready();
    redisTemplate.convertAndSend(topic, message); // 채팅방 topic으로 pub
            
    chatbubbleService.save(message) // db에 저장
    chatNotificationService.push(message) // 회원에게 알람 발송
    chatMetainfoService.update(message) // metainfo 수정
  }

이 외에도 코드가 복잡해질 수 있는 비즈니스 로직이 매우 많았습니다.

이렇듯 하나의 트리거가 발생했을 때 연쇄적으로 일어나야 하는 서비스 로직이 여럿 있습니다. 심지어 이러한 코드의 문제는 하나의 트랜젝션에서 실행되므로 하나의 익셉션이 발생하면 모두 취소가 된다거나 하나의 로직이 모두 실행될 때까지 다른 로직들의 대기시간이 길어진다는 단점이 있습니다.

그리고 만약 로직이 더 추가된다면? 점점 파사드와 서비스가 비대해지며 코드가 더 복잡해질 수 있습니다.

💡 다른 예시
만약 회원가입을 한 회원에게 다음과 같은 서비스 로직이 동시에 적용되어야 한다면 어떻게 해야할까요?

- 회원에게 가입 환영 이메일을 발송합니다.
- 회원이 입력한 추천인에게 포인트를 줍니다.
- 회원에게 가입 기념 쿠폰을 발행합니다.
- …

이런 경우에 어떻게 도메인간의 의존성을 낮추고 코드를 간결하게 할 수 있을까 학습해보다 Spring Event 를 도입해보기로 했습니다. Spring Event를 통해 비동기로 연동되는 로직을 처리하거나 같은 트랜젝션 안에서 처리하게 구현할 수도 있습니다. 이렇게 처리하게 되니 기능 확장과 연동에 큰 자유를 얻게 되어서 좋았습니다.

Spring Event


여기서 event는 사전적인 의미 그대로 ‘과거 일어난 어떤 일’이라고 이해하면 쉬운데요. 예를 들어 위의 상황의 경우에서는 “회원이 채팅 메시지를 발송한다”가 하나의 이벤트가 될 수 있습니다.

Spring Event를 통해 이벤트가 발생하면 그 이벤트가 트리거가 되어 반응하여 원하는 동작을 수행하는 기능을 구현할 수 있습니다.

SpringEvent는 ApplicationContext가 제공하는 기능 중 하나입니다.

public interface ApplicationContext extends EnvironmentCapable, ListableBeanFactory, HierarchicalBeanFactory,
		MessageSource, ApplicationEventPublisher, ResourcePatternResolver {

실제 ApplicationContext는 ApplicationEventPublisher 를 참조합니다.

  • 동작 방식은 다음의 블로그 글을 참고했습니다. 참고링크
출처 : 도메인 주도 개발 시작하기

이벤트를 도입하려면 다음의 구성을 구현해야 합니다.

  1. 이벤트 : 이벤트 퍼블리셔를 통해 발행될 이벤트
  2. 이벤트 생성 주체 : 이벤트를 생성하여 Event Publisher에 이벤트를 전달합니다.
  3. 이벤트 퍼블리셔(이벤트 디스패처) : 해당 이벤트를 처리할 수 있는 핸들러에 이벤트를 전파합니다. (어노테이션을 기반으로 동작하며 해당하는 메서드를 invoke시키는 방식입니다.)
  4. 이벤트 핸들러 (이벤트 리스너) : 최종적으로 이벤트를 받아 처리합니다.

SpringEvent를 적용시켜보자


위에서 언급한 구성을 구현하여 다음과 같은 이벤트 처리 로직을 구현하려고 합니다.

  1. BubbleService 에서 Events.raise() 메서드를 통해 arrivedEvent 을 발행시킵니다.
  2. 그럼 퍼블리셔(디스패처)를 거쳐 이벤트가 발행되었을 때
  3. 각 다른 도메인의 arrivedEvent 핸들러가 동작합니다.
Events는 EventPublisher를 가지고 있으므로 같은 노드로 표현했습니다.

구현사항

BaseEvent

    • 먼저 모든 event에 공통으로 적용할 필드를 기반으로 BaseEvent 를 구현하였습니다.
    • Spring Framework 4.2 이전 버전을 사용하는 경우 이벤트를 ApplicationEvent를 확장해서 사용해야하지만, 이후 버전에서는 클래스를 확장하지 않고 구현할 수 있습니다.
    • 제네릭으로 이벤트 유형의 정보를 같이 전달하는 것도 가능합니다.
@Getter
public abstract class BaseEvent ~~extends ApplicationEvent~~ {
    private Instant createdAt;

	    protected BaseEvent() {
        this.createdAt = Instant.now();
    }
}

Event 구현

    • 이를 확장하여 구체적인 이벤트를 구현해주었습니다.
    • 해당 이벤트는 사용자가 발송한 채팅이 도착했을 때 발행되는 이벤트입니다.
@Getter
public class ChatBubbleArrivedEvent extends BaseEvent {
    private final ChatBubble chatBubble;

    public ChatBubbleArrivedEvent(ChatBubble chatBubble) {
        super();
        this.chatBubble = chatBubble;
        log.info("ChatBubble Arrived Event Occur = {}", chatBubble.toString());
    }

    public String getChatReceiverId() {
        return chatBubble.getReceiver();
    }
}

Events 구현

  • Event 를 발행을 시킬 클래스 Events 를 만들었습니다.
public class Events {
    private static ApplicationEventPublisher publisher;

    static void setPublisher(ApplicationEventPublisher publisher) {
        Events.publisher = publisher;
    }

    public static void raise(BaseEvent event) {
        if (publisher!=null) {
            publisher.publishEvent(event);
        }
    }
}

Event 발행 주체 구현

  • Service 에서 Event를 발생시킵니다.
@Transactional
    public void publish(String topic, ChatBubble message) {
        log.debug("pub log : " +  message.toString() + "/ topic: " + topic);
        message.ready();
        redisTemplate.convertAndSend(topic, message);

        Events.raise(new ChatBubbleArrivedEvent(message)); // 이벤트를 발생시킵니다.
    }

Event Handle 로직 구현

  • EventListener를 구현합니다.
@EventListener // 채팅방 메타 정보를 업데이트합니다.
public void chatBubbleArrivedEventHandler(**ChatBubbleArrivedEvent** event) throws NotChatroomMemberException {
    ChatBubble chatBubble = event.getChatBubble();
    Chatroom chatroom = getChatroom(chatBubble.getRoomId());
    chatroom.updateLastMessage(chatBubble);
    Chatroom saveChatroom = metaInfoRepository.save(chatroom);
}

이렇게 구현할 경우 다음과 같은 타임라인으로 동작합니다.

비동기 이벤트 처리

모든 이벤트가 순서대로 연쇄적으로 발생하지 않아도 되므로 비동기 이벤트 처리를 하는 편이 어플리케이션 효율성에 유리하다고 판단했습니다. 예를 들어 상대방이 채팅 메시지를 보냈을 때 채팅 metainfo 업데이트가 몇 초 후에 동작해도 상관이 없는 것이지요.

비동기 이벤트로 해당 코드를 바꾸기 위해서는 간단히 다음과 같이 어노테이션을 붙여주면 됩니다. 이 핸들링 메서드를 여러개 구현하면 이벤트가 발행될 때 모두 실행됩니다.

@Async // 비동기 이벤트처리합니다.
@EventListener
public void chatBubbleArrivedEventHandler(ChatBubbleArrivedEvent event) throws NotChatroomMemberException {
    ChatBubble chatBubble = event.getChatBubble();
    Chatroom chatroom = getChatroom(chatBubble.getRoomId());
    chatroom.updateLastMessage(chatBubble);
    Chatroom saveChatroom = metaInfoRepository.save(chatroom);
    Events.raise(ChatNotificationEvent.of(saveChatroom, chatBubble));
}
@EnableAsync // 이것도 붙여주어야 적용됩니다.
@SpringBootApplication
public class BackendApplication {

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

}

이벤트 발행 주체의 트랜잭션 단계와 바인딩 @TransactionalEventListener

이 어노테이션을 사용해 이벤트 리스너 실행과 트랜젝션 단계를 바인딩하여 실행할 수 있습니다.

phase 정의를 잘 활용하면 채팅 메시지 발송이 실패했을 때의 로직을 구현할 수 있을 것 같습니다.

  • AFTER_COMMIT : (기본값)은 트랜잭션이 성공적으로 완료된 경우 이벤트를 발생시키는 데 사용됩니다.
  • AFTER_ROLLBACK : 트랜잭션이 롤백된 경우
  • AFTER_COMPLETION : 트랜잭션이 완료된 경우
  • BEFORE_COMMIT : 트랜잭션 커밋 직전에 실행
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) // 이런식으로 사용합니다.
public void handleCustom(CustomSpringEvent event) {
    log.info("Handling event inside a transaction BEFORE COMMIT.");
}

조건부 이벤트 구현

이벤트 내부 정보로 플래그를 구현하고 이를 condition 에 작성해주면 조건부로 이벤트를 만들 수 있습니다.

public GenericSpringEvent T {
	private boolean notificationAgree; // 플래그를 구현합니다.
}
@EventListener(condition = "#event.notificationAgree") // 이벤트를 조건부로 만드는 것도 가능합니다.
public void handleSuccessful(GenericSpringEvent<String> event) {
    log.info("Handling generic event (conditional).");
}

구현의 한계와 개선할 점


코드 가독성과 디버깅 문제

이와 같이 이벤트를 구현하여 서비스 로직을 연동하였을 때, 도메인 의존성을 낮추려는 목표는 훌륭히 달성하였습니다. 하지만 이벤트 발행이 어떤 영향을 미치는 지 코드를 읽기 어려워졌다는 단점이 있습니다. 이벤트를 발행했을 때 어떤 로직이 발생하는 지 테스트와 디버깅을 하기 힘들어졌다는 점 또한 아쉬운 점입니다.

로컬 핸들러의 한계

Spring Event는 어플리케이션 로컬 핸들러로 이루어지므로 하나의 어플리케이션에서 실행한다는 한계점이 있습니다. 특히 다음의 주의사항을 고려해야 합니다.

  • 로컬 핸들러의 경우 이벤트 유실 주의
    • 이벤트를 핸들링하다 예외 발생 등으로 실패하면 해당 이벤트는 유실될 수 있습니다.
  • 이벤트 재처리하며 멱등성 주의
    • 만약 이벤트 재처리를 위한 로직을 구현한다면, 재처리를 통해 데이터가 변경되거나 의도와 다르게 어플리케이션이 동작하지는 않는지 주의해야 합니다.

MSA를 구현한다면 서비스간 이벤트 핸들링을 하는 것이 힘듭니다. 이럴경우 다음의 대안들이 있습니다.

  • 메시지 큐를 사용합니다. -> 이 방법을 가장 많이 사용하는 것 같습니다.
    • 카프카, 레빗MQ 등 메시징 시스템
  • 이벤트 저장소와 이벤트 포워더를 사용합니다.
    • 이벤트 저장소 DB와 포워더를 구현하여 사용합니다.
    • 이벤트 유실 가능성을 줄일 수 있습니다.
  • 이벤트 저장소와 이벤트 제공 API를 사용합니다.
    • 이벤트 저장소 DB와 외부 API를 사용합니다.

이에 대한 자세한 정보는 `도메인 개발 시작하기` 책에서 참고할 수 있으며 현재 프로젝트 규모에서는 다루지 않았습니다만 중요한 이벤트라거나 좀 더 복잡한 구조의 어플리케이션, 혹은 MSA에서는 적극 메시지 큐, 이벤트 저장소 도입을 고려할 것입니다.

실무에서의 이벤트 사용


실무 사례에서 카프카, 레빗MQ 등 메시징 시스템을 필두로 이벤트를 기반한 아키텍처를 손쉽게 찾아볼 수 있습니다. 물론 실무에서는 로컬 핸들링만이 아니라 계층적으로 이를 구조화하고 이벤트 저장소를 도입한 이벤트 기반 아키텍처를 구현한 모습입니다.

이런 식으로 이벤트의 발행과 구독으로 어플리케이션 구동이 연동될 수 있습니다. 출처 : https://techblog.woowahan.com/7835/

Spring Event 구현하고 이벤트 기반 아키텍처를 공부하면서 도움이 되었던 글과 영상을 남깁니다.


Refs.

]]>
<![CDATA[해시테이블(Hash Table) 자바코드로 구현하기]]>

해시테이블이란?

한정되지 않은
https://en.wikipedia.org/wiki/Hash_table

HashMap이라고도 알려진 해시 테이블(HashTable)은 키(Key)를 값(Value)로 매핑하는 추상 데이터 유형입니다. 내

]]>
http://localhost:2369/hashtable-java/64e40f8c749ee079fb78d291Sun, 20 Aug 2023 01:29:00 GMT

해시테이블이란?

한정되지 않은
https://en.wikipedia.org/wiki/Hash_table

HashMap이라고도 알려진 해시 테이블(HashTable)은 키(Key)를 값(Value)로 매핑하는 추상 데이터 유형입니다. 내부적으로 연관 배열(associative array)나 딕셔너리(dictionary)을 사용하여 데이터를 저장합니다.

각 키Key에 해시 함수를 사용하여 해시코드라고도 하는 인덱스(index)를 생성하고 이 index 위치에 값(Value)가 저장됩니다.

colors = {
    "Red"    : "#FF0000",
    "Green"  : "#00FF00",
    "Blue"   : "#0000FF",
    "White"  : "#FFFFFF",
    "Black"  : "#000000",
    "Yellow" : "#FFFF00",
    "Cyan"   : "#00FFFF",
    "Magenta": "#FF00FF",
    "Gray"   : "#808080",
    "Orange" : "#FFA500"
}

예를 들어 위의 구조에서 키는 "Gray", 값은 "#808080" 를 저장해두면 키인 Gray로 바로 값을 찾을 수 있습니다. 해시 함수를 통해 키인 "Gray"를 인덱스로 만든 후 배열의 인덱스 자리에 { “Gray” : "#808080” } 를 저장합니다.

여기서 인덱스의 범위는 해시 테이블의 크기에 의존적입니다. 예를 들어 해시테이블 크기가 100이면 해시 함수로 얻을 수 있는 인덱스의 범위는 0부터 99까지입니다.

해시 테이블에서 를 할 때 평균 시간 복잡도는 테이블에 저장된 요소의 개수와는 무관하게 O(1)입니다.

AlgorithmAverageWorse
SearchO(1)O(n)
InsertO(1)O(n)
DeleteO(1)O(n)

저장/삭제할 때 키Key를 기준으로 테이블에 저장/삭제할 위치를 계산하여 시행합니다. (시간복잡도 O(1)) 조회를 할 때도 위치 계산하여 O(1) 시간에 데이터를 조회합니다.

어떻게 사용하면 좋을까?

장점

  • 평균적으로 O(1)의 시간 복잡도로 조회,삽입, 삭제를 수행할 수 있어 데이터 접근을 빠르게 합니다.
  • 키를 기반으로 값을 저장하고 검색하는 구조로 데이터 관계를 보다 쉽고 명확하게 관리가 가능합니다.
  • 키Key는 유일한 값으로 저장해야하므로 중복을 방지할 수 있습니다.

단점

  • 해시 함수의 결과값으로 나오는 Index의 값이 중복되면 해시충돌이 일어날 수 있습니다.
  • 순서대로 배열에 저장되는 것이 아니므로 순서가 필요한 로직에는 사용하기 힘듭니다.

사용 예시

  • 데이터 중복 검사가 필요한 경우
  • 데이터 접근을 빠르게 해야하는 경우
  • 캐싱 Caching
    • Redis에 Key-Value를 저장할 때 내부적으로 해시테이블 구조를 사용합니다.
    • 이 경우 충돌하는 항목 중 하나를 삭제하여 해시 충돌을 처리하기도 합니다.
  • Key-Value 구조로 데이터를 관리해야 하는 경우

해시 충돌 Hash Collision

키Key에 해시 함수를 적용했을 때 결과 값이 동일한 데이터 2개가 있다고 했을 때 어떤 것을 어떻게 저장해야 할까요? 이렇게 해시 테이블 내에 삽입하고자 하는 메모리 위치에 다른 데이터가 저장을 시도하는 경우 해시 충돌이 발생한다고 합니다.

만약 해시 함수의 결과값 범위가 무한하다면 이런 일이 일어나지 않겠지만, 해시 함수의 결과가 해시 테이블 크기 범위 이내이기 때문에 이러한 문제가 발생합니다.

이에 대한 대표적인 해결법 2개를 적용한 구현 사례를 공유하겠습니다.

Separate Chaning

undefined
https://en.wikipedia.org/wiki/Hash_table

  • 해시 충돌이 일어나는 데이터를 연결 리스트 LinkedList 의 마지막 혹은 처음 노드로 연결해서 저장합니다.
  • 해시 테이블 구현의 일반적인 방식입니다.
import java.util.*;

public class SeparateChaningHashTable<K, V> implements HashTable{

    private static final float LOAD_FACTOR = 0.75f;
    private LinkedList<Entry<K, V>>[] tables;
    private int size;

    @Override
    public void put(Object key, Object value) {
        if (isResizingRequired()) {
            resize();
        }

        Entry<K, V> objectObjectEntry = new Entry<>((K)key, (V)value);
        tables[hashCode(key)].add(objectObjectEntry);
        numberOfItems ++;
    }

직접 구현할 때 내부 자료구조를 LinkedList의 배열로 구현할 수 있습니다.

    @Override
    public Object get(Object key) {
        if (isEmpty()) {
            throw new EmptyStackException();
        }
        return tables[hashCode(key)].stream()
                .filter(e -> e.getKey().equals(key))
                .map(Entry::getValue).findAny()
                .orElseThrow(() -> new NoSuchElementException("해당하는 요소가 없습니다."));
    }

    public int hashCode(Object key) {
        return Math.abs(key.hashCode()) % tables.length;
    }
}

조회를 할 때는 LinkedList를 탐색하여 Key가 일치하는 데이터를 반환합니다.

Open Addressing

한정되지 않은
https://en.wikipedia.org/wiki/Hash_table
  • 모든 항목 데이터가 배열에 저장되는 형태입니다.
  • 새 항목을 삽입해야하는데 해시 충돌이 일어나면 해시된 슬롯부터 시작하여 비어있는 슬롯을 찾을 때까지 프로브 서열probe sequences 순서로 진행하면서 배열을 검사하여 먼저 찾는 빈 슬롯에 레코드를 저장합니다.
  • 주로 사용하는 probe sequences 는 다음과 같습니다.
    • 선형 탐색 Linear probing
      • 프로브 사이 간격이 고정되어 있습니다. 보통 1입니다.
    • 이차원 프로빙 Quadratic probing
      • 원래 계산 결과값에 2차 다항식의 연속 출력을 추가하여 프로브 사이의 간격을 늘립니다.
    • 더블 해싱 Double hashing
      • 프로브 간의 간격을 보조 해시 함수로 계산합니다.

다음은 선형 탐색 방식으로 구현한 사례입니다. (기본값 1)

import java.util.*;

public class LinearProbingHashTable<K, V> implements HashTable {

    private static final float LOAD_FACTOR = 0.6f;
    private Entry<K,V>[] tables;
    private int size;

    public LinearProbingHashTable(int capacity) {
        this.tables = new Entry[capacity];
        this.size = 0;
    }

}

Entry 배열을 내부 구조로 가지고 있습니다.

    @Override
    public void put(Object key, Object value) {
        if (isResizingRequired()) {
            resize();
        }

        Entry<K, V> objectEntry = new Entry<>((K) key, (V) value);
        int index = hashCode((K) key);

        // 같은 Key가 있는지도 확인해야 합니다.. 같은 Key가 있을 경우 삽입이 불가능합니다.

        while (tables[index] != null) {
            index = (index + 1) % tables.length;
        }

        tables[index] = objectEntry;
        numberOfItems++;
    }

    @Override
    public void remove(Object key) {
        int index = hashCode((K) key);

        for (int i=0; i<tables.length; i++) {
            if (tables[index].getKey().equals(key)) {
                tables[index] = null;
                numberOfItems --;
                return;
            }
            index = (index+1)% tables.length;
        }
    }

    @Override
    public Object get(Object key) {
        int index = hashCode((K) key);

        for (int i=0; i<tables.length; i++) {
            if (tables[index] != null && tables[index].getKey().equals(key)) {
                return tables[index].getValue();
            }
            index = (index+1)% tables.length;
        }

        throw new NoSuchElementException();
    }
}

이렇게 구현할 때의 가장 잊기 쉬운 점은 결국 자료구조를 순회하며 키Key가 같은 것을 찾아야 한다는 점입니다. 조회를 할때나 삭제를 할 때도 해당 위치에 있는 키Key가 일치하는지 검증하고 일치하지 않다면 index를 +1씩 하며 순회하며 탐색해줍니다.

이런 경우 시간복잡도가 최악의 경우 O(n)이 됩니다.


  • 코드 전체는 github에서 확인하실 수 있습니다.

참고자료

]]>
<![CDATA[MySQL 검색 기능 개선하기 : Full-text search]]>이 글은 당근마켓을 모티브로 한 프로젝트 Secondhand 구현시 이슈 사항을 정리한 글입니다.

발단 : 왜 이것이 필요했나?


Secondhand는 동네를 검색

]]>
http://localhost:2369/mysql-full-test-search/64e12f21749ee079fb78d205Sat, 19 Aug 2023 21:07:52 GMT이 글은 당근마켓을 모티브로 한 프로젝트 Secondhand 구현시 이슈 사항을 정리한 글입니다.

발단 : 왜 이것이 필요했나?


Secondhand는 동네를 검색할 수 있는 기능이 있습니다. 예를 들어 이런거요.

[이미지 출처 : https://kikimong.com/7179]

검색을 용이하게 하기 위해 일부러 비정규화하였습니다.

먼저 데이터는 이런식으로 들어가 있습니다. 법정동 코드를 pk 로 하였습니다. 행정동 코드보다 법정동 코드를 선택한 이유는 변동이 더 적기 때문입니다. [참고링크 : 법정동과 행정동의 차이는?]

그래서 이것을 어떻게 구현하면 좋을까요?

  • 이 글은 mysql 8.0 InnoDB 환경에서 테스트 되었습니다.

전개


초기 구현 방법 Where ~ like

사실 이 프로젝트의 기간 제한이 있었습니다. 총 4주간의 기간동안 처음 구현하는 채팅 시스템까지 모두 구현하는 것이 목표였기 때문에 이 로직에 투자할 시간이 매우 적었습니다. 그래서 가장 처음에는 가장 쉬운 방법으로 구현을 시도했습니다.

select (columns) from region where concat(city,county,district) like '%string%';
  • concat() 으로 city, county, district를 합쳐 검색할 수 있게 했습니다.
  • like %검색어% 로 검색어 앞 뒤로 다른 문자열이 있더라도 해당 문자를 포함하는 결과를 조회할 수 있도록 했습니다.

그런데 이 구현방법에는 심각한 단점들이 있었습니다.

먼저 기술적인 단점으로 like %검색어% 를 사용하게 되면 인덱스를 사용하여 성능개선을 하는 것이 불가능합니다. 인덱싱은 저장된 레코드의 제일 첫번째 글자부터 하나씩 비교해가며 일치하는 접두사가 있을 때 이를 사용하여 원하는 레코드를 빠르게 찾기 때문입니다. [8.3.1 How MySQL Uses Indexes] Full table scan을 하면서 성능 면에서 매우 떨어질 것이라는 예상을 할 수 있었습니다.

  • explain 시 결과
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+--------------+
| id | select_type | table | partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra        |
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+--------------+
| 1  | SIMPLE      | r     | NULL       | ALL  | NULL          | NULL | NULL    | NULL | 1117 | 100      | Using where; |
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+--------------+
  • 실제 실행시 결과
430 rows retrieved starting from 1 in 150 ms (execution: 18 ms, fetching: 132 ms)

그래도 일단 복합 index를 추가해봅니다.

인덱스를 탈 수 없는 쿼리라는 것을 알았지만 복합 index를 생성하여 성능개선이 실제로 되었는지 테스트 해보았습니다. 검색 범위가 3개의 컬럼이었기 때문에 이에 대해 미리 정렬되어있는 index가 있다면 검색 성능에 더 유리해지지 않을까 싶었기 때문입니다.

물론 이렇게 복합 index를 추가할 수 있었던 이유는 이 데이터는 여간해선 안변하는 고정된 데이터이기 때문입니다. 삽입 삭제가 잦은 데이터에서는 이 방법을 시도하지 않았을 것입니다.

  • 인덱스는 이렇게 생성해주었습니다.
CREATE INDEX idx_address ON region (city, county, district);
  • explain 시 결과
    • 인덱스를 타지 못할 것이라는 예상과는 달리 인덱스를 탈 수 있는 것을 확인할 수 있었습니다.
select (columns) from region where concat(city,county,district) like '%서울특별시강남구%';
+----+-------------+-------+------------+-------+---------------+-------------+---------+------+------+----------+--------------------------+
| id | select_type | table | partitions | type  | possible_keys | key         | key_len | ref  | rows | filtered | Extra                    |
+----+-------------+-------+------------+-------+---------------+-------------+---------+------+------+----------+--------------------------+
| 1  | SIMPLE      | r     | NULL       | index | NULL          | idx_address | 546     | NULL | 1117 | 100      | Using where; Using index |
+----+-------------+-------+------------+-------+---------------+-------------+---------+------+------+----------+--------------------------+
  • 실행시 성능
    • 성능이 눈에 뜨이게 좋아진 것도 확인할 수 있었습니다.
430 rows retrieved starting from 1 in 68 ms (execution: 13 ms, fetching: 55 ms)

위기 : 근데 누가 이렇게 검색해요 👀


위에서 언급했던 단점 중에 가장 치명적이라고 생각하는 단점이 바로 이것이었습니다. 과연 이용자가 정확하게 DB에 저장한 문자열 그대로 검색을 할까요? 대부분 그냥 서울강남 혹은 역삼동 이렇게 검색하지 강남구역삼1동 이라고 검색하지는 않겠지요?

지역검색 이렇게 하는 사람 있나요?

그래서 비즈니스 요구사항을 다시 확인하며 어떤 것을 구현해야 할지 정리해보았습니다.

  1. 시, 구, 동을 한꺼번에 검색할 수 있어야 한다.
  2. 정확한 문자열을 검색하지 않더라도 일부 문자열로 검색하더라도 원하는 동이 결과로 나와야 한다.
  3. 비슷한 결과나 인접한 동네 등 여러가지 결과를 추천해주어야 한다.
  4. 검색 성능을 향상시킨다.

그래서 이것을 어떻게 해결할 수 있을까요?

먼저 고려했던 것은 ElasticSearch 입니다. 검색 및 분석을 위한 엔진으로 빠르고 데이터 업데이트가 없는 주소 검색에 효과적일 것이라고 생각했기 때문입니다. [Elastic 가이드북] 하지만 빠른 구현을 요하는 시점에서 새로 배워서 적용하는 것보단 일단 빠르게 해당 구현을 마무리하고, 추후에 Log 시스템을 구축할 때 도입해보고 데이터가 3800여건밖에 안되는 동네 검색은 좀 더 가벼운 방식으로 해결해보고자 했습니다.

Full-text Search


그러다가 알게된 것이 전문검색이라고도하는 Fulltext search입니다. 게시물의 제목, 문장이나 문서 내용에서 키워드를 검색하는 기능입니다.

Full text index

이를 사용하기 위해서는 먼저 Full text Index를 먼저 정의해주어야 하는데요. 이는 MySQL에서 index와 같이 미리 데이터를 정렬해둔 자료구조를 생성하여 쿼리 및 DML 성능을 높일 수 있습니다.

InnoDB 혹은 MyISAM의 테이블에서 사용할 수 있습니다. char 혹은 varchar, text 등 텍스트를 다루는 컬럼에서 대해서만 사용할 수 있습니다. 테이블을 생성할 때 함께 생성하거나 나중에 ALTER TABLE 을 하여 추가할 수 있습니다.

💡
대용량 데이터의 경우, 인덱스를 만들어두고 데이터를 넣는 것보다는 데이터를 먼저 넣고 인덱스를 생성하는 편이 훨씬 빠르다고 합니다.
  • InnoDB에서 Full text index에 대한 자세한 설명은 이 링크를 참고했습니다.

다음의 쿼리를 실행하여 테이블 컬럼에 Fulltext index를 추가해 주었습니다. 여기서 ngram parser를 사용했는데요.

MySQL은 중국어, 일본어, 한국어(CJK)를 지원하는 내장형 ngram parser와 일본어용 설치 가능한 MeCab parser 플러그인을 제공합니다. (다른 DBMS는 추가적인 Parser를 제공하기도 합니다.)

ALTER TABLE region ADD FULLTEXT INDEX fulltext_address (city, county, district) with parser ngram;

ngram parser는 ngram_token_size를 기준으로 문자열을 분해하여 구문을 분해합니다. 예를 들어 서울특별시 라는 문자열을 ngram_token_size 가 2일 때(이게 기본값입니다.), ngram 파서로 토큰 분해한다면 서울, 울특, 특별, 별시 이렇게 4개의 토큰으로 분석하는 것입니다. 만약 공백이 있다면 구문 분석시 공백을 제거합니다.

-- 이 쿼리로 토큰화된 데이터를 볼 수 있습니다.
SELECT * FROM INFORMATION_SCHEMA.INNODB_FT_INDEX_CACHE ORDER BY doc_id, position;
  • 더 자세한 내용은 이 문서를 참고하였습니다.

Full-text Search

위와 같이 Full-text index를 생성한 후, 이제 검색을 테스트해보았습니다. 검색 모드는 2가지가 있는데요. Natural Language 검색과 Boolean 검색입니다.

Boolean Full-Text Searches

문자열을 단어 단위로 분리한 후 검색 규칙을 붙여서 검색을 할 수 있습니다. 행을 정렬하지 않습니다. 조건을 만족하면 반환하는 식입니다.

예를 들어 이 쿼리에서는 MySQL 이 들어가고 YourSQL 이 없는 항목을 검색합니다.

mysql> SELECT * FROM articles WHERE MATCH (title,body)
    -> AGAINST ('+MySQL -YourSQL' IN BOOLEAN MODE);
+----+-----------------------+-------------------------------------+
| id | title                 | body                                |
+----+-----------------------+-------------------------------------+
|  1 | MySQL Tutorial        | DBMS stands for DataBase ...        |
|  2 | How To Use MySQL Well | After you went through a ...        |
|  3 | Optimizing MySQL      | In this tutorial, we show ...       |
|  4 | 1001 MySQL Tricks     | 1. Never run mysqld as root. 2. ... |
|  6 | MySQL Security        | When configured properly, MySQL ... |
+----+-----------------------+-------------------------------------+
  • explain
+----+-------------+-------+------------+----------+------------------+------------------+---------+-------+------+----------+-----------------------------------+
| id | select_type | table | partitions | type     | possible_keys    | key              | key_len | ref   | rows | filtered | Extra                             |
+----+-------------+-------+------------+----------+------------------+------------------+---------+-------+------+----------+-----------------------------------+
| 1  | SIMPLE      | r     | NULL       | fulltext | fulltext_address | fulltext_address | 0       | const | 1    | 100      | Using where; Ft_hints: no_ranking |
+----+-------------+-------+------------+----------+------------------+------------------+---------+-------+------+----------+-----------------------------------+
  • 쿼리 실행시 성능
> SELECT * FROM region r WHERE MATCH(city, county, district) AGAINST('서울특별시 ㅈ*' IN BOOLEAN MODE);


430 rows retrieved starting from 1 in 51 ms (execution: 16 ms, fetching: 35 ms)
  • 결과

Natural Language Searches

검색 문자열을 token_size로 분리한 후 해당 단어 중 하나라도 포함되는 행을 찾습니다. 연관성이 높을 수록, 즉 포함된 토큰이 많은 순서대로 정렬하여 반환합니다. 단, Order By가 없어야하고, 테이블 조인하는 경우 Fulltext index가 있는 테이블이 왼쪽 테이블이어야 합니다.

그 외의 주의할 점은 다음과 같습니다.

  • 전체 테이블의 50% 이상의 레코드가 해당 토큰을 가지고 있다면 무시됩니다.
  • 대소문자를 구분하지 않습니다.
  • 길이가 기준보다 짧으면 무시됩니다.
  • a , the 와 같은 특정 단어는 무시됩니다. 이를 stopwords 라고 합니다.
    • select * from INFORMATION_SCHEMA.INNODB_FT_DEFAULT_STOPWORD; 로 직접 조회하여 목록을 확인할 수 있습니다.
  • 분할된 테이블에서는 전체 텍스트 검색이 불가능합니다.
  • % 를 와일드카드로 처리하지 않습니다.

  • explain
+----+-------------+-------+------------+----------+--------------------+--------------------+---------+-------+------+----------+-------------------------------+
| id | select_type | table | partitions | type     | possible_keys      | key                | key_len | ref   | rows | filtered | Extra                         |
+----+-------------+-------+------------+----------+--------------------+--------------------+---------+-------+------+----------+-------------------------------+
| 1  | SIMPLE      | r     | null       | fulltext | idx_fulltext_ngram | idx_fulltext_ngram | 0       | const | 1    | 100      | Using where; Ft_hints: sorted |
+----+-------------+-------+------------+----------+--------------------+--------------------+---------+-------+------+----------+-------------------------------+
  • 실행시 성능
> SELECT * FROM region r WHERE MATCH(city, county, district) AGAINST('서울강남역삼' IN natural language MODE);

430 rows retrieved starting from 1 in 51 ms (execution: 16 ms, fetching: 35 ms)
  • 결과

구현 결과

natural language 검색 모드를 통해 서울강남역삼 으로 검색해도 원하는 결과가 나올 수 있게 되었습니다. 특히 초성이 들어간 검색어를 넣더라도 정확도가 높은 결과를 보여주어 요구사항에 적합하다 판단하였습니다.

단, 검색어에 따라 조회된 결과가 너무 많을 경우를 고려하여 연관도 상위 50개 데이터만 반환할 수 있도록 구현하였습니다.

  • Repository
@Query(value = "SELECT * FROM region r WHERE MATCH(r.city, r.county, r.district) AGAINST(:address IN NATURAL LANGUAGE MODE) limit 100", nativeQuery = true)
List<Region> findAllByAddress(@Param("address") String address);
  • Get 요청시 반환
http://localhost:8080/regions?keyword=강남역삼
{
    "message": "Regions searched Successfully",
    "data": [
        {
            "id": 1168064000,
            "city": "서울특별시",
            "county": "강남구",
            "district": "역삼1동"
        },
        {
            "id": 1168065000,
            "city": "서울특별시",
            "county": "강남구",
            "district": "역삼2동"
        },
        {
            "id": 1168051000,
            "city": "서울특별시",
            "county": "강남구",
            "district": "신사동"
        },
        {
            "id": 1168052100,
            "city": "서울특별시",
            "county": "강남구",
            "district": "논현1동"
        },
        {
            "id": 1168053100,
            "city": "서울특별시",
            "county": "강남구",
            "district": "논현2동"
        },
        {
            "id": 1168054500,
            "city": "서울특별시",
            "county": "강남구",
            "district": "압구정동"
        },
        // 이하 생략
    ]
}

마무리


위와 같이 요구사항을 충족하면서 가장 효율적인 방법으로 해당 기능을 구현하였습니다. 다만 의문점이 몇가지 남았는데요. 그 부분에 대해서 추가적으로 기재하고 이 글을 마무리하도록 하겠습니다.

Elastic Search를 쓰면 더 좋을까요?

본문에서 언급했다시피 Elastic Search를 사용해서 검색 엔진을 별도로 사용할 수 있는데요. 그 부분에 대한 성능 차이와 log와는 달리 한정된 수의 데이터에 쓰는 것이 좋을지 궁금해서 추가적으로 알아보았습니다.

공통적으로 데이터의 양이 많을 수록 Elastic search와의 성능차이가 두드러지게 나타나는 것을 알 수 있었습니다.

근처 동네 검색은 어떻게 구현할 수 있을까?

실제 당근마켓은 인접한 동네를 알 수 있는 기능이 있습니다. 이것은 어떻게 구현할 수 있을까요? 지금의 구현 방법으로는 문자열에 기반하기 때문에 제가 만약 강남구 끝자락에 있어도 인접한 구가 우선순위로 반환되지 않습니다. 이때문에 다양한 생각을 했었던 것을 나열하자면 다음과 같습니다.

  • 검색엔진 등을 통해 인접한 동네 정보를 별도로 저장합니다.
  • 행정표준코드는 지명 정보와 일치하게 구성되어 있습니다. 이에 대해 더 분석해보아도 될 것 같습니다.
  • 위도와 경도 등 이미 부여된 좌표를 이용하여 인접 동네에 대한 탐색 기준을 새로 정합니다.
    • MySQL에서 좌표 사이 거리를 구하는 쿼리가 있어서 첨부합니다. [참고링크] 
    • 예를 들어 배달의 민족은 S2 를 통해 행정동과 좌표를 매핑하여 배달 좌표를 디테일하게 지정합니다. [참고링크]


Refs.

]]>
<![CDATA[2022년 회고 (3) 백엔드 개발자가 되겠어 ✊]]>난생처음 CS스터디
우아한테크코스 프리코스 3기, 코드스쿼드 프리코스 후기 ]]>
http://localhost:2369/2022-log-3/64e418f8749ee079fb78d29aSun, 22 Jan 2023 02:10:00 GMT난생처음 CS스터디
우아한테크코스 프리코스 3기, 코드스쿼드 프리코스 후기

아직도 2022년 이라니 질릴만도 합니다만... 살면서 가장 바빴던 시기이자 가장 열정적인 시기를 보내고 있는만큼 좀 더 잘 기록하고 싶은 욕심에 글이 길어지고 있습니다. 1년을 3년처럼 살았으니 3편정도는 써야되지 않겠습니까? 허허

나중에 개발자 경력이 더 쌓였을 때도 과거 초심자의 역사를 즐거운 마음으로 다시 읽어보면 좋겠습니다.


백엔드 개발자가 되고 싶어요


이때 즈음부터 백엔드 개발자가 되어야겠다는 결심을 했다. 왜 백엔드인가 하면.

처음에는 나는 프론트엔드가 더 잘 맞는다고 느끼기도 했다. 이전에 기획자로서 했던 일도 이용자와 직접 접하며 생기는 인터액션에 대한 풍부한 상상이 필요했었다. 프론트엔드가 다루는 부분이 연장선으로 여겨져서 익숙했었다. 아무래도 처음 웹 개발을 접하다보니 가장 자주 마주하는 웹페이지가 편한 이유도 있었다.

하지만 공부를 거듭할수록 보여지는 것에 구애받지 않고 온전히 비즈니스 로직에 집중할 수 있는 백엔드의 매력에 푹 빠졌다. 복잡한 문제를 잘 나누어서 효율적으로 처리할 수 있다는 점. 구현을 하는 방법이 매우 다양한데 그 중에서 적절한 방법을 찾는 것이 퍼즐을 푸는 것처럼 즐겁게 느껴졌다. 무엇보다 광범위한 부분을 이해하고 컨트롤 할 수 있다는 점도 성격에 잘 맞았다.

지금도 이 결정은 내게 잘 맞는 선택을 했다는 생각이 든다.

그런데 왜 이렇게 알아야 하는 것이 많죠?


2022년 8월~12월 : CS 스터디 : 환멸의 계곡

한편 8월부터 교육과정에서 만난 동료와 함께 스터디를 시작했다. 짧은 국비교육과정에서 다루지 않는 것들이 많다보니 그 부족함을 채우기 위함이었다.

undefined
스터디 이름이 환멸의 계곡인 이유... ⭐️ 환멸의 계곡에라도 빠졌으면 좋겠다는 의미였다. 가트너의 하이프 사이클에서 Trough of Disillusionment 단계는 "환멸의 계곡" 이라고도 불린다. 이미지 출처는 위키백과 https://ko.wikipedia.org/wiki/하이프_사이클

처음에는 자료구조와 알고리즘을 공부하며 코딩테스트를 대비해보자고 시작했지만 컴퓨터 공학을 전공하였던 동료의 리드에 맞추어 영역을 CS로 넓혀 공부했고 2명이라는 적은 인원에도 주 2회씩 총 45회차나 진행했으니 꽤 길게 이어나간 것이다. (repository) 매주 각각 2~4개 주제를 공부해와서 하나씩 쌓아갔다.

모임일지 겸 자료창고인 Notion 페이지

사실 스스로 느꼈던 한계는 분명했다. 워낙 빠른 시간 내에 하던 스터디이다보니 공부 방식도 다른 사람들이 정리해둔 블로그에 많이 의존했고 뼈문과 출신인 나에게는 기본 지식이 없는 상태에서 공부하다보니 사상누각과 같은 한계가 있었다. 실제로 이 지식이 어디에 적용되어있는지 모르다보니 전체적인 그림보다는 새로운 키워드를 입수하고 추상적으로 이해할 수 밖에 없었던 것이다.

그럼에도 이런 전문 지식을 잘 설명해줄 수 있는 누군가와 함께 공부한다는 그 자체만으로 많은 도움이 되었고, 의지가 되었던 스터디였다. 이 때 처음 배웠던 개념들이 이후에도 반복적으로 등장하면서 점점 이해도가 높아져가는 기반이 되었다.

코드스쿼드 프리코스


2022년 11월 : 코드스쿼드 마스터즈 BE 프리코스

사실 이때만해도 바로 취업을 하고 싶었다. 5.5개월동안 열심히 했으니 바로 취업할 수 있지않을까? 생각을 했는데. 공부를 하면 할 수록 나의 부족한 점만 크게 느껴졌다.

근데 이때쯤 코드스쿼드 프리코스​를 모집한다는 정보를 입수했다. 어차피 취준하느라 공백일 한 달. 가이드라인을 받아 다시 방향성도 잡고 루틴하게 공부시간도 가질겸 신청했다. (어째 국비교육을 신청했을 때랑 같은 의식의 흐름..🤔?) 이때는 몰랐다 이 과정이 얼마나 많은 에너지를 필요로 하는지...

  • 당시 선택에 큰 도움이 되었던 블로그 글
[후기] 코드스쿼드 2022 마스터즈 코스 수료 (웹 백엔드)
후 길다면 길었고, 짧다면 짧은 6개월이 금방 지나가 버렸다~ (위 닌자 로고의 마지막 기수 ㅎㅎ 2022 과정 중간에 매듭? 같은 걸로 변경됐는데... 개인적으로 별로.... 뭐랄까... 코드스쿼드 멤버들의 언더독? 같은 느낌이 사라졌달까?.... 닌자 로고 짱!!) 2022년 1월 1일. 부트캠프 코드스쿼드에 참여하여 공부하던 게 엊그제 같은데... 어느덧 6개월이 지나 코드스쿼드 과정은 7월 1일부로 마무리했고 수료식도 했다. 같은 목표를 가지고 함께 공부할수 있는 팀원들을 얻게 되어 너무 행복하다 ㅎㅎ!! 1. 과정 시작 전…
(2021) 1. 비전공자로 자바 백엔드 개발자 시작하기
저는 개인적으로 이런 이야기를 하는 것을 썩 좋아하진 않습니다. 어떤 사람의 커리어나, 그 사람의 현재 위치는 운이 굉장히 큰 영향을 끼쳤다고 믿기 때문입니다. 그 사람이 했던 방식, 했던 선택들을 그대로 한다고 해서 똑같은 결과물이 나온다는 보장이 없다고 생각합니다. 그래서 누군가의 상황을 듣고, ”아! 그런 상황은 이렇게 이렇게 해보세요” 라고 조언할 수가 없습니다. 일단, 저도 모든 선택이 다 처음이여서요. 리셋버튼 누르면서 2~3회차 살아온게 아니라는거죠. 꽃보다 누나에서 윤여정 선생님과 이승기님의 이야기는 꼭 한번 보시면…

프리코스에서 배웠던 것들

한참 코로나로 인한 거리두기 중일 때라 프리코스는 온라인으로 진행되었다. 클래스의 개념, JVM의 메모리 구조, 힙 메모리와 스택 메모리부터 차근차근 배우기 시작했는데 그동안 기능 구현을 중심으로 학습해왔던 나에게는 신세계이자 이해하기 어려운 내용들이었다. 이런 개념들을 어떻게 학습해야 할 지 학습 방법부터 새로 배워야 하는 것들이 많았다 🥹

다음이 프리코스 4주 과정동안 에서 주로 배웠던 것들이다.

  • 객체지향의 주요 개념과 구현
  • 주요 자료구조의 개념과 구현
  • 자바의 컬렉션 프레임워크

특히 지금도 심취해있는 객체지향에 대한 짝사랑이 이때부터 본격적으로 시작되었던 것 같다 ... 하하

코드스쿼드 프리코스 1주차 회고

동시에 위의 내용과 함께 얻을 수 있었던 것은 학습하는 방향과 습관이다. 어떤 것을 더 잘 알아야하는지에 대한 학습 방향 가이드라인이 있었고 오후부터 시작하는 코어타임에 동료들과 학습한 내용을 공유하는 것도 방향을 잡는데 큰 도움이 되었다.

코드스쿼드는 수강생들은 자유롭게 방목한 채로 스스로 성장하게 한다.

이게 이전 국비교육을 들었을 때와 가장 큰 차이점이었다. 하루 코어타임이 8시간이라고 해도 짜여진 스케줄은 별로 없고 코어타임동안 자유롭게 공부하고, 필요할 때 동료들과 대화하며 부족한 부분을 채워나갔다. 그리고 이 스타일이 나에게 꽤 잘맞았다.

그리고 더 공부를 하기로 했다.

어떤 영역이든 이런 어려움이 따를테지만, 개발을 배우면서 가장 괴로운 점은 분명히 나는 공부를 하고 있다고는 하는데 모르는 것이 지수적으로 증가한다는 것이다. 심지어 이미 공부를 한 내용도 사실 정확하게 알고 기억하기란 어려웠다.

그런데 아이러니하게도 동시에 이것이 가장 즐거운 점이기도 하다. 내가 모르는 것이 계속 존재하고 개념과 기술의 꼬리를 물며 학습하고, 이전에 학습했던 것을 기반으로 직접 구현할 수 있다는 것이 새로운 세계를 탐험하는 것 같았다.

그리고 내게 필요했던 것은 이 탐험을 어떻게 스스로 할 수 있느냐에 대한 연습이었던 것 같다. 내게는 그 연습시간이 더 많이 필요했고 당시에 바로 취업하기보다는 더 공부하고 싶었다. 스스로 부족함을 많이 느꼈다.

그래서 고민고민하다 코드스쿼드 마스터즈 본과정까지 듣기로 결정하고 입과시험을 보았다. 그리고 운이 좋게 마스터즈 코스(오프라인 과정)에 합격할 수 있었다.

Sun, Earth, Moon
날짜를 입력하세요.
1991년 7월 8일
ㅤㅤㅤㅤㅤㅤ﹡ㅤㅤㅤㅤㅤㅤㅤㅤㅤ+ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤ..ㅤ..ㅤㅤ+..ㅤㅤ+ㅤ.ㅤㅤㅤ
ㅤㅤㅤㅤㅤㅤㅤㅤㅤ*ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤ.ㅤ.ㅤㅤㅤ..ㅤㅤ*ㅤ+ㅤ.ㅤ+.ㅤㅤㅤㅤ
ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤ+ㅤㅤㅤㅤ..ㅤ.ㅤㅤㅤ.ㅤㅤㅤ+ㅤ..ㅤ+ㅤ.ㅤㅤㅤ.
ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤ.ㅤㅤ.ㅤㅤ.ㅤㅤ+ㅤㅤㅤ..+ㅤㅤㅤㅤ...ㅤ
ㅤㅤㅤㅤㅤ.ㅤㅤㅤㅤㅤㅤ+ㅤㅤㅤㅤㅤㅤㅤㅤㅤ.ㅤ.ㅤㅤㅤㅤㅤ+.ㅤ+ㅤㅤ.ㅤ.ㅤㅤㅤ..ㅤ
ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤ...ㅤㅤ.ㅤㅤㅤㅤ..+ㅤㅤㅤㅤ+...ㅤㅤ.ㅤㅤ
ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤ"You Are Here!".ㅤ.*.ㅤㅤ+ㅤㅤ+ㅤㅤ.ㅤㅤㅤ.ㅤㅤㅤ
ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤ |ㅤ.ㅤㅤㅤ.+ㅤ.+ㅤ.ㅤ.ㅤ.ㅤㅤㅤㅤㅤㅤㅤㅤ
ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤ.ㅤㅤ゜ㅤ\|/ㅤ+ㅤ.+.*ㅤㅤㅤㅤㅤㅤㅤㅤㅤ.🌞ㅤㅤㅤㅤ
ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤ..ㅤㅤㅤVㅤㅤ+ㅤ.ㅤ+ㅤ.ㅤ.ㅤㅤㅤ.ㅤ.ㅤ*ㅤㅤㅤㅤ
ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤ+ㅤㅤㅤ🌍.ㅤㅤㅤㅤ.++ㅤㅤ.ㅤㅤ.ㅤㅤㅤㅤㅤㅤㅤ.ㅤㅤ
ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤ.ㅤㅤㅤ.*.ㅤㅤㅤㅤㅤㅤ+ㅤㅤㅤ+.ㅤㅤㅤㅤㅤ.ㅤㅤㅤㅤㅤㅤㅤ
ㅤㅤㅤㅤㅤㅤㅤ.ㅤㅤㅤㅤㅤ.....ㅤ.ㅤㅤㅤㅤ+ㅤ+ㅤㅤ..ㅤㅤㅤㅤㅤ.ㅤㅤㅤㅤㅤㅤㅤㅤ
ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤ..ㅤㅤ.ㅤㅤ..ㅤ.+.ㅤ.ㅤㅤ+ㅤㅤㅤㅤ.ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤ
ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤ+.ㅤㅤ.ㅤㅤ.ㅤㅤ.ㅤㅤㅤ.++.ㅤ..*ㅤ.ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤ
ㅤ+ㅤㅤㅤㅤㅤㅤㅤㅤㅤ.+.ㅤㅤ.ㅤㅤ..🌓ㅤ.ㅤㅤ..ㅤㅤ.ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤ
ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤ.ㅤㅤ*ㅤㅤ.ㅤ.ㅤ.ㅤ+..+ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤ
ㅤㅤㅤㅤㅤㅤㅤㅤㅤ...ㅤㅤㅤ..+ㅤㅤㅤ.+.ㅤ.ㅤㅤ.ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤ
ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤ.ㅤ.ㅤㅤ.ㅤㅤ++ㅤㅤㅤ.ㅤㅤ...*ㅤㅤㅤ*ㅤㅤㅤ.ㅤㅤㅤㅤㅤ
+ㅤㅤㅤㅤ...ㅤㅤㅤㅤㅤ.ㅤ+.ㅤ.ㅤㅤ+.ㅤㅤㅤㅤㅤ.ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤ

입과시험이었던 '멋있게' 출력한 우주. 입과시험이 내 취향 저격이어서 엄청엄청 즐거웠던 기억이 난다. 화면에는 없지만 행성과 로켓 등 요소가 몇 개 더 있다.

private void putMoon(String[][] space, int x, int y, double angle){
    double radian=Math.toRadians(angle);
    float nx = Math.round(x+(space.length/10*Math.cos(radian)));
    float ny = Math.round(y+(space.length/10*Math.sin(radian)));
    
    if(space.length/2<nx && nx<x){       // 달이 지구와 태양 사이에 있다면 반달로 저장
        space[ny][nx]="🌗";
    }else if(x<nx &&nx<space.length/2){
        space[ny][nx]="🌓";
    }else{
        space[ny][nx]="🌝";
    }
}

입과시험에서 썼던 코드 중 가장 맘에 드는 코드. 달 위치를 표시할 때 태양과 지구와의 거리를 고민해서 반달을 출력하는 것도 넣었다.

]]>
<![CDATA[2022년 회고 (2) 풀스택 국비교육 수강기]]>
  • 케케묵은 지난글 👇
  • 국비 교육 과정을 듣기로

    ]]>
    http://localhost:2369/2022-log-2/64e5516b749ee079fb78d2abSun, 15 Jan 2023 00:23:00 GMT
  • 케케묵은 지난글 👇
  • 국비 교육 과정을 듣기로 한다.

    2022년 6월~10월 : 국비교육 수강

    사실 지금 생각해보면 한없이 가벼운 선택이었다.

    어차피 1년정도 쉬기로 한거, 돈도 더 들지 않겠다 주말에는 지인의 카페에서 계속 알바를 하고 평일에는 9시부터 6시까지 풀스택 국비교육과정을 듣기로 한 선택말이다.

    선택의 기준은 아주 간단했다.

    1. 시작하기에 부담이 없을 것, 곧 언제든 다시 돌아갈 수 있을 것
    2. 교육과정은 취업을 하기 유리할 것 (범용적일 것)

    여기다가 개발자인 친구가 “너는 프론트엔드, 백엔드 개념도 모르니까 일단 다 들어보고 나중에 심화해서 공부해봐” 라고 조언해준 것을 토대로 결정한 것이 멀티캠퍼스에서 진행하는 AI 플랫폼을 활용한 웹 서비스 개발 과정을 들었다. (지금 비슷한 과정은 이건거 같다. 링크)

    실제로 배웠던 것은 다음과 같다.

    커리큘럼

    • Java 3주
    • MySQL 기본 쿼리 문법과 Java와 연동 1주
    • HTML5, CSS, JavaScript, jQuery 2주
    • Sevlet & JSP, Spring, Sptring boot 와 MyBatis 3주
    • Jenkins, Naver Cloud Platform을 이용한 배포

    프로젝트

    • 자율 기획 프로젝트 2주
      • 배포하지 않음.
    • 자율 기획 프로젝트 및 배포 약 8주
      • 배포 플랫폼 : Naver cloud platform

    기타 특강

      • Git, Github 사용 1회
      • Notion 사용 1회
      • React 체험 3회
      • Oracle DB 사용해보기 1회
      • 기타 취업 관련 특강….

    AI 서비스이고 플랫폼이고 뭐고 사실 그때 제대로 알지 못했고, 나중에 생각해보니 Open API 중에서 AI 기술을 접목한 API를 사용해본다는 뜻이었다 👀 이때 API라는 개념이 없었는데..😣 지금 생각해보니 구현을 한 것 자체가 정말 대단했다.

    수업은 한참 코로나가 진행중이던 때라 대부분 비대면으로 이루어졌다.

    정규 수업시간은 8시간동안 계속 수업과 실습 코드 작성을 했고, 몰입해서 이 시간을 보내고 나면 나머지 시간은 지쳐서 쓰러져 자기 일쑤였다.

    이렇게 약 3개월동안 위의 기술을 모두 배우고 프로젝트를 2.5개월간 하려다보니 정말 필수적인 것만 속성으로 배울 수 있었다. 이때 당시의 기록들을 좀 들추어 각 기술에 대한 이해도를 살펴보니 꽤 재밌다.

    • 자바 교육과정 중, 변수나 연산자, 조건문과 반복문은 사실 함수나 다름없어서 큰 놀라움은 없었다. 그저 콘솔에서 입력과 출력으로 어플리케이션이 돌아간다는 자체가 가장 신기했다.
    • 교육 내용은 확실히 자바 기초에 부합하는 정확한 내용들이었으나 학습하고 공부하는 입장에서 너무 빠른 input 으로 ‘그렇구나’ 하면서 넘어가는 지식이 굉장히 많았다. 언어 자체를 어떻게 사용하는 지에 집중하여서 학습하였던 것 같다.
    • 학습 내용 중 가장 충격적이고 바로 받아들이기 힘들었던 개념은 객체지향언어 라는 특성이었다. 패키지와 클래스를 분리하고 이를 import 하거나 인스턴스를 만들어서 사용할 수 있다는 것을 배웠을 때 한동안 멘붕을 느꼈었다.
    • 바로 다음주 연달아 Java 메모리 사용과 참조타입을 공부하며 두번째 충격을 받게된다 🫠
    • 처음 배운 그 순간부터 DB를 별로 좋아하지 않았나보다. 추후 JDBC 를 사용하며 자바 교육기간동안 3차 멘붕을 맞게 된다.
    • HTML, CSS, JavaScript 를 좋아했다. 다만 코드가 늘어날 수록 코드를 주체하지 못해 고민했다.
    • 자바스크립트는 jQuery보다는 바닐라로 사용하는 것을 훨씬 좋아했는데, 비동기 요청을 하고 받는 것을 하면서도 비동기에 대한 이해가 부족했던 것 같다.
    • 배포는 네트워크에 대한 지식이 전무하였지만, 수업자료가 꽤 잘되어있어서 할 수 있었다.
    • controller -> service -> repository 로 이어지는 계층형 아키텍처를 이해하는 데 힘들었다. 이것은 함께한 수강생 중 대부분이 비슷했는데 그냥 '익숙해진다'에 더 가까운 학습이었던 것 같다. 이런 아키텍처에 대한 이해는 본격적으로 프로젝트를 시작하고 구현을 반복하면서 점점 더 익혀갔다.
    • 개발 공부를 시작한 것에 즐거워하고 만족했다.

    프로젝트에 대하여

    ezgif com-gif-maker
    • 사용 기술 스택 : Java11, SpringBoot 2.7, MySQL, MyBatis, JavaScript, jQuery(비동기 요청시에 사용)

    거의 2달정도를 잡고 있었던 이 프로젝트 Artchive. 참 무에서 유로 간다고 생각하고 비전공자 5명이서 한땀한땀 만들었다. HTML 시멘틱 태그를 처음부터 만드느라 구현자마다 미묘하게 페이지가 달라져서 나중에 통일시키느라 고생했던 기억이난다.

    지금 보면 AWS에 배포하긴 했지만 인프라나 설계면에서 부족한점이 많다.

    오히려 HTML, CSS 쪽이나 바닐라 JS를 이용하여 지도 API를 사용하고, 비동기 요청을 하는 등 프론트 역량을 더 많이 발휘했었다. 화면에 보이는 로직 중 여행 코스를 기획하고 저장하는 페이지 view부터 crud까지 맡아서 했다. 아래 ERD 에서는 노란색으로 표시된 코스에 해당한다.

    Entity2

    이때의 가장 큰 이슈는 두가지였다.

    첫번째, "각 코스의 순서를 view에서 어떻게 수정할 것인가, 그리고 이것이 DB에 어떻게 저장해야 좋을까" 였다. 프론트에서 order와 내용을 JSON형태로 넘겨 DB에 String 형태로 직렬화하여 스냅샷처럼 저장했다. 🥹 링크드 리스트나 우선순위를 주는 등의 다양한 방법이나 테이블을 어느정도 정규화+비정규화하고 인덱스를 생성하여 최적화할 수 있는 방법들이 있었는데.. 당시 생각해보면 게시판 페이지 로딩부터 느렸던 이유가 다 있었다.

    두번째, AI학습을 위한 데이터 수집과 학습의 자동화였다. 사용자의 이용패턴을 DB에 저장하고 이를 데이터로 추천해주는 Aitems를 적용했었는데. (담당은 내가 아니었다.) 이부분은 스스로 이해도가 너무 낮았고, 더미 데이터를 대량으로 만들 수 있을만한 여유도 기술도 없었다. 이 Ai 학습 패턴 업데이트를 자동화하지 못했던 것도 아쉽다.

    지금 보면 아이디어 자체는 여러가지 시도하기 좋았는데 아쉬움이 많이 남는 프로젝트이다.


    교육과정의 좋았던 점

    • 부담없이 view부터 어플리케이션, DB, 인프라까지 전체를 훑는 경험해 볼 수 있었다. 그래서 내가 어디에 더 흥미있는지 고민하기에 좋은 환경이었다.
    • 일단 부담이 없었다. 시도해보는 가벼운 마음으로 시작할 수 있었다.
    • 강사님이 수강생 한 사람 한 사람 버리지 않으려는 분이어서 진도를 쫓아갈 수 있었다.

    교육과정의 아쉬웠던 점

    • 너무 급하게 배우려다보니 무엇하나 어떻게 학습해야하는지 알지 못한채로 5.5개월이 끝나버렸다.
    • 이건 우리반의 특성인 것 같은데 먼저 공부해봤거나 전공을 했거나 프로젝트 방법을 아는 사람이 극 소수인데다 초반에 자율 조구성을 할 때 1~2개 그룹에 편성되어서 어떻게 프로젝트를 해야하는지 모르는채로 시작해야 했다.
    • 프로젝트에 대한 평가가 기획과 발표 스킬에 집중되어 있었다.

    ]]>
    <![CDATA[2022년 회고 (1) 커뮤니티 기획자가 개발자를 꿈꾸게된 이야기]]>개요

    엄청 늦었지만 2022년 회고.
    1편은 사회과학계열 전공 기획자가 개발자로 이직을 결심한 이야기.

    주의, 철저히 나의 얘기 기

    ]]>
    http://localhost:2369/2022-log-1/64e12a8b749ee079fb78d1f2Sat, 07 Jan 2023 20:48:00 GMT개요

    엄청 늦었지만 2022년 회고.
    1편은 사회과학계열 전공 기획자가 개발자로 이직을 결심한 이야기.

    주의, 철저히 나의 얘기 기록하는 거라 딱히 도움은 안될 것 같습니다.. 굳이 따지자면 정보 10, 사담 90의 글이 될 예정입니다.

    간다, 이 악물고 😬

    기획자의 기쁨과 슬픔

    2022년 이전 : 커뮤니티 기획자로서의 일!
    2022년 1~2월 : 퇴사 여행, 사람만나기

    직업을 묻는 질문을 참 자주 듣는다. 나는 언제나 내 직업을 설명하기에 어려움이 들었다.

    보통 대다수의 사람들에게는 '문화예술 프로그램/커뮤니티 기획자'라는 답변을 했다. 하지만 정확하게 내 스스로 그동안의 직업을 규정하자면 지역 활동가이자, 청년 활동가였다. 청소년기부터 사회 문제에 관심이 많았고, 특히 다양성과 도시(서울) 문제에 지극히 관심이 많았다. 그러다보니 사회학을 전공하고 이 직업을 갖기까지 굉장히 자연스러운 수순이었다.

    그동안의 내 일의 전반은 사회 이슈라고 생각되는 것들을 포착하고 원인과 해결에 대한 가설을 세우는 것으로부터 기획을 시작하기도 하고, 사업 청사진을 그리는 것부터 시작했다. 그리고 기획된 사업에서 구체적으로 사업 계획을 짜고 운영하고 보고서를 작성하는 것이다. 음.. 이렇게 말하면 너무 추상적인가? 구체적으론 의자 앞에서 앉아서 하는 기획서 작성부터 홍보마케팅, 사업 관계자 섭외 및 협업, 공간 기획, 행정회계, 행사할 때 마이크를 잡는 것도 종종 나의 업무 영역이었다. 이런 전반적인 과정을 모두 겪는 것(혹은 일의 A-Z까지 파악하고 있는 것)이 내 성격에는 잘 맞았던 것 같다.

    이 일을 하며 정말 힘든 점도 많았지만 막연하게 필요하다 생각했던 것을 현실로 만드는 작업이 너무나 즐거웠다.

    입사했던 곳들이 영세한 곳이어서 자유도가 높았다. 기획자의 업무 영역이 넓은 것도 일의 틈틈이 내 관심사를 녹이기에 최적의 환경이었다. 새로운 툴, 개념이나 장비를 배우는 것을 좋아했다. 일을 효율적으로 만드는 것도. 일이 광범위한 것도 별로 문제 되지 않았다. 오히려 매번 새로 습득한 것들을 일에 바로 적용할 수 있다는 것도 기꺼웠다.

    그리고 했던 일들의 과정을 꼼꼼히 기록하고, 후기를 남겨 사람들에게 읽히는 것도 정식 업무는 아니었지만 자처해서 업무로 만들어하곤 했다. 지금 생각해 보면 이런 것으로 누적된 스트레스를 풀었던 것 같기도 하다.

    기획자로 일하는 것은 겉으로 보이는 즐거움보다 힘든 일이다.

    매번 새로운 아이디어를 생산해 내야하고, 매력적이어야 한다. 내가 설득되지 못하는 기획은 일을 할 때마다 여간 괴로운 것이 아니기 때문이다.

    공부와 트렌드를 따라가는 것을 동시에 해야 한다. 기획도 아웃풋이어서 인풋이 없으면 제대로 나오지 못한다. 근데 아웃풋을 내느라 광범위한 업무를 수행하다 보면 인풋을 넣어줄 시간이 없는 아이러니함😬... 정말 야근은 밥먹듯이 꾸준히 했다. 그렇게 일하다 보면 내가 아주 좋아하던 일인데도 종종 일에 파묻혀 자기 확신이 안 드는 때는 내 기획을 내가 무서워할 때도 있었다. 답이 나오지 않는 것을 알면서도 이것이 유의미한가 의심해 보는 것이다.

    돌이켜보면 직무 전환을 결심한 것 중 가장 큰 이유가 이것이지 않을까...?

    퇴직 직후 하동, 구례, 제주도를 친구(사진 속 인물)와 여행했다. 이때는 몰랐다. 새로운 직업을 꿈꾸게 될 줄은.

    직전 조직에서 일은 3년정도 했다. 21년 12월을 끝으로 퇴직을 하고 나서도 나는 이 직업을 꽤 사랑해서 한 반년 재충전 후 다시 활동가로서 취업을 하리라 의심치 않았다.


    근데 분위기 갑자기 개발자 👩🏻‍💻

    2022년 3~4월 : 지인 카페 알바, 취업 준비 👉 개발자로 직무 전환 고민

    본격적으로 취업 시장에 뛰어들고 조금 지났을 때였다. 어쩌다 덜컥 코로나19에 걸렸다. 누구 말만 따라 한 3일은 지독하게 아파서 침대를 못 벗어나고 끙끙 앓았다.

    근데 4일째부터 침대 위 생활만 하게되자 슬슬 지루해지기 시작했다. 격리는 4일이나 더 해야 했다. 누워서 영화를 켜두는 것도, 책을 보는 것도 더 지루해질 때쯤 평소 꼭 배우고 싶었던 코딩을 배우자 싶었다. 사실 개발은 성인 이후에 계속 배우고 싶다고 마음 한편에 담아두었던 기술이었다. 옛날 옛적 나모웹에디터로 웹페이지를 만들던 추억이 크게 작용했다. (혹시 다들.. 나모웹에디터 아시는지...?)

    그리고 위에도 언급했듯이 나는 새로운 것을 습득하는 것을 즐거워하고 호기심도 많은 편이라 배우길 시작하는 것은 어렵지 않았다. 눈치채셨듯이 워낙 시작이 가벼운 사람이다.

    구글링으로 몇 번 검색해보니까 사람들이 취미 코딩 강의로 생활코딩을 추천하길래 HTML부터 천천히 실습하기 시작했다. 그러다 개발에 매력에 푹 빠져버렸다. 지금도 처음 코딩을 해보겠다고 하는 친구가 있으면 생활코딩 egoing을 추천한다. 한 단계 넘을 때마다 칭찬받는 재미에 어느새 마지막까지 완강을 하고야 마는 것이다.

    커뮤니티 기획 업무는 기획 후 준비, 실행 과정이 있어서 누군가의 리액션이 있기까지 시간이 꽤 걸리는 반면, 개발의 결과는 바로 확인이 가능하다는 것이 너무 매력적이었다. 사람과 그 날의 기운(?)에 매번 달라지는 모임 분위기도 매력있었지만.. 이게 바로 기술의 묘미인가? 작게 기능을 나누고 한 줄 한 줄 일을 시키고, 이를 설계하고 돌아가게 하는 즐거움이 있었다.

    공부가 조금씩 늘어나며 할 수 있는 것들의 범위가 확 늘어나는 것도 재미있었다. 동시에 상상의 범위도 늘어나고. 지금까지 했던 일중에 가장 시간이 빨리 흘러가는 일이었어서 더 신기했다.

    또 새로운 것을 배우고, 한 것을 기록하고 아는 것을 나누는 일련의 과정들이 즐거웠다. 이런 진득한 배움의 과정을 나는 깊게 바라고 있었던 것같다.

    한 2~3주 틈틈히 생활코딩 커리큘럼을 따라가다가 뼈문과인 내 주변 유일한 개발자인 친구를 만났을 때 조심스럽게 상담을 했다.

    아무래도 나이가 이미 30대인지라 직무를 바꾸는 도전이 조금 부담스러웠다. 그쪽 업계의 생리를 알 필요가 있었다. 그리고 개발을 배운다면 응당 얘기를 듣는 그놈의 "적성"도 걱정이 되었다.

    그런데 그 친구도 굉장히 흔쾌히 개발자로의 직무 변경을 추천하는 것이 아닌가? 오히려 잘 어울릴 것 같다며 격려까지 해주었다🥲 한 4시간여 가량 고민하는 바를 상의한 뒤, 당일에 바로 결정을 내렸다. 어차피 6개월 교육 듣는다면 원래도 쉬면서 재충전하려고 했으니 맘처럼 잘 안되더라도 그냥 쉬는 셈치자는 안일한 마음도 조금은 있었다. (👈 1달 만에 무너질 마음)

    그리고 그 후 1주일 안에 국비교육을 신청하게 된다.

    4월 28일의 서울숲. 얘기하면서 서울숲을 몇 바퀴를 돌았는지 모르겠다 😵

    다른 사람이 본다면 갑자기 진로를 확 틀었다고 볼 수 있겠지만 사실 나는 내가 가진 역량을 하나 더 키운다라는 측면이 더 크다. 물론 돈을 버는 방법은 바뀌겠지만, 기술을 하나 더 가짐으로써 '내가 하고 싶은 것을 더 쉽게 찾아갈 수 있게 되었다' 라고 생각하고 있다.

    내가 10년 후에도 20년 후에도 개발자로 일할 수 있다면 그것도 행운이다. 살아남을 수 있는 개발자라니!

    하지만 또 새로운 길을 찾아 가더라도 그것 나름대로 좋을 것 같다. 내가 지금 배운 프로그래밍을 목적을 위한 수단으로 잘 쓸 수 있다면 더할나위 없을 것이다.


    사실 국비교육 수강에 대한 정보가 궁금할 사람이 많을 것 같아서.. 별로 안궁금할 얘기랑 분리해봤다.

    2편은 뼈문과가 국비 교육 수강하면서 혼란의 도가니에 빠진 이야기가 이어질 예정이다.

    ]]>