Try using it in your preferred language.

English

  • English
  • 汉语
  • Español
  • Bahasa Indonesia
  • Português
  • Русский
  • 日本語
  • 한국어
  • Deutsch
  • Français
  • Italiano
  • Türkçe
  • Tiếng Việt
  • ไทย
  • Polski
  • Nederlands
  • हिन्दी
  • Magyar
translation

AI가 번역한 다른 언어 보기

제이온

[Java] Synchronized Collection vs Concurrent Collection

  • 작성 언어: 한국어
  • 기준국가: 모든 국가 country-flag

언어 선택

  • 한국어
  • English
  • 汉语
  • Español
  • Bahasa Indonesia
  • Português
  • Русский
  • 日本語
  • Deutsch
  • Français
  • Italiano
  • Türkçe
  • Tiếng Việt
  • ไทย
  • Polski
  • Nederlands
  • हिन्दी
  • Magyar

durumis AI가 요약한 글

  • 동기화된 컬렉션은 멀티 스레드 환경에서 안전하지만, 여러 연산을 묶어 단일 연산처럼 사용하거나, 성능 저하를 야기할 수 있기 때문에 병렬 컬렉션을 사용하는 것이 좋다.
  • 병렬 컬렉션은 동기화된 컬렉션보다 더 효율적으로 동시성을 보장하며, CopyOnWriteArrayList, ConcurrentHashMap 등 다양한 종류가 있다.
  • 특히, CopyOnWriteArrayList는 읽기 작업에 잠금이 걸리지 않아 SynchronizedList보다 성능이 좋지만, 쓰기 작업 시 배열 복사를 수행하여 성능 저하를 유발할 수 있다.

Synchronized Collection

동기화된 컬렉션은 대표적으로 다음과 같은 클래스가 있다.


  • Vector
  • Hashtable
  • Collections.synchronizedXXX


이 클래스 모두 public으로 선언된 메소드에 synchronized 키워드를 사용하여 내부의 값을 한 스레드만 사용할 수 있도록 제어하면서 동시성을 보장하고 있다.


Vector

public class Vector extends AbstractList
    implements List, RandomAccess, Cloneable, java.io.Serializable {
    ...
    public synchronized boolean add(E e) {
                modCount++;
                add(e, elementData, elementCount);
                return true;
    }
    ...
}

Vector 클래스에서 요소를 추가하는 add() 메소드를 살펴 보면 synchronized 키워드가 보인다. 즉, 내부적으로 Vector에서 요소 삽입 연산이 진행될 때 동기화가 보장된다.

Hashtable

public class Hashtable extends Dictionary
    implements Map, Cloneable, java.io.Serializable {
        ...
        public synchronized boolean contains(Object value) {
        if (value == null) {
            throw new NullPointerException();
        }

        Entry tab[] = table;
        for (int i = tab.length ; i-- > 0 ;) {
            for (Entry e = tab[i] ; e != null ; e = e.next) {
                if (e.value.equals(value)) {
                    return true;
                }
            }
        }
        return false;
    }
        ...
}


Hashtable 클래스에서 동일한 value가 존재하는지 확인하는 contains() 메소드를 살펴보면 Vector 클래스와 동일하게 synchronized 키워드가 사용된 것을 확인할 수 있다.


Collections.synchronizedXXX

Collections.synchronizedList() 메소드를 사용해 생성되는 SynchronizedList 클래스를 살펴 보자.


static class SynchronizedList extends SynchronizedCollection
        implements List {

        final Object mutex;

        ...
        public E get(int index) {
                synchronized (mutex) {return list.get(index);}
        }

        public E set(int index, E element) {
                synchronized (mutex) {return list.set(index, element);}
        }

        public void add(int index, E element) {
                synchronized (mutex) {list.add(index, element);}
        }

        public E remove(int index) {
                synchronized (mutex) {return list.remove(index);}
        }
        ...
}


SynchronizedList 클래스의 메소드를 살펴 보면 모두 synchronized 키워드가 사용된 것을 알 수 있다. 다만, synchronized 블록을 사용하여 뮤텍스를 통해 동기화를 구현하였다. 모든 메소드는 mutex 객체를 공유하므로 하나의 스레드가 synchronized 블록에 들어간 순간 다른 메소드의 synchronized 블록이 다 lock이 걸린다.


Synchronized Collection의 문제점

멀티 스레드 환경에서 동기화된 컬렉션을 사용해야 하는 경우도 있지만, 웬만해서는 다른 동기화 방식을 사용하는 것이 좋다. 이유는 크게 두 가지가 있다.


여러 연산을 묶어 단일 연산처럼 사용하는 경우

동기화된 컬렉션 클래스는 멀티 스레드 환경에서 동시성을 보장한다. 하지만 여러 연산을 묶어 하나의 단일 연산처럼 활용해야 하는 경우 문제가 발생한다. 이러한 동기화된 컬렉션을 사용해도 올바르게 동작하지 않을 수도 있다.


final List list = Collections.synchronizedList(new ArrayList());
final int nThreads = 2;
ExecutorService es = Executors.newFixedThreadPool(nThreads);

for (int i = 0; i < nThreads; i++) {
    es.execute(new Runnable() {

        public void run() {
            while(true) {
                try {
                    list.clear();
                    list.add("888");
                    list.remove(0);
                } catch(IndexOutOfBoundsException e) {
                    e.printStackTrace();
                }
            }
        }
    });
}


위의 코드를 실행하면 Thread A가 remove(0) 을 하는 순간에 Thread B가 clear() 를 한다면 에러가 발생한다. 따라서 아래와 같이 동기화 블록으로 묶어야 한다.


synchronized (list) {
    list.clear();
    list.add("888");
    list.remove(0);
}


성능 저하

공유 객체를 사용하려는 모든 메소드를 synchronized 메소드로 만들어 버리거나, 메소드 내부에 동일한 synchronized 블록을 정의하게 되면, 하나의 스레드가 lock을 획득하는 순간 다른 스레드는 모든 동기화 메소드를 이용하지 못하고 blocking 상태가 된다. 이러한 반복된 현상은 성능 저하로 이어질 수 있다.


Concurrent Collection

java.util.concurrent 패키지에서 제공하는 병렬 컬렉션의 종류는 다음과 같고, 이 중 일부만 이번 글에서 다루려고 한다.


  • CopyOnWriteArrayList
    • List 클래스의 하위 클래스이며 객체 목록을 반복하며 조회하는 연산의 성능을 최우선으로 한 병렬 컬렉션이다.
  • ConcurrentMap
    • 병렬 컬렉션이며, 인터페이스를 보면 추가하려는 항목이 기존에 없는 경우에만 새로 추가하는 put-if-absent, replace, conditional remove 연산 등이 정의되어 있다.
  • ConcurrentHashMap
    • ConcurrentMap의 하위 클래스이며, HashMap을 대치하면서 병렬성을 확보한 병렬 컬렉션이다.
  • ConcurrentLinkedQueue
    • FIFO 방식 Queue이며, 병렬성을 확보한 병렬 컬렉션이다. 큐에서 꺼낼 원소가 없다면 즉시 리턴하고 다른 일을 수행하러 간다.
  • LinkedBlockingQueue
    • ConcurrentLinkedQueue와 비슷하다. 그러나 큐가 비어 있다면 큐에서 항목을 뽑아내는 연산은 새로운 항목이 추가될 때까지 대기하고, 반대로 큐에 크기가 지정되어 있는 경우에 큐가 지정한 크기만큼 가득 차 있다면, 큐에 새로운 항목을 추가하는 연산은 큐에 빈 자리가 생길 때까지 대기한다는 차이점이 있다.
  • ConcurrentSkipListMap, ConcurrentSkipListSet
    • 각각 SortedMap 과 SortedSet 클래스의 병렬성을 높이도록 발전된 형태이다.


기존에 사용하던 동기화 컬렉션 클래스를 병렬 컬렉션으로 교체하는 것만으로도 별 다른 위험 요소 없이 전체적인 성능을 상당히 끌어올릴 수 있다.

병렬 컬렉션과 대비되는 동기화 컬렉션을 비교하며 자세히 알아 보자.


CopyOnWriteArrayList

동기화된 ArrayList를 생성하는 방법은 아래 두 가지가 있다.


  • Collections.synchronizedList()
  • CopyOnWriteArrayList


Collections.synchronizedList()는 JDK 1.2 버전에 추가되었다. 이 컬렉션은 모든 읽기와 쓰기 동작에 대해 동기화가 되어 있으므로 융통성이 없는 설계라고 볼 수 있다. 따라서 이를 보완한 CopyOnWriteArrayList가 등장하게 되었다.


읽기 동작

SynchronizedList는 읽기와 쓰기 동작 시 인스턴스 자체에 잠금이 걸린다. 그러나 CopyOnWriteArrayList는 모든 쓰기 동작 시 원본 배열에 있는 요소를 복사하여 새로운 임시 배열을 만들고, 이 임시 배열에 쓰기 동작을 수행한 후 원본 배열을 갱신한다. 이 덕분에 읽기 동작은 잠금이 걸리지 않으므로 SynchronizedList보다 성능이 좋다.


public class CopyOnWriteArrayList implements List, RandomAccess, Cloneable, java.io.Serializable {

    final transient Object lock = new Object();
    private transient volatile Object[] array;

        ...
        public E get(int index) {
        return elementAt(getArray(), index);
    }
        ...
}


위는 get() 메소드인데, synchronized가 없으므로 잠금이 걸리지 않는다.


쓰기 동작

CopyOnWriteArrayList는 쓰기 동작 수행 시 명시적 락을 사용한다. 결국 두 종류의 컬렉션 모두 이 동작에서 잠금이 걸린다. 이때 CopyOnWriteArrayList는 비용이 상대적으로 높은 배열 복사 작업을 하기 때문에 상당한 쓰기 작업이 수행되면 성능 이슈가 발생할 수 있다.


public class CopyOnWriteArrayList implements List, RandomAccess, Cloneable, java.io.Serializable {

    final transient Object lock = new Object();
    private transient volatile Object[] array;

        public void add(int index, E element) {
        synchronized (lock) {
            ...
            int numMoved = len - index;
            if (numMoved == 0)
                newElements = Arrays.copyOf(es, len + 1);
            else {
                newElements = new Object[len + 1];
                System.arraycopy(es, 0, newElements, 0, index);
                System.arraycopy(es, index, newElements, index + 1,
                                 numMoved);
            }
                        ...
        }
    }
}


위는 add() 메소드인데, synchronized 블럭을 통해 잠금을 걸고 배열을 복사 작업을 수행한다.


Iterator

CopyOnWriteArrayList에서 iterator를 뽑아내는 시점의 컬렉션 데이터를 기준으로 반복하며, 반복하는 동안 컬렉션에 데이터가 추가되거나 삭제되는 내용은 반복문과 상관 없는 복사본을 대상으로 반영하기 때문에 동시 사용성에 문제가 없다.


CopyOnWriteArraySet

동기화된 Set을 생성하는 방법은 아래 두 가지가 있다.


  • Collections.synchronizedSet()
  • CopyOnWriteArraySet


메소드 네이밍을 보면 알 수 있듯이, CopyOnWriteArrayList와 동작 방식이 자료 구조 특징을 제외하면 거의 동일하다.


읽기 동작

public class CopyOnWriteArraySet extends AbstractSet implements java.io.Serializable {

    private final CopyOnWriteArrayList al;

        public boolean contains(Object o) {
        return al.contains(o);
    }
}


위는 contains() 메소드인데, CopyOnWriteArraySet은 내부적으로 CopyOnWriteArrayList을 정의하고, CopyOnWriteArrayList의 메소드를 사용하는 것을 알 수 있다.


public boolean contains(Object o) {
        return indexOf(o) >= 0;
}

public int indexOf(Object o) {
        Object[] es = getArray();
        return indexOfRange(o, es, 0, es.length);
}

    private static int indexOfRange(Object o, Object[] es, int from, int to) {
        if (o == null) {
            for (int i = from; i < to; i++)
                if (es[i] == null)
                    return i;
        } else {
            for (int i = from; i < to; i++)
                if (o.equals(es[i]))
                    return i;
        }
        return -1;
}


CopyOnWriteArrayList의 contains() 메소드를 살펴보면, 잠금이 걸리지 않는 것을 알 수 있다.


쓰기 동작

public class CopyOnWriteArraySet extends AbstractSet implements java.io.Serializable {

        private final CopyOnWriteArrayList al;

        public boolean add(E e) {
        return al.addIfAbsent(e);
    }
}


add() 메소드도 CopyOnWriteArrayList의 메소드를 차용한다.


public boolean addIfAbsent(E e) {
    Object[] snapshot = getArray();
    return indexOfRange(e, snapshot, 0, snapshot.length) < 0
        && addIfAbsent(e, snapshot);
}

private boolean addIfAbsent(E e, Object[] snapshot) {
        synchronized (lock) {
            Object[] current = getArray();
            int len = current.length;
            if (snapshot != current) {
                // Optimize for lost race to another addXXX operation
                int common = Math.min(snapshot.length, len);
                for (int i = 0; i < common; i++)
                    if (current[i] != snapshot[i]
                        && Objects.equals(e, current[i]))
                        return false;
                if (indexOfRange(e, current, common, len) >= 0)
                        return false;
            }
            Object[] newElements = Arrays.copyOf(current, len + 1);
            newElements[len] = e;
            setArray(newElements);
            return true;
        }
  }


addIfAbsent() 메소드를 살펴 보면, 쓰기 동작 시 잠금을 걸고 배열 복사를 수행한다. 따라서 CopyOnWriteArraySet도 CopyOnWriteArrayList과 마찬가지로 쓰기 작업은 많이 하지 않는 것이 좋다.


ConcurrentHashMap

동기화된 HashMap를 생성하는 방법은 아래 두 가지가 있다.


  • Collections.synchronizedMap(new HashMap<>())
  • ConcurrentHashMap


ConcurrentHashMap은 HashMap과 동일하게 Hash를 기반으로 하는 Map이다. synchronizedMap에 비해 더욱 효율적인 방법으로 동시성을 보장한다.


자바 8 이전에는 ReentrantLock을 상속 받는 Segment를 이용해, 영역을 구분함으로써 영역 별로 잠금을 하는 방식이었다.


자바 8 이후부터는 각 테이블 버킷을 독립적으로 잠그는 방식을 사용한다. 빈 버킷에 노드를 삽입할 경우에는 잠금 대신 CAS 알고리즘을 사용하고, 그 외의 변경은 각 버킷의 첫 번째 노드를 기준으로 부분적인 잠금(synchronized block)을 획득하여 스레드 경합을 최소화하며 동시성을 보장한다.


ConcurrentHashMap에 새로운 노드를 삽입하는 putVal() 메소드의 코드를 보며, 동시성을 어떻게 보장하는지 확인해 보자. 참고로 다음 예제 코드는 자바 11 기준이다.


putVal() 메소드는 크게 아래와 같이 두 가지 경우(분기는 총 네 부분)로 나눌 수 있다.


  • 빈 해시 버킷에 노드를 삽입하는 경우
  • 해시 버킷에 이미 노드가 존재하는 경우


final V putVal(K key, V value, boolean onlyIfAbsent) {
        if (key == null || value == null) throw new NullPointerException();
        int hash = spread(key.hashCode());
        int binCount = 0;
        for (Node[] tab = table;;) {
            Node f; int n, i, fh; K fk; V fv;
            if (tab == null || (n = tab.length) == 0)
                tab = initTable();
                        // (1)
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
                                // (2)
                if (casTabAt(tab, i, null, new Node(hash, key, value)))
                    break;
            }
            else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);
            else if (onlyIfAbsent // check first node without acquiring lock
                     && fh == hash
                     && ((fk = f.key) == key || (fk != null && key.equals(fk)))
                     && (fv = f.val) != null)
                return fv;
                        // (3)
            else {
                V oldVal = null;
                synchronized (f) {
                    if (tabAt(tab, i) == f) {
                        if (fh >= 0) {
                            binCount = 1;
                            for (Node e = f;; ++binCount) {
                                K ek;
                                                                // (4)
                                if (e.hash == hash &&
                                    ((ek = e.key) == key ||
                                     (ek != null && key.equals(ek)))) {
                                    oldVal = e.val;
                                    if (!onlyIfAbsent)
                                        e.val = value;
                                    break;
                                }
                                Node pred = e;
                                                                // (5)
                                if ((e = e.next) == null) {
                                    pred.next = new Node(hash, key, value);
                                    break;
                                }
                            }
                        }
                                                // (6)
                        else if (f instanceof TreeBin) {
                            Node p;
                            binCount = 2;
                            if ((p = ((TreeBin)f).putTreeVal(hash, key,
                                                           value)) != null) {
                                oldVal = p.val;
                                if (!onlyIfAbsent)
                                    p.val = value;
                            }
                        }
                        else if (f instanceof ReservationNode)
                            throw new IllegalStateException("Recursive update");
                    }
                }
                                ...
            }
        }
                ...
    }


빈 해시 버킷에 노드를 삽입하는 경우

(1) 새로운 노드를 삽입하기 위해, 해당 버킷 값(tabAt())을 가져와 비어 있는지 확인한다.


static final  Node tabAt(Node[] tab, int i) {
        return (Node)U.getObjectAcquire(tab, ((long)i << ASHIFT) + ABASE);
}


(2) 노드에 담겨 있는 volatile 변수에 접근하여 기존 값(null)과 비교한 후, 같으면 새로운 노드를 저장한다. 같지 않으면, for문으로 다시 돌아간다. 이 방식은 CAS 알고리즘이다.


static final  boolean casTabAt(Node[] tab, int i, Node c, Node v) {
        return U.compareAndSetObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}


CAS 알고리즘을 사용하여 원자성과 가시성 문제를 해결하여 동시성을 보장한다.


해시 버킷에 이미 노드가 존재하는 경우

(3) 이미 노드가 존재하는 경우에는 synchronized block을 사용하여 하나의 스레드만 접근하도록 제어한다. 이때, 비어있지 않은 Node 타입의 해시 버킷에 잠금을 걸기 때문에 동일한 버킷에 접근하는 스레드는 Blocking 상태가 된다.

(4) 새로운 노드로 교체한다.

(5) 해시 충돌이 일어난 경우 Separate Chaing에 추가한다.

(6) 해시 충돌이 일어난 경우 트리에 추가한다.


참고


예상 면접 질문 및 답변

Vector, HashTable, Collections.SynchronziedXXX의 문제점은?

Vector, HashTable, SynchronziedXxx 클래스는 synchronized 메소드 또는 블록을 사용하며, 하나의 잠금 객체를 공유한다. 따라서, 컬렉션에 하나의 스레드가 잠금을 획득하는 경우 다른 스레드들은 모든 메소드를 사용하지 못하고 Blocking 상태가 된다. 이는 애플리케이션 성능 저하의 원인이 될 수 있다.


SynchronizedList와 CopyOnArrayList의 차이점은?

SynchronizedList는 읽기와 쓰기 동작시 인스턴스 자체에 잠금이 걸린다. 그러나 CopyOnArrayList는 쓰기 동작시 해당 블록에 잠금을 걸고 원본 배열에 있는 요소를 복사하여 새로운 임시 배열을 만들고, 이 임시 배열에 쓰기 동작을 수행한 후 원본 배열을 갱신한다. 이 덕분에 읽기 동작은 잠금이 걸리지 않아 SynchronizedList보다 읽기 성능이 좋다. 그러나 쓰기 동작은 비용이 높은 배열 복사 작업을 하기 때문에 SynchronizedList보다 쓰기 성능이 떨어진다.

따라서, 변경 작업보다 읽는 작업이 많을 경우 CopyOnArrayList를 사용하는 것이 더 효과적이다.


SynchronizedMap과 ConcurrentHashMap의 차이점은?

SynchronziedMap은 읽기와 쓰기 동작 시 인스턴스 자체에 잠금이 걸린다. 그러나 ConcurrentHashMap은 각 테이블 버킷을 독립적으로 잠그는 방식을 사용한다. 만약, 빈 버킷에 노드를 삽입할 경우 잠금(Lock) 대신 CAS 알고리즘을 사용하고, 그 외의 변경은 접근한 버킷에만 잠금이 걸려 스레드 경합을 최소화하며 동시성을 보장해준다.

제이온
제이온
제이온
제이온
[Spring] @Async 사용 방법 Spring @Async를 사용하여 Java에서 비동기 처리를 구현하는 방법을 설명합니다. @EnableAsync 어노테이션을 사용하여 비동기 메소드를 선언하고, ThreadPoolTaskExecutor를 사용하여 스레드 풀을 구성하여 효율적인 비동기 작업 처리를 수행할 수 있습니다. Future, ListenableFuture, CompletableFuture 등 다양한 리턴 타입을 사용하여 비동기 메소드의 결과를 처리하는 방법도 다룹니다.

2024년 4월 25일

Java Collections Framework(JCF)란? - JCF의 정의와 특징 (JAVA) Java Collections Framework(JCF)는 자바에서 데이터를 쉽고 효과적으로 처리할 수 있도록 표준화된 방법을 제공하는 클래스 집합입니다. JCF는 데이터 저장 자료 구조와 처리 알고리즘을 클래스로 구현하여 코드 재사용성을 높이고, 개발 시간을 단축하며 프로그램 성능과 품질을 향상시킵니다.

2024년 4월 27일

[이펙티브 자바] 아이템 6. 불필요한 객체 생성을 피하라 자바에서 불필요한 객체 생성을 최소화하여 성능을 향상시키는 방법에 대해 설명합니다. 문자열, 부울, 정규 표현식, 뷰 객체, 오토 박싱 등 다양한 예시와 함께 객체 재사용의 중요성을 강조합니다. 특히 방어적 복사가 필요한 경우 객체 재사용으로 인한 문제점을 주의해야 합니다.

2024년 4월 28일

[비전공, 개발자로 살아남기] 14. 신입 개발자 자주 묻는 기술면접 내용 요약 신입 개발자 면접에서 자주 나오는 기술 질문과 답변을 정리했습니다. 메모리 영역, 자료구조, 데이터베이스, 프로그래밍 패러다임, 페이지 교체 알고리즘, 프로세스와 스레드, OSI 7 계층, TCP와 UDP 등 다양한 주제를 다룹니다.
투잡뛰는 개발 노동자
투잡뛰는 개발 노동자
투잡뛰는 개발 노동자
투잡뛰는 개발 노동자

2024년 4월 3일

[Concurrency] Atomic Operation: Memory Fence와 Memory Ordering Atomic 연산에서 메모리 순서를 고려하는 것은 동시성 처리에 필수적입니다. CPU 최적화로 인해 명령어 순서가 바뀌는 현상이 발생할 수 있으며, 이는 동시성 환경에서 문제를 일으킬 수 있습니다. Memory Fence와 Ordering 옵션을 통해 이러한 문제를 해결할 수 있습니다. Ordering 옵션에는 Relaxed, Acquire, Release, AcqRel, SecCst가 있으며, 각각 다른 수준의 메모리 순서 보장을 제공합니다.
곽경직
곽경직
곽경직
곽경직
곽경직

2024년 4월 12일

Rust가 동시성 버그를 방지하는 방법 Rust는 강력한 타입 시스템을 통해 동시성 프로그래밍에서 발생하는 일반적인 버그를 컴파일 타임에 감지하여 안전성을 높입니다. 특히, 스레드에 값을 전달할 때 move 클로저를 사용하여 값을 이동시켜야 하고, 여러 스레드에서 공유되는 변수는 Arc와 Mutex와 같은 내부 가변성 패턴을 활용하여 안전하게 관리할 수 있습니다.
곽경직
곽경직
곽경직
곽경직
곽경직

2024년 3월 28일

물리적 데이터 모델링 물리적 데이터 모델링은 논리적 데이터 모델링을 기반으로 저장 공간 효율성, 오브젝트 파티셔닝, 인덱스 최적화 등 성능 향상에 중점을 둡니다.
제이의 블로그
제이의 블로그
제이의 블로그
제이의 블로그
제이의 블로그

2024년 4월 9일

[Javascript] Object의 구조 (V8) JavaScript에서 Object는 내부적으로 해시테이블과 유사한 방식으로 동작하지만, V8 엔진에서는 Hidden class를 이용하여 Fast 모드와 Dictionary 모드로 변환되어 성능을 최적화합니다. Hidden class는 객체의 구조를 정의하고 Fast 모드에서는 빠른 속도를 제공하지만, 키 추가 등의 변화가 발생하면 Dictionary 모드로 전환되어 해시테이블처럼 동작하며 성능이 저하될 수 있습니다.
곽경직
곽경직
곽경직
곽경직
곽경직

2024년 3월 18일

Rust로 기깔난 CLI 만들어보기 Rust 언어를 사용하여 clap과 ratatui 라이브러리를 활용하여 로그인, 로그아웃 기능을 갖춘 CLI 프로그램을 만드는 방법을 소개합니다. 플래그를 이용한 명령어 처리와 TUI를 이용한 대화형 인터페이스 구현 방법을 보여줍니다. GitHub 링크를 통해 전체 코드를 확인할 수 있습니다.
곽경직
곽경직
곽경직
곽경직
곽경직

2024년 3월 13일