들어가며

Disclaimer

  • Last Update: 2024/06/06

  • SSAFY는 첫 학기에 ‘관통 프로젝트’라는 프로젝트를 진행한다.
  • 명세 및 주제 등 자세한 내용은 공개할 수 없다.
  • 이번 글에서는 최대한 프로젝트 내 추가적으로 구현한 기능을 위주로 서술한다.

기능

1. 향상된 검색 기능 제공

문제의식

단순한 검색 기능은 %LIKE%와 같은 방식으로 이루어지고는 한다. 하지만 이러한 방식은 반복적으로 여러 단어에 대해 검색이 이루어질 시 비효율적이라고 판단했다. 특히, 아래 문제가 중점적으로 고려되었다.

(1) 기존 검색어의 공백까지 포함하는 결과값만이 나온다 (2) 여러 단어를 모두 병렬적으로 포함하는 경우를 검색할 수 없다

따라서, 여러 검색어를 공백으로 구분하면서 모두 포함하는 검색 결과를 보여주려고 했다.

게다가 개인적인 목표 중 하나로, 이미 작성해둔 코드가 대규모 서비스로의 확장성에 대한 고려였다.

해결

우선, 여러 검색어를 모두 포함하기 위해서 검색어들을 하나의 문자열로 받아온다. 이 검색어는 공백으로 구분될텐데, 공백 문자나 개행 문자가 여럿 오는 예외사항이 있었다. 따라서 아래 코드와 같이 정규식 \\s+ 을 이용해 공백 문자가 얼마나 오든 간에 그를 기준으로 구분하도록 했다.

List<String> queryList = Arrays.asList(query.split("\\s+"))

MySQL에서 제공하는 FULL-TEXT INDEX 및 NGRAM PARSER를 활용했다. FULL-TEXT INDEX는 긴 문자 데이터를 빠르게 검색할 수 있도록 돕는다. 특히, 이번 프로젝트에는 FULL-TEXT INDEX와 함께 NGRAM PARSER를 이용했다. NGRAM은 특정 문자를 음절 단위 토큰으로 나누어 검사할 수 있도록 한다. 이는 조사의 활용이 빈번한 한국어에서 유용한 방식이기도 하다.

FULL-TEXT INDEX를 검색하기 위해서는, MATCH() AGAINST()와 같은 함수를 이용한다. 이러한 함수를 이용하는 옵션에는 natural language mode와 boolean mode가 있다. natural language 모드가 정확한 일치를 보장한다면, Boolean Mode는 논리 연산을 이용해 검색 기준을 조정한다.

2. ‘잔디’ 기능

문제의식

비즈니스적인 측면을 고려하자면, 서비스는 참여도가 높은 유저를 최대한 모으거나 유저의 참여도를 높일 필요가 있다. 이번 프로젝트에서는 서비스에서의 자신의 기여도를 시각적으로 확인하는 방식을 구현하면 좋겠다고 판단했다.

해결

이번 프로젝트 발표의 청중이 모두 github를 이용하고 있거나 이용한 경험이 있다는 점에서 해당 사이트와 유사한 ‘잔디’ 기능을 구현하고자 했다.

그렇다면 잔디를 어떻게 구현해야할까? 기본적으로 잔디 테이블은 잔디 ID를 PK로 가지고 유저 ID를 FK로 가져야 한다. 또한, 날짜를 기준으로 구분되기에 날짜마다 잔디가 있어야 한다. 여기에 두 가지 선택이 있다.

(1) 날짜가 바뀔 때 마다 각 유저 마다 새로운 날짜에 해당하는 잔디를 추가 (2) 1년 단위로 유저마다 현재 연도에 대한 모든 잔디를 생성

결론적으로는 방식 (2)를 선택했다. (1)은 회원가입 시 생성되는 잔디가 딱 하나이기에, 사용자에게 컴포넌트가 직관적으로 다가가지 않을 것 같았던 까닭이다. 게다가 (1)은 스프링 스케쥴러를 이용할 필요가 있어 보였는데, 프로젝트 기능 상 그를 공부하기까지 추가적인 공수가 든다고 판단했다.

반면, (2)는 회원가입 시부터 해당 연도에 대한 잔디가 생성되어 직관적이다. 또한, 백엔드 측에서의 정기적인 DB 쿼리 발생 빈도가 확실히 적을 것이라 판단했다.

이후에는 프로시저를 이용해 회원가입 시 잔디 테이블 내 레코드를 생성할 수 있도록 했다. 유저가 회원 가입한 일자에서 해당 연도의 12월 31일까지 잔디가 생성된다.

3. 시점이 다른 변경 사항 일괄 반영

문제의식

이중적인 구조를 가지는 데이터가 있다. 주제를 말할 수 없기에, 중고 거래 어플리케이션의 예시를 들어보자. 이 어플리케이션은 한 상품 목록에 여러 상품이 포함될 수 있다고 가정하자. 상품 테이블은 상품 목록 테이블의 PK를 FK로 가진다. 아래가 예시다.

상품 글 A

[상품 목록]

- 시계: 2000원
- 노트북: 10000원
- 휴대폰: 5000원

사용자는 글 수정시에도 목록 내 시계의 가격을 수정하거나, 노트북 항목을 삭제하거나, 새로이 가방이라는 항목을 추가할 수 있다. 만약 이 항목들의 변경 사항이 매 번 API를 호출해 처리된다면 그에 따른 오버헤드가 발생한다.

즉, 클라이언트 화면에서 일어나는 변경사항은 수정 완료 버튼을 통해 제출될 때에 백엔드에서 한 번에 처리되어야 한다. 클라이언트 화면에서의 변경 사항과 백엔드 및 DB에서 처리되는 변경사항의 시점이 다른 셈이다.

해결

이 문제를 해결하기 위해서, 주로 클라이언트를 맡은 동료와 함께 논의해 새로운 DTO를 만들어 이용했다. 클라이언트 측에서는 각 변경사항 order에 대해 1: 생성, 2: 수정, 3: 삭제와 같은 orderType과 함께 상품 product에 대한 명령을 보낸다.

Order: { 
    orderType: int, // 1: 생성, 2: 수정, 3: 삭제
    product: productDto // 수정 페이지에서 변경 사항이 반영됨
}

이제 List<Order>와 함께 해당 상품 목록이 속한 상품 글 ID까지 클라이언트로부터 넘겨받으면 된다.

이러한 방식은 클라이언트 화면과 DB 데이터 사이에 변경되는 시점이 일치하지 않는다는 문제를 단숨에 해결한다. 또한, 변경 사항을 일괄로 처리하며 API 통신 횟수가 줄어들어 부하가 감소할 것이라는 결론을 내렸다.

4. DB 내 UUID 바이너리화

문제의식

MySQL에서 제공하는 Auto-Increment 기능은 간편하지만, 분산 환경으로의 확장성이 낮다. 특히, DB를 합칠 때 PK가 겹치면 곤란하다. 만약 offset을 나누거나 번호를 달리 해 DB를 이용한다면 이러한 문제에 대처할 수 있지만, 서버가 추가될 때 추가적인 작업이 더욱 필요하다. 또한, 이번 프로젝트의 난이도 상 샤딩은 구현하기에 비용이 크다고 판단했다. 그럼에도 비즈니스적인 서비스 이용 사항, 예를 들자면 글의 번호 변동을 통해 서비스 이용 정보(시간 당 작성 글)가 유출될 수 있는 auto-increment는 쓸 수 없다고 판단했다.

분산 환경으로의 확장을 고려할 때, 비록 모놀리식 환경이지만 처음부터 이관이나 변경에 대한 비용을 고려할 필요가 있다고 판단했다. 따라서 PK를 UUID, ULID, Snowflake 또는 TSID로 이용하는 방안을 고려했다. 그 중에서도, 프로젝트 난이도를 고려하여 일단 MySQL 환경에 대해 기본적인 UUID를 최적화 해보고자 했다.

그러나 UUID는 36 바이트 문자열이라는 점에서 8 바이트 문자열인 BIGINT보다도 약 4.5배 더 많은 공간을 차지한다는 문제가 있다.

게다가 MySQL의 B-Tree Index를 고려하면, UUID v4는 무작위성 때문에 대규모 서비스에서 이용한다면 비용이 더욱 들 것이라고 가정했다. B-Tree Index는 데이터가 오름차순으로 연속적인(Sequential) 경우에 효율적인 까닭이다.

해결

UUID v1은 기존에는 MAC Address가 노출된다는 취약점이 있었지만, 해당 부분을 자바에서 제공하는 SecureRandom을 이용해 난수 값으로 대체하면 이 문제는 해결된다.

UUID v1의 구조는 TimeStamp (low time) - TimeStamp (mid time) - TimeStamp (high time) & UUID Version - Clock sequence - Node Identifier과 같다. 여기에서 tiemstamp가 갖는 의미에 기반해 high - mid - low - ...로 바꾸어주면 UUID의 범위가 이전보다 줄어들어 인덱스 크기와 검색 성능에 우위가 생긴다.

그렇다면 36 바이트 크기의 UUID 데이터를 어떻게 더 효율적으로 이용할 수 있을 것인가? BIGINT 타입이 8 바이트임을 고려하자면, UUID는 4.5배나 더 많은 용량을 차지한다. 해당 레코드가 1억 개라고 가정하면, 2.8 기가바이트를 더 소모한다. 그러나 FK까지 고려한다면 차이는 더욱 커질 테다.

따라서 UUID를 36 바이트 문자열로 저장하는 대신 16 바이트 BINARY 타입으로 저장하는 방식을 취했다. 변환 과정에서 chunk를 구분하는 -는 제외하고, 해당 문자열을 BINARY 데이터로 이용했다. 이는 UUID를 문자열로 이용하는 방식 대비 약 55%의 용량을 절감한다.

대신, BLOB으로 저장된 바이너리 데이터는 Mybatis를 통해 자바 내 클래스로 변환되어야 한다. BLOB은 바이트 배열로 변환되므로, 해당 배열을 UUID로 변환한 뒤에 String으로 이용할 수 있도록 BlobToUUIDToStringTypeHandler.java 파일을 작성해주었다. 이는 Mybatis 오픈소스 깃허브 내에서 제공되는 TypeHandler를 참고했다.