Posts 학원 #60일차: 요구사항 분석(Use Case), Executors 태스크 프레임워크 - 스레드풀
Post
Cancel

학원 #60일차: 요구사항 분석(Use Case), Executors 태스크 프레임워크 - 스레드풀

요구사항 분석

  • Use-Case(사용 사례): actor가 시스템을 사용하여 달성하려는 목표
  • 이를 알려면 사용자(actor)가 누구인지 알아야 한다.

Use case 식별 방법

image

Use Case는 개발할 기능을 뜻한다. 버튼을 누르는 것도 기능이 맞긴 하지만, Use Case는 사용자의 목적에 집중한다. 즉 버튼을 사용자가 왜 누르는지, 한 단위의 업무 단위에 초점을 맞춘다. 다음은 Use Case 식별 방법이다.

  • 1) (개발할) 시스템을 사용해서 처리하는 “업무”
    • ex: 전화, 팩스 보내는 것은 업무이지만 시스템을 사용해서 처리하는 업무가 아니기 때문에 use case가 아니다.
  • 2) 한 사람한 번한 순간에 수행하는 업무
    • ex: 메일 보내는 일은 한 사람이 한 번에 한 순간에 수행하는 업무로 쪼갤 수 있다.
      • 메일 임시 보관하기
      • 메일 보내기
  • 3) 카운트가 가능한 단위로 업무를 쪼갠다
    • 업무의 시작과 끝이 명확해야 한다.
    • 게시물 100건 조회, 게시물 40건 수정 등. 즉 게시물 관리는 use case를 너무 크게 뽑은 것이다. 게시물 조회, 변경, 삭제, 추가 각각이 하나의 use case이다.
    • 게시물 변경 버튼을 누르는 것은 업무가 아니기 때문에 use case가 아니다.

2번과 3번이 Use Case의 적정 크기를 결정한다. Use Case를 너무 두리뭉실하게 뽑으면 기능을 범위를 조정하기 힘들기 때문에 Use Case의 적정 크기를 적절하게 결정하는 것은 중요하다.

Use Case 적정 크기

  • 개발 단위: 분석 -> 설계 -> 구현 -> 테스트

  • RUP 개발 프로세스: 2주~6주 동안 개발
  • Agile 개발 방법론: 1시간 ~ 1일

Use Case의 특별한 예

image

여러 Use Case의 공통 시나리오인 경우 업무가 아닌데도 Use Case로 식별할 수 있다. 게시글 등록, 게시글 변경, 게시글 삭제는 로그인에 포함된다. 로그인은 업무는 아니지만 다른 Use Case들의 관리를 쉽게 하기 위해 추출한다. 결국 이것이 개발 편의성을 제공한다.

Use Case 통합

image

  • 서로 관련된 업무인 경우 관리의 편의성을 위해 한 개의 Use Case로 합치기도 한다.
  • Use Case는 명사구 형태를 많이 띈다 (XxxHandler, XxxManage, XxxService) 한국어로는 ‘관리하기’ 등 ‘~하기’라는 말로 한다.

Use Case Diagram

image

primary actor가 사용자고, secondary actor는 우리 시스템의 의존하는 외부 시스템이다. 화살표 방향을 주의하자!

포함, 확장 관계

image

실무에서는 유연성을 위해 secondary actor(카카오 주소검색) 까진 명시하지 않는다. 예를 들어 주소검색 시스템은 카카오가 아니라 네이버 주소검색도 쓸 수 있다. 소프트웨어 설계는 건축물 설계와 다르다. 건축물 설계는 철근 종류 및 크기와 와 같은 규격을 상세하게 적어야 한다. 그러나 소프트웨어 설계는 완벽해야 하는 것이 아니라 큰 그림을 주어야 한다. 개발자들이 secondary actor 선택 등은 개발자들이 선택할 수 있어야 한다.

주소검색은 업무는 아니지만 회원가입할 때도 하고 회원 정보 검색할 때도 한다. 즉 여러 유스 케이스에서 공통적으로 사용하는 주소검색을 유스케이스로 뽑을 수 있다.

유스케이스와 유스케이스끼리 포함 확장 관계가 있다. 주소검색을 하지 않고 회원가입을 끝낼 방법이 없다면, 즉 필수적이라면 포함 관계(include)에 있다고 말한다. 포함 관계에 있다면 실행 흐름이 단절된다. 반면 주소 검색을 하는 것이 선택적이라면 확장(extend)하는 use case라고 한다. 여기서 주소검색은 회원가입의 extension point가 된다.

image

보통 실무에서 로그인까지는 적지 않는다.

여기서 관리자는 회원을 상속받기 때문에 회원이 하는 기능은 다 할 수 있다.

  • RUP 프로세스:

image

시스템 아키텍처는 우선순위 결정, 구현 모델 구축, 배포 설명서, 레퍼런스 아키텍처, 가이드라인 설계, 구현 모델 작성, 통신 프로토콜 작성, 인터페이스, 이벤트, 시그널 관련, 프로그램 가이드라인(변수명, 메서드명, 클래스명 등) 제공 등의 일을 한다.

개발자는 UI 프로토타입use case 명세서 두 문서가 있어야 개발을 할 수 있다. 클라이언트가 있는 SI 기업의 경우 요구사항 명세서가 꼭 있어야 한다.

내가 기획한 서비스 WandokUse Case Diagram을 그려보았다.

Wandok_ use case (1)

Executors 태스크 프레임워크

java.util.concurrent

스레드풀 만들고 사용하기

  1. 스레드풀에 요청할 작업을 Runnable 구현체로 작성한다.
  2. execute()를 호출하면서 구현체를 파라미터로 넘겨준다.
  3. 스레드풀은 스레드가 없다면 새로 생성하고, 기존에 놀고 있는 스레드가 있다면 그 스레드를 사용한다.
  4. 스레드의 start()를 호출함으로써 파라미터로 넘겨준 Runnable 객체의 run()를 실행한다.

image

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Exam0110 {
  public static void main(String[] args) {
    // 스레드풀을 생성: 최대 3개의 스레드 생성
    ExecutorService executorService = Executors.newFixedThreadPool(3);

    // 스레드풀에 작업 수행 요청
    // - 작업은 Runnable 구현체로 작성하여 넘겨준다.
    // - 스레드풀은 스레드를 생성하여 작업을 수행시킨다.
    executorService.execute(() -> System.out.printf("%s - Hello!\n",
        Thread.currentThread().getName()));

    System.out.println("main() 종료!");
    // JVM은 main 스레드가 종료하더라도 나머지 스레드가 종료할 때까지 기다린다.
    // 스레드풀에서 생성한 스레드가 요청한 작업을 마치더라도
    // 다음 작업을 수행하기 위해 계속 실행된 채로 대기하고 있기 때문에
    // JVM은 종료하지 않는다.
  }
}

1
2
main() 종료!
pool-1-thread-1 스레드 실행!

JVM은 main 스레드가 종료하더라도 나머지 스레드가 종료할 때까지 기다린다. 스레드풀에서 생성한 스레드가 요청한 작업을 마치더라도 다음 작업을 수행하기 위해 계속 실행된 채로 대기하고 있다. 스레드드는 내부적으로 반복문을 계속 돌린다. 반복할 때 notify()로 스레드를 깨우기 전까지는 wait()하게 한다. 따라서 JVM은 종료하지 않는다.

스레드풀의 장점은 이것이다. 스레드 풀 없이 스레드를 직접 생성하고 일을 시키면 (start() -> run()) 일이 끝난 후 dead 상태로, 되살릴 수 없게 된다. 일을 시킬 때마다 스레드가 죽고 죽은 스레드는 가비지가 되는 문제를 스레드풀로 해결할 수 있다.

image

익명 클래스로 인터페이스를 구현할 때 인터페이스도 익명 클래스도 생성자가 없기 때문에 object를 상속받게 된다. 즉 object의 생성자를 호출한다.

스레드풀 종료하기: shutdown()

스레드풀에 있는 모든 스레드들이 요청한 작업을 끝내면 종료하도록 지시한다. executorService.shutdown()모든 스레드가 종료될 때가지 기다리지 않고 바로 리턴한다. shutdown() 호출 이후에는 새 작업 요청은 받지 않는다. 즉 이때 다시 execute()를 호출하면 예외가 발생한다.

1
2
3
4
5
6
7
8
9
public class Exam0120 {
  public static void main(String[] args) {
    ExecutorService executorService = Executors.newFixedThreadPool(3);
    executorService.execute(() -> System.out.printf("%s 스레드 실행!", Thread.currentThread().getName()));
    
    executorService.shutdown();
    System.out.println("main() 종료!");
  }
}

고정크기 스레드풀: FixedThreadPool

이번 예제부터는 같은 클래스 안에 다음과 같은 Runnable 구현체가 있다고 하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static class MyRunnable implements Runnable {
  int millisec;

  public MyRunnable(int millisec) {
    this.millisec = millisec;
  }

  public void run() {
    try {
      System.out.printf("%s 스레드 실행 중...\n", Thread.currentThread().getName());
      Thread.sleep(millisec);
    } catch (Exception e) {
      System.out.printf("%s 스레드 실행 중 오류 발생!\n", Thread.currentThread().getName());
    }
  }

image

작업은 큐에 등록된 순서대로 보관된다. 스레드풀은 큐에서 작업을 꺼내 일을 시킨다. 스레드풀의 크기를 초과해서 작업 요청을 한다면, 놀고 있는 스레드가 없을 경우, 다른 스레드의 작업이 끝날 때까지 작업큐에 대기하고 있는다. 작업을 끝낸 스레드가 생기면 큐에서 작업을 꺼내 실행한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static void main(String[] args) {
  ExecutorService executorService = Executors.newFixedThreadPool(3);

  // 일단 스레드풀의 크기(3개)만큼 작업 수행을 요청한다.
  executorService.execute(new MyRunnable(6000));
  // 이 작업은 가장 먼저 종료된다.
  // 이 작업을 한 스레드(thread-2)는 종료 후 즉시 작업큐에 있던 작업을 수행한다. 
  executorService.execute(new MyRunnable(3000));
  executorService.execute(new MyRunnable(9000));

  // 스레드풀의 크기를 초과해서 작업 수행을 요청한다.
  executorService.execute(new MyRunnable(2000));
  executorService.execute(new MyRunnable(4000));

  System.out.println("main() 종료!");
}

실행결과

1
2
3
4
5
6
7
8
9
10
11
pool-1-thread-1 스레드 실행 중...
main() 종료!
pool-1-thread-3 스레드 실행 중...
pool-1-thread-2 스레드 실행 중...
pool-1-thread-2 스레드 종료!
pool-1-thread-2 스레드 실행 중...
pool-1-thread-2 스레드 종료!
pool-1-thread-2 스레드 실행 중...
pool-1-thread-1 스레드 종료!
pool-1-thread-3 스레드 종료!
pool-1-thread-2 스레드 종료!

가변크기 스레드풀: CachedThreadPool

image

보관소에 저장되어 있는 스레드가 없으면 만들고, 있으면 그 스레드를 사용한다.

가변크기 스레드풀은 스레드의 수를 고정하지 않고 필요할 때마다 스레드를 생성하는 스레드풀이다. 작업을 끝낸 스레드는 다시 사용할 수 있도록 pool에 보관한다. 놀고 있는 스레드가 없으면 새 스레드를 생성한다. 고정크기 스레드풀의 경우 개수보다 더 많은 작업을 시키면 작업을 끝내고 처리할 때까지 기다린다. 그러나 가변크기 스레드풀은 기다리지 않고 스레드를 계속 생성한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void main(String[] args) throws Exception {
  ExecutorService executorService = Executors.newCachedThreadPool();
  
  executorService.execute(new MyRunnable(6000));
  executorService.execute(new MyRunnable(3000));
  executorService.execute(new MyRunnable(9000));
  executorService.execute(new MyRunnable(2000));

  // 작업을 끝낸 스레드가 생길 때까지 일부러 기다린다.
  Thread.sleep(3000);

  executorService.execute(new MyRunnable(4000));

  System.out.println("main() 종료!");
}

실행결과

1
2
3
4
5
6
7
8
9
10
11
pool-1-thread-1 스레드 실행 중...
pool-1-thread-4 스레드 실행 중...
pool-1-thread-2 스레드 실행 중...
pool-1-thread-3 스레드 실행 중...
pool-1-thread-4 스레드 종료!
main() 종료!
pool-1-thread-4 스레드 실행 중...
pool-1-thread-2 스레드 종료!
pool-1-thread-1 스레드 종료!
pool-1-thread-4 스레드 종료!
pool-1-thread-3 스레드 종료!

이 방식은 한 번에 많은 양의 작업이 들어온다면 스레드가 그만큼 많이 생성된다는 단점이 있다. 만약 클라이언트가 한 번에 백만명이 들어온다면 스레드가 백만개가 생성된다.

한 개의 스레드를 갖는 스레드풀: SingleThreadExecutor

  • 한 개의 스레드를 갖는 스레드풀은 작업을 순차적으로 처리한다.

  • 싱글 스레드는 예매 서버를 구축하는 경우 등 한 스레드가 순서대로 처리해야 할 때 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
  public static void main(String[] args) throws Exception {

    // 한 개의 스레드만 갖는 스레드풀이다.
    ExecutorService executorService = Executors.newSingleThreadExecutor();

    // 스레드가 한 개이기 때문에 순차적으로 실행한다.
    executorService.execute(new MyRunnable(6000));
    executorService.execute(new MyRunnable(3000));
    executorService.execute(new MyRunnable(9000));
    executorService.execute(new MyRunnable(2000));
    executorService.execute(new MyRunnable(4000));

    System.out.println("main() 종료!");
  }

실행 결과

1
2
3
4
5
6
7
8
9
10
11
main() 종료!
pool-1-thread-1 스레드 실행 중...
pool-1-thread-1 스레드 종료!
pool-1-thread-1 스레드 실행 중...
pool-1-thread-1 스레드 종료!
pool-1-thread-1 스레드 실행 중...
pool-1-thread-1 스레드 종료!
pool-1-thread-1 스레드 실행 중...
pool-1-thread-1 스레드 종료!
pool-1-thread-1 스레드 실행 중...
pool-1-thread-1 스레드 종료!

작업 실행: execute()

스레드풀은 작업큐에 작업을 호출한 순서대로 보관한다. 그리고 놀고 있는 스레드가 있다면, 작업큐에서 작업을 꺼내 수행시킨다. 놀고 있는 스레드가 없다면, 새로 스레드를 생성한다. 스레드가 최대 개수라면 작업을 끝낼 때까지 기다린다. execute() 메서드를 사용할 경우 submit() 메서드를 사용할 때와 달리 수행한 작업의 종료 여부를 확인할 수 없다.

1
2
3
4
5
6
public static void main(String[] args) {
  ExecutorService executorService = Executor.newFixedThreadPool(3);
  // 스레드풀에 수행할 작업 등록
  executorService.execute(new MyRunnable(6000));
  System.out.pirnltn("main() 종료!");
}
1
2
main() 종료!
pool-1-thread-1 스레드 실행 중...

작업 실행: submit()

1
2
3
4
5
6
7
8
9
10
public static void main(String[] args) throws Exception {
  ExecutorService executorService = Executors.newFixedThreadPool(3);
  Future<?> future1 = executorService.submit(new MyRunnable(2000));
  Future<?> future2 = executorService.submit(new MyRunnable(4000));
  
  future2.get();
  System.out.println("두 번째 작업이 끝났음");
  future1.get();
  System.out.println("첫 번재 작업이 끝났음");
}

submit()execute()와 같다. 다만 작업의 종료 상태를 확인할 수 있는 Future 객체를 리턴한다. Future.get()은 요청한 작업이 완료될 때까지 기다리고(blocking; pending) 요청한 작업이 완료되면 null을 리턴한다. 특정 작업이 끝날 때까지 기다릴 필요가 있을 때는 submit()을 사용한다.

1
2
3
4
5
6
7
pool-1-thread-1 스레드 실행 중...
pool-1-thread-2 스레드 실행 중...
pool-1-thread-1 스레드 종료! // 이미 종료됨
pool-1-thread-2 스레드 종료!
두 번째 작업이 끝났음
첫 번째 작업이 끝났음 //따라서 future.get()은 바로 null을 리턴함
main() 종료!

스레드풀 종료

  • shutdown(): 진행 중인 작업을 완료하고 대기 중인 작업도 완료한 다음 종료
  • shutdownNow(): 진행 중인 작업을 즉시 종료하고, 대기 중인 작업 목록은 리턴

shutdown()

더이상 작업 요청을 받지 않고 이전에 요청한 작업들이 완료되면 스레드를 종료하도록 예약한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static void main(String[] args) throws Exception {
  ExecutorService executorService = Executors.newFixedThreadPool(3);
  executorService.execute(new MyRunnable(6000));
  executorService.execute(new MyRunnable(2000));
  executorService.execute(new MyRunnable(4000));
  executorService.execute(new MyRunnable(4000));
  executorService.execute(new MyRunnable(4000));
  executorService.execute(new MyRunnable(4000));
  executorService.shutdown();

  // 작업 요청을 거절한다 => 예외 발생!
  executorService.execute(new MyRunnable(4000));
  System.out.println("main() 종료!");
}

실행 결과

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Exception in thread "main" java.util.concurrent.RejectedExecutionException: Task com.eomcs.concurrent.ex7.Exam0410$MyRunnable@5acf9800 rejected from java.util.concurrent.ThreadPoolExecutor@4617c264[Shutting down, pool size = 3, active threads = 3, queued tasks = 3, completed tasks = 0]
	at java.base/java.util.concurrent.ThreadPoolExecutor$AbortPolicy.rejectedExecution(ThreadPoolExecutor.java:2055)
	at java.base/java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:825)
	at java.base/java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1355)
	at com.eomcs.concurrent.ex7.Exam0410.main(Exam0410.java:37)
pool-1-thread-2 스레드 실행 중...
pool-1-thread-1 스레드 실행 중...
pool-1-thread-3 스레드 실행 중...
pool-1-thread-2 스레드 종료!
pool-1-thread-2 스레드 실행 중...
pool-1-thread-3 스레드 종료!
pool-1-thread-3 스레드 실행 중...
pool-1-thread-1 스레드 종료!
pool-1-thread-1 스레드 실행 중...
pool-1-thread-2 스레드 종료!
pool-1-thread-3 스레드 종료!
pool-1-thread-1 스레드 종료!

스레드풀 종료: shutdownNow()

shutdown()은 실행 중인 작업뿐만 아니라 대기 중인 작업이 모두 끝날 때까지 기다린다. 그러나 shutdownNow()는 현재 수행 중인 작업들을 모두 멈추도록 지시한다. 대기 중인 작업들은 취소하고, 취소한 작업 목록을 리턴해준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public static void main(String[] args) {
  ExecutorService executorService = Executors.newFixedThreadPool(3);

  executorService.execute(new MyRunnable(6000));
  executorService.execute(new MyRunnable(2000));
  executorService.execute(new MyRunnable(4000));
  executorService.execute(new MyRunnable(5000));
  executorService.execute(new MyRunnable(6000));
  executorService.execute(new MyRunnable(7000));

  // 현재 수행 중인 작업들을 모두 멈추도록 지시
  // => 대기 중인 작업들을 취소하고 그 목록을 리턴
  List<Runnable> tasks = executorService.shutdownNow();
  for (Runnable task : tasks) {
    System.out.println(((MyRunnable) task).millisec);
  }
  // 물론 새 작업 요청도 거절한다.
  // => 예외 발생!
  executorService.execute(new MyRunnable(4000));

  System.out.println("main() 종료!");
}

스레드가 잠자고 있는데 interrupt가 되었다. 따라서 run()의 try문 안에서 잠자고 있다가 예외가 발생하기 때문에 catch문이 실행된다.

스레드 안에 인터럽트 메서드가 있다. 강제로 스레드를 강제로 종료하는 메서드.

1
2
3
4
5
6
7
8
9
10
11
Exception in thread "main" java.util.concurrent.RejectedExecutionException: Task com.eomcs.concurrent.ex7.Exam0420$MyRunnable@5acf9800 rejected from java.util.concurrent.ThreadPoolExecutor@4617c264[Shutting down, pool size = 3, active threads = 3, queued tasks = 0, completed tasks = 0]
	at java.base/java.util.concurrent.ThreadPoolExecutor$AbortPolicy.rejectedExecution(ThreadPoolExecutor.java:2055)
	at java.base/java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:825)
	at java.base/java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1355)
	at com.eomcs.concurrent.ex7.Exam0420.main(Exam0420.java:37)
pool-1-thread-3 스레드 실행 중...
pool-1-thread-1 스레드 실행 중...
pool-1-thread-2 스레드 실행 중...
pool-1-thread-1 스레드 실행 중 오류 발생!
pool-1-thread-3 스레드 실행 중 오류 발생!
pool-1-thread-2 스레드 실행 중 오류 발생!

스레드풀 종료 대기: awaitTermination()

스레드풀의 모든 스레드가 종료되면 즉시 true를 리턴한다. 만약 지정된 시간(10초)이 경과될 때까지 종료되지 않았다면 더이상 기다리지 않고 false를 리턴한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static void main(String[] args) throws Exception {
  ExecutorService executorService = Executors.newFixedThreadPool(3);

  executorService.execute(new MyRunnable(6000));
  executorService.execute(new MyRunnable(2000));
  executorService.execute(new MyRunnable(4000));
  executorService.execute(new MyRunnable(13000));

  executorService.shutdown();

  if (!executorService.awaitTermination(10, TimeUnit.SECONDS)) {
    System.out.println("아직 종료 안된 작업이 있다.");
  } else {
    System.out.println("모든 작업을 종료하였다.");
  }

  System.out.println("main() 종료!");
}

스레드가 종료될 때까지 기다리게 하고 싶으면 이 메서드를 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public static void main(String[] args) throws Exception {
  ExecutorService executorService = Executors.newFixedThreadPool(3);
  
  executorService.execute(new MyRunnable(6000));
  executorService.execute(new MyRunnable(2000));
  executorService.execute(new MyRunnable(4000));
  executorService.execute(new MyRunnable(20000));
  
  executorService.shutdown();
  
 // 스레드풀의 모든 스레드가 종료될 때까지 기다린다.
  if (!executorService.awaitTermination(10, TimeUnit.SECONDS)) {
    System.out.println("아직 종료 안 된 작업이 있다.");
    System.out.println("남아 있는 작업의 강제 종료를 시도하겠다.");
    // 10초가 경과될 때까지 종료되지 않으면, 
    // 수행 중인 작업은 강제 종료하라고 지시하고,
    // 대기 중인 작업은 취소한다.
    executorService.shutdownNow();
    
    // 그리고 다시 작업이 종료될 때까지 기다린다.
    if (!executorService.awaitTermination(5, TimeUnit.SECONDS)) {
      System.out.println("스레드풀의 강제 종료를 완료하지 못했다.");
    } else {
      System.out.println("모든 작업을 강제 종료했다.");
    }
  }
}

실행 결과

1
2
3
4
5
6
7
8
9
10
11
12
pool-1-thread-2 스레드 실행 중...
pool-1-thread-3 스레드 실행 중...
pool-1-thread-1 스레드 실행 중...
pool-1-thread-2 스레드 종료!
pool-1-thread-2 스레드 실행 중...
pool-1-thread-3 스레드 종료!
pool-1-thread-1 스레드 종료!
아직 종료 안된 작업이 있다.
남아 있는 작업의 강제 종료를 시도하겠다.
pool-1-thread-2 스레드 실행 중 오류 발생!
모든 작업을 강제 종료했다.
main() 종료!
This post is licensed under CC BY 4.0 by the author.

모두의 네트워크 #2장: 네트워크 기본 규칙

Do it! 자료구조와 함께 배우는 알고리즘 #4장: 스택과 큐(+ 링 버퍼)

Loading comments from Disqus ...