06. 동시성, 빌드와 테스트
여러 스레드가 같은 데이터를 건드릴 때 생기는 문제와, 프로젝트를 빌드하고 검증하는 방법을 정리합니다.
스레드와 공유 자원
스레드는 같은 메모리를 공유합니다. 그래서 빠르게 주고받을 수 있고, 동시에 같은 값을 건드리면 깨집니다.
Thread thread = new Thread(() -> System.out.println("다른 스레드"));
thread.start(); // 새 스레드에서 실행
thread.join(); // 끝날 때까지 기다립니다run()을 직접 부르면 그냥 메서드 호출일 뿐 새 스레드가 생기지 않습니다.
겹쳐 쓰면 결과가 어긋납니다.
class Counter {
private int count = 0;
void increase() {
count++; // 읽고, 더하고, 쓰는 세 단계입니다
}
}두 스레드가 같은 시점에 읽으면 둘 다 같은 값을 보고 같은 값을 씁니다. 한 번의 증가가 사라집니다.
var counter = new Counter();
var pool = Executors.newFixedThreadPool(2);
for (int i = 0; i < 10_000; i++) {
pool.submit(counter::increase);
}
// 기대는 10000이지만 그보다 작게 나옵니다동기화와 스레드 안전
같은 시점에 하나만 들어가도록 막습니다.
class Counter {
private int count = 0;
synchronized void increase() {
count++;
}
}필요한 구간만 좁게 잠그는 편이 낫습니다. 메서드 전체를 잠그면 관계없는 작업까지 기다립니다.
private final Object lock = new Object();
void increase() {
prepare(); // 잠글 필요 없는 부분
synchronized (lock) {
count++;
}
}단순한 숫자라면 원자적 연산을 제공하는 클래스가 더 빠릅니다.
private final AtomicInteger count = new AtomicInteger();
count.incrementAndGet();volatile은 변경이 다른 스레드에 보이도록 보장할 뿐 원자성은 주지 않습니다. 플래그 같은 단순 값에만 씁니다.
private volatile boolean running = true; // 종료 신호용으로는 적절합니다컬렉션도 동시 접근에 안전한 것을 따로 씁니다.
Map<Long, Todo> shared = new ConcurrentHashMap<>();
List<Todo> list = new CopyOnWriteArrayList<>();가장 안전한 방법은 공유하지 않는 것입니다. 불변 객체를 쓰거나 각 스레드가 자기 데이터만 다루게 하면 동기화 자체가 필요 없습니다. record로 만든 값 객체가 여기에 맞습니다.
서로 다른 순서로 두 개의 잠금을 잡으면 교착 상태가 됩니다. 잠금 순서를 정해 두고 지키는 것이 예방책입니다.
Executor로 작업 분리하기
스레드를 직접 만들면 개수를 통제할 수 없습니다. 풀에 작업을 맡깁니다.
try (var pool = Executors.newFixedThreadPool(4)) {
Future<List<Todo>> future = pool.submit(() -> storage.load());
List<Todo> todos = future.get(); // 끝날 때까지 기다립니다
}ExecutorService는 AutoCloseable이라 try-with-resources로 닫을 수 있습니다(Java 19+). 그 이전에는 shutdown()을 직접 불러야 합니다.
여러 작업을 이어 붙일 때는 CompletableFuture가 편합니다.
CompletableFuture<List<Todo>> todos =
CompletableFuture.supplyAsync(() -> storage.load())
.thenApply(this::filterUndone)
.exceptionally(error -> List.of());
CompletableFuture.allOf(a, b).join(); // 둘 다 끝날 때까지Java 21부터는 가벼운 가상 스레드를 쓸 수 있습니다. 수만 개를 만들어도 부담이 적어, 기다리는 시간이 많은 작업에 적합합니다.
try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
for (var url : urls) {
pool.submit(() -> fetch(url));
}
}빌드 도구의 역할
소스를 컴파일하고, 의존성을 내려받고, 테스트를 돌리고, 배포할 파일을 만드는 일을 한 번에 합니다. Gradle과 Maven이 널리 쓰입니다.
./gradlew build # 컴파일 + 테스트 + 패키징
./gradlew test
./gradlew run래퍼(gradlew)를 저장소에 포함하면, 받는 사람이 Gradle을 따로 깔지 않아도 같은 버전으로 빌드됩니다.
의존성 관리
필요한 라이브러리를 선언하면 내려받아 연결해 줍니다.
// build.gradle.kts
plugins {
java
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
dependencies {
implementation("com.fasterxml.jackson.core:jackson-databind:2.18.0")
testImplementation("org.junit.jupiter:junit-jupiter:5.11.0")
}
tasks.test {
useJUnitPlatform()
}implementation은 실행에 필요한 것, testImplementation은 테스트에서만 쓰는 것입니다. 구분해 두면 배포물에 테스트 도구가 섞이지 않습니다.
toolchain으로 Java 버전을 고정하면 개발자마다 다른 JDK를 깔고 있어도 같은 결과가 나옵니다.
패키지 구성
패키지는 폴더 구조와 일치해야 합니다.
src/main/java/com/bitpage/todo/
├── Main.java
├── domain/
│ ├── Todo.java
│ └── TodoService.java
└── storage/
├── TodoStorage.java
└── JsonStorage.javapackage com.bitpage.todo.domain;
import com.bitpage.todo.storage.TodoStorage;기술 종류(controller, service, repository)로 나누는 방식과 기능 단위(todo, user)로 나누는 방식이 있습니다. 프로젝트가 커질수록 기능 단위가 찾기 쉽습니다. 하나의 기능을 고칠 때 여러 폴더를 오가지 않아도 되기 때문입니다.
단위 테스트
JUnit 5를 씁니다. 테스트는 src/test/java에 같은 패키지 구조로 둡니다.
package com.bitpage.todo.domain;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
class TodoServiceTest {
@Nested
@DisplayName("할 일을 추가할 때")
class Add {
@Test
@DisplayName("목록 끝에 추가된다")
void addsToEnd() {
var service = new TodoService(new MemoryStorage());
service.add("공부");
service.add("운동");
assertEquals(2, service.findAll().size());
assertEquals("운동", service.findAll().get(1).title());
}
@Test
@DisplayName("제목이 비면 예외를 던진다")
void rejectsEmptyTitle() {
var service = new TodoService(new MemoryStorage());
assertThrows(EmptyTitleException.class, () -> service.add(" "));
}
}
}입력만 다른 테스트는 묶어서 씁니다.
@ParameterizedTest
@ValueSource(strings = {"", " ", "\t"})
void rejectsBlankTitle(String title) {
assertThrows(EmptyTitleException.class, () -> service.add(title));
}외부에 의존하는 부분은 인터페이스로 분리해 두면 테스트에서 간단한 구현으로 바꿔 끼울 수 있습니다. 02. 클래스와 객체의 TodoStorage가 그 예입니다. 파일을 실제로 쓰는 대신 메모리 구현을 넘기면 테스트가 빠르고 서로 간섭하지 않습니다.
테스트할 가치가 있는 것은 규칙입니다. 빈 제목을 막는지, 정렬 기준이 맞는지, 경계값에서 어떻게 되는지 같은 것들입니다. getter와 setter까지 덮을 필요는 없습니다.