3장 · 서비스 & 트랜잭션
업무 로직이 들어가는 서비스 층이 무엇인지, 여러 DB 작업을 안전하게 묶는 트랜잭션?은 왜 필요한지, 그리고 엔티티?와 DTO? 사이를 어떻게 변환하고 누가·언제 저장했는지(감사 컬럼)를 어떻게 자동으로 기록하는지 살펴봅니다.
서비스 층이란?
서비스 층은 업무 로직(비즈니스 규칙)이 사는 자리입니다. 정석 3계층에서는 컨트롤러?가 요청을 받아 서비스에 넘기고, 서비스가 규칙을 처리하면서 리포지토리?로 DB를 다룹니다. "비밀번호를 암호화한 뒤 저장한다", "로그인이 성공하면 마지막 로그인 시각을 갱신한다" 같은 여러 단계를 엮은 절차가 서비스가 맡는 일이에요.
// core/service/AuthLogService.java — 로그인이 성공이면 lastLogin 갱신 (여러 단계를 엮음)
public void recordAuthAttempt(AuthLogDTO logDto) {
AuthLog authLog = new AuthLog();
BeanUtils.copyProperties(logDto, authLog); // DTO → 엔티티 복사
authLog.setAttemptTime(LocalDateTime.now());
authLogRepo.save(authLog); // ① 시도 기록 저장
if (AuthStatus.SUCCESS.equals(logDto.getStatus())) {
updateLastLogin(logDto.getUserid()); // ② 성공이면 마지막 로그인 갱신
}
}개념은 "컨트롤러 → 서비스 → 리포지토리" 3계층으로 이해하되, 이 학습 프로젝트는 서비스 층이 얇습니다.
일반 CRUD(사용자·역할 등)는 서비스를 거치지 않고 컨트롤러가 리포지토리를 직접 호출해요.
서비스로 분리된 건 절차가 있는 로그인 기록(AuthLogService) 정도입니다.
UserService에는 비즈니스 CRUD가 아예 없고, SecurityContext?에서
현재 로그인 사용자를 꺼내는 헬퍼 한 개만 있습니다.
// core/service/UserService.java — 비즈니스 CRUD 없음. 현재 사용자 꺼내는 헬퍼뿐
@Service
public class UserService {
/** 현재 사용자. Async 환경에서는 사용 불가. 로그인 사용자가 아니면 null */
public User getCurrentUser() {
return SecurityContextHolder.getContext()
.getAuthentication()
.getPrincipal() instanceof User user ? user : null;
}
}@Transactional — 전부 성공 아니면 전부 취소
한 작업이 DB를 여러 번 바꿀 때, 도중에 실패하면 앞에서 바꾼 것들이 어중간하게 남아 데이터가 깨질 수 있습니다.
트랜잭션은 이렇게 묶인 작업을 전부 성공시키거나, 하나라도 실패하면 전부 되돌리는(rollback) 단위예요.
메서드에 @Transactional을 붙이면 그 메서드 전체가 하나의 트랜잭션으로 묶입니다.
// core/service/AuthLogService.java:81 — 서비스의 저장 메서드
@Transactional
public AuthLog save(AuthLog authLog) {
return authLogRepo.save(authLog);
}
이 프로젝트는 서비스 층이 얇다 보니, 컨트롤러에도 @Transactional이 붙어 있습니다.
예를 들어 사용자 계정을 만드는 메서드는 비밀번호 암호화·저장을 한 묶음으로 처리하도록 트랜잭션을 겁니다.
// web/endpoint/UserAccountEndpoint.java:69 — 컨트롤러에 직접 붙은 @Transactional
@PostMapping("")
@Transactional
public void createUserAccount(@Valid @RequestBody UserCreateReq req) {
String encryped = passwordEncoder.encode(req.password()); // 암호화
var newUser = new User();
newUser.setUserid(req.userid());
newUser.setEncryptedPassword(encryped);
// ...
userRepo.save(newUser); // 저장
}원칙적으로는 "여러 DB 작업을 한 단위로 묶는 곳" — 즉 서비스 메서드에 붙입니다. 이 프로젝트는 서비스가 얇아 컨트롤러에 붙은 경우가 있지만, 일반적으로는 업무 절차를 가진 서비스에 두는 것을 권장해요.
DTO ↔ 엔티티 변환
DB와 매핑된 엔티티를 그대로 화면에 내보내면 안 됩니다(보안·결합도 문제). 그래서 응답할 땐 DTO로 변환해요.
이 프로젝트의 주력 방식은 응답 DTO에 둔 fromEntity 정적 메서드입니다.
엔티티를 받아 필요한 필드만 골라 DTO를 만들어 돌려줘요.
// web/dto/UserDTO.java — 주력 변환 방식: 정적 fromEntity
public record UserDTO(UUID id, String userid, String name, String email, /* ... */) {
public static UserDTO fromEntity(User user) {
if (user == null) return null;
return new UserDTO(
user.getId(), user.getUserid(), user.getName(), user.getEmail()
/* ... 필요한 필드만 옮긴다 ... */
);
}
}
또 다른 방식으로, 필드 이름이 같을 때 스프링의 BeanUtils.copyProperties로 한 번에 복사하기도 합니다.
AuthLogService가 이 방식을 써요(양방향 모두 사용).
// core/service/AuthLogService.java:28~52 — BeanUtils로 같은 이름 필드 복사 // DTO → 엔티티 AuthLog authLog = new AuthLog(); BeanUtils.copyProperties(logDto, authLog); // 엔티티 → DTO (조회 결과 변환) var dto = new AuthLogDTO(); BeanUtils.copyProperties(authLog, dto);
의존성 목록(build.gradle)에는 modelmapper가 들어 있지만, 실제로 변환에 쓰는 코드는 없습니다.
변환은 위의 fromEntity(주력)와 BeanUtils.copyProperties 두 가지로 이뤄져요.
"라이브러리가 있으니 modelmapper를 쓰겠지" 하고 오해하지 않도록 주의하세요.
감사 컬럼 자동 기록 — 3박자
"이 데이터를 누가·언제 만들었고 수정했나"는 거의 모든 테이블에 필요합니다.
이 프로젝트는 저장/수정 시 createdBy·updatedBy(누가)와 createdAt·updatedAt(언제)을
자동으로 채웁니다. 이를 위해 세 가지가 맞물려 돕니다.
① 공통 베이스 엔티티 — 감사 컬럼을 정의하고 리스너를 단다
모든 엔티티가 상속하는 BaseAuditEntity가 4개 컬럼을 갖고,
@CreatedBy/@LastModifiedBy 표시와 @EntityListeners(AuditingEntityListener.class)로
"저장/수정 시점에 값을 채워라"라고 알립니다.
// shared/base/BaseAuditEntity.java:16 — 공통 감사 컬럼 + 리스너
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class) // 저장/수정 이벤트를 가로채 값 채움
public abstract class BaseAuditEntity {
@Column(name = "created_at") private LocalDateTime createdAt; // 언제
@Column(name = "updated_at") private LocalDateTime updatedAt;
@CreatedBy @Column(name = "created_by") private String createdBy; // 누가
@LastModifiedBy @Column(name = "updated_by") private String updatedBy;
}② 감사 기능 켜기 — auditorAware 연결
@EnableJpaAuditing(auditorAwareRef = "auditorAware")로 감사 기능을 켜고,
"누구인지(작성자)는 auditorAware 빈에게 물어봐라"라고 지정합니다.
// config/SecurityConfig.java:17 — 감사 활성화 + 작성자 제공자 지정
@Configuration
@EnableJpaAuditing(auditorAwareRef = "auditorAware")
public class SecurityConfig {
@Bean
public AuditorAware<String> auditorAware() {
return new SpringSecurityAuditorAware(); // ③에서 정의
}
}③ 현재 사용자 알려주기 — SpringSecurityAuditorAware
JPA가 "지금 작성자가 누구냐"고 물으면, 이 클래스가 SecurityContext?에서
로그인 사용자를 꺼내 이름을 돌려줍니다. 그 값이 createdBy/updatedBy에 채워져요.
// config/SpringSecurityAuditorAware.java:13 — 로그인 사용자 이름 반환
public Optional<String> getCurrentAuditor() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) return Optional.empty();
User user = (User) auth.getPrincipal();
return Optional.ofNullable(user.getName()); // 이 이름이 createdBy/updatedBy로
}BaseAuditEntity(컬럼+리스너) ↔ @EnableJpaAuditing(기능 켜고 작성자 제공자 지정) ↔ SpringSecurityAuditorAware(현재 사용자 이름) — 이 3박자가 맞물려, 저장/수정할 때마다 누가·언제가 코드 한 줄 없이 자동으로 남습니다.
- 업무 절차 추가 — "A를 저장한 뒤 조건에 따라 B도 바꾼다" 같은 다단계 로직은 서비스로 빼고
@Transactional로 묶는다 - 응답 만들 때 — 엔티티를 그대로 내보내지 말고
DTO.fromEntity()로 필요한 필드만 변환한다 - 이력 추적 — 새 테이블 엔티티는
BaseAuditEntity를 상속만 하면 누가·언제가 자동 기록된다 - 현재 사용자 필요 시 — 컨트롤러는
@AuthenticationPrincipal, 서비스 내부는UserService.getCurrentUser()로 꺼낸다
한눈에 정리
| 주제 | 핵심 | 이 프로젝트에서 |
|---|---|---|
| 서비스 층 | 업무 로직이 사는 자리 | 얇음 — 일반 CRUD는 컨트롤러가 리포지토리 직접 호출 |
UserService | — | 비즈니스 CRUD 없음, getCurrentUser() 헬퍼만 |
@Transactional | 전부 성공 / 전부 취소 | AuthLogService.save, 컨트롤러 createUserAccount |
| DTO 변환 (주력) | 응답용으로 엔티티→DTO | 정적 fromEntity() 메서드 |
| DTO 변환 (보조) | 같은 이름 필드 복사 | BeanUtils.copyProperties (AuthLogService) |
| modelmapper | — | 의존성만 있고 실제 미사용 |
| 감사 컬럼 | 누가·언제 자동 기록 | BaseAuditEntity + @EnableJpaAuditing + AuditorAware |