Spring Boot Auto Configuration 예제를 보다가 maven은 많은데 나한테 익숙한 gradle의 예제는 몇 개 안되어서 gradle로 한 번 만들어 보았다.

  1. contact-spring-boot-starter 프로젝트를 clone후에 build 한다.
    • 만든 jar파일을 어떻게 dependency를 추가해야 할까 잠시 고민했는데 maven local repository를 사용하면 간단했다.
  2. gradle install로 jar파일을 local repository에 올린다.
    • maven plugin을 추가하면 gradle install로 local repository에 올라간다.
    • maven local은 내 계정 폴더에 .m2 폴더이다.
    • 같은 버전으로 install을 계속하다 보면 갱신이 안될 수도 있다. 그럴 때는 .m2폴더를 지워야 할 때도 있었다.
  3. AutoConfigurationTest 프로젝트를 clone후에 build 한다.
  4. gradle bootrun으로 실행한다.
  5. AppConfig에 MyContact Bean을 생성한 것과 안 한 것과의 차이를 확인해 본다.
    • @ConditionalOnMissingBean에 의해서 MyContact를 만들면 만들어진 bean의 내용이 나오고 MyContact bean이 없다면 properties의 내용이 나온다.

'Programming > Spring Framework' 카테고리의 다른 글

2way ssl 인증을 위한 JKS 만들기  (0) 2020.03.10
코딩 시험과 TDD  (0) 2019.02.14
Annotation-based Controller  (0) 2017.02.01

지금 운영되는 서버에 인증서가 만료가 되었다. 서로 다른 도메인의 서버끼리 2way 인증을 하는데는 서로의 certification을 가지고 있어야 한다. Java Key Store는 key pair와 trusted certificate를 담을 수 있는 그릇이다.

먼저 갱신한 인증서를 확인해보자. 

server측 인증서
Client측 인증서

서버쪽은 CA인증서로부터 발급받은 key pair이고, 클라이언트는 self-signed key pair이다. 이 상태로는 2way ssl 인증이 안된다. 서로의 certification을 교환해서 가지고 있어야 된다.

 

x.509형식의 .cer파일을 클라이언트 JKS에 import하자.

클라이언트 JKS 최종 결과

마지막 그림처럼 Trusted Certificate로 server의 certificate가 추가 되었다.

그럼 반대로 클라이언트 인증서를 서버 JKS에 추가하는 것도 같은 방식으로 진행하면 된다.

.cer 파일로 추출된 클라이언트 인증서를 서버JKS에 trusted 인증서로 등록하자.

서버 JKS 최종 결과

이제 서버에서 JKS 관련 설정을 해보자. application.yml에서 아래와 같이 설정한다.

application.yml

설정 키 값만 보더라도 뭘 의미 하는지는 알 수 있을거라 믿는다. 이 설정 중에 주목해야 할 것이 하나 있는데 바로 server.ssl.client-auth : need 항목이다. 이 설정이 있어야 약속된 인증서(Trusted Certificate)를 들고 접속하는 클라이언트인지 아닌지 체크를 하게 되어있다. 역시 spring boot... 설정만으로 mutual ssl 인증이 동작하는 구나...

참고 사이트 : https://www.naschenweng.info/2018/02/01/java-mutual-ssl-authentication-2-way-ssl-authentication/

 

Java mutual SSL authentication / 2-way SSL authentication

Despite SSL being widely used, Java mutual SSL authentication (also referred to as 2-way SSL authentication or certificate based authentication) is a fairly simple implementation when understanding the key concepts of how mutual SSL authentication works. T

www.naschenweng.info

openssl을 이용한 script에 대한 내용은 많아서 Keystore Explorer를 이용하여 캡쳐를 만들어 보았다. 하지만 개인적으로는 (git 사용도 마찬가지로) GUI보다 CLI가 더 안정적이고 풍부한 기능을 제공하니 되도록 CLI를 사용하는 버릇을 들이는게 좋을 듯 하다.

'Programming > Spring Framework' 카테고리의 다른 글

Spring Boot Auto Configuration 예제  (0) 2020.04.21
코딩 시험과 TDD  (0) 2019.02.14
Annotation-based Controller  (0) 2017.02.01

@Entity

@Entity 어노테이션은 데이타베이스의 테이블과 일대일로 매칭되는 객체 단위이며 Entity 객체의 인스턴스 하나가 테이블에서 하나의 레코드 값을 의미합니다. 그래서 객체의 인스턴스를 구분하기 위한 유일한 키값을 가지는데 이것은 테이블 상의 Primary Key 와 같은 의미를 가지며 @Id 어노테이션으로 표기 됩니다.

먼저 Spring Boot 를 설정할때 spring.jpa.hibernate.ddl-auto 설정이 create 혹은 update 로 되어 있을 경우 Spring 프로젝트가 시작될때 EntityManager 가 자동으로 DDL 을 수행해 테이블을 생성해 줍니다.

이때 명시적으로 @Table  name 속성을 이용해 데이타베이스상의 실제 테이블 명칭을 지정하지 않는다면 Entity 클래스의 이름 그대로 CamelCase 를 유지한채 테이블이 생성이 되기 때문에 테이블 이름을 명시적으로 작성하는 것이 관례입니다. 왜냐하면 데이타베이스상에서 보편적으로 사용 되는 명명법은 UnderScore 가 원칙이기 때문입니다. 

@Entity
@Table(name = "ORGANIZATION")
public class Organization { 
    ...
}

@Entity
@Table(name = "EMPLOYEE")
public class Employee { 
    ...
}

@Entity
@Table(name = "EMPLOYEE_ADDRESS")
public class EmployeeAddress { 
    ...
}

 

@Column

@Column 어노테이션은 데이타베이스의 테이블에 있는 컬럼과 동일하게 1:1로 매칭되기 때문에 Entity 클래스안에 내부변수로 정의 됩니다. 만약 테이블에 a, b, c 컬럼이 있다면 각각 3개의 @Column 어노테이션을 작성 하게 됩니다. 다만 이때 의도적으로 필요없는 컬럼들은 작성하지 않아도 되는데 데이타베이스 테이블에 실제 a, b, c, d 총 4개의 컬럼이 있더라도 a,b,c 컬럼만 Entity 클래스에 작성해도 무방 하다는 이야기 입니다.

이때 @Column 어노테이션은 별다른 옵션을 설정하지 않는다면 생략이 가능합니다. 즉 Entity 클래스에 정의된 모든 내부변수는기본적으로 @Column 어노테이션을 포함한다고 볼 수 있습니다.

Spring Boot 의 spring.jpa.hibernate.ddl-auto 설정이 create 혹은 update 로 되어 있을 경우 create 일때는 최초에 한번 컬럼이 생성이 되고, update 일때는 Entity 클래스에 있지만 해당 테이블에 존재하지 않는 컬럼을 추가로 생성해 줍니다. 하지만 컬럼의 데이타 타입이 변경 되었거나 길이가 변경 되었을때 자동으로 데이타베이스에 반영을 해주지는 않기 때문에 속성이 변경되면 기존 테이블을 drop 후 새롭게 create 하던지 개별로 alter table 을 통해 직접 ddl 구문을 적용하는 것이 좋습니다.

spring.jpa.hibernate.ddl-auto 설정이 create-drop 로 되어 있으면 프로젝트가 시작될때 자동으로 기존 테이블을 drop 한 후 create 를 해줍니다. 하지만 기존 스키마가 전부 삭제 되기 때문에 시스템 설계와 개발 시점에만 사용해야 하며 운영 시점에 create, update, create-drop 을 사용 하지 않아야 합니다.

@Column  @Entity 어노테이션과 동일하게 name 속성을 명시하지 않으면 Entity 클래스에 정의한 컬럼 변수의 이름으로 생성이 됩니다. 그렇기 때문에 CamelCase 로 작성된 컬럼 변수가 있다면 UnderScore 형식으로 name 을 명시적으로 작성 합니다.

데이타베이스상에서 컬럼은 실제 데이타가 가질 수 있는 최대 길이를 가지게 되는데 이것은 데이타베이스에 데이타를 효율적으로 관리하기 위해서 입니다. @Column 에도 이처럼 length 속성으로 길이를 명시 할 수 있습니다. 만약 length 속성이 없다면 기본 길이인 255가 지정 됩니다. 이것은 문자열 형태인 데이타 속성에만 해당 되며 큰 숫자를 표현하는 BigDecimal 일 경우 precision, scale 로 최대 길이를 지정 할 수 있습니다.

@Column 
private String code;

@Column(length = 100)
private String name;

//@Column 은 생략이 가능합니다.
private String description; 

@Column(precision = 11, scale = 2)
private BigDecimal amount;

@Column
private Integer size;

@Column(name ="register_date")
private LocalDateTime registerDate;

 

@Id

데이타베이스의 테이블은 기본적으로 유일한 값을 가집니다. 그것을 PK (Primary Key) 라고 하는데 데이타베이스는 이 유일한 키값을 기준으로 질의한 데이타를 추출해 결과셋으로 반환해 줍니다. 테이블 상에 PK 가 없는 테이블도 있지만 대부분의 경우 반드시 PK 가 존재합니다.

JPA 에서도 Entity 클래스 상에 해당 PK 를 명시적으로 표시를 해야 되는데 그것을 @Id 어노테이션을 이용해 이것이 PK 임을 지정 합니다.

만약 Spring Boot 의 spring.jpa.hibernate.ddl-auto 속성이 create 로 되어 있고 아직 해당 테이블이 데이타베이스상에 존재하지 않는다면 EntityManager 가 DDL 을 통해 테이블을 생성하면서 PK 를 같이 생성해 줍니다.

@Id
@Column
private String code;

 

@GeneratedValue

PK 컬럼의 데이타 형식은 정해져 있지는 않으나 구분이 가능한 유일한 값을 가지고 있어야 하고 데이타 경합으로 인해 발생되는 데드락 같은 현상을 방지 하기 위해 대부분 BigInterger 즉 Java 의 Long 을 주로 사용합니다.

물론 String 형태의 고정된 키값을 직접 생성해서 관리하기도 합니다. 중요한 것은 대량의 요청이 유입 되더라도 중복과 deadlock 데드락이 발생 되지 않을 만큼 키값이 빨리 생성이 되고 안전하게 관리 되어야 한다는 점입니다.

deadlock

동일한 시점에 요청이 유입 되었을때 데이타베이스는 테이블 혹은 레코드를 lock 을 걸어 데이타가 변경되지 않도록 막아 놓고 다른 작업을 진행합니다.

이때 1번째 요청이 A 테이블의 값을 변경하고 lock 을 걸어둔 상태에서 B 테이블을 사용하려고 하고, 2번째 요청이 B 테이블의 값을 변경하고 lock 을 걸어둔 상태에서 A 테이블을 사용하려고 할때 데이타베이스는 우선순위를 판단 할 수 없어 그대로 교착상태에 빠져 버립니다.

이때는 어쩔 수 없이 강제로 시스템을 재시작하여 데이타베이스 커낵션을 초기화 시켜줘야 합니다.

 

가장 보편적으로 사용이 되는 데이타베이스인 MySQL, ORACLE 에는 Long 타입의 키값을 생성하는 방식이 서로 조금 다른데요. MySQL 은 auto increment 방식을 ORACLE 은 sequence 방식을 사용합니다.

- auto increment

먼저 MySQL 의 auto increment 방식은 숫자형의 PK 컬럼 속성을 auto increment 로 지정하면 자동으로 새로운 레코드가 생성이 될때마다 마지막 PK 값에서 자동으로 +1 을 해주는 방식입니다. 이를 위해 @GeneratedValue 어노테이션의 strategy 속성을 GenerationType.IDENTITY 로 지정해 auto increment 컬럼인 것을 EntityManager 에 알려 줍니다.

이때 자동으로 생성되는 값을 가지는 PK 컬럼의 이름은 명시적으로 id 로 지정하는 것이 관례 입니다.

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

- sequence

ORACLE 에서 사용되는 sequence 방식은 sequence ORACLE 객체를 생성해 두고 해당 sequence 를 호출할때마다 기존 값의 +1 이 된 값을 반환해 주는 방식입니다. 이를 위해 @GeneratedValue 어노테이션의 strategy 속성을 GenerationType.SEQUENCE 로 지정해 sequence 를 사용해 PK 값을 사용하겠다고 지정합니다.

@Id
@SequenceGenerator(name="seq", sequenceName="jpa_sequence")
@GeneratedValue(strategy=GenerationType.SEQUENCE, generator="seq")
private Long id;

 

@EmbeddedId

앞서 데이타베이스의 테이블은 기본적으로 유일한 값을 가지는데 그것을 PK 라고 이야기 한다고 설명 드렸습니다. 일반적인 경우에는 PK 를 단일 @Id 로 구성하지만 경우에 따라선 복합키로서 테이블의 PK 를 정의 하기도 합니다. 복합키는 두개 이상의 @Id 로 구성이 되는데 이를 직접 Entity 에 정의하는 것이 아니라 별도의 Value 를 사용해 복합키를 정의합니다.

먼저 Value 를 생성한 다음 @Embeddable 어노테이션을 이용해 이 Value 가 Entity 에 삽입이 가능함을 명시 하고 Entity 에서는 @EmbeddedId 어노테이션을 이용해 이 Entity 에 해당 Value  PK 로 사용한다고 지정합니다.

@Embeddable
public class CompanyOrganizationKey implements Serializable {
	@Column(name = "company_code")
    private String companyCode;
    
    @Column(name = "organization_code")
    private String organizationCode;
}

@Entity(name = "company_organization")
public class CompanyOrganization {
    @EmbeddedId
    protected CompanyOrganizationKey companyOrganizationKey;
}

 

@Enumerated

@Enumerated 어노테이션은 java 의 enum 형태로 되어 있는 미리 정의되어 있는 코드 값이나 구분값을 데이타 타입으로 사용하고자 할때 사용됩니다. 속성으로 EnumType.ORDINAL, EnumType.STRING 이 있는데 ORDINAL 은 enum 객체에 정의된 순서가 컬럼의 값으로 사용되고 STRING 은 enum 의 문자열 자체가 컬럼의 값으로 사용이 됩니다.

enum FlagYN {
    Y, N
}

@Enumerated(EnumType.ORDINAL)
@Column(name = "access_yn")
private FlagYN accessYn; //0, 1 이 값으로 저장

@Enumerated(EnumType.STRING)
@Column(name = "use_yn", length = 1)
private FlagYN useYn; //'Y', 'N' 이 값으로 저장

 

@Transient

만약 Entity 객체에 속성으로서 지정되어 있지만 데이타베이스상에 필요없는 속성이라면 @Transient 어노테이션을 이용해서 해당 속성을 데이타베이스에서 이용하지 않겠다 라고 정의합니다. 이렇게 하면 해당 속성을 Entity 객체에 임시로 값을 담는 용도로 사용이 가능해 집니다.

@Transient
private String tempValue;

출처 : https://jogeum.net/6


사실 마지막 2개는 잘 쓰지 않았던거 같다. Entity에 Enum을 사용하는 것은 application과 DB간의 변환과정이 필요해서 많이 꺼려한 내용이었다. 그러나 @Enumerated을 몰랐던 것이였다... 보통 아래와 같이 Entity에 Enum을 쓰지 않고 String으로 사용했다.

@ColumnDefault("SEND")
@Column(name = "STATE", nullable = false, length = 10)
private String state;

NotificationMessage.builder()
  .messageId(messageId)
  .notification(notification)
  .sendDate(Instant.now())
  .state(STATE.SEND.name()).build());

이럴땐 @Enumerated(EnumType.STRING)를 사용하면 깔끔하게 해결될 것 같다.

그런데 @Transient는 조금 회의적이다. Entity에 과연 임시로 값을 담아야 하나?라는 생각은 의문이 들기 때문이다. 나는 Entity와 DTO의 구분을 지어줘야 한다고 생각하기 때문이다. 어쨌든 enum은 더 나이스하게 사용 가능 할 것 같다.

'Programming > JPA' 카테고리의 다른 글

N + 1 문제 해결 1  (0) 2020.06.11
N + 1 문제 원인  (0) 2020.06.05
왜 JPA를 써야할까?  (0) 2020.06.05
jpa 복합키에서 auto increment  (0) 2019.06.27
JPA 기본 키 전략  (0) 2019.06.27

먼저 중요 패키지 버전을 보면 아래와 같다.

  • springboot 2.1.5.RELEASE
    • spring-data 2.1.5.RELEASE
  • spring-core 5.1.7.RELEASE
  • hibernate-core 5.3.10.Final
  • mysql 5.7.21-log

entity는 아래와 같습니다.

@Data
@Builder
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor
@Entity
@EntityListeners(AuditingEntityListener.class)
@Table(name = "T_NOTIFICATION")
public class Notification {

    @EmbeddedId
    private NotificationId id;

    @CreatedDate
    @Column(name = "CREATE_DT", nullable = false, updatable = false, columnDefinition = "datetime(3)")
    private Instant createdDate;

    @LastModifiedDate
    @Column(name = "RECEIVER_RESPONSE_DT", nullable = true, updatable = false, columnDefinition = "datetime(3)")
    private Instant receiverResponseDate;

    @Column(name = "NOTI_REASON", nullable = false, length = 64)
    private String notiReason;

    @ColumnDefault("1")
    @Column(name = "SEND_COUNT", nullable = false, columnDefinition = "tinyint")
    private int sendCount;

    @Column(name = "REQUEST_USER", nullable = false, length = 36)
    private String requestUser;

    @Column(name = "SEND_MESSAGE", nullable = false, columnDefinition = "text")
    private String message;

    @Builder.Default
    @OneToMany(mappedBy = "notification", fetch = FetchType.EAGER, cascade = {CascadeType.ALL}, orphanRemoval = true)
    private Collection<NotificationMessage> notificationMessages = new ArrayList<NotificationMessage>();
}
@Embeddable
@AllArgsConstructor
@NoArgsConstructor
@Data
public class NotificationId implements Serializable {

    private static final long serialVersionUID = 1L;

    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "NOTI_SEQ", unique = true, updatable = false, nullable = false)
    private BigInteger NotiSeq;

    @Column(name = "PROJECT_ID", nullable = false, length = 36)
    private String projectId;

    @Column(name = "RECEIVER_ID", nullable = false, length = 128)
    private String receiverId;

    @Column(name = "NOTI_GID", nullable = false, length = 128)
    private String notiId;

}
@Data
@Builder
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor
@Entity
@EntityListeners(AuditingEntityListener.class)
@Table(name = "T_NOTIFICATION_MESSAGE")
public class NotificationMessage {

    @Id
    @Column(name = "MESSAGE_ID", nullable = false, length = 100)
    private String messageId;

    @ColumnDefault("SEND")
    @Column(name = "STATE", nullable = false, length = 10)
    private String state;

    @CreatedDate
    @Column(name = "SEND_DT", nullable = false, columnDefinition = "datetime(3)")
    private Instant sendDate;

    @Column(name = "END_DT", nullable = true, columnDefinition = "datetime(3)")
    private Instant endDate;

    @Column(name = "SEND_RESULT_CODE", nullable = true, columnDefinition = "smallint(5)")
    private Integer resultCode;

    @Column(name = "SEND_RESULT_REASON", nullable = true, length = 1024)
    private String resultReason;

    @Column(name = "SEND_RESULT_MESSAGE", nullable = true, columnDefinition = "text")
    private String resultMessage;

    @ManyToOne
    @JoinColumns(value = {
            @JoinColumn(name = "NOTI_SEQ", referencedColumnName = "NOTI_SEQ"),
            @JoinColumn(name = "PROJECT_ID", referencedColumnName = "PROJECT_ID"),
            @JoinColumn(name = "RECEIVER_ID", referencedColumnName = "RECEIVER_ID"),
            @JoinColumn(name = "NOTI_GID", referencedColumnName = "NOTI_GID"),
    }, foreignKey = @ForeignKey(name = "FK_T_NOTIFICATION_TO_T_NOTIFICATION_MESSAGE"))
    private Notification notification;
}

Notification class에 EmbeddedId를 설정하여 복합키를 사용하고 있고 NotificationId class에 정의된 복합키중에 notiSeq는 @GeneratedValue로 설정하여 auto increment를 사용하려고 하였습니다. 실제 코드에서는 

@Override
public Notification insertOrUpdatePushMessage(Message message) {
    Notification notification = convertMessageToNotification(message);
    return this.repository.saveAndFlush(notification);
}

Message를 받아서 Notification으로 변경해서 save()를 하죠. 그런데 문제는 save()하고 return된 Notification 객체에는 notiSeq가 Null이 들어오는 것이었습니다.

사실 hibernate의 batch를 사용하고 있어서 auto increment를 가져오는 타이밍이 persistant와 안 맞나 보다 하고 batch도 사용하지 않아 봤는데 실패!!! 그럼 flush를 안 해서 그런가??? 하고 위 코드처럼 saveAndFlush()로 바꿔도 실패!!! 멘붕 중에 아래 블로그를 찾았다.

https://kihoonkim.github.io/2017/01/27/JPA(Java%20ORM)/3.%20JPA-%EC%97%94%ED%8B%B0%ED%8B%B0%20%EB%A7%A4%ED%95%91/

 

(JPA - 3) 엔티티 매핑

앞에서 영속성 컨텍스트에 객체(엔티티)를 저장하는 것을 보았다.객체와 관계형 데이터베이스의 다른 개념을 어떻게 매핑하고 데이터가 반영되는지 알아보자. 객체(엔티티)를 영속성 컨테스트에 저장(persist) 후 트랜젝션이 커밋되는 시점에각 데이터베이스에 맞는 SQL을 생성하여 데이터베이스로 보내진다.객체를 어떻게 엔티티로 만들고 테이블과 매핑하는지 알아보

kihoonkim.github.io

두 방식 중 무엇을 사용하든 복합키에는 @GeneratedValue를 통해 기본키를 자동생성할 수 없다.
반드시 직접 할당 전략을 사용해야 된다.

오!!! 복합키는 안 되는 거구나.... 허무함과 안도감이 교차했다. 나중에 왜 그런지 hibernate문서를 좀 더 찾아봐야겠지만 일단 믿고 가기로 했다. 

결론은 복합키에 unique한 auto increment를 사용하는 게 모순이라고 판단해서 auto increment만 PK로 변경해서 진행 중이다.

'Programming > JPA' 카테고리의 다른 글

N + 1 문제 해결 1  (0) 2020.06.11
N + 1 문제 원인  (0) 2020.06.05
왜 JPA를 써야할까?  (0) 2020.06.05
JPA 기본 Annotation 정리  (7) 2019.07.04
JPA 기본 키 전략  (0) 2019.06.27

1. 직접 할당

@id 애노테이션으로 필드와 매핑한다. 자바 기본형, 래퍼형, String, Date, BigDecimal, BigInteger가 타입이 가능하다

 

2. IDENTITY

기본 키 생성을 DB에 위임한다. DB의 auto_increment와 같은 기능을 사용할 때 쓴다. 키 필드에 @GeneratedValue(strategy = GenerationType.IDENTITY)를 사용한다.

이 전략을 사용하면 JPA는 기본 키 값을 얻어오기 위해 DB를 추가로 조회한다. 따라서 이 전략을 사용하는 엔티티를 새로 생성하여 식별자 값을 할당하려면 1차 캐시를 넘어서 DB에서 Insert한 후에 기본 키 값을 조회한다. 즉, persist()를 호출하는 즉시 Insert SQL이 DB에 전달되므로 트랜잭션을 지원하는 쓰기 지연이 동작하지 않는다.

 

3. SEQUENCE

데이타베이스 시퀀스 : 유일한 값을 순서대로 생성하는 데이타베이스 오브젝트

시퀀스를 사용해서 기본 키를 생성하는 전략이다. 오라클, PostgreSQL, DB2, H2 등이 시퀀스를 지원한다.

@Entity
@SequenceGenerator(name = "MY_SEQ_GENERATOR",
		sequnceName = "MY_SEQ",
		initialValue = 1,
		allocationSize = 1)
public class Board {
	@Id
	@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "MY_SEQ_GENERATOR")
	private Long id;
}

@SequenceGenerator를 의 속성에서 name에 식별자 생성기 이름을 정해준다. 그리고 sequenceName에 DB에 등록되어 있는 시퀀스 이름을 지정해주고, 초기값(initialValue)과 한번 호출에 증가하는 수(allocationSize)를 입력하여 시퀀스 생성기를 등록한다.

 

이 전략에서 persist()를 호출할 때 DB의 시퀀스를 사용하여 식별자를 조회한다. 그리고 그 식별자를 엔티티에 할당한 후 엔티티를 영속성 컨텍스트에 저장한다.

 

4. TABLE

키 생성 전용 테이블을 하나 만들고, 여기에 이름과 값으로 사용할 컬럼을 만들어 DB의 시퀀스처럼 동작하게 하는 전략.

@Entity
@TableGenerator(name = "MY_SEQ_GENERATOR",
		table = "MY_SEQUNCES",
		pkColumnValue = "MY_SEQ",
		allocationSize = 1)
public class Board {
	@Id
	@GeneratedValue(strategy = GenerationType.TABLE, generator = "MY_SEQ_GENERATOR")
	private Long id;
}

 

MY_SEQUNCES 테이블은 아래와 같을 것이다

sequnceName next_val

MY_SEQ 2

@TableGenerator.pkColumnValue에서 지정한 "MY_SEQ"가 컬럼명으로 추가되었고, 키 생성기를 사용하여 기본 키를 할당할 때마다 next_val 컬럼 값이 증가한다.

출처: https://feco.tistory.com/96 [wmJun]

'Programming > JPA' 카테고리의 다른 글

N + 1 문제 해결 1  (0) 2020.06.11
N + 1 문제 원인  (0) 2020.06.05
왜 JPA를 써야할까?  (0) 2020.06.05
JPA 기본 Annotation 정리  (7) 2019.07.04
jpa 복합키에서 auto increment  (0) 2019.06.27

PermGen은 자바 8에서 Metaspcae로 대체됐다. 이미지 출처: https://help.sap.com/

읽기 전 주의사항(그림을 보는 법)

그림을 그리다보니 Stack에 있는 동그라미 모양과 힙 메모리에 있는 동그라미 모양이 동일한 그림들이 많이 있습니다.
이건 둘이 동일한 메모리를 의미하는 게 아니라 그냥 스택에서 힙을 참조한다는 걸 그린 건데,
사실 둘의 모양을 다르게 그려야하는데 아무 생각없이 복붙해서 그리다보니 이렇게 그리게 되었고…
되돌리기에는 너무 많이 그림을 그려놔서(히스토리 추적이 안 되게 막 그려서…) 귀챠니즘으로 인해 그림을 수정하지 않았습니다.
이 점 참고하셔서 보시길 바랍니다!

들어가기에 앞서


이 글은 이일웅 님께서 번역하신 자바 최적화란 책을 읽던 도중 공부한 내용을 정리한 글입니다.
절대 해당 책의 홍보는 아니며 좋은 책을 써준 사람과 번역해주신 분께 진심으로 감사하는 마음에 썼습니다.

자바는 C언어와 달리 프로그래머가 일일이 쓰지 않는 메모리(가비지)를 회수할 필요가 없게 끔 가비지 컬렉터가 알아서 열일한다.
자바의 모든 가비지 컬렉션에는 Stop the World(어플리케이션 스레드를 모두 멈추기 때문에 어플리케이션이 멈추게 된다.)가 발생하고 GC 쓰레드만 열일하게 된다.

죠죠의 기묘한 모험이 떠오르는 건 왜일까... 출처: https://www.youtube.com/watch?v=_cNXjmuhCCc
저수준 세부를 일일이 신경쓰지 않는 대가로 저수준 제어권을 포기한다는 사상이 자바의 핵심이다.

자바는 블루 컬러(주로 생산직에 종사하는 육체 노동자) 언어입니다.
박사 학위 논문 주제가 아니라 일을 하려고 만든 언어죠.
— 제임스 고슬링(자바의 아버지) —

즉, 일일이 메모리 해제하는 걸 ‘박사 학위 논문 주제’ 급의 어려운 일이라고 자바에서 여기는 것이다.
이런 어려운 일은 우리가 할테니 너희는 일을 해라!(비즈니스 로직이나 짜라!) 이런 뉘앙스 같다.

GC는 아래 두 가지 원칙을 준수해야한다. (프로그래머가 일일이 메모리 해제하다간 이런 유형의 휴먼 에러가 발생한다는 걸 보여준다.)

  • 반드시 모든 가비지(쓰지 않는 메모리)를 수집(free)해야한다.
    메모리만 엄~청 빵빵하다면 가비지가 많더라도 굳이 메모리 해제할 필요가 없다.
    사실 GC도 메모리가 부족할 때만 수행한다.
  • 살아있는 객체(접근 가능한 객체)는 절대로 수집해선 안 된다.
    C언어에서는 살아있는 객체(접근 가능한 객체)를 해제하면 Dangling pointer가 만들어지고, 어플리케이션이 뻗거나 해당 메모리에 다른 데이터가 할당돼서 해당 데이터를 더럽히는 등의 버그가 발생하게 된다.
    자바에서는 살아있는 객체를 수집해가면 나중에 참조하는 쪽에서 NPE(NullPointerException) 등등이 발생할 가능성이 높다.

Mark and Sweep Algorithm

자바의 GC 알고리듬의 기본은 Mark(살아있는 객체를 표시) and Sweep(쓸어담기) 알고리듬이다.

GC 루트(스택 프레임, 전역 객체 등등과 같이 메모리 풀 외부에서 내부를 가리키는 포인터)로부터 살아있는 객체(접근 가능한 객체)를 찾는다.

살아있는 객체를 찾으면 mark bit를 true(혹은 1)로 세팅한다.

모든 객체에 대해 마크가 끝났으면 이제 mark bit가 false(혹은 0)인 객체를 찾는다.

mark bit가 false(혹은 0)인 객체는 죽은 객체(접근 불가능한 객체)이므로 가비지 컬렉터가 수거해간다.

Weak Generational 가설

JVM 및 유사 소프트웨어에서 객체 수명은 이원적 분포 양상을 보인다.
대부분의 객체는 아주 짧은 시간만 살아있지만, 나머지 객체는 기대 수명이 훨씬 길다.

이 법칙은 사람들이 실제 실험과 경험을 토대로 얻어냈다.
따라서 GC의 대상인 힙은 아래 두 가지가 핵심이라는 결론이 나왔다.

  • 젊은 객체를 빠르게 수집할 수 있도록 설계해야한다.
  • 늙은 객체와 단명 객체를 떼어놓는 게 가장 좋다.

Hotspot VM은 Weak Generational 가설을 활용해 아래와 같은 규칙을 만들었다.

  • 객체마다 generational count(객체가 지금까지 무사통과한 가비지 컬렉션 횟수)를 센다.
  • 새로운 객체는 Young Generation이라는 공간에 생성된다.
  • 장수했다고 할 정도로 충분히 오래 살아남은 객체들은 별도의 메모리 영역(Old Generation 또는 Tenured Generation)에 보관된다.

또한 Weak Generational 가설 중에 ‘늙은 객체가 젊은 객체를 참조할 일은 거의 없다.’는 내용도 있는데 아예 없는 건 아니므로
Hotspot VM에서는 카드 테이블(JVM이 관리하는 바이트 배열로 각 요소는 Old Generation 공간의 512 바이트 영역을 가리킨다.)이라는 자료구조에 늙은 객체가 젊은 객체를 참조하는 정보를 기록한다.
따라서 Young Generation의 GC가 수행될 때 늙은 객체가 젊은 객체를 참조하는지도 확인해봐야한다.
하지만 이 때는 늙은 객체를 전부 뒤져보는 게 아니라 카드 테이블만 뒤져보면 돼서 GC의 수행 속도를 높여준다.

또한 메모리의 raw address를 가지고 데이터에 접근(역참조) 가능한 C언어 같은 언어는 이렇게 이분법적으로 메모리 영역을 나눈 구조와 맞지 않는다.

Young Generation에서 Old Generation으로 이동한 데이터는 메모리의 raw address도 바뀔텐데, 해당 raw address로 역참조를 했을 때 메모리 재할당으로 인해 다른 값이 튀어나올 가능성이 높기 때문이다.
다행히 자바는 메모리의 raw address를 사용하지도 않고, offset 연산자(. 연산자)만으로 필드나 메서드에 액세스 할 수 있기 때문에 이런 문제로부터 자유롭다.

Young Generation

Weak Generational 가설에 따라 단명하는 젊은 객체들만 모아놓은 공간이다.
대부분의 객체가 Young Generation에서 사망하시고, 새로 생성된 객체가 할당되기 때문에 GC가 자주 일어나는 영역이다.
GC가 일어나는 동안 Stop the World가 발생하는데 이 빈도가 매우 잦기 때문에 Young Generation의 GC는 수행 시간이 짧아야한다.
수행 시간이 짧으려면 수거해가는 객체의 수를 줄이면 되고, 객체의 수를 줄이려면 영역의 사이즈를 적당히 줄이면 된다.
수행 시간이 짧은 GC이기 때문에 Minor GC라고 부르는 게 아닐까?

Young Generation 사이즈를 지정하는 JVM flag는 -XX:NewSize와 -XX:MaxNewSize이며
-XX:NewRatio 속성을 통해 Old Generation 사이즈와 Young Generation 사이즈의 비율을 정할 수 있다.
예를 들어 -XX:NewRatio=3으로 지정하면 1:3=Young:Old 라고 보면 된다.
(Young은 힙 메모리의 1/4를 먹고, Old는 힙 메모리의 3/4를 먹게 되는 것이다.)

The parameters NewSize and MaxNewSize bound the young generation size from below and above.
Setting these to the same value fixes the young generation,
just as setting -Xms and -Xmx to the same value fixes the total heap size.
This is useful for tuning the young generation at a finer granularity than the integral multiples allowed by NewRatio.
https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/sizing.html

-XX:NewRatio 파라미터보다는 -XX:NewSize와 -XX:MaxNewSize의 사이즈를 동일하게 설정하는 게 튜닝하는데 더 좋다고 한다.
아마 동일하게 설정하는 이유는 해당 영역의 사이즈가 동적으로 줄어들고 늘어나게 되면, GC에도 영향을 미치고 기타 Ratio 관련 설정을 재계산해야되기 때문에 성능에 영향을 미치지 않기 때문 아닐까…?

또한 Young Generation의 GC는 자주 일어나기 때문에 수행 시간이 매우 짧아야하니 적어도 Old Generation의 사이즈보다 적게 할당하는 게 일반적이라고 한다.
다만 객체의 수명이 긴 객체들이 많거나 새로운 객체의 할당이 별로 없는 객체의 경우는 Young Generation의 사이즈를 좀 더 과감하게 줄이는 등 케이스 바이 케이스로 사이즈를 정해야할 것 같다.
이 모든 결정은 추측이 아닌 모니터링을 통해 할당 비율이라던지 기타 등등의 데이터들을 수치화해서 정확한 데이터 기반으로 의사 결정을 내려야할 것이다. (말이 쉽지 ㅠㅠ 어떻게 하는지도 모른다…)

Eden Space

Young Generation의 일부분이다.

새롭게 생성된 객체의 용량이 Eden Space의 용량보다 큰 경우를 제외하고는 Eden 영역에 할당된다.

그러다가 새롭게 할당할 객체의 메모리 확보를 하지 못한 경우, 즉 Eden 영역이 꽉 찬 경우에 Minor GC를 수행하게 된다.

이 때 GC를 수행하게 되면 메모리 단편화가 생기게 되고 이로 인해 객체를 할당할 전체적인 용량은 확보됐지만 연속된 메모리 공간이 없게 된다.
OS 레벨에서는 연속된 메모리 공간에 할당하지 않고 쪼개서 할당해도 되긴 하지만 할당하는 데도 오랜 시간이 걸리고, 데이터를 불러올 때도 순차적인 접근이 아니기 때문에 오래 걸리게 된다.
또한 JVM의 메모리 할당은 알아두면 좋을 상식에도 나오다시피 bump-the-pointer라는 기술을 써서 저렇게 중간에 메모리를 할당하는 일은 없다.

아니면 GC 이후에 메모리 Compaction을 수행해야하기 때문에 오버헤드가 발생할 수 밖에 없다.

Survivor Space

위에서 언급한 Eden 영역에서 GC로 인해 생기는 오버헤드를 줄이고자 생긴 영역이다.
이 영역 또한 Young Generaion의 일부이다.
Survivor 영역은 동일한 사이즈의 두 개의 영역으로 구분되는데 각각의 이름은 from과 to이다.
(VisualVM 같은 모니터링 툴에는 S0, S1으로 표시되곤 한다.)

Eden 영역에서 생존한 객체들이 Survivor 영역의 연속된 메모리 공간으로 넘어오게 되고

Eden 영역은 싹 비우게 됨으로써 Eden 영역의 제일 처음부터 할당하면 되므로 Eden 영역의 메모리 단편화 문제를 해결했다.

또한 -XX:SurvivorRatio 속성을 통해 Eden Space 사이즈와 Survivor Generation 사이즈의 비율을 정할 수 있다.
예를 들어 -XX:SurvivorRatio=6으로 지정하면 1:6=Survivor:Eden 라고 보면 된다.
(Suivovr는 Young Generation의 1/7를 먹고, Eden은 Young Generation의 6/7를 먹게 되는 것이다.)
즉, 두 Survivor 영역의 합보다 Eden 영역의 메모리가 더 크다.
(생존해서 Survivor 영역에 존재하는 객체보다 새롭게 생성돼서 Eden 영역에 할당되는 객체가 훨씬 많으므로…)

You can use the parameter SurvivorRatio can be used to tune the size of the survivor spaces, but this is often not important for performance.
https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/sizing.html

퍼포먼스에 영향을 주는 경우는 드물다고 적혀있지 않으니 굳이 쓸 필요는 없을 것 같다.

Survivor Space는 왜 2개일까?

그 이유는 Minor GC의 대상이 Eden에만 국한되는 게 아니라 Survivor 영역까지 Minor GC를 하기 때문이다.

Survivor 영역을 Minor GC를 수행하면 어떻게 될까? Eden 영역만 존재할 때와 마찬가지로 Survivor 영역에도 메모리 단편화가 존재하게 된다.

알아두면 좋을 상식에도 나오다시피 bump-the-pointer라는 기술을 써서 중간에 빈 공간이 있더라도 해당 공간에 할당하지 않는다.
그럼 Survivor Space의 단편화를 없애려면 어떻게 하면 될까?

Eden 영역에서 Survivor 영역을 만든 것과 같이 새로운 영역을 추가하면 된다!
따라서 새롭게 영역을 추가하다보니 Survivor Space가 두 개가 된 거다.

Minor GC

그럼 이제 Young Generation에서 일어나는 Minor GC에 대해서 알아보자.
(물론 JVM 플래그를 어떻게 주느냐에 따라서 Minor GC의 알고리듬이 달라질 수 있고, 여기서 설명하는 Minor GC의 알고리듬은 아주 간단하고 기본적인 수준에서 설명하고 있다.)

새롭게 생성된 객체는 전부 Eden Space에 할당된다. 이 때 객체의 generational count는 0이다.

새롭게 생성된 객체를 또 할당하려는데 Eden Space에 할당할 공간이 없으면 Minor GC를 수행하게 된다. 이제부터 Stop the World의 시작이다.

Eden 영역에 할당된 객체를 순회하면서 GC 루트로부터 접근 가능한 객체만 mark를 한다.

생존한 모든 객체(mark 당한 객체)는 Survivor Space로 복사한다.

GC로부터 살아남은 객체는 이제 generational count가 1로 증가한다. (이렇게 generational count를 1씩 늘리는 프로세스를 aging이라고 부른다... 나이를 먹어가는 ㅠㅠ)

Eden Space를 비운다. (Sweep) 이제 Stop the World가 끝났다.

이제 Eden Space의 공간 확보가 됐으니 새롭게 생성된 객체를 Eden Space에 할당한다.

새롭게 생성된 객체를 또 할당하려는데 Eden Space에 할당할 공간이 없으면 Minor GC를 수행하게 된다. (Stop the World의 시작)

이번에는 Eden 영역과 더불어 Survivor Space에 할당된 객체를 순회하면서 GC 루트로부터 접근 가능한 객체만 mark를 한다.

Survivor Space에서 생존하지 못한(mark 당하지 않은) 모든 객체를 수거해간다. (Sweep)

이 때 Survivor Space에서 생존한 객체는 generational count가 2로 증가한다.

Eden Space에서 mark된(생존한) 객체는 Survivor Space로 복사된다.

이 때 Eden에서 복사되는 객체는 generational count가 1로 증가한다.

이제 Eden Space를 비워준다, Sweep. (Stop the World의 끝)

이제 새로운 객체를 할당하면 된다.

위 과정들을 반복하다가 또 Eden Space가 꽉 차서 GC를 수행하게 됐다고 가정하자. (Stop the world의 시작)

모든 과정을 마치고 이제 Eden Space에서 생존한 객체들을 Survivor Space로 옮기려고 했더니 Survivor Space에 연속된 메모리 공간을 확보하지 못해서 더 이상 메모리 할당이 불가능하다고 가정해보자.

이 때 From Survivor Space에서 생존한 모든 객체들을 To Survivor Space의 연속된 공간에 먼저 옮기고, 그 후에 Eden Space에서 생존한 객체를 To Survivor Space의 연속된 공간에 옮긴다.
To Survivor Space에 Eden Space에 있는 내용보다 From Survivor Space에 있는 내용을 먼저 복사하는 이유는
generational count가 적은 객체(Eden Space에 거주중인 객체들)보다 generational count가 높은 객체(From Survivor Space에 거주중인 객체들)의
수명이 더 길 가능성이 높기 때문이다. (Weak Generational 가설에 의해…)
수명이 더 길 가능성이 높은 메모리를 먼저 배치하는 이유는 메모리의 단편화를 줄이기 위함이다.

생존한 모든 객체를 옮겼으므로 From Survivor Space와 Eden Space를 비운다.

기존 From Survivor Space의 역할을 To Survivor Space가 대신하게 됐으므로 둘의 이름을 바꾼다. (Stop the World의 끝)

GC가 끝났으므로 새로운 객체를 Eden Space에 할당한다.

위 과정을 반복하다가 생존을 반복한 From Survivor Space에 있는 객체가 적당히 나이를 먹었다고 가정해보자.

그럼 해당 객체는 Promotion(승진)을 한다.

그러다 다시 Minor GC를 해야되게 됐다.

이 경우에는 흔치 않게 Old Generation에서 Young Generation을 참조하고 있어서 GC 로직이 복잡해보이는데 간단하게 카드 테이블에 저장된 객체만 추가로 검사해서 Old Generation에서 Young Generation으로 참조 중인 객체를 쉽고 빠르게 찾을 수 있다.

Promotion

아래 나오는 그림에서 동그라미 안의 숫자는 객체의 나이(객체가 GC로부터 살아남은 횟수)를 의미한다.

Promotion(승진)은 Young Generation에서 적당히 나이를 먹어서(GC로 부터 살아남아서 계속해서 generational count가 늘어나서 적당한 generational count가 됐음을 의미)

이제 Old Generation으로 갈 나이가 됐으니 Old Generation으로 이동하는 걸 의미한다.
generational count가 어느정도 있으려면(짬밥을 어느정도 먹었으려면) 당연히 Survivor Space에 있는 객체가 이동됨을 의미한다.

적당한 나이는 -XX:InitialTenuringThreshold와 -XX:MaxTenuringThreshold 파라미터로 정할 수 있다.
(Old 영역으로 승진하는 객체의 갯수나 비율이 많아지면 자동으로 TenuringThreshold를 늘리는 원리인지 뭔지 잘 모르겠다…)

Premature Promotion

적당한 나이(TenuringThreshold)를 먹지 않았는데 어쩔 수 없이 Old Generation으로 이동하는 행위를 premature promotion(조기 승진)이라고 부른다.
아래 나오는 그림에서 동그라미 안의 숫자는 객체의 나이(객체가 GC로부터 살아남은 횟수)를 의미한다.

주로 메모리 할당이 잦다보니 Survivor Space에 적당한 공간이 없어서

나이를 먹지 않았음에도 Old Generation으로 옮겨지는 경우도 Premature Promotion이고,

새롭게 할당될 객체의 용량이 Eden Space의 용량보다 큰 경우에는

바로 Old Generation에 할당되게 되는데 이 경우에도 Premature Promotion이고,

원래 같으면 Eden Space의 내용이 From Survivor Space 영역의 바로 아래 공간에 할당되면 되는데, -XX:TargetSurvivorRatio(기본값 50)에 의해 할당되지 못하는 경우가 있다.
-XX:TargetSurvivorRatio는 Minor GC 이후의 From Survivor Space의 사용률(%)을 제한하는 옵션이다.

적당한 나이가 되지 않은 어린 객체가 Old Generation으로 이동하는 것도 Premature Promotion이다.

이 premature promotion의 경우에는 Old Generation에 놓기 때문에 Major GC 혹은 Full GC가 일어나기 전에는 회수해가지 않으며
적당한 나이를 먹지 않고 와서 단명할 가능성이 높음에도 불구하고 쓸데없이 Old Generation을 차지하고 있기 때문에
Major GC 혹은 Full GC의 발생 빈도를 늘려 어플리케이션 전반에 영향을 미치기 때문에 적절하게 Young Generation과 관련된 사이즈를 정하는 게 중요하다.

Old Generation

객체가 적당한 나이를 먹거나 조기 승진을 통해 넘어온 객체들이 존재하는 영역이다.
Tenure Generation이라고도 부른다.
해당 영역에 존재하는 객체들을 Young Generation에 있는 객체들보다 회수당할 가능성이 적다는 게 Weak Generational 가설이다.
또한 대부분의 객체가 Young Generation에서 사망하시기 때문에 Old Generation으로 오는 객체는 Young Generation에 할당되는 객체의 비율에 비해 현저히 낮다.

Major GC

Major GC 역시 Old Generation이 꽉 찼을 때 수행된다.
기본적으로 Old Generation은 메모리 할당률이 낮기 때문에 GC가 일어나는 빈도가 적다.
또한 대부분 Old Generation은 Young Generation 보다 용량을 크게 잡기 때문에 객체의 갯수도 많아서 GC 시간이 길다.
GC의 시간이 길다는 것은 그만큼 Stop the World, 어플리케이션이 멈추는 시간도 길다는 의미고 그런 의미에서 Major GC(주요한 GC)라고 이름을 붙인 게 아닐까 싶다.
또한 Old Generation은 Young Generation과 같이 Survivor Space가 존재하는 게 아니기 때문에 메모리 단편화도 신경써야하고 관리해야할 객체도 많다보니 훨씬 알고리듬이 복잡해진다.
이에 대해선 다음에 글을 써볼 예정이다.

Full GC

Minor GC + Major GC를 Full GC라고 부른다.
정확히 언제 일어나는지 모르겠지만 Old Generation GC에 대해 좀 더 자세히 공부하고 다시 작성해야겠다.

Permanent Generation

JVM의 Method Area를 Hotspot VM에서 Permanent Generation(줄여서 PermGen)으로 부르기 시작하면서 다른 VM에서도 PermGen이라고 부르는 것 같다.
PermGen은 자바 8에서 사라졌다.
PermGen에는 클래스의 메타데이터, 메서드의 메타데이터, 상수풀, static 변수 등등이 들어간다.
PermGen은 이름만 들어보면 Permanent(영구적인)가 들어가다보니 영구히 존재하는 데이터들만 저장될 거 같은데 필수는 아니지만 GC가 수행되기도 한다.
GC가 수행된다는 관점에서인지 이 영역을 힙 메모리로 보는 사람도 있는데 나는 클래스의 인스턴스가 저장되는 것도 아니고
-Xmx, -Xms와는 별개로 사이즈가 지정되다보니 힙메모리가 아니라고 생각하고 있다.
-XX:PermSize와 -XX:MaxPermSize로 사이즈를 지정할 수 있고, GC를 수행하지 않는다면 용량이 부족해질 수 있고 아래와 같은 OOME가 난다.
java.lang.OutOfMemoryError: PermGen space
이러한 에러가 나는 이유는 여러가지가 있는데 대표적으로 다음과 같다.

  • collection을 static으로 만들고 계속해서 요소를 추가하는 경우(이런 실수를 범하면 절대 안된다.)
  • 서버를 재시작하지 않고 변경 내역을 바로바로 반영해주는 HotDeploy를 계속해서 사용하다보면 Class와 Method의 메타데이터가 계속해서 쌓이게 되는데
    서버를 주기적으로 재시작해주지 않고, 계속해서 HotDeploy 하는 경우(실서버에서 이런 경우는 거의 없을 것이다.)

Metadata

자바 8부터 PermGen의 역할을 Metadata 영역에서 맡기 시작했고 Native 영역으로 옮겨졌다. (즉 OS에서 관리한다는 의미)
PermGen 중에 일부만 Metadata 영역에서 맡기 시작했고, 상수풀이나 static 변수는 Heap 메모리로 옮겨져왔다.
즉, 개발자가 실수하기 쉽거나 변경이 잦은 내용들은 힙 메모리로 끌고와서 GC의 대상이 되게 하고, 정말 변경이 될 가능성이 적은 내용들만 Native 영역으로 옮겨버렸다.
문제는 Native 영역은 dump를 떠도 안 나와서 분석하기 힘들다는데 이럴 일은 아마 거의 없을 것이다…
Metadata에 대한 관리는 OS에서 함으로 믿고 맡겨도 된다고 생각하지만(개발자가 직접하는 것보다 낫지 않을까?),
혹시 이 쪽을 튜닝해야하거나 OOME(java.lang.OutOfMemoryError: Metadata space)가 발생한 경우에는 -XX:MetaspaceSize와 -XX:MaxMetaspaceSize 파라미터를 사용해보자.

OutOfMemoryException

주로 OOME라고 줄여부르며 메모리 공간이 부족해서 나는 예외로 어플리케이션 자체가 뻗는 현상이 발생한다.
이 예외가 발생했을 때는 메모리 릭(메모리 결함)이 발생한 경우이고 Heap 메모리나 PermGen(Metaspace) 등등의 영역이 부족할 때 발생하는데
어떤 메모리가 부족한 건지, 아니면 왜 이 오류가 났는지 Stacktrace를 찍고 사망한다.

여기서는 힙 메모리가 부족해서 OOME가 발생한 경우의 원인에 대해서만 설명하겠다.
우선 메모리가 부족하면 가비지 컬렉터는 힙메모리의 가비지 컬렉션을 수행한다.
가비지 컬렉션을 수행했음에도 불구하고 새로운 객체를 더이상 할당할 수 없는 경우에 OOME가 발생하게 된다.
이 때는 아주 급한 경우에는 일단 -Xmx와 -Xms로 메모리를 늘리고 보고,

 

   

위 두가지 설정을 주고 실행해서 재발하면 힙덤프를 생성하거나 아니면
jmap 등등으로 살아있는 서버의 힙덤프를 떠서 어디서 메모리 릭이 발생했는지 Eclipse MAT 등등으로 분석하거나

 

   

위 설정 등등을 줘서 VisualVM으로 서버에 실제로 떠있는 어플리케이션 등등을 모니터링하는 방법이 있다.

알아두면 좋을 상식

  • Hotspot VM의 GC는 Arena라는 메모리 영역에서 작동한다.
  • Hotspot VM은 시작 시 메모리를 유저 공간에 할당/관리한다.
    따라서 힙 메모리를 관리할 때 시스템 콜을 하지 않으므로 커널 공간으로 컨텍스트 스위칭을 하지 않아서 성능 향상에 도움이 된다.
  • Hotspot VM은 할당된 메모리 가장 마지막의 다음 영역을 가리켜 연속된 빈 공간에 효율적으로 빠르게 할당하는 bump-the-pointer라는 기술을 사용했다.
  • Hotspot VM은 멀티 스레드 환경에서 객체를 할당할 때 스레드 간의 경합 등등의 문제를 줄이고자 TLAB(Thread Local Allocation Buffer)를 사용했다.
    Eden Space를 여러 버퍼로 나누어 각 어플리케이션 스레드에게 할당함으로써 자기 자신이 사용해야 할 버퍼를 바로 찾게되고, 리소스를 공유해서 생기는 문제를 없애버렸다.
    만약 본인에게 할당된 TLAB가 부족할 경우에는 크기를 동적으로 조정한다.

참조 링크

그 외 더 많은 거 같은데 기억이 잘 나지 않는다…

- 출처 : https://perfectacle.github.io/2019/05/07/jvm-gc-basic/

Java NIO ByteBuffer

자바 NIO 바이트 버퍼는 바이트 데이터를 저장하고 읽는 저장소다. 배열을 멤버 변수로 가지고 배열에 대한 읽고 쓰기 메소드를 제공한다. xxxBuffer 형태의 각 데이터별로 버퍼를 제공한다. ByteBuffer는 capacity / position / limit 세가지 속성을 가진다.

  • capacity : 버퍼에 저장할 수 있는 데이터의 최대 크기. 한번 정하면 바꿀 수 없다. 버퍼를 생성할 때 생성자의 인수로 입력된 값이다.
  • position : 읽기 또는 쓰기 작업 중인 위치를 나타낸다. 버퍼 객체가 생성되면 0으로 초기화되고 쓰기(put) 또는 읽기(get) 작업이 수행되면 자동으로 증가한다.
  • limit : 읽고 쓸 수 있는 버퍼 공간의 최대치. limit 메소드로 값을 조절할 수 있지만 capacity보다 크게 설정할 수 없다.


Java ByteBuffer 생성 및 종류

자바의 ByteBuffer는 생성자가 아닌 추상 클래스의 메소드를 통해 생성한다. 아래의 메소드를 이용한다.

  • allocate : JVM 힙 영역에 바이트 버퍼를 생성. 이를 보통 힙 버퍼라고 한다. 인수는 앞에서 설명한 capacity 값에 해당하는 버퍼의 크기이다. 바이트 버퍼의 값은 모두 0으로 초기화된다. 힙 버퍼는 풀링이 사용되지 않는 경우 빠른 할당과 해제 속도를 보여준다.
  • allocateDirect : JVM 힙 영역이 아닌 OS의 커널 영역에 바이트 버퍼를 생성한다. 이를 다이렉트 버퍼라고 한다. allocateDirect 메소드는 ByteBuffer 추상 클래스만 사용할 수 있다. 즉 Direct Buffer는 ByteBuffer로만 생성할 수 있다. 다이렉트 버퍼는 힙 버퍼에 비해 생성 시간은 길지만 더 빠른 IO 성능을 제공한다.

    전송할 데이터가 힙에 할당된 버퍼에 있는 경우 JVM은 소켓을 통해 데이터를 전송하기 전에 내부적으로 버퍼를 다이렉트 버퍼로 복사한다. 다이렉트 버퍼를 사용하면 이런 오버헤드를 줄일 수 있다. 하지만 다이렉트 버퍼의 데이터에 접근하려면 복사본을 만들어야 접근할 수 있다는 단점이 있다.

  • wrap : 입력된 바이트 배열을 이용해 바이트 버퍼를 생성한다. 입력에 사용된 바이트 배열이 변경되면 wrap을 사용해 생성한 바이트 배열의 값도 변경된다.


그외 Java NIO ByteBuffer의 특징

  • Java ByteBuffer는 읽기 / 쓰기 작업시 같은 position의 값이 바뀐다. 읽기 / 쓰기 인덱스가 분리되어 있지 않아서 작업 전환시 flip() 메소드를 사용해야한다. 그리고 다중 스레드 환경에서 바이트 버퍼를 공유하지 않아야한다.


Netty Architecture Overview


Netty ByteBuf 특징

  • heapBuf

  • directBuf

  • 별도의 Read Index / Write Index가 있다.
  • 위의 이유로 flip() 메소드를 사용하지 않아도 된다.
  • 가변 바이트 버퍼를 사용할 수 있다.
  • 바이트 버퍼 풀 기능을 제공한다.
  • 복합 버퍼 사용이 가능하다.(Heap + Direct)
  • Java의 ByteBuffer와 Netty의 ByteBuf의 상호 변환이 가능하다.


Netty의 ByteBuf 생성

네티의 바이트 버퍼는 자바의 바이트 버퍼와 다르게 프레임워크 레벨의 바이트 버퍼 풀을 제공하고 이를 통해 생성된 바이트 버퍼를 재사용한다. Netty의 바이트 버퍼를 바이트 버퍼 풀에 할당하려면 ByteBufAllocator 인터페이스를 사용한다. ByteBufAllocator의 하위 추상 구현체인 PooledByteBufAllocator 클래스로 각 바이트 버퍼를 생성한다.


Pooled / Unpooled ByteBuf

자바의 바이트 버퍼는 데이터 형에 따른 ByteBuffer 생성을 지원했지만 네티는 풀링 여부로 ByteBuf를 구분한다. Unpooled 클래스와 PooledByteBufAllocator 사용해 생성하고 다이렉트 버퍼와 힙 버퍼를 생성하기 위해 directBuffer 메소드와 heapBuffer 메소드를 사용한다.

ByteBuf 종류풀링 함풀링 안 함
힙 버퍼PooledHeapByteBufUnpooledHeapByteBuf
다이렉트 버퍼PooledDirectByteBufUnpooledDirectByteBuf

PooledByteBufAllocator는 ByteBuf 인스턴스를 풀링해 성능을 개선하고 메모리 단편화를 최소화한다. UnpooledByteBufAllocator는 ByteBuf 인스턴스를 풀링하지 않고 호출될 때마다 새로운 인스턴스를 반환한다.

생성 방법풀링 함풀링 안 함
힙 버퍼ByteBufAllocator.DEFAULT.heapBuffer()Unpooled.buffer()
다이렉트 버퍼ByteBufAllocator.DEFAULT.directBuffer()Unpooled.directBuffer()
  • ByteBuf 생성 예
ByteBuf buf = Unpooled.buffer(11);
// 바이트 버퍼 풀을 사용하지 않는 11바이트 크기의 힙 버퍼 생성
ByteBuf buf = Unpooled.directBuffer(11);
// 바이트 버퍼 풀을 사용하지 않는 11바이트 크기의 다이렉트 버퍼를 생성
ByteBuf buf = PooledByteBufAllocator.DEFAULT.heapBuffer(11);
// 풀링된 11바이트 크기의 힙 버퍼 생성
ByteBuf buf = PooledByteBufAllocator.DEFAULT.directBuffer(11);
// 풀링된 11바이트 크기의 다이렉트 버퍼 생성

크기를 지정하지 않으면 Netty에 지정된 기본 값인 256바이트 크기의 바이트 버퍼가 생성된다.


Read / Write

ByteBuf의 읽을 수 있는 바이트 세그먼트에 실제 데이터가 저장된다. 이때 새로 할당, 래핑, 복사된 버퍼에서 readerIndex의 기본값은 0이다. read나 skip으로 시작하는 모든 메소드는 현재 readerIndex 위치에 있는 데이터를 읽거나 건너 뛰고 읽은 바이트 수 만큼 readerIndex를 증가시킨다.

ByteBuf의 기록할 수 있는 바이트 세그먼트는 정의되지 않은 내용이 들어 있고 기록할 수 있는 영역이다. 새로 할당된 버퍼의 writerIndex 기본값은 0이고 write로 시작하는 모든 메소드는 현재 writerIndex 위치부터 데이터를 기록하고 기록한 만큼 writerIndex를 증가시킨다.

readBytes(ByteBuf dest)인수의 ByteBuf만큼 읽는다
writeBytes(ByteBuf dest)인수의 ByteBuf만큼 쓴다.
  • 모든 데이터 읽기
    ByteBuf buffer = ...;
    while(buffer.isReadable()){
    System.out.println(buffer.readByte());
    }
    
  • 데이터 기록
    // 버퍼의 기록할 수 있는 바이트를 임의의 정수로 채움
    ByteBuf buffer = ...;
    while(buffer.writableBytes() >= 4) {
    buffer.writeInt(random.nextInt());
    }
    

읽기 / 쓰기 작업

Netty ByteBuf의 읽기, 쓰기 작업은 두가지로 나뉜다.

  • get()set() 작업은 저장한 인덱스에서 시작하고 인덱스를 변경하지 않는다.
  • read()write() 작업은 지정한 인덱스에서 시작하고 접근한 바이트 수만큼 인덱스를 증가시킨다.

자주 쓰이는 get() 작업

이름설명
getBoolean(int)지정한 인덱스의 Boolean 값을 반환
getByte(int)지정한 인덱스의 바이트를 반환
getUnsignedByte(int)지정한 인덱스의 부호 없는 바이트 값을 short로 반환
getMedium(int)지정한 인덱스의 24비트 미디엄 int값을 반환
getUnsignedMedium(int)지정한 인덱스의 부호 없는 24비트 미디엄 int 값을 반환
getInt(int)지정한 인덱스의 int값을 반환
getUnsignedInt(int)지정한 인덱스의 부호 없는 int값을 long으로 반환
getLong(int)지정한 인덱스의 long 값을 반환

자주 쓰이는 set() 작업

이름설명
setBoolean(int, boolean)지정한 인덱스의 Boolean 값을 설정
setByte(int index, int value)지정한 인덱스의 바이트 값을 설정
setMedium(int index, int value)지정한 인덱스의 24비트 미디엄 값을 설정
setInt(int index, int value)지정한 인덱스의 int 값을 설정
setLong(int index, long value)지정한 인덱스의 long 값을 설정
setShort(int index, int value)지정한 인덱스의 short 값을 설정
  • get()/set() 예제
    Charset utf8 = Charset.forName("UTF-8");
    ByteBuf buf = Unpooled.copiedBuffer("Netty !", utf8);
    System.out.println((char)buf.getByte(0));
    // 첫번째 문자 'N' 출력
    int readerIndex = buf.readerIndex();
    int writerIndex = buf.writerIndex();
    // 현재 인덱스들을 저장
    buf.setByte(0, (byte)'B');
    // 인덱스 0에 있는 바이트를 문자'B'로 변경
    System.out.println((char)buf.getByte(0));
    // 첫번째 문자 'B'를 출력
    assert readerIndex = buf.readerIndex();
    assert writerIndex = buf.writerIndex();
    // 인덱스가 바뀌지 않았으므로 성공
    

자주 쓰이는 read() 작업

이름설명
readBoolean()현재 readerIndex 위치의 Boolean 값을 반환하고 readerIndex를 1만큼 증가시킨다
readByte()현재 readerIndex 위치의 바이트 값을 반환하고 readerIndex를 1만큼 증가시킨다
readUnsignedByte()현재 readerIndex 위치의 short 값을 반환하고 readerIndex를 1만큼 증가시킨다
readMedium()현재 readerIndex 위치의 24비트 미디엄 값을 반환하고 readerIndex를 3만큼 증가시킨다
readUnsignedMedium()현재 readerIndex 위치의 부호 없는 24비트 미디엄 값을 반환하고 readerIndex를 3만큼 증가시킨다
readInt()현재 readerIndex 위치의 int값을 반환하고 readerIndex를 4만큼 증가시킨다
readUnsignedInt()현재 readerIndex 위치의 부호 없는 int값을 long으로 반환하고 readerIndex를 4만큼 증가시킨다
readLong()현재 readerIndex 위치의 long 값을 반환하고 readerIndex를 8만큼 증가시킨다
readShort()현재 readerIndex 위치의 short 값을 반환하고 readerIndex를 2만큼 증가시킨다
readUnsignedShort()현재 readerIndex 위치의 부호 없는 short값을 int로 반환하고 readerIndex를 2만큼 증가시킨다
readBytes(ByteBuf | byte[] destination, int dstIndex [,int length])현재 ByteBuf의 현재 readerIndex로부터 시작하는 바이트를 (length가 지정된 경우 length 바이트 만큼) 대상 ByteBuf또는 byte[]의 대상 dstIndex로부터 전송한다. 로컬 readerIndex는 전송된 바이트 수만큼 증가한다.

자주 쓰이는 write() 작업

이름설명
writeBoolean(boolean)현재 writerIndex 위치에 Boolean값을 기록하고 writerIndex를 1만큼 증가시킨다
writeByte(int)현재 writerIndex 위치에 바이트 값을 기록하고 writerIndex를 1만큼 증가시킨다
writeMedium(int)현재 writerIndex 위치에 미디엄 값을 기록하고 writerIndex를 3만큼 증가시킨다
writeInt(int)현재 writerIndex 위치에 int 값을 기록하고 writerIndex를 4만큼 증가시킨다
writeLong(long)현재 writerIndex 위치에 long값을 기록하고 writerIndex를 8만큼 증가시킨다
writeShort(int)현재 writerIndex 위치에 short값을 기록하고 writerIndex를 2만큼 증가시킨다
writeBytes(source ByteBuf | byte[] [, int srcIndex, int length])지정된 원본(ByteBuf 또는 byte[])의 현재 writerIndex부터 데이터 전송을 시작한다. srcIndex와 length가 지정된 경우 srcIndex부터 시작해 length 바이트 만큼 읽는다. 현재 writerIndex는 기록된 바이트 수만큼 증가한다
  • read()/write() 예제
    Charset utf8 = Charset.forName("UTF-8");
    ByteBuf buf = Unpooled.copiedBuffer("Netty !", utf8);
    // 지정한 문자열의 바이트를 저장하는 ByteBuf 생성
    System.out.println((char)buf.readByte());
    // 첫번째 문자 'N'을 출력
    int readerIndex = buf.readerIndex();
    int writerIndex = buf.writerIndex();
    // 현재 readerIndex/writerIndex 를 저장
    buf.writeByte((byte)'?');
    // 버퍼에 '?'를 추가
    assert readerIndex == buf.readerIndex();
    assert writerIndex != buf.writerIndex();
    

기타 유용한 메소드

이름설명
isReadable()읽을 수 있는 바이트가 하나 이상이면 true를 반환
isWritable()기록할 수 있는 바이트가 하나 이상이면 true를 반환
readableBytes()읽을 수 있는 바이트 수를 반환
writableBytes()기록할 수 있는 바이트 수를 반환
capacity()ByteBuf가 저장할 수 있는 바이트 수를 반환한다. 이 수를 초과하면 maxCapacity()에 도달할 때까지 용량이 확장된다.
maxCapacity()ByteBuf가 저장할 수 있는 최대 바이트 수를 반환
hasArray()ByteBuf에 힙 버퍼가 있는 경우 true를 반환
array()ByteBuf에 힙 버퍼가 있는 경우 해당 바이트 배열을 반환하며, 그렇지 않으면 UnsupportedOperationException을 발생시킨다.


ByteBufAllocator

자바 바이트 버퍼는 언어 자체에서 제공하는 버퍼 풀이 없다. 따라서 바이트 버퍼 풀을 이용하려면 객체 풀링을 제공하는 서드파티 라이브러리를 사용하거나 직접 구현해야한다. 네티는 프레임 워크에서 바이트 버퍼 풀을 제공하고 있으며 다이렉트 버퍼와 힙 버퍼를 모두 풀링할 수 있다. 네티의 바이트 버퍼 풀링은 ByteBufAllocator를 사용해 바이트 버퍼를 생성할 때 자동으로 수행된다.

ByteBufAllocator의 참조는 Channel에서 얻거나 ChannelHandler에 바인딩 된 ChannelHandlerContext를 통해 얻을 수 있다.

Channel channel = ...;
ByteBufAllocator allocator = channel.alloc()
// Channel에서 ByteBufAllocator를 얻음
...
ChannelHandlerContext ctx = ...;
ByteBufAllocator allocator2 = ctx.alloc();
// ChannelHandlerContext에서 ByteBufAllocator를 얻음
...
ByteBuf newBuffer = ByteBufAllocator.buffer();
// ByteBufAllocator의 buffer 메소드를 사용해 생성된 바이트 버퍼
// 똑같이 ByteBufAllocator에서 관리된다.
// 바이트 버퍼를 채널에 기록하거나 명시적으로 release 호출 시 바이트 버퍼 풀로 돌아간다.
...
// new Buffer 사용
ctx.write(msg);
// write 메소드 인수로 바이트 버퍼가 입력되면 데이터를 채널에 기록하고 난 뒤 버퍼 풀로 돌아간다.

풀링되지 않은 버퍼

ByteBufAllocator의 참조가 없는 경우, 네티는 풀링되지 않는 ByteBuf 인스턴스를 생성하는 정적 도우미 메서드 Unpooled 클래스를 제공한다.

이름설명
buffer풀링되지 않은 힙 기반 ByteBuf 반환
directBuffer풀링되지 않은 다이렉트 ByteBuf 반환
wrappedBuffer지정한 데이터를 래핑하는 ByteBuf 반환
copiedBuffer지정한 데이터를 복사하는 ByteBuf 반환

Unpooled 클래스는 다른 네티 컴포넌트가 필요 없는 네트워킹과 무관한 프로젝트에 ByteBuf를 제공해 확정성 높은 고성능 버퍼 API를 이용할 수 있게 해준다.

참조 카운팅

네티는 바이트 버퍼를 풀링하기 위해 바이트 버퍼에 참조수를 기록한다. ReferenceCountUtil클래스에 정의된 retain 메소드와 release 메소드를 사용할 수 있다 retain 메소드는 참조 수를 증가시키고 release 메소드는 참조 수를 감소 시키고 할당된 메모리가 해제 된다.

Channel channel = ...;
ByteBufAllocator allocator = channel.alloc();
// channel에서 ByteBufAllocator를 얻음
....
ByteBuf buffer = allocator.directBuffer();
// ByteBufAllocator로부터 ByteBuf를 할당
assert buffer.refCnt() = 1;
// 참조 카운트가 1인지 확인
...
boolean released = buffer.release();
// 객체에 대한 참조 카운트를 감소 시킴
// 참조 카운트가 0이 되면 객체가 해제되고 메소드가 true를 반환한다.

참조 카운트가 0일 때 release()를 호출하면 IllegalReferenceCountException이 발생한다. 참조 해제는 각 객체가 새로 정의할 수 있다. 예를 들면 클래스의 release() 구현에서 참조 카운트를 현재 값과 상관 없이 0으로 설정하면 모든 활성 참조를 일시에 해제할 수 있다.


기타 작업

부호 없는 값 읽기

자바는 부호 없는 데이터 형이 없어서 네티에서는 부호 없는 데이터를 처리하기 위한 메소드를 제공한다. 네티에서는 부호 없는 데이터를 읽을 때 읽을 데이터보다 큰 데이터 형에 할당한다.

ByteBuf = Unpooled.buffer(11);
buf.writeShort(-1);
// 빈 바이트 버퍼에 음수 -1 기록 (2byte)

assertEquals(65535, buf.getUnsignedShort(0));
// -1은 16진수 표기법으로 0xFFFF이고 이를 부호 없는 정수로 표현하면 65535가 된다.
// 이를 4byte로 저장한다.
// getUnsignedShort 메소드로 바이트 버퍼에 저장된 0번째 바이트부터 2바이트 읽어서
// 4바이트 데이터인 int로 읽어들이면 65535가 된다.
  • 부호 없는 데이터 지원 메소드
메소드원본 데이터형리턴 데이터형
getUnsignedBytebyteshort
getUnsignedShortshortint
getUnsignedMediummediumint
getUnsignedIntintlong

엔디안 변환

네티의 바이트 버퍼의 기본 엔디안은 자바와 동일하게 빅엔디안이다. 리틀 엔디안 바이트 버퍼가 필요한 경우를 order 메소드로 엔디안을 변환할 수 있다.

ByteBuf buf = Unpooled.buffer();
...
ByteBuf lettleEndianBuf = buf.order(ByteOrder.LITTLE_ENDIAN);

order 메소드로 생성한 바이트 버퍼는 새로운 바이트 버퍼가 아닌 주어진 바이트 버퍼의 내용을 공유하는 파생 버퍼이다. 기존 바이트 버퍼의 배열과 인덱스들을 공유한다. 즉 내용은 같지만 리틀 엔디안으로 접근하는 바이트 버퍼를 생성한다.

  • 참고 서적
  • 네티 인 액션
  • 자바 네트워크 소녀 Netty



public class HelloWorld {
static long start;

public static void main(String[] args) throws InterruptedException, ExecutionException {
HelloWorld helloWorld = new HelloWorld();
Future<String> hello;
System.out.println("[" + System.currentTimeMillis() + "]" + "Start!");
hello = helloWorld.getSayHelloAsync();
System.out.println("[" + System.currentTimeMillis() + "]" + "End!");
System.out.println(hello.get());
}

public String sayHello() throws InterruptedException{
System.out.println("[" + System.currentTimeMillis() + "]calling sayHello()");
Thread.sleep(1000);
System.out.println("[" + System.currentTimeMillis() + "]end sayHello()");
return "[" + System.currentTimeMillis() + "]" + "HelloWorld!";
}

public Future<String> getSayHelloAsync(){
CompletableFuture<String> future = new CompletableFuture<>();
new Thread(()-> {
try {
String result = sayHello();
future.complete(result);
} catch (Exception e) {
future.completeExceptionally(e);
}
}).start();
return future;
}
}
[1552891812467]Start!
[1552891812526]End!
[1552891812526]calling sayHello()
[1552891813526]end sayHello()
[1552891813526]HelloWorld!

여기에서 중요한건 "End!"가 프린트 되는 시점입니다. 동기식은 return을 기다리기 때문에 "End!" 프린트 되기전에 sayHello()함수 안의 내용이 먼저 프린트 되는 반면에, 비동기식은 return을 기다리지 않고 "End!"가 먼저 프린트 되고 후에 sayHello()함수 안의 내용이 먼저 프린트되는 것을 보실 수 있습니다.

return을 기다리는 동기식과 return을 기다리지 않고 다음 작업을 계속 진행하는 비동기식의 차이를 알아 봤습니다.



보통 프로그래밍을 하면 동기프로그램을 짜게 됩니다. HelloWorld와 같은 sample 코드들 말입니다. Main 함수에서 호출되는 다른 함수들을 생각해 보세요.

public class HelloWorld {

public static void main(String[] args) {
HelloWorld helloWorld = new HelloWorld();
System.out.println(helloWorld.sayHello());
}

public String sayHello() {
return "Hello!";
}
}

sayHello()함수가 호출되고 있고 이 함수는 string을 return하고 있습니다. 아주 짧은 순간이긴 하지만 sayHello()가 실행되는 순간에는 main함수는 멈추고 return되기를 기다리고 있는 것입니다. 다음 예를 보시면 이해가 빠르실 겁니다.

동기식

public class HelloWorld {
static long start;

public static void main(String[] args) throws InterruptedException{
HelloWorld helloWorld = new HelloWorld();
System.out.println("[" + System.currentTimeMillis() + "]" + "Start!");
System.out.println(helloWorld.sayHello());
System.out.println("[" + System.currentTimeMillis() + "]" + "End!");
}

public String sayHello() throws InterruptedException{
System.out.println("[" + System.currentTimeMillis() + "]calling sayHello()");
Thread.sleep(1000);
System.out.println("[" + System.currentTimeMillis() + "]end sayHello()");
return "[" + System.currentTimeMillis() + "]" + "HelloWorld!";
}
}
[1552890046994]Start!
[1552890046994]calling sayHello()
[1552890047994]end sayHello()
[1552890047994]HelloWorld!
[1552890047994]End!

비동기식

public class HelloWorld {
static long start;

public static void main(String[] args) throws InterruptedException, ExecutionException {
HelloWorld helloWorld = new HelloWorld();
Future<String> hello;
System.out.println("[" + System.currentTimeMillis() + "]" + "Start!");
hello = helloWorld.getSayHelloAsync();
System.out.println("[" + System.currentTimeMillis() + "]" + "End!");
System.out.println(hello.get());
}

public String sayHello() throws InterruptedException{
System.out.println("[" + System.currentTimeMillis() + "]calling sayHello()");
Thread.sleep(1000);
System.out.println("[" + System.currentTimeMillis() + "]end sayHello()");
return "[" + System.currentTimeMillis() + "]" + "HelloWorld!";
}

public Future<String> getSayHelloAsync(){
CompletableFuture<String> future = new CompletableFuture<>();
new Thread(()-> {
try {
String result = sayHello();
future.complete(result);
} catch (Exception e) {
future.completeExceptionally(e);
}
}).start();
return future;
}
}
[1552891812467]Start!
[1552891812526]End!
[1552891812526]calling sayHello()
[1552891813526]end sayHello()
[1552891813526]HelloWorld!

여기에서 중요한건 sayHello()의 결과를 얻어오는 타이밍입니다. 동기식은 main함수의 3줄 sayHello()가 실행되어야 "End!"가 프린트 되는 반면, 비동기식은 getSayHelloAsync()가 실행되고 바로 "End!"가 출력되고나서 결과를 가져옵니다.

한마디로 Synchronous/Asynchronous는 호출되는 함수의 호출에 따른 결과를 언제 받느냐에 따라 구분이 됩니다. 

함수를 호출했을 때 바로 결과를 주기로 한 방식은 Synchronous

함수를 호출했을 때 바로 결과를 받지않고 나중에 결과를 주기로 한 방식은 Asynchronous

이번에 Async와 Sync의 차이점에 대해서 알아 봤습니다. 다음 포스트에서는 Block과 Non-block에 대해서 알아보겠습니다. 참고로 block/non-block과 sync/async의 개념은 양립할 수 있는 개념이기에 sync/async도 비교 설명 하겠습니다.


+ Recent posts