요구사항과 실행 가능한 프로젝트 골격
마지막 장은 새로운 기능을 나열하는 장이 아니라 앞에서 배운 계약을 하나의 Study Log API에 다시 쌓는 실습입니다. 각 절의 파일은 다음 절에서 교체하거나 확장하므로, 별도 프로젝트를 매번 만들지 않고 같은 디렉터리에서 누적합니다.
첫 단계에서는 빌드 가능한 Boot 4.1 프로젝트와 설정 경계를 만듭니다. 아직 DB schema와 Controller가 없으므로 이 절에서 health 응답이나 업무 API 성공까지 주장하지 않습니다.
완료 조건을 코드보다 먼저 정의한다
완료 조건은 세 층으로 적습니다. 외부 계약에는 URI·JSON·상태 코드가, 품질 계약에는 입력 검증·JWT scope·동시성 처리가, 운영 계약에는 health·migration·image rollback이 들어갑니다. 구현 파일이 어느 조건을 책임지는지 연결해야 빠진 요구를 찾을 수 있습니다.
이 절의 설정은 local과 운영을 의도적으로 나눕니다. local profile은 학습용 HMAC secret으로 직접 만든 토큰을 검증하고, 기본 profile은 외부 issuer와 JWK를 사용합니다. local 편의를 운영 인증 우회로 가져가지 않는 것이 경계입니다.
pom·설정·패키지 골격을 완성한다
pom.xml에는 이후 세 절에서 실제로 사용할 Web MVC, Validation, JPA, Flyway, Resource Server, Actuator와 Testcontainers 의존성을 한 번에 선언합니다. 애플리케이션 클래스에는 시간 의존 코드를 고정할 수 있도록 UTC Clock bean만 둡니다.
application.yaml의 PostgreSQL과 issuer 주소는 기본 실행 계약이고, application-local.yaml은 secret 값 자체를 저장하지 않고 환경 변수 이름만 받습니다. 아래 LocalToken은 브라우저나 외부 인증 서버 없이 scope 시나리오를 재현하기 위한 개발 도구이며 운영 artifact에 포함되는 src/main 코드가 아닙니다.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.0</version>
<relativePath/>
</parent>
<groupId>com.andongmin</groupId>
<artifactId>study-log-api</artifactId>
<version>1.0.0</version>
<properties>
<java.version>25</java.version>
<project.build.outputTimestamp>2026-07-13T00:00:00Z</project.build.outputTimestamp>
</properties>
<dependencies>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webmvc</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-jpa</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-flyway</artifactId></dependency>
<dependency><groupId>org.flywaydb</groupId><artifactId>flyway-database-postgresql</artifactId></dependency>
<dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><scope>runtime</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webmvc-test</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-jpa-test</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security-test</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-testcontainers</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.testcontainers</groupId><artifactId>testcontainers-postgresql</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.testcontainers</groupId><artifactId>testcontainers-junit-jupiter</artifactId><scope>test</scope></dependency>
</dependencies>
<build>
<plugins>
<plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin>
</plugins>
</build>
</project>package com.andongmin.studylog;
import java.time.Clock;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
@SpringBootApplication
public class StudyLogApplication {
public static void main(String[] args) {
SpringApplication.run(StudyLogApplication.class, args);
}
@Bean
Clock applicationClock() {
return Clock.systemUTC();
}
}spring:
application:
name: study-log-api
datasource:
url: jdbc:postgresql://localhost:5432/studylog
username: studylog
password: studylog
jpa:
hibernate:
ddl-auto: validate
open-in-view: false
flyway:
validate-migration-naming: true
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://id.example.test/realms/study-log
jwk-set-uri: https://id.example.test/realms/study-log/protocol/openid-connect/certs
audiences: study-log-api
management:
endpoints:
web:
exposure:
include: health,info,metrics
endpoint:
health:
probes:
enabled: true
add-additional-paths: true
show-details: neverstudy-log:
local-jwt-secret: ${LOCAL_JWT_SECRET}package com.andongmin.studylog.security;
import java.nio.charset.StandardCharsets;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtAudienceValidator;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
@Configuration(proxyBeanMethods = false)
@Profile("local")
public class LocalJwtConfiguration {
private static final String ISSUER = "http://localhost:8080";
@Bean
JwtDecoder localJwtDecoder(
@Value("${study-log.local-jwt-secret}") String secret) {
byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
if (keyBytes.length < 32) {
throw new IllegalArgumentException(
"LOCAL_JWT_SECRET must contain at least 32 UTF-8 bytes");
}
var key = new SecretKeySpec(keyBytes, "HmacSHA256");
NimbusJwtDecoder decoder = NimbusJwtDecoder.withSecretKey(key)
.macAlgorithm(MacAlgorithm.HS256)
.build();
var validator = new DelegatingOAuth2TokenValidator<Jwt>(
JwtValidators.createDefaultWithIssuer(ISSUER),
new JwtAudienceValidator("study-log-api"));
decoder.setJwtValidator(validator);
return decoder;
}
}import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public class LocalToken {
public static void main(String[] args) throws Exception {
if (args.length < 1 || args[0].getBytes(StandardCharsets.UTF_8).length < 32) {
throw new IllegalArgumentException("pass a secret of at least 32 UTF-8 bytes");
}
String scope = args.length == 2 ? args[1] : "study.read study.write";
if (!scope.matches("[a-z. ]+")) {
throw new IllegalArgumentException("scope may contain lowercase letters, dots, and spaces");
}
long issuedAt = Instant.now().getEpochSecond();
String header = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}";
String payload = "{\"iss\":\"http://localhost:8080\"," +
"\"aud\":[\"study-log-api\"]," +
"\"sub\":\"local-learner\",\"scope\":\"" + scope + "\"," +
"\"iat\":" + issuedAt + ",\"exp\":" + (issuedAt + 3600) + "}";
String content = encode(header.getBytes(StandardCharsets.UTF_8)) + "." +
encode(payload.getBytes(StandardCharsets.UTF_8));
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(args[0].getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
System.out.println(content + "." + encode(mac.doFinal(
content.getBytes(StandardCharsets.US_ASCII))));
}
private static String encode(byte[] value) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(value);
}
}LocalJwtConfiguration은 local profile에서만 JwtDecoder를 제공하므로 Resource Server 자동 설정의 외부 decoder를 대신합니다. issuer와 audience validator도 유지하기 때문에 서명만 맞고 대상 서비스가 다른 토큰은 허용하지 않습니다.
Wrapper·컴파일·local 토큰을 먼저 확인한다
빈 디렉터리에는 아직 mvnw가 없으므로 Wrapper를 만드는 최초 한 번은 시스템 Maven이 필요합니다. 시스템 Maven이 없다면 임의 명령이 성공한다고 가정하지 말고 Maven 설치 또는 Spring Initializr로 Wrapper를 먼저 준비합니다.
command -v mvn >/dev/null
mvn -N wrapper:wrapper -Dmaven=3.9.16
./mvnw --version
./mvnw -q clean verify
export LOCAL_JWT_SECRET=study-log-local-development-key-2026
WRITE_TOKEN="$(java dev/LocalToken.java "$LOCAL_JWT_SECRET")"
test "$(awk -F. '{print NF}' <<< "$WRITE_TOKEN")" -eq 3출력에서 Java 25와 Maven 3.9.16을 확인하고 빌드가 성공해야 합니다. 생성한 local JWT는 점으로 나뉜 세 구간이어야 하지만, 이 검사는 토큰 구조만 확인할 뿐 HTTP 인증 성공을 증명하지 않습니다. 실제 서명·scope 검증은 ch13-3의 local 실행에서 확인합니다.요구사항을 파일과 검증 명령에 연결한다
각 요구사항은 구현 파일, 자동 검증, 운영 신호 중 적어도 하나와 연결되어야 누락을 판정할 수 있습니다.
- 데이터 규칙은 Entity와 migration이 함께 책임집니다.
- 외부 성공·실패 계약은 Controller, Security, Advice, smoke script가 연결합니다.
- 실행과 복구 계약은 Actuator, Compose, release rehearsal이 남깁니다.
아직 대응 파일이 없는 요구는 완료로 표시하지 않고 다음 절의 작업으로 남깁니다.
첫 실패는 설정 층을 섞지 않고 고친다
컴파일 실패는 의존성이나 Java 버전부터, local token 실패는 secret 길이와 source launcher부터 확인합니다. 외부 issuer가 응답하지 않는 문제를 local secret으로 우회하거나 반대로 운영 설정을 개발 편의에 맞춰 약화하지 않습니다.
골격이 빌드되면 PostgreSQL schema와 Entity를 동시에 추가해 Java 불변식과 DB 제약이 같은 규칙을 말하게 만듭니다.