스프링 부트로 프로젝트를 하면서 @Transactional이 주는 편리함을 당연하다고만 받아들였다.
그러다 보니 어떤 원리로 @Transactional 메소드가 트랜잭션으로서 작동하는지 모른 채로 사용해왔다.

나와 같은 고민을 한 사람들에게 스프링이 트랜잭션을 컨트롤하는 방법에 대해 공유하려고 한다.

개요

스프링이 트랜잭션을 관리하는 방법을 알아보기 위해 스프링이 없는 상태에서 트랜잭션을 관리하기부터 시작해서
스프링의 설계 사상을 한 단계씩 적용해가면서 차근차근 이해해보려고 한다.

즉, JDBC 레벨에서 트랜잭션을 관리하는 방법부터 시작하면서 스프링이 트랜잭션 관리를 설계하기 위해 어떤 생각들을
적용하는지 하나하나 알아보는 것이 기초를 다지기 좋다고 생각해서이다.

JDBC의 트랜잭션 관리

트랜잭션을 관리하는 방식이 JDBC이든, 스프링이든, Hibernate이든 관계없이 DB 트랜잭션은 아래와 같은 과정을 거친다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import javax.sql.DataSource;
import java.sql.Connection;

public void doTransaction(DataSource dataSource) {
Connection connection;
try {
connection= dataSource.getConnection();
connection.setAutoCommit(false);
// SQL 실행
connection.commit();
} catch (Exception e) {
connection.rollback();
} finally {
connection.close();
}
}

Java에서 데이터베이스의 트랜잭션을 시작하는 방법은 이 방법 밖에는 없다.
즉, 스프링도 최종적으론 이 JDBC API를 호출함으로써 트랜잭션을 관리하고 있다.
사실 스프링이 @Transactional 메소드에 대해서 해주는 역할은 위 동작을 해주는 것 뿐이다.

JDBC 트랜잭션 전파 옵션과 격리 수준

여기서 트랜잭션의 전파(propagation) 방식과 격리(isolation) 수준을 다르게 한다면 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.Savepoint;

public void doTransaction(DataSource dataSource) {
Connection connection;
Savepoint savepoint;
try {
connection = dataSource.getConnection();
// 격리 수준 설정
connection.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);
// 전파 옵션 설정
savepoint = connection.setSavepoint();
connection.setAutoCommit(false);
// SQL 실행
connection.commit();
} catch (Exception e) {
connection.rollback(savepoint);
} finally {
connection.close();
}
}

스프링의 트랜잭션 관리

스프링에서는 위 작업을 단순화하기 위해서 PlatformTransactionManager 인터페이스를 만들었고 이 인터페이스는
JDBC가 트랜잭션을 시작하던 방법을 추상화했다.

프로그래밍적 트랜잭션 관리

PlatformTransactionManager를 통해 트랜잭션을 시작하고 관리하는 코드를 작성한다면 아래와 같이 작성할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public Object doTransaction(PlatformTransactionManager platformTransactionManager) {
// 트랜잭션 옵션 설정
TransactionDefinition transactionDefinition;
transactionDefinition.setPropagationBehavior(PROPAGATION_REQUIRES_NEW);
transactionDefinition.setIsolationLevel(ISOLATION_READ_COMMITTED);

// Connection 얻어오기
TransactionStatus transactionStatus = platformTransactionManager.getTranscation(transactionDefinition);
try {
// SQL 실행
} catch (Exception e) {
platformTransactionManager.rollback();
throw new RuntimeException(e);
}
platformTransactionManager.commit();
return result;
}

스프링이 구체적으로 제시한 사용법은 TransactionTemplate에 잘 구현되어 있다.

TransactionTemplate.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public <T> T execute(TransactionCallback<T> action) throws TransactionException {
Assert.state(this.transactionManager != null, "No PlatformTransactionManager set");

if (this.transactionManager instanceof CallbackPreferringPlatformTransactionManager cpptm) {
return cpptm.execute(this, action);
} else {
TransactionStatus status = this.transactionManager.getTransaction(this);
T result;
try {
result = action.doInTransaction(status);
} catch (RuntimeException | Error ex) {
// Transactional code threw application exception -> rollback
rollbackOnException(status, ex);
throw ex;
} catch (Throwable ex) {
// Transactional code threw unexpected exception -> rollback
rollbackOnException(status, ex);
throw new UndeclaredThrowableException(ex, "TransactionCallback threw undeclared checked exception");
}
this.transactionManager.commit(status);
return result;
}
}

이런 추상화 덕분에 스프링 사용자는 다음과 같이 간편해졌다.

  • 더이상 JDBC의 Connectionopen, commit, close등을 호출할 필요가 없어짐
  • 예외 발생 시 RuntimeException으로 변경하기 때문에 try-catch 구문을 작성할 필요가 없어짐
  • 트랜잭션 안에서의 동작만 기술하면 됨

하지만 이렇게 코드를 통한 트랜잭션 실행은 스프링이 근본적으로 추구하는 방향은 아니었다.

선언적 트랜잭션 관리

스프링에서 XML 방식으로 설정하는 것이 기본이었을 때에는 트랜잭션 또한 XML로 설정해야 했다.
XML을 통한 설정법은 이미 레거시(Legacy)가 되었으므로 자세히 알아보지는 않겠지만 이런 방식이 가능하다는 것을 알아두면 좋을 것 같다.

1
2
3
4
5
6
7
<tx:advice id="transactionAdvice" transaction-manager="transactionManager">
<tx:attributes>
<!-- get으로 시작하는 메소드는 readOnly를 true로 설정 -->
<tx:method name="get*" read-only="true"/>
<tx:method name="*"/>
</tx:attributes>
</tx:advice>

스프링 AOP를 통해 Advice를 위와 같이 구성하고 아래와 같이 특정 빈에 적용할 수 있었다.

1
2
3
4
5
6
<aop:config>
<aop:pointcut id="userServicePointcut" expression="execution(* x.y.service.UserService.*(..))"/>
<aop:advisor advice-ref="transactionAdvice" pointcut-ref="userServicePointcut"/>
</aop:config>

<bean id="userService" class="x.y.service.UserService"/>

그리고 UserService 클래스는 아래와 같을 것이다.

1
2
3
4
5
6
public class UserService {
public Long save(User user) {
// SQL 실행
return id;
}
}

AOP를 이용한 선언적 트랜잭션 관리를 사용함으로써 프로그래밍적 트랜잭션보다 코드는 훨씬 더 간단해졌다.
하지만 이를 위해 XML 파일을 작성하고 설정해야 했으며 XML 파일은 내용이 너무 장황했다.

@Transactional

이제 최근 스프링의 트랜잭션 관리 방법인 @Transactional 사용을 보자.

1
2
3
4
5
6
7
public class UserService {
@Transactional
public Long save(User user) {
// SQL 실행
return id;
}
}

이제 더이상 XML 설정도 필요 없고 try-catch, commit, rollback 같은 코드도 필요하지 않다.

하지만 다음 두 가지 설정을 해야한다.

  • Spring Configuration 중에 @EnableTransactionManagement가 적용된 Configuration이 있어야 한다.
    (Spring Boot에서는 자동으로 설정해준다.)
  • PlatformTransactionManager 빈이 등록되어 있어야 한다.

이 두 설정만 마치면 스프링은 자동으로 @Transactional이 있는 public 메소드에 대해 트랜잭션을 관리할 것이다.

즉, 스프링은 위의 코드를 아래와 같이 번역해 줄 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class UserService {
public Long save(User user) {
Connection connection;
try {
connection = dataSource.getConnection();
connection.setAutoCommit(false);
// SQL 실행
return id;
} catch (Exception e) {
connection.rollback();
} finally {
connection.close();
}
}
}

어떻게 이게 가능한지 이제 파헤쳐보자.

CGLIB, JDK 프록시

내가 작성한 UserServicesave() 메소드는 SQL을 실행하고 id를 리턴할 뿐인데 스프링이 내가 짠 코드를 변경할 방법은 없다.

그 과정에서 스프링은 꼼수를 부린다. 내가 만든 UserService만 객체로 만드는 것이 아니라 프록시 객체도 같이 생성한다.

그리고 스프링의 IoC 컨테이너는 UserService빈을 필요로 하는 다른 곳에 의존성으로 주입해줄 때 프록시를 대신 주입한다.

CGLIB 라이브러리는 UserService를 상속한 프록시 객체를 만들어준다. (JDK 프록시는 방식이 약간 다르긴 여기서 다루진 않겠다.)

1
2
3
4
5
6
7
8
9
public class UserController {

@Autowired
UserService userService;

public Long post(User user) {
return userService.save(user);
}
}

즉, UserController에서 UserServicesave() 메소드를 호출하면, 진짜 UserService가 호출되는 것이 아니라
가짜로 만든 프록시의 save()가 호출되는 것이다.

PlatformTransactionManager 역할

그렇다면 이 과정에서 PlatformTransactionManager는 왜 필요한 걸까?

생성된 프록시는 직접 트랜잭션을 컨트롤하지 않는다. 대신 PlatformTransactionManager에게 이런 임무를 맡겨버린다.

예를 들어 DataSourceTransactionManagerdoBegin() 메소드의 소스코드를 보면 우리가 초반부에 알아본 방법과
동일하다는 것을 알 수 있다.

DataSourceTransactionManager.java

1
2
3
4
5
6
7
8
9
10
11
12
13
public class DataSourceTransactionManager implements PlatformTransactionManager {
protected void doBegin(Object transaction, TransactionDefinition definition) {
Connection con;
try {
con = this.obtainDataSource().getConnection();
// ..
con.setAutoCommit(false);
// JDBC 트랜잭션 시작 방법과 동일
} catch (Exception e) {
// 생략
}
}
}

스프링이 구현한 DataSourceTransactionManager는 우리가 처음에 알아본 JDBC로 트랜잭션을 시작하는 방법과 완전히 똑같다.

요약

트랜잭션을 편리하게 할 수 있게 스프링이 우리에게 해준 것은 반복된 코드를 추상화해준 것이다.

  1. 스프링은 @Transactional이 붙은 메소드(또느 클래스)를 찾아서 프록시 클래스를 만든다.
  2. 프록시는 TransactionManager를 자체적으로 가지고 있고 트랜잭션 관리를 여기에 맡겨버린다.
  3. TransactionManager는 JDBC로 트랜잭션을 관리하는 방법과 동일하게 트랜잭션을 관리한다.

Spring Security 6.2.3 버전을 기준으로 작성했습니다.

Method Security란?

스프링 시큐리티를 처음 접할 때 기본적으로 다루는 인가는 요청 URI에 대한 인가인 것 같다.

1
2
3
4
5
6
7
8
9
10
11
@Bean
SecurityFilterChain web(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/user/**").hasAuthority("USER")
.requestMatchers("/admin/**").hasAuthority("ADMIN")
.anyRequest().authenticated()
)

return http.build();
}

위 코드처럼 빈을 만들고 requestMatchers()를 사용해서 API 엔드포인트에 인가를 적용하는 방식을 처음 접하게 된다.

규모가 작은 사이드 프로젝트에서는 해당 방식으로 인가를 처리해도 충분히 커버 가능하다.

그리고 도메인 권한이 중요하지 않으면 크게 문제가 없다.

하지만 도메인에 대한 권한을 엄격하게 관리하는 서비스이거나 대규모 프로젝트에서는

API 엔드포인트의 수도 늘어나기 때문에 엔드포인트만 검사하는 것만으로는 촘촘하게 권한을 검사하기 힘들 수 있다.

그래서 다음과 같은 경우에 Method Security를 사용하면 좋다:

  • 권한 체크 로직이 복잡해서 세분화되어야 할 경우
  • 서비스 레이어에서도 권한을 체크해야 할 경우
  • 애너테이션 방식의 코드 스타일과 AOP를 지향할 경우
API 레벨 메소드 레벨
권한 수준 촘촘하지 않음 촘촘함
설정 방법 Config 빈 메소드에 선언
설정 스타일 DSL 애너테이션
인가 표현식 Ant, 정규식 등 SpEL

메소드 인가Spring AOP 기반으로 작동한다.

즉, 메소드 호출의 beforeafter에 권한을 체크하고 싶은 경우 사용하면 된다.


메소드 시큐리티 활성화

메소드 레벨 인가를 활성화하기 위해서는 @Configuration이 설정된 빈에 @EnableMethodSecurity 를 추가해주어야 한다.

1
2
3
@EnableMethodSecurity
@Configuration
public class SecurityConfig {}

메소드 인가의 흐름

1
2
3
4
5
6
@Service
public class MyService {
@PreAuthorize("hasAuthority('permission:read')")
@PostAuthorize("returnObject.owner == authentication.name")
public Customer readCustomer(String id) { ... }
}

위와 같은 서비스가 있다고 가정하자.

그러면 readCustomer()라는 메소드에 대한 인가는 다음과 같은 흐름으로 적용된다.

  1. Spring AOPreadCustomer()의 프록시 메소드를 만들어서 감싼다.
  2. readCustomer()의 프록시 메소드에서는 AuthorizationManagerBeforeMethodInterceptor를 호출한다.
  3. 인터셉터PreAuthroizeAuthorizationManager#check()를 호출한다.
  4. AuthorizationManager@PreAuthorize안의 SpEL 표현식과 Authentication을 담은 컨텍스트를 인터셉터에 제공한다.
  5. 인터셉터는 컨텍스트에 담긴 Authentication과 표현식을 보고 권한 부여가 가능한지 체크한다.
  6. 권한 체크에 통과하면 AOP는 readCustomer()를 호출한다.
  7. 체크에 통과하지 못한다면 AccessDeniedException를 던진다.
  8. readCustomer()의 호출이 끝나고 리턴되면 프록시가 AuthorizationManagerAfterMethodInterceptor를 호출한다.
  9. 인터셉터PostAuthroizeAuthorizationManager#check()를 호출한다.
  10. @PostAuthorize안의 표현식이 통과하면 정상적인 흐름으로 종료된다.
  11. 마찬가지로 통과하지 못하면 AccessDeniedException을 던진다.

애너테이션

메소드 인가를 설정하는 방법은 여러가지가 있지만 애너테이션 방식이 제일 권장된다.

애너테이션에서 권한을 검증하는 로직을 작성할 때는 여러가지 방법이 있는데

이 내용은 별도의 문서로 정리하겠다.

@PreAuthorize

@PreAuthorize는 메소드 호출 전, 권한을 검증하고 싶을 때 사용한다.

1
2
3
4
5
6
@Component
public class BankService {
@PreAuthorize("hasRole('ADMIN')")
public Account readAccount(Long id) {
}
}

위 메소드는 호출하기 전에 Authentication 객체가 ROLE_ADMIN Role이 있는지 체크하고

Role이 없다면 AccessDeniedException이 던져진다.

MockUser를 사용해서 테스트를 작성할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Autowired
BankService bankService;

@WithMockUser(roles="ADMIN")
@Test
void readAccountWithAdminRoleThenInvokes() {
Account account = bankService.readAccount("1");
}

@WithMockUser(roles="USER")
@Test
void readAccountWithUserRoleThenAccessDenied() {
assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(
() -> this.bankService.readAccount("1"));
}

@PostAuthorize

@PostAuthorize는 메소드가 호출되고 리턴될 때 권한을 검증하고 싶을 때 사용한다.

1
2
3
4
5
6
7
8
@Component
public class BankService {
@PostAuthorize("returnObject.owner == authentication.name")
public Account readAccount(Long id) {
...
return account;
}
}

위 메소드는 리턴되는 Account 객체의 owner 프로퍼티가

Authentication 객체의 name 프로퍼티와 같을 때만 정상적으로 리턴되고

같지 않다면 마찬가지로 AccessDeniedException이 발생한다.

아래와 같이 테스트해볼 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Autowired
BankService bankService;

@WithMockUser(username="owner")
@Test
void readAccountWhenOwnedThenReturns() {
Account account = bankService.readAccount("1");
// ... assertions
}

@WithMockUser(username="wrong")
@Test
void readAccountWhenNotOwnedThenAccessDenied() {
assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(
() -> this.bankService.readAccount("1"));
}

@PreFilter

@PreFilter는 메소드 호출 전 파라미터로 전달되는 객체들을 인가를 통해 필터링하고 싶을 때 사용한다.

1
2
3
4
5
6
7
@Component
public class BankService {
@PreFilter("filterObject.owner == authentication.name")
public List<Account> updateAccounts(Account... accounts) {
return accounts;
}
}

위 메소드에서 파라미터로 전달되는 accounts 안에는

owner 프로퍼티가 Authenticationname 프로퍼티와 같은 객체만 필터링돼서 전달된다.

1
2
3
4
5
6
7
8
9
10
11
@Autowired
BankService bankService;

@WithMockUser(username="owner")
@Test
void updateAccountsWhenOwnedThenReturns() {
Account ownedBy = new Account("owner");
Account notOwnedBy = new Account("not owner");
List<Account> updated = bankService.updateAccounts(ownedBy, notOwnedBy);
assertThat(updated).containsOnly(ownedBy);
}

위 테스트 코드에서 updateAccounts()의 파라미터로 2개의 Account 객체를 전달했지만,

@PreFilter로 인해 실제 메소드 내부에 전달되는 파라미터는 ownedBy 객체만 전달된다.

@PreFilter로 필터링할 수 있는 파라미터 타입은 배열, Collection, Map, Stream이다.
(Stream은 닫히지 않은 상태여야 한다.)

1
2
3
4
5
6
7
8
9
10
11
@PreFilter
public void updateAccounts(Account[] accounts)

@PreFilter
public void updateAccounts(Collection<Account> accounts)

@PreFilter
public void updateAccounts(Map<String, Account> accounts)

@PreFilter
public void updateAccounts(Stream<Account> accounts)

@PostFilter

@PostFilter는 메소드가 리턴하는 객체들을 인가를 통해 필터링하고 싶을 때 사용한다.

1
2
3
4
5
6
7
@Component
public class BankService {
@PostFilter("filterObject.owner == authentication.name")
public Collection<Account> readAccounts(String... ids) {
return accounts;
}
}

위 코드는 리턴되는 accounts 컬렉션에서 객체들의 owner 프로퍼티가

Authenticationname 프로퍼티와 같은 객체만 필터링되어 리턴된다는 의미이다.

아래와 같은 테스트코드로 확인해볼 수 있다.

1
2
3
4
5
6
7
8
9
10
@Autowired
BankService bankService;

@WithMockUser(username="owner")
@Test
void readAccountsWhenOwnedThenReturns() {
Collection<Account> accounts = bankService.updateAccounts("owner", "not-owner");
assertThat(accounts).hasSize(1);
assertThat(accounts.get(0).getOwner()).isEqualTo("owner");
}

@PostFilter로 필터링할 수 있는 리턴 타입은 배열, Collection, Map, Stream이다.

1
2
3
4
5
6
7
8
9
10
11
@PostFilter
public Account[] readAccounts()

@PostFilter
public Collection<Account> readAccounts()

@PostFilter
public Map<String, Account> readAccounts()

@PostFilter
public Stream<Account> readAccounts()

주의사항

@PreFilter@PostFilter를 사용해서 사이즈가 큰 컬렉션을 필터링하는 작업은
메모리를 매우 많이 소모한다. 자칫 잘못하면 OOM이 발생할 수도 있다.
그렇기 때문에 사이즈가 큰 컬렉션을 가져와서 필터링하기보다는
데이터를 가져올 때 먼저 필터링해서 가져오는 방식을 택해야 한다.
즉, SQL이나 NoSQL로 데이터를 가져올 때부터 적절한 조건식으로 데이터를 먼저 필터링해야 한다.

기타 애너테이션

@Secured

@Secured@PreAuthorize의 레거시 버전이며 사용하려면

@EnableMethodSecurity(securedEnabled = true) 로 변경해야 한다.

JSR-250 애너테이션

JSR-250 애너테이션에는 @RolseAllowed, @PermitAll, @DenyAll 등이 있으며 사용하려면

@EnableMethodSecurity(jsr250Enabled = true) 로 변경해야 한다.

클래스 레벨 애너테이션

1
2
3
4
5
6
@Controller
@PreAuthorize("hasAuthority('ROLE_USER')")
public class UserApiController {
@GetMapping("/endpoint")
public String endpoint() { ... }
}

위와 같이 클래스 레벨로 애너테이션을 추가할 수 있고 해당 클래스의 모든 메소드에 적용된다.

1
2
3
4
5
6
7
@Controller
@PreAuthorize("hasAuthority('ROLE_USER')")
public class UserApiController {
@GetMapping("/admin")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
public String admin() { ... }
}

위처럼 예외로 다른 표현식을 사용해야할 경우에는 해당 메소드에 애너테이션을 오버라이드할 수 있다.

메타 애너테이션 (커스텀 애너테이션)

위에서 소개한 @PreAuthorize, @PostAuthorize 등의 애너테이션에 똑같은 표현식을

계속 적는 것은 분명 가독성과 유지보수 관점에서 좋지 않다.

이를 위해 메타 애너테이션이라 불리는 커스텀 애너테이션을 만들어서 대신 사용할 수 있다.

1
2
3
4
@Target({ ElementType.METHOD, ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasRole('ADMIN')")
public @interface IsAdmin {}

위처럼 애너테이션 인터페이스를 만들고

1
2
3
4
5
6
@Component
public class BankService {
@IsAdmin
public Account readAccount(Long id) {
}
}

와 같이 커스텀 애너테이션으로 @PreAuthorize("hasRole('ADMIN')")와 같은 효과를 낼 수 있다.

포인트컷(Pointcut)이란?

포인트컷은 Advice가 언제 실행될지 조인 포인트 중에서 골라내는 작업이라고 할 수 있다.

즉, 어드바이스가 어떤 메소드들에서 실행될지 골라내는 작업이다.

스프링 AOP에서는 스프링 빈의 메소드 실행에 대한 조인 포인트만 제공하기 때문에 스프링에서 포인트컷은 스프링 빈의 메소드와 연결된다고 볼 수 있다.


포인트컷 선언

포인트컷을 선언할 때는 다음 두 가지가 필요하다.

  1. 시그니쳐 (Signature)
  2. 포인트컷 표현식 (Pointcut Expression)

AspectJ에서 시그니쳐란 쉽게 말해 Java의 메소드이다. 이름과 파라미터로 식별 가능한 고유한 메소드이고 꼭 void 타입이어야 한다.

포인트컷 표현식이란 어떤 메소드가 실행될 때 이 애스펙트가 적용되어야 하는지 나타내는 표현식이다.

AspectJ에서 포인트컷은 다음과 같이 선언할 수 있다.

1
2
@Pointcut("execution(* transfer(..))") // 포인트컷 표현식
private void anyOldTransfer() {} // Signature

위 포인트컷의 시그니쳐는 anyOldTransfer라는 이름과 0개의 파라미터로 구성돼있다.

그리고 포인트컷 표현식은 @Pointcut("execution(* transfer(..))")가 된다.


스프링 AOP의 포인트컷 표현식

스프링 AOP에서 지원하는 AspectJ의 포인트컷 지정자 (Pointcut designator, PCD) 는 다음과 같다:

execution

어떤 메소드가 실행될 때인지를 지정한다.

execution([접근자] 타입 [클래스].이름(파라미터))

  • 접근자: public, private 등의 접근 제어자. 생략 가능.
  • 타입: 메소드의 리턴 타입.
  • 클래스: 패키지명을 포함한 클래스명. 생략 가능.
  • 이름: 메소드 이름.
  • 파라미터: 메소드의 파라미터 타입.
    • *: 모든 타입.
    • ..: 0개 이상의 모든 파라미터.
1
@Pointcut("execution(public String com.baeldung.pointcutadvice.dao.FooDao.findById(Long))")

예를 들어 위와 같은 포인트컷 표현식이 있다면

접근자는 public, 리턴 타입은 String, 클래스는 com.baeldung.pointcutadvice.dao.FooDao, 메소드 이름은 findById, 파라미터는 Long 타입 1개만 갖고 있는 메소드를 지정하는 것이다.

1
@Pointcut("execution(* com.baeldung.pointcutadvice.dao.FooDao.*(..))")

위 표현식에서는 접근자는 생략되었고 리턴 타입은 * 로 모든 타입이고, 클래스는 com.baeldung.pointcutadvice.dao.FooDao, 메소드 이름은 *로 모든 메소드이며 파라미터는 ..로 갯수와 타입에 상관없이 모든 메소드를 지정하게 된다.

within

어떤 타입 안에 있는 메소드인지 지정한다.

1
@Pointcut("within(com.baeldung.pointcutadvice.dao.FooDao)")

FooDao 내의 모든 메소드를 의미하고

1
@Pointcut("within(com.baeldung..*)")

com.baeldung 패키지와 그 하위의 모든 패키지의 모든 타입 안의 모든 메소드를 의미한다.

thistarget

thistarget은 AOP가 프록시를 생성할 때 CGLIB 방식인지 JDK dynamic 방식인지에 따라 선택해야 한다.

만약 IFooService라는 인터페이스가 있고 이를 구현한 FooServiceImpl 클래스가 있다고 가정해보자.

1
2
3
public class FooServiceImpl implements IFooService {
...
}

이 경우에는 스프링 AOP는 JDK dynamic 프록시를 사용하기 때문에 target 표현식을 사용해야 한다.

1
@Pointcut(target(com.xyz.IFooService))

A instanceof IFooService 가 true인 모든 A의 메소드를 의미한다.


만약 FooServiceImpl가 다음처럼 아무 인터페이스도 구현하지 않고 있다면,

1
2
3
public class FooServiceImpl {
...
}

이 경우에는 생성되는 프록시가 FooServiceImpl의 자식 클래스로 생성되기 때문에

1
@Pointcut(target(com.xyz.FooServiceImpl))

처럼 사용해야 한다.

args

메소드의 파라미터로 전달된 런타임 객체의 타입이 지정된 타입일 경우에 적용된다.

예를 들어 어떤 메소드의 파라미터로 String 타입이 전달될 때 적용하고 싶다면

1
@Pointcut("args(java.lang.String)")

execution과 차이점

execution(* *(java.lang.String))String 타입을 파라미터로 받는다고 선언된 메소드를 의미하고

args(java.lang.String)은 런타임에 전달된 파라미터의 타입이 String 타입인 메소드를 의미한다.

다음과 같이 두 메소드가 있는데 하나는 List 타입으로 선언돼있고 하나는 ArrayList 타입으로 선언돼있다고 가정하자.

1
2
3
public void method(List<String> list) {}

public void method2(ArrayList<String> arrayList) {}

그리고 만약 method(new ArrayList<>());를 호출한다고 하면

이 경우에 execution(* *(java.util.ArrayList))method2에만 적용되는 것이고

args(java.util.ArrayList)methodmethod2 둘 다에 적용되는 것이다.

@target

위에서 설명한 target과 다르다는 것을 주의해야 한다.

지정한 애너테이션을 갖고 있는 클래스의 모든 메소드를 의미한다.

1
@Pointcut("@target(org.springframework.stereotype.Repository)")

@args

위에서 설명한 args와 다르다는 것을 주의해야 한다.

메소드의 파라미터로 전달된 런타임 객체의 실제 타입이 지정된 애너테이션을 갖고 있는 클래스일 경우에 적용된다.

예를 들어 어떤 메소드의 파라미터로 @Entity가 붙은 클래스의 객체가 전달될 때 적용하고 싶다고 가정하면

1
2
@Pointcut("@args(com.baeldung.pointcutadvice.annotations.Entity)")
public void methodsAcceptingEntities() {}

로 지정하게 되면 @Entity가 붙은 객체가 전달되는 모든 메소드를 의미한다.

이렇게 지정한 포인트컷의 파라미터에 접근하려면 advice에서 JoinPoint 를 파라미터로 받아서 접근 가능하다.

1
2
3
4
5
@Before("methodsAcceptingEntities()")
public void logMethodAcceptionEntityAnnotatedBean(JoinPoint jp) {
Object entity = jp.getArgs()[0]; // @Entity가 선언된 객체
logger.info("Accepting beans with @Entity annotation: " + entity);
}

@within

within과 다르다는 점을 주의한다.

within과 거의 비슷하지만 @within은 지정한 애너테이션을 갖고 있는 타입의 모든 메소드를 의미한다.

1
@Pointcut("@within(org.springframework.stereotype.Repository)")

는 아래와 동일한 표현식이다.

1
@Pointcut("within(@org.springframework.stereotype.Repository *)")

@annotation

지정된 애너테이션을 갖고 있는 메소드에 적용된다.

@within은 지정된 애너테이션을 갖고 있는 타입의 모든 메소드에 적용되는 것이고 @annotation은 지정된 애너테이션을 갖고 있는 메소드에 적용되는 차이점이 있다.

예를 들어 @Loggable이라는 애너테이션을 만들어서 메소드에 추가한다면

@Loggable이 붙은 모든 메소드에 대해 아래와 같이 적용할 수 있다.

1
2
3
4
5
6
7
8
@Pointcut("@annotation(com.baeldung.pointcutadvice.annotations.Loggable)")
public void loggableMethods() {}

@Before("loggableMethods()")
public void logMethod(JoinPoint jp) {
String methodName = jp.getSignature().getName();
logger.info("Executing method: " + methodName);
}

bean

이 지정자는 AspectJ에는 없는 지정자로 스프링 AOP에서 자체 지원한다.

지정한 빈의 모든 메소드를 의미한다. *를 통한 와일드카드 표현식을 지원한다.

이외의 PCD

스프링 AOP에서는 위에서 소개한 포인트컷 지정자 이외에 다른 AspectJ 지정자를 사용하면 IllegalArgumentException이 발생한다.


포인트컷 조합

포인트컷은 적용될 대상 메소드를 집합처럼 나타내는 표현식이므로 여러 포인트컷을 조합해서 집합처럼 사용할 수 있다.

사용 가능한 연산자는 다음과 같다.

  • && : 두 포인트컷의 교집합
  • || : 두 포인트컷의 합집합
  • ! : 여집합

포인트컷 공유

규모가 큰 애플리케이션을 개발할 때는 자주 사용되는 공통 포인트컷을 묶어서 하나의 클래스로 만들 것을 추천한다.

예를 들면, 아래와 같이 CommonPointcuts라는 클래스를 만들고

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.xyz;

import org.aspectj.lang.annotation.Pointcut;

public class CommonPointcuts {

/**
* dao 패키지 밑의 모든 클래스의 모든 메소드에 적용
*/
@Pointcut("within(com.xyz.dao..*)")
public void inDataAccessLayer() {}

/**
* service 패키지 하위에 있는 모든 타입의 모든 메소드 지정.
*
* 만약 Service 빈 이름이 모두 ~Service로 끝난다면
* bean(*Service)로 지정해도 좋다.
*/
@Pointcut("execution(* com.xyz..service.*.*(..))")
public void businessService() {}

}

Advice를 만들 때 공통 포인트컷을 사용할 수 있다.

1
2
3
4
@Before("com.xyz.CommonPointcuts.businessService()")
public void beforeBusinessServiceMethods() {
...
}

포인트컷 잘 작성하기

어떤 메소드가 해당 포인트컷에 적합한지 찾아내는 것은 비용이 많이 드는 작업이다.

특히 동적으로 생성된 빈의 메소드는 더욱더 비용이 많이 든다.

그래서 AspectJ는 컴파일할 때 포인트컷을 모두 찾아내서 검사하기 쉬운 메소드 순서로 정렬한다.

좋은 포인트컷을 작성하기 위해서는 이 포인트컷의 목표를 생각하고 최대한 메소드 집합의 범위를 좁혀야 한다.

포인트컷 지정자(PCD)는 다음 세 그룹으로 분류할 수 있다.

  • Kinded
    • 특정 조인포인트(메소드)만 선택 : execution
  • Scoping
    • 조인포인트의 그룹을 선택 : within
  • Contextual
    • 문맥에 따라 다르게 선택 : this, target, @annotation

포인트컷을 잘 작성하기 위해서는 최소한 kindedscoping을 포함해야 한다.

Kinded 지정자만 선언하거나 Contextual 지정자만 선언한 포인트컷은 작동은 하지만 시간, 메모리 효율성에서 성능에 영향을 줄 수 있다.

Scoping 지정자는 특정 그룹만 대상으로 하면 되기 때문에 조인포인트를 찾아내는 게 매우 빠르다.

즉, 잘 작성된 포인트컷은 Scoping 지정자를 포함한 포인트컷이다.

가상 스레드 도입 배경

이전부터 Project Loom에 대해서 관심이 많았다.

지금까지 자바 서버는 요청 트래픽이 몰리는 상황에서 값비싼 컨텍스트 스위치 비용을 지불해왔다.


이를 해결하기 위해 다양한 시도를 해왔다.

  • Reactive Streams와 같은 비동기 API
  • 코루틴을 자바에 추가하기

Spring WebFlux는 Reactive API를 훌륭하게 구현해서 스레드가 부족한 환경에서 성능을 개선했다.

또, JVM 진영의 코틀린에서 coroutine을 통해 메소드를 비동기적으로 호출할 수 있게 지원해줬다.


하지만 위의 시도들은 다음과 같은 이유로 자바의 표준이 되지 못했다

  • 웹플럭스는 요청을 처리하는 순서가 보장되지 않아 디버깅을 단계별로 진행할 수 없고, 처리되는 스레드가 다를 수 있어 stack trace도 제공할 수 없었다.
  • 자바 플랫폼에 코루틴 API를 적용하려면 엄청난 대규모 작업이 될 수 밖에 없다. 기존의 스레드 API를 사용해 개발했던 내용은 모두 걷어내야 한다.

코루틴을 사용하려면 코틀린을 사용하면 되지만, 여전히 자바를 사용하고 싶은 사람은 코루틴도 해결책이 아니었다.

이 때문에 Project Loom은 자바 방식으로 해결하기 위해 3가지 기능을 도입하기로 하는데 그 중 첫 번째가 가상 스레드(Virtual Thread)이다.


가상 스레드란?

가상 스레드는 한마디로 JVM에 의해 관리되는 경량 스레드이다.

기존의 JVM 스레드는 플랫폼 스레드(Platform Thread)라고 부르며 이는 OS 스레드를 래핑(Wrapping)한 스레드여서 OS 스레드와 일대일 관계였다.

플랫폼 스레드와 차이

플랫폼 스레드는 다음과 같은 단점이 있다.

  • OS 스레드의 래퍼로 구현하기 때문에 사용가능한 스레드 수가 제한됨
  • OS에 의해 생성되고 스케줄링되기 때문에 비용이 비싸고 컨텍스트 스위칭 비용도 비싸다

이에 비해 가상 스레드는 JDK에서 구현하고 제공하는 user-mode 스레드이다.

가상 스레드는 특정 OS 스레드에 연결되어 있지 않고 M:N 스케줄링을 사용한다. 가상 스레드의 수(M)가 더 적은 수(N)의 OS 스레드에서 실행되게 예약한다.

가상 스레드는 CPU에서 계산을 수행할 때만 OS 스레드를 사용한다.

가상 스레드에서 blocking I/O 작업을 시작할 때 자바는 non-blocking OS를 호출하고 가상 스레드를 임시로 중단한다.

기존 스레드 모델과 성능 비교

간단한 애플리케이션을 만들어서 성능 비교를 해보았다.

스프링 부트 버전

  • Spring Boot 3.2.1

성능 테스트 도구

  • Apache JMeter

테스트 환경 (VM)

  • Centos7 x86_64
  • CPU 1 core 1 Thread
  • 1GB Memory
  • JDK 21

요청을 받으면 300ms동안 sleep하고 리턴하는 간단한 API를 만들었다.

그리고 현재 스레드가 가상 스레드인지 확인하기 위해 Thread.currentThread().isVirtual을 사용하여 로그도 남겼다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.example.study.controller

import org.slf4j.LoggerFactory
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController

class UserController {

private val log = LoggerFactory.getLogger(this.javaClass)

@GetMapping
fun get(): String {
log.info("VT: {}", Thread.currentThread().isVirtual)
Thread.sleep(300)
return "ok"
}
}

그리고 톰캣이 요청을 처리할 때 가상 스레드를 사용할 수 있게 Bean을 등록해주었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.example.study.config

import org.apache.coyote.ProtocolHandler
import org.springframework.boot.web.embedded.tomcat.TomcatProtocolHandlerCustomizer
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.util.concurrent.Executors

@Configuration
class ThreadConfig {
@Bean
fun virtualThreadExecutorCustomizer(): TomcatProtocolHandlerCustomizer<*> {
return TomcatProtocolHandlerCustomizer { protocolHandler: ProtocolHandler ->
protocolHandler.executor = Executors.newVirtualThreadPerTaskExecutor()
}
}
}

이 Bean 설정은 Spring Boot 3.2 버전 이상에서는 application.properties에서
spring.threads.virtual.enabled=true를 설정하면 자동으로 생성된다.

테스트 결과

처음에 가상유저를 100으로 했을 때는 플랫폼 스레드와 가상 스레드의 차이가 없었다.

이는 기본 스레드 풀 200개가 활성화되어서 별 차이가 없는 것 같았다.

500 가상 유저

가상 스레드는 최대 스레드 풀을 상회하는 요청이 들어왔을 때 진가를 발휘했다.

기존 스레드

TPS

응답시간

기존 스레드 모델에서는 650 ~ 700 TPS를 맴돌았고 평균 응답시간은 750ms였다.

가상 스레드

TPS

응답시간

가상 스레드로 실행한 서버는 1400 ~ 1700 TPS를 기록했고 평균 응답시간은 초반을 제외하면 300 ~ 400ms 정도였다.

1000 가상 유저

이번엔 훨씬 더 많은 유저를 가정하고 테스트해보았다.

기존 스레드

TPS

응답시간

기존 스레드의 TPS는 가상 유저가 500일 때와 별다르지 않았고 응답시간은 거의 2배가 되었다.

가상 스레드

TPS

응답시간

가상 스레드로는 2400 ~ 3000 TPS를 기록했고 평균 응답시간은 300 ~ 400ms였다.

확실히 기존 플랫폼 스레드만을 사용했을 때보다 유의미하게 성능이 개선되는 것을 확인할 수 있었다.

가상 스레드 주의사항

이렇게 좋아보이기만 하는 가상 스레드이지만 주의해서 사용하지 않으면 오히려 사용하지 않는 것보다도 못한 경우가 될 수 있다.

  • 풀링하지 말 것
    • 가상 스레드는 생성하는 비용은 적지만 하나의 작업을 실행하고 GC에 의해 제거되게 설계되었으므로 풀링해놓고 계속 사용하는 것이 더 낭비가 된다.
    • 톰캣의 스레드 풀 설정을 해제하고 Executors.newVirtualThreadPerTaskExecutor()를 설정해준다.
    • 스레드 풀을 사용했던 코드는 세마포어를 사용하게 변경해야 한다.
  • 스레드로컬(ThreadLocal) 사용에 주의할 것
    • 스레드 간에 리소스를 공유했던 스레드로컬(ThreadLocal)을 사용하는 대신 글로벌 캐싱 전략을 사용하도록 변경한다.
      • 예를 들면 스레드로컬에 DB 커넥션을 저장해두고 사용하는 방식이 있었다면 가상 스레드 환경에서는 성능이 저하될 수 있다.
      • 기존의 스레드 로컬을 보완하기 위한 Scoped Values 역시 Project Loom이 준비 중인 핵심 기능이다.
  • CPU 연산이 필요한 코드는 실행하지 말 것
    • 가상 스레드는 non-blocking I/O 작업 처리량을 늘리기 위해 설계된 스레드로써 CPU 연산 성능은 기존 플랫폼 스레드보다 떨어진다.
    • 데이터베이스 I/O, 네트워킹 같은 작업을 가상 스레드에게 맡기는 것이 효율적일 것이다.
  • synchronized 또는 native method을 호출하지 말 것
    • 가상 스레드가 해당 함수를 만나면 캐리어 스레드(연결된 플랫폼 스레드)에 고정(pinned)되어 block하고 마운트를 해제할 수 없게 된다.
    • 플랫폼 스레드로 실행해도 마찬가지이지만 가상 스레드가 플랫폼 스레드에 pinned 되면 해당 OS 스레드도 block 상태가 된다.
    • 자주 사용되지 않거나 인메모리 작업을 보호하는 synchronized 까지 제거할 필요는 없다. 하지만 자주 호출되고 오랫동안 고정시키는 코드 블럭은 ReentrantLock을 사용하는 것을 고려한다.

문제

시침, 분침, 초침이 있는 아날로그시계가 있습니다. 시계의 시침은 12시간마다, 분침은 60분마다, 초침은 60초마다 시계를 한 바퀴 돕니다. 따라서 시침, 분침, 초침이 움직이는 속도는 일정하며 각각 다릅니다. 이 시계에는 초침이 시침/분침과 겹칠 때마다 알람이 울리는 기능이 있습니다. 당신은 특정 시간 동안 알람이 울린 횟수를 알고 싶습니다.

다음은 0시 5분 30초부터 0시 7분 0초까지 알람이 울린 횟수를 세는 예시입니다.

가장 짧은 바늘이 시침, 중간 길이인 바늘이 분침, 가장 긴 바늘이 초침입니다.
알람이 울리는 횟수를 세기 시작한 시각은 0시 5분 30초입니다.
이후 0시 6분 0초까지 초침과 시침/분침이 겹치는 일은 없습니다.

약 0시 6분 0.501초에 초침과 시침이 겹칩니다. 이때 알람이 한 번 울립니다.
이후 0시 6분 6초까지 초침과 시침/분침이 겹치는 일은 없습니다.

약 0시 6분 6.102초에 초침과 분침이 겹칩니다. 이때 알람이 한 번 울립니다.
이후 0시 7분 0초까지 초침과 시침/분침이 겹치는 일은 없습니다.
0시 5분 30초부터 0시 7분 0초까지는 알람이 두 번 울립니다. 이후 약 0시 7분 0.584초에 초침과 시침이 겹쳐서 울리는 세 번째 알람은 횟수에 포함되지 않습니다.


초기 접근 방법과 시행착오

처음에는 초기 시침, 분침, 초침의 각도를 설정하고 초를 1초씩 증가하면서 변경되는 초침의 각도와 분침의 각도, 시침의 각도를 비교해서

현재 초침의 각도 ≤ 현재 분침의 각도 ≤ 1초 뒤 분침의 각도 ≤ 1초 뒤 시침의 각도

를 만족하면 케이스를 하나 증가시키는 방법으로 접근했다.

그래서

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public int solution(int h1, int m1, int s1, int h2, int m2, int s2) {
// 원래 각도에 120을 곱하자
int hourDegree = h1 % 12 * 30 * 120 + m1 * 60 + s1;

int minDegree = m1 * 6 * 120 + s1 * 12;

int secDegree = s1 * 6 * 120;

int answer = 0;

while (true) {
if (secDegree <= minDegree && minDegree + 12 < secDegree + 720) {
answer++;
}

if (secDegree <= hourDegree && hourDegree + 1 < secDegree + 720) {
answer++;
}

if (secDegree == minDegree && minDegree == hourDegree) {
answer--;
}

s1++;
if (s1 == 60) {
s1 = 0;
m1++;
}
if (m1 == 60) {
m1 = 0;
h1++;
}
if (h1 == h2 && m1 == m2 && s1 == s2)
break;
secDegree = s1 * 6 * 120;
minDegree = m1 * 6 * 120 + s1 * 12;
hourDegree = h1 % 12 * 30 * 120 + m1 * 60 + s1;
}

return answer;
}

이렇게 접근했더니 11시 59분 30초부터 12시 0분 0초까지 돌아갈 때 답이 맞지 않았다.


문제 해결

도저히 풀기 어려운 와중에 수학적인 접근법을 제시해 본 사람이 있었다.

알고리즘

  1. 0시 0분 0초를 기준으로 H시 M분 S초까지의 총 시간을 초 단위로 구한다
  2. 해당 시간동안 초침이 시침과 만나는 횟수를 구한다
  3. 해당 시간동안 초침이 분침과 만나는 횟수를 구한다
  4. 위 (2)와 (3)을 더한다
  5. 시침, 분침, 초침이 모두 겹치는 0시 0분 0초와 12시 0분 0초가 포함되면 1을 뺀다
  6. (h2시 m2분 s2초까지 횟수) - (h1시 m1분 s1초까지 횟수) = 최종 정답

초침과 시침 만나는 횟수 구하기


0시 0분 0초에 모두 겹쳐있는 상태에서 출발한다고 가정해보자.

초침은 1초에 6도를 돈다.

시침은 1시간에 30도를 돌아가고 1시간은 3600초이므로 시침은 1초 동안 30/360030 / 3600 도 즉 11201 \over 120 도 돌아간다.

초침이 1바퀴를 돌아 제자리로 온 뒤에 다시 움직여서 시침과 만나는 시간을 식으로 세우면

6t360=1120t 6t - 360 = {1 \over 120}t

여기서 좌변은 t초 후의 초침의 각이고 우변은 t초 후의 시침의 각이다.

식을 정리하면,

719t=43200719t = 43200

이므로 t=43200719t = {43200 \over 719} 초 후에 초침과 시침이 만난다는 것을 알 수 있다.

초침과 시침이 만난 후에는 다시 둘이 겹쳐있는 상태이므로 또 4320071943200 \over 719초 후에 만나게 되므로, 초침과 시침이 만나는 주기는 43200719{43200 \over 719}초가 된다.

그러면 t초 동안 만나는 횟수는 t를 4320071943200 \over 719로 나누면 되는 것이다.

초침과 분침 만나는 횟수 구하기

분침은 1초에 1101\over10도 씩 움직인다.

시침과 마찬가지로 식을 세우면

6t360=110t6t - 360 = {1\over10}t

이므로 정리하면

59t=360059t = 3600

이 되어서 초침과 분침은 3600593600\over59초마다 한번 씩 만나게 되는 걸 알 수 있다.


코드로 옮기기

총 시간을 구하는 함수와 해당 시간동안 분침, 시침과 만나는 횟수를 구하는 함수를 따로 분리하였다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* h시 m분 s초 까지 총 시간(초)
*/
int totalSec(int h, int m, int s) {
return 3600 * h + 60 * m + s;
}

/**
* 0시 0분 0초부터 h시 m분 s초 까지의 알람 횟수
* 0시 0분 0초에 울리는 알람은 제외됨.
*/
int countAlarm(int h, int m, int s) {
int totalSec = totalSec(h, m, s);

// 초침과 분침은 3600/59초마다 만남
int minAlarm = totalSec * 59 / 3600;

// 초침과 시침은 43200/719초마다 만남
int hourAlarm = totalSec * 719 / 43200;

// 12시 0분 0초 이상인 경우에는 시침 분침 초침이 겹치므로 1회 빼야함
int exception = totalSec >= 43200 ? 1 : 0;

return minAlarm + hourAlarm - exception;
}

그리고 문제에서 시작 시간이 0시 0분 0초일 때 1회 알람이 울린다고 설명이 있으므로

알고리즘에서 세웠던 식에 추가적으로 작업이 필요했다.

최종 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Solution {
public int solution(int h1, int m1, int s1, int h2, int m2, int s2) {
// 시작 시간이 0시 0분 0초이거나 12시 0분 0초인 경우 시작하자마자 1회 울리므로 예외 처리
int exception = (h1 % 12 == 0 && m1 == s1 && s1 == 0) ? 1 : 0;
return countAlarm(h2, m2, s2) - countAlarm(h1, m1, s1) + exception;
}

/**
* h시 m분 s초 까지 총 시간(초)
*/
int totalSec(int h, int m, int s) {
return 3600 * h + 60 * m + s;
}

/**
* 0시 0분 0초부터 h시 m분 s초 까지의 알람 횟수
* 0시 0분 0초에 울리는 알람은 제외됨.
*/
int countAlarm(int h, int m, int s) {
int totalSec = totalSec(h, m, s);

// 초침과 분침은 3600/59초마다 만남
int minAlarm = totalSec * 59 / 3600;

// 초침과 시침은 43200/719초마다 만남
int hourAlarm = totalSec * 719 / 43200;

// 12시 0분 0초 이상인 경우에는 시침 분침 초침이 겹치므로 1회 빼야함
int exception = totalSec >= 43200 ? 1 : 0;

return minAlarm + hourAlarm - exception;
}
}

느낀점

사실 수학적인 풀이는 다른 사람의 풀이를 약간 참고하였는데

알고리즘 문제 풀이를 할 때는 역시 프로그래밍적 구현도 중요하지만 수학적인 접근 방법을 먼저 생각해보는 것이 좋은 것 같다.

Heap Size

일반적으로 메모리의 50%를 힙으로 할당하는 것을 권장하지만 힙 사이즈가 32G 이상으로 커질 경우 단점이 있다.

힙 사이즈가 크다고 무조건 성능이 좋아지는 것이 아니다. 오히려 32G 이상의 힙 사이즈는 성능을 저하시킨다고 한다.

이건 비단 ES 뿐만 아니라 JVM을 사용하는 모든 앱이라면 공통으로 적용되는 점이다.

이유

JVM은 OOPS(Ordinary Object Pointers) 라는 것으로 메모리의 주소를 관리한다. C의 포인터랑 비슷한 개념이다.

예전 32bit OS의 경우 메모리가 4G이므로 메모리 주소 찾는 게 그렇게 오래 걸리지 않았다.

하지만 64bit인 경우 메모리 주소를 찾는데 너무 오래 걸리므로 JVM은 Compressed OOPS라는 기법을 사용하여 메모리 주소를 관리하기로 한다.

원리는 32bit 주소에 offset bit를 3비트 추가하여 32bit * 8 개의 메모리 주소를 참조할 수 있게 하는 것으로 대략 32GB의 주소값까지 표현할 수 있게 된다.

하지만 힙 사이즈가 32G보다 커질 경우 이 Compressed OOPS 방식을 사용하지 않고 그냥 OOPS를 사용하여 메모리 주소를 찾는데 매우 오래 걸리게 된다.

Compressed OOPS 방식의 35bit 주소에서 offset 3bit를 버리기 위해 shift left 3 ( << 3 ) 연산을 하게 되는데 이때 메모리 주소의 시작점이 0이 아니라면 여기에 메모리 시작 주소를 더해야 하는 연산이 추가로 들어간다. (Non-zero based)

하지만 메모리 주소의 시작점이 0이라면 shift left 3 (<< 3)만 해도 원래 주소를 알아낼 수 있다. (Zero based)

이는 JVM으로 oops 방식을 확인하는 명령어로 확인해볼 수 있다.

  • 메모리가 64G인 시스템에서 힙을 31G로 준다면?
    1
    java -Xmx31G -XX:+UnlockDiagnosticVMOptions -Xlog:gc+heap+coops=debug -version
    1
    Heap address: 0x0000001000800000, size: 31744 MB, Compressed Oops mode: Non-zero disjoint base: 0x0000001000000000, Oop shift amount: 3

이런 식으로 base 주소가 0이 아님을 확인할 수 있고 Non-zero base라고 표시된다.

  • 이번엔 힙을 30G로 준다면
    1
    java -Xmx30G -XX:+UnlockDiagnosticVMOptions -Xlog:gc+heap+coops=debug -version
    1
    Heap address: 0x0000000080000000, size: 30720 MB, Compressed Oops mode: Zero based, Oop shift amount: 3

Zero based 모드라고 표시되는 것을 확인할 수 있다.

그리고 ES는 OS의 나머지 여유 메모리를 파일시스템 캐시로 이용하기 때문에 이 캐시의 크기를 더 늘리는 것이 더 좋은 성능을 낼 수 있다고 한다.


즉, OS의 메모리 절반을 힙 사이즈로 할당하면 나머지 절반은 ES가 캐시로 사용한다는 얘기.

또 다른 이유로는 힙 사이즈가 클 경우 gc가 빈번하게 일어나는 앱에서는 힙에 비례하여 cpu 사용량이 증가하고 stop the world 시간이 늘어나기 때문에 너무 큰 힙 사이즈는 좋지 않을 수 있다.

이건 JVM 애플리케이션의 공통 사항이다.


Mapping

인덱스를 생성하기 전에 인덱스 템플릿을 생성하고 매핑을 적절히 해주는 것이 좋다.

특히 string 필드는 ES에서 기본적으로 text와 keyword 타입을 둘다 생성하기 때문에 용량을 낭비하고 검색 성능이 저하된다.

따라서 텍스트 분석이 필요하고 검색 기능이 필요한 필드는 text만 매핑하고 나머지 필터링을 위한 필드는 keyword로 매핑하여 불필요한 연산이나 용량을 제거해주는 것이 좋다.

하지만 반대로 Mapping에 대해 자세히 이해하지 못하고 획일화된 Mapping만 사용한다면 NoSQL의 장점을 활용하지 못하게 된다.

인덱스의 Mapping을 변경하는 작업은 그렇게 어려운 일이 아니므로 조금씩 공부하면서 변경하는 것도 좋다.


Shard

샤드는 기본적으로 인덱스 1개와 매핑되어 있고 샤드의 갯수는 인덱스 1개당 1개 이상이다. (1대다 관계)

노드가 여러 개인 경우 샤드를 분산배치함으로써 클러스터링, 로드 밸런싱 등이 가능해진다.

샤드의 갯수

ES에서는 노드 1개당 샤드의 갯수가 너무 많으면 좋지 않다고 한다.

샤드의 갯수가 너무 많으면 마스터 노드가 샤드를 추적할 때 연산량이 많아져 성능이 저하된다.

일반적으로 Heap 사이즈 1GB 당 20개 미만 (예. Heap 8GB -> 샤드 160개) 으로 하는 것이 좋지만 이보다 더 적게 할 것을 권장한다고 한다.

샤드의 용량

샤드 1개의 용량은 약 20GB~40GB 정도로 유지하는 것이 좋다고 한다.

샤드에는 Lucene 엔진 인스턴스로서 정보를 보관하고 있는 세그먼트가 있다.

세그먼트는 데이터가 많을수록 커지기는 하는데 중요한 점은 데이터가 적을 때의 세그먼트와 데이터가 많을 때 세그먼트 크기는 별로 차이나지 않는 것이다.

이 말은, 샤드에 적재된 데이터가 적을 때 1GB 당 오버헤드가 데이터가 많을 때 1GB 당 오버헤드보다 월등히 크다는 것을 말한다.

이 때문에 50MB 용량의 샤드 1000개에서 쿼리하는 것보다 50GB의 샤드 1개에서 쿼리하는 것이 대부분의 경우 훨씬 더 빠르다.

50MB의 샤드와 50GB 샤드의 세그먼트 크기는 별 차이가 없는데, 전자는 1000개의 세그먼트를 합치는 과정에서 엄청난 리소스를 필요로 하기 때문이다.

그래서 이미 샤드의 갯수가 많아졌을 경우 강제 병합 과정을 통해서 세그먼트를 병합해 오버헤드를 줄여 성능을 개선할 수 있다.

하지만 또한, 50GB 이상으로 너무 크게 하지 않을 것을 권장하고 있다.


ElasticSearch >= 8.3 는 다르다!

해당 프로젝트를 진행한 날짜가 2022년이고 당시에 ES 7.10 버전을 기준으로 작성했었는데, 최근 ES 문서를 확인해보니까 8.3 버전 이상부터는 **샤드의 갯수 규칙 (힙 1GB 당 20개 미만)**이 다르다고 한다!

새로운 팁

8.3 버전 이상에서 ES의 경험 법칙에 따르면 각 데이터 노드의 인덱스 당 매핑된 필드 수 X 1kB + 0.5GB 를 권장한다고 한다.

예를 들어서, 데이터 노드가 1000개의 인덱스 샤드를 보유하고 있고, 각 인덱스가 4000개의 필드가 매핑되어 있다면

1000 x 4000 x 1kB = 4GB추가로 0.5GB를 할당해 4.5GB 이상의 힙 크기를 할당해줄 것을 권장하고 있다.

또, 샤드 당 데이터의 크기는 10GB~50GB를 권장하고 있다.

페이징 쿼리 파헤쳐 보기 - 1. Row count까지 같이 조회

사내 관리자 페이지를 개발하면서 페이징 처리할 데이터와 페이징 Row count를 한 번에 조회할 수 있는 방법을 고민했던 내용을 정리해본다

OVER () 사용

StackOverflow에 이 내용으로 물어보면 가장 처음 나오는 결과가 OVER () 를 사용하는 방법이다.

1
2
3
4
5
6
7
SELECT ...
, total_count = COUNT(*) OVER()
-- 또는, COUNT(*) OVER() AS total_count
FROM ...
ORDER BY ...
OFFSET 120 ROWS
FETCH NEXT 10 ROWS ONLY;

Initial log…

  • 지금까지의 나는
    • 무계획
    • 즉흥적
    • 머리로, 속으로 기억하고 있으면 된 것
  • 앞으로의 나는
    • 꾸준하게
    • 기록하고
    • 단기 목표를 세우고
    • 널리 퍼뜨리는

2024년, 달라지기 위해 첫 로그를 적는다

‘나 안내서’를 작성하고
‘느낀 점’을 기록하자
누구에게 보여주기 위한 것이 아닌 나를 위해서

0%