Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
### gateway:auth — JWT ###
# HS256 서명 키. 최소 256비트(32바이트) 이상이어야 한다
JWT_SECRET_KEY=replace-with-32-byte-or-longer-secret-key
JWT_ISSUER=stream-server
# Access Token 만료 시간 (ms)
JWT_ACCESS_TOKEN_EXPIRY=3600000
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,7 @@ out/

### VS Code ###
.vscode/

.DS_Store

docs/plans
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;

@SpringBootApplication
@ConfigurationPropertiesScan
public class StreamServerApplication {

public static void main(String[] args) {
Expand Down
3 changes: 3 additions & 0 deletions bootstrap/src/main/resources/application.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
spring:
application:
name: stream-server
config:
import:
- classpath:application-gateway-auth.yml
6 changes: 6 additions & 0 deletions bootstrap/src/test/resources/application-gateway-auth.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# 테스트 클래스패스가 gateway:auth의 동명 파일을 가린다.
# 실제 값은 환경변수로 주입되므로 테스트에서는 더미 값을 쓴다.
jwt:
secret-key: test-secret-key-must-be-at-least-32-bytes-long
issuer: stream-server
access-token-expiry: 3600000
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package kr.ac.kookmin.stream.common;

/**
* 학생회 부서. ADMIN에게만 부여되며, member 도메인의 학부(Department)와는 다른 개념이다.
*/
public enum CouncilDepartment {
PRESIDENCY, // 회장단
EXECUTIVE, // 집행부
GENERAL_AFFAIRS, // 총무부
PLANNING, // 기획부
PR, // 홍보부
MEDIA, // 미디어부
WELFARE, // 복지부
COMMUNICATION // 소통부
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package kr.ac.kookmin.stream.common;

import java.util.Set;

public interface PrincipalProvider {
Long userId();
Set<Role> roles();
Set<CouncilDepartment> councilDepartments();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package kr.ac.kookmin.stream.common;

public enum Role {
STUDENT,
ADMIN
}
3 changes: 2 additions & 1 deletion docs/conventions/00-index.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Java 21 + Spring Boot 4.1 + Spring Modulith 기반, 단일 학생회 플랫폼
| 문서 | 다루는 내용 | 언제 참조하는가 |
| --- | --- | --- |
| [`architecture.md`](./architecture.md) | 모듈 구조, 의존 방향, Modulith 경계 규칙, 레이어, 도메인 간 통신(UseCase·이벤트·아웃박스) | 새 모듈/도메인 설계, 의존성 리뷰 |
| [`coding-style.md`](./coding-style.md) | 네이밍, 도메인 객체(record)/DTO/Command/Entity/Repository/Service/UseCase 패턴, Validation | 실제 코드 작성/리뷰 |
| [`coding-style.md`](./coding-style.md) | 네이밍, 도메인 객체(record)/DTO/Command/Entity/Repository/Service/UseCase 패턴, 정적 팩토리·Lombok, Validation | 실제 코드 작성/리뷰 |
| [`error-handling.md`](./error-handling.md) | `ErrorCode`/`BusinessException`, `GlobalExceptionHandler`, `@ApiErrorCode` Swagger 문서화 | 에러 코드 추가, 예외 처리 |
| [`config-and-auth.md`](./config-and-auth.md) | 설정 바인딩, 2계층 권한 모델(role + 부서), `PrincipalProvider`, `DepartmentAccessChecker` | 설정값 추가, 인증·인가 작업 |
| [`logging.md`](./logging.md) | MDC 요청 추적, `MdcFilter`/`LoggingFilter`, 로그 레벨, JSON 로깅 | 로깅 코드, MDC 필드 추가 |
Expand All @@ -28,6 +28,7 @@ Java 21 + Spring Boot 4.1 + Spring Modulith 기반, 단일 학생회 플랫폼
- "A 도메인 변화에 B가 반응" → `architecture.md` 6-2절 (이벤트 + 아웃박스)
- "부서 권한으로 승인 제한" → `config-and-auth.md` 4-4절 (`DepartmentAccessChecker`)
- "soft delete 컬럼 인덱스/유니크" → `flyway-migration.md` 3-4절
- "객체를 어떻게 생성하지 / Lombok 어디까지" → `coding-style.md` 2-10·2-11절
- "에러 코드 추가" → `error-handling.md`
- "도메인 내부 구현 숨기기" → `architecture.md` 4-3절 (최상위 공개 / `internal`)

Expand Down
118 changes: 86 additions & 32 deletions docs/conventions/coding-style.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ public record PageResult<T>(List<T> content, int page, int size, long totalCount

### 2-5. JPA Entity

- `infrastructure:db`에 선언한다. 도메인 객체를 받는 생성자와 `toDomain()`을 제공하고, 용도에 맞는 Base Entity를 상속한다. JPA용 `protected` 기본 생성자를 둔다.
- `infrastructure:db`에 선언한다. 도메인 객체를 받는 정적 팩토리 `from(...)`과 `toDomain()`을 제공하고, 용도에 맞는 Base Entity를 상속한다. JPA용 기본 생성자는 `@NoArgsConstructor(access = AccessLevel.PROTECTED)`로 둔다(2-10·2-11절).

**Base Entity 선택 기준** (`infrastructure:db` 공통 패키지)

Expand All @@ -143,6 +143,7 @@ public record PageResult<T>(List<T> content, int page, int size, long totalCount
// infrastructure:db
@Entity
@Table(name = "members")
@NoArgsConstructor(access = AccessLevel.PROTECTED) // JPA
public class MemberJpaEntity extends BaseSoftDeleteEntity {

@Id
Expand All @@ -152,14 +153,16 @@ public class MemberJpaEntity extends BaseSoftDeleteEntity {
private String studentNo;
private String name;

protected MemberJpaEntity() {} // JPA

public MemberJpaEntity(Member member) {
private MemberJpaEntity(Member member) {
this.id = member.id();
this.studentNo = member.studentNo();
this.name = member.name();
}

public static MemberJpaEntity from(Member member) {
return new MemberJpaEntity(member);
}

public Member toDomain() {
return new Member(id, studentNo, name);
}
Expand Down Expand Up @@ -198,14 +201,11 @@ public interface MemberJpaRepository extends JpaRepository<MemberJpaEntity, Long
}

@Repository
@RequiredArgsConstructor
public class MemberRepositoryImpl implements MemberRepository {

private final MemberJpaRepository memberJpaRepository;

public MemberRepositoryImpl(MemberJpaRepository memberJpaRepository) {
this.memberJpaRepository = memberJpaRepository;
}

@Override
public Optional<Member> findById(Long id) {
return memberJpaRepository.findById(id).map(MemberJpaEntity::toDomain);
Expand All @@ -218,7 +218,7 @@ public class MemberRepositoryImpl implements MemberRepository {

@Override
public Member save(Member member) {
return memberJpaRepository.save(new MemberJpaEntity(member)).toDomain();
return memberJpaRepository.save(MemberJpaEntity.from(member)).toDomain();
}
}
```
Expand All @@ -241,14 +241,11 @@ public interface MemberService {
```java
// core:domain:member/internal (감춰짐)
@Service
@RequiredArgsConstructor
class MemberServiceImpl implements MemberService {

private final MemberRepository memberRepository;

MemberServiceImpl(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}

@Override
@Transactional
public Member register(MemberRegisterCommand command) {
Expand Down Expand Up @@ -278,14 +275,11 @@ class MemberServiceImpl implements MemberService {
// api:admin-api — 운영진 회원 등록
@RestController
@RequestMapping("/v1/admin/members")
@RequiredArgsConstructor
public class AdminMemberController {

private final MemberService memberService;

public AdminMemberController(MemberService memberService) {
this.memberService = memberService;
}

@PostMapping
public ApiResponse<MemberResponse> register(@Valid @RequestBody MemberRegisterRequest request) {
Member member = memberService.register(request.toCommand());
Expand All @@ -298,14 +292,11 @@ public class AdminMemberController {
// api:app-api — 학생 내 정보 조회
@RestController
@RequestMapping("/v1/app/members")
@RequiredArgsConstructor
public class AppMemberController {

private final MemberService memberService;

public AppMemberController(MemberService memberService) {
this.memberService = memberService;
}

@GetMapping("/me")
public ApiResponse<MemberResponse> me(StudentApiUser apiUser) {
Member member = memberService.getById(apiUser.userId());
Expand All @@ -322,18 +313,13 @@ public class AppMemberController {
```java
// api:admin-api — 조회 조합 (운영진 대시보드)
@Component
@RequiredArgsConstructor
public class AdminDashboardUseCase {

private final MemberService memberService;
private final EventService eventService;
private final NoticeService noticeService;

public AdminDashboardUseCase(MemberService memberService, EventService eventService, NoticeService noticeService) {
this.memberService = memberService;
this.eventService = eventService;
this.noticeService = noticeService;
}

@Transactional(readOnly = true)
public AdminDashboardResponse getDashboard() {
long memberCount = memberService.countActive();
Expand All @@ -347,16 +333,12 @@ public class AdminDashboardUseCase {
```java
// api:app-api — 원자적 쓰기 오케스트레이션 (유료 행사 신청)
@Component
@RequiredArgsConstructor
public class EventApplicationUseCase {

private final EventService eventService;
private final FeeService feeService;

public EventApplicationUseCase(EventService eventService, FeeService feeService) {
this.eventService = eventService;
this.feeService = feeService;
}

@Transactional // 정원 차감 + 회비 반영을 원자적으로
public EventApplicationResponse apply(Long memberId, EventApplyCommand command) {
Event event = eventService.apply(memberId, command); // 정원 차감·신청 등록
Expand All @@ -366,6 +348,78 @@ public class EventApplicationUseCase {
}
```

### 2-10. 객체 생성 — 정적 팩토리 메서드

객체는 `new`로 직접 만들지 않고 정적 팩토리 메서드로 생성한다. 생성자는 `private`(JPA처럼 프레임워크가 요구하면 `protected`)으로 감춘다.

| 이름 | 쓰임 | 예 |
| --- | --- | --- |
| `from` | 다른 타입 하나를 받아 변환 | `MemberResponse.from(member)`, `MemberJpaEntity.from(member)` |
| `of` | 값 여러 개를 받아 조합 | `JwtAuthFilter.of(jwtProvider, handlerExceptionResolver)` |
| `create` | 새로 만든다는 의미를 드러낼 때 | `Member.create(studentNo, name)` |

```java
// gateway:auth
@Getter
@Accessors(fluent = true)
public class UserAuthentication extends AbstractAuthenticationToken {

private final Long userId;

private UserAuthentication(JwtPayload payload) {
super(toAuthorities(payload));
this.userId = payload.userId();
setAuthenticated(true);
}

public static UserAuthentication from(JwtPayload payload) {
return new UserAuthentication(payload);
}
}
```

- 이름이 생성 의도를 드러내므로, 인자 목록만으로는 구분되지 않는 여러 생성 경로를 표현할 수 있다.
- 생성자를 감추면 호출부가 `new`로 우회할 수 없어 생성 경로가 하나로 모인다.
- **예외** — 아래는 `new`를 그대로 쓴다.
- `record`(도메인 객체·Command·Request/Response): 표준 생성자를 쓴다. 단 타입 변환이 끼면 `from(...)`/`toCommand()`를 둔다(2-2·2-3절).
- 예외 클래스: `throw new BusinessException(...)`.
- 스프링이 생성·주입하는 빈: 애초에 직접 생성하지 않는다(2-11절).

### 2-11. Lombok

루트 `build.gradle.kts`에서 전 모듈에 적용된다. 모듈별 `build.gradle.kts`에 다시 선언하지 않는다. 동작 설정은 루트 `lombok.config`에 둔다.

| 어노테이션 | 용도 |
| --- | --- |
| `@Getter` | 필드 접근자. 클래스 단위로 붙인다 |
| `@Accessors(fluent = true)` | `getXxx()` 대신 `xxx()` 접근자. `record`와 표기를 맞출 때 (`CommonErrorCode`, `UserAuthentication`) |
| `@RequiredArgsConstructor` | 스프링 빈(`@Service`/`@Repository`/`@Component`/`@RestController`)의 생성자 주입 |
| `@RequiredArgsConstructor(access = AccessLevel.PRIVATE)` | 정적 팩토리(2-10절)와 짝지어 생성자를 감출 때 |
| `@NoArgsConstructor(access = AccessLevel.PROTECTED)` | JPA Entity의 기본 생성자 |
| `@AllArgsConstructor` | 필드를 갖는 enum (`{Domain}ErrorCode`) |

- `@Data`·`@Setter`는 쓰지 않는다. 객체는 불변을 기본으로 하고, 상태 변경은 의도가 드러나는 메서드(`entity.delete()` 등)로 표현한다.
- `record`에는 Lombok을 붙이지 않는다. 접근자·`equals`/`hashCode`가 이미 제공된다.
- 스프링 빈의 생성자 주입은 **항상 `@RequiredArgsConstructor`**로 한다. 생성자를 직접 쓰는 경우는 하나뿐이다 — 주입받은 값으로 다른 필드를 초기화해야 할 때. 예: `JwtProperties`로 `SecretKey`를 만드는 `JwtProvider`.
- 주입할 빈을 지목해야 하면 **필드에** `@Qualifier`를 붙인다. 루트 `lombok.config`의 `lombok.copyableAnnotations`가 이를 생성자 파라미터로 복사한다.

```java
// gateway:auth — HandlerExceptionResolver 빈이 여럿이라 이름으로 지목
@Component
@RequiredArgsConstructor
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {

@Qualifier("handlerExceptionResolver")
private final HandlerExceptionResolver handlerExceptionResolver;
}
```

```properties
# lombok.config (루트)
config.stopBubbling = true
lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier
```

---

## 3. Validation
Expand Down
11 changes: 2 additions & 9 deletions docs/conventions/config-and-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,14 +133,11 @@ Controller / UseCase — 부서 단위 인가는 DepartmentAccessChec
```java
// gateway:auth — DepartmentAccessChecker.java
@Component
@RequiredArgsConstructor
public class DepartmentAccessChecker {

private final PrincipalProvider principalProvider;

public DepartmentAccessChecker(PrincipalProvider principalProvider) {
this.principalProvider = principalProvider;
}

// 요구 부서 권한이 없으면 403
public void requireDepartment(Department required) {
if (!principalProvider.departments().contains(required)) {
Expand All @@ -153,16 +150,12 @@ public class DepartmentAccessChecker {
```java
// api:admin-api — 사물함 승인은 복지부만
@Component
@RequiredArgsConstructor
public class LockerApprovalUseCase {

private final DepartmentAccessChecker departmentAccessChecker;
private final LockerService lockerService;

public LockerApprovalUseCase(DepartmentAccessChecker departmentAccessChecker, LockerService lockerService) {
this.departmentAccessChecker = departmentAccessChecker;
this.lockerService = lockerService;
}

public void approve(Long userId, Long applicationId) {
departmentAccessChecker.requireDepartment(Department.WELFARE);
lockerService.approve(applicationId);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package kr.ac.kookmin.stream.security;

import java.util.Set;
import kr.ac.kookmin.stream.common.BusinessException;
import kr.ac.kookmin.stream.common.CommonErrorCode;
import kr.ac.kookmin.stream.common.CouncilDepartment;
import kr.ac.kookmin.stream.common.PrincipalProvider;
import kr.ac.kookmin.stream.common.Role;
import kr.ac.kookmin.stream.security.jwt.UserAuthentication;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;

@Component
public class SecurityPrincipalProvider implements PrincipalProvider {

@Override
public Long userId() {
return currentAuthentication().userId();
}

@Override
public Set<Role> roles() {
return currentAuthentication().roles();
}

@Override
public Set<CouncilDepartment> councilDepartments() {
return currentAuthentication().councilDepartments();
}

private UserAuthentication currentAuthentication() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (!(authentication instanceof UserAuthentication userAuthentication)) {
throw new BusinessException(CommonErrorCode.UNAUTHORIZED);
}
return userAuthentication;
}
}
Loading