3차 프로젝트(Spring Security) 기반 심화 학습 — 실무 수준의 코드로 완성한다
AOP 로깅 · 컬렉션 · 예외처리 · @Transactional · @Builder
3차(Spring Security)까지 완성하면 기능은 다 만들었다. 3차 심화는 기능을 추가하는 게 아니라 코드 품질을 실무 수준으로 끌어올리는 단계다. 이 파일은 심화과정의 5단계 전체를 한눈에 조망하는 커리큘럼 맵이다.
이 심화과정은 하나의 질문에서 시작되었다.
무엇을 익혔고 무엇이 부족한지 파악해야 심화과정의 방향이 잡힌다.
| 차수 | 핵심 기술 | 브랜치 | 포트 | 상태 |
|---|---|---|---|---|
| 1차 | Thymeleaf + 세션 인증 | master | 8081 | ✅ 완성 |
| 2차 | React + JWT 인증 | feature/react | 8082 / 3001 | ✅ 완성 |
| 3차 | Spring Security + 관리자 | feature/security | 8083 / 3002 | ✅ 완성 |
| 3차 심화 | AOP · 컬렉션 · 예외 · 트랜잭션 · Builder | feature/advanced | - | ⬜ 진행 중 |
| 4차 (선택) | Docker 컨테이너화 + AWS 배포 | - | 8080 / 3000 | 선택과정 |
3차 코드(feature/security)를 기반으로 5단계를 순서대로 진행한다. 각 단계는 이론 → 실제 코드 적용 → 커밋 순으로 진행.
# 3차 코드 기반으로 심화 브랜치 생성 git checkout feature/security git checkout -b feature/advanced # 각 STEP 완료 시 커밋 git commit -m "STEP1: AOP 로깅 추가 (LogAspect.java)" git commit -m "STEP3: 전역 예외처리 추가 (GlobalExceptionHandler)" git commit -m "STEP5: Builder 패턴 적용"
반복되는 로그 코드를 한 곳에 모아 자동으로 처리한다. 기존 코드 한 줄도 수정하지 않는다.
public void write(BoardDto dto) { // 😩 모든 메서드마다 반복 log.info("write 시작"); long s = System.currentTimeMillis(); // 핵심 로직... log.info("write 완료: {}ms", now()-s); } public void update(...) { // 😩 또 반복 log.info("update 시작"); long s = System.currentTimeMillis(); // 핵심 로직... log.info("update 완료: {}ms", now()-s); }
public void write(BoardDto dto) { // 핵심 로직만! boardRepository.save(board); } public void update(...) { // 핵심 로직만! board.setTitle(dto.getTitle()); }
@Aspect @Component @Slf4j public class LogAspect { @Around("execution(* ..service.*.*(..))") public Object log(ProceedingJoinPoint jp) throws Throwable { // 모든 Service에 자동 적용! } }
| 용어 | 뜻 | 우리 코드 예시 |
|---|---|---|
Aspect |
부가 기능을 모아둔 클래스 | LogAspect.java — 로깅 기능 전체 |
Pointcut |
어디에 적용할지 지정 (표현식) | execution(* com.example.board.service.*.*(..))→ service 패키지의 모든 클래스, 모든 메서드 |
Advice |
언제 실행할지 + 실행할 코드 | @Around 메서드 전후 / @AfterThrowing 예외 발생 시 |
JoinPoint |
실제로 Advice가 적용된 지점 | BoardService.write() 호출 시점 |
| 어노테이션 | 실행 시점 | 주요 용도 |
|---|---|---|
@Around | 메서드 실행 전 + 후 모두 | 실행시간 측정, 로깅 (가장 많이 사용) |
@Before | 메서드 실행 전 | 파라미터 검증, 권한 체크 |
@After | 메서드 실행 후 (성공/실패 무관) | 리소스 정리 |
@AfterReturning | 메서드 정상 완료 후 | 반환값 로깅 |
@AfterThrowing | 예외 발생 시에만 | 예외 로깅, 알림 |
// execution( 반환타입 패키지.클래스.메서드(파라미터) ) "execution(* com.example.board.service.*.*(..))" // 분석: // * → 반환타입 무관 (void, String, List 전부) // com.example.board.service → 이 패키지 안에 있는 // * → 모든 클래스 (BoardService, UserService, AdminService) // .* → 모든 메서드 (write, update, delete, getList ...) // (..) → 파라미터 무관 (0개든 100개든) // 결과: service 패키지의 모든 메서드에 자동 적용! // 다른 예시들 "execution(* com.example.board.service.BoardService.*(..))" // BoardService만 "execution(* com.example.board.service.*.get*(..))" // get으로 시작하는 메서드만 "execution(public * com.example.board.service.*.*(..))" // public 메서드만
@Around는 메서드 실행 전후를 모두 감싸는 Advice다. 반드시 jp.proceed()를 호출해야 실제 메서드가 실행된다. 이걸 호출하지 않으면 실제 로직이 실행되지 않는다.
@Around("serviceLayer()") public Object logExecutionTime(ProceedingJoinPoint jp) throws Throwable { // ① 실제 메서드 실행 전 코드 log.info("메서드 시작"); long start = System.currentTimeMillis(); // ② jp.proceed() = 실제 메서드 실행 (필수!) // BoardService.write() 같은 실제 비즈니스 로직이 여기서 실행됨 Object result = jp.proceed(); // ③ 실제 메서드 실행 후 코드 long end = System.currentTimeMillis(); log.info("메서드 완료: {}ms", end - start); // ④ 반드시 result 반환 (메서드의 실제 반환값) return result; } // 실행 흐름: 클라이언트가 boardService.write() 호출 // → AOP가 가로챔 → ① 실행 전 코드 → ② jp.proceed() → ③ 실행 후 코드 → ④ 결과 반환
// ProceedingJoinPoint jp 에서 꺼낼 수 있는 정보들 jp.getTarget().getClass().getSimpleName() // → "BoardService" (어떤 클래스의 메서드인지) jp.getSignature().getName() // → "write" (메서드 이름) jp.getArgs() // → [BoardDto(title=테스트), User(id=1)] (파라미터들) Arrays.toString(jp.getArgs()) // → "[BoardDto(title=테스트, content=내용), User(id=1)]" (문자열 변환)
Spring Boot 프로젝트에서 AOP를 사용하려면 spring-boot-starter-aop 의존성을 추가해야 한다. 현재 3차 코드에는 이 의존성이 없으므로 추가가 필요하다.
dependencies {
implementation "org.springframework.boot:spring-boot-starter-data-jpa"
implementation "org.springframework.boot:spring-boot-starter-webmvc"
implementation "org.springframework.boot:spring-boot-starter-security"
// ↓ 추가!
implementation "org.springframework.boot:spring-boot-starter-aop"
implementation "io.jsonwebtoken:jjwt-api:0.12.3"
runtimeOnly "io.jsonwebtoken:jjwt-impl:0.12.3"
runtimeOnly "io.jsonwebtoken:jjwt-jackson:0.12.3"
compileOnly "org.projectlombok:lombok"
runtimeOnly "org.mariadb.jdbc:mariadb-java-client"
annotationProcessor "org.projectlombok:lombok"
testImplementation "org.springframework.boot:spring-boot-starter-test"
}
@Slf4j // log.info() 자동 생성 (Lombok) @Aspect // 이 클래스가 AOP 클래스임을 선언 @Component // Spring Bean으로 등록 public class LogAspect { // service 패키지의 모든 클래스, 모든 메서드에 적용 @Pointcut("execution(* com.example.board.service.*.*(..))") public void serviceLayer() {} // ① 실행 전후 처리 — 실행시간 측정 @Around("serviceLayer()") public Object logExecutionTime(ProceedingJoinPoint jp) throws Throwable { String className = jp.getTarget().getClass().getSimpleName(); String methodName = jp.getSignature().getName(); log.info("[START] {}.{}() - 파라미터: {}", className, methodName, Arrays.toString(jp.getArgs())); long start = System.currentTimeMillis(); Object result = jp.proceed(); // 실제 메서드 실행 (필수!) long time = System.currentTimeMillis() - start; log.info("[END] {}.{}() - 실행시간: {}ms", className, methodName, time); return result; } // ② 예외 발생 시에만 실행 @AfterThrowing(pointcut = "serviceLayer()", throwing = "ex") public void logException(JoinPoint jp, Exception ex) { log.error("[EXCEPTION] {}.{}() - {} : {}", jp.getTarget().getClass().getSimpleName(), jp.getSignature().getName(), ex.getClass().getSimpleName(), ex.getMessage()); } }
// 게시글 작성 API 호출 시 [START] BoardService.write() - 파라미터: [BoardDto(title=테스트, content=내용), User(id=1)] [END] BoardService.write() - 실행시간: 23ms // 로그인 시 [START] UserService.login() - 파라미터: [UserDto(username=hong)] [END] UserService.login() - 실행시간: 156ms // 없는 게시글 조회 시 [START] BoardService.getDetail() - 파라미터: [999] [EXCEPTION] BoardService.getDetail() - IllegalArgumentException : 없는 게시글입니다.
src/main/java/com/example/board/
├── aspect/ ← 새로 만든 폴더
│ └── LogAspect.java ← 새로 만든 파일
├── config/
├── controller/
├── service/ ← 이 안의 모든 메서드에 자동 적용됨
│ ├── BoardService.java
│ ├── UserService.java
│ └── AdminService.java
└── ...
Spring이 @Aspect와 @Component를 보고 자동으로 BoardService, UserService, AdminService 앞뒤에 LogAspect의 코드를 끼워 넣는다. 이게 AOP의 핵심이다. 비즈니스 로직(Service)은 자신이 감시당하는지도 모른다.
우리 프로젝트에서 이미 쓰고 있었지만 이론 정리가 안 되어있던 부분. List, Map, Set부터 정렬, 유틸까지 완전히 정리한다.
| 컬렉션 | 특징 | 언제 쓰나 | 우리 프로젝트 사용 예 |
|---|---|---|---|
List<T> | 순서 O, 중복 O | 게시글 목록처럼 순서 있는 데이터 | List<BoardDto>, List<GrantedAuthority> |
Map<K,V> | 키-값 쌍 | API 응답 메시지, 설정값 | Map.of("message","성공","token",token) |
Set<T> | 순서 X, 중복 X | 중복 제거가 필요한 데이터 | 태그 기능 등 (현재 미사용) |
Page<T> | JPA 페이징 | 게시판 목록 페이징 | Page<Board> boards |
List<String> list = new ArrayList<>(); list.add("홍길동"); list.get(0); // "홍길동" list.size(); // 1 list.contains("홍길동"); // true // 우리 프로젝트 List.of(new SimpleGrantedAuthority(role));
Map<String, String> map = new HashMap<>(); map.put("token", token); map.get("token"); // token 값 // 우리 프로젝트 Map.of("message", "로그인 성공", "token", token);
Set<String> set = new HashSet<>(); set.add("Java"); set.add("Java"); // 중복! 무시됨 set.size(); // 1 // List 중복 제거 활용 new HashSet<>(listWithDup);
// 최신순 정렬 boards.sort(Comparator .comparing(Board::getCreatedAt) .reversed()); // JPA에서 Sort.by(Sort.Direction.DESC, "createdAt");
Collections.sort(list); Collections.max(list); Collections.min(list); Collections.reverse(list); Collections.shuffle(list); Collections.emptyList();
Iterator<String> it = list.iterator(); while (it.hasNext()) { String s = it.next(); if (s.equals("삭제")) it.remove(); // 안전! }
| 상황 | 사용할 것 | 이유 |
|---|---|---|
| 게시글 목록, 유저 목록처럼 순서 있는 데이터 | List | 순서 보장 + 인덱스 접근 가능 |
| API 응답 JSON (message, token, role) | Map.of() | 키-값 쌍, 수정 불가로 안전 |
| 태그, 권한처럼 중복 없어야 할 데이터 | Set | 자동 중복 제거 |
| 목록 순회 중 삭제 필요 | Iterator | ConcurrentModificationException 방지 |
| JPA 게시글 최신순 정렬 | Sort.by | JPA Pageable과 연동 |
| 컬렉션 최댓값/뒤집기/섞기 | Collections 유틸 | 정적 유틸 메서드 활용 |
커스텀 예외 클래스 + @ControllerAdvice 전역 예외처리로 모든 에러를 일관된 형식의 JSON으로 반환한다.
// 각 Controller마다 try/catch 반복 @PostMapping("/login") public ResponseEntity<?> login(...) { try { String token = userService.login(dto); return ResponseEntity.ok(...); } catch (Exception e) { return ResponseEntity.badRequest()...; } } // BoardController에도 같은 패턴 반복...
// try/catch 완전 제거! @PostMapping("/login") public ResponseEntity<?> login(...) { String token = userService.login(dto); return ResponseEntity.ok( Map.of("token", token)); // 예외 → GlobalExceptionHandler 자동 처리 }
| 어노테이션 | 역할 | 범위 |
|---|---|---|
@ExceptionHandler |
특정 예외가 발생했을 때 처리할 메서드 지정 | 해당 Controller 내에서만 (단독 사용 시) |
@ControllerAdvice |
모든 Controller에 적용되는 공통 처리기 | 전체 Controller (전역) |
@RestControllerAdvice |
@ControllerAdvice + @ResponseBody | 전체 Controller, JSON 자동 반환 |
우리 프로젝트는 REST API(JSON 응답)이므로 @RestControllerAdvice를 사용한다. @ControllerAdvice에 @ResponseBody가 합쳐진 것이다.
@Getter public class CustomException extends RuntimeException { // RuntimeException 상속 = @Transactional 자동 롤백! private final HttpStatus status; private final String message; // 자주 쓰는 예외 — 정적 팩토리 메서드 public static CustomException notFound(String msg) { return new CustomException(HttpStatus.NOT_FOUND, msg); } public static CustomException badRequest(String msg) { return new CustomException(HttpStatus.BAD_REQUEST, msg); } public static CustomException unauthorized(String msg) { return new CustomException(HttpStatus.UNAUTHORIZED, msg); } }
@RestControllerAdvice // 모든 Controller 예외를 여기서 처리 public class GlobalExceptionHandler { @ExceptionHandler(CustomException.class) public ResponseEntity<?> handleCustom(CustomException e) { return ResponseEntity.status(e.getStatus()) .body(Map.of("status", e.getStatus().value(), "message", e.getMessage())); } @ExceptionHandler(IllegalArgumentException.class) // 기존 코드 호환성 유지 public ResponseEntity<?> handleIllegal(IllegalArgumentException e) { return ResponseEntity.badRequest() .body(Map.of("status", 400, "message", e.getMessage())); } @ExceptionHandler(Exception.class) // 안전망 public ResponseEntity<?> handleAll(Exception e) { return ResponseEntity.internalServerError() .body(Map.of("status", 500, "message", "서버 오류가 발생했습니다.")); } }
@Transactional public void update(Long id, BoardDto dto, User user) { Board board = boardRepository.findById(id) .orElseThrow(() -> new IllegalArgumentException("없는 게시글")); if (!board.getUser().getId().equals(user.getId())) throw new IllegalArgumentException("권한 없음"); board.setTitle(dto.getTitle()); }
@Transactional public void update(Long id, BoardDto dto, User user) { Board board = boardRepository.findById(id) .orElseThrow(() -> CustomException.notFound("없는 게시글입니다.")); if (!board.getUser().getId().equals(user.getId())) throw CustomException.forbidden("수정 권한이 없습니다."); board.setTitle(dto.getTitle()); }
// 없는 게시글 조회 시 (404) { "status": 404, "message": "없는 게시글입니다." } // 잘못된 비밀번호 (400) { "status": 400, "message": "아이디 또는 비밀번호가 틀렸습니다." } // 수정 권한 없음 (403) { "status": 403, "message": "수정 권한이 없습니다." } // 서버 오류 (500) { "status": 500, "message": "서버 오류가 발생했습니다." }
신규 생성
exception/CustomException.java
exception/GlobalExceptionHandler.java
수정 (try/catch 제거 + CustomException 적용)
service/BoardService.java · service/UserService.java · service/AdminService.java
controller/UserController.java · controller/BoardController.java · controller/AdminController.java
트랜잭션 전파, 롤백 조건, 더티체킹 — 우리 프로젝트 Service 코드를 기반으로 완전히 분석한다.
@Transactional // = REQUIRED public void write(BoardDto dto) { boardRepository.save(board); // 예외 → 자동 롤백! }
@Transactional(readOnly = true) public Page<BoardDto> getList(...) { // 조회만 → 스냅샷 안 만듦 // 성능 향상! }
@Transactional public void update(Long id, BoardDto dto) { Board board = repo.findById(id).get(); board.setTitle(dto.getTitle()); // save() 없어도 자동 UPDATE! }
// 자동 롤백 O throw new RuntimeException(); throw new CustomException(...); // 자동 롤백 X (직접 지정) @Transactional(rollbackFor = Exception.class)
| 메서드 | readOnly | 이유 |
|---|---|---|
| BoardService.write() | false | INSERT → 롤백 필요 |
| BoardService.update() | false | UPDATE → 더티체킹 |
| BoardService.delete() | false | DELETE → 롤백 필요 |
| BoardService.getList() | true 권장 | 조회만 → 성능 최적화 |
| UserService.register() | false | INSERT → 롤백 필요 |
| UserService.login() | true 권장 | 조회만 → 성능 최적화 |
| AdminService.getAllUsers() | true 권장 | 조회만 → 성능 최적화 |
| AdminService.deleteUser() | false | DELETE 2개 → 원자성 보장 |
| AdminService.deleteBoard() | false | DELETE → 롤백 필요 |
| 속성 | 의미 | 예시 |
|---|---|---|
Atomicity (원자성) | 전부 성공 or 전부 실패 | 유저 삭제 시 게시글도 같이 삭제 — 하나라도 실패하면 둘 다 취소 |
Consistency (일관성) | 트랜잭션 전후 DB 상태 일관 | 잔액이 음수가 되는 이체 불가 |
Isolation (격리성) | 동시 실행 트랜잭션 간 간섭 없음 | 두 사람이 동시에 같은 게시글 수정 시 충돌 방지 |
Durability (영속성) | 커밋된 데이터는 영구 보존 | 서버가 꺼져도 저장된 데이터는 유지 |
| 예외 종류 | 자동 롤백 | 예시 |
|---|---|---|
| RuntimeException (unchecked) | ✅ 자동 롤백 | IllegalArgumentException, NullPointerException, CustomException |
| Exception (checked) | ❌ 롤백 안됨 | IOException, SQLException |
| rollbackFor 직접 지정 | ✅ 강제 롤백 | @Transactional(rollbackFor = Exception.class) |
STEP 3에서 만든 CustomException은 RuntimeException을 상속한다. 그래야 @Transactional의 자동 롤백 대상이 된다. 만약 Exception을 상속하면 예외가 발생해도 롤백되지 않아 DB가 오염될 수 있다.
한 트랜잭션 안에서 다른 @Transactional 메서드를 호출할 때 어떻게 처리할지 결정한다. 우리 프로젝트에서는 기본값인 REQUIRED만 알면 충분하다.
| 전파 옵션 | 동작 | 사용 시점 |
|---|---|---|
REQUIRED (기본값) | 기존 트랜잭션 있으면 참여, 없으면 새로 생성 | 대부분의 경우 (우리 프로젝트 전부) |
REQUIRES_NEW | 항상 새 트랜잭션 생성 (기존 일시 중단) | 로그 저장처럼 독립적으로 커밋해야 할 때 |
NESTED | 중첩 트랜잭션 (savepoint 활용) | 부분 롤백이 필요한 복잡한 로직 |
생성자 방식의 단점을 해결하는 Builder 패턴. Entity와 DTO 생성 코드를 더 명확하고 안전하게 만든다.
new Board( null, "제목", "내용", user, 0, LocalDateTime.now(), null ); // 뭐가 뭔지 모름!
Board board = new Board(); board.setTitle("제목"); board.setContent("내용"); board.setUser(user); board.setViewCount(0); // 필수 필드 빠뜨려도 // 컴파일 에러 안남!
Board.builder() .title("제목") .content("내용") .user(user) .viewCount(0) .build(); // 명확하고 안전!
@Getter @Builder @NoArgsConstructor @AllArgsConstructor public class BoardDto { private Long id; private String title; private String content; private String nickname; } // BoardService - Entity → DTO 변환 private BoardDto toDto(Board board) { return BoardDto.builder() .id(board.getId()) .title(board.getTitle()) .content(board.getContent()) .nickname(board.getUser().getNickname()) .build(); }
클래스에 @Builder를 붙이면 Lombok이 컴파일 시점에 자동으로 Builder 클래스를 생성한다. 직접 코드를 작성할 필요가 없다.
// Lombok이 자동으로 만들어주는 것 — 직접 작성 불필요 public static BoardDtoBuilder builder() { return new BoardDtoBuilder(); } public static class BoardDtoBuilder { private Long id; private String title; private String content; private String nickname; public BoardDtoBuilder id(Long id) { this.id = id; return this; } public BoardDtoBuilder title(String t) { this.title = t; return this; } public BoardDtoBuilder content(String c) { this.content = c; return this; } public BoardDtoBuilder nickname(String n) { this.nickname = n; return this; } public BoardDto build() { return new BoardDto(id, title, content, nickname); } }
@Getter @Setter @Builder // ← 추가! @NoArgsConstructor @AllArgsConstructor // ← 추가! (@Builder 필수) public class UserDto { private Long id; private String username; private String password; private String nickname; private String role; private String adminCode; } // AdminService.getAllUsers() — Builder 사용 예시 public List<UserDto> getAllUsers() { return userRepository.findAll().stream() .map(user -> UserDto.builder() .id(user.getId()) .username(user.getUsername()) .nickname(user.getNickname()) .role(user.getRole()) .build()) // Setter 방식보다 훨씬 명확! .collect(Collectors.toList()); }
① @Builder만 쓰면 기본 생성자(NoArgsConstructor)가 사라진다. JPA Entity는 기본 생성자가 필수이므로 반드시 @NoArgsConstructor를 함께 써야 한다.
② @NoArgsConstructor와 @Builder를 함께 쓸 때는 @AllArgsConstructor도 추가해야 한다. @Builder가 전체 파라미터 생성자를 필요로 하기 때문이다.
③ Entity에 @Builder를 쓸 때는 신중하게 — 모든 필드를 외부에서 세팅 가능하게 열어두면 보안 문제가 생길 수 있다. DTO에 사용하는 것이 더 안전하다.
dto/BoardDto.java → @Builder, @AllArgsConstructor 추가
dto/UserDto.java → @Builder, @AllArgsConstructor 추가
service/BoardService.java → toDto() Builder 방식으로 변경
service/AdminService.java → getAllUsers(), getAllBoards() Builder 방식으로 변경
심화과정 완료 후 단기 → 중기 → 장기 목표로 단계적으로 나아간다.
| 항목 | 3차 완성 후 | 심화과정 완성 후 |
|---|---|---|
| 기본 CRUD | ✅ 완성 | ✅ 유지 |
| 인증/인가 | ✅ JWT + Security | ✅ 유지 |
| 로깅 | ❌ 없음 | ✅ AOP 자동 로깅 (STEP 1) |
| 컬렉션 이론 | ⚠️ 쓰고 있지만 정리 안됨 | ✅ 완전 정리 (STEP 2) |
| 예외처리 | ⚠️ Controller마다 try/catch | ✅ 전역 예외처리 (STEP 3) |
| 트랜잭션 | ⚠️ 기본 @Transactional만 | ✅ readOnly + 전파 이해 (STEP 4) |
| DTO 생성 방식 | ⚠️ Setter 방식 (장황) | ✅ @Builder 패턴 (STEP 5) |
| 포트폴리오 완성도 | 약 70% | 약 90% |