Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
cafeacf
feat: 클래스 정의
changuii Feb 25, 2026
faca7bb
test: AssertSoftly의 람다 변수 네이밍은 s로 작성한다.
changuii Feb 25, 2026
564e265
feat: 새로운 권한 검증 코드 추가
changuii Feb 25, 2026
24e4516
feat: 각 Strategy 구현
changuii Feb 25, 2026
fe2dd87
refactor AuthorizationContext 빌더 패턴 대신, 정적 팩터리 메서드로 변경
changuii Feb 25, 2026
6e3fadf
feat: Notify 동작 추가
changuii Feb 25, 2026
8a62f24
refactor: NewAuthorizationService로 권한 검증 전환
changuii Feb 25, 2026
3c7cce2
test: Stub 매개변수를 고정
changuii Feb 25, 2026
81f1915
refactor: Event, EventDate 도메인의 권한 검증을 새로운 방식으로 변경
changuii Feb 25, 2026
9eaa793
refactor: FestivalImage 도메인의 권한 검증을 새로운 방식으로 변경
changuii Feb 25, 2026
a4b7107
refactor: Festival 도메인의 권한 검증을 새로운 방식으로 변경
changuii Feb 25, 2026
96f5668
refactor: lineup 도메인의 권한 검증을 새로운 방식으로 변경
changuii Feb 25, 2026
7d4bfc1
refactor: lostitem 도메인의 권한 검증을 새로운 방식으로 변경
changuii Feb 25, 2026
5db2e70
refactor: organization 도메인의 권한 검증을 새로운 방식으로 변경
changuii Feb 25, 2026
719d9a7
refactor: organizer 도메인의 권한 검증을 새로운 방식으로 변경
changuii Feb 25, 2026
afc7727
refactor: Place 도메인들의 권한 검증을 새로운 방식으로 변경
changuii Feb 25, 2026
9d93f4a
refactor: Question, Staff, TimeTag 도메인들의 권한 검증을 새로운 방식으로 변경
changuii Feb 25, 2026
6000b9e
refactor: newAuthorizationService -> AuthorizationService로 변경
changuii Feb 25, 2026
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
40 changes: 39 additions & 1 deletion code-style.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> **목적**: AI Agent 및 AI 코드 리뷰 자동화를 위한 명확한 규칙 정의
> **대상 언어/프레임워크**: Java 17+, Spring Boot, JPA, Lombok, JUnit 5, RestAssured, Swagger (springdoc-openapi), Flyway
> **최종 수정일**: 2025-02-24
> **최종 수정일**: 2026-02-25

---

Expand Down Expand Up @@ -311,6 +311,22 @@ String name = place.getTitle();
| `null`이 절대 들어오지 않음이 확실한 경우 | 원시 타입 (`long`, `int`, `boolean` 등) | 성능 이점 및 의도 명시 |
| Entity의 `id` 필드 | **항상 래퍼 타입** (`Long`) | JPA 영속화 전 `null` 상태 필요 |

### 3.3 null 검증

- null 여부를 검사할 때는 `== null` / `!= null` 대신 반드시 `Objects.isNull()` / `Objects.nonNull()`을 사용한다.

```java
// ❌ 직접 비교
if (scopeId == null) {
throw new InternalServerException();
}

// ✅ Objects 유틸 사용
if (Objects.isNull(scopeId)) {
throw new InternalServerException();
}
```

---

## 4. Bean 주입
Expand Down Expand Up @@ -1064,6 +1080,20 @@ when(placeJpaRepository.findById(anyLong()))

- 검증할 데이터가 **1개**이면 `assertThat`만 사용한다.
- 검증할 데이터가 **여러 개**이면 `assertSoftly`를 사용한다.
- `assertSoftly` 내부 람다의 파라미터 변수명은 **반드시 `s`**로 작성한다. (`softly` 등 다른 이름 사용 금지)

```java
// ✅ 람다 파라미터 s 사용
assertSoftly(s -> {
s.assertThat(result.title()).isEqualTo("제목");
s.assertThat(result.content()).isEqualTo("내용");
});

// ❌ softly 사용 금지
assertSoftly(softly -> {
softly.assertThat(result.title()).isEqualTo("제목");
});
```

### 12.9 필드 순서

Expand Down Expand Up @@ -1162,11 +1192,16 @@ then(announcementJpaRepository).should(never())

### 12.18 테스트 코드 개행 규칙

- BDDMockito `given()` 호출에서 `.willReturn()`, `.willThrow()` 등의 체이닝은 **반드시 개행**한다. 한 줄로 작성하지 않는다.

```java
// ✅ BDDMockito given() 개행
given(placeJpaRepository.findById(anyLong()))
.willReturn(Optional.of(place));

// ❌ 한 줄로 작성 금지
given(placeJpaRepository.findById(anyLong())).willReturn(Optional.of(place));

// ✅ assertThat 개행 — 체이닝 여러 개
assertThat(result)
.hasSize(3)
Expand Down Expand Up @@ -1324,6 +1359,7 @@ public class AnnouncementService { }
- [ ] 클래스 필드 외에 `final` 키워드가 사용되었는가?
- [ ] Entity의 `id` 필드가 원시 타입(`long`)으로 선언되었는가?
- [ ] 연관관계가 FK ID(`Long festivalId`)로만 매핑되어 있는가?
- [ ] null 검증에 `== null` / `!= null` 직접 비교를 사용하고 있는가? (`Objects.isNull()` / `Objects.nonNull()` 사용해야 함)

### 검증

Expand Down Expand Up @@ -1378,7 +1414,9 @@ public class AnnouncementService { }
- [ ] 객체 생성과 저장이 분리되지 않았는가?
- [ ] RestAssured에서 `.given()` 전 개행이 누락되었는가?
- [ ] `Mockito.when()` 대신 `BDDMockito.given()`을 사용하고 있는가?
- [ ] `given().willReturn()` 등 체이닝이 한 줄로 작성되어 있는가? (반드시 개행해야 함)
- [ ] 검증이 여러 개인데 `assertSoftly`를 사용하지 않았는가?
- [ ] `assertSoftly` 람다 파라미터명이 `s`가 아닌가? (❌ `softly`, ✅ `s`)
- [ ] 도메인 테스트에서 Fixture를 사용하고 있는가? (직접 `new`로 생성해야 함)
- [ ] Controller 테스트에서 필드 사이즈 검증이 누락되었는가?
- [ ] when 절의 결과 변수명이 `result`가 아닌가?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import com.daedan.festabook.announcement.dto.AnnouncementUpdateRequest;
import com.daedan.festabook.announcement.dto.AnnouncementUpdateResponse;
import com.daedan.festabook.announcement.infrastructure.AnnouncementJpaRepository;
import com.daedan.festabook.authorization.AccessAction;
import com.daedan.festabook.authorization.AuthorizationContext;
import com.daedan.festabook.authorization.service.AuthorizationService;
import com.daedan.festabook.festival.domain.Festival;
import com.daedan.festabook.festival.domain.FestivalNotificationManager;
Expand All @@ -32,17 +34,22 @@ public class AnnouncementService {
private static final String ANNOUNCEMENT_TITLE_WITH_UNIVERSITY_NAME_FORMAT = "[%s] %s";

private final AnnouncementJpaRepository announcementJpaRepository;
private final AuthorizationService authorizationService;
private final FestivalJpaRepository festivalJpaRepository;
private final FestivalNotificationManager notificationManager;
private final AuthorizationService authorizationService;

@Lockable(
spelKey = "'AnnouncementService'.concat(#festivalId)",
useMethodScopeLock = false
)
@Transactional
public AnnouncementResponse createAnnouncement(Long festivalId, AnnouncementRequest request) {
authorizationService.validateFestivalCommandAccess(festivalId);
authorizationService.authorize(
Announcement.class,
AccessAction.CREATE,
AuthorizationContext.ofScope(festivalId)
);

Festival festival = getFestivalById(festivalId);

Announcement announcement = request.toEntity(festival);
Expand Down Expand Up @@ -71,7 +78,11 @@ public AnnouncementUpdateResponse updateAnnouncement(
Long announcementId,
AnnouncementUpdateRequest request
) {
authorizationService.validateFestivalCommandAccess(Announcement.class, announcementId);
authorizationService.authorize(
Announcement.class,
AccessAction.UPDATE,
AuthorizationContext.ofAssociated(announcementId, Announcement.class)
);
Announcement announcement = getAnnouncementById(announcementId);
announcement.updateTitleAndContent(request.title(), request.content());
return AnnouncementUpdateResponse.from(announcement);
Expand All @@ -87,7 +98,11 @@ public AnnouncementPinUpdateResponse updateAnnouncementPin(
Long announcementId,
AnnouncementPinUpdateRequest request
) {
authorizationService.validateFestivalCommandAccess(Announcement.class, announcementId);
authorizationService.authorize(
Announcement.class,
AccessAction.UPDATE,
AuthorizationContext.ofAssociated(announcementId, Announcement.class)
);
Announcement announcement = getAnnouncementById(announcementId);
if (announcement.isUnpinned() && request.pinned()) {
validatePinnedLimit(festivalId);
Expand All @@ -99,13 +114,21 @@ public AnnouncementPinUpdateResponse updateAnnouncementPin(

@Transactional
public void deleteAnnouncementByAnnouncementId(Long announcementId) {
authorizationService.validateFestivalCommandAccess(Announcement.class, announcementId);
authorizationService.authorize(
Announcement.class,
AccessAction.DELETE,
AuthorizationContext.ofAssociated(announcementId, Announcement.class)
);
Announcement announcement = getAnnouncementById(announcementId);
announcementJpaRepository.delete(announcement);
}

public void sendAnnouncementNotification(Long festivalId, Long announcementId) {
authorizationService.validateFestivalCommandAccess(Announcement.class, announcementId);
authorizationService.authorize(
Announcement.class,
AccessAction.NOTIFY,
AuthorizationContext.ofAssociated(announcementId, Announcement.class)
);
Announcement announcement = getAnnouncementById(announcementId);
Festival festival = getFestivalByIdWithOrganization(festivalId);

Expand Down
10 changes: 10 additions & 0 deletions src/main/java/com/daedan/festabook/authorization/AccessAction.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.daedan.festabook.authorization;

public enum AccessAction {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저는 READ WRITE로 이전처럼 두개만 있는게 좋아보여요

CREATE,
READ,
UPDATE,
DELETE,
MANAGE,
NOTIFY
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ROLETYPE을 그대로 쓰면 안 됐나요? 변경 지점이 두 군데인 것 같아요..

Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.daedan.festabook.authorization;

public enum AccessSubject {
ORGANIZER,
STAFF,
PLACE_ACCESS
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.daedan.festabook.authorization;

import java.util.List;

@SuppressWarnings("rawtypes")
public class AuthorizationContext {

private final Long scopeId;
private final List<Long> associatedIds;
private final Class resolverType;
private final Long principalId;
Comment on lines +6 to +11
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

제가 볼때는 이건 변수명으로 유추가 불가능할 것 같거든요? 새로 들어온 개발자가 있다면?

이 부분은 주석을 활용해도 될 것 같은데 어떠세요?

그리고 각 Id 설명도 부탁드려요..


private AuthorizationContext(Long scopeId, List<Long> associatedIds, Class<?> resolverType, Long principalId) {
this.scopeId = scopeId;
this.associatedIds = associatedIds;
this.resolverType = resolverType;
this.principalId = principalId;
}

public static AuthorizationContext ofScope(Long scopeId) {
return new AuthorizationContext(scopeId, null, null, null);
}

public static AuthorizationContext ofAssociated(List<Long> associatedIds, Class<?> resolverType) {
return new AuthorizationContext(null, associatedIds, resolverType, null);
}

public static AuthorizationContext ofAssociated(Long associatedId, Class<?> resolverType) {
return new AuthorizationContext(null, List.of(associatedId), resolverType, null);
}

public static AuthorizationContext ofScopeAndAssociated(Long scopeId, Long associatedId, Class<?> resolverType) {
return new AuthorizationContext(scopeId, List.of(associatedId), resolverType, null);
}

public static AuthorizationContext ofScopes(List<Long> scopeIds) {
return new AuthorizationContext(null, scopeIds, null, null);
}

public static AuthorizationContext ofPrincipal(Long principalId) {
return new AuthorizationContext(null, null, null, principalId);
}

public Long getScopeId() {
return scopeId;
}

public List<Long> getAssociatedIds() {
return associatedIds;
}

public Class getResolverType() {
return resolverType;
}

public Long getPrincipalId() {
return principalId;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.daedan.festabook.authorization;

public record AuthorizationKey(
Class<?> target,
AccessAction action,
AccessSubject subject
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.daedan.festabook.authorization;

import com.daedan.festabook.global.security.authorization.AccountDetails;

public interface AuthorizationStrategy {

boolean supports(AuthorizationKey key);

boolean isAuthorized(AccountDetails accountDetails, AuthorizationContext context);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import com.daedan.festabook.global.domain.BaseEntity;
import java.util.List;
import java.util.Optional;

public interface PlaceIdsResolver {

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.daedan.festabook.authorization.resolver;

import com.daedan.festabook.authorization.domain.FestivalIdsResolver;
import com.daedan.festabook.global.exception.InternalServerException;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.stereotype.Component;

@Component
public class FestivalIdsResolverRegistry {

private final Map<Class<?>, FestivalIdsResolver> resolverMap;

public FestivalIdsResolverRegistry(List<FestivalIdsResolver> resolvers) {
this.resolverMap = resolvers.stream()
.collect(Collectors.toMap(FestivalIdsResolver::getResolverType, resolver -> resolver));
}

public boolean hasResolver(Class<?> resolverType) {
return resolverMap.containsKey(resolverType);
}

public List<Long> resolve(Class<?> resolverType, List<Long> entityIds) {
FestivalIdsResolver resolver = resolverMap.get(resolverType);
if (resolver == null) {
throw new InternalServerException();
}
return resolver.resolve(entityIds);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.daedan.festabook.authorization.resolver;

import com.daedan.festabook.authorization.domain.OrganizationIdResolver;
import com.daedan.festabook.global.exception.InternalServerException;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.stereotype.Component;

@Component
public class OrganizationIdResolverRegistry {

private final Map<Class<?>, OrganizationIdResolver> resolverMap;

public OrganizationIdResolverRegistry(List<OrganizationIdResolver> resolvers) {
this.resolverMap = resolvers.stream()
.collect(Collectors.toMap(OrganizationIdResolver::getResolverType, resolver -> resolver));
}

public boolean hasResolver(Class<?> resolverType) {
return resolverMap.containsKey(resolverType);
}

public Long resolve(Class<?> resolverType, Long entityId) {
OrganizationIdResolver resolver = resolverMap.get(resolverType);
if (resolver == null) {
throw new InternalServerException();
}
return resolver.resolve(entityId)
.orElseThrow(InternalServerException::new);
Comment on lines +29 to +30

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Treat unresolved organization IDs as auth denial, not 500

When OrganizationIdResolver returns empty, this registry now throws InternalServerException; in this commit StaffService.deleteStaff and updateStaffAuthorities call authorization before loading staff, so a nonexistent staffId now becomes a server error instead of a normal forbidden/not-found style response. This turns ordinary client input into a 500 path and makes auth failures look like infrastructure faults.

Useful? React with 👍 / 👎.

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.daedan.festabook.authorization.resolver;

import com.daedan.festabook.authorization.domain.PlaceIdsResolver;
import com.daedan.festabook.global.exception.InternalServerException;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.stereotype.Component;

@Component
public class PlaceIdsResolverRegistry {

private final Map<Class<?>, PlaceIdsResolver> resolverMap;

public PlaceIdsResolverRegistry(List<PlaceIdsResolver> resolvers) {
this.resolverMap = resolvers.stream()
.collect(Collectors.toMap(PlaceIdsResolver::getResolverType, resolver -> resolver));
}

public boolean hasResolver(Class<?> resolverType) {
return resolverMap.containsKey(resolverType);
}

public List<Long> resolve(Class<?> resolverType, List<Long> entityIds) {
PlaceIdsResolver resolver = resolverMap.get(resolverType);
if (resolver == null) {
throw new InternalServerException();
}
return resolver.resolve(entityIds);
}
}
Loading
Loading