Redis on the Raspberry Pi: adventures in unaligned lands

Salvatore Sanfilippo

라즈베리 파이에서 Redis를 돌리기: 비정렬 세계에서의 모험

원문은 Salvatore Sanfilippo님이 에 게재했습니다. 이 블로그 구독하기

1,000만 대가 팔리고 센서와 디스플레이 같은 주변 장치와 이를 활용한 사실상 무궁무진한 애플리케이션이 등장한 지금, 라즈베리 파이는 단순한 성공작을 넘어 임베디드 분야에서 프로그래머들이 실험을 위해 선호하는 플랫폼 중 하나로 자리 잡았다고 해도 과언이 아니다. 특히 Pi Zero 같은 모델 덕분에 수직 통합형 장치를 직접 설계하고 제작하며 그에 맞는 소프트웨어를 작성하는 데 따르는 온갖 위험과 비용을 감수하지 않고도 하드웨어 제품을 만들 수 있는 플랫폼이 되어가고 있는 듯하다.

나 역시 Redis 또한 프로그래머들이 무언가를 해킹하고 실험하며 새로운 것을 만들 때 즐겨 쓰는 플랫폼이라고 생각하는 걸 좋아한다. 게다가 임베디드/IoT 용도로 쓰이는 장치들은 센서로부터 받은 데이터 같은 정보를 장치 자체에 일시적으로 혹은 영구적으로 저장해야 하는 경우가 많다. 기기 내에서 연산을 수행하거나 원격 서버로 전송하기 위해서다. Redis는 바로 이런 데이터 스트림과 시계열 저장을 위해 특화된 “Stream” 자료형을 추가하고 있다. 현재 명세는 거의 완성 단계에 있으며 구현 작업은 앞으로 몇 주 안에 시작될 예정이다. 기존 Redis 자료구조와 새로 추가될 스트림, 그리고 작은 메모리 사용량과 작은 하드웨어에서도 낼 수 있는 준수한 성능(덕분에 전력 소모도 낮다)이 결합되면 라즈베리 파이의 잠재적인 활용 사례는 물론 전반적인 소형 ARM 장치에 잘 어울리는 조합처럼 보였다. 빠진 조각은 너무나도 자명한 것이었다. 바로 파이에서 잘 돌아가게 만드는 일이었다.

파이의 멋진 점 중 하나는 개발 환경이 몇 년 전의 임베디드 개발 환경처럼 느껴지지 않는다는 것이다. 그냥 리눅스가 돌아가고, 데비안 계열에서 기대할 수 있는 도구들을 모두 갖추고 있다. 기본적으로 Redis를 파이에서 동작하도록 맞추는 일 자체는 그리 큰 작업이 아니었다. 리눅스 시스템 프로그램과 파이 사이에 존재할 수 있는 가장 근본적인 불일치는 성능이나 리소스 사용량 측면의 불일치일 텐데, 이 부분은 Redis 자체의 설계 덕분에 문제가 되지 않는다. 빈 인스턴스가 차지하는 상주 메모리(RSS)는 총 1MB에 불과하고, 질의를 메모리에서 처리하므로 충분히 빠르고 플래시 디스크에 부담을 많이 주지도 않으며, 영속성이 필요할 때는 쓰기 패턴이 순차 추가(append-only)인 AOF를 사용한다. 하지만 파이는 ARM 프로세서에서 동작하고, 이는 비정렬 접근을 다룰 때 약간의 주의가 필요하다는 뜻이다.

이 블로그 글에서는 Redis와 라즈베리 파이가 더 잘 어울리도록 하기 위해 내가 한 작업을 소개하면서, x86 플랫폼처럼 비정렬 접근을 투명하게 처리해주지 않는 아키텍처를 다룰 때 알아두면 좋은 내용들을 전반적으로 정리해 보려 한다.

ARM 프로세서에 대한 몇 가지 이야기

Redis를 ARM으로 포팅할 때 가장 흥미로운 점은 ARM 프로세서가 비정렬 메모리 접근을 그다지 좋아하지 않는다는 것, 아니 사실 좋아하지 않았다는 것이다. 만약 고수준 프로그래밍만 해왔다면 모를 수도 있지만, 역사적으로 많은 프로세서 아키텍처는 워드 크기의 배수가 아닌 주소에서 메모리 워드를 로드하거나 저장할 수 없었다. 예컨대 워드 크기가 4바이트인 32비트 프로세서라면 0x4, 0x8 같은 주소에서는 워드를 읽고 쓸 수 있지만 0x7 같은 주소에서는 할 수 없다. 그 결과는 CPU와 그 정확한 설정에 따라 예외가 발생하기도 하고, 때로는 이상한 동작이 나타나기도 한다.

그러다 x86 계열 프로세서가 세상을 지배하면서 모두가 이 문제를 어느 정도 잊어버렸다(물론 SEE 명령어 등을 다룰 때는 예외지만, 이제는 그런 명령어에도 비정렬 변형이 제공된다). 사실 처음부터 잊어버린 건 아니었다. x86 프로세서도 비정렬 접근 시 예외를 일으키지 않고 처리할 수는 있었지만 적지 않은 성능 저하를 감수해야 했다. 워드 경계에 걸친 부분적인 읽기/쓰기는 두 배의 작업을 필요로 했기 때문이다. 하지만 최근의 x86 프로세서들은 대부분의 경우 비정렬 접근을 정렬된 접근만큼 빠르게 처리하는 최적화를 갖추고 있어, 요즘 x86에서는 이 문제가 정말로 ‘문제가 아닌’ 수준이 되었다.

ARM은 ARMv5까지만 해도 비정렬 접근이 이상한 결과를 초래하는 플랫폼 중 하나였고, 그 결과는 정말 예상 밖이었다. ARM 공식 문서를 인용하면, “주소가 4의 배수가 아닐 경우 LDR 명령어는 실제로 정렬되지 않은 워드를 로드하는 대신 회전된 결과를 반환한다. 일반적으로 이 회전은 프로그래머가 기대하는 것이 아니다.” 뭐, 확실히 프로그래머가 기대하는 동작은 아니다. 하지만 원래 라즈베리 파이조차도 ARMv6 프로세서를 탑재하고 있었다. v6는 성능 저하를 감수하긴 하지만 워드 크기의 비정렬 접근은 처리할 수 있다. 다만 여러 워드를 다루는 명령어는 예외를 일으켜 프로그램이 버스 시그널(SIGBUS)로 종료되거나 커널의 도움을 요청하게 된다(자세한 내용은 뒤에서 살펴보자). 이는 Redis가 파이에서 곧바로 엉망으로 크래시 나지 않았다는 뜻이다. Redis가 수행하던 대부분의 비정렬 접근이 사실 워드 크기였기 때문이다. 하지만 때때로 컴파일러가 연산을 가속하기 위해 다중 로드/스토어 명령어를 생성하거나, Redis 코드 자체가 비정렬 주소에서 64비트 값을 로드/스토어하려 할 때가 있었다. 이는 이론상으로는 보통 크래시로 이어지지만, 어쨌든 리눅스가 이 부분에서 약간 도움을 준다.

크래시 대신 커널에 부탁하기

리눅스 커널은 ARM 프로세서에서 동작할 때, CPU가 정상적으로 지원하지 않는 비정렬 주소에 대한 연산이 발생해도 사용자 프로세스가 기대한 대로 동작하도록 도와줄 수 있다. 그 방식은 커널 내부에 해당 예외를 위한 핸들러를 등록해 두는 것이다. 커널은 실패한 연산을 확인하고 함수로 시뮬레이션하여 프로세서가 직접 실행한 것과 같은 최종 결과를 만든 뒤, 문제를 일으킨 프로세스를 재개시켜 계속 실행되도록 한다.

로우 레벨 프로그래밍에 관심이 있다면, 이 리눅스 커널 파일을 한번 살펴볼 만하다: http://lxr.free-electrons.com/source/arch/arm/mm/alignment.c

CPU가 비정렬 접근 예외를 발생시켰을 때 커널이 실제로 어떻게 동작하는지는 /proc/cpu/alignment 파일로 제어한다:

$ cat /proc/cpu/alignment
User:		0
System:		12590 (ip6_datagram_recv_common_ctl+0xc8/0xd4 [ipv6])
Skipped:	0
Half:		0
Word:		0
DWord:		0
Multi:		12590
User faults:	2 (fixup)

보다시피 커널이 교정해 준 모든 비정렬 접근에 대해 사용자 공간과 커널 공간을 구분해 카운터가 집계된다. 위 예시에서는 커널 공간에서 12,590건의 접근이 교정되었다. 사용자 공간 프로세스에서 교정된 건은 없다. “User faults” 줄은 사용자 공간 프로세스가 CPU가 처리할 수 없는 비정렬 접근을 수행했을 때 커널이 어떻게 할지에 대한 설정을 보여준다. 문제를 고치거나, SIGBUS를 보내거나, 커널 로그에 이벤트를 기록할 수 있다. 이는 /proc/cpu/alignment에 쓸 수 있는 정수의 개별 비트로 제어된다. 예를 들어 사용자 공간 비정렬 접근을 교정하는 동시에 로그도 남기려면 “echo 3 > /proc/cpu/alignment”라고 하면 된다(비트 1은 로깅을, 비트 2는 교정을 활성화한다).

내 생각에 리눅스 커널이 이런 기능을 활성화한 이유는, 커널 개발자들이 비정렬 메모리 접근을 제대로 다루지 못하는 가여운 사용자 공간 프로그래머들을 걱정해서라기보다는, 커널 자체가 위 “System” 카운터에서 볼 수 있듯 항상 정렬된 접근만 수행하는 것이 아니기 때문인 듯하다. 그래서 커널 코드 곳곳을 일일이 점검하는 대신, 이 방식이 리눅스를 ARM으로 포팅하는 가장 간단한 방법이었다.

리눅스가 이를 투명하게 처리해 주니, ‘뭐, 고칠 게 없네. /proc/cpu/alignment를 투명하게 고치도록 설정해 두면 Redis도 기대한 대로 잘 동작하겠지’라고 생각할 수도 있다. 하지만 실제로는 두 가지 이유로 그렇지 않다:

  1. 비정렬 접근이 발생해 커널이 이를 고칠 때마다 실행 속도가 매우 느려진다. 속도 저하는 예를 들어 워드 크기의 비정렬 접근을 할 때 필요한 두 번째 메모리 접근보다 훨씬 크다. 이 현상은 다중 로드 및 스토어 명령어에서만 발생하지만, 특정 조건에서 Redis가 필요 이상으로 훨씬 느려진다는 점은 여전히 아쉽다.
  2. ARM 비정렬 접근에 대한 리눅스 커널 구현은 완벽하지 않다. GCC가 생성하는 코드 중에는 Linux 4.4.34에서 제대로 처리되지 않는 명령어가 포함된 경우가 있다.

아주 간단한 예시는 다음과 같다:

#include <stdlib.h>

int main(int argc, char **argv) {
        int count = 1000;
        char *buf = malloc(count*sizeof(double));
        double sum = 0;
        double *l = (double*) (buf+1);
        while(count--) {
                l++;
                sum += *l;
        }
        return 0;
}
$ gcc foo.c -g -ggdb
$ ./a.out
Bus error

내 파이의 커널 설정이 비정렬 접근을 처리해 교정하도록 되어 있음에도 프로그램은 여전히 SIGBUS를 받았다! GDB로 어디서 이런 일이 일어나는지 살펴보자:

$ gdb ./a.out
(gdb) run

Program received signal SIGBUS, Bus error.
0x00010484 in main (argc=1, argv=0xbefff3b4) at foo.c:10
10	                sum += *l;

음, 예상대로 정렬되지 않은 double 포인터를 역참조하는 내부 루프에서 문제가 발생한 것이다. 하지만 예외를 발생시킨 ARM 명령어를 확인해 좀 더 자세히 들여다보자:

(gdb) x/i $pc
=> 0x10484 <main+100>:	vldr	d6, [r11, #-20]	; 0xffffffec

VLDR 명령어는 확장 레지스터에 메모리 위치의 값을 로드하는 데 사용되며, 부동소수점 연산에 쓰인다. 어찌 된 일인지 비정렬 접근 교정을 담당하는 리눅스 커널 구현은 이 명령어를 처리하지 못한다(아마 구현이 아직 완전하지 않기 때문일 것이다). 실제로 “dmesg” 명령어를 실행하면 정렬 교정을 담당하는 함수가 해당 명령어를 인식하지 못했다는 사실이 나타난다:

[317778.925569] Alignment trap: not handling instruction ed937b00 at [<00010480>]
[317778.925610] Unhandled fault: alignment exception (0x011) at 0x01cb8011

그러니 파이의 기본 C 컴파일러가 기본 리눅스 커널이 처리하지 못하는 코드를 생성할 수도 있는 상황이라면, 나는 커널이 비정렬 접근을 교정하지 않도록 설정되어 있을 때도 Redis가 문제없이 동작할 수 있기를 정말로 원했다. 이는 ARM에서의 Redis는 CPU가 투명하게 처리할 수 있는 유일한 경우인 워드 크기의 비정렬 접근만을 수행해야 한다는 뜻이다.

버그 수정하기

ARM이 대부분의 비정렬 메모리 접근을 잘 처리해 주기 때문에, Redis는 파이에서 이미 대부분 잘 동작하는 것처럼 보였다. 특히 커널이 기본적으로 지원되지 않는 많은 비정렬 접근을 교정하도록 설정되어 있었기 때문이다. 정렬 교정을 비활성화한 상태에서도 표면적으로는 여전히 잘 동작했다. 하지만 테스트를 실행해 보니 특히 비트 연산이나 해시 함수 같은 눈에 띄는 영역에서 여러 크래시가 드러났다.

이제 Redis가 하는 첫 번째 조치는 정렬되지 않은 접근을 지원하지 않는 아키텍처에서 컴파일될 때 USE_ALIGNED_ACCESS를 정의하는 것이다. 이후에는 비정렬 접근을 수행하던 빠른 경로를 피하거나, 포인터 역참조를 memcpy() 연산으로 교체하는 방식으로 코드를 수정하는 일만 남았다. memcpy()를 사용하면 포인터 역참조보다 훨씬 느릴 거라고 생각할 수도 있지만, 실제로는 생각보다 훨씬 낫다. memcpy(src,dst,sizeof(uint64_t))처럼 크기가 고정된 memcpy 호출에 대해서는 컴파일러가 함수 호출을 생략할 만큼 똑똑하다. 주소가 정렬되지 않았더라도 작업을 수행하는 가장 빠른 명령어 집합을 실제로 생성해 준다. 예를 들어 x86 프로세서에서는 이 함수 호출이 실제로 단일 MOV 명령어로 변환된다.

이런 수정이 끝난 뒤 Redis와 나의 두 대의 라즈베리 파이, 즉 원래 모델 B와 훨씬 빠른 Pi 3는 아주 좋은 친구가 되었다. 크래시 리포트에서 호출 추적을 생성하는 테스트 하나를 제외하고는 모든 테스트를 통과했고(이것도 조만간 고칠 예정이다), 가끔 통합 테스트에서 파이가 마스터와 슬레이브 구성을 설정하는 속도가 느려 몇 가지 실패가 발생하긴 했지만 말이다. 하지만 이 시점에서 나는 정확성에 대한 욕심이 더 생겼고, 정렬 문제를 조금 더 찾아보고 싶어졌다.

한 걸음 더 나아가기: SPARC

ARM용 Redis를 수정하는 동안, GitHub 저장소에는 Solaris/SPARC에서 Redis가 잘 동작하도록 만드는 병렬 이슈가 열려 있었다. SPARC는 ARM처럼 관대하지 않다. 어떤 비정렬 접근도 처리할 수 없다. 나는 C 프로그래밍을 처음 시작하던 시절 아주 오래된 SPARC station 4를 샀던 터라 이 점을 아주 잘 기억한다. 빅 엔디안인데다 어떤 종류의 비정렬 접근도 처리하지 못했으니, 프로그램을 이리저리 포팅하는 것이 어떤 것인지 관점을 갖게 해주었다. 몇 달 뒤 그 위에 보드카를 쏟는 바람에 메인보드가 영원히 타버린 건 아쉽지만, 지금도 부모님 댁에 그대로 보관되어 있다.

Solaris/SPARC에서 비정렬 접근을 다루는 방식은 Linux/ARM보다 더 복잡하다. 32비트 비정렬 접근은 항상 커널이 교정하지만, 64비트 비정렬 접근은 컴파일 플래그에 따라 사용자 공간 트랩을 등록해 처리한다. Sun Studio C 컴파일러는 매우 정교한 방식으로 어떤 일이 일어날지 제어하는 전용 옵션을 가지고 있으며, 심지어 비정렬 접근을 쉽게 탐지하고 수정할 수 있는 도구도 제공한다.

Redis에서 워드 크기가 아닌 비정렬 접근이 드물었다면, 워드 크기의 비정렬 접근은 도처에 있을 거라고 예상할 수도 있다. 하지만 실제로는 그렇지 않았다. Redis 3.0까지는 OpenBSD/SPARC 박스에서 수시로 Redis를 테스트하고 수정했기 때문이다. 그래서 가장 큰 문제는 키를 해싱하는 함수였다. 원래 Redis 문자열 라이브러리인 SDS는 헤더 크기가 고정되어 있었기 때문에 키를 해싱할 때 접근이 항상 정렬되어 있었다. Redis 3.2부터 SDS 헤더는 가변 크기라 더 이상 그렇지 않다. 게다가 내가 몇 년 전 마지막으로 SPARC에서 Redis를 테스트한 이후로 여기저기 새로운 비정렬 접근이 누적되어 있었다.

해시 함수를 고치기 위해 나는 SipHash로 교체하기도 했는데, 이는 HashDoS 공격에 대한 보안 수정이기도 하다. 다만 현재 사용 중인 것은 C와 D 라운드 수를 줄인 SipHash 변형인 SipHash1-2라는 점을 밝혀둔다. 그렇지 않으면 적지 않은 속도 저하가 발생하기 때문에 이렇게 한 것이지만, 내가 아는 한 SipHash1-2에 대한 실질적인 공격 방법은 없고, 어쨌든 이전에 사용하던 MurmurHash2보다 훨씬 안전하다. MurmurHash2는 이 측면에서 워낙 취약해서 시드와 무관한 충돌을 만들어내는 것이 가능할 정도다.

내가 사용 중인 SipHash 구현은 레퍼런스 구현을 기반으로 코드를 약간 단순화하고 대소문자를 구분하지 않는 변형을 추가한 것이다. 비정렬 접근을 처리하고 엔디안에 구애받지 않도록 설계되어 있다. 어쩌면 제대로 작성된 해시 함수의 레퍼런스 구현을 처음 본 것일지도 모르겠다…

다른 SPARC 관련 수정들은 친절한 Redis 사용자가 Solaris/SPARC 접속 환경을 제공해 준 덕분에 훨씬 수월해졌다. 비정렬 접근을 수정하는 과정에서 Solaris/SPARC에서 Redis를 빌드하고 테스트하는 과정도 함께 수정하려 노력했으니, 전반적으로 호환성을 개선하는 좋은 연습이 되었다. 이 작업이 끝난 뒤 Redis는 적어도 스탠드얼론 코드에 대해서는 마침내 “정렬 안전(alignment safe)” 상태가 되었다. 클러스터 영역에서는 아직 할 일이 더 남아 있다.

라즈베리 파이에서 Redis의 성능

자, 다시 파이 이야기로 돌아오자 :-) 이렇게 작은 하드웨어에서 Redis는 얼마나 빠를까? 글쎄, 파이 모델이 여러 가지인 만큼 이 질문에 대한 답도 여러 가지다. Pi 3에서의 Redis는 놀라울 정도로 빠르다. 내 벤치마크는 루프백 인터페이스를 통해 수행했는데, 파이 위의 Redis는 주로 로컬 프로그램이 데이터를 쓰거나 IPC와 클라우드-엣지 간 정보 교환을 위한 메시지 버스 용도로 쓰일 것이기 때문이다(여기서 클라우드는 어플라이언스의 중앙 서버를, 엣지는 어플라이언스의 로컬 설치 환경을 의미한다). 하지만 이더넷 포트를 통해 접근할 때도 잘 동작한다.

Pi 3에서는 다음과 같은 수치가 나왔다:

테스트 1 : 100만 개 키에 대해 500만 번 쓰기(키마다 균등 분포). 영속성 없음, 파이프라이닝 없음. 28000 ops/sec.
테스트 2: 테스트 1과 동일하지만 8개 연산씩 파이프라이닝 사용: 80000 ops/sec.
테스트 3: 테스트 1과 동일하지만 AOF 활성화, fsync 1초: 23000 ops/sec
테스트 4: 테스트 3과 동일하지만 AOF 재작성 진행 중: 21000 ops/sec

기본적으로 Pi 3에서의 Redis는 어떤 사용 사례에도 충분히 빠르다고 보인다. Redis는 대부분 단일 스레드이며, AOF 로그를 재작성할 때는 백그라운드 프로세스가 하나 더 있어 이중 스레드로 동작한다는 점을 고려하면, 위와 같은 성능을 내는 동안에도 파이에서 다른 프로세스들을 동시에 실행할 수 있을 것으로 기대할 수 있다. 요점은 이 수치들이 파이가 포화 상태에 이르렀다는 것을 의미하지 않는다는 것이다.

오리지널 모델 B에서는 상황이 *꽤* 다르다. 수치가 훨씬 낮아서 파이프라이닝 없이 2000 ops/sec, 파이프라이닝을 사용하면 15000 ops/sec 정도다. 이 엄청난 격차는 문맥 교환을 필요로 하는 write나 read 같은 시스템 콜 처리가 매우 비효율적이라는 점을 시사하는 듯하다. 하지만 대부분의 애플리케이션에서는 여전히 충분히 괜찮은 수치다. Redis가 대부분의 경우 외부 클라이언트를 서비스하지 않을 것이고, 높은 부하의 데이터 로깅이 필요할 때는 파이프라이닝을 구현하는 것이 종종 간단하기 때문이다.

다만 지금 당장은 테스트해 볼 수 있는 가장 흥미로운 장치 중 하나인 Pi Zero(Pi 3 말고도)가 없다. 이 장치가 어떤 수치를 보여줄지 궁금하다. 내가 사용 중인 모델 B보다는 더 좋아야 할 것이다.

Pi의 지속성

Redis가 파이에서 잘 동작한다는 점에서 내가 좋아하는 것 중 하나는, Pi Zero 같은 제품 덕분에 라즈베리가 IoT 제품, 심지어 최종 사용자를 위한 완제품을 위한 사실상 표준 플랫폼이 될 가능성에 대해 설레게 된다는 것이다. 시간이 있다면 하드웨어 분야에서 내가 하고 싶은 것들이 얼마나 많은지 모른다. 센서, 디스플레이, GPIO 포트, 그리고 매우 낮은 가격은 과거에 비해 훨씬 더 간단한 방식으로 하드웨어 스타트업을 만드는 것을 가능하게 하며, 전 세계의 해커들이 이제 다양한 스마트 어플라이언스를 출시할 수 있게 되었다는 생각이 든다. 비록 주변적인 역할일지라도, 파이(그리고 앞으로는 안드로이드와 다른 ARM 기반 시스템)에서 좋은 Redis 경험을 제공함으로써 이 흐름에 조금이나마 동참하고 싶다. Redis는 적은 자원 요구량과 순차 추가(append-only) 연산, 그리고 이력 이벤트를 기반으로 동작을 결정하기 위한 로깅과 기기 내 데이터 분석에 모두 적합한 데이터 모델을 두루 갖추고 있어, 이 분야에서 분명 도움이 될 수 있다고 믿는다.

그래서 지금부터 라즈베리 파이는 나에게 원래 Redis의 ‘표준’으로 삼았던 리눅스 서버처럼 주요 타깃 플랫폼 중 하나다. 앞으로 몇 주 동안 나는 Redis 4.0에 모두 포함될 수정 작업을 계속해 나갈 것이다. 동시에 Redis 공식 사이트에 Redis와 파이에 대한 모든 정보를 담은 새로운 섹션을 작성할 예정이다. 다양한 장치에서의 벤치마크, 모범 사례 등 말이다. 어쩌면 앞으로는 Redis를 IoT 장치와 클라우드 사이의 데이터 버스처럼 사용할 수 있도록 개념 증명용 ‘에이전트’를 출시할 수도 있을 것이다. 이를 통해 장치는 바깥 세상과의 연결이 동작할 때 클라우드로 데이터를 옮기는 작업을 에이전트에 맡기고, Redis 안에 데이터를 그냥 기록하기만 하면 되며, 동시에 장치가 실행할 명령을 가져오고 그에 대한 응답을 다시 보낼 수 있게 된다. 이 기능은 Redis 4.2에서 스트림 자료구조가 제공되면 더욱 흥미로워질 것이다.

임베디드 환경에서 Redis가 도움이 될 만한 애플리케이션과, 이를 위해 내가 무엇을 더 개선하면 좋을지에 대한 여러분의 의견을 듣고 싶다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글